diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a5a2875cc..dffa7b4775 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -28,15 +28,23 @@ import { import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useSearch } from "@tanstack/react-router"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ComponentProps, +} from "react"; +import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { collapseExpandedComposerCursor, parseStandaloneComposerSlashCommand, @@ -168,6 +176,12 @@ import { useServerKeybindings, } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; +import { useWorkspaceSecondarySurface } from "./workspace/WorkspaceProvider"; +import { useWorkspaceSecondarySurfaceActions } from "./workspace/useWorkspaceSecondarySurfaceActions"; +import { + createConversationDiffSurface, + createTurnDiffSurface, +} from "../workspace/surfaces/diffSurface"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; @@ -179,6 +193,32 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record; +type WorkspaceAwareChatHeaderProps = Omit< + ComponentProps, + "diffOpen" | "onToggleDiff" +> & { + isServerThread: boolean; + threadRef: ScopedThreadRef; +}; + +const WorkspaceAwareChatHeader = memo(function WorkspaceAwareChatHeader( + props: WorkspaceAwareChatHeaderProps, +) { + const secondarySurface = useWorkspaceSecondarySurface(); + const { toggleSecondarySurface } = useWorkspaceSecondarySurfaceActions(); + const onToggleDiff = useCallback(() => { + if (!props.isServerThread) { + return; + } + + toggleSecondarySurface(createConversationDiffSurface(props.threadRef), { replace: true }); + }, [props.isServerThread, props.threadRef, toggleSecondarySurface]); + + return ( + + ); +}); + function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { return useStore( useMemo(() => { @@ -312,14 +352,12 @@ type ChatViewProps = | { environmentId: EnvironmentId; threadId: ThreadId; - onDiffPanelOpen?: () => void; routeKind: "server"; draftId?: never; } | { environmentId: EnvironmentId; threadId: ThreadId; - onDiffPanelOpen?: () => void; routeKind: "draft"; draftId: DraftId; }; @@ -573,7 +611,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }); export default function ChatView(props: ChatViewProps) { - const { environmentId, threadId, routeKind, onDiffPanelOpen } = props; + const { environmentId, threadId, routeKind } = props; const draftId = routeKind === "draft" ? props.draftId : null; const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -608,10 +646,7 @@ export default function ChatView(props: ChatViewProps) { ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); - const rawSearch = useSearch({ - strict: false, - select: (params) => parseDiffRouteSearch(params), - }); + const { openSecondarySurface, toggleSecondarySurface } = useWorkspaceSecondarySurfaceActions(); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. const composerRuntimeMode = useComposerDraftStore( @@ -795,7 +830,6 @@ export default function ChatView(props: ChatViewProps) { composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; - const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), @@ -1475,22 +1509,9 @@ export default function ChatView(props: ChatViewProps) { if (!isServerThread) { return; } - if (!diffOpen) { - onDiffPanelOpen?.(); - } - void navigate({ - to: "/$environmentId/$threadId", - params: { - environmentId, - threadId, - }, - replace: true, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; - }, - }); - }, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]); + + toggleSecondarySurface(createConversationDiffSurface(routeThreadRef), { replace: true }); + }, [isServerThread, routeThreadRef, toggleSecondarySurface]); const envLocked = Boolean( activeThread && @@ -3255,22 +3276,11 @@ export default function ChatView(props: ChatViewProps) { if (!isServerThread) { return; } - onDiffPanelOpen?.(); - void navigate({ - to: "/$environmentId/$threadId", - params: { - environmentId, - threadId, - }, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return filePath - ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } - : { ...rest, diff: "1", diffTurnId: turnId }; - }, + openSecondarySurface(createTurnDiffSurface(routeThreadRef, turnId, filePath), { + replace: false, }); }, - [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], + [isServerThread, openSecondarySurface, routeThreadRef], ); const onRevertUserMessage = useCallback( (messageId: MessageId) => { @@ -3297,7 +3307,7 @@ export default function ChatView(props: ChatViewProps) { isElectron ? "drag-region flex h-[52px] items-center" : "py-2 sm:py-3", )} > - { + void runProjectScript(script); + }} onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} onToggleTerminal={toggleTerminalVisibility} - onToggleDiff={onToggleDiff} /> diff --git a/apps/web/src/components/DiffPanel.logic.test.ts b/apps/web/src/components/DiffPanel.logic.test.ts new file mode 100644 index 0000000000..363604c164 --- /dev/null +++ b/apps/web/src/components/DiffPanel.logic.test.ts @@ -0,0 +1,46 @@ +import { TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { normalizeDiffSurfaceFocus } from "./DiffPanel.logic"; + +describe("normalizeDiffSurfaceFocus", () => { + it("keeps conversation focus unchanged", () => { + expect(normalizeDiffSurfaceFocus({ scope: "conversation" }, [])).toEqual({ + scope: "conversation", + }); + }); + + it("keeps turn focus when the requested turn exists", () => { + const turnId = TurnId.make("turn-1"); + + expect( + normalizeDiffSurfaceFocus( + { + scope: "turn", + turnId, + filePath: "src/app.ts", + }, + [turnId], + ), + ).toEqual({ + scope: "turn", + turnId, + filePath: "src/app.ts", + }); + }); + + it("falls back to conversation focus when the requested turn no longer exists", () => { + expect( + normalizeDiffSurfaceFocus( + { + scope: "turn", + turnId: TurnId.make("missing-turn"), + filePath: "src/app.ts", + }, + [TurnId.make("different-turn")], + ), + ).toEqual({ + scope: "conversation", + }); + }); +}); diff --git a/apps/web/src/components/DiffPanel.logic.ts b/apps/web/src/components/DiffPanel.logic.ts new file mode 100644 index 0000000000..783f320f57 --- /dev/null +++ b/apps/web/src/components/DiffPanel.logic.ts @@ -0,0 +1,19 @@ +import type { TurnId } from "@t3tools/contracts"; + +import { type DiffSurfaceFocus } from "../workspace/types"; + +export function normalizeDiffSurfaceFocus( + focus: DiffSurfaceFocus, + availableTurnIds: readonly TurnId[], +): DiffSurfaceFocus { + if (focus.scope !== "turn") { + return focus; + } + + // Preserve explicit turn deep links until we have at least one summary to validate against. + if (availableTurnIds.length === 0 || availableTurnIds.includes(focus.turnId)) { + return focus; + } + + return { scope: "conversation" }; +} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 63a5231f73..d1736ef412 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,9 +1,7 @@ import { parsePatchFiles } from "@pierre/diffs"; import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; -import { scopeThreadRef } from "@t3tools/client-runtime"; -import type { TurnId } from "@t3tools/contracts"; +import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; import { ChevronLeftIcon, ChevronRightIcon, @@ -25,16 +23,16 @@ import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { resolvePathLinkTarget } from "../terminal-links"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { useTheme } from "../hooks/useTheme"; import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { selectProjectByRef, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; -import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; +import { useStore } from "../store"; +import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; +import { normalizeDiffSurfaceFocus } from "./DiffPanel.logic"; +import { sameDiffSurfaceFocus, type DiffSurfaceFocus } from "../workspace/types"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; @@ -161,44 +159,48 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { } interface DiffPanelProps { + threadRef: ScopedThreadRef; + focus: DiffSurfaceFocus; mode?: DiffPanelMode; + onFocusChange?: (focus: DiffSurfaceFocus, options?: { replace?: boolean }) => void; } export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; -export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { - const navigate = useNavigate(); +export default function DiffPanel({ + threadRef, + focus, + mode = "inline", + onFocusChange, +}: DiffPanelProps) { const { resolvedTheme } = useTheme(); const settings = useSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const patchViewportRef = useRef(null); const turnStripRef = useRef(null); - const previousDiffOpenRef = useRef(false); const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); - const routeThreadRef = useParams({ - strict: false, - select: (params) => resolveThreadRouteRef(params), - }); - const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); - const diffOpen = diffSearch.diff === "1"; - const activeThreadId = routeThreadRef?.threadId ?? null; - const activeThread = useStore( - useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), - ); + const activeThreadId = threadRef.threadId; + const activeThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); const activeProjectId = activeThread?.projectId ?? null; - const activeProject = useStore((store) => - activeThread && activeProjectId - ? selectProjectByRef(store, { - environmentId: activeThread.environmentId, - projectId: activeProjectId, - }) - : undefined, + const activeProject = useStore( + useMemo( + () => + createProjectSelectorByRef( + activeProjectId + ? { + environmentId: threadRef.environmentId, + projectId: activeProjectId, + } + : null, + ), + [activeProjectId, threadRef.environmentId], + ), ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; const gitStatusQuery = useGitStatus({ - environmentId: activeThread?.environmentId ?? null, + environmentId: threadRef.environmentId, cwd: activeCwd ?? null, }); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; @@ -218,14 +220,22 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }), [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], ); + const normalizedFocus = useMemo( + () => + normalizeDiffSurfaceFocus( + focus, + orderedTurnDiffSummaries.map((summary) => summary.turnId), + ), + [focus, orderedTurnDiffSummaries], + ); - const selectedTurnId = diffSearch.diffTurnId ?? null; - const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; + const selectedTurnId = normalizedFocus.scope === "turn" ? normalizedFocus.turnId : null; + const selectedFilePath = + normalizedFocus.scope === "turn" ? (normalizedFocus.filePath ?? null) : null; const selectedTurn = selectedTurnId === null ? undefined - : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? - orderedTurnDiffSummaries[0]); + : orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId); const selectedCheckpointTurnCount = selectedTurn && (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); @@ -315,11 +325,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }, [renderablePatch]); useEffect(() => { - if (diffOpen && !previousDiffOpenRef.current) { - setDiffWordWrap(settings.diffWordWrap); + if (sameDiffSurfaceFocus(focus, normalizedFocus)) { + return; } - previousDiffOpenRef.current = diffOpen; - }, [diffOpen, settings.diffWordWrap]); + + onFocusChange?.(normalizedFocus, { replace: true }); + }, [focus, normalizedFocus, onFocusChange]); useEffect(() => { if (!selectedFilePath || !patchViewportRef.current) { @@ -343,28 +354,15 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { [activeCwd], ); - const selectTurn = (turnId: TurnId) => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1", diffTurnId: turnId }; - }, - }); - }; - const selectWholeConversation = () => { - if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; - }, - }); - }; + const selectTurn = useCallback( + (turnId: TurnId) => { + onFocusChange?.({ scope: "turn", turnId }); + }, + [onFocusChange], + ); + const selectWholeConversation = useCallback(() => { + onFocusChange?.({ scope: "conversation" }); + }, [onFocusChange]); const updateTurnStripScrollState = useCallback(() => { const element = turnStripRef.current; if (!element) { diff --git a/apps/web/src/components/workspace/WorkspaceProvider.tsx b/apps/web/src/components/workspace/WorkspaceProvider.tsx new file mode 100644 index 0000000000..b2a5e0f1fe --- /dev/null +++ b/apps/web/src/components/workspace/WorkspaceProvider.tsx @@ -0,0 +1,112 @@ +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from "react"; +import { useStore as useZustandStore } from "zustand"; + +import { parseWorkspaceRouteSearch } from "~/workspaceRouteSearch"; +import { buildDraftThreadRouteParams, buildThreadRouteParams } from "~/threadRoutes"; +import { + createWorkspaceStore, + type OpenSurfaceFn, + selectResolvedWorkspaceState, + type UpdateSurfaceFn, + type WorkspaceNavigationOptions, + type WorkspaceStore, + type WorkspaceStoreApi, +} from "~/workspace/store"; +import { buildWorkspaceRouteSearch, resolveWorkspaceState } from "~/workspace/urlState"; +import type { SecondarySurface, WorkspaceState, WorkspaceTarget } from "~/workspace/types"; + +const WorkspaceStoreContext = createContext(null); + +export function WorkspaceProvider(props: { target: WorkspaceTarget; children: ReactNode }) { + const navigate = useNavigate(); + const search = useSearch({ strict: false, select: (value) => parseWorkspaceRouteSearch(value) }); + const resolvedState = useMemo( + () => resolveWorkspaceState(props.target, search), + [props.target, search], + ); + const latestRouteRef = useRef<{ + target: WorkspaceTarget; + resolvedState: WorkspaceState; + }>({ + target: props.target, + resolvedState, + }); + const storeRef = useRef(null); + if (!storeRef.current) { + storeRef.current = createWorkspaceStore(resolvedState, { + navigateToState: (nextState, options) => { + const latestRoute = latestRouteRef.current; + + if (latestRoute.target.kind === "server") { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(latestRoute.target.threadRef), + replace: options?.replace ?? false, + search: (previous) => buildWorkspaceRouteSearch(nextState, previous), + }); + return; + } + + void navigate({ + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(latestRoute.target.draftId), + replace: options?.replace ?? false, + search: (previous) => buildWorkspaceRouteSearch(nextState, previous), + }); + }, + }); + } + + const store = storeRef.current; + latestRouteRef.current = { + target: props.target, + resolvedState, + }; + + useEffect(() => { + store.getState().syncRouteState(resolvedState); + }, [resolvedState, store]); + + return ( + {props.children} + ); +} + +function useWorkspaceStoreApiInternal(): WorkspaceStoreApi { + const context = useContext(WorkspaceStoreContext); + if (!context) { + throw new Error("Workspace hooks must be used within a WorkspaceProvider."); + } + return context; +} + +export function useWorkspaceStoreApi(): WorkspaceStoreApi { + return useWorkspaceStoreApiInternal(); +} + +export function useWorkspaceStore(selector: (state: WorkspaceStore) => T): T { + const store = useWorkspaceStoreApiInternal(); + return useZustandStore(store, selector); +} + +export function useWorkspaceState(): WorkspaceState { + return useWorkspaceStore(selectResolvedWorkspaceState); +} + +export function useWorkspaceSecondarySurface(): SecondarySurface | null { + return useWorkspaceStore((state) => selectResolvedWorkspaceState(state).surfaces.secondary); +} + +export function useWorkspaceActions(): { + openSurface: OpenSurfaceFn; + closeSurface: (placement: "secondary", options?: WorkspaceNavigationOptions) => void; + updateSurface: UpdateSurfaceFn; +} { + const store = useWorkspaceStoreApiInternal(); + + return useMemo(() => { + const { openSurface, closeSurface, updateSurface } = store.getState(); + return { openSurface, closeSurface, updateSurface }; + }, [store]); +} diff --git a/apps/web/src/components/workspace/WorkspaceShell.tsx b/apps/web/src/components/workspace/WorkspaceShell.tsx new file mode 100644 index 0000000000..461618c843 --- /dev/null +++ b/apps/web/src/components/workspace/WorkspaceShell.tsx @@ -0,0 +1,190 @@ +import { scopedThreadKey } from "@t3tools/client-runtime"; +import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; + +import { useMediaQuery } from "~/hooks/useMediaQuery"; +import { + renderMainSurface, + renderSecondarySurface, + sameWorkspaceSurface, +} from "~/workspace/surfaceDefinitions"; +import { type MainSurface, type SecondarySurface } from "~/workspace/types"; +import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "../ui/sidebar"; +import { Sheet, SheetPopup } from "../ui/sheet"; +import { useWorkspaceActions, useWorkspaceState } from "./WorkspaceProvider"; + +const SECONDARY_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; +const SECONDARY_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; +const SECONDARY_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; +const SECONDARY_SIDEBAR_MIN_WIDTH = 26 * 16; +const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; + +function shouldAcceptSecondarySidebarWidth({ + 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; +} + +export function WorkspaceShell() { + const shouldUseSecondarySheet = useMediaQuery(SECONDARY_LAYOUT_MEDIA_QUERY); + const state = useWorkspaceState(); + const { closeSurface } = useWorkspaceActions(); + const secondarySurface = state.surfaces.secondary; + const targetThreadKey = + state.target.kind === "server" + ? scopedThreadKey(state.target.threadRef) + : `draft:${state.target.draftId}`; + const secondarySurfaceRef = useRef(secondarySurface); + const [mountedSecondarySurface, setMountedSecondarySurface] = useState( + secondarySurface, + ); + + useEffect(() => { + secondarySurfaceRef.current = secondarySurface; + }, [secondarySurface]); + + useEffect(() => { + setMountedSecondarySurface(secondarySurfaceRef.current); + }, [targetThreadKey]); + + useEffect(() => { + if (secondarySurface) { + setMountedSecondarySurface(secondarySurface); + } + }, [secondarySurface]); + + const renderedSecondarySurface = secondarySurface ?? mountedSecondarySurface; + const secondaryOpen = secondarySurface !== null; + const closeSecondarySurface = useCallback(() => { + closeSurface("secondary", { replace: true }); + }, [closeSurface]); + + const mainContent = ( + + + + ); + + if (shouldUseSecondarySheet) { + return ( + <> + {mainContent} + { + if (!open && secondaryOpen) { + closeSecondarySurface(); + } + }} + > + + {renderedSecondarySurface ? ( + + ) : null} + + + + ); + } + + return ( + <> + {mainContent} + { + if (!open && secondaryOpen) { + closeSecondarySurface(); + } + }} + className="w-auto min-h-0 flex-none bg-transparent" + style={{ "--sidebar-width": SECONDARY_DEFAULT_WIDTH } as CSSProperties} + > + + {renderedSecondarySurface ? ( + + ) : null} + + + + + ); +} + +const MainSurfaceSlot = memo( + function MainSurfaceSlot(props: { surface: MainSurface }) { + return renderMainSurface(props.surface); + }, + (previousProps, nextProps) => sameWorkspaceSurface(previousProps.surface, nextProps.surface), +); + +const SecondarySurfaceSlot = memo( + function SecondarySurfaceSlot(props: { + surface: SecondarySurface; + renderMode: "sidebar" | "sheet"; + }) { + return renderSecondarySurface(props.surface, props.renderMode); + }, + (previousProps, nextProps) => + previousProps.renderMode === nextProps.renderMode && + sameWorkspaceSurface(previousProps.surface, nextProps.surface), +); diff --git a/apps/web/src/components/workspace/useWorkspaceSecondarySurfaceActions.ts b/apps/web/src/components/workspace/useWorkspaceSecondarySurfaceActions.ts new file mode 100644 index 0000000000..0d607db5fa --- /dev/null +++ b/apps/web/src/components/workspace/useWorkspaceSecondarySurfaceActions.ts @@ -0,0 +1,71 @@ +import { useCallback, useMemo } from "react"; + +import { type WorkspaceNavigationOptions } from "~/workspace/store"; +import { sameWorkspaceSurface } from "~/workspace/surfaceDefinitions"; +import type { + SecondarySurface, + WorkspaceSurfaceIdForPlacement, + WorkspaceSurfaceInputById, +} from "~/workspace/types"; +import { useWorkspaceActions, useWorkspaceSecondarySurface } from "./WorkspaceProvider"; + +export function useWorkspaceSecondarySurfaceActions(): { + openSecondarySurface: (surface: SecondarySurface, options?: WorkspaceNavigationOptions) => void; + closeSecondarySurface: (options?: WorkspaceNavigationOptions) => void; + toggleSecondarySurface: (surface: SecondarySurface, options?: WorkspaceNavigationOptions) => void; + updateSecondarySurface: >( + surfaceId: TId, + input: WorkspaceSurfaceInputById[TId], + options?: WorkspaceNavigationOptions, + ) => void; +} { + const secondarySurface = useWorkspaceSecondarySurface(); + const { closeSurface, openSurface, updateSurface } = useWorkspaceActions(); + + const openSecondarySurface = useCallback( + (surface: SecondarySurface, options?: WorkspaceNavigationOptions) => { + openSurface("secondary", surface, options); + }, + [openSurface], + ); + + const closeSecondarySurface = useCallback( + (options?: WorkspaceNavigationOptions) => { + closeSurface("secondary", options); + }, + [closeSurface], + ); + + const toggleSecondarySurface = useCallback( + (surface: SecondarySurface, options?: WorkspaceNavigationOptions) => { + if (sameWorkspaceSurface(secondarySurface, surface)) { + closeSurface("secondary", options); + return; + } + + openSurface("secondary", surface, options); + }, + [closeSurface, openSurface, secondarySurface], + ); + + const updateSecondarySurface = useCallback( + >( + surfaceId: TId, + input: WorkspaceSurfaceInputById[TId], + options?: WorkspaceNavigationOptions, + ) => { + updateSurface("secondary", surfaceId, input, options); + }, + [updateSurface], + ); + + return useMemo( + () => ({ + openSecondarySurface, + closeSecondarySurface, + toggleSecondarySurface, + updateSecondarySurface, + }), + [closeSecondarySurface, openSecondarySurface, toggleSecondarySurface, updateSecondarySurface], + ); +} diff --git a/apps/web/src/diffRouteSearch.test.ts b/apps/web/src/diffRouteSearch.test.ts deleted file mode 100644 index ef00874bd2..0000000000 --- a/apps/web/src/diffRouteSearch.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseDiffRouteSearch } from "./diffRouteSearch"; - -describe("parseDiffRouteSearch", () => { - it("parses valid diff search values", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({ - diff: "1", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - }); - - it("treats numeric and boolean diff toggles as open", () => { - expect( - parseDiffRouteSearch({ - diff: 1, - diffTurnId: "turn-1", - }), - ).toEqual({ - diff: "1", - diffTurnId: "turn-1", - }); - - expect( - parseDiffRouteSearch({ - diff: true, - diffTurnId: "turn-1", - }), - ).toEqual({ - diff: "1", - diffTurnId: "turn-1", - }); - }); - - it("drops turn and file values when diff is closed", () => { - const parsed = parseDiffRouteSearch({ - diff: "0", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({}); - }); - - it("drops file value when turn is not selected", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({ - diff: "1", - }); - }); - - it("normalizes whitespace-only values", () => { - const parsed = parseDiffRouteSearch({ - diff: "1", - diffTurnId: " ", - diffFilePath: " ", - }); - - expect(parsed).toEqual({ - diff: "1", - }); - }); -}); diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts deleted file mode 100644 index d9b072f28e..0000000000 --- a/apps/web/src/diffRouteSearch.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { TurnId } from "@t3tools/contracts"; - -export interface DiffRouteSearch { - diff?: "1" | undefined; - diffTurnId?: TurnId | undefined; - diffFilePath?: string | undefined; -} - -function isDiffOpenValue(value: unknown): boolean { - return value === "1" || value === 1 || value === true; -} - -function normalizeSearchString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim(); - return normalized.length > 0 ? normalized : undefined; -} - -export function stripDiffSearchParams>( - params: T, -): Omit { - const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; - return rest as Omit; -} - -export function parseDiffRouteSearch(search: Record): DiffRouteSearch { - const diff = isDiffOpenValue(search.diff) ? "1" : undefined; - const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; - const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined; - const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; - - return { - ...(diff ? { diff } : {}), - ...(diffTurnId ? { diffTurnId } : {}), - ...(diffFilePath ? { diffFilePath } : {}), - }; -} diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index b62fd6b9c6..4f46eee04b 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,173 +1,24 @@ import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; -import { Suspense, lazy, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo } from "react"; -import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; -import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; -import { - DiffPanelHeaderSkeleton, - DiffPanelLoadingState, - DiffPanelShell, - type DiffPanelMode, -} from "../components/DiffPanelShell"; +import { WorkspaceProvider } from "../components/workspace/WorkspaceProvider"; +import { WorkspaceShell } from "../components/workspace/WorkspaceShell"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { - type DiffRouteSearch, - parseDiffRouteSearch, - stripDiffSearchParams, -} from "../diffRouteSearch"; -import { useMediaQuery } from "../hooks/useMediaQuery"; import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; -import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; -import { Sheet, SheetPopup } from "../components/ui/sheet"; -import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; - -const DiffPanel = lazy(() => import("../components/DiffPanel")); -const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; -const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; -const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; -const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; -const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; - -const DiffPanelSheet = (props: { - children: ReactNode; - diffOpen: boolean; - onCloseDiff: () => void; -}) => { - return ( - { - if (!open) { - props.onCloseDiff(); - } - }} - > - - {props.children} - - - ); -}; - -const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { - return ( - }> - - - ); -}; - -const LazyDiffPanel = (props: { mode: DiffPanelMode }) => { - return ( - - }> - - - - ); -}; - -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; - }, - [], - ); - - return ( - - - {renderDiffContent ? : null} - - - - ); -}; +import { resolveThreadRouteRef } from "../threadRoutes"; +import { + type WorkspaceRouteSearch, + parseWorkspaceRouteSearch, + WORKSPACE_ROUTE_SEARCH_KEYS, +} from "../workspaceRouteSearch"; function ChatThreadRouteView() { const navigate = useNavigate(); const threadRef = Route.useParams({ select: (params) => resolveThreadRouteRef(params), }); - const search = Route.useSearch(); const bootstrapComplete = useStore( (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, ); @@ -191,52 +42,6 @@ function ChatThreadRouteView() { const routeThreadExists = threadExists || draftThreadExists; const serverThreadStarted = threadHasStarted(serverThread); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; - const diffOpen = search.diff === "1"; - const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); - const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; - const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({ - threadKey: currentThreadKey, - hasOpenedDiff: diffOpen, - })); - const hasOpenedDiff = - diffPanelMountState.threadKey === currentThreadKey - ? diffPanelMountState.hasOpenedDiff - : diffOpen; - const markDiffOpened = useCallback(() => { - setDiffPanelMountState((previous) => { - if (previous.threadKey === currentThreadKey && previous.hasOpenedDiff) { - return previous; - } - return { - threadKey: currentThreadKey, - hasOpenedDiff: true, - }; - }); - }, [currentThreadKey]); - const closeDiff = useCallback(() => { - if (!threadRef) { - return; - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - 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) { @@ -259,50 +64,26 @@ function ChatThreadRouteView() { return null; } - const shouldRenderDiffContent = diffOpen || hasOpenedDiff; - - if (!shouldUseDiffSheet) { - return ( - <> - - - - - - ); - } - return ( - <> - - - - - {shouldRenderDiffContent ? : null} - - + + + ); } export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ - validateSearch: (search) => parseDiffRouteSearch(search), + validateSearch: (search) => parseWorkspaceRouteSearch(search), search: { - middlewares: [retainSearchParams(["diff"])], + middlewares: [ + retainSearchParams([ + ...WORKSPACE_ROUTE_SEARCH_KEYS, + ] as (keyof WorkspaceRouteSearch)[]), + ], }, component: ChatThreadRouteView, }); diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index e7bb1d0912..93254ee8de 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -1,9 +1,9 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useMemo } from "react"; -import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; +import { WorkspaceProvider } from "../components/workspace/WorkspaceProvider"; +import { WorkspaceShell } from "../components/workspace/WorkspaceShell"; import { useComposerDraftStore, DraftId } from "../composerDraftStore"; -import { SidebarInset } from "../components/ui/sidebar"; import { createThreadSelectorAcrossEnvironments } from "../storeSelectors"; import { useStore } from "../store"; import { buildThreadRouteParams } from "../threadRoutes"; @@ -54,15 +54,7 @@ function DraftChatThreadRouteView() { }, [canonicalThreadRef, draftSession, navigate]); if (canonicalThreadRef) { - return ( - - - - ); + return null; } if (!draftSession) { @@ -70,14 +62,16 @@ function DraftChatThreadRouteView() { } return ( - - - + + + ); } diff --git a/apps/web/src/workspace.test.ts b/apps/web/src/workspace.test.ts new file mode 100644 index 0000000000..60aa450acc --- /dev/null +++ b/apps/web/src/workspace.test.ts @@ -0,0 +1,215 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { EnvironmentId, ThreadId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vitest"; + +import { + createWorkspaceStore, + selectResolvedWorkspaceState, + type WorkspaceNavigationOptions, +} from "./workspace/store"; +import { + createDefaultWorkspaceState, + type WorkspaceState, + type WorkspaceTarget, +} from "./workspace/types"; +import { buildWorkspaceRouteSearch, resolveWorkspaceState } from "./workspace/urlState"; + +const TEST_ENVIRONMENT_ID = EnvironmentId.make("workspace-env"); +const TEST_THREAD_ID = ThreadId.make("workspace-thread"); +const TEST_TURN_ID = TurnId.make("turn-1"); + +function createServerTarget(): Extract { + return { + kind: "server", + threadRef: scopeThreadRef(TEST_ENVIRONMENT_ID, TEST_THREAD_ID), + }; +} + +describe("workspace url state", () => { + it("round-trips conversation diff state through search", () => { + const target = createServerTarget(); + const initialState = createDefaultWorkspaceState(target); + const diffState: WorkspaceState = { + ...initialState, + surfaces: { + ...initialState.surfaces, + secondary: { + id: "diff", + input: { + threadRef: target.threadRef, + focus: { scope: "conversation" }, + }, + }, + }, + }; + + const search = buildWorkspaceRouteSearch(diffState, { unrelated: "keep-me" }); + + expect(search).toEqual({ + unrelated: "keep-me", + panel: "diff", + }); + expect(resolveWorkspaceState(target, search)).toEqual(diffState); + }); + + it("round-trips focused turn diff state through search", () => { + const target = createServerTarget(); + const initialState = createDefaultWorkspaceState(target); + const diffState: WorkspaceState = { + ...initialState, + surfaces: { + ...initialState.surfaces, + secondary: { + id: "diff", + input: { + threadRef: target.threadRef, + focus: { + scope: "turn", + turnId: TEST_TURN_ID, + filePath: "src/app.ts", + }, + }, + }, + }, + }; + + const search = buildWorkspaceRouteSearch(diffState, {}); + + expect(search).toEqual({ + panel: "diff", + panelTurnId: TEST_TURN_ID, + panelFilePath: "src/app.ts", + }); + expect(resolveWorkspaceState(target, search)).toEqual(diffState); + }); + + it("clears stale focused diff params when the secondary panel changes state", () => { + const target = createServerTarget(); + const initialState = createDefaultWorkspaceState(target); + const focusedDiffState: WorkspaceState = { + ...initialState, + surfaces: { + ...initialState.surfaces, + secondary: { + id: "diff", + input: { + threadRef: target.threadRef, + focus: { + scope: "turn", + turnId: TEST_TURN_ID, + filePath: "src/app.ts", + }, + }, + }, + }, + }; + const conversationDiffState: WorkspaceState = { + ...focusedDiffState, + surfaces: { + ...focusedDiffState.surfaces, + secondary: { + id: "diff", + input: { + threadRef: target.threadRef, + focus: { scope: "conversation" }, + }, + }, + }, + }; + + expect( + buildWorkspaceRouteSearch( + conversationDiffState, + buildWorkspaceRouteSearch(focusedDiffState, {}), + ), + ).toEqual({ + panel: "diff", + }); + }); +}); + +describe("workspace store optimistic sync", () => { + it("clears the optimistic transition once the router catches up", () => { + const target = createServerTarget(); + const initialState = createDefaultWorkspaceState(target); + const navigateToState = vi.fn( + (_nextState: WorkspaceState, _options?: WorkspaceNavigationOptions) => {}, + ); + const store = createWorkspaceStore(initialState, { + navigateToState, + }); + + store.getState().openSurface( + "secondary", + { + id: "diff", + input: { + threadRef: target.threadRef, + focus: { scope: "conversation" }, + }, + }, + { replace: true }, + ); + + const navigatedState = navigateToState.mock.calls[0]?.[0] as WorkspaceState | undefined; + + expect(navigatedState?.surfaces.secondary?.id).toBe("diff"); + expect(selectResolvedWorkspaceState(store.getState()).surfaces.secondary?.id).toBe("diff"); + + store.getState().syncRouteState(navigatedState ?? initialState); + + expect(store.getState().optimisticTransitions).toEqual([]); + expect(selectResolvedWorkspaceState(store.getState())).toEqual(navigatedState); + }); + + it("updates and closes the secondary surface optimistically", () => { + const target = createServerTarget(); + const initialState = createDefaultWorkspaceState(target); + const navigateToState = vi.fn( + (_nextState: WorkspaceState, _options?: WorkspaceNavigationOptions) => {}, + ); + const store = createWorkspaceStore(initialState, { + navigateToState, + }); + + store.getState().openSurface( + "secondary", + { + id: "diff", + input: { + threadRef: target.threadRef, + focus: { scope: "conversation" }, + }, + }, + { replace: true }, + ); + + store.getState().updateSurface( + "secondary", + "diff", + { + threadRef: target.threadRef, + focus: { + scope: "turn", + turnId: TEST_TURN_ID, + }, + }, + { replace: true }, + ); + + expect(selectResolvedWorkspaceState(store.getState()).surfaces.secondary).toEqual({ + id: "diff", + input: { + threadRef: target.threadRef, + focus: { + scope: "turn", + turnId: TEST_TURN_ID, + }, + }, + }); + + store.getState().closeSurface("secondary", { replace: true }); + + expect(selectResolvedWorkspaceState(store.getState()).surfaces.secondary).toBeNull(); + }); +}); diff --git a/apps/web/src/workspace/reducer.ts b/apps/web/src/workspace/reducer.ts new file mode 100644 index 0000000000..a75d8c061c --- /dev/null +++ b/apps/web/src/workspace/reducer.ts @@ -0,0 +1,132 @@ +import { isWorkspaceSurfaceCompatibleWithTarget, sameWorkspaceSurface } from "./surfaceDefinitions"; +import type { + MainSurface, + SecondarySurface, + WorkspaceState, + WorkspaceSurfaceIdForPlacement, + WorkspaceSurfaceInputById, +} from "./types"; + +export type WorkspaceAction = + | { + type: "openSurface"; + placement: "main"; + surface: MainSurface; + } + | { + type: "openSurface"; + placement: "secondary"; + surface: SecondarySurface; + } + | { + type: "closeSurface"; + placement: "secondary"; + } + | { + type: "updateSurface"; + placement: "main"; + surfaceId: WorkspaceSurfaceIdForPlacement<"main">; + input: WorkspaceSurfaceInputById[WorkspaceSurfaceIdForPlacement<"main">]; + } + | { + type: "updateSurface"; + placement: "secondary"; + surfaceId: WorkspaceSurfaceIdForPlacement<"secondary">; + input: WorkspaceSurfaceInputById[WorkspaceSurfaceIdForPlacement<"secondary">]; + }; + +export function reduceWorkspaceState( + state: WorkspaceState, + action: WorkspaceAction, +): WorkspaceState { + switch (action.type) { + case "openSurface": { + if (!isWorkspaceSurfaceCompatibleWithTarget(action.surface, state.target)) { + return state; + } + + if (action.placement === "main") { + if (sameWorkspaceSurface(state.surfaces.main, action.surface)) { + return state; + } + + return { + ...state, + surfaces: { + ...state.surfaces, + main: action.surface, + }, + }; + } + + if (sameWorkspaceSurface(state.surfaces.secondary, action.surface)) { + return state; + } + + return { + ...state, + surfaces: { + ...state.surfaces, + secondary: action.surface, + }, + }; + } + case "closeSurface": + if (state.surfaces.secondary === null) { + return state; + } + + return { + ...state, + surfaces: { + ...state.surfaces, + secondary: null, + }, + }; + case "updateSurface": { + if (action.placement === "main") { + const nextSurface: MainSurface = { + id: action.surfaceId, + input: action.input, + }; + + if (!isWorkspaceSurfaceCompatibleWithTarget(nextSurface, state.target)) { + return state; + } + + if (sameWorkspaceSurface(state.surfaces.main, nextSurface)) { + return state; + } + + return { + ...state, + surfaces: { + ...state.surfaces, + main: nextSurface, + }, + }; + } + + const nextSurface: SecondarySurface = { + id: action.surfaceId, + input: action.input, + }; + + if (!isWorkspaceSurfaceCompatibleWithTarget(nextSurface, state.target)) { + return state; + } + + if (sameWorkspaceSurface(state.surfaces.secondary, nextSurface)) { + return state; + } + + return { + ...state, + surfaces: { + ...state.surfaces, + secondary: nextSurface, + }, + }; + } + } +} diff --git a/apps/web/src/workspace/store.ts b/apps/web/src/workspace/store.ts new file mode 100644 index 0000000000..ce0f131573 --- /dev/null +++ b/apps/web/src/workspace/store.ts @@ -0,0 +1,180 @@ +import { createStore } from "zustand/vanilla"; + +import { reduceWorkspaceState } from "./reducer"; +import { sameWorkspaceState } from "./surfaceDefinitions"; +import type { + MainSurface, + SecondarySurface, + WorkspaceState, + WorkspaceSurfaceIdForPlacement, + WorkspaceSurfaceInputById, +} from "./types"; + +type WorkspaceOptimisticTransition = { + nextState: WorkspaceState; +}; + +export interface WorkspaceNavigationOptions { + replace?: boolean; +} + +export type OpenSurfaceFn = { + (placement: "main", surface: MainSurface, options?: WorkspaceNavigationOptions): void; + (placement: "secondary", surface: SecondarySurface, options?: WorkspaceNavigationOptions): void; +}; + +export type UpdateSurfaceFn = { + ( + placement: "main", + surfaceId: WorkspaceSurfaceIdForPlacement<"main">, + input: WorkspaceSurfaceInputById[WorkspaceSurfaceIdForPlacement<"main">], + options?: WorkspaceNavigationOptions, + ): void; + ( + placement: "secondary", + surfaceId: WorkspaceSurfaceIdForPlacement<"secondary">, + input: WorkspaceSurfaceInputById[WorkspaceSurfaceIdForPlacement<"secondary">], + options?: WorkspaceNavigationOptions, + ): void; +}; + +interface WorkspaceStoreController { + navigateToState: (state: WorkspaceState, options?: WorkspaceNavigationOptions) => void; +} + +export interface WorkspaceStoreState { + routeState: WorkspaceState; + optimisticTransitions: WorkspaceOptimisticTransition[]; +} + +export interface WorkspaceStore extends WorkspaceStoreState { + setOptimisticState: (nextState: WorkspaceState) => void; + syncRouteState: (routeState: WorkspaceState) => void; + openSurface: OpenSurfaceFn; + closeSurface: (placement: "secondary", options?: WorkspaceNavigationOptions) => void; + updateSurface: UpdateSurfaceFn; +} + +export type WorkspaceStoreApi = ReturnType; + +export function selectResolvedWorkspaceState(state: WorkspaceStoreState): WorkspaceState { + return state.optimisticTransitions.at(-1)?.nextState ?? state.routeState; +} + +export function createWorkspaceStore( + initialState: WorkspaceState, + controller: WorkspaceStoreController, +) { + return createStore()((set, get) => ({ + routeState: initialState, + optimisticTransitions: [], + setOptimisticState: (nextState) => + set((current) => { + const previousTransition = current.optimisticTransitions.at(-1); + if (previousTransition && sameWorkspaceState(previousTransition.nextState, nextState)) { + return current; + } + + return { + optimisticTransitions: [ + ...current.optimisticTransitions, + { + nextState, + }, + ], + }; + }), + syncRouteState: (routeState) => + set((current) => { + if (sameWorkspaceState(current.routeState, routeState)) { + return current; + } + + const matchedTransitionIndex = current.optimisticTransitions.findIndex((transition) => + sameWorkspaceState(transition.nextState, routeState), + ); + if (matchedTransitionIndex >= 0) { + return { + routeState, + optimisticTransitions: current.optimisticTransitions.slice(matchedTransitionIndex + 1), + }; + } + + return { + routeState, + optimisticTransitions: [], + }; + }), + openSurface: (( + placement: "main" | "secondary", + surface: MainSurface | SecondarySurface, + options, + ) => { + const currentState = selectResolvedWorkspaceState(get()); + const nextState = reduceWorkspaceState( + currentState, + placement === "main" + ? { + type: "openSurface", + placement, + surface: surface as MainSurface, + } + : { + type: "openSurface", + placement, + surface: surface as SecondarySurface, + }, + ); + if (nextState === currentState) { + return; + } + + get().setOptimisticState(nextState); + controller.navigateToState(nextState, options); + }) as OpenSurfaceFn, + closeSurface: (placement: "secondary", options?: WorkspaceNavigationOptions) => { + const currentState = selectResolvedWorkspaceState(get()); + const nextState = reduceWorkspaceState(currentState, { type: "closeSurface", placement }); + if (nextState === currentState) { + return; + } + + get().setOptimisticState(nextState); + controller.navigateToState(nextState, options); + }, + updateSurface: (( + placement: "main" | "secondary", + surfaceId: + | WorkspaceSurfaceIdForPlacement<"main"> + | WorkspaceSurfaceIdForPlacement<"secondary">, + input: MainSurface["input"] | SecondarySurface["input"], + options, + ) => { + const currentState = selectResolvedWorkspaceState(get()); + const nextState = reduceWorkspaceState(currentState, { + type: "updateSurface", + placement, + surfaceId, + input, + } as + | { + type: "updateSurface"; + placement: "main"; + surfaceId: WorkspaceSurfaceIdForPlacement<"main">; + input: MainSurface["input"]; + } + | { + type: "updateSurface"; + placement: "secondary"; + surfaceId: WorkspaceSurfaceIdForPlacement<"secondary">; + input: SecondarySurface["input"]; + }); + if (nextState === currentState) { + return; + } + + get().setOptimisticState(nextState); + controller.navigateToState(nextState, options); + }) as UpdateSurfaceFn, + })); +} diff --git a/apps/web/src/workspace/surfaceDefinitions.tsx b/apps/web/src/workspace/surfaceDefinitions.tsx new file mode 100644 index 0000000000..efcdfe3a7c --- /dev/null +++ b/apps/web/src/workspace/surfaceDefinitions.tsx @@ -0,0 +1,143 @@ +import type { ReactNode } from "react"; + +import type { WorkspaceRouteSearch } from "../workspaceRouteSearch"; +import { chatSurfaceDefinition } from "./surfaces/chatSurface"; +import { diffSurfaceDefinition } from "./surfaces/diffSurface"; +import { + sameWorkspaceTarget, + type MainSurface, + type SecondarySurface, + type WorkspaceState, + type WorkspaceSurfaceForPlacement, + type WorkspaceSurfaceId, + type WorkspaceSurfaceInstance, + type WorkspaceSurfacePlacement, + type WorkspaceSurfacePlacementById, + type WorkspaceTarget, +} from "./types"; + +export type WorkspaceSurfaceRenderMode = "inline" | "sidebar" | "sheet"; + +export interface WorkspaceSurfaceDefinition { + id: TId; + placement: WorkspaceSurfacePlacementById[TId]; + isEqual: (left: WorkspaceSurfaceInstance, right: WorkspaceSurfaceInstance) => boolean; + isCompatibleWithTarget: ( + surface: WorkspaceSurfaceInstance, + target: WorkspaceTarget, + ) => boolean; + render: ( + surface: WorkspaceSurfaceInstance, + renderMode: WorkspaceSurfaceRenderMode, + ) => ReactNode; + resolveFromSearch: ( + target: WorkspaceTarget, + search: WorkspaceRouteSearch, + ) => WorkspaceSurfaceInstance | null; + serializeToSearch: (surface: WorkspaceSurfaceInstance) => Partial; +} + +export const workspaceSurfaceDefinitions = { + chat: chatSurfaceDefinition, + diff: diffSurfaceDefinition, +} satisfies { + [K in WorkspaceSurfaceId]: WorkspaceSurfaceDefinition; +}; + +function getWorkspaceSurfaceDefinition( + surfaceId: TId, +): WorkspaceSurfaceDefinition { + return workspaceSurfaceDefinitions[surfaceId] as unknown as WorkspaceSurfaceDefinition; +} + +export function sameWorkspaceSurface( + left: WorkspaceSurfaceInstance | null | undefined, + right: WorkspaceSurfaceInstance | null | undefined, +): boolean { + if (!left || !right || left.id !== right.id) { + return false; + } + + const definition = getWorkspaceSurfaceDefinition(left.id); + return definition.isEqual( + left as WorkspaceSurfaceInstance, + right as WorkspaceSurfaceInstance, + ); +} + +export function isWorkspaceSurfaceCompatibleWithTarget( + surface: WorkspaceSurfaceInstance, + target: WorkspaceTarget, +): boolean { + return getWorkspaceSurfaceDefinition(surface.id).isCompatibleWithTarget( + surface as WorkspaceSurfaceInstance, + target, + ); +} + +export function sameWorkspaceState( + left: WorkspaceState | null | undefined, + right: WorkspaceState | null | undefined, +): boolean { + return Boolean( + left && + right && + left.version === right.version && + sameWorkspaceTarget(left.target, right.target) && + sameWorkspaceSurface(left.surfaces.main, right.surfaces.main) && + ((left.surfaces.secondary === null && right.surfaces.secondary === null) || + sameWorkspaceSurface(left.surfaces.secondary, right.surfaces.secondary)), + ); +} + +export function resolveWorkspaceSurfaceFromSearch

( + placement: P, + target: WorkspaceTarget, + search: WorkspaceRouteSearch, +): WorkspaceSurfaceForPlacement

| null { + for (const definition of Object.values(workspaceSurfaceDefinitions)) { + if (definition.placement !== placement) { + continue; + } + + const surface = definition.resolveFromSearch(target, search); + if (surface) { + return surface as WorkspaceSurfaceForPlacement

; + } + } + + return null; +} + +export function serializeWorkspaceSurfaceToSearch( + surface: WorkspaceSurfaceInstance | null, +): Partial { + if (!surface) { + return {}; + } + + return getWorkspaceSurfaceDefinition(surface.id).serializeToSearch( + surface as WorkspaceSurfaceInstance, + ); +} + +export function renderWorkspaceSurface( + surface: WorkspaceSurfaceInstance, + renderMode: WorkspaceSurfaceRenderMode, +): ReactNode { + return getWorkspaceSurfaceDefinition(surface.id).render( + surface as WorkspaceSurfaceInstance, + renderMode, + ); +} + +export function renderMainSurface(surface: MainSurface): ReactNode { + return renderWorkspaceSurface(surface, "inline"); +} + +export function renderSecondarySurface( + surface: SecondarySurface, + renderMode: Exclude, +): ReactNode { + return renderWorkspaceSurface(surface, renderMode); +} diff --git a/apps/web/src/workspace/surfaces/RegisteredDiffSurface.tsx b/apps/web/src/workspace/surfaces/RegisteredDiffSurface.tsx new file mode 100644 index 0000000000..59aa2b6674 --- /dev/null +++ b/apps/web/src/workspace/surfaces/RegisteredDiffSurface.tsx @@ -0,0 +1,37 @@ +import { DiffWorkerPoolProvider } from "../../components/DiffWorkerPoolProvider"; +import { useWorkspaceActions } from "../../components/workspace/WorkspaceProvider"; +import DiffPanel from "../../components/DiffPanel"; +import type { DiffSurface } from "./diffSurface"; +import { useCallback } from "react"; + +export default function RegisteredDiffSurface(props: { + surface: DiffSurface; + renderMode: "sidebar" | "sheet"; +}) { + const { updateSurface } = useWorkspaceActions(); + const onFocusChange = useCallback( + (focus: DiffSurface["input"]["focus"], options?: { replace?: boolean }) => { + updateSurface( + "secondary", + "diff", + { + threadRef: props.surface.input.threadRef, + focus, + }, + { replace: options?.replace ?? false }, + ); + }, + [props.surface.input.threadRef, updateSurface], + ); + + return ( + + + + ); +} diff --git a/apps/web/src/workspace/surfaces/chatSurface.tsx b/apps/web/src/workspace/surfaces/chatSurface.tsx new file mode 100644 index 0000000000..4eb718635b --- /dev/null +++ b/apps/web/src/workspace/surfaces/chatSurface.tsx @@ -0,0 +1,30 @@ +import ChatView from "../../components/ChatView"; +import type { WorkspaceRouteSearch } from "../../workspaceRouteSearch"; +import { sameWorkspaceTarget, type WorkspaceTarget, type WorkspaceSurfaceInstance } from "../types"; +import type { WorkspaceSurfaceDefinition } from "../surfaceDefinitions"; + +export type ChatSurface = WorkspaceSurfaceInstance<"chat">; + +export const chatSurfaceDefinition: WorkspaceSurfaceDefinition<"chat"> = { + id: "chat", + placement: "main", + isEqual: (left, right) => sameWorkspaceTarget(left.input, right.input), + isCompatibleWithTarget: (surface, target) => sameWorkspaceTarget(surface.input, target), + render: (surface) => + surface.input.kind === "server" ? ( + + ) : ( + + ), + resolveFromSearch: (_target: WorkspaceTarget, _search: WorkspaceRouteSearch) => null, + serializeToSearch: () => ({}), +}; diff --git a/apps/web/src/workspace/surfaces/diffSurface.tsx b/apps/web/src/workspace/surfaces/diffSurface.tsx new file mode 100644 index 0000000000..07b6ed6d1b --- /dev/null +++ b/apps/web/src/workspace/surfaces/diffSurface.tsx @@ -0,0 +1,106 @@ +import { type ScopedThreadRef, TurnId as TurnIdSchema, type TurnId } from "@t3tools/contracts"; +import { lazy, Suspense } from "react"; + +import { + normalizeWorkspaceRouteSearchString, + type WorkspaceRouteSearch, +} from "../../workspaceRouteSearch"; +import { + DiffPanelHeaderSkeleton, + DiffPanelLoadingState, + DiffPanelShell, + type DiffPanelMode, +} from "../../components/DiffPanelShell"; +import type { WorkspaceSurfaceDefinition } from "../surfaceDefinitions"; +import { + sameDiffSurfaceFocus, + sameThreadRef, + type SecondarySurface, + type WorkspaceTarget, +} from "../types"; + +export type DiffSurface = Extract; + +const LazyRegisteredDiffSurface = lazy(() => import("./RegisteredDiffSurface")); + +function DiffFallback(props: { mode: DiffPanelMode }) { + return ( + }> + + + ); +} + +export function createConversationDiffSurface(threadRef: ScopedThreadRef): DiffSurface { + return { + id: "diff", + input: { + threadRef, + focus: { scope: "conversation" }, + }, + }; +} + +export function createTurnDiffSurface( + threadRef: ScopedThreadRef, + turnId: TurnId, + filePath?: string, +): DiffSurface { + return { + id: "diff", + input: { + threadRef, + focus: filePath ? { scope: "turn", turnId, filePath } : { scope: "turn", turnId }, + }, + }; +} + +export function parseDiffSurfaceFromSearch( + target: WorkspaceTarget, + search: WorkspaceRouteSearch, +): DiffSurface | null { + if (target.kind !== "server" || search.panel !== "diff") { + return null; + } + + const turnIdRaw = normalizeWorkspaceRouteSearchString(search.panelTurnId); + const turnId = turnIdRaw ? TurnIdSchema.make(turnIdRaw) : undefined; + const filePath = + turnId !== undefined ? normalizeWorkspaceRouteSearchString(search.panelFilePath) : undefined; + + return turnId !== undefined + ? createTurnDiffSurface(target.threadRef, turnId, filePath) + : createConversationDiffSurface(target.threadRef); +} + +export function serializeDiffSurfaceToSearch(surface: DiffSurface): Partial { + return { + panel: "diff", + ...(surface.input.focus.scope === "turn" + ? { + panelTurnId: surface.input.focus.turnId, + ...(surface.input.focus.filePath ? { panelFilePath: surface.input.focus.filePath } : {}), + } + : {}), + }; +} + +export const diffSurfaceDefinition: WorkspaceSurfaceDefinition<"diff"> = { + id: "diff", + placement: "secondary", + isEqual: (left, right) => + sameThreadRef(left.input.threadRef, right.input.threadRef) && + sameDiffSurfaceFocus(left.input.focus, right.input.focus), + isCompatibleWithTarget: (surface, target) => + target.kind === "server" && sameThreadRef(surface.input.threadRef, target.threadRef), + render: (surface, renderMode) => ( + }> + + + ), + resolveFromSearch: (target, search) => parseDiffSurfaceFromSearch(target, search), + serializeToSearch: (surface) => serializeDiffSurfaceToSearch(surface), +}; diff --git a/apps/web/src/workspace/types.ts b/apps/web/src/workspace/types.ts new file mode 100644 index 0000000000..418661a658 --- /dev/null +++ b/apps/web/src/workspace/types.ts @@ -0,0 +1,125 @@ +import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; + +import type { DraftId } from "../composerDraftStore"; + +export type DiffSurfaceFocus = + | { scope: "conversation" } + | { scope: "turn"; turnId: TurnId; filePath?: string | undefined }; + +export type WorkspaceTarget = + | { + kind: "server"; + threadRef: ScopedThreadRef; + } + | { + kind: "draft"; + draftId: DraftId; + environmentId: ScopedThreadRef["environmentId"]; + threadId: ScopedThreadRef["threadId"]; + }; + +export interface WorkspaceSurfaceInputById { + chat: WorkspaceTarget; + diff: { + threadRef: ScopedThreadRef; + focus: DiffSurfaceFocus; + }; +} + +export interface WorkspaceSurfacePlacementById { + chat: "main"; + diff: "secondary"; +} + +export type WorkspaceSurfaceId = keyof WorkspaceSurfaceInputById; +export type WorkspaceSurfacePlacement = WorkspaceSurfacePlacementById[WorkspaceSurfaceId]; + +export type WorkspaceSurfaceIdForPlacement

