diff --git a/apps/desktop/src/test/electronMock.ts b/apps/desktop/src/test/electronMock.ts new file mode 100644 index 00000000000..2ba4b1a8086 --- /dev/null +++ b/apps/desktop/src/test/electronMock.ts @@ -0,0 +1,128 @@ +import { EventEmitter } from "node:events"; + +const appEmitter = new EventEmitter(); + +export const app = Object.assign(appEmitter, { + commandLine: { + appendSwitch: () => undefined, + }, + dock: { + setIcon: () => undefined, + }, + exit: () => undefined, + focus: () => undefined, + getAppPath: () => "/app", + getPath: () => "/tmp/t3code-desktop-test", + getVersion: () => "0.0.0-test", + isPackaged: false, + name: "T3 Code", + quit: () => undefined, + relaunch: () => undefined, + removeListener: appEmitter.removeListener.bind(appEmitter), + runningUnderARM64Translation: false, + setAboutPanelOptions: () => undefined, + setAppUserModelId: () => undefined, + setDesktopName: () => undefined, + setName: () => undefined, + setPath: () => undefined, + whenReady: () => Promise.resolve(), +}); + +export class BrowserWindow { + static getAllWindows() { + return []; + } + + static getFocusedWindow() { + return null; + } + + readonly webContents = { + copyImageAt: () => undefined, + isLoadingMainFrame: () => false, + on: () => undefined, + once: () => undefined, + openDevTools: () => undefined, + replaceMisspelling: () => undefined, + send: () => undefined, + setWindowOpenHandler: () => undefined, + }; + + destroy() {} + focus() {} + isDestroyed() { + return false; + } + isMinimized() { + return false; + } + isVisible() { + return true; + } + loadURL() { + return Promise.resolve(); + } + on() {} + once() {} + restore() {} + setBackgroundColor() {} + setTitle() {} + setTitleBarOverlay() {} + show() {} +} + +export const clipboard = { + writeText: () => undefined, +}; + +export const dialog = { + showErrorBox: () => undefined, + showMessageBox: () => Promise.resolve({ response: 0, checkboxChecked: false }), + showOpenDialog: () => Promise.resolve({ canceled: true, filePaths: [] }), +}; + +export const Menu = { + buildFromTemplate: () => ({ + popup: () => undefined, + }), + setApplicationMenu: () => undefined, +}; + +export const nativeImage = { + createFromNamedImage: () => ({ + isEmpty: () => true, + resize: () => ({ + isEmpty: () => true, + }), + }), +}; + +const nativeThemeEmitter = new EventEmitter(); + +export const nativeTheme = Object.assign(nativeThemeEmitter, { + removeListener: nativeThemeEmitter.removeListener.bind(nativeThemeEmitter), + shouldUseDarkColors: false, + themeSource: "system", +}); + +export const protocol = { + handle: () => undefined, + registerFileProtocol: () => true, + registerSchemesAsPrivileged: () => undefined, + unhandle: () => undefined, +}; + +export const safeStorage = { + decryptString: () => "", + encryptString: () => Buffer.from(""), + isEncryptionAvailable: () => true, +}; + +export const shell = { + openExternal: () => Promise.resolve(), +}; + +export const ipcMain = { + handle: () => undefined, + removeHandler: () => undefined, +}; diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts new file mode 100644 index 00000000000..af53cf3924a --- /dev/null +++ b/apps/desktop/vitest.config.ts @@ -0,0 +1,18 @@ +import * as path from "node:path"; +import { defineConfig, mergeConfig } from "vitest/config"; + +import baseConfig from "../../vitest.config.ts"; + +export default mergeConfig( + baseConfig, + defineConfig({ + resolve: { + alias: [ + { + find: /^electron$/, + replacement: path.resolve(import.meta.dirname, "./src/test/electronMock.ts"), + }, + ], + }, + }), +); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..c3577928ee1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -184,6 +184,7 @@ import { } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; +import { ResizableRightPanel } from "./ResizableRightPanel"; import { RightPanelSheet } from "./RightPanelSheet"; import { Button } from "./ui/button"; import { @@ -200,6 +201,7 @@ const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const PLAN_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_plan_sidebar_width_ratio"; type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; readonly label: string; @@ -3724,17 +3726,19 @@ export default function ChatView(props: ChatViewProps) { {/* Plan sidebar */} {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( - + + + ) : null} {/* end horizontal flex container */} diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index afd4bb2e0bc..d68e4c7c433 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -130,9 +130,7 @@ const PlanSidebar = memo(function PlanSidebar({
{/* Header */} diff --git a/apps/web/src/components/ResizableRightPanel.tsx b/apps/web/src/components/ResizableRightPanel.tsx new file mode 100644 index 00000000000..c2c7391e11b --- /dev/null +++ b/apps/web/src/components/ResizableRightPanel.tsx @@ -0,0 +1,263 @@ +import * as Schema from "effect/Schema"; +import { + type PointerEvent as ReactPointerEvent, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; +import { cn } from "~/lib/utils"; + +const DEFAULT_RATIO = 0.4; +const MIN_RATIO = 0.3; +const MAX_RATIO = 0.8; +const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; +let bodyResizeStyleOwner: symbol | null = null; + +type ResizeState = { + frameId: number | null; + handle: HTMLDivElement; + latestX: number; + panel: HTMLDivElement; + pointerId: number; + startWidth: number; + startX: number; +}; + +const clampRatio = (ratio: number) => Math.max(MIN_RATIO, Math.min(ratio, MAX_RATIO)); + +function readStoredRatio(storageKey: string | undefined) { + if (!storageKey) return DEFAULT_RATIO; + try { + const storedRatio = getLocalStorageItem(storageKey, Schema.Finite); + return storedRatio === null ? DEFAULT_RATIO : clampRatio(storedRatio); + } catch (error) { + console.error("[LOCALSTORAGE] Error:", error); + return DEFAULT_RATIO; + } +} + +const applyBodyResizeStyles = (owner: symbol) => { + bodyResizeStyleOwner = owner; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; +}; + +const clearBodyResizeStyles = (owner: symbol) => { + if (bodyResizeStyleOwner !== owner) return; + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + bodyResizeStyleOwner = null; +}; + +const resolveResizeRatio = ( + resizeState: Pick, + clientX: number, +) => { + const containerWidth = resizeState.panel.parentElement?.clientWidth ?? 0; + if (containerWidth <= 0) return null; + + const nextWidth = resizeState.startWidth + resizeState.startX - clientX; + return clampRatio(nextWidth / containerWidth); +}; + +const measureWithPanelRatio = (panel: HTMLDivElement, ratio: number, measure: () => T) => { + const previousWidth = panel.style.width; + panel.style.width = `${ratio * 100}%`; + + try { + return measure(); + } finally { + if (previousWidth.length > 0) { + panel.style.width = previousWidth; + } else { + panel.style.removeProperty("width"); + } + } +}; + +const canAcceptComposerWidth = (panel: HTMLDivElement, ratio: number) => { + const composerForm = document.querySelector("[data-chat-composer-form='true']"); + if (!composerForm) return true; + const composerViewport = composerForm.parentElement; + if (!composerViewport) return true; + + return measureWithPanelRatio(panel, ratio, () => { + const viewportStyle = window.getComputedStyle(composerViewport); + const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0; + const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0; + const viewportContentWidth = Math.max( + 0, + composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, + ); + const formRect = composerForm.getBoundingClientRect(); + const composerFooter = composerForm.querySelector( + "[data-chat-composer-footer='true']", + ); + const composerRightActions = composerForm.querySelector( + "[data-chat-composer-actions='right']", + ); + const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0; + const composerFooterGap = composerFooter + ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) || + Number.parseFloat(window.getComputedStyle(composerFooter).gap) || + 0 + : 0; + const minimumComposerWidth = + COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap; + const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; + const overflowsViewport = formRect.width > viewportContentWidth + 0.5; + const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth; + + return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth; + }); +}; + +export function ResizableRightPanel({ + children, + className, + storageKey, +}: { + children: ReactNode; + className?: string; + storageKey?: string; +}) { + const [widthRatio, setWidthRatio] = useState(() => readStoredRatio(storageKey)); + const resizeOwnerRef = useRef(Symbol("ResizableRightPanel")); + const panelRef = useRef(null); + const storageKeyRef = useRef(storageKey); + const widthRatioRef = useRef(widthRatio); + const resizeStateRef = useRef(null); + storageKeyRef.current = storageKey; + + const commitWidthRatio = useCallback((ratio: number) => { + widthRatioRef.current = ratio; + setWidthRatio(ratio); + }, []); + + const commitResizePosition = useCallback( + (resizeState: ResizeState, clientX: number) => { + const nextRatio = resolveResizeRatio(resizeState, clientX); + if (nextRatio === null || !canAcceptComposerWidth(resizeState.panel, nextRatio)) return; + commitWidthRatio(nextRatio); + }, + [commitWidthRatio], + ); + + const stopResize = useCallback( + (pointerId: number, finalClientX?: number) => { + const resizeState = resizeStateRef.current; + if (!resizeState) return; + if (resizeState.frameId !== null) { + window.cancelAnimationFrame(resizeState.frameId); + } + commitResizePosition(resizeState, finalClientX ?? resizeState.latestX); + if (resizeState.handle.hasPointerCapture(pointerId)) { + resizeState.handle.releasePointerCapture(pointerId); + } + clearBodyResizeStyles(resizeOwnerRef.current); + resizeStateRef.current = null; + if (storageKey) { + setLocalStorageItem(storageKey, widthRatioRef.current, Schema.Finite); + } + }, + [commitResizePosition, storageKey], + ); + + const handlePointerDown = useCallback((event: ReactPointerEvent) => { + if (event.button !== 0) return; + const panel = panelRef.current; + if (!panel) return; + + event.preventDefault(); + event.stopPropagation(); + resizeStateRef.current = { + frameId: null, + handle: event.currentTarget, + latestX: event.clientX, + panel, + pointerId: event.pointerId, + startWidth: panel.getBoundingClientRect().width, + startX: event.clientX, + }; + event.currentTarget.setPointerCapture(event.pointerId); + applyBodyResizeStyles(resizeOwnerRef.current); + }, []); + + const handlePointerMove = useCallback( + (event: ReactPointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState || resizeState.pointerId !== event.pointerId) return; + + event.preventDefault(); + resizeState.latestX = event.clientX; + + if (resizeState.frameId !== null) return; + resizeState.frameId = window.requestAnimationFrame(() => { + const activeResizeState = resizeStateRef.current; + if (!activeResizeState) return; + + activeResizeState.frameId = null; + commitResizePosition(activeResizeState, activeResizeState.latestX); + }); + }, + [commitResizePosition], + ); + + const handlePointerUp = useCallback( + (event: ReactPointerEvent) => { + const resizeState = resizeStateRef.current; + if (!resizeState || resizeState.pointerId !== event.pointerId) return; + stopResize(event.pointerId, event.clientX); + }, + [stopResize], + ); + + useEffect(() => { + const resizeOwner = resizeOwnerRef.current; + return () => { + const resizeState = resizeStateRef.current; + if (resizeState?.frameId !== null && resizeState?.frameId !== undefined) { + window.cancelAnimationFrame(resizeState.frameId); + } + if (resizeState) { + const nextRatio = resolveResizeRatio(resizeState, resizeState.latestX); + if (nextRatio !== null && canAcceptComposerWidth(resizeState.panel, nextRatio)) { + widthRatioRef.current = nextRatio; + } + if (storageKeyRef.current) { + setLocalStorageItem(storageKeyRef.current, widthRatioRef.current, Schema.Finite); + } + if (resizeState.handle.hasPointerCapture(resizeState.pointerId)) { + resizeState.handle.releasePointerCapture(resizeState.pointerId); + } + resizeStateRef.current = null; + } + clearBodyResizeStyles(resizeOwner); + }; + }, []); + + return ( +
+
+ {children} +
+ ); +} diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 1877fee6f7d..e4240a287a9 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -11,25 +11,19 @@ import { type DiffPanelMode, } from "../components/DiffPanelShell"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { - type DiffRouteSearch, - parseDiffRouteSearch, - stripDiffSearchParams, -} from "../diffRouteSearch"; +import { type DiffRouteSearch, parseDiffRouteSearch } from "../diffRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; +import { ResizableRightPanel } from "../components/ResizableRightPanel"; import { RightPanelSheet } from "../components/RightPanelSheet"; -import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; +import { SidebarInset } from "~/components/ui/sidebar"; +import { cn } from "~/lib/utils"; const DiffPanel = lazy(() => import("../components/DiffPanel")); -const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; -const DIFF_INLINE_DEFAULT_WIDTH = "clamp(24rem,34vw,36rem)"; -const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 22 * 16; -const DIFF_INLINE_SIDEBAR_MAX_WIDTH = 256 * 16; -const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; +const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width_ratio"; const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { return ( @@ -49,92 +43,17 @@ const LazyDiffPanel = (props: { mode: DiffPanelMode }) => { ); }; -const DiffPanelInlineSidebar = (props: { - diffOpen: boolean; - onCloseDiff: () => void; - onOpenDiff: () => void; - renderDiffContent: boolean; -}) => { - const { diffOpen, onCloseDiff, onOpenDiff, renderDiffContent } = props; - const onOpenChange = useCallback( - (open: boolean) => { - if (open) { - onOpenDiff(); - return; - } - onCloseDiff(); - }, - [onCloseDiff, onOpenDiff], - ); - const shouldAcceptInlineSidebarWidth = useCallback( - ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { - const composerForm = document.querySelector("[data-chat-composer-form='true']"); - if (!composerForm) return true; - const composerViewport = composerForm.parentElement; - if (!composerViewport) return true; - const previousSidebarWidth = wrapper.style.getPropertyValue("--sidebar-width"); - wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); - - const viewportStyle = window.getComputedStyle(composerViewport); - const viewportPaddingLeft = Number.parseFloat(viewportStyle.paddingLeft) || 0; - const viewportPaddingRight = Number.parseFloat(viewportStyle.paddingRight) || 0; - const viewportContentWidth = Math.max( - 0, - composerViewport.clientWidth - viewportPaddingLeft - viewportPaddingRight, - ); - const formRect = composerForm.getBoundingClientRect(); - const composerFooter = composerForm.querySelector( - "[data-chat-composer-footer='true']", - ); - const composerRightActions = composerForm.querySelector( - "[data-chat-composer-actions='right']", - ); - const composerRightActionsWidth = composerRightActions?.getBoundingClientRect().width ?? 0; - const composerFooterGap = composerFooter - ? Number.parseFloat(window.getComputedStyle(composerFooter).columnGap) || - Number.parseFloat(window.getComputedStyle(composerFooter).gap) || - 0 - : 0; - const minimumComposerWidth = - COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX + composerRightActionsWidth + composerFooterGap; - const hasComposerOverflow = composerForm.scrollWidth > composerForm.clientWidth + 0.5; - const overflowsViewport = formRect.width > viewportContentWidth + 0.5; - const violatesMinimumComposerWidth = composerForm.clientWidth + 0.5 < minimumComposerWidth; - - if (previousSidebarWidth.length > 0) { - wrapper.style.setProperty("--sidebar-width", previousSidebarWidth); - } else { - wrapper.style.removeProperty("--sidebar-width"); - } - - return !hasComposerOverflow && !overflowsViewport && !violatesMinimumComposerWidth; - }, - [], - ); +const DiffPanelInlineSidebar = (props: { diffOpen: boolean; renderDiffContent: boolean }) => { + const { diffOpen, renderDiffContent } = props; + if (!renderDiffContent) return null; return ( - - - {renderDiffContent ? : null} - - - + + ); }; @@ -199,21 +118,6 @@ function ChatThreadRouteView() { search: { diff: undefined }, }); }, [navigate, threadRef]); - const openDiff = useCallback(() => { - if (!threadRef) { - return; - } - markDiffOpened(); - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; - }, - }); - }, [markDiffOpened, navigate, threadRef]); - useEffect(() => { if (!threadRef || !bootstrapComplete) { return; @@ -239,7 +143,7 @@ function ChatThreadRouteView() { if (!shouldUseDiffSheet) { return ( - <> +
- - + +
); }