= { + [K in WorkspaceSurfaceId]: WorkspaceSurfacePlacementById[K] extends P ? K : never; +}[WorkspaceSurfaceId]; + +export type WorkspaceSurfaceInstance = + TId extends WorkspaceSurfaceId + ? { + id: TId; + input: WorkspaceSurfaceInputById[TId]; + } + : never; + +export type WorkspaceSurfaceForPlacement

= + WorkspaceSurfaceInstance>; + +export type MainSurface = WorkspaceSurfaceForPlacement<"main">; +export type SecondarySurface = WorkspaceSurfaceForPlacement<"secondary">; + +export type WorkspaceState = { + version: 1; + target: WorkspaceTarget; + surfaces: { + main: MainSurface; + secondary: SecondarySurface | null; + }; +}; + +export function sameThreadRef( + left: ScopedThreadRef | null | undefined, + right: ScopedThreadRef | null | undefined, +): boolean { + return left?.environmentId === right?.environmentId && left?.threadId === right?.threadId; +} + +export function sameWorkspaceTarget( + left: WorkspaceTarget | null | undefined, + right: WorkspaceTarget | null | undefined, +): boolean { + if (!left || !right || left.kind !== right.kind) { + return false; + } + + if (left.kind === "server" && right.kind === "server") { + return sameThreadRef(left.threadRef, right.threadRef); + } + + if (left.kind !== "draft" || right.kind !== "draft") { + return false; + } + + return ( + left.draftId === right.draftId && + left.environmentId === right.environmentId && + left.threadId === right.threadId + ); +} + +export function sameDiffSurfaceFocus( + left: DiffSurfaceFocus | null | undefined, + right: DiffSurfaceFocus | null | undefined, +): boolean { + if (!left || !right || left.scope !== right.scope) { + return false; + } + + if (left.scope === "conversation" && right.scope === "conversation") { + return true; + } + + if (left.scope !== "turn" || right.scope !== "turn") { + return false; + } + + return left.turnId === right.turnId && left.filePath === right.filePath; +} + +export function createDefaultWorkspaceState(target: WorkspaceTarget): WorkspaceState { + return { + version: 1, + target, + surfaces: { + main: { + id: "chat", + input: target, + }, + secondary: null, + }, + }; +} diff --git a/apps/web/src/workspace/urlState.ts b/apps/web/src/workspace/urlState.ts new file mode 100644 index 0000000000..093a1fa47a --- /dev/null +++ b/apps/web/src/workspace/urlState.ts @@ -0,0 +1,36 @@ +import { mergeWorkspaceRouteSearch, type WorkspaceRouteSearch } from "../workspaceRouteSearch"; +import { + resolveWorkspaceSurfaceFromSearch, + serializeWorkspaceSurfaceToSearch, +} from "./surfaceDefinitions"; +import { createDefaultWorkspaceState, type WorkspaceState, type WorkspaceTarget } from "./types"; + +export function resolveWorkspaceState( + target: WorkspaceTarget, + search: WorkspaceRouteSearch, +): WorkspaceState { + const state = createDefaultWorkspaceState(target); + const secondarySurface = resolveWorkspaceSurfaceFromSearch("secondary", target, search); + + if (!secondarySurface) { + return state; + } + + return { + ...state, + surfaces: { + ...state.surfaces, + secondary: secondarySurface, + }, + }; +} + +export function buildWorkspaceRouteSearch>( + state: WorkspaceState, + previous: T, +): T & WorkspaceRouteSearch { + return mergeWorkspaceRouteSearch( + previous, + serializeWorkspaceSurfaceToSearch(state.surfaces.secondary), + ); +} diff --git a/apps/web/src/workspaceRouteSearch.test.ts b/apps/web/src/workspaceRouteSearch.test.ts new file mode 100644 index 0000000000..b133668abf --- /dev/null +++ b/apps/web/src/workspaceRouteSearch.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { + clearWorkspaceRouteSearch, + mergeWorkspaceRouteSearch, + parseWorkspaceRouteSearch, + stripWorkspaceRouteSearchParams, +} from "./workspaceRouteSearch"; + +describe("parseWorkspaceRouteSearch", () => { + it("keeps a valid panel value", () => { + const parsed = parseWorkspaceRouteSearch({ + panel: "diff", + panelTurnId: "turn-1", + panelFilePath: "src/app.ts", + unrelated: "keep-me", + }); + + expect(parsed).toEqual({ + panel: "diff", + panelTurnId: "turn-1", + panelFilePath: "src/app.ts", + unrelated: "keep-me", + }); + }); + + it("drops whitespace-only panel values", () => { + const parsed = parseWorkspaceRouteSearch({ + panel: " ", + panelTurnId: "turn-1", + }); + + expect(parsed).toEqual({ + panelTurnId: "turn-1", + }); + }); +}); + +describe("stripWorkspaceRouteSearchParams", () => { + it("removes all provided workspace keys while preserving unrelated search params", () => { + expect( + stripWorkspaceRouteSearchParams( + { + panel: "diff", + panelTurnId: "turn-1", + panelFilePath: "src/app.ts", + unrelated: "keep-me", + }, + ["panel", "panelTurnId", "panelFilePath"], + ), + ).toEqual({ + unrelated: "keep-me", + }); + }); +}); + +describe("workspace panel search helpers", () => { + it("clears known workspace panel params while keeping unrelated keys", () => { + expect( + clearWorkspaceRouteSearch({ + panel: "diff", + panelTurnId: "turn-1", + panelFilePath: "src/app.ts", + unrelated: "keep-me", + }), + ).toEqual({ + unrelated: "keep-me", + }); + }); + + it("replaces stale panel params when merging a new panel state", () => { + expect( + mergeWorkspaceRouteSearch( + { + panel: "diff", + panelTurnId: "turn-1", + panelFilePath: "src/app.ts", + unrelated: "keep-me", + }, + { + panel: "diff", + }, + ), + ).toEqual({ + panel: "diff", + unrelated: "keep-me", + }); + }); +}); diff --git a/apps/web/src/workspaceRouteSearch.ts b/apps/web/src/workspaceRouteSearch.ts new file mode 100644 index 0000000000..c4b514a985 --- /dev/null +++ b/apps/web/src/workspaceRouteSearch.ts @@ -0,0 +1,54 @@ +export interface WorkspaceRouteSearch extends Record { + panel?: string | undefined; +} + +export const WORKSPACE_ROUTE_SEARCH_KEYS = ["panel", "panelTurnId", "panelFilePath"] as const; + +export function normalizeWorkspaceRouteSearchString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +export function stripWorkspaceRouteSearchParams>( + params: T, + keys: readonly string[], +): T { + const next = { ...params }; + + for (const key of keys) { + delete next[key]; + } + + return next; +} + +export function clearWorkspaceRouteSearch>(params: T): T { + return stripWorkspaceRouteSearchParams(params, WORKSPACE_ROUTE_SEARCH_KEYS); +} + +export function mergeWorkspaceRouteSearch>( + previous: T, + nextSearch: Partial, +): T & WorkspaceRouteSearch { + return { + ...clearWorkspaceRouteSearch(previous), + ...nextSearch, + }; +} + +export function parseWorkspaceRouteSearch(search: Record): WorkspaceRouteSearch { + const next: WorkspaceRouteSearch = { ...search }; + const panel = normalizeWorkspaceRouteSearchString(search.panel); + + if (panel) { + next.panel = panel; + } else { + delete next.panel; + } + + return next; +}