From bfed571dd024e98c1477cc33a1a070ef752e8a50 Mon Sep 17 00:00:00 2001 From: Andrea Coiro Date: Wed, 11 Mar 2026 10:23:25 +0100 Subject: [PATCH 1/8] Add browser shell sidebar and per-thread panel state --- apps/server/src/keybindings.ts | 3 + apps/web/src/browser.ts | 72 ++++ apps/web/src/browserStateStore.test.ts | 53 +++ apps/web/src/browserStateStore.ts | 177 +++++++++ apps/web/src/components/BrowserPanel.tsx | 416 ++++++++++++++++++++ apps/web/src/components/ChatView.tsx | 140 ++++--- apps/web/src/diffRouteSearch.ts | 4 +- apps/web/src/index.css | 9 + apps/web/src/keybindings.test.ts | 68 ++++ apps/web/src/keybindings.ts | 8 + apps/web/src/rightPanelStateStore.ts | 127 +++++++ apps/web/src/routes/__root.tsx | 12 + apps/web/src/routes/_chat.$threadId.tsx | 417 ++++++++++++++++++--- packages/contracts/src/keybindings.test.ts | 18 + packages/contracts/src/keybindings.ts | 3 + 15 files changed, 1427 insertions(+), 100 deletions(-) create mode 100644 apps/web/src/browser.ts create mode 100644 apps/web/src/browserStateStore.test.ts create mode 100644 apps/web/src/browserStateStore.ts create mode 100644 apps/web/src/components/BrowserPanel.tsx create mode 100644 apps/web/src/rightPanelStateStore.ts diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index bf5846782..d4b3548b5 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -70,6 +70,9 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+b", command: "browser.toggle", when: "!terminalFocus" }, + { key: "mod+t", command: "browser.newTab", when: "!terminalFocus" }, + { key: "mod+w", command: "browser.closeTab", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, diff --git a/apps/web/src/browser.ts b/apps/web/src/browser.ts new file mode 100644 index 000000000..a97b015d1 --- /dev/null +++ b/apps/web/src/browser.ts @@ -0,0 +1,72 @@ +import { randomUUID } from "./lib/utils"; + +export type BrowserTab = { + id: string; + url: string; + title?: string | null; + faviconUrl?: string | null; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + lastError?: string | null; +}; + +export type BrowserUrlParseResult = { ok: true; url: string } | { ok: false; error: string }; + +const EXPLICIT_SCHEME_PATTERN = /^[A-Za-z][A-Za-z\d+.-]*:\/\//; + +export function createBrowserTab(url = "about:blank"): BrowserTab { + return { + id: `browser-tab-${randomUUID()}`, + url, + title: null, + faviconUrl: null, + isLoading: false, + canGoBack: false, + canGoForward: false, + lastError: null, + }; +} + +export function normalizeBrowserDisplayUrl(url: string | null | undefined): string { + if (!url || url === "about:blank") { + return ""; + } + return url; +} + +export function getBrowserTabLabel(tab: Pick): string { + const title = tab.title?.trim(); + if (title) { + return title; + } + if (tab.url === "about:blank") { + return "New tab"; + } + + try { + const parsed = new URL(tab.url); + return parsed.host || parsed.href; + } catch { + return tab.url; + } +} + +export function parseSubmittedBrowserUrl(rawValue: string): BrowserUrlParseResult { + const trimmed = rawValue.trim(); + if (!trimmed) { + return { ok: true, url: "about:blank" }; + } + + if (trimmed === "about:blank") { + return { ok: true, url: trimmed }; + } + + const candidate = EXPLICIT_SCHEME_PATTERN.test(trimmed) ? trimmed : `http://${trimmed}`; + + try { + return { ok: true, url: new URL(candidate).toString() }; + } catch { + return { ok: false, error: "Enter a valid URL." }; + } +} diff --git a/apps/web/src/browserStateStore.test.ts b/apps/web/src/browserStateStore.test.ts new file mode 100644 index 000000000..f3bb702ba --- /dev/null +++ b/apps/web/src/browserStateStore.test.ts @@ -0,0 +1,53 @@ +import { ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { createBrowserTab } from "./browser"; +import { selectThreadBrowserState, useBrowserStateStore } from "./browserStateStore"; + +const THREAD_ID = ThreadId.makeUnsafe("thread-1"); + +describe("browserStateStore actions", () => { + beforeEach(() => { + if (typeof localStorage !== "undefined") { + localStorage.clear(); + } + useBrowserStateStore.setState({ browserStateByThreadId: {} }); + }); + + it("returns an empty default state for unknown threads", () => { + const browserState = selectThreadBrowserState( + useBrowserStateStore.getState().browserStateByThreadId, + THREAD_ID, + ); + expect(browserState).toEqual({ + activeTabId: null, + tabs: [], + inputValue: "", + focusRequestId: 0, + }); + }); + + it("does not rewrite state for no-op updates", () => { + const tab = { ...createBrowserTab("http://localhost:3000"), id: "tab-1" }; + useBrowserStateStore.setState({ + browserStateByThreadId: { + [THREAD_ID]: { + activeTabId: tab.id, + tabs: [tab], + inputValue: tab.url, + focusRequestId: 0, + }, + }, + }); + + const beforeMap = useBrowserStateStore.getState().browserStateByThreadId; + const beforeEntry = beforeMap[THREAD_ID]; + useBrowserStateStore.getState().updateThreadBrowserState(THREAD_ID, (state) => state); + const afterMap = useBrowserStateStore.getState().browserStateByThreadId; + const afterEntry = afterMap[THREAD_ID]; + + expect(afterMap).toBe(beforeMap); + expect(afterEntry).toBe(beforeEntry); + expect(afterEntry?.tabs).toBe(beforeEntry?.tabs); + }); +}); diff --git a/apps/web/src/browserStateStore.ts b/apps/web/src/browserStateStore.ts new file mode 100644 index 000000000..9f3302059 --- /dev/null +++ b/apps/web/src/browserStateStore.ts @@ -0,0 +1,177 @@ +import type { ThreadId } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { type BrowserTab } from "./browser"; + +export interface ThreadBrowserState { + activeTabId: string | null; + tabs: BrowserTab[]; + inputValue: string; + focusRequestId: number; +} + +const BROWSER_STATE_STORAGE_KEY = "t3code:browser-state:v1"; + +const DEFAULT_THREAD_BROWSER_STATE: ThreadBrowserState = Object.freeze({ + activeTabId: null, + tabs: [], + inputValue: "", + focusRequestId: 0, +}); + +function createDefaultThreadBrowserState(): ThreadBrowserState { + return { + ...DEFAULT_THREAD_BROWSER_STATE, + tabs: [], + }; +} + +function threadBrowserStateEqual(left: ThreadBrowserState, right: ThreadBrowserState): boolean { + return ( + left.activeTabId === right.activeTabId && + left.inputValue === right.inputValue && + left.focusRequestId === right.focusRequestId && + left.tabs === right.tabs + ); +} + +function isValidBrowserTab(tab: BrowserTab): boolean { + return tab.id.trim().length > 0 && typeof tab.url === "string" && tab.url.length > 0; +} + +function normalizeThreadBrowserState(state: ThreadBrowserState): ThreadBrowserState { + let tabsChanged = false; + const nextTabs: BrowserTab[] = []; + for (const tab of state.tabs) { + if (!isValidBrowserTab(tab)) { + tabsChanged = true; + continue; + } + nextTabs.push(tab); + } + const tabs = tabsChanged ? nextTabs : state.tabs; + const activeTabId = + state.activeTabId && tabs.some((tab) => tab.id === state.activeTabId) + ? state.activeTabId + : (tabs[0]?.id ?? null); + const normalized: ThreadBrowserState = { + activeTabId, + tabs, + inputValue: state.inputValue, + focusRequestId: + Number.isFinite(state.focusRequestId) && state.focusRequestId > 0 + ? Math.trunc(state.focusRequestId) + : 0, + }; + return threadBrowserStateEqual(state, normalized) ? state : normalized; +} + +function isDefaultThreadBrowserState(state: ThreadBrowserState): boolean { + const normalized = normalizeThreadBrowserState(state); + return ( + normalized.activeTabId === DEFAULT_THREAD_BROWSER_STATE.activeTabId && + normalized.inputValue === DEFAULT_THREAD_BROWSER_STATE.inputValue && + normalized.focusRequestId === DEFAULT_THREAD_BROWSER_STATE.focusRequestId && + normalized.tabs.length === 0 + ); +} + +export function selectThreadBrowserState( + browserStateByThreadId: Record, + threadId: ThreadId, +): ThreadBrowserState { + if (threadId.length === 0) { + return DEFAULT_THREAD_BROWSER_STATE; + } + return browserStateByThreadId[threadId] ?? DEFAULT_THREAD_BROWSER_STATE; +} + +function updateBrowserStateByThreadId( + browserStateByThreadId: Record, + threadId: ThreadId, + updater: (state: ThreadBrowserState) => ThreadBrowserState, +): Record { + if (threadId.length === 0) { + return browserStateByThreadId; + } + + const current = selectThreadBrowserState(browserStateByThreadId, threadId); + const next = normalizeThreadBrowserState(updater(current)); + if (next === current || threadBrowserStateEqual(current, next)) { + return browserStateByThreadId; + } + + if (isDefaultThreadBrowserState(next)) { + if (browserStateByThreadId[threadId] === undefined) { + return browserStateByThreadId; + } + const { [threadId]: _removed, ...rest } = browserStateByThreadId; + return rest as Record; + } + + return { + ...browserStateByThreadId, + [threadId]: next, + }; +} + +interface BrowserStateStoreState { + browserStateByThreadId: Record; + updateThreadBrowserState: ( + threadId: ThreadId, + updater: (state: ThreadBrowserState) => ThreadBrowserState, + ) => void; + removeOrphanedBrowserStates: (activeThreadIds: Set) => void; + clearBrowserState: (threadId: ThreadId) => void; +} + +export const useBrowserStateStore = create()( + persist( + (set) => ({ + browserStateByThreadId: {}, + updateThreadBrowserState: (threadId, updater) => + set((state) => { + const nextBrowserStateByThreadId = updateBrowserStateByThreadId( + state.browserStateByThreadId, + threadId, + updater, + ); + if (nextBrowserStateByThreadId === state.browserStateByThreadId) { + return state; + } + return { browserStateByThreadId: nextBrowserStateByThreadId }; + }), + removeOrphanedBrowserStates: (activeThreadIds) => + set((state) => { + const orphanedIds = Object.keys(state.browserStateByThreadId).filter( + (id) => !activeThreadIds.has(id as ThreadId), + ); + if (orphanedIds.length === 0) { + return state; + } + const next = { ...state.browserStateByThreadId }; + for (const id of orphanedIds) { + delete next[id as ThreadId]; + } + return { browserStateByThreadId: next }; + }), + clearBrowserState: (threadId) => + set((state) => ({ + browserStateByThreadId: updateBrowserStateByThreadId( + state.browserStateByThreadId, + threadId, + () => createDefaultThreadBrowserState(), + ), + })), + }), + { + name: BROWSER_STATE_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ + browserStateByThreadId: state.browserStateByThreadId, + }), + }, + ), +); diff --git a/apps/web/src/components/BrowserPanel.tsx b/apps/web/src/components/BrowserPanel.tsx new file mode 100644 index 000000000..c59e0c3e4 --- /dev/null +++ b/apps/web/src/components/BrowserPanel.tsx @@ -0,0 +1,416 @@ +"use client"; + +import { + ArrowLeftIcon, + ArrowRightIcon, + ExternalLinkIcon, + GlobeIcon, + PlusIcon, + RefreshCwIcon, + XIcon, +} from "lucide-react"; +import { + memo, + useEffect, + useLayoutEffect, + useRef, + useState, + type FormEvent, + type ReactNode, +} from "react"; + +import { getBrowserTabLabel, type BrowserTab } from "../browser"; +import { isElectron } from "../env"; +import { cn } from "~/lib/utils"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +interface BrowserPanelProps { + state: { activeTabId: string | null; tabs: BrowserTab[] }; + activeTab: BrowserTab | null; + inputValue: string; + focusRequestId: number; + newTabShortcutLabel?: string | null; + closeTabShortcutLabel?: string | null; + onInputChange: (value: string) => void; + onCreateTab: () => void; + onActivateTab: (tabId: string) => void; + onCloseTab: (tabId: string) => void; + onSubmit: () => void; + onBack: () => void; + onForward: () => void; + onReload: () => void; + onOpenExternal: () => void; + viewportRef?: (el: HTMLDivElement | null) => void; +} + +type TabIconProps = { + tab: BrowserTab; +}; + +type ToolbarIconButtonProps = { + ariaLabel: string; + children: ReactNode; + disabled?: boolean; + onClick: () => void; + tooltip: string; +}; + +const TAB_SCROLLBAR_CLASS = "browser-panel-tab-strip"; + +const BrowserTabIcon = memo(function BrowserTabIcon({ tab }: TabIconProps) { + const [faviconFailed, setFaviconFailed] = useState(false); + + useEffect(() => { + setFaviconFailed(false); + }, [tab.faviconUrl]); + + if (tab.isLoading) { + return ; + } + + if (tab.faviconUrl && !faviconFailed) { + return ( + { + setFaviconFailed(true); + }} + /> + ); + } + + return ; +}); + +function BrowserTabDivider({ visible }: { visible: boolean }) { + return ( +
+
+
+ ); +} + +function ToolbarIconButton({ + ariaLabel, + children, + disabled = false, + onClick, + tooltip, +}: ToolbarIconButtonProps) { + return ( + + + + + } + /> + {tooltip} + + ); +} + +export default function BrowserPanel({ + state, + activeTab, + inputValue, + focusRequestId, + newTabShortcutLabel, + closeTabShortcutLabel, + onInputChange, + onCreateTab, + onActivateTab, + onCloseTab, + onSubmit, + onBack, + onForward, + onReload, + onOpenExternal, + viewportRef, +}: BrowserPanelProps) { + const tabStripRef = useRef(null); + const activeTabRef = useRef(null); + const inputRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + + useLayoutEffect(() => { + const strip = tabStripRef.current; + if (!strip) { + return; + } + + const updateOverflow = () => { + setIsOverflowing(strip.scrollWidth > strip.clientWidth + 1); + }; + + updateOverflow(); + + const observer = new ResizeObserver(() => { + updateOverflow(); + }); + + observer.observe(strip); + return () => { + observer.disconnect(); + }; + }, [state.tabs.length]); + + useLayoutEffect(() => { + const strip = tabStripRef.current; + const activeButton = activeTabRef.current; + if (!strip || !activeButton) { + return; + } + + const stripRect = strip.getBoundingClientRect(); + const activeRect = activeButton.getBoundingClientRect(); + const isFullyVisible = activeRect.left >= stripRect.left && activeRect.right <= stripRect.right; + + if (!isFullyVisible) { + activeButton.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "nearest", + }); + } + }, [state.activeTabId, state.tabs.length]); + + useEffect(() => { + const input = inputRef.current; + if (!input) { + return; + } + input.focus(); + input.select(); + }, [focusRequestId]); + + const closeTooltip = closeTabShortcutLabel ? `Close tab (${closeTabShortcutLabel})` : "Close tab"; + const newTabTooltip = newTabShortcutLabel ? `New tab (${newTabShortcutLabel})` : "New tab"; + const lastError = activeTab?.lastError?.trim() || null; + const showEmptyState = !activeTab || activeTab.url === "about:blank"; + const topBarClassName = cn( + "relative flex h-[52px] min-h-[52px] items-end bg-background/70 px-3", + isElectron && "drag-region", + ); + const controlsClassName = cn( + "flex h-full min-w-0 flex-1 items-end pt-2", + isElectron && "[-webkit-app-region:no-drag]", + ); + const navRowClassName = cn( + "flex h-11 min-h-11 items-center gap-1.5 border-border/80 border-b bg-card/94 px-2.5", + isElectron && "[-webkit-app-region:no-drag]", + ); + + return ( +
+
+
+
+
+
+ {state.tabs.map((tab, index) => { + const previousTab = index > 0 ? (state.tabs[index - 1] ?? null) : null; + const isActive = tab.id === state.activeTabId; + const showDivider = + previousTab !== null && + previousTab.id !== state.activeTabId && + tab.id !== state.activeTabId; + + return ( +
+ {index > 0 ? : null} +
+ + + { + event.stopPropagation(); + onCloseTab(tab.id); + }} + > + + + } + /> + {closeTooltip} + +
+
+ ); + })} + {!isOverflowing ? ( + + + + + } + /> + {newTabTooltip} + + ) : null} +
+ {isOverflowing ? ( +
+
+ + + + + } + /> + {newTabTooltip} + +
+ ) : null} +
+
+
+ +
) => { + event.preventDefault(); + onSubmit(); + }} + > + + + + + + + + + +
+ { + onInputChange(event.target.value); + }} + placeholder="http://localhost:3000" + autoCapitalize="none" + autoCorrect="off" + autoComplete="off" + spellCheck={false} + inputMode="url" + nativeInput + /> +
+ + + +
+ +
+
+ {showEmptyState ? ( +
+ Enter a URL to preview a local app or external site. +
+ ) : null} + {lastError ? ( +
+ {lastError} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c8a0a152..ed8da5804 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -138,6 +138,7 @@ import { FileIcon, FolderIcon, DiffIcon, + GlobeIcon, EllipsisIcon, FolderClosedIcon, ListTodoIcon, @@ -199,7 +200,7 @@ import { projectScriptIdFromCommand, setupProjectScript, } from "~/projectScripts"; -import { Toggle } from "./ui/toggle"; +import { Toggle, ToggleGroup } from "./ui/toggle-group"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; @@ -213,6 +214,7 @@ import { useComposerThreadDraft, } from "../composerDraftStore"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; +import { selectThreadRightPanelState, useRightPanelStateStore } from "../rightPanelStateStore"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { clamp } from "effect/Number"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; @@ -587,6 +589,10 @@ export default function ChatView({ threadId }: ChatViewProps) { strict: false, select: (params) => parseDiffRouteSearch(params), }); + const rightPanelState = useRightPanelStateStore((state) => + selectThreadRightPanelState(state.rightPanelStateByThreadId, threadId), + ); + const setSelectedPanel = useRightPanelStateStore((state) => state.setSelectedPanel); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); @@ -759,7 +765,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; - const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); @@ -1393,17 +1398,30 @@ export default function ChatView({ threadId }: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "diff.toggle"), [keybindings], ); + const browserPanelShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "browser.toggle"), + [keybindings], + ); + const forcedSelectedSidePanel = rawSearch.diff === "1" || rawSearch.diffTurnId ? "diff" : null; + const selectedSidePanel = forcedSelectedSidePanel ?? rightPanelState.selectedPanel; + const onSelectSidePanel = useCallback( + (panel: "diff" | "browser" | null) => { + setSelectedPanel(threadId, panel); + void navigate({ + to: "/$threadId", + params: { threadId }, + replace: true, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return rest; + }, + }); + }, + [navigate, setSelectedPanel, threadId], + ); const onToggleDiff = useCallback(() => { - void navigate({ - to: "/$threadId", - params: { threadId }, - replace: true, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return diffOpen ? rest : { ...rest, diff: "1" }; - }, - }); - }, [diffOpen, navigate, threadId]); + onSelectSidePanel(selectedSidePanel === "diff" ? null : "diff"); + }, [onSelectSidePanel, selectedSidePanel]); const envLocked = Boolean( activeThread && @@ -2380,6 +2398,13 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } + if (command === "browser.toggle") { + event.preventDefault(); + event.stopPropagation(); + onSelectSidePanel(selectedSidePanel === "browser" ? null : "browser"); + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -2401,7 +2426,9 @@ export default function ChatView({ threadId }: ChatViewProps) { runProjectScript, splitTerminal, keybindings, + onSelectSidePanel, onToggleDiff, + selectedSidePanel, toggleTerminalVisibility, ]); @@ -3494,6 +3521,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { + setSelectedPanel(threadId, "diff"); void navigate({ to: "/$threadId", params: { threadId }, @@ -3505,7 +3533,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, }); }, - [navigate, threadId], + [navigate, setSelectedPanel, threadId], ); const onRevertUserMessage = (messageId: MessageId) => { const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); @@ -3563,15 +3591,16 @@ export default function ChatView({ threadId }: ChatViewProps) { keybindings={keybindings} availableEditors={availableEditors} diffToggleShortcutLabel={diffPanelShortcutLabel} + browserToggleShortcutLabel={browserPanelShortcutLabel} gitCwd={gitCwd} - diffOpen={diffOpen} + selectedSidePanel={selectedSidePanel} onRunProjectScript={(script) => { void runProjectScript(script); }} onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} - onToggleDiff={onToggleDiff} + onSelectSidePanel={onSelectSidePanel} /> @@ -4264,13 +4293,14 @@ interface ChatHeaderProps { keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; diffToggleShortcutLabel: string | null; + browserToggleShortcutLabel: string | null; gitCwd: string | null; - diffOpen: boolean; + selectedSidePanel: "diff" | "browser" | null; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; - onToggleDiff: () => void; + onSelectSidePanel: (panel: "diff" | "browser" | null) => void; } const ChatHeader = memo(function ChatHeader({ @@ -4284,13 +4314,14 @@ const ChatHeader = memo(function ChatHeader({ keybindings, availableEditors, diffToggleShortcutLabel, + browserToggleShortcutLabel, gitCwd, - diffOpen, + selectedSidePanel, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript, - onToggleDiff, + onSelectSidePanel, }: ChatHeaderProps) { return (
@@ -4333,30 +4364,53 @@ const ChatHeader = memo(function ChatHeader({ /> )} {activeProjectName && } - - - - - } - /> - - {!isGitRepo - ? "Diff panel is unavailable because this project is not a git repository." - : diffToggleShortcutLabel - ? `Toggle diff panel (${diffToggleShortcutLabel})` - : "Toggle diff panel"} - - + + + { + onSelectSidePanel(pressed ? "diff" : null); + }} + aria-label="Toggle diff panel" + disabled={!isGitRepo} + > + + + } + /> + + {!isGitRepo + ? "Diff panel is unavailable because this project is not a git repository." + : diffToggleShortcutLabel + ? `Toggle diff panel (${diffToggleShortcutLabel})` + : "Toggle diff panel"} + + + + { + onSelectSidePanel(pressed ? "browser" : null); + }} + aria-label="Toggle in-app browser" + > + + + } + /> + + {browserToggleShortcutLabel + ? `Toggle in-app browser (${browserToggleShortcutLabel})` + : "Toggle in-app browser"} + + +
); diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts index f310b74b7..b12224ee0 100644 --- a/apps/web/src/diffRouteSearch.ts +++ b/apps/web/src/diffRouteSearch.ts @@ -27,9 +27,9 @@ export function stripDiffSearchParams>( export function parseDiffRouteSearch(search: Record): DiffRouteSearch { const diff = isDiffOpenValue(search.diff) ? "1" : undefined; - const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; + const diffTurnIdRaw = normalizeSearchString(search.diffTurnId); const diffTurnId = diffTurnIdRaw ? TurnId.makeUnsafe(diffTurnIdRaw) : undefined; - const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; + const diffFilePath = diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; return { ...(diff ? { diff } : {}), diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 228179161..18e96e11b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -212,6 +212,15 @@ input { display: none; } +.browser-panel-tab-strip { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.browser-panel-tab-strip::-webkit-scrollbar { + display: none; +} + /* Terminal drawer scrollbar parity with chat */ .thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical { width: 6px !important; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 0ecccf43f..04ac6a878 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -97,6 +97,21 @@ const DEFAULT_BINDINGS = compile([ command: "diff.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { + shortcut: modShortcut("b"), + command: "browser.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { + shortcut: modShortcut("t"), + command: "browser.newTab", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { + shortcut: modShortcut("w"), + command: "browser.closeTab", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, @@ -237,6 +252,18 @@ describe("shortcutLabelForCommand", () => { it("returns labels for non-terminal commands", () => { assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "browser.toggle", "Linux"), + "Ctrl+B", + ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "browser.newTab", "Linux"), + "Ctrl+T", + ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "browser.closeTab", "Linux"), + "Ctrl+W", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), "Ctrl+O", @@ -298,6 +325,47 @@ describe("chat/editor shortcuts", () => { }), ); }); + + it("matches browser.toggle shortcut outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "browser.toggle", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "browser.toggle", + ); + }); + + it("matches browser tab shortcuts outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "t", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "browser.newTab", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "w", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "browser.closeTab", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "w", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "browser.closeTab", + ); + }); }); describe("cross-command precedence", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 09d9308aa..710c490b2 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -206,6 +206,14 @@ export function isDiffToggleShortcut( return matchesCommandShortcut(event, keybindings, "diff.toggle", options); } +export function isBrowserToggleShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "browser.toggle", options); +} + export function isChatNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/rightPanelStateStore.ts b/apps/web/src/rightPanelStateStore.ts new file mode 100644 index 000000000..28914d07a --- /dev/null +++ b/apps/web/src/rightPanelStateStore.ts @@ -0,0 +1,127 @@ +import type { ThreadId } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export type RightPanelKind = "diff" | "browser"; + +export interface ThreadRightPanelState { + selectedPanel: RightPanelKind | null; + lastSelectedPanel: RightPanelKind; +} + +const RIGHT_PANEL_STATE_STORAGE_KEY = "t3code:right-panel-state:v1"; + +const DEFAULT_THREAD_RIGHT_PANEL_STATE: ThreadRightPanelState = Object.freeze({ + selectedPanel: null, + lastSelectedPanel: "diff", +}); + +function normalizeThreadRightPanelState(state: ThreadRightPanelState): ThreadRightPanelState { + const selectedPanel = + state.selectedPanel === "diff" || state.selectedPanel === "browser" + ? state.selectedPanel + : null; + const lastSelectedPanel = state.lastSelectedPanel === "browser" ? "browser" : "diff"; + if (selectedPanel === state.selectedPanel && lastSelectedPanel === state.lastSelectedPanel) { + return state; + } + return { selectedPanel, lastSelectedPanel }; +} + +function isDefaultThreadRightPanelState(state: ThreadRightPanelState): boolean { + const normalized = normalizeThreadRightPanelState(state); + return ( + normalized.selectedPanel === DEFAULT_THREAD_RIGHT_PANEL_STATE.selectedPanel && + normalized.lastSelectedPanel === DEFAULT_THREAD_RIGHT_PANEL_STATE.lastSelectedPanel + ); +} + +export function selectThreadRightPanelState( + rightPanelStateByThreadId: Record, + threadId: ThreadId, +): ThreadRightPanelState { + if (threadId.length === 0) { + return DEFAULT_THREAD_RIGHT_PANEL_STATE; + } + return rightPanelStateByThreadId[threadId] ?? DEFAULT_THREAD_RIGHT_PANEL_STATE; +} + +function updateRightPanelStateByThreadId( + rightPanelStateByThreadId: Record, + threadId: ThreadId, + updater: (state: ThreadRightPanelState) => ThreadRightPanelState, +): Record { + if (threadId.length === 0) { + return rightPanelStateByThreadId; + } + + const current = selectThreadRightPanelState(rightPanelStateByThreadId, threadId); + const next = normalizeThreadRightPanelState(updater(current)); + if ( + next === current || + (next.selectedPanel === current.selectedPanel && + next.lastSelectedPanel === current.lastSelectedPanel) + ) { + return rightPanelStateByThreadId; + } + + if (isDefaultThreadRightPanelState(next)) { + if (rightPanelStateByThreadId[threadId] === undefined) { + return rightPanelStateByThreadId; + } + const { [threadId]: _removed, ...rest } = rightPanelStateByThreadId; + return rest as Record; + } + + return { + ...rightPanelStateByThreadId, + [threadId]: next, + }; +} + +interface RightPanelStateStoreState { + rightPanelStateByThreadId: Record; + setSelectedPanel: (threadId: ThreadId, panel: RightPanelKind | null) => void; + removeOrphanedRightPanelStates: (activeThreadIds: Set) => void; +} + +export const useRightPanelStateStore = create()( + persist( + (set) => ({ + rightPanelStateByThreadId: {}, + setSelectedPanel: (threadId, panel) => + set((state) => ({ + rightPanelStateByThreadId: updateRightPanelStateByThreadId( + state.rightPanelStateByThreadId, + threadId, + (current) => ({ + selectedPanel: panel, + lastSelectedPanel: panel ?? current.lastSelectedPanel, + }), + ), + })), + removeOrphanedRightPanelStates: (activeThreadIds) => + set((state) => { + const orphanedIds = Object.keys(state.rightPanelStateByThreadId).filter( + (id) => !activeThreadIds.has(id as ThreadId), + ); + if (orphanedIds.length === 0) { + return state; + } + const next = { ...state.rightPanelStateByThreadId }; + for (const id of orphanedIds) { + delete next[id as ThreadId]; + } + return { rightPanelStateByThreadId: next }; + }), + }), + { + name: RIGHT_PANEL_STATE_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ + rightPanelStateByThreadId: state.rightPanelStateByThreadId, + }), + }, + ), +); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 2fe07505f..fd643a2fe 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,6 +17,8 @@ import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQue import { readNativeApi } from "../nativeApi"; import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; import { useStore } from "../store"; +import { useBrowserStateStore } from "../browserStateStore"; +import { useRightPanelStateStore } from "../rightPanelStateStore"; import { useTerminalStateStore } from "../terminalStateStore"; import { preferredTerminalEditor } from "../terminal-links"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; @@ -133,6 +135,12 @@ function errorDetails(error: unknown): string { function EventRouter() { const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setProjectExpanded = useStore((store) => store.setProjectExpanded); + const removeOrphanedBrowserStates = useBrowserStateStore( + (store) => store.removeOrphanedBrowserStates, + ); + const removeOrphanedRightPanelStates = useRightPanelStateStore( + (store) => store.removeOrphanedRightPanelStates, + ); const removeOrphanedTerminalStates = useTerminalStateStore( (store) => store.removeOrphanedTerminalStates, ); @@ -166,6 +174,8 @@ function EventRouter() { snapshotThreads: snapshot.threads, draftThreadIds, }); + removeOrphanedBrowserStates(activeThreadIds); + removeOrphanedRightPanelStates(activeThreadIds); removeOrphanedTerminalStates(activeThreadIds); if (pending) { pending = false; @@ -309,6 +319,8 @@ function EventRouter() { }, [ navigate, queryClient, + removeOrphanedBrowserStates, + removeOrphanedRightPanelStates, removeOrphanedTerminalStates, setProjectExpanded, syncServerReadModel, diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index b85aeab0d..46424030b 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -1,8 +1,25 @@ -import { ThreadId } from "@t3tools/contracts"; -import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; -import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; +import { type ResolvedKeybindingsConfig, ThreadId } from "@t3tools/contracts"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { + Suspense, + lazy, + type CSSProperties, + type ReactNode, + useCallback, + useEffect, + useMemo, +} from "react"; import ChatView from "../components/ChatView"; +import { + createBrowserTab, + getBrowserTabLabel, + normalizeBrowserDisplayUrl, + parseSubmittedBrowserUrl, +} from "../browser"; +import { selectThreadBrowserState, useBrowserStateStore } from "../browserStateStore"; +import BrowserPanel from "../components/BrowserPanel"; import { useComposerDraftStore } from "../composerDraftStore"; import { type DiffRouteSearch, @@ -10,28 +27,44 @@ import { stripDiffSearchParams, } from "../diffRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { readNativeApi } from "../nativeApi"; +import { + selectThreadRightPanelState, + useRightPanelStateStore, + type RightPanelKind, +} from "../rightPanelStateStore"; import { useStore } from "../store"; 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 RIGHT_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_right_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 EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; + +function resolveSelectedSidePanel(search: DiffRouteSearch): RightPanelKind | null { + if (search.diff === "1" || search.diffTurnId) { + return "diff"; + } + return null; +} -const DiffPanelSheet = (props: { +const RightPanelSheet = (props: { children: ReactNode; - diffOpen: boolean; - onCloseDiff: () => void; + panelOpen: boolean; + onClosePanel: () => void; }) => { return ( { if (!open) { - props.onCloseDiff(); + props.onClosePanel(); } }} > @@ -47,37 +80,38 @@ const DiffPanelSheet = (props: { ); }; -const DiffLoadingFallback = (props: { inline: boolean }) => { +const RightPanelLoadingFallback = (props: { inline: boolean; label: string }) => { if (props.inline) { return (
- Loading diff viewer... + {props.label}
); } return ( ); }; -const DiffPanelInlineSidebar = (props: { - diffOpen: boolean; - onCloseDiff: () => void; - onOpenDiff: () => void; +const RightPanelInlineSidebar = (props: { + panelOpen: boolean; + onClosePanel: () => void; + onReopenPanel: () => void; + children: ReactNode; }) => { - const { diffOpen, onCloseDiff, onOpenDiff } = props; + const { panelOpen, onClosePanel, onReopenPanel } = props; const onOpenChange = useCallback( (open: boolean) => { if (open) { - onOpenDiff(); + onReopenPanel(); return; } - onCloseDiff(); + onClosePanel(); }, - [onCloseDiff, onOpenDiff], + [onClosePanel, onReopenPanel], ); const shouldAcceptInlineSidebarWidth = useCallback( ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { @@ -128,10 +162,10 @@ const DiffPanelInlineSidebar = (props: { return ( - }> - - + {props.children} @@ -164,9 +196,64 @@ function ChatThreadRouteView() { Object.hasOwn(store.draftThreadsByThreadId, threadId), ); const routeThreadExists = threadExists || draftThreadExists; - const diffOpen = search.diff === "1"; + const rightPanelState = useRightPanelStateStore((state) => + selectThreadRightPanelState(state.rightPanelStateByThreadId, threadId), + ); + const setSelectedPanel = useRightPanelStateStore((state) => state.setSelectedPanel); + const forcedSelectedPanel = resolveSelectedSidePanel(search); + const selectedPanel = forcedSelectedPanel ?? rightPanelState.selectedPanel; const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); - const closeDiff = useCallback(() => { + const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ + ...serverConfigQueryOptions(), + select: (config) => config.keybindings, + }); + const browserThreadState = useBrowserStateStore((state) => + selectThreadBrowserState(state.browserStateByThreadId, threadId), + ); + const updateThreadBrowserState = useBrowserStateStore((state) => state.updateThreadBrowserState); + const activeBrowserTab = useMemo( + () => browserThreadState.tabs.find((tab) => tab.id === browserThreadState.activeTabId) ?? null, + [browserThreadState.activeTabId, browserThreadState.tabs], + ); + + useEffect(() => { + if (forcedSelectedPanel !== "diff") { + return; + } + setSelectedPanel(threadId, "diff"); + }, [forcedSelectedPanel, setSelectedPanel, threadId]); + + useEffect(() => { + if (selectedPanel === "browser" && browserThreadState.tabs.length === 0) { + const initialTab = createBrowserTab(); + updateThreadBrowserState(threadId, (state) => ({ + ...state, + activeTabId: initialTab.id, + tabs: [initialTab], + focusRequestId: state.focusRequestId + 1, + })); + } + }, [browserThreadState.tabs.length, selectedPanel, threadId, updateThreadBrowserState]); + + useEffect(() => { + const nextInputValue = normalizeBrowserDisplayUrl(activeBrowserTab?.url); + updateThreadBrowserState(threadId, (state) => + state.inputValue === nextInputValue ? state : { ...state, inputValue: nextInputValue }, + ); + }, [activeBrowserTab?.id, activeBrowserTab?.url, threadId, updateThreadBrowserState]); + + useEffect(() => { + if (!threadsHydrated) { + return; + } + + if (!routeThreadExists) { + void navigate({ to: "/", replace: true }); + } + }, [navigate, routeThreadExists, threadsHydrated]); + + const closePanel = useCallback(() => { + setSelectedPanel(threadId, null); void navigate({ to: "/$threadId", params: { threadId }, @@ -174,40 +261,234 @@ function ChatThreadRouteView() { return stripDiffSearchParams(previous); }, }); - }, [navigate, threadId]); - const openDiff = useCallback(() => { - void navigate({ - to: "/$threadId", - params: { threadId }, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; - }, - }); - }, [navigate, threadId]); + }, [navigate, setSelectedPanel, threadId]); + const openPanel = useCallback( + (panel: RightPanelKind) => { + setSelectedPanel(threadId, panel); + if (panel === "browser") { + updateThreadBrowserState(threadId, (state) => ({ + ...state, + focusRequestId: state.focusRequestId + 1, + })); + } + void navigate({ + to: "/$threadId", + params: { threadId }, + search: (previous) => { + return stripDiffSearchParams(previous); + }, + }); + }, + [navigate, setSelectedPanel, threadId, updateThreadBrowserState], + ); + const reopenPanel = useCallback(() => { + openPanel(rightPanelState.lastSelectedPanel); + }, [openPanel, rightPanelState.lastSelectedPanel]); + const createTab = useCallback(() => { + const nextTab = createBrowserTab(); + updateThreadBrowserState(threadId, (state) => ({ + ...state, + activeTabId: nextTab.id, + tabs: [...state.tabs, nextTab], + inputValue: "", + focusRequestId: state.focusRequestId + 1, + })); + }, [threadId, updateThreadBrowserState]); + const activateTab = useCallback( + (tabId: string) => { + updateThreadBrowserState(threadId, (state) => + state.activeTabId === tabId ? state : { ...state, activeTabId: tabId }, + ); + }, + [threadId, updateThreadBrowserState], + ); + const closeTab = useCallback( + (tabId: string) => { + updateThreadBrowserState(threadId, (state) => { + const closedIndex = state.tabs.findIndex((tab) => tab.id === tabId); + if (closedIndex < 0) { + return state; + } + const tabs = state.tabs.filter((tab) => tab.id !== tabId); + const activeTabId = + state.activeTabId === tabId + ? (tabs[closedIndex]?.id ?? tabs[closedIndex - 1]?.id ?? null) + : state.activeTabId; + return { ...state, activeTabId, tabs }; + }); + }, + [threadId, updateThreadBrowserState], + ); + const submitBrowserInput = useCallback(() => { + const parsedUrl = parseSubmittedBrowserUrl(browserThreadState.inputValue); + updateThreadBrowserState(threadId, (state) => { + if (!parsedUrl.ok) { + if (!state.activeTabId) { + return { + ...state, + focusRequestId: state.focusRequestId + 1, + }; + } + return { + ...state, + focusRequestId: state.focusRequestId + 1, + tabs: state.tabs.map((tab) => + tab.id === state.activeTabId ? { ...tab, lastError: parsedUrl.error } : tab, + ), + }; + } - useEffect(() => { - if (!threadsHydrated) { - return; - } + const nextInputValue = normalizeBrowserDisplayUrl(parsedUrl.url); + if (!state.activeTabId) { + const nextTab = createBrowserTab(parsedUrl.url); + return { + activeTabId: nextTab.id, + tabs: [ + { + ...nextTab, + title: + parsedUrl.url === "about:blank" + ? null + : getBrowserTabLabel({ title: null, url: parsedUrl.url }), + }, + ], + inputValue: nextInputValue, + focusRequestId: state.focusRequestId, + }; + } - if (!routeThreadExists) { - void navigate({ to: "/", replace: true }); + return { + ...state, + inputValue: nextInputValue, + tabs: state.tabs.map((tab) => + tab.id === state.activeTabId + ? { + ...tab, + url: parsedUrl.url, + title: + parsedUrl.url === "about:blank" + ? null + : getBrowserTabLabel({ title: null, url: parsedUrl.url }), + isLoading: false, + canGoBack: false, + canGoForward: false, + lastError: null, + } + : tab, + ), + }; + }); + }, [browserThreadState.inputValue, threadId, updateThreadBrowserState]); + const openActiveTabExternally = useCallback(() => { + const url = activeBrowserTab?.url; + const api = readNativeApi(); + if (!api || !url || url === "about:blank") { return; } - }, [navigate, routeThreadExists, threadsHydrated, threadId]); + void api.shell.openExternal(url).catch(() => undefined); + }, [activeBrowserTab?.url]); + const browserNewTabShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "browser.newTab"), + [keybindings], + ); + const browserCloseTabShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "browser.closeTab"), + [keybindings], + ); + + useEffect(() => { + const isTerminalFocused = (): boolean => { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) return false; + if (activeElement.classList.contains("xterm-helper-textarea")) return true; + return activeElement.closest(".thread-terminal-drawer .xterm") !== null; + }; + + const handler = (event: KeyboardEvent) => { + if (event.defaultPrevented) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: false, + }, + }); + + if (command === "browser.newTab") { + event.preventDefault(); + event.stopPropagation(); + createTab(); + if (selectedPanel !== "browser") { + openPanel("browser"); + } + return; + } + + if (command === "browser.closeTab") { + if (selectedPanel !== "browser" || !activeBrowserTab) { + return; + } + event.preventDefault(); + event.stopPropagation(); + closeTab(activeBrowserTab.id); + } + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [activeBrowserTab, closeTab, createTab, keybindings, openPanel, selectedPanel]); if (!threadsHydrated || !routeThreadExists) { return null; } + const rightPanelContent = + selectedPanel === null ? null : selectedPanel === "browser" ? ( + { + updateThreadBrowserState(threadId, (state) => + state.inputValue === value ? state : { ...state, inputValue: value }, + ); + }} + onCreateTab={createTab} + onActivateTab={activateTab} + onCloseTab={closeTab} + onSubmit={submitBrowserInput} + onBack={() => undefined} + onForward={() => undefined} + onReload={() => undefined} + onOpenExternal={openActiveTabExternally} + /> + ) : ( + }> + + + ); + if (!shouldUseDiffSheet) { return ( <> - + + {rightPanelContent} + ); } @@ -217,19 +498,45 @@ function ChatThreadRouteView() { - - }> - - - + + {selectedPanel === null ? null : selectedPanel === "browser" ? ( + { + updateThreadBrowserState(threadId, (state) => + state.inputValue === value ? state : { ...state, inputValue: value }, + ); + }} + onCreateTab={createTab} + onActivateTab={activateTab} + onCloseTab={closeTab} + onSubmit={submitBrowserInput} + onBack={() => undefined} + onForward={() => undefined} + onReload={() => undefined} + onOpenExternal={openActiveTabExternally} + /> + ) : ( + } + > + + + )} + ); } export const Route = createFileRoute("/_chat/$threadId")({ validateSearch: (search) => parseDiffRouteSearch(search), - search: { - middlewares: [retainSearchParams(["diff"])], - }, component: ChatThreadRouteView, }); diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 1b99362c5..66d368aae 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -41,6 +41,24 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedDiffToggle.command, "diff.toggle"); + const parsedBrowserToggle = yield* decode(KeybindingRule, { + key: "mod+b", + command: "browser.toggle", + }); + assert.strictEqual(parsedBrowserToggle.command, "browser.toggle"); + + const parsedBrowserNewTab = yield* decode(KeybindingRule, { + key: "mod+t", + command: "browser.newTab", + }); + assert.strictEqual(parsedBrowserNewTab.command, "browser.newTab"); + + const parsedBrowserCloseTab = yield* decode(KeybindingRule, { + key: "mod+w", + command: "browser.closeTab", + }); + assert.strictEqual(parsedBrowserCloseTab.command, "browser.closeTab"); + const parsedLocal = yield* decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 48821b182..e15602581 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -13,6 +13,9 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "diff.toggle", + "browser.toggle", + "browser.newTab", + "browser.closeTab", "chat.new", "chat.newLocal", "editor.openFavorite", From 68d9c4facf580bd70fedabd2d77e7805397307bc Mon Sep 17 00:00:00 2001 From: Andrea Coiro Date: Wed, 11 Mar 2026 11:32:16 +0100 Subject: [PATCH 2/8] Wire Electron browser runtime into browser panel --- .plans/18-browser-panel-shell-and-runtime.md | 603 +++++++++++++++++++ apps/desktop/src/browserManager.ts | 482 +++++++++++++++ apps/desktop/src/main.ts | 224 +++++++ apps/desktop/src/preload.ts | 28 + apps/web/src/blockingOverlayStore.ts | 19 + apps/web/src/components/BrowserPanel.tsx | 11 +- apps/web/src/components/ui/alert-dialog.tsx | 16 + apps/web/src/components/ui/dialog.tsx | 16 + apps/web/src/routes/__root.tsx | 42 ++ apps/web/src/routes/_chat.$threadId.tsx | 261 +++++++- apps/web/src/wsNativeApi.ts | 27 + packages/contracts/src/ipc.ts | 75 +++ 12 files changed, 1788 insertions(+), 16 deletions(-) create mode 100644 .plans/18-browser-panel-shell-and-runtime.md create mode 100644 apps/desktop/src/browserManager.ts create mode 100644 apps/web/src/blockingOverlayStore.ts diff --git a/.plans/18-browser-panel-shell-and-runtime.md b/.plans/18-browser-panel-shell-and-runtime.md new file mode 100644 index 000000000..8c989532f --- /dev/null +++ b/.plans/18-browser-panel-shell-and-runtime.md @@ -0,0 +1,603 @@ +# Plan: Rebuild Browser Panel Shell and Electron Runtime + +## Summary + +Recreate the in-app browser feature in two layers: + +1. A reusable browser shell in the web app that lives in the existing right-side panel. +2. A desktop-only Electron runtime that renders real browser content behind that shell using `WebContentsView`. + +This plan is intentionally reconstruction-oriented. A fresh session should be able to follow it and rebuild the same behavior, UI, ownership boundaries, and tradeoffs that exist in the current implementation. + +## Goals + +- Add a polished browser panel UI that shares the right panel with the diff viewer. +- Keep browser tabs and right-panel visibility scoped per thread and persisted. +- Support keyboard shortcuts for browser toggle/new tab/close tab. +- Render real browser content in Electron behind the shell. +- Keep the renderer as the owner of browser UI state. +- Keep Electron as the owner of live browser runtime state. +- Bound native resource usage with an LRU warm-tab budget. + +## Non-Goals + +- No browser runtime for plain web builds. +- No persistent page session restoration beyond shell metadata. +- No in-toolbar DevTools button. +- No attempt to preserve JS/history state for evicted tabs. + +## Final User Experience + +### Right Panel + +- The right panel can show either: + - diff + - browser +- Only one can be shown at a time. +- Diff and browser are toggled from a joined segmented control in the chat header. +- The selected right panel is remembered per thread. +- Closing and reopening the panel restores the thread's last selected panel. + +### Browser Shell + +- The browser panel has three vertical sections: + - top tab strip + - compact navigation row + - viewport area +- Tabs are horizontally scrollable and visually styled like a desktop in-app browser. +- The `+` button creates new tabs and remains visible when the strip overflows. +- The URL field is controlled and supports bare host input like `localhost:3000`. +- Invalid input shows a bottom error banner instead of corrupting tab state. +- Empty tabs show a centered empty state message. + +### Desktop Runtime + +- Real web content is rendered only in Electron. +- Only one native browser view is attached to the window at a time: + - active thread + - active browser tab + - browser panel visible +- Hidden tabs are not all kept live forever. +- A small global warm-tab cache keeps recently used tabs alive. +- Older hidden tabs are evicted and later restored by reloading their URL. + +## Architecture + +## Renderer Ownership + +The web app owns: + +- per-thread browser tabs +- active tab id +- URL input value +- focus request id +- right-panel selected state +- last selected right-panel state +- browser shell rendering +- browser tab metadata projected from native runtime: + - title + - favicon + - loading state + - history affordances + - last error + +## Electron Ownership + +The desktop app owns: + +- native browser instances (`WebContentsView`) +- real navigation/runtime state +- browser tab lifecycle for live native views +- viewport attachment and bounds +- LRU live-tab eviction +- forwarding tab runtime updates back to the renderer + +## Shared Boundary + +`packages/contracts/src/ipc.ts` defines the browser IPC contract used by: + +- Electron preload bridge +- Electron main process handlers +- web `NativeApi` + +The browser IPC surface needs: + +- `ensureTab` +- `navigate` +- `goBack` +- `goForward` +- `reload` +- `closeTab` +- `syncHost` +- `clearThread` +- `onEvent` + +The main browser event shape is a tab-state event carrying: + +- `threadId` +- `tabId` +- `url` +- `title` +- `faviconUrl` +- `isLoading` +- `canGoBack` +- `canGoForward` +- `lastError` + +## Phase 1: Shared Browser Shell Utilities + +Add `apps/web/src/browser.ts`. + +It should export: + +- `BrowserTab` type +- helper to create a new blank tab +- helper to normalize `about:blank` for the address bar +- helper to derive a tab label +- helper to parse submitted URLs into a success/error result + +Required parsing behavior: + +- blank input becomes `about:blank` +- exact `about:blank` stays unchanged +- already-schemed URLs are parsed as-is +- bare hosts like `localhost:3000`, `127.0.0.1:3000`, and `example.com` get `http://` +- malformed input returns `{ ok: false, error: "Enter a valid URL." }` + +This module exists to prevent route-local duplication and to keep parsing/label logic reusable. + +## Phase 2: Reusable Browser Panel Component + +Add `apps/web/src/components/BrowserPanel.tsx`. + +The component is presentation-focused and reusable. It accepts: + +- browser tab state +- active tab +- controlled input value +- focus request id +- tab callbacks +- browser action callbacks +- optional viewport ref +- optional shortcut labels for new/close tab + +### Layout + +- full-height vertical panel +- `52px` tab row +- `44px` nav row +- remaining height is viewport + +### Tab Strip Behavior + +- horizontally scrollable +- hidden scrollbar +- overflow tracked with `ResizeObserver` +- `useLayoutEffect` scrolls the active tab fully into view when selection changes or tabs are created +- tabs use: + - loading icon when loading + - favicon when available + - globe fallback otherwise +- close button and new-tab button have shortcut-aware tooltips +- active tab uses stronger foreground, filled surface, and visual connection to the row edge +- inactive tabs remain transparent +- inactive hover should not add a background fill +- divider slots between non-selected neighboring tabs must preserve layout and only toggle opacity + +### Final Tab Styling Details + +- remove real bottom border from the row container +- render a positioned bottom divider behind the tabs instead +- active tab should visually sit on top of that divider +- use `-mb-px` and a 1px downward translation so the active tab merges into the panel edge +- tab padding is `pl-3 pr-2` +- new-tab button is compact, square-ish, rounded, and slightly lifted +- sticky overflow new-tab container should not use a gradient background + +### Nav Row + +- buttons: + - back + - forward + - reload + - URL input + - open externally +- no DevTools toolbar button +- URL input should be compact and fully controlled +- when `focusRequestId` changes, focus and select all text + +### Viewport + +- include an `absolute inset-0` host div via `viewportRef` +- empty state shown for no active tab or `about:blank` +- bottom floating error banner when `lastError` exists + +### Electron Drag Regions + +Because the desktop app uses a hidden inset titlebar: + +- the top strip can remain inside a drag region +- interactive controls must be marked `no-drag` +- tabs and the `+` button must stay clickable in compact sidebar layouts + +## Phase 3: Per-Thread Browser Store + +Add `apps/web/src/browserStateStore.ts`. + +Use Zustand `persist(createJSONStorage(() => localStorage))`. + +State shape per thread: + +- `activeTabId` +- `tabs` +- `inputValue` +- `focusRequestId` + +Key requirements: + +- browser state is keyed by `threadId` +- switching threads restores that thread's browser tabs and input state +- reloads restore browser state +- orphaned thread entries can be removed centrally + +### Equality Requirement + +No-op updates must preserve identity. + +Do not always recreate the `tabs` array during normalization. If a caller updates a thread state with the same object, the store should not produce new references or unnecessary persisted writes. + +Add a focused regression test for this behavior. + +## Phase 4: Per-Thread Right Panel Store + +Add `apps/web/src/rightPanelStateStore.ts`. + +Also use persisted Zustand state keyed by `threadId`. + +State per thread: + +- `selectedPanel: "diff" | "browser" | null` +- `lastSelectedPanel: "diff" | "browser"` + +Behavior: + +- right-panel visibility is thread-owned +- browser visibility is restored when switching back to a thread +- diff visibility is also restored per thread +- reopening the panel from closed state restores the thread's last selected panel + +Diff deep-link payload should remain URL-based. Normal panel visibility should not. + +## Phase 5: Integrate Browser Shell Into Chat Route + +Update `apps/web/src/routes/_chat.$threadId.tsx`. + +The route should: + +- use the persisted browser store instead of local browser state +- use the persisted right-panel store instead of relying on URL state for normal open/close +- render `BrowserPanel` in the same right-side panel container used by diff +- ensure only one of diff or browser is visible + +### Diff Search Rules + +Keep diff search params for explicit deep links: + +- `diff` +- `diffTurnId` +- `diffFilePath` + +If diff deep-link params are present: + +- force diff open +- sync `"diff"` into the right-panel store + +When switching away from diff or closing the panel: + +- strip diff params from the URL + +### Browser Shell Route Logic + +The route should support: + +- lazily creating the first blank tab when the browser panel opens +- creating tabs +- activating tabs +- closing tabs +- syncing the controlled URL input to the active tab +- parsing submitted URLs through the shared helper +- updating `lastError` on invalid input +- opening the current URL externally via native shell + +For shell-only mode before native runtime exists: + +- `back` +- `forward` +- `reload` + +can remain no-ops. + +## Phase 6: Header Toggle Group and Keyboard Shortcuts + +Update `apps/web/src/components/ChatView.tsx`. + +### Header Toggle Group + +Add a globe toggle next to diff, but render both as a joined segmented control, not two visually separate buttons. + +Behavior: + +- only one of diff/browser can be active +- clicking the inactive segment switches panels +- clicking the active segment closes the panel +- shortcut-aware tooltip on both controls + +### Keyboard Commands + +Add commands across contracts, server defaults, and frontend keybinding resolution: + +- `browser.toggle` +- `browser.newTab` +- `browser.closeTab` + +Default bindings: + +- `mod+b` => browser toggle +- `mod+t` => new tab +- `mod+w` => close active browser tab + +Existing `mod+d` still toggles diff. + +`mod+t` should open the browser panel if it is currently hidden. + +## Phase 7: Browser IPC Contract and Bridges + +Update `packages/contracts/src/ipc.ts`, `apps/desktop/src/preload.ts`, `apps/desktop/src/main.ts`, and `apps/web/src/wsNativeApi.ts`. + +### Contract + +Add browser-specific IPC input and event types. + +### Preload + +Expose browser methods from Electron preload. + +### Web Native API + +Expose the browser API through the existing `NativeApi`. + +Plain web builds should remain safe no-ops. + +## Phase 8: Electron Browser Runtime + +Add `apps/desktop/src/browserManager.ts`. + +This module is the core runtime for native browser content. + +### Record Model + +Keep one record per `threadId + tabId`. + +Each record stores: + +- thread id +- tab id +- current runtime state +- optional live `WebContentsView` +- last-access timestamp + +### Core Behavior + +- `ensureTab` creates missing records only +- existing records must not be overwritten from stale renderer state +- `navigate` updates the live runtime and loads the requested URL +- `goBack`, `goForward`, `reload`, `closeTab`, `clearThread` operate on the matching record(s) + +### Event Wiring + +Each live `WebContentsView` should listen for navigation/runtime changes and emit browser tab-state events back to the renderer. + +Key projected fields: + +- URL +- title +- favicon +- loading +- history affordances +- last error + +### Host Attachment + +The manager should attach exactly one native view to the BrowserWindow at a time: + +- only when browser panel is selected +- only for the active thread +- only for the active tab +- only when viewport bounds are known + +Everything else stays detached. + +## Phase 9: Viewport Host Sync + +Use the `viewportRef` already present in the browser panel. + +From the chat route: + +- measure the viewport host with `getBoundingClientRect()` +- send bounds to `api.browser.syncHost(...)` +- send `visible: false` or `bounds: null` when browser should be hidden + +### Important Animation Behavior + +When the right panel opens with an animation, bounds are not stable immediately. + +Use a short `requestAnimationFrame` sync burst after the browser panel becomes visible so the native view tracks the animated position until it stabilizes. + +### Immediate Activation Sync + +When the user activates a tab: + +- immediately update the controlled address bar value +- immediately sync the host to the selected tab + +Do not wait only for follow-up effects, or the shell will visibly lag behind the tab selection. + +## Phase 10: Hide Native Browser Under Blocking Dialogs + +The native `WebContentsView` can visually sit above DOM overlays, so z-index is not sufficient. + +When blocking dialogs are visible: + +- temporarily hide the browser host +- restore it when dialogs close + +Use actual visible dialog detection instead of counting mounted dialog components, because some dialogs stay mounted while closed. + +The implemented approach should: + +- inspect dialog visibility markers in the DOM +- watch dialog open/close changes with a `MutationObserver` +- resync the native host whenever dialog visibility changes + +Do not globally count sheets, because the browser panel itself can be hosted inside a sheet on compact layouts. + +## Phase 11: Warm-Tab LRU Resource Budget + +Do not keep every tab across every thread as a live `WebContentsView`. + +Introduce a global warm-tab budget in `browserManager.ts`. + +The implementation target is: + +- keep at most 3 live native tabs globally +- always keep the active tab live +- allow recently used hidden tabs to remain warm +- evict the least recently used hidden live tab when over budget + +### Eviction Behavior + +When a tab is evicted: + +- destroy its live `WebContentsView` +- keep lightweight metadata +- reset `canGoBack` and `canGoForward` to `false` + +This is an intentional tradeoff: + +- memory stays bounded +- hidden tabs do not keep unlimited Chromium resources alive +- evicted tabs lose in-page JS/history state +- revisiting a cold tab recreates the native view and reloads the URL + +## Phase 12: Cold-Tab Restore Correctness + +Be careful when reviving an evicted tab. + +Do not emit blank runtime state from a freshly created empty view before reloading the saved URL. That can overwrite the renderer's tab back to `about:blank`. + +Correct restore behavior: + +- recreate the live view +- keep stored metadata until real navigation events arrive +- load the stored URL +- let post-load runtime events update the projected state + +## Phase 13: Redirect and Address-Bar Correctness + +Avoid renderer/native URL ownership fights. + +Specifically: + +- `ensureTab` should not keep pushing renderer tab URLs into an already existing native tab +- explicit navigation should be the only path that changes runtime URL from renderer intent + +This prevents `http -> https` redirect loops where the address bar oscillates between the stale submitted URL and the real runtime URL. + +## Phase 14: Root Route Event Subscription and Cleanup + +Update `apps/web/src/routes/__root.tsx`. + +Responsibilities: + +- subscribe once to browser native events +- merge incoming tab-state events into the per-thread browser store +- clear orphaned native browser threads when thread state is cleaned up +- clear orphaned persisted browser state +- clear orphaned persisted right-panel state + +Renderer store remains the source of truth for tab existence and ordering. Native events should update matching tabs, not invent new renderer tabs. + +## Phase 15: CSS + +Update `apps/web/src/index.css`. + +Add browser tab-strip scrollbar hiding rules. + +The final implementation hides the horizontal scrollbar entirely rather than using a thin visible scrollbar. + +## Files To Add + +- `apps/web/src/browser.ts` +- `apps/web/src/browserStateStore.ts` +- `apps/web/src/browserStateStore.test.ts` +- `apps/web/src/components/BrowserPanel.tsx` +- `apps/web/src/rightPanelStateStore.ts` +- `apps/desktop/src/browserManager.ts` + +## Files To Modify + +- `apps/server/src/keybindings.ts` +- `apps/web/src/components/ChatView.tsx` +- `apps/web/src/diffRouteSearch.ts` +- `apps/web/src/index.css` +- `apps/web/src/keybindings.ts` +- `apps/web/src/keybindings.test.ts` +- `apps/web/src/routes/__root.tsx` +- `apps/web/src/routes/_chat.$threadId.tsx` +- `apps/web/src/wsNativeApi.ts` +- `apps/desktop/src/main.ts` +- `apps/desktop/src/preload.ts` +- `packages/contracts/src/ipc.ts` +- `packages/contracts/src/keybindings.ts` +- `packages/contracts/src/keybindings.test.ts` + +## Testing and Validation + +Required checks: + +- `bun fmt` +- `bun lint` +- `bun typecheck` + +Recommended targeted tests: + +- browser store no-op identity regression +- keybinding contract/default coverage for browser commands +- web native API browser bridge tests + +Recommended manual desktop smoke checks: + +- browser panel open/close +- diff/browser toggle switching +- `cmd+d`, `cmd+b`, `cmd+t`, `cmd+w` +- opening more than 3 tabs and returning to older tabs +- `http -> https` redirect behavior +- thread switching with browser tabs in multiple threads +- blocking dialog open/close over visible browser content +- right-panel open animation while browser content is visible +- external-open action + +## Done Criteria + +- Browser shell exists and matches the tuned desktop-style UI. +- Diff and browser share the same right-side panel and behave as a joined toggle group. +- Browser tab state is persisted per thread. +- Right-panel visibility is persisted per thread. +- Diff deep links still work through URL params. +- Electron renders real browser content behind the shell. +- Only one native browser host is attached at a time. +- Native browser resource usage is bounded by a global warm-tab budget. +- Cold-tab restore works correctly. +- Redirected URLs do not fight the address bar. +- Dialogs correctly hide native browser content. +- `bun fmt`, `bun lint`, and `bun typecheck` pass. diff --git a/apps/desktop/src/browserManager.ts b/apps/desktop/src/browserManager.ts new file mode 100644 index 000000000..bb440b82b --- /dev/null +++ b/apps/desktop/src/browserManager.ts @@ -0,0 +1,482 @@ +import { WebContentsView, type BrowserWindow, type Rectangle } from "electron"; +import type { + BrowserClearThreadInput, + BrowserEnsureTabInput, + BrowserEvent, + BrowserNavigateInput, + BrowserSyncHostInput, + BrowserTabRuntimeState, + BrowserTabTargetInput, + ThreadId, +} from "@t3tools/contracts"; + +const ERR_ABORTED = -3; +const MAX_LIVE_BROWSER_TABS = 3; + +type BrowserTabRecord = { + key: string; + threadId: ThreadId; + tabId: string; + view: WebContentsView | null; + state: BrowserTabRuntimeState; + lastAccessedAt: number; +}; + +export interface BrowserManager { + ensureTab: (input: BrowserEnsureTabInput) => Promise; + navigate: (input: BrowserNavigateInput) => Promise; + goBack: (input: BrowserTabTargetInput) => Promise; + goForward: (input: BrowserTabTargetInput) => Promise; + reload: (input: BrowserTabTargetInput) => Promise; + closeTab: (input: BrowserTabTargetInput) => Promise; + syncHost: (input: BrowserSyncHostInput) => void; + clearThread: (input: BrowserClearThreadInput) => void; + destroyAll: () => void; +} + +interface BrowserManagerOptions { + emitEvent: (event: BrowserEvent) => void; + getWindow: () => BrowserWindow | null; + openExternal: (url: string) => void | Promise; +} + +function recordKey(threadId: ThreadId, tabId: string): string { + return `${threadId}\u0000${tabId}`; +} + +function normalizeRuntimeUrl(url: string | null | undefined): string { + if (!url || url.trim().length === 0) { + return "about:blank"; + } + return url; +} + +function readBrowserTitle(view: WebContentsView, fallback: string | null): string | null { + const title = view.webContents.getTitle().trim(); + if (title.length > 0) { + return title; + } + return fallback; +} + +function now(): number { + return Date.now(); +} + +function statesEqual(left: BrowserTabRuntimeState, right: BrowserTabRuntimeState): boolean { + return ( + left.url === right.url && + left.title === right.title && + left.faviconUrl === right.faviconUrl && + left.isLoading === right.isLoading && + left.canGoBack === right.canGoBack && + left.canGoForward === right.canGoForward && + left.lastError === right.lastError + ); +} + +function sanitizeBounds(bounds: BrowserSyncHostInput["bounds"]): Rectangle | null { + if (!bounds) { + return null; + } + const x = Number.isFinite(bounds.x) ? Math.max(0, Math.round(bounds.x)) : null; + const y = Number.isFinite(bounds.y) ? Math.max(0, Math.round(bounds.y)) : null; + const width = Number.isFinite(bounds.width) ? Math.max(0, Math.round(bounds.width)) : null; + const height = Number.isFinite(bounds.height) ? Math.max(0, Math.round(bounds.height)) : null; + if ( + x === null || + y === null || + width === null || + height === null || + width === 0 || + height === 0 + ) { + return null; + } + return { x, y, width, height }; +} + +export function createBrowserManager(options: BrowserManagerOptions): BrowserManager { + const records = new Map(); + let activeHost: BrowserSyncHostInput | null = null; + let attachedRecordKey: string | null = null; + + const emitState = ( + record: BrowserTabRecord, + patch: Partial = {}, + emitOptions: { preferBlankTitle?: boolean } = {}, + ): void => { + const runtimeUrl = record.view + ? normalizeRuntimeUrl(record.view.webContents.getURL()) + : normalizeRuntimeUrl(record.state.url); + const fallbackTitle = + emitOptions.preferBlankTitle || runtimeUrl === "about:blank" ? null : record.state.title; + const nextState: BrowserTabRuntimeState = { + url: patch.url ?? runtimeUrl ?? record.state.url, + title: + patch.title !== undefined + ? patch.title + : record.view + ? readBrowserTitle(record.view, fallbackTitle ?? record.state.title) + : (fallbackTitle ?? record.state.title), + faviconUrl: + patch.faviconUrl !== undefined ? patch.faviconUrl : (record.state.faviconUrl ?? null), + isLoading: patch.isLoading ?? (record.view ? record.view.webContents.isLoading() : false), + canGoBack: patch.canGoBack ?? (record.view ? record.view.webContents.canGoBack() : false), + canGoForward: + patch.canGoForward ?? (record.view ? record.view.webContents.canGoForward() : false), + lastError: patch.lastError !== undefined ? patch.lastError : record.state.lastError, + }; + if (nextState.url === "about:blank" && nextState.title === "") { + nextState.title = null; + } + if (statesEqual(record.state, nextState)) { + return; + } + record.state = nextState; + options.emitEvent({ + type: "tab-state", + threadId: record.threadId, + tabId: record.tabId, + state: nextState, + }); + }; + + const touchRecord = (record: BrowserTabRecord): void => { + record.lastAccessedAt = now(); + }; + + const detachRecord = (record: BrowserTabRecord | null): void => { + if (!record?.view) { + return; + } + record.view.setVisible(false); + const window = options.getWindow(); + if (!window) { + return; + } + if (window.contentView.children.includes(record.view)) { + window.contentView.removeChildView(record.view); + } + }; + + const disposeRecordView = (record: BrowserTabRecord): void => { + const view = record.view; + if (!view) { + return; + } + if (attachedRecordKey === record.key) { + detachRecord(record); + attachedRecordKey = null; + } else { + view.setVisible(false); + const window = options.getWindow(); + if (window && window.contentView.children.includes(view)) { + window.contentView.removeChildView(view); + } + } + record.view = null; + if (!view.webContents.isDestroyed()) { + view.webContents.close({ waitForBeforeUnload: false }); + } + emitState(record, { + isLoading: false, + canGoBack: false, + canGoForward: false, + }); + }; + + const enforceLiveTabBudget = (protectedRecordKey: string | null): void => { + const liveRecords = [...records.values()].filter((record) => record.view !== null); + if (liveRecords.length <= MAX_LIVE_BROWSER_TABS) { + return; + } + const protectedKeys = new Set(); + if (protectedRecordKey) { + protectedKeys.add(protectedRecordKey); + } + if (activeHost?.visible && activeHost.tabId) { + protectedKeys.add(recordKey(activeHost.threadId, activeHost.tabId)); + } + const evictionCandidates = liveRecords + .filter((record) => !protectedKeys.has(record.key)) + .toSorted((left, right) => left.lastAccessedAt - right.lastAccessedAt); + + while (liveRecords.filter((record) => record.view !== null).length > MAX_LIVE_BROWSER_TABS) { + const nextCandidate = evictionCandidates.shift(); + if (!nextCandidate) { + break; + } + disposeRecordView(nextCandidate); + } + }; + + const wireRecordEvents = (record: BrowserTabRecord, view: WebContentsView): void => { + const { webContents } = view; + const isCurrentView = () => record.view === view; + const emitIfCurrent = ( + patch: Partial = {}, + emitOptions: { preferBlankTitle?: boolean } = {}, + ) => { + if (!isCurrentView()) { + return; + } + touchRecord(record); + emitState(record, patch, emitOptions); + }; + + webContents.setWindowOpenHandler(({ url }) => { + void options.openExternal(url); + return { action: "deny" }; + }); + webContents.on("did-start-loading", () => { + emitIfCurrent({ isLoading: true, lastError: null }); + }); + webContents.on("did-stop-loading", () => { + emitIfCurrent({ isLoading: false }); + }); + webContents.on("did-navigate", (_event, url) => { + emitIfCurrent({ url: normalizeRuntimeUrl(url), lastError: null }, { preferBlankTitle: true }); + }); + webContents.on("did-navigate-in-page", (_event, url) => { + emitIfCurrent({ url: normalizeRuntimeUrl(url), lastError: null }, { preferBlankTitle: true }); + }); + webContents.on("page-title-updated", (event, title) => { + event.preventDefault(); + emitIfCurrent({ title: title.trim().length > 0 ? title : null }); + }); + webContents.on("page-favicon-updated", (_event, favicons) => { + emitIfCurrent({ faviconUrl: favicons[0] ?? null }); + }); + webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame || errorCode === ERR_ABORTED) { + return; + } + emitIfCurrent({ + url: normalizeRuntimeUrl(validatedURL), + isLoading: false, + lastError: errorDescription || "Failed to load page.", + }); + }, + ); + webContents.on("render-process-gone", (_event, details) => { + emitIfCurrent({ + isLoading: false, + lastError: `Browser tab crashed (${details.reason}).`, + }); + }); + webContents.once("destroyed", () => { + if (!isCurrentView()) { + return; + } + if (attachedRecordKey === record.key) { + attachedRecordKey = null; + } + record.view = null; + emitState(record, { + isLoading: false, + canGoBack: false, + canGoForward: false, + }); + syncAttachedView(); + }); + }; + + const createLiveViewForRecord = ( + record: BrowserTabRecord, + options: { restoreFromState?: boolean } = {}, + ): WebContentsView => { + if (record.view) { + touchRecord(record); + return record.view; + } + const view = new WebContentsView(); + view.setVisible(false); + record.view = view; + touchRecord(record); + wireRecordEvents(record, view); + if (options.restoreFromState && record.state.url !== "about:blank") { + void loadRecordUrl(record, record.state.url); + } + enforceLiveTabBudget(record.key); + return view; + }; + + const syncAttachedView = (): void => { + const window = options.getWindow(); + const desiredRecord = + activeHost && activeHost.visible && activeHost.tabId && sanitizeBounds(activeHost.bounds) + ? (records.get(recordKey(activeHost.threadId, activeHost.tabId)) ?? null) + : null; + const attachedRecord = attachedRecordKey ? (records.get(attachedRecordKey) ?? null) : null; + if (attachedRecord && (!window || !desiredRecord || desiredRecord.key !== attachedRecord.key)) { + detachRecord(attachedRecord); + attachedRecordKey = null; + } + if (!window || !desiredRecord || !activeHost) { + return; + } + const bounds = sanitizeBounds(activeHost.bounds); + if (!bounds) { + detachRecord(desiredRecord); + attachedRecordKey = null; + return; + } + createLiveViewForRecord(desiredRecord, { restoreFromState: true }); + touchRecord(desiredRecord); + if (!desiredRecord.view) { + return; + } + desiredRecord.view.setBounds(bounds); + desiredRecord.view.setVisible(true); + window.contentView.addChildView(desiredRecord.view); + attachedRecordKey = desiredRecord.key; + }; + + const destroyRecord = (record: BrowserTabRecord): void => { + disposeRecordView(record); + records.delete(record.key); + syncAttachedView(); + }; + + const createRecord = (input: BrowserEnsureTabInput): BrowserTabRecord => { + const key = recordKey(input.threadId, input.tabId); + const record: BrowserTabRecord = { + key, + threadId: input.threadId, + tabId: input.tabId, + view: null, + state: { + url: normalizeRuntimeUrl(input.url), + title: null, + faviconUrl: null, + isLoading: false, + canGoBack: false, + canGoForward: false, + lastError: null, + }, + lastAccessedAt: now(), + }; + records.set(key, record); + emitState(record, { url: normalizeRuntimeUrl(input.url) }, { preferBlankTitle: true }); + return record; + }; + + const loadRecordUrl = async (record: BrowserTabRecord, url: string): Promise => { + createLiveViewForRecord(record); + emitState( + record, + { + url, + title: url === "about:blank" ? null : record.state.title, + faviconUrl: url === "about:blank" ? null : record.state.faviconUrl, + isLoading: url !== "about:blank", + lastError: null, + }, + { preferBlankTitle: url === "about:blank" }, + ); + try { + if (!record.view) { + return; + } + await record.view.webContents.loadURL(url); + } catch (error) { + if (!record.view || record.view.webContents.isDestroyed()) { + return; + } + emitState(record, { + url, + isLoading: false, + lastError: error instanceof Error ? error.message : "Failed to load page.", + }); + } + }; + + const ensureTab = async (input: BrowserEnsureTabInput): Promise => { + const key = recordKey(input.threadId, input.tabId); + const existing = records.get(key); + if (existing) { + return; + } + createRecord(input); + syncAttachedView(); + }; + + return { + ensureTab, + navigate: async (input) => { + await ensureTab(input); + const record = records.get(recordKey(input.threadId, input.tabId)); + if (!record) { + return; + } + await loadRecordUrl(record, input.url); + syncAttachedView(); + }, + goBack: async (input) => { + const record = records.get(recordKey(input.threadId, input.tabId)); + if (!record?.view || !record.view.webContents.canGoBack()) { + return; + } + touchRecord(record); + record.view.webContents.goBack(); + }, + goForward: async (input) => { + const record = records.get(recordKey(input.threadId, input.tabId)); + if (!record?.view || !record.view.webContents.canGoForward()) { + return; + } + touchRecord(record); + record.view.webContents.goForward(); + }, + reload: async (input) => { + const record = records.get(recordKey(input.threadId, input.tabId)); + if (!record) { + return; + } + if (!record.view) { + await loadRecordUrl(record, record.state.url); + syncAttachedView(); + return; + } + touchRecord(record); + record.view.webContents.reload(); + }, + closeTab: async (input) => { + const record = records.get(recordKey(input.threadId, input.tabId)); + if (!record) { + return; + } + destroyRecord(record); + }, + syncHost: (input) => { + activeHost = input; + syncAttachedView(); + }, + clearThread: (input) => { + for (const record of records.values()) { + if (record.threadId !== input.threadId) { + continue; + } + destroyRecord(record); + } + if (activeHost?.threadId === input.threadId) { + activeHost = { + threadId: input.threadId, + tabId: null, + visible: false, + bounds: null, + }; + } + syncAttachedView(); + }, + destroyAll: () => { + activeHost = null; + for (const record of records.values()) { + destroyRecord(record); + } + }, + }; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 443492ada..ac3dc61f1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -18,6 +18,12 @@ import { import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; import type { + BrowserClearThreadInput, + BrowserEnsureTabInput, + BrowserEvent, + BrowserNavigateInput, + BrowserSyncHostInput, + BrowserTabTargetInput, DesktopTheme, DesktopUpdateActionResult, DesktopUpdateState, @@ -30,6 +36,7 @@ import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { fixPath } from "./fixPath"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; +import { createBrowserManager } from "./browserManager"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -51,6 +58,15 @@ const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +const BROWSER_ENSURE_TAB_CHANNEL = "desktop:browser-ensure-tab"; +const BROWSER_NAVIGATE_CHANNEL = "desktop:browser-navigate"; +const BROWSER_GO_BACK_CHANNEL = "desktop:browser-go-back"; +const BROWSER_GO_FORWARD_CHANNEL = "desktop:browser-go-forward"; +const BROWSER_RELOAD_CHANNEL = "desktop:browser-reload"; +const BROWSER_CLOSE_TAB_CHANNEL = "desktop:browser-close-tab"; +const BROWSER_SYNC_HOST_CHANNEL = "desktop:browser-sync-host"; +const BROWSER_CLEAR_THREAD_CHANNEL = "desktop:browser-clear-thread"; +const BROWSER_EVENT_CHANNEL = "desktop:browser-event"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; @@ -160,6 +176,125 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } +function getSafeNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function getSafeBrowserTabTargetInput(rawInput: unknown): BrowserTabTargetInput | null { + if (typeof rawInput !== "object" || rawInput === null) { + return null; + } + const threadId = getSafeNonEmptyString(Reflect.get(rawInput, "threadId")); + const tabId = getSafeNonEmptyString(Reflect.get(rawInput, "tabId")); + if (!threadId || !tabId) { + return null; + } + return { + threadId: threadId as BrowserTabTargetInput["threadId"], + tabId, + } satisfies BrowserTabTargetInput; +} + +function getSafeBrowserEnsureTabInput(rawInput: unknown): BrowserEnsureTabInput | null { + const target = getSafeBrowserTabTargetInput(rawInput); + if (!target) { + return null; + } + const urlRaw = Reflect.get(rawInput as object, "url"); + const url = urlRaw === undefined ? undefined : getSafeNonEmptyString(urlRaw); + if (urlRaw !== undefined && !url) { + return null; + } + return { + ...target, + ...(url ? { url } : {}), + }; +} + +function getSafeBrowserNavigateInput(rawInput: unknown): BrowserNavigateInput | null { + const target = getSafeBrowserTabTargetInput(rawInput); + if (!target) { + return null; + } + const url = getSafeNonEmptyString(Reflect.get(rawInput as object, "url")); + if (!url) { + return null; + } + return { + ...target, + url, + }; +} + +function getSafeBrowserBounds(rawBounds: unknown): BrowserSyncHostInput["bounds"] { + if (rawBounds === null) { + return null; + } + if (typeof rawBounds !== "object" || rawBounds === null) { + return null; + } + const x = Reflect.get(rawBounds, "x"); + const y = Reflect.get(rawBounds, "y"); + const width = Reflect.get(rawBounds, "width"); + const height = Reflect.get(rawBounds, "height"); + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(width) || + !Number.isFinite(height) + ) { + return null; + } + return { + x, + y, + width, + height, + } satisfies NonNullable; +} + +function getSafeBrowserSyncHostInput(rawInput: unknown): BrowserSyncHostInput | null { + if (typeof rawInput !== "object" || rawInput === null) { + return null; + } + const threadId = getSafeNonEmptyString(Reflect.get(rawInput, "threadId")); + if (!threadId) { + return null; + } + const rawTabId = Reflect.get(rawInput, "tabId"); + const tabId = + rawTabId === null + ? null + : typeof rawTabId === "string" + ? getSafeNonEmptyString(rawTabId) + : null; + const visible = Reflect.get(rawInput, "visible"); + if (typeof visible !== "boolean") { + return null; + } + return { + threadId: threadId as BrowserSyncHostInput["threadId"], + tabId, + visible, + bounds: getSafeBrowserBounds(Reflect.get(rawInput, "bounds")), + }; +} + +function getSafeBrowserClearThreadInput(rawInput: unknown): BrowserClearThreadInput | null { + if (typeof rawInput !== "object" || rawInput === null) { + return null; + } + const threadId = getSafeNonEmptyString(Reflect.get(rawInput, "threadId")); + if (!threadId) { + return null; + } + return { threadId: threadId as BrowserClearThreadInput["threadId"] }; +} + function writeDesktopStreamChunk( streamName: "stdout" | "stderr", chunk: unknown, @@ -277,6 +412,22 @@ let updateCheckInFlight = false; let updateDownloadInFlight = false; let updaterConfigured = false; let updateState: DesktopUpdateState = initialUpdateState(); +const browserManager = createBrowserManager({ + emitEvent: (event: BrowserEvent) => { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) continue; + window.webContents.send(BROWSER_EVENT_CHANNEL, event); + } + }, + getWindow: () => mainWindow, + openExternal: (url) => { + const externalUrl = getSafeExternalUrl(url); + if (!externalUrl) { + return; + } + void shell.openExternal(externalUrl); + }, +}); function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { if (updateDownloadInFlight) return "download"; @@ -1160,6 +1311,78 @@ function registerIpcHandlers(): void { } }); + ipcMain.removeHandler(BROWSER_ENSURE_TAB_CHANNEL); + ipcMain.handle(BROWSER_ENSURE_TAB_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserEnsureTabInput(rawInput); + if (!input) { + return; + } + await browserManager.ensureTab(input); + }); + + ipcMain.removeHandler(BROWSER_NAVIGATE_CHANNEL); + ipcMain.handle(BROWSER_NAVIGATE_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserNavigateInput(rawInput); + if (!input) { + return; + } + await browserManager.navigate(input); + }); + + ipcMain.removeHandler(BROWSER_GO_BACK_CHANNEL); + ipcMain.handle(BROWSER_GO_BACK_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserTabTargetInput(rawInput); + if (!input) { + return; + } + await browserManager.goBack(input); + }); + + ipcMain.removeHandler(BROWSER_GO_FORWARD_CHANNEL); + ipcMain.handle(BROWSER_GO_FORWARD_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserTabTargetInput(rawInput); + if (!input) { + return; + } + await browserManager.goForward(input); + }); + + ipcMain.removeHandler(BROWSER_RELOAD_CHANNEL); + ipcMain.handle(BROWSER_RELOAD_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserTabTargetInput(rawInput); + if (!input) { + return; + } + await browserManager.reload(input); + }); + + ipcMain.removeHandler(BROWSER_CLOSE_TAB_CHANNEL); + ipcMain.handle(BROWSER_CLOSE_TAB_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserTabTargetInput(rawInput); + if (!input) { + return; + } + await browserManager.closeTab(input); + }); + + ipcMain.removeHandler(BROWSER_SYNC_HOST_CHANNEL); + ipcMain.handle(BROWSER_SYNC_HOST_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserSyncHostInput(rawInput); + if (!input) { + return; + } + browserManager.syncHost(input); + }); + + ipcMain.removeHandler(BROWSER_CLEAR_THREAD_CHANNEL); + ipcMain.handle(BROWSER_CLEAR_THREAD_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserClearThreadInput(rawInput); + if (!input) { + return; + } + browserManager.clearThread(input); + }); + ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState); @@ -1314,6 +1537,7 @@ app.on("before-quit", () => { isQuitting = true; writeDesktopLogHeader("before-quit received"); clearUpdatePollTimer(); + browserManager.destroyAll(); stopBackend(); restoreStdIoCapture?.(); }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8..bab8c49e5 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -6,6 +6,15 @@ const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +const BROWSER_ENSURE_TAB_CHANNEL = "desktop:browser-ensure-tab"; +const BROWSER_NAVIGATE_CHANNEL = "desktop:browser-navigate"; +const BROWSER_GO_BACK_CHANNEL = "desktop:browser-go-back"; +const BROWSER_GO_FORWARD_CHANNEL = "desktop:browser-go-forward"; +const BROWSER_RELOAD_CHANNEL = "desktop:browser-reload"; +const BROWSER_CLOSE_TAB_CHANNEL = "desktop:browser-close-tab"; +const BROWSER_SYNC_HOST_CHANNEL = "desktop:browser-sync-host"; +const BROWSER_CLEAR_THREAD_CHANNEL = "desktop:browser-clear-thread"; +const BROWSER_EVENT_CHANNEL = "desktop:browser-event"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; @@ -20,6 +29,25 @@ contextBridge.exposeInMainWorld("desktopBridge", { setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), + browserEnsureTab: (input) => ipcRenderer.invoke(BROWSER_ENSURE_TAB_CHANNEL, input), + browserNavigate: (input) => ipcRenderer.invoke(BROWSER_NAVIGATE_CHANNEL, input), + browserGoBack: (input) => ipcRenderer.invoke(BROWSER_GO_BACK_CHANNEL, input), + browserGoForward: (input) => ipcRenderer.invoke(BROWSER_GO_FORWARD_CHANNEL, input), + browserReload: (input) => ipcRenderer.invoke(BROWSER_RELOAD_CHANNEL, input), + browserCloseTab: (input) => ipcRenderer.invoke(BROWSER_CLOSE_TAB_CHANNEL, input), + browserSyncHost: (input) => ipcRenderer.invoke(BROWSER_SYNC_HOST_CHANNEL, input), + browserClearThread: (input) => ipcRenderer.invoke(BROWSER_CLEAR_THREAD_CHANNEL, input), + onBrowserEvent: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, payload: unknown) => { + if (typeof payload !== "object" || payload === null) return; + listener(payload as Parameters[0]); + }; + + ipcRenderer.on(BROWSER_EVENT_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(BROWSER_EVENT_CHANNEL, wrappedListener); + }; + }, onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/web/src/blockingOverlayStore.ts b/apps/web/src/blockingOverlayStore.ts new file mode 100644 index 000000000..78d2179a0 --- /dev/null +++ b/apps/web/src/blockingOverlayStore.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; + +interface BlockingOverlayStoreState { + blockingOverlayCount: number; + incrementBlockingOverlayCount: () => void; + decrementBlockingOverlayCount: () => void; +} + +export const useBlockingOverlayStore = create()((set) => ({ + blockingOverlayCount: 0, + incrementBlockingOverlayCount: () => + set((state) => ({ + blockingOverlayCount: state.blockingOverlayCount + 1, + })), + decrementBlockingOverlayCount: () => + set((state) => ({ + blockingOverlayCount: Math.max(0, state.blockingOverlayCount - 1), + })), +})); diff --git a/apps/web/src/components/BrowserPanel.tsx b/apps/web/src/components/BrowserPanel.tsx index c59e0c3e4..b8958bf1f 100644 --- a/apps/web/src/components/BrowserPanel.tsx +++ b/apps/web/src/components/BrowserPanel.tsx @@ -237,6 +237,7 @@ export default function BrowserPanel({ {state.tabs.map((tab, index) => { const previousTab = index > 0 ? (state.tabs[index - 1] ?? null) : null; const isActive = tab.id === state.activeTabId; + const tabLabel = getBrowserTabLabel(tab); const showDivider = previousTab !== null && previousTab.id !== state.activeTabId && @@ -247,7 +248,7 @@ export default function BrowserPanel({ {index > 0 ? : null}
{ onActivateTab(tab.id); }} - title={getBrowserTabLabel(tab)} + title={tabLabel} > - - {getBrowserTabLabel(tab)} - + {tabLabel} { diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index 5c65f261f..d5b4bf237 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -1,8 +1,10 @@ "use client"; import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; +import { useEffect } from "react"; import { cn } from "~/lib/utils"; +import { useBlockingOverlayStore } from "~/blockingOverlayStore"; const AlertDialogCreateHandle = AlertDialogPrimitive.createHandle; @@ -47,6 +49,20 @@ function AlertDialogPopup({ }: AlertDialogPrimitive.Popup.Props & { bottomStickOnMobile?: boolean; }) { + const incrementBlockingOverlayCount = useBlockingOverlayStore( + (store) => store.incrementBlockingOverlayCount, + ); + const decrementBlockingOverlayCount = useBlockingOverlayStore( + (store) => store.decrementBlockingOverlayCount, + ); + + useEffect(() => { + incrementBlockingOverlayCount(); + return () => { + decrementBlockingOverlayCount(); + }; + }, [decrementBlockingOverlayCount, incrementBlockingOverlayCount]); + return ( diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 080ac8099..e313603b3 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -2,9 +2,11 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { XIcon } from "lucide-react"; +import { useEffect } from "react"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { useBlockingOverlayStore } from "~/blockingOverlayStore"; const DialogCreateHandle = DialogPrimitive.createHandle; @@ -56,6 +58,20 @@ function DialogPopup({ showCloseButton?: boolean; bottomStickOnMobile?: boolean; }) { + const incrementBlockingOverlayCount = useBlockingOverlayStore( + (store) => store.incrementBlockingOverlayCount, + ); + const decrementBlockingOverlayCount = useBlockingOverlayStore( + (store) => store.decrementBlockingOverlayCount, + ); + + useEffect(() => { + incrementBlockingOverlayCount(); + return () => { + decrementBlockingOverlayCount(); + }; + }, [decrementBlockingOverlayCount, incrementBlockingOverlayCount]); + return ( diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index fd643a2fe..7d109e944 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -135,6 +135,7 @@ function errorDetails(error: unknown): string { function EventRouter() { const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setProjectExpanded = useStore((store) => store.setProjectExpanded); + const updateThreadBrowserState = useBrowserStateStore((store) => store.updateThreadBrowserState); const removeOrphanedBrowserStates = useBrowserStateStore( (store) => store.removeOrphanedBrowserStates, ); @@ -174,6 +175,12 @@ function EventRouter() { snapshotThreads: snapshot.threads, draftThreadIds, }); + const orphanedBrowserThreadIds = Object.keys( + useBrowserStateStore.getState().browserStateByThreadId, + ).filter((id) => !activeThreadIds.has(id as ThreadId)) as ThreadId[]; + for (const orphanedThreadId of orphanedBrowserThreadIds) { + void api.browser.clearThread({ threadId: orphanedThreadId }).catch(() => undefined); + } removeOrphanedBrowserStates(activeThreadIds); removeOrphanedRightPanelStates(activeThreadIds); removeOrphanedTerminalStates(activeThreadIds); @@ -239,6 +246,39 @@ function EventRouter() { hasRunningSubprocess, ); }); + const unsubBrowserEvent = api.browser.onEvent((event) => { + if (event.type !== "tab-state") { + return; + } + updateThreadBrowserState(event.threadId, (state) => { + const tabIndex = state.tabs.findIndex((tab) => tab.id === event.tabId); + if (tabIndex < 0) { + return state; + } + const existingTab = state.tabs[tabIndex]; + if (!existingTab) { + return state; + } + if ( + existingTab.url === event.state.url && + (existingTab.title ?? null) === event.state.title && + (existingTab.faviconUrl ?? null) === event.state.faviconUrl && + existingTab.isLoading === event.state.isLoading && + existingTab.canGoBack === event.state.canGoBack && + existingTab.canGoForward === event.state.canGoForward && + (existingTab.lastError ?? null) === event.state.lastError + ) { + return state; + } + const nextTab = { + ...existingTab, + ...event.state, + }; + const nextTabs = [...state.tabs]; + nextTabs[tabIndex] = nextTab; + return { ...state, tabs: nextTabs }; + }); + }); const unsubWelcome = onServerWelcome((payload) => { void (async () => { await syncSnapshot(); @@ -313,6 +353,7 @@ function EventRouter() { domainEventFlushThrottler.cancel(); unsubDomainEvent(); unsubTerminalEvent(); + unsubBrowserEvent(); unsubWelcome(); unsubServerConfigUpdated(); }; @@ -324,6 +365,7 @@ function EventRouter() { removeOrphanedTerminalStates, setProjectExpanded, syncServerReadModel, + updateThreadBrowserState, ]); return null; diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 46424030b..b9669f7b8 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -1,4 +1,4 @@ -import { type ResolvedKeybindingsConfig, ThreadId } from "@t3tools/contracts"; +import { type BrowserBounds, type ResolvedKeybindingsConfig, ThreadId } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { @@ -8,7 +8,9 @@ import { type ReactNode, useCallback, useEffect, + useLayoutEffect, useMemo, + useState, } from "react"; import ChatView from "../components/ChatView"; @@ -47,6 +49,32 @@ const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; +function getViewportBounds(element: HTMLDivElement): BrowserBounds { + const rect = element.getBoundingClientRect(); + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; +} + +function hasVisibleBlockingDialog(): boolean { + if (typeof document === "undefined") { + return false; + } + return ( + document.querySelector( + [ + '[data-slot="dialog-backdrop"]:not([data-closed]):not([hidden])', + '[data-slot="dialog-popup"]:not([data-closed]):not([hidden])', + '[data-slot="alert-dialog-backdrop"]:not([data-closed]):not([hidden])', + '[data-slot="alert-dialog-popup"]:not([data-closed]):not([hidden])', + ].join(", "), + ) !== null + ); +} + function resolveSelectedSidePanel(search: DiffRouteSearch): RightPanelKind | null { if (search.diff === "1" || search.diffTurnId) { return "diff"; @@ -211,6 +239,7 @@ function ChatThreadRouteView() { selectThreadBrowserState(state.browserStateByThreadId, threadId), ); const updateThreadBrowserState = useBrowserStateStore((state) => state.updateThreadBrowserState); + const [browserViewportElement, setBrowserViewportElement] = useState(null); const activeBrowserTab = useMemo( () => browserThreadState.tabs.find((tab) => tab.id === browserThreadState.activeTabId) ?? null, [browserThreadState.activeTabId, browserThreadState.tabs], @@ -242,6 +271,123 @@ function ChatThreadRouteView() { ); }, [activeBrowserTab?.id, activeBrowserTab?.url, threadId, updateThreadBrowserState]); + useEffect(() => { + const api = readNativeApi(); + if (!api) { + return; + } + for (const tab of browserThreadState.tabs) { + void api.browser.ensureTab({ threadId, tabId: tab.id, url: tab.url }).catch(() => undefined); + } + }, [browserThreadState.tabs, threadId]); + + const syncBrowserHost = useCallback(() => { + const api = readNativeApi(); + if (!api) { + return; + } + const visible = selectedPanel === "browser" && !hasVisibleBlockingDialog(); + const bounds = + visible && browserViewportElement ? getViewportBounds(browserViewportElement) : null; + void api.browser + .syncHost({ + threadId, + tabId: visible ? (activeBrowserTab?.id ?? null) : null, + visible, + bounds, + }) + .catch(() => undefined); + }, [activeBrowserTab?.id, browserViewportElement, selectedPanel, threadId]); + + useLayoutEffect(() => { + syncBrowserHost(); + }, [syncBrowserHost]); + + useEffect(() => { + if (!browserViewportElement) { + return; + } + const sync = () => { + syncBrowserHost(); + }; + const observer = new ResizeObserver(sync); + observer.observe(browserViewportElement); + window.addEventListener("resize", sync); + return () => { + observer.disconnect(); + window.removeEventListener("resize", sync); + }; + }, [browserViewportElement, syncBrowserHost]); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + const observer = new MutationObserver(() => { + syncBrowserHost(); + }); + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["data-closed", "hidden"], + }); + return () => { + observer.disconnect(); + }; + }, [syncBrowserHost]); + + useEffect(() => { + if (selectedPanel !== "browser" || !browserViewportElement) { + return; + } + + let frameId = 0; + let frameCount = 0; + let previousBoundsKey: string | null = null; + let stableFrameCount = 0; + + const tick = () => { + const { x, y, width, height } = getViewportBounds(browserViewportElement); + const nextBoundsKey = `${x}:${y}:${width}:${height}`; + if (nextBoundsKey === previousBoundsKey) { + stableFrameCount += 1; + } else { + stableFrameCount = 0; + previousBoundsKey = nextBoundsKey; + } + + syncBrowserHost(); + frameCount += 1; + if (frameCount >= 30 || stableFrameCount >= 4) { + return; + } + frameId = window.requestAnimationFrame(tick); + }; + + frameId = window.requestAnimationFrame(tick); + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [browserViewportElement, selectedPanel, syncBrowserHost]); + + useEffect(() => { + const api = readNativeApi(); + if (!api) { + return; + } + return () => { + void api.browser + .syncHost({ + threadId, + tabId: null, + visible: false, + bounds: null, + }) + .catch(() => undefined); + }; + }, [threadId]); + useEffect(() => { if (!threadsHydrated) { return; @@ -296,14 +442,43 @@ function ChatThreadRouteView() { }, [threadId, updateThreadBrowserState]); const activateTab = useCallback( (tabId: string) => { + const nextTab = browserThreadState.tabs.find((tab) => tab.id === tabId) ?? null; + const nextInputValue = normalizeBrowserDisplayUrl(nextTab?.url); updateThreadBrowserState(threadId, (state) => - state.activeTabId === tabId ? state : { ...state, activeTabId: tabId }, + state.activeTabId === tabId && state.inputValue === nextInputValue + ? state + : { + ...state, + activeTabId: tabId, + inputValue: nextInputValue, + }, ); + + const api = readNativeApi(); + if (!api || selectedPanel !== "browser") { + return; + } + const bounds = browserViewportElement ? getViewportBounds(browserViewportElement) : null; + void api.browser + .syncHost({ + threadId, + tabId, + visible: !hasVisibleBlockingDialog(), + bounds, + }) + .catch(() => undefined); }, - [threadId, updateThreadBrowserState], + [ + browserThreadState.tabs, + browserViewportElement, + selectedPanel, + threadId, + updateThreadBrowserState, + ], ); const closeTab = useCallback( (tabId: string) => { + const api = readNativeApi(); updateThreadBrowserState(threadId, (state) => { const closedIndex = state.tabs.findIndex((tab) => tab.id === tabId); if (closedIndex < 0) { @@ -316,11 +491,14 @@ function ChatThreadRouteView() { : state.activeTabId; return { ...state, activeTabId, tabs }; }); + void api?.browser.closeTab({ threadId, tabId }).catch(() => undefined); }, [threadId, updateThreadBrowserState], ); const submitBrowserInput = useCallback(() => { const parsedUrl = parseSubmittedBrowserUrl(browserThreadState.inputValue); + const currentActiveTabId = browserThreadState.activeTabId; + const api = readNativeApi(); updateThreadBrowserState(threadId, (state) => { if (!parsedUrl.ok) { if (!state.activeTabId) { @@ -378,7 +556,21 @@ function ChatThreadRouteView() { ), }; }); - }, [browserThreadState.inputValue, threadId, updateThreadBrowserState]); + if (parsedUrl.ok && currentActiveTabId) { + void api?.browser + .navigate({ + threadId, + tabId: currentActiveTabId, + url: parsedUrl.url, + }) + .catch(() => undefined); + } + }, [ + browserThreadState.activeTabId, + browserThreadState.inputValue, + threadId, + updateThreadBrowserState, + ]); const openActiveTabExternally = useCallback(() => { const url = activeBrowserTab?.url; const api = readNativeApi(); @@ -395,6 +587,9 @@ function ChatThreadRouteView() { () => shortcutLabelForCommand(keybindings, "browser.closeTab"), [keybindings], ); + const browserViewportRef = useCallback((element: HTMLDivElement | null) => { + setBrowserViewportElement((current) => (current === element ? current : element)); + }, []); useEffect(() => { const isTerminalFocused = (): boolean => { @@ -465,10 +660,31 @@ function ChatThreadRouteView() { onActivateTab={activateTab} onCloseTab={closeTab} onSubmit={submitBrowserInput} - onBack={() => undefined} - onForward={() => undefined} - onReload={() => undefined} + onBack={() => { + if (!activeBrowserTab) { + return; + } + const api = readNativeApi(); + void api?.browser.goBack({ threadId, tabId: activeBrowserTab.id }).catch(() => undefined); + }} + onForward={() => { + if (!activeBrowserTab) { + return; + } + const api = readNativeApi(); + void api?.browser + .goForward({ threadId, tabId: activeBrowserTab.id }) + .catch(() => undefined); + }} + onReload={() => { + if (!activeBrowserTab) { + return; + } + const api = readNativeApi(); + void api?.browser.reload({ threadId, tabId: activeBrowserTab.id }).catch(() => undefined); + }} onOpenExternal={openActiveTabExternally} + viewportRef={browserViewportRef} /> ) : ( }> @@ -519,10 +735,35 @@ function ChatThreadRouteView() { onActivateTab={activateTab} onCloseTab={closeTab} onSubmit={submitBrowserInput} - onBack={() => undefined} - onForward={() => undefined} - onReload={() => undefined} + onBack={() => { + if (!activeBrowserTab) { + return; + } + const api = readNativeApi(); + void api?.browser + .goBack({ threadId, tabId: activeBrowserTab.id }) + .catch(() => undefined); + }} + onForward={() => { + if (!activeBrowserTab) { + return; + } + const api = readNativeApi(); + void api?.browser + .goForward({ threadId, tabId: activeBrowserTab.id }) + .catch(() => undefined); + }} + onReload={() => { + if (!activeBrowserTab) { + return; + } + const api = readNativeApi(); + void api?.browser + .reload({ threadId, tabId: activeBrowserTab.id }) + .catch(() => undefined); + }} onOpenExternal={openActiveTabExternally} + viewportRef={browserViewportRef} /> ) : ( { + await window.desktopBridge?.browserEnsureTab(input); + }, + navigate: async (input) => { + await window.desktopBridge?.browserNavigate(input); + }, + goBack: async (input) => { + await window.desktopBridge?.browserGoBack(input); + }, + goForward: async (input) => { + await window.desktopBridge?.browserGoForward(input); + }, + reload: async (input) => { + await window.desktopBridge?.browserReload(input); + }, + closeTab: async (input) => { + await window.desktopBridge?.browserCloseTab(input); + }, + syncHost: async (input) => { + await window.desktopBridge?.browserSyncHost(input); + }, + clearThread: async (input) => { + await window.desktopBridge?.browserClearThread(input); + }, + onEvent: (callback) => window.desktopBridge?.onBrowserEvent(callback) ?? (() => undefined), + }, git: { pull: (input) => transport.request(WS_METHODS.gitPull, input), status: (input) => transport.request(WS_METHODS.gitStatus, input), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..01f8d9150 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -46,6 +46,7 @@ import type { OrchestrationReadModel, } from "./orchestration"; import { EditorId } from "./editor"; +import type { ThreadId } from "./baseSchemas"; export interface ContextMenuItem { id: T; @@ -94,6 +95,60 @@ export interface DesktopUpdateActionResult { state: DesktopUpdateState; } +export interface BrowserBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface BrowserTabRuntimeState { + url: string; + title: string | null; + faviconUrl: string | null; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + lastError: string | null; +} + +export interface BrowserEnsureTabInput { + threadId: ThreadId; + tabId: string; + url?: string; +} + +export interface BrowserNavigateInput { + threadId: ThreadId; + tabId: string; + url: string; +} + +export interface BrowserTabTargetInput { + threadId: ThreadId; + tabId: string; +} + +export interface BrowserSyncHostInput { + threadId: ThreadId; + tabId: string | null; + visible: boolean; + bounds: BrowserBounds | null; +} + +export interface BrowserClearThreadInput { + threadId: ThreadId; +} + +export interface BrowserTabStateEvent { + type: "tab-state"; + threadId: ThreadId; + tabId: string; + state: BrowserTabRuntimeState; +} + +export type BrowserEvent = BrowserTabStateEvent; + export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; @@ -104,6 +159,15 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + browserEnsureTab: (input: BrowserEnsureTabInput) => Promise; + browserNavigate: (input: BrowserNavigateInput) => Promise; + browserGoBack: (input: BrowserTabTargetInput) => Promise; + browserGoForward: (input: BrowserTabTargetInput) => Promise; + browserReload: (input: BrowserTabTargetInput) => Promise; + browserCloseTab: (input: BrowserTabTargetInput) => Promise; + browserSyncHost: (input: BrowserSyncHostInput) => Promise; + browserClearThread: (input: BrowserClearThreadInput) => Promise; + onBrowserEvent: (listener: (event: BrowserEvent) => void) => () => void; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; downloadUpdate: () => Promise; @@ -133,6 +197,17 @@ export interface NativeApi { openInEditor: (cwd: string, editor: EditorId) => Promise; openExternal: (url: string) => Promise; }; + browser: { + ensureTab: (input: BrowserEnsureTabInput) => Promise; + navigate: (input: BrowserNavigateInput) => Promise; + goBack: (input: BrowserTabTargetInput) => Promise; + goForward: (input: BrowserTabTargetInput) => Promise; + reload: (input: BrowserTabTargetInput) => Promise; + closeTab: (input: BrowserTabTargetInput) => Promise; + syncHost: (input: BrowserSyncHostInput) => Promise; + clearThread: (input: BrowserClearThreadInput) => Promise; + onEvent: (callback: (event: BrowserEvent) => void) => () => void; + }; git: { // Existing branch/worktree API listBranches: (input: GitListBranchesInput) => Promise; From eaa947be798a70285ae8b1f158d316ad9fb48ca7 Mon Sep 17 00:00:00 2001 From: Andrea Coiro Date: Wed, 11 Mar 2026 10:23:25 +0100 Subject: [PATCH 3/8] Add browser shell sidebar and per-thread panel state --- apps/server/src/keybindings.ts | 3 + apps/web/src/browser.ts | 72 ++++ apps/web/src/browserStateStore.test.ts | 53 +++ apps/web/src/browserStateStore.ts | 177 +++++++++ apps/web/src/components/BrowserPanel.tsx | 416 +++++++++++++++++++ apps/web/src/components/ChatView.tsx | 56 ++- apps/web/src/components/chat/ChatHeader.tsx | 85 ++-- apps/web/src/diffRouteSearch.ts | 4 +- apps/web/src/index.css | 9 + apps/web/src/keybindings.test.ts | 68 ++++ apps/web/src/keybindings.ts | 8 + apps/web/src/rightPanelStateStore.ts | 127 ++++++ apps/web/src/routes/__root.tsx | 12 + apps/web/src/routes/_chat.$threadId.tsx | 417 +++++++++++++++++--- packages/contracts/src/keybindings.test.ts | 18 + packages/contracts/src/keybindings.ts | 3 + 16 files changed, 1427 insertions(+), 101 deletions(-) create mode 100644 apps/web/src/browser.ts create mode 100644 apps/web/src/browserStateStore.test.ts create mode 100644 apps/web/src/browserStateStore.ts create mode 100644 apps/web/src/components/BrowserPanel.tsx create mode 100644 apps/web/src/rightPanelStateStore.ts diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index bf5846782..d4b3548b5 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -70,6 +70,9 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+b", command: "browser.toggle", when: "!terminalFocus" }, + { key: "mod+t", command: "browser.newTab", when: "!terminalFocus" }, + { key: "mod+w", command: "browser.closeTab", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, diff --git a/apps/web/src/browser.ts b/apps/web/src/browser.ts new file mode 100644 index 000000000..a97b015d1 --- /dev/null +++ b/apps/web/src/browser.ts @@ -0,0 +1,72 @@ +import { randomUUID } from "./lib/utils"; + +export type BrowserTab = { + id: string; + url: string; + title?: string | null; + faviconUrl?: string | null; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + lastError?: string | null; +}; + +export type BrowserUrlParseResult = { ok: true; url: string } | { ok: false; error: string }; + +const EXPLICIT_SCHEME_PATTERN = /^[A-Za-z][A-Za-z\d+.-]*:\/\//; + +export function createBrowserTab(url = "about:blank"): BrowserTab { + return { + id: `browser-tab-${randomUUID()}`, + url, + title: null, + faviconUrl: null, + isLoading: false, + canGoBack: false, + canGoForward: false, + lastError: null, + }; +} + +export function normalizeBrowserDisplayUrl(url: string | null | undefined): string { + if (!url || url === "about:blank") { + return ""; + } + return url; +} + +export function getBrowserTabLabel(tab: Pick): string { + const title = tab.title?.trim(); + if (title) { + return title; + } + if (tab.url === "about:blank") { + return "New tab"; + } + + try { + const parsed = new URL(tab.url); + return parsed.host || parsed.href; + } catch { + return tab.url; + } +} + +export function parseSubmittedBrowserUrl(rawValue: string): BrowserUrlParseResult { + const trimmed = rawValue.trim(); + if (!trimmed) { + return { ok: true, url: "about:blank" }; + } + + if (trimmed === "about:blank") { + return { ok: true, url: trimmed }; + } + + const candidate = EXPLICIT_SCHEME_PATTERN.test(trimmed) ? trimmed : `http://${trimmed}`; + + try { + return { ok: true, url: new URL(candidate).toString() }; + } catch { + return { ok: false, error: "Enter a valid URL." }; + } +} diff --git a/apps/web/src/browserStateStore.test.ts b/apps/web/src/browserStateStore.test.ts new file mode 100644 index 000000000..f3bb702ba --- /dev/null +++ b/apps/web/src/browserStateStore.test.ts @@ -0,0 +1,53 @@ +import { ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { createBrowserTab } from "./browser"; +import { selectThreadBrowserState, useBrowserStateStore } from "./browserStateStore"; + +const THREAD_ID = ThreadId.makeUnsafe("thread-1"); + +describe("browserStateStore actions", () => { + beforeEach(() => { + if (typeof localStorage !== "undefined") { + localStorage.clear(); + } + useBrowserStateStore.setState({ browserStateByThreadId: {} }); + }); + + it("returns an empty default state for unknown threads", () => { + const browserState = selectThreadBrowserState( + useBrowserStateStore.getState().browserStateByThreadId, + THREAD_ID, + ); + expect(browserState).toEqual({ + activeTabId: null, + tabs: [], + inputValue: "", + focusRequestId: 0, + }); + }); + + it("does not rewrite state for no-op updates", () => { + const tab = { ...createBrowserTab("http://localhost:3000"), id: "tab-1" }; + useBrowserStateStore.setState({ + browserStateByThreadId: { + [THREAD_ID]: { + activeTabId: tab.id, + tabs: [tab], + inputValue: tab.url, + focusRequestId: 0, + }, + }, + }); + + const beforeMap = useBrowserStateStore.getState().browserStateByThreadId; + const beforeEntry = beforeMap[THREAD_ID]; + useBrowserStateStore.getState().updateThreadBrowserState(THREAD_ID, (state) => state); + const afterMap = useBrowserStateStore.getState().browserStateByThreadId; + const afterEntry = afterMap[THREAD_ID]; + + expect(afterMap).toBe(beforeMap); + expect(afterEntry).toBe(beforeEntry); + expect(afterEntry?.tabs).toBe(beforeEntry?.tabs); + }); +}); diff --git a/apps/web/src/browserStateStore.ts b/apps/web/src/browserStateStore.ts new file mode 100644 index 000000000..9f3302059 --- /dev/null +++ b/apps/web/src/browserStateStore.ts @@ -0,0 +1,177 @@ +import type { ThreadId } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { type BrowserTab } from "./browser"; + +export interface ThreadBrowserState { + activeTabId: string | null; + tabs: BrowserTab[]; + inputValue: string; + focusRequestId: number; +} + +const BROWSER_STATE_STORAGE_KEY = "t3code:browser-state:v1"; + +const DEFAULT_THREAD_BROWSER_STATE: ThreadBrowserState = Object.freeze({ + activeTabId: null, + tabs: [], + inputValue: "", + focusRequestId: 0, +}); + +function createDefaultThreadBrowserState(): ThreadBrowserState { + return { + ...DEFAULT_THREAD_BROWSER_STATE, + tabs: [], + }; +} + +function threadBrowserStateEqual(left: ThreadBrowserState, right: ThreadBrowserState): boolean { + return ( + left.activeTabId === right.activeTabId && + left.inputValue === right.inputValue && + left.focusRequestId === right.focusRequestId && + left.tabs === right.tabs + ); +} + +function isValidBrowserTab(tab: BrowserTab): boolean { + return tab.id.trim().length > 0 && typeof tab.url === "string" && tab.url.length > 0; +} + +function normalizeThreadBrowserState(state: ThreadBrowserState): ThreadBrowserState { + let tabsChanged = false; + const nextTabs: BrowserTab[] = []; + for (const tab of state.tabs) { + if (!isValidBrowserTab(tab)) { + tabsChanged = true; + continue; + } + nextTabs.push(tab); + } + const tabs = tabsChanged ? nextTabs : state.tabs; + const activeTabId = + state.activeTabId && tabs.some((tab) => tab.id === state.activeTabId) + ? state.activeTabId + : (tabs[0]?.id ?? null); + const normalized: ThreadBrowserState = { + activeTabId, + tabs, + inputValue: state.inputValue, + focusRequestId: + Number.isFinite(state.focusRequestId) && state.focusRequestId > 0 + ? Math.trunc(state.focusRequestId) + : 0, + }; + return threadBrowserStateEqual(state, normalized) ? state : normalized; +} + +function isDefaultThreadBrowserState(state: ThreadBrowserState): boolean { + const normalized = normalizeThreadBrowserState(state); + return ( + normalized.activeTabId === DEFAULT_THREAD_BROWSER_STATE.activeTabId && + normalized.inputValue === DEFAULT_THREAD_BROWSER_STATE.inputValue && + normalized.focusRequestId === DEFAULT_THREAD_BROWSER_STATE.focusRequestId && + normalized.tabs.length === 0 + ); +} + +export function selectThreadBrowserState( + browserStateByThreadId: Record, + threadId: ThreadId, +): ThreadBrowserState { + if (threadId.length === 0) { + return DEFAULT_THREAD_BROWSER_STATE; + } + return browserStateByThreadId[threadId] ?? DEFAULT_THREAD_BROWSER_STATE; +} + +function updateBrowserStateByThreadId( + browserStateByThreadId: Record, + threadId: ThreadId, + updater: (state: ThreadBrowserState) => ThreadBrowserState, +): Record { + if (threadId.length === 0) { + return browserStateByThreadId; + } + + const current = selectThreadBrowserState(browserStateByThreadId, threadId); + const next = normalizeThreadBrowserState(updater(current)); + if (next === current || threadBrowserStateEqual(current, next)) { + return browserStateByThreadId; + } + + if (isDefaultThreadBrowserState(next)) { + if (browserStateByThreadId[threadId] === undefined) { + return browserStateByThreadId; + } + const { [threadId]: _removed, ...rest } = browserStateByThreadId; + return rest as Record; + } + + return { + ...browserStateByThreadId, + [threadId]: next, + }; +} + +interface BrowserStateStoreState { + browserStateByThreadId: Record; + updateThreadBrowserState: ( + threadId: ThreadId, + updater: (state: ThreadBrowserState) => ThreadBrowserState, + ) => void; + removeOrphanedBrowserStates: (activeThreadIds: Set) => void; + clearBrowserState: (threadId: ThreadId) => void; +} + +export const useBrowserStateStore = create()( + persist( + (set) => ({ + browserStateByThreadId: {}, + updateThreadBrowserState: (threadId, updater) => + set((state) => { + const nextBrowserStateByThreadId = updateBrowserStateByThreadId( + state.browserStateByThreadId, + threadId, + updater, + ); + if (nextBrowserStateByThreadId === state.browserStateByThreadId) { + return state; + } + return { browserStateByThreadId: nextBrowserStateByThreadId }; + }), + removeOrphanedBrowserStates: (activeThreadIds) => + set((state) => { + const orphanedIds = Object.keys(state.browserStateByThreadId).filter( + (id) => !activeThreadIds.has(id as ThreadId), + ); + if (orphanedIds.length === 0) { + return state; + } + const next = { ...state.browserStateByThreadId }; + for (const id of orphanedIds) { + delete next[id as ThreadId]; + } + return { browserStateByThreadId: next }; + }), + clearBrowserState: (threadId) => + set((state) => ({ + browserStateByThreadId: updateBrowserStateByThreadId( + state.browserStateByThreadId, + threadId, + () => createDefaultThreadBrowserState(), + ), + })), + }), + { + name: BROWSER_STATE_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ + browserStateByThreadId: state.browserStateByThreadId, + }), + }, + ), +); diff --git a/apps/web/src/components/BrowserPanel.tsx b/apps/web/src/components/BrowserPanel.tsx new file mode 100644 index 000000000..c59e0c3e4 --- /dev/null +++ b/apps/web/src/components/BrowserPanel.tsx @@ -0,0 +1,416 @@ +"use client"; + +import { + ArrowLeftIcon, + ArrowRightIcon, + ExternalLinkIcon, + GlobeIcon, + PlusIcon, + RefreshCwIcon, + XIcon, +} from "lucide-react"; +import { + memo, + useEffect, + useLayoutEffect, + useRef, + useState, + type FormEvent, + type ReactNode, +} from "react"; + +import { getBrowserTabLabel, type BrowserTab } from "../browser"; +import { isElectron } from "../env"; +import { cn } from "~/lib/utils"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +interface BrowserPanelProps { + state: { activeTabId: string | null; tabs: BrowserTab[] }; + activeTab: BrowserTab | null; + inputValue: string; + focusRequestId: number; + newTabShortcutLabel?: string | null; + closeTabShortcutLabel?: string | null; + onInputChange: (value: string) => void; + onCreateTab: () => void; + onActivateTab: (tabId: string) => void; + onCloseTab: (tabId: string) => void; + onSubmit: () => void; + onBack: () => void; + onForward: () => void; + onReload: () => void; + onOpenExternal: () => void; + viewportRef?: (el: HTMLDivElement | null) => void; +} + +type TabIconProps = { + tab: BrowserTab; +}; + +type ToolbarIconButtonProps = { + ariaLabel: string; + children: ReactNode; + disabled?: boolean; + onClick: () => void; + tooltip: string; +}; + +const TAB_SCROLLBAR_CLASS = "browser-panel-tab-strip"; + +const BrowserTabIcon = memo(function BrowserTabIcon({ tab }: TabIconProps) { + const [faviconFailed, setFaviconFailed] = useState(false); + + useEffect(() => { + setFaviconFailed(false); + }, [tab.faviconUrl]); + + if (tab.isLoading) { + return ; + } + + if (tab.faviconUrl && !faviconFailed) { + return ( + { + setFaviconFailed(true); + }} + /> + ); + } + + return ; +}); + +function BrowserTabDivider({ visible }: { visible: boolean }) { + return ( +
+
+
+ ); +} + +function ToolbarIconButton({ + ariaLabel, + children, + disabled = false, + onClick, + tooltip, +}: ToolbarIconButtonProps) { + return ( + + + + + } + /> + {tooltip} + + ); +} + +export default function BrowserPanel({ + state, + activeTab, + inputValue, + focusRequestId, + newTabShortcutLabel, + closeTabShortcutLabel, + onInputChange, + onCreateTab, + onActivateTab, + onCloseTab, + onSubmit, + onBack, + onForward, + onReload, + onOpenExternal, + viewportRef, +}: BrowserPanelProps) { + const tabStripRef = useRef(null); + const activeTabRef = useRef(null); + const inputRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + + useLayoutEffect(() => { + const strip = tabStripRef.current; + if (!strip) { + return; + } + + const updateOverflow = () => { + setIsOverflowing(strip.scrollWidth > strip.clientWidth + 1); + }; + + updateOverflow(); + + const observer = new ResizeObserver(() => { + updateOverflow(); + }); + + observer.observe(strip); + return () => { + observer.disconnect(); + }; + }, [state.tabs.length]); + + useLayoutEffect(() => { + const strip = tabStripRef.current; + const activeButton = activeTabRef.current; + if (!strip || !activeButton) { + return; + } + + const stripRect = strip.getBoundingClientRect(); + const activeRect = activeButton.getBoundingClientRect(); + const isFullyVisible = activeRect.left >= stripRect.left && activeRect.right <= stripRect.right; + + if (!isFullyVisible) { + activeButton.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "nearest", + }); + } + }, [state.activeTabId, state.tabs.length]); + + useEffect(() => { + const input = inputRef.current; + if (!input) { + return; + } + input.focus(); + input.select(); + }, [focusRequestId]); + + const closeTooltip = closeTabShortcutLabel ? `Close tab (${closeTabShortcutLabel})` : "Close tab"; + const newTabTooltip = newTabShortcutLabel ? `New tab (${newTabShortcutLabel})` : "New tab"; + const lastError = activeTab?.lastError?.trim() || null; + const showEmptyState = !activeTab || activeTab.url === "about:blank"; + const topBarClassName = cn( + "relative flex h-[52px] min-h-[52px] items-end bg-background/70 px-3", + isElectron && "drag-region", + ); + const controlsClassName = cn( + "flex h-full min-w-0 flex-1 items-end pt-2", + isElectron && "[-webkit-app-region:no-drag]", + ); + const navRowClassName = cn( + "flex h-11 min-h-11 items-center gap-1.5 border-border/80 border-b bg-card/94 px-2.5", + isElectron && "[-webkit-app-region:no-drag]", + ); + + return ( +
+
+
+
+
+
+ {state.tabs.map((tab, index) => { + const previousTab = index > 0 ? (state.tabs[index - 1] ?? null) : null; + const isActive = tab.id === state.activeTabId; + const showDivider = + previousTab !== null && + previousTab.id !== state.activeTabId && + tab.id !== state.activeTabId; + + return ( +
+ {index > 0 ? : null} +
+ + + { + event.stopPropagation(); + onCloseTab(tab.id); + }} + > + + + } + /> + {closeTooltip} + +
+
+ ); + })} + {!isOverflowing ? ( + + + + + } + /> + {newTabTooltip} + + ) : null} +
+ {isOverflowing ? ( +
+
+ + + + + } + /> + {newTabTooltip} + +
+ ) : null} +
+
+
+ +
) => { + event.preventDefault(); + onSubmit(); + }} + > + + + + + + + + + +
+ { + onInputChange(event.target.value); + }} + placeholder="http://localhost:3000" + autoCapitalize="none" + autoCorrect="off" + autoComplete="off" + spellCheck={false} + inputMode="url" + nativeInput + /> +
+ + + +
+ +
+
+ {showEmptyState ? ( +
+ Enter a URL to preview a local app or external site. +
+ ) : null} + {lastError ? ( +
+ {lastError} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d81e024f3..05fca88fc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -125,6 +125,7 @@ import { useComposerThreadDraft, } from "../composerDraftStore"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; +import { selectThreadRightPanelState, useRightPanelStateStore } from "../rightPanelStateStore"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -187,6 +188,10 @@ export default function ChatView({ threadId }: ChatViewProps) { strict: false, select: (params) => parseDiffRouteSearch(params), }); + const rightPanelState = useRightPanelStateStore((state) => + selectThreadRightPanelState(state.rightPanelStateByThreadId, threadId), + ); + const setSelectedPanel = useRightPanelStateStore((state) => state.setSelectedPanel); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); @@ -359,7 +364,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; - const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); @@ -993,17 +997,30 @@ export default function ChatView({ threadId }: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "diff.toggle"), [keybindings], ); + const browserPanelShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "browser.toggle"), + [keybindings], + ); + const forcedSelectedSidePanel = rawSearch.diff === "1" || rawSearch.diffTurnId ? "diff" : null; + const selectedSidePanel = forcedSelectedSidePanel ?? rightPanelState.selectedPanel; + const onSelectSidePanel = useCallback( + (panel: "diff" | "browser" | null) => { + setSelectedPanel(threadId, panel); + void navigate({ + to: "/$threadId", + params: { threadId }, + replace: true, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return rest; + }, + }); + }, + [navigate, setSelectedPanel, threadId], + ); const onToggleDiff = useCallback(() => { - void navigate({ - to: "/$threadId", - params: { threadId }, - replace: true, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return diffOpen ? rest : { ...rest, diff: "1" }; - }, - }); - }, [diffOpen, navigate, threadId]); + onSelectSidePanel(selectedSidePanel === "diff" ? null : "diff"); + }, [onSelectSidePanel, selectedSidePanel]); const envLocked = Boolean( activeThread && @@ -1986,6 +2003,13 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } + if (command === "browser.toggle") { + event.preventDefault(); + event.stopPropagation(); + onSelectSidePanel(selectedSidePanel === "browser" ? null : "browser"); + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -2007,7 +2031,9 @@ export default function ChatView({ threadId }: ChatViewProps) { runProjectScript, splitTerminal, keybindings, + onSelectSidePanel, onToggleDiff, + selectedSidePanel, toggleTerminalVisibility, ]); @@ -3103,6 +3129,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { + setSelectedPanel(threadId, "diff"); void navigate({ to: "/$threadId", params: { threadId }, @@ -3114,7 +3141,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, }); }, - [navigate, threadId], + [navigate, setSelectedPanel, threadId], ); const onRevertUserMessage = (messageId: MessageId) => { const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); @@ -3172,15 +3199,16 @@ export default function ChatView({ threadId }: ChatViewProps) { keybindings={keybindings} availableEditors={availableEditors} diffToggleShortcutLabel={diffPanelShortcutLabel} + browserToggleShortcutLabel={browserPanelShortcutLabel} gitCwd={gitCwd} - diffOpen={diffOpen} + selectedSidePanel={selectedSidePanel} onRunProjectScript={(script) => { void runProjectScript(script); }} onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} - onToggleDiff={onToggleDiff} + onSelectSidePanel={onSelectSidePanel} /> diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ea7f911be..e7b9b6cb1 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -6,11 +6,11 @@ import { } from "@t3tools/contracts"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; -import { DiffIcon } from "lucide-react"; +import { DiffIcon, GlobeIcon } from "lucide-react"; import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; -import { Toggle } from "../ui/toggle"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; @@ -25,13 +25,14 @@ interface ChatHeaderProps { keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; diffToggleShortcutLabel: string | null; + browserToggleShortcutLabel: string | null; gitCwd: string | null; - diffOpen: boolean; + selectedSidePanel: "diff" | "browser" | null; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; - onToggleDiff: () => void; + onSelectSidePanel: (panel: "diff" | "browser" | null) => void; } export const ChatHeader = memo(function ChatHeader({ @@ -45,13 +46,14 @@ export const ChatHeader = memo(function ChatHeader({ keybindings, availableEditors, diffToggleShortcutLabel, + browserToggleShortcutLabel, gitCwd, - diffOpen, + selectedSidePanel, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, onDeleteProjectScript, - onToggleDiff, + onSelectSidePanel, }: ChatHeaderProps) { return (
@@ -94,30 +96,53 @@ export const ChatHeader = memo(function ChatHeader({ /> )} {activeProjectName && } - - - - - } - /> - - {!isGitRepo - ? "Diff panel is unavailable because this project is not a git repository." - : diffToggleShortcutLabel - ? `Toggle diff panel (${diffToggleShortcutLabel})` - : "Toggle diff panel"} - - + + + { + onSelectSidePanel(pressed ? "diff" : null); + }} + aria-label="Toggle diff panel" + disabled={!isGitRepo} + > + + + } + /> + + {!isGitRepo + ? "Diff panel is unavailable because this project is not a git repository." + : diffToggleShortcutLabel + ? `Toggle diff panel (${diffToggleShortcutLabel})` + : "Toggle diff panel"} + + + + { + onSelectSidePanel(pressed ? "browser" : null); + }} + aria-label="Toggle in-app browser" + > + + + } + /> + + {browserToggleShortcutLabel + ? `Toggle in-app browser (${browserToggleShortcutLabel})` + : "Toggle in-app browser"} + + +
); diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts index f310b74b7..b12224ee0 100644 --- a/apps/web/src/diffRouteSearch.ts +++ b/apps/web/src/diffRouteSearch.ts @@ -27,9 +27,9 @@ export function stripDiffSearchParams>( export function parseDiffRouteSearch(search: Record): DiffRouteSearch { const diff = isDiffOpenValue(search.diff) ? "1" : undefined; - const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; + const diffTurnIdRaw = normalizeSearchString(search.diffTurnId); const diffTurnId = diffTurnIdRaw ? TurnId.makeUnsafe(diffTurnIdRaw) : undefined; - const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; + const diffFilePath = diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; return { ...(diff ? { diff } : {}), diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 228179161..18e96e11b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -212,6 +212,15 @@ input { display: none; } +.browser-panel-tab-strip { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.browser-panel-tab-strip::-webkit-scrollbar { + display: none; +} + /* Terminal drawer scrollbar parity with chat */ .thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical { width: 6px !important; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 0ecccf43f..04ac6a878 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -97,6 +97,21 @@ const DEFAULT_BINDINGS = compile([ command: "diff.toggle", whenAst: whenNot(whenIdentifier("terminalFocus")), }, + { + shortcut: modShortcut("b"), + command: "browser.toggle", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { + shortcut: modShortcut("t"), + command: "browser.newTab", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, + { + shortcut: modShortcut("w"), + command: "browser.closeTab", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, @@ -237,6 +252,18 @@ describe("shortcutLabelForCommand", () => { it("returns labels for non-terminal commands", () => { assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "browser.toggle", "Linux"), + "Ctrl+B", + ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "browser.newTab", "Linux"), + "Ctrl+T", + ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "browser.closeTab", "Linux"), + "Ctrl+W", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), "Ctrl+O", @@ -298,6 +325,47 @@ describe("chat/editor shortcuts", () => { }), ); }); + + it("matches browser.toggle shortcut outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "browser.toggle", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "b", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "browser.toggle", + ); + }); + + it("matches browser tab shortcuts outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "t", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "browser.newTab", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "w", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "browser.closeTab", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "w", metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "browser.closeTab", + ); + }); }); describe("cross-command precedence", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 09d9308aa..710c490b2 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -206,6 +206,14 @@ export function isDiffToggleShortcut( return matchesCommandShortcut(event, keybindings, "diff.toggle", options); } +export function isBrowserToggleShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "browser.toggle", options); +} + export function isChatNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/rightPanelStateStore.ts b/apps/web/src/rightPanelStateStore.ts new file mode 100644 index 000000000..28914d07a --- /dev/null +++ b/apps/web/src/rightPanelStateStore.ts @@ -0,0 +1,127 @@ +import type { ThreadId } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export type RightPanelKind = "diff" | "browser"; + +export interface ThreadRightPanelState { + selectedPanel: RightPanelKind | null; + lastSelectedPanel: RightPanelKind; +} + +const RIGHT_PANEL_STATE_STORAGE_KEY = "t3code:right-panel-state:v1"; + +const DEFAULT_THREAD_RIGHT_PANEL_STATE: ThreadRightPanelState = Object.freeze({ + selectedPanel: null, + lastSelectedPanel: "diff", +}); + +function normalizeThreadRightPanelState(state: ThreadRightPanelState): ThreadRightPanelState { + const selectedPanel = + state.selectedPanel === "diff" || state.selectedPanel === "browser" + ? state.selectedPanel + : null; + const lastSelectedPanel = state.lastSelectedPanel === "browser" ? "browser" : "diff"; + if (selectedPanel === state.selectedPanel && lastSelectedPanel === state.lastSelectedPanel) { + return state; + } + return { selectedPanel, lastSelectedPanel }; +} + +function isDefaultThreadRightPanelState(state: ThreadRightPanelState): boolean { + const normalized = normalizeThreadRightPanelState(state); + return ( + normalized.selectedPanel === DEFAULT_THREAD_RIGHT_PANEL_STATE.selectedPanel && + normalized.lastSelectedPanel === DEFAULT_THREAD_RIGHT_PANEL_STATE.lastSelectedPanel + ); +} + +export function selectThreadRightPanelState( + rightPanelStateByThreadId: Record, + threadId: ThreadId, +): ThreadRightPanelState { + if (threadId.length === 0) { + return DEFAULT_THREAD_RIGHT_PANEL_STATE; + } + return rightPanelStateByThreadId[threadId] ?? DEFAULT_THREAD_RIGHT_PANEL_STATE; +} + +function updateRightPanelStateByThreadId( + rightPanelStateByThreadId: Record, + threadId: ThreadId, + updater: (state: ThreadRightPanelState) => ThreadRightPanelState, +): Record { + if (threadId.length === 0) { + return rightPanelStateByThreadId; + } + + const current = selectThreadRightPanelState(rightPanelStateByThreadId, threadId); + const next = normalizeThreadRightPanelState(updater(current)); + if ( + next === current || + (next.selectedPanel === current.selectedPanel && + next.lastSelectedPanel === current.lastSelectedPanel) + ) { + return rightPanelStateByThreadId; + } + + if (isDefaultThreadRightPanelState(next)) { + if (rightPanelStateByThreadId[threadId] === undefined) { + return rightPanelStateByThreadId; + } + const { [threadId]: _removed, ...rest } = rightPanelStateByThreadId; + return rest as Record; + } + + return { + ...rightPanelStateByThreadId, + [threadId]: next, + }; +} + +interface RightPanelStateStoreState { + rightPanelStateByThreadId: Record; + setSelectedPanel: (threadId: ThreadId, panel: RightPanelKind | null) => void; + removeOrphanedRightPanelStates: (activeThreadIds: Set) => void; +} + +export const useRightPanelStateStore = create()( + persist( + (set) => ({ + rightPanelStateByThreadId: {}, + setSelectedPanel: (threadId, panel) => + set((state) => ({ + rightPanelStateByThreadId: updateRightPanelStateByThreadId( + state.rightPanelStateByThreadId, + threadId, + (current) => ({ + selectedPanel: panel, + lastSelectedPanel: panel ?? current.lastSelectedPanel, + }), + ), + })), + removeOrphanedRightPanelStates: (activeThreadIds) => + set((state) => { + const orphanedIds = Object.keys(state.rightPanelStateByThreadId).filter( + (id) => !activeThreadIds.has(id as ThreadId), + ); + if (orphanedIds.length === 0) { + return state; + } + const next = { ...state.rightPanelStateByThreadId }; + for (const id of orphanedIds) { + delete next[id as ThreadId]; + } + return { rightPanelStateByThreadId: next }; + }), + }), + { + name: RIGHT_PANEL_STATE_STORAGE_KEY, + version: 1, + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ + rightPanelStateByThreadId: state.rightPanelStateByThreadId, + }), + }, + ), +); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 2fe07505f..fd643a2fe 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,6 +17,8 @@ import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQue import { readNativeApi } from "../nativeApi"; import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; import { useStore } from "../store"; +import { useBrowserStateStore } from "../browserStateStore"; +import { useRightPanelStateStore } from "../rightPanelStateStore"; import { useTerminalStateStore } from "../terminalStateStore"; import { preferredTerminalEditor } from "../terminal-links"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; @@ -133,6 +135,12 @@ function errorDetails(error: unknown): string { function EventRouter() { const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setProjectExpanded = useStore((store) => store.setProjectExpanded); + const removeOrphanedBrowserStates = useBrowserStateStore( + (store) => store.removeOrphanedBrowserStates, + ); + const removeOrphanedRightPanelStates = useRightPanelStateStore( + (store) => store.removeOrphanedRightPanelStates, + ); const removeOrphanedTerminalStates = useTerminalStateStore( (store) => store.removeOrphanedTerminalStates, ); @@ -166,6 +174,8 @@ function EventRouter() { snapshotThreads: snapshot.threads, draftThreadIds, }); + removeOrphanedBrowserStates(activeThreadIds); + removeOrphanedRightPanelStates(activeThreadIds); removeOrphanedTerminalStates(activeThreadIds); if (pending) { pending = false; @@ -309,6 +319,8 @@ function EventRouter() { }, [ navigate, queryClient, + removeOrphanedBrowserStates, + removeOrphanedRightPanelStates, removeOrphanedTerminalStates, setProjectExpanded, syncServerReadModel, diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index b85aeab0d..46424030b 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -1,8 +1,25 @@ -import { ThreadId } from "@t3tools/contracts"; -import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; -import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; +import { type ResolvedKeybindingsConfig, ThreadId } from "@t3tools/contracts"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { + Suspense, + lazy, + type CSSProperties, + type ReactNode, + useCallback, + useEffect, + useMemo, +} from "react"; import ChatView from "../components/ChatView"; +import { + createBrowserTab, + getBrowserTabLabel, + normalizeBrowserDisplayUrl, + parseSubmittedBrowserUrl, +} from "../browser"; +import { selectThreadBrowserState, useBrowserStateStore } from "../browserStateStore"; +import BrowserPanel from "../components/BrowserPanel"; import { useComposerDraftStore } from "../composerDraftStore"; import { type DiffRouteSearch, @@ -10,28 +27,44 @@ import { stripDiffSearchParams, } from "../diffRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { readNativeApi } from "../nativeApi"; +import { + selectThreadRightPanelState, + useRightPanelStateStore, + type RightPanelKind, +} from "../rightPanelStateStore"; import { useStore } from "../store"; 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 RIGHT_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_right_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 EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; + +function resolveSelectedSidePanel(search: DiffRouteSearch): RightPanelKind | null { + if (search.diff === "1" || search.diffTurnId) { + return "diff"; + } + return null; +} -const DiffPanelSheet = (props: { +const RightPanelSheet = (props: { children: ReactNode; - diffOpen: boolean; - onCloseDiff: () => void; + panelOpen: boolean; + onClosePanel: () => void; }) => { return ( { if (!open) { - props.onCloseDiff(); + props.onClosePanel(); } }} > @@ -47,37 +80,38 @@ const DiffPanelSheet = (props: { ); }; -const DiffLoadingFallback = (props: { inline: boolean }) => { +const RightPanelLoadingFallback = (props: { inline: boolean; label: string }) => { if (props.inline) { return (
- Loading diff viewer... + {props.label}
); } return ( ); }; -const DiffPanelInlineSidebar = (props: { - diffOpen: boolean; - onCloseDiff: () => void; - onOpenDiff: () => void; +const RightPanelInlineSidebar = (props: { + panelOpen: boolean; + onClosePanel: () => void; + onReopenPanel: () => void; + children: ReactNode; }) => { - const { diffOpen, onCloseDiff, onOpenDiff } = props; + const { panelOpen, onClosePanel, onReopenPanel } = props; const onOpenChange = useCallback( (open: boolean) => { if (open) { - onOpenDiff(); + onReopenPanel(); return; } - onCloseDiff(); + onClosePanel(); }, - [onCloseDiff, onOpenDiff], + [onClosePanel, onReopenPanel], ); const shouldAcceptInlineSidebarWidth = useCallback( ({ nextWidth, wrapper }: { nextWidth: number; wrapper: HTMLElement }) => { @@ -128,10 +162,10 @@ const DiffPanelInlineSidebar = (props: { return ( - }> - - + {props.children} @@ -164,9 +196,64 @@ function ChatThreadRouteView() { Object.hasOwn(store.draftThreadsByThreadId, threadId), ); const routeThreadExists = threadExists || draftThreadExists; - const diffOpen = search.diff === "1"; + const rightPanelState = useRightPanelStateStore((state) => + selectThreadRightPanelState(state.rightPanelStateByThreadId, threadId), + ); + const setSelectedPanel = useRightPanelStateStore((state) => state.setSelectedPanel); + const forcedSelectedPanel = resolveSelectedSidePanel(search); + const selectedPanel = forcedSelectedPanel ?? rightPanelState.selectedPanel; const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); - const closeDiff = useCallback(() => { + const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ + ...serverConfigQueryOptions(), + select: (config) => config.keybindings, + }); + const browserThreadState = useBrowserStateStore((state) => + selectThreadBrowserState(state.browserStateByThreadId, threadId), + ); + const updateThreadBrowserState = useBrowserStateStore((state) => state.updateThreadBrowserState); + const activeBrowserTab = useMemo( + () => browserThreadState.tabs.find((tab) => tab.id === browserThreadState.activeTabId) ?? null, + [browserThreadState.activeTabId, browserThreadState.tabs], + ); + + useEffect(() => { + if (forcedSelectedPanel !== "diff") { + return; + } + setSelectedPanel(threadId, "diff"); + }, [forcedSelectedPanel, setSelectedPanel, threadId]); + + useEffect(() => { + if (selectedPanel === "browser" && browserThreadState.tabs.length === 0) { + const initialTab = createBrowserTab(); + updateThreadBrowserState(threadId, (state) => ({ + ...state, + activeTabId: initialTab.id, + tabs: [initialTab], + focusRequestId: state.focusRequestId + 1, + })); + } + }, [browserThreadState.tabs.length, selectedPanel, threadId, updateThreadBrowserState]); + + useEffect(() => { + const nextInputValue = normalizeBrowserDisplayUrl(activeBrowserTab?.url); + updateThreadBrowserState(threadId, (state) => + state.inputValue === nextInputValue ? state : { ...state, inputValue: nextInputValue }, + ); + }, [activeBrowserTab?.id, activeBrowserTab?.url, threadId, updateThreadBrowserState]); + + useEffect(() => { + if (!threadsHydrated) { + return; + } + + if (!routeThreadExists) { + void navigate({ to: "/", replace: true }); + } + }, [navigate, routeThreadExists, threadsHydrated]); + + const closePanel = useCallback(() => { + setSelectedPanel(threadId, null); void navigate({ to: "/$threadId", params: { threadId }, @@ -174,40 +261,234 @@ function ChatThreadRouteView() { return stripDiffSearchParams(previous); }, }); - }, [navigate, threadId]); - const openDiff = useCallback(() => { - void navigate({ - to: "/$threadId", - params: { threadId }, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; - }, - }); - }, [navigate, threadId]); + }, [navigate, setSelectedPanel, threadId]); + const openPanel = useCallback( + (panel: RightPanelKind) => { + setSelectedPanel(threadId, panel); + if (panel === "browser") { + updateThreadBrowserState(threadId, (state) => ({ + ...state, + focusRequestId: state.focusRequestId + 1, + })); + } + void navigate({ + to: "/$threadId", + params: { threadId }, + search: (previous) => { + return stripDiffSearchParams(previous); + }, + }); + }, + [navigate, setSelectedPanel, threadId, updateThreadBrowserState], + ); + const reopenPanel = useCallback(() => { + openPanel(rightPanelState.lastSelectedPanel); + }, [openPanel, rightPanelState.lastSelectedPanel]); + const createTab = useCallback(() => { + const nextTab = createBrowserTab(); + updateThreadBrowserState(threadId, (state) => ({ + ...state, + activeTabId: nextTab.id, + tabs: [...state.tabs, nextTab], + inputValue: "", + focusRequestId: state.focusRequestId + 1, + })); + }, [threadId, updateThreadBrowserState]); + const activateTab = useCallback( + (tabId: string) => { + updateThreadBrowserState(threadId, (state) => + state.activeTabId === tabId ? state : { ...state, activeTabId: tabId }, + ); + }, + [threadId, updateThreadBrowserState], + ); + const closeTab = useCallback( + (tabId: string) => { + updateThreadBrowserState(threadId, (state) => { + const closedIndex = state.tabs.findIndex((tab) => tab.id === tabId); + if (closedIndex < 0) { + return state; + } + const tabs = state.tabs.filter((tab) => tab.id !== tabId); + const activeTabId = + state.activeTabId === tabId + ? (tabs[closedIndex]?.id ?? tabs[closedIndex - 1]?.id ?? null) + : state.activeTabId; + return { ...state, activeTabId, tabs }; + }); + }, + [threadId, updateThreadBrowserState], + ); + const submitBrowserInput = useCallback(() => { + const parsedUrl = parseSubmittedBrowserUrl(browserThreadState.inputValue); + updateThreadBrowserState(threadId, (state) => { + if (!parsedUrl.ok) { + if (!state.activeTabId) { + return { + ...state, + focusRequestId: state.focusRequestId + 1, + }; + } + return { + ...state, + focusRequestId: state.focusRequestId + 1, + tabs: state.tabs.map((tab) => + tab.id === state.activeTabId ? { ...tab, lastError: parsedUrl.error } : tab, + ), + }; + } - useEffect(() => { - if (!threadsHydrated) { - return; - } + const nextInputValue = normalizeBrowserDisplayUrl(parsedUrl.url); + if (!state.activeTabId) { + const nextTab = createBrowserTab(parsedUrl.url); + return { + activeTabId: nextTab.id, + tabs: [ + { + ...nextTab, + title: + parsedUrl.url === "about:blank" + ? null + : getBrowserTabLabel({ title: null, url: parsedUrl.url }), + }, + ], + inputValue: nextInputValue, + focusRequestId: state.focusRequestId, + }; + } - if (!routeThreadExists) { - void navigate({ to: "/", replace: true }); + return { + ...state, + inputValue: nextInputValue, + tabs: state.tabs.map((tab) => + tab.id === state.activeTabId + ? { + ...tab, + url: parsedUrl.url, + title: + parsedUrl.url === "about:blank" + ? null + : getBrowserTabLabel({ title: null, url: parsedUrl.url }), + isLoading: false, + canGoBack: false, + canGoForward: false, + lastError: null, + } + : tab, + ), + }; + }); + }, [browserThreadState.inputValue, threadId, updateThreadBrowserState]); + const openActiveTabExternally = useCallback(() => { + const url = activeBrowserTab?.url; + const api = readNativeApi(); + if (!api || !url || url === "about:blank") { return; } - }, [navigate, routeThreadExists, threadsHydrated, threadId]); + void api.shell.openExternal(url).catch(() => undefined); + }, [activeBrowserTab?.url]); + const browserNewTabShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "browser.newTab"), + [keybindings], + ); + const browserCloseTabShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "browser.closeTab"), + [keybindings], + ); + + useEffect(() => { + const isTerminalFocused = (): boolean => { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) return false; + if (activeElement.classList.contains("xterm-helper-textarea")) return true; + return activeElement.closest(".thread-terminal-drawer .xterm") !== null; + }; + + const handler = (event: KeyboardEvent) => { + if (event.defaultPrevented) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: false, + }, + }); + + if (command === "browser.newTab") { + event.preventDefault(); + event.stopPropagation(); + createTab(); + if (selectedPanel !== "browser") { + openPanel("browser"); + } + return; + } + + if (command === "browser.closeTab") { + if (selectedPanel !== "browser" || !activeBrowserTab) { + return; + } + event.preventDefault(); + event.stopPropagation(); + closeTab(activeBrowserTab.id); + } + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [activeBrowserTab, closeTab, createTab, keybindings, openPanel, selectedPanel]); if (!threadsHydrated || !routeThreadExists) { return null; } + const rightPanelContent = + selectedPanel === null ? null : selectedPanel === "browser" ? ( + { + updateThreadBrowserState(threadId, (state) => + state.inputValue === value ? state : { ...state, inputValue: value }, + ); + }} + onCreateTab={createTab} + onActivateTab={activateTab} + onCloseTab={closeTab} + onSubmit={submitBrowserInput} + onBack={() => undefined} + onForward={() => undefined} + onReload={() => undefined} + onOpenExternal={openActiveTabExternally} + /> + ) : ( + }> + + + ); + if (!shouldUseDiffSheet) { return ( <> - + + {rightPanelContent} + ); } @@ -217,19 +498,45 @@ function ChatThreadRouteView() { - - }> - - - + + {selectedPanel === null ? null : selectedPanel === "browser" ? ( + { + updateThreadBrowserState(threadId, (state) => + state.inputValue === value ? state : { ...state, inputValue: value }, + ); + }} + onCreateTab={createTab} + onActivateTab={activateTab} + onCloseTab={closeTab} + onSubmit={submitBrowserInput} + onBack={() => undefined} + onForward={() => undefined} + onReload={() => undefined} + onOpenExternal={openActiveTabExternally} + /> + ) : ( + } + > + + + )} + ); } export const Route = createFileRoute("/_chat/$threadId")({ validateSearch: (search) => parseDiffRouteSearch(search), - search: { - middlewares: [retainSearchParams(["diff"])], - }, component: ChatThreadRouteView, }); diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 1b99362c5..66d368aae 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -41,6 +41,24 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedDiffToggle.command, "diff.toggle"); + const parsedBrowserToggle = yield* decode(KeybindingRule, { + key: "mod+b", + command: "browser.toggle", + }); + assert.strictEqual(parsedBrowserToggle.command, "browser.toggle"); + + const parsedBrowserNewTab = yield* decode(KeybindingRule, { + key: "mod+t", + command: "browser.newTab", + }); + assert.strictEqual(parsedBrowserNewTab.command, "browser.newTab"); + + const parsedBrowserCloseTab = yield* decode(KeybindingRule, { + key: "mod+w", + command: "browser.closeTab", + }); + assert.strictEqual(parsedBrowserCloseTab.command, "browser.closeTab"); + const parsedLocal = yield* decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 48821b182..e15602581 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -13,6 +13,9 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.new", "terminal.close", "diff.toggle", + "browser.toggle", + "browser.newTab", + "browser.closeTab", "chat.new", "chat.newLocal", "editor.openFavorite", From e9eddf227c75769343c960c2dc6c60f8206f2238 Mon Sep 17 00:00:00 2001 From: Andrea Coiro Date: Wed, 11 Mar 2026 11:32:16 +0100 Subject: [PATCH 4/8] Wire Electron browser runtime into browser panel --- .plans/18-browser-panel-shell-and-runtime.md | 603 +++++++++++++++++++ apps/desktop/src/browserManager.ts | 482 +++++++++++++++ apps/desktop/src/main.ts | 224 +++++++ apps/desktop/src/preload.ts | 28 + apps/web/src/blockingOverlayStore.ts | 19 + apps/web/src/components/BrowserPanel.tsx | 11 +- apps/web/src/components/ui/alert-dialog.tsx | 16 + apps/web/src/components/ui/dialog.tsx | 16 + apps/web/src/routes/__root.tsx | 42 ++ apps/web/src/routes/_chat.$threadId.tsx | 261 +++++++- apps/web/src/wsNativeApi.ts | 27 + packages/contracts/src/ipc.ts | 75 +++ 12 files changed, 1788 insertions(+), 16 deletions(-) create mode 100644 .plans/18-browser-panel-shell-and-runtime.md create mode 100644 apps/desktop/src/browserManager.ts create mode 100644 apps/web/src/blockingOverlayStore.ts diff --git a/.plans/18-browser-panel-shell-and-runtime.md b/.plans/18-browser-panel-shell-and-runtime.md new file mode 100644 index 000000000..8c989532f --- /dev/null +++ b/.plans/18-browser-panel-shell-and-runtime.md @@ -0,0 +1,603 @@ +# Plan: Rebuild Browser Panel Shell and Electron Runtime + +## Summary + +Recreate the in-app browser feature in two layers: + +1. A reusable browser shell in the web app that lives in the existing right-side panel. +2. A desktop-only Electron runtime that renders real browser content behind that shell using `WebContentsView`. + +This plan is intentionally reconstruction-oriented. A fresh session should be able to follow it and rebuild the same behavior, UI, ownership boundaries, and tradeoffs that exist in the current implementation. + +## Goals + +- Add a polished browser panel UI that shares the right panel with the diff viewer. +- Keep browser tabs and right-panel visibility scoped per thread and persisted. +- Support keyboard shortcuts for browser toggle/new tab/close tab. +- Render real browser content in Electron behind the shell. +- Keep the renderer as the owner of browser UI state. +- Keep Electron as the owner of live browser runtime state. +- Bound native resource usage with an LRU warm-tab budget. + +## Non-Goals + +- No browser runtime for plain web builds. +- No persistent page session restoration beyond shell metadata. +- No in-toolbar DevTools button. +- No attempt to preserve JS/history state for evicted tabs. + +## Final User Experience + +### Right Panel + +- The right panel can show either: + - diff + - browser +- Only one can be shown at a time. +- Diff and browser are toggled from a joined segmented control in the chat header. +- The selected right panel is remembered per thread. +- Closing and reopening the panel restores the thread's last selected panel. + +### Browser Shell + +- The browser panel has three vertical sections: + - top tab strip + - compact navigation row + - viewport area +- Tabs are horizontally scrollable and visually styled like a desktop in-app browser. +- The `+` button creates new tabs and remains visible when the strip overflows. +- The URL field is controlled and supports bare host input like `localhost:3000`. +- Invalid input shows a bottom error banner instead of corrupting tab state. +- Empty tabs show a centered empty state message. + +### Desktop Runtime + +- Real web content is rendered only in Electron. +- Only one native browser view is attached to the window at a time: + - active thread + - active browser tab + - browser panel visible +- Hidden tabs are not all kept live forever. +- A small global warm-tab cache keeps recently used tabs alive. +- Older hidden tabs are evicted and later restored by reloading their URL. + +## Architecture + +## Renderer Ownership + +The web app owns: + +- per-thread browser tabs +- active tab id +- URL input value +- focus request id +- right-panel selected state +- last selected right-panel state +- browser shell rendering +- browser tab metadata projected from native runtime: + - title + - favicon + - loading state + - history affordances + - last error + +## Electron Ownership + +The desktop app owns: + +- native browser instances (`WebContentsView`) +- real navigation/runtime state +- browser tab lifecycle for live native views +- viewport attachment and bounds +- LRU live-tab eviction +- forwarding tab runtime updates back to the renderer + +## Shared Boundary + +`packages/contracts/src/ipc.ts` defines the browser IPC contract used by: + +- Electron preload bridge +- Electron main process handlers +- web `NativeApi` + +The browser IPC surface needs: + +- `ensureTab` +- `navigate` +- `goBack` +- `goForward` +- `reload` +- `closeTab` +- `syncHost` +- `clearThread` +- `onEvent` + +The main browser event shape is a tab-state event carrying: + +- `threadId` +- `tabId` +- `url` +- `title` +- `faviconUrl` +- `isLoading` +- `canGoBack` +- `canGoForward` +- `lastError` + +## Phase 1: Shared Browser Shell Utilities + +Add `apps/web/src/browser.ts`. + +It should export: + +- `BrowserTab` type +- helper to create a new blank tab +- helper to normalize `about:blank` for the address bar +- helper to derive a tab label +- helper to parse submitted URLs into a success/error result + +Required parsing behavior: + +- blank input becomes `about:blank` +- exact `about:blank` stays unchanged +- already-schemed URLs are parsed as-is +- bare hosts like `localhost:3000`, `127.0.0.1:3000`, and `example.com` get `http://` +- malformed input returns `{ ok: false, error: "Enter a valid URL." }` + +This module exists to prevent route-local duplication and to keep parsing/label logic reusable. + +## Phase 2: Reusable Browser Panel Component + +Add `apps/web/src/components/BrowserPanel.tsx`. + +The component is presentation-focused and reusable. It accepts: + +- browser tab state +- active tab +- controlled input value +- focus request id +- tab callbacks +- browser action callbacks +- optional viewport ref +- optional shortcut labels for new/close tab + +### Layout + +- full-height vertical panel +- `52px` tab row +- `44px` nav row +- remaining height is viewport + +### Tab Strip Behavior + +- horizontally scrollable +- hidden scrollbar +- overflow tracked with `ResizeObserver` +- `useLayoutEffect` scrolls the active tab fully into view when selection changes or tabs are created +- tabs use: + - loading icon when loading + - favicon when available + - globe fallback otherwise +- close button and new-tab button have shortcut-aware tooltips +- active tab uses stronger foreground, filled surface, and visual connection to the row edge +- inactive tabs remain transparent +- inactive hover should not add a background fill +- divider slots between non-selected neighboring tabs must preserve layout and only toggle opacity + +### Final Tab Styling Details + +- remove real bottom border from the row container +- render a positioned bottom divider behind the tabs instead +- active tab should visually sit on top of that divider +- use `-mb-px` and a 1px downward translation so the active tab merges into the panel edge +- tab padding is `pl-3 pr-2` +- new-tab button is compact, square-ish, rounded, and slightly lifted +- sticky overflow new-tab container should not use a gradient background + +### Nav Row + +- buttons: + - back + - forward + - reload + - URL input + - open externally +- no DevTools toolbar button +- URL input should be compact and fully controlled +- when `focusRequestId` changes, focus and select all text + +### Viewport + +- include an `absolute inset-0` host div via `viewportRef` +- empty state shown for no active tab or `about:blank` +- bottom floating error banner when `lastError` exists + +### Electron Drag Regions + +Because the desktop app uses a hidden inset titlebar: + +- the top strip can remain inside a drag region +- interactive controls must be marked `no-drag` +- tabs and the `+` button must stay clickable in compact sidebar layouts + +## Phase 3: Per-Thread Browser Store + +Add `apps/web/src/browserStateStore.ts`. + +Use Zustand `persist(createJSONStorage(() => localStorage))`. + +State shape per thread: + +- `activeTabId` +- `tabs` +- `inputValue` +- `focusRequestId` + +Key requirements: + +- browser state is keyed by `threadId` +- switching threads restores that thread's browser tabs and input state +- reloads restore browser state +- orphaned thread entries can be removed centrally + +### Equality Requirement + +No-op updates must preserve identity. + +Do not always recreate the `tabs` array during normalization. If a caller updates a thread state with the same object, the store should not produce new references or unnecessary persisted writes. + +Add a focused regression test for this behavior. + +## Phase 4: Per-Thread Right Panel Store + +Add `apps/web/src/rightPanelStateStore.ts`. + +Also use persisted Zustand state keyed by `threadId`. + +State per thread: + +- `selectedPanel: "diff" | "browser" | null` +- `lastSelectedPanel: "diff" | "browser"` + +Behavior: + +- right-panel visibility is thread-owned +- browser visibility is restored when switching back to a thread +- diff visibility is also restored per thread +- reopening the panel from closed state restores the thread's last selected panel + +Diff deep-link payload should remain URL-based. Normal panel visibility should not. + +## Phase 5: Integrate Browser Shell Into Chat Route + +Update `apps/web/src/routes/_chat.$threadId.tsx`. + +The route should: + +- use the persisted browser store instead of local browser state +- use the persisted right-panel store instead of relying on URL state for normal open/close +- render `BrowserPanel` in the same right-side panel container used by diff +- ensure only one of diff or browser is visible + +### Diff Search Rules + +Keep diff search params for explicit deep links: + +- `diff` +- `diffTurnId` +- `diffFilePath` + +If diff deep-link params are present: + +- force diff open +- sync `"diff"` into the right-panel store + +When switching away from diff or closing the panel: + +- strip diff params from the URL + +### Browser Shell Route Logic + +The route should support: + +- lazily creating the first blank tab when the browser panel opens +- creating tabs +- activating tabs +- closing tabs +- syncing the controlled URL input to the active tab +- parsing submitted URLs through the shared helper +- updating `lastError` on invalid input +- opening the current URL externally via native shell + +For shell-only mode before native runtime exists: + +- `back` +- `forward` +- `reload` + +can remain no-ops. + +## Phase 6: Header Toggle Group and Keyboard Shortcuts + +Update `apps/web/src/components/ChatView.tsx`. + +### Header Toggle Group + +Add a globe toggle next to diff, but render both as a joined segmented control, not two visually separate buttons. + +Behavior: + +- only one of diff/browser can be active +- clicking the inactive segment switches panels +- clicking the active segment closes the panel +- shortcut-aware tooltip on both controls + +### Keyboard Commands + +Add commands across contracts, server defaults, and frontend keybinding resolution: + +- `browser.toggle` +- `browser.newTab` +- `browser.closeTab` + +Default bindings: + +- `mod+b` => browser toggle +- `mod+t` => new tab +- `mod+w` => close active browser tab + +Existing `mod+d` still toggles diff. + +`mod+t` should open the browser panel if it is currently hidden. + +## Phase 7: Browser IPC Contract and Bridges + +Update `packages/contracts/src/ipc.ts`, `apps/desktop/src/preload.ts`, `apps/desktop/src/main.ts`, and `apps/web/src/wsNativeApi.ts`. + +### Contract + +Add browser-specific IPC input and event types. + +### Preload + +Expose browser methods from Electron preload. + +### Web Native API + +Expose the browser API through the existing `NativeApi`. + +Plain web builds should remain safe no-ops. + +## Phase 8: Electron Browser Runtime + +Add `apps/desktop/src/browserManager.ts`. + +This module is the core runtime for native browser content. + +### Record Model + +Keep one record per `threadId + tabId`. + +Each record stores: + +- thread id +- tab id +- current runtime state +- optional live `WebContentsView` +- last-access timestamp + +### Core Behavior + +- `ensureTab` creates missing records only +- existing records must not be overwritten from stale renderer state +- `navigate` updates the live runtime and loads the requested URL +- `goBack`, `goForward`, `reload`, `closeTab`, `clearThread` operate on the matching record(s) + +### Event Wiring + +Each live `WebContentsView` should listen for navigation/runtime changes and emit browser tab-state events back to the renderer. + +Key projected fields: + +- URL +- title +- favicon +- loading +- history affordances +- last error + +### Host Attachment + +The manager should attach exactly one native view to the BrowserWindow at a time: + +- only when browser panel is selected +- only for the active thread +- only for the active tab +- only when viewport bounds are known + +Everything else stays detached. + +## Phase 9: Viewport Host Sync + +Use the `viewportRef` already present in the browser panel. + +From the chat route: + +- measure the viewport host with `getBoundingClientRect()` +- send bounds to `api.browser.syncHost(...)` +- send `visible: false` or `bounds: null` when browser should be hidden + +### Important Animation Behavior + +When the right panel opens with an animation, bounds are not stable immediately. + +Use a short `requestAnimationFrame` sync burst after the browser panel becomes visible so the native view tracks the animated position until it stabilizes. + +### Immediate Activation Sync + +When the user activates a tab: + +- immediately update the controlled address bar value +- immediately sync the host to the selected tab + +Do not wait only for follow-up effects, or the shell will visibly lag behind the tab selection. + +## Phase 10: Hide Native Browser Under Blocking Dialogs + +The native `WebContentsView` can visually sit above DOM overlays, so z-index is not sufficient. + +When blocking dialogs are visible: + +- temporarily hide the browser host +- restore it when dialogs close + +Use actual visible dialog detection instead of counting mounted dialog components, because some dialogs stay mounted while closed. + +The implemented approach should: + +- inspect dialog visibility markers in the DOM +- watch dialog open/close changes with a `MutationObserver` +- resync the native host whenever dialog visibility changes + +Do not globally count sheets, because the browser panel itself can be hosted inside a sheet on compact layouts. + +## Phase 11: Warm-Tab LRU Resource Budget + +Do not keep every tab across every thread as a live `WebContentsView`. + +Introduce a global warm-tab budget in `browserManager.ts`. + +The implementation target is: + +- keep at most 3 live native tabs globally +- always keep the active tab live +- allow recently used hidden tabs to remain warm +- evict the least recently used hidden live tab when over budget + +### Eviction Behavior + +When a tab is evicted: + +- destroy its live `WebContentsView` +- keep lightweight metadata +- reset `canGoBack` and `canGoForward` to `false` + +This is an intentional tradeoff: + +- memory stays bounded +- hidden tabs do not keep unlimited Chromium resources alive +- evicted tabs lose in-page JS/history state +- revisiting a cold tab recreates the native view and reloads the URL + +## Phase 12: Cold-Tab Restore Correctness + +Be careful when reviving an evicted tab. + +Do not emit blank runtime state from a freshly created empty view before reloading the saved URL. That can overwrite the renderer's tab back to `about:blank`. + +Correct restore behavior: + +- recreate the live view +- keep stored metadata until real navigation events arrive +- load the stored URL +- let post-load runtime events update the projected state + +## Phase 13: Redirect and Address-Bar Correctness + +Avoid renderer/native URL ownership fights. + +Specifically: + +- `ensureTab` should not keep pushing renderer tab URLs into an already existing native tab +- explicit navigation should be the only path that changes runtime URL from renderer intent + +This prevents `http -> https` redirect loops where the address bar oscillates between the stale submitted URL and the real runtime URL. + +## Phase 14: Root Route Event Subscription and Cleanup + +Update `apps/web/src/routes/__root.tsx`. + +Responsibilities: + +- subscribe once to browser native events +- merge incoming tab-state events into the per-thread browser store +- clear orphaned native browser threads when thread state is cleaned up +- clear orphaned persisted browser state +- clear orphaned persisted right-panel state + +Renderer store remains the source of truth for tab existence and ordering. Native events should update matching tabs, not invent new renderer tabs. + +## Phase 15: CSS + +Update `apps/web/src/index.css`. + +Add browser tab-strip scrollbar hiding rules. + +The final implementation hides the horizontal scrollbar entirely rather than using a thin visible scrollbar. + +## Files To Add + +- `apps/web/src/browser.ts` +- `apps/web/src/browserStateStore.ts` +- `apps/web/src/browserStateStore.test.ts` +- `apps/web/src/components/BrowserPanel.tsx` +- `apps/web/src/rightPanelStateStore.ts` +- `apps/desktop/src/browserManager.ts` + +## Files To Modify + +- `apps/server/src/keybindings.ts` +- `apps/web/src/components/ChatView.tsx` +- `apps/web/src/diffRouteSearch.ts` +- `apps/web/src/index.css` +- `apps/web/src/keybindings.ts` +- `apps/web/src/keybindings.test.ts` +- `apps/web/src/routes/__root.tsx` +- `apps/web/src/routes/_chat.$threadId.tsx` +- `apps/web/src/wsNativeApi.ts` +- `apps/desktop/src/main.ts` +- `apps/desktop/src/preload.ts` +- `packages/contracts/src/ipc.ts` +- `packages/contracts/src/keybindings.ts` +- `packages/contracts/src/keybindings.test.ts` + +## Testing and Validation + +Required checks: + +- `bun fmt` +- `bun lint` +- `bun typecheck` + +Recommended targeted tests: + +- browser store no-op identity regression +- keybinding contract/default coverage for browser commands +- web native API browser bridge tests + +Recommended manual desktop smoke checks: + +- browser panel open/close +- diff/browser toggle switching +- `cmd+d`, `cmd+b`, `cmd+t`, `cmd+w` +- opening more than 3 tabs and returning to older tabs +- `http -> https` redirect behavior +- thread switching with browser tabs in multiple threads +- blocking dialog open/close over visible browser content +- right-panel open animation while browser content is visible +- external-open action + +## Done Criteria + +- Browser shell exists and matches the tuned desktop-style UI. +- Diff and browser share the same right-side panel and behave as a joined toggle group. +- Browser tab state is persisted per thread. +- Right-panel visibility is persisted per thread. +- Diff deep links still work through URL params. +- Electron renders real browser content behind the shell. +- Only one native browser host is attached at a time. +- Native browser resource usage is bounded by a global warm-tab budget. +- Cold-tab restore works correctly. +- Redirected URLs do not fight the address bar. +- Dialogs correctly hide native browser content. +- `bun fmt`, `bun lint`, and `bun typecheck` pass. diff --git a/apps/desktop/src/browserManager.ts b/apps/desktop/src/browserManager.ts new file mode 100644 index 000000000..bb440b82b --- /dev/null +++ b/apps/desktop/src/browserManager.ts @@ -0,0 +1,482 @@ +import { WebContentsView, type BrowserWindow, type Rectangle } from "electron"; +import type { + BrowserClearThreadInput, + BrowserEnsureTabInput, + BrowserEvent, + BrowserNavigateInput, + BrowserSyncHostInput, + BrowserTabRuntimeState, + BrowserTabTargetInput, + ThreadId, +} from "@t3tools/contracts"; + +const ERR_ABORTED = -3; +const MAX_LIVE_BROWSER_TABS = 3; + +type BrowserTabRecord = { + key: string; + threadId: ThreadId; + tabId: string; + view: WebContentsView | null; + state: BrowserTabRuntimeState; + lastAccessedAt: number; +}; + +export interface BrowserManager { + ensureTab: (input: BrowserEnsureTabInput) => Promise; + navigate: (input: BrowserNavigateInput) => Promise; + goBack: (input: BrowserTabTargetInput) => Promise; + goForward: (input: BrowserTabTargetInput) => Promise; + reload: (input: BrowserTabTargetInput) => Promise; + closeTab: (input: BrowserTabTargetInput) => Promise; + syncHost: (input: BrowserSyncHostInput) => void; + clearThread: (input: BrowserClearThreadInput) => void; + destroyAll: () => void; +} + +interface BrowserManagerOptions { + emitEvent: (event: BrowserEvent) => void; + getWindow: () => BrowserWindow | null; + openExternal: (url: string) => void | Promise; +} + +function recordKey(threadId: ThreadId, tabId: string): string { + return `${threadId}\u0000${tabId}`; +} + +function normalizeRuntimeUrl(url: string | null | undefined): string { + if (!url || url.trim().length === 0) { + return "about:blank"; + } + return url; +} + +function readBrowserTitle(view: WebContentsView, fallback: string | null): string | null { + const title = view.webContents.getTitle().trim(); + if (title.length > 0) { + return title; + } + return fallback; +} + +function now(): number { + return Date.now(); +} + +function statesEqual(left: BrowserTabRuntimeState, right: BrowserTabRuntimeState): boolean { + return ( + left.url === right.url && + left.title === right.title && + left.faviconUrl === right.faviconUrl && + left.isLoading === right.isLoading && + left.canGoBack === right.canGoBack && + left.canGoForward === right.canGoForward && + left.lastError === right.lastError + ); +} + +function sanitizeBounds(bounds: BrowserSyncHostInput["bounds"]): Rectangle | null { + if (!bounds) { + return null; + } + const x = Number.isFinite(bounds.x) ? Math.max(0, Math.round(bounds.x)) : null; + const y = Number.isFinite(bounds.y) ? Math.max(0, Math.round(bounds.y)) : null; + const width = Number.isFinite(bounds.width) ? Math.max(0, Math.round(bounds.width)) : null; + const height = Number.isFinite(bounds.height) ? Math.max(0, Math.round(bounds.height)) : null; + if ( + x === null || + y === null || + width === null || + height === null || + width === 0 || + height === 0 + ) { + return null; + } + return { x, y, width, height }; +} + +export function createBrowserManager(options: BrowserManagerOptions): BrowserManager { + const records = new Map(); + let activeHost: BrowserSyncHostInput | null = null; + let attachedRecordKey: string | null = null; + + const emitState = ( + record: BrowserTabRecord, + patch: Partial = {}, + emitOptions: { preferBlankTitle?: boolean } = {}, + ): void => { + const runtimeUrl = record.view + ? normalizeRuntimeUrl(record.view.webContents.getURL()) + : normalizeRuntimeUrl(record.state.url); + const fallbackTitle = + emitOptions.preferBlankTitle || runtimeUrl === "about:blank" ? null : record.state.title; + const nextState: BrowserTabRuntimeState = { + url: patch.url ?? runtimeUrl ?? record.state.url, + title: + patch.title !== undefined + ? patch.title + : record.view + ? readBrowserTitle(record.view, fallbackTitle ?? record.state.title) + : (fallbackTitle ?? record.state.title), + faviconUrl: + patch.faviconUrl !== undefined ? patch.faviconUrl : (record.state.faviconUrl ?? null), + isLoading: patch.isLoading ?? (record.view ? record.view.webContents.isLoading() : false), + canGoBack: patch.canGoBack ?? (record.view ? record.view.webContents.canGoBack() : false), + canGoForward: + patch.canGoForward ?? (record.view ? record.view.webContents.canGoForward() : false), + lastError: patch.lastError !== undefined ? patch.lastError : record.state.lastError, + }; + if (nextState.url === "about:blank" && nextState.title === "") { + nextState.title = null; + } + if (statesEqual(record.state, nextState)) { + return; + } + record.state = nextState; + options.emitEvent({ + type: "tab-state", + threadId: record.threadId, + tabId: record.tabId, + state: nextState, + }); + }; + + const touchRecord = (record: BrowserTabRecord): void => { + record.lastAccessedAt = now(); + }; + + const detachRecord = (record: BrowserTabRecord | null): void => { + if (!record?.view) { + return; + } + record.view.setVisible(false); + const window = options.getWindow(); + if (!window) { + return; + } + if (window.contentView.children.includes(record.view)) { + window.contentView.removeChildView(record.view); + } + }; + + const disposeRecordView = (record: BrowserTabRecord): void => { + const view = record.view; + if (!view) { + return; + } + if (attachedRecordKey === record.key) { + detachRecord(record); + attachedRecordKey = null; + } else { + view.setVisible(false); + const window = options.getWindow(); + if (window && window.contentView.children.includes(view)) { + window.contentView.removeChildView(view); + } + } + record.view = null; + if (!view.webContents.isDestroyed()) { + view.webContents.close({ waitForBeforeUnload: false }); + } + emitState(record, { + isLoading: false, + canGoBack: false, + canGoForward: false, + }); + }; + + const enforceLiveTabBudget = (protectedRecordKey: string | null): void => { + const liveRecords = [...records.values()].filter((record) => record.view !== null); + if (liveRecords.length <= MAX_LIVE_BROWSER_TABS) { + return; + } + const protectedKeys = new Set(); + if (protectedRecordKey) { + protectedKeys.add(protectedRecordKey); + } + if (activeHost?.visible && activeHost.tabId) { + protectedKeys.add(recordKey(activeHost.threadId, activeHost.tabId)); + } + const evictionCandidates = liveRecords + .filter((record) => !protectedKeys.has(record.key)) + .toSorted((left, right) => left.lastAccessedAt - right.lastAccessedAt); + + while (liveRecords.filter((record) => record.view !== null).length > MAX_LIVE_BROWSER_TABS) { + const nextCandidate = evictionCandidates.shift(); + if (!nextCandidate) { + break; + } + disposeRecordView(nextCandidate); + } + }; + + const wireRecordEvents = (record: BrowserTabRecord, view: WebContentsView): void => { + const { webContents } = view; + const isCurrentView = () => record.view === view; + const emitIfCurrent = ( + patch: Partial = {}, + emitOptions: { preferBlankTitle?: boolean } = {}, + ) => { + if (!isCurrentView()) { + return; + } + touchRecord(record); + emitState(record, patch, emitOptions); + }; + + webContents.setWindowOpenHandler(({ url }) => { + void options.openExternal(url); + return { action: "deny" }; + }); + webContents.on("did-start-loading", () => { + emitIfCurrent({ isLoading: true, lastError: null }); + }); + webContents.on("did-stop-loading", () => { + emitIfCurrent({ isLoading: false }); + }); + webContents.on("did-navigate", (_event, url) => { + emitIfCurrent({ url: normalizeRuntimeUrl(url), lastError: null }, { preferBlankTitle: true }); + }); + webContents.on("did-navigate-in-page", (_event, url) => { + emitIfCurrent({ url: normalizeRuntimeUrl(url), lastError: null }, { preferBlankTitle: true }); + }); + webContents.on("page-title-updated", (event, title) => { + event.preventDefault(); + emitIfCurrent({ title: title.trim().length > 0 ? title : null }); + }); + webContents.on("page-favicon-updated", (_event, favicons) => { + emitIfCurrent({ faviconUrl: favicons[0] ?? null }); + }); + webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame || errorCode === ERR_ABORTED) { + return; + } + emitIfCurrent({ + url: normalizeRuntimeUrl(validatedURL), + isLoading: false, + lastError: errorDescription || "Failed to load page.", + }); + }, + ); + webContents.on("render-process-gone", (_event, details) => { + emitIfCurrent({ + isLoading: false, + lastError: `Browser tab crashed (${details.reason}).`, + }); + }); + webContents.once("destroyed", () => { + if (!isCurrentView()) { + return; + } + if (attachedRecordKey === record.key) { + attachedRecordKey = null; + } + record.view = null; + emitState(record, { + isLoading: false, + canGoBack: false, + canGoForward: false, + }); + syncAttachedView(); + }); + }; + + const createLiveViewForRecord = ( + record: BrowserTabRecord, + options: { restoreFromState?: boolean } = {}, + ): WebContentsView => { + if (record.view) { + touchRecord(record); + return record.view; + } + const view = new WebContentsView(); + view.setVisible(false); + record.view = view; + touchRecord(record); + wireRecordEvents(record, view); + if (options.restoreFromState && record.state.url !== "about:blank") { + void loadRecordUrl(record, record.state.url); + } + enforceLiveTabBudget(record.key); + return view; + }; + + const syncAttachedView = (): void => { + const window = options.getWindow(); + const desiredRecord = + activeHost && activeHost.visible && activeHost.tabId && sanitizeBounds(activeHost.bounds) + ? (records.get(recordKey(activeHost.threadId, activeHost.tabId)) ?? null) + : null; + const attachedRecord = attachedRecordKey ? (records.get(attachedRecordKey) ?? null) : null; + if (attachedRecord && (!window || !desiredRecord || desiredRecord.key !== attachedRecord.key)) { + detachRecord(attachedRecord); + attachedRecordKey = null; + } + if (!window || !desiredRecord || !activeHost) { + return; + } + const bounds = sanitizeBounds(activeHost.bounds); + if (!bounds) { + detachRecord(desiredRecord); + attachedRecordKey = null; + return; + } + createLiveViewForRecord(desiredRecord, { restoreFromState: true }); + touchRecord(desiredRecord); + if (!desiredRecord.view) { + return; + } + desiredRecord.view.setBounds(bounds); + desiredRecord.view.setVisible(true); + window.contentView.addChildView(desiredRecord.view); + attachedRecordKey = desiredRecord.key; + }; + + const destroyRecord = (record: BrowserTabRecord): void => { + disposeRecordView(record); + records.delete(record.key); + syncAttachedView(); + }; + + const createRecord = (input: BrowserEnsureTabInput): BrowserTabRecord => { + const key = recordKey(input.threadId, input.tabId); + const record: BrowserTabRecord = { + key, + threadId: input.threadId, + tabId: input.tabId, + view: null, + state: { + url: normalizeRuntimeUrl(input.url), + title: null, + faviconUrl: null, + isLoading: false, + canGoBack: false, + canGoForward: false, + lastError: null, + }, + lastAccessedAt: now(), + }; + records.set(key, record); + emitState(record, { url: normalizeRuntimeUrl(input.url) }, { preferBlankTitle: true }); + return record; + }; + + const loadRecordUrl = async (record: BrowserTabRecord, url: string): Promise => { + createLiveViewForRecord(record); + emitState( + record, + { + url, + title: url === "about:blank" ? null : record.state.title, + faviconUrl: url === "about:blank" ? null : record.state.faviconUrl, + isLoading: url !== "about:blank", + lastError: null, + }, + { preferBlankTitle: url === "about:blank" }, + ); + try { + if (!record.view) { + return; + } + await record.view.webContents.loadURL(url); + } catch (error) { + if (!record.view || record.view.webContents.isDestroyed()) { + return; + } + emitState(record, { + url, + isLoading: false, + lastError: error instanceof Error ? error.message : "Failed to load page.", + }); + } + }; + + const ensureTab = async (input: BrowserEnsureTabInput): Promise => { + const key = recordKey(input.threadId, input.tabId); + const existing = records.get(key); + if (existing) { + return; + } + createRecord(input); + syncAttachedView(); + }; + + return { + ensureTab, + navigate: async (input) => { + await ensureTab(input); + const record = records.get(recordKey(input.threadId, input.tabId)); + if (!record) { + return; + } + await loadRecordUrl(record, input.url); + syncAttachedView(); + }, + goBack: async (input) => { + const record = records.get(recordKey(input.threadId, input.tabId)); + if (!record?.view || !record.view.webContents.canGoBack()) { + return; + } + touchRecord(record); + record.view.webContents.goBack(); + }, + goForward: async (input) => { + const record = records.get(recordKey(input.threadId, input.tabId)); + if (!record?.view || !record.view.webContents.canGoForward()) { + return; + } + touchRecord(record); + record.view.webContents.goForward(); + }, + reload: async (input) => { + const record = records.get(recordKey(input.threadId, input.tabId)); + if (!record) { + return; + } + if (!record.view) { + await loadRecordUrl(record, record.state.url); + syncAttachedView(); + return; + } + touchRecord(record); + record.view.webContents.reload(); + }, + closeTab: async (input) => { + const record = records.get(recordKey(input.threadId, input.tabId)); + if (!record) { + return; + } + destroyRecord(record); + }, + syncHost: (input) => { + activeHost = input; + syncAttachedView(); + }, + clearThread: (input) => { + for (const record of records.values()) { + if (record.threadId !== input.threadId) { + continue; + } + destroyRecord(record); + } + if (activeHost?.threadId === input.threadId) { + activeHost = { + threadId: input.threadId, + tabId: null, + visible: false, + bounds: null, + }; + } + syncAttachedView(); + }, + destroyAll: () => { + activeHost = null; + for (const record of records.values()) { + destroyRecord(record); + } + }, + }; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 443492ada..ac3dc61f1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -18,6 +18,12 @@ import { import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; import type { + BrowserClearThreadInput, + BrowserEnsureTabInput, + BrowserEvent, + BrowserNavigateInput, + BrowserSyncHostInput, + BrowserTabTargetInput, DesktopTheme, DesktopUpdateActionResult, DesktopUpdateState, @@ -30,6 +36,7 @@ import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { fixPath } from "./fixPath"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; +import { createBrowserManager } from "./browserManager"; import { createInitialDesktopUpdateState, reduceDesktopUpdateStateOnCheckFailure, @@ -51,6 +58,15 @@ const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +const BROWSER_ENSURE_TAB_CHANNEL = "desktop:browser-ensure-tab"; +const BROWSER_NAVIGATE_CHANNEL = "desktop:browser-navigate"; +const BROWSER_GO_BACK_CHANNEL = "desktop:browser-go-back"; +const BROWSER_GO_FORWARD_CHANNEL = "desktop:browser-go-forward"; +const BROWSER_RELOAD_CHANNEL = "desktop:browser-reload"; +const BROWSER_CLOSE_TAB_CHANNEL = "desktop:browser-close-tab"; +const BROWSER_SYNC_HOST_CHANNEL = "desktop:browser-sync-host"; +const BROWSER_CLEAR_THREAD_CHANNEL = "desktop:browser-clear-thread"; +const BROWSER_EVENT_CHANNEL = "desktop:browser-event"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; @@ -160,6 +176,125 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } +function getSafeNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function getSafeBrowserTabTargetInput(rawInput: unknown): BrowserTabTargetInput | null { + if (typeof rawInput !== "object" || rawInput === null) { + return null; + } + const threadId = getSafeNonEmptyString(Reflect.get(rawInput, "threadId")); + const tabId = getSafeNonEmptyString(Reflect.get(rawInput, "tabId")); + if (!threadId || !tabId) { + return null; + } + return { + threadId: threadId as BrowserTabTargetInput["threadId"], + tabId, + } satisfies BrowserTabTargetInput; +} + +function getSafeBrowserEnsureTabInput(rawInput: unknown): BrowserEnsureTabInput | null { + const target = getSafeBrowserTabTargetInput(rawInput); + if (!target) { + return null; + } + const urlRaw = Reflect.get(rawInput as object, "url"); + const url = urlRaw === undefined ? undefined : getSafeNonEmptyString(urlRaw); + if (urlRaw !== undefined && !url) { + return null; + } + return { + ...target, + ...(url ? { url } : {}), + }; +} + +function getSafeBrowserNavigateInput(rawInput: unknown): BrowserNavigateInput | null { + const target = getSafeBrowserTabTargetInput(rawInput); + if (!target) { + return null; + } + const url = getSafeNonEmptyString(Reflect.get(rawInput as object, "url")); + if (!url) { + return null; + } + return { + ...target, + url, + }; +} + +function getSafeBrowserBounds(rawBounds: unknown): BrowserSyncHostInput["bounds"] { + if (rawBounds === null) { + return null; + } + if (typeof rawBounds !== "object" || rawBounds === null) { + return null; + } + const x = Reflect.get(rawBounds, "x"); + const y = Reflect.get(rawBounds, "y"); + const width = Reflect.get(rawBounds, "width"); + const height = Reflect.get(rawBounds, "height"); + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(width) || + !Number.isFinite(height) + ) { + return null; + } + return { + x, + y, + width, + height, + } satisfies NonNullable; +} + +function getSafeBrowserSyncHostInput(rawInput: unknown): BrowserSyncHostInput | null { + if (typeof rawInput !== "object" || rawInput === null) { + return null; + } + const threadId = getSafeNonEmptyString(Reflect.get(rawInput, "threadId")); + if (!threadId) { + return null; + } + const rawTabId = Reflect.get(rawInput, "tabId"); + const tabId = + rawTabId === null + ? null + : typeof rawTabId === "string" + ? getSafeNonEmptyString(rawTabId) + : null; + const visible = Reflect.get(rawInput, "visible"); + if (typeof visible !== "boolean") { + return null; + } + return { + threadId: threadId as BrowserSyncHostInput["threadId"], + tabId, + visible, + bounds: getSafeBrowserBounds(Reflect.get(rawInput, "bounds")), + }; +} + +function getSafeBrowserClearThreadInput(rawInput: unknown): BrowserClearThreadInput | null { + if (typeof rawInput !== "object" || rawInput === null) { + return null; + } + const threadId = getSafeNonEmptyString(Reflect.get(rawInput, "threadId")); + if (!threadId) { + return null; + } + return { threadId: threadId as BrowserClearThreadInput["threadId"] }; +} + function writeDesktopStreamChunk( streamName: "stdout" | "stderr", chunk: unknown, @@ -277,6 +412,22 @@ let updateCheckInFlight = false; let updateDownloadInFlight = false; let updaterConfigured = false; let updateState: DesktopUpdateState = initialUpdateState(); +const browserManager = createBrowserManager({ + emitEvent: (event: BrowserEvent) => { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) continue; + window.webContents.send(BROWSER_EVENT_CHANNEL, event); + } + }, + getWindow: () => mainWindow, + openExternal: (url) => { + const externalUrl = getSafeExternalUrl(url); + if (!externalUrl) { + return; + } + void shell.openExternal(externalUrl); + }, +}); function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { if (updateDownloadInFlight) return "download"; @@ -1160,6 +1311,78 @@ function registerIpcHandlers(): void { } }); + ipcMain.removeHandler(BROWSER_ENSURE_TAB_CHANNEL); + ipcMain.handle(BROWSER_ENSURE_TAB_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserEnsureTabInput(rawInput); + if (!input) { + return; + } + await browserManager.ensureTab(input); + }); + + ipcMain.removeHandler(BROWSER_NAVIGATE_CHANNEL); + ipcMain.handle(BROWSER_NAVIGATE_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserNavigateInput(rawInput); + if (!input) { + return; + } + await browserManager.navigate(input); + }); + + ipcMain.removeHandler(BROWSER_GO_BACK_CHANNEL); + ipcMain.handle(BROWSER_GO_BACK_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserTabTargetInput(rawInput); + if (!input) { + return; + } + await browserManager.goBack(input); + }); + + ipcMain.removeHandler(BROWSER_GO_FORWARD_CHANNEL); + ipcMain.handle(BROWSER_GO_FORWARD_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserTabTargetInput(rawInput); + if (!input) { + return; + } + await browserManager.goForward(input); + }); + + ipcMain.removeHandler(BROWSER_RELOAD_CHANNEL); + ipcMain.handle(BROWSER_RELOAD_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserTabTargetInput(rawInput); + if (!input) { + return; + } + await browserManager.reload(input); + }); + + ipcMain.removeHandler(BROWSER_CLOSE_TAB_CHANNEL); + ipcMain.handle(BROWSER_CLOSE_TAB_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserTabTargetInput(rawInput); + if (!input) { + return; + } + await browserManager.closeTab(input); + }); + + ipcMain.removeHandler(BROWSER_SYNC_HOST_CHANNEL); + ipcMain.handle(BROWSER_SYNC_HOST_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserSyncHostInput(rawInput); + if (!input) { + return; + } + browserManager.syncHost(input); + }); + + ipcMain.removeHandler(BROWSER_CLEAR_THREAD_CHANNEL); + ipcMain.handle(BROWSER_CLEAR_THREAD_CHANNEL, async (_event, rawInput: unknown) => { + const input = getSafeBrowserClearThreadInput(rawInput); + if (!input) { + return; + } + browserManager.clearThread(input); + }); + ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState); @@ -1314,6 +1537,7 @@ app.on("before-quit", () => { isQuitting = true; writeDesktopLogHeader("before-quit received"); clearUpdatePollTimer(); + browserManager.destroyAll(); stopBackend(); restoreStdIoCapture?.(); }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8..bab8c49e5 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -6,6 +6,15 @@ const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +const BROWSER_ENSURE_TAB_CHANNEL = "desktop:browser-ensure-tab"; +const BROWSER_NAVIGATE_CHANNEL = "desktop:browser-navigate"; +const BROWSER_GO_BACK_CHANNEL = "desktop:browser-go-back"; +const BROWSER_GO_FORWARD_CHANNEL = "desktop:browser-go-forward"; +const BROWSER_RELOAD_CHANNEL = "desktop:browser-reload"; +const BROWSER_CLOSE_TAB_CHANNEL = "desktop:browser-close-tab"; +const BROWSER_SYNC_HOST_CHANNEL = "desktop:browser-sync-host"; +const BROWSER_CLEAR_THREAD_CHANNEL = "desktop:browser-clear-thread"; +const BROWSER_EVENT_CHANNEL = "desktop:browser-event"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; @@ -20,6 +29,25 @@ contextBridge.exposeInMainWorld("desktopBridge", { setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), + browserEnsureTab: (input) => ipcRenderer.invoke(BROWSER_ENSURE_TAB_CHANNEL, input), + browserNavigate: (input) => ipcRenderer.invoke(BROWSER_NAVIGATE_CHANNEL, input), + browserGoBack: (input) => ipcRenderer.invoke(BROWSER_GO_BACK_CHANNEL, input), + browserGoForward: (input) => ipcRenderer.invoke(BROWSER_GO_FORWARD_CHANNEL, input), + browserReload: (input) => ipcRenderer.invoke(BROWSER_RELOAD_CHANNEL, input), + browserCloseTab: (input) => ipcRenderer.invoke(BROWSER_CLOSE_TAB_CHANNEL, input), + browserSyncHost: (input) => ipcRenderer.invoke(BROWSER_SYNC_HOST_CHANNEL, input), + browserClearThread: (input) => ipcRenderer.invoke(BROWSER_CLEAR_THREAD_CHANNEL, input), + onBrowserEvent: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, payload: unknown) => { + if (typeof payload !== "object" || payload === null) return; + listener(payload as Parameters[0]); + }; + + ipcRenderer.on(BROWSER_EVENT_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(BROWSER_EVENT_CHANNEL, wrappedListener); + }; + }, onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/web/src/blockingOverlayStore.ts b/apps/web/src/blockingOverlayStore.ts new file mode 100644 index 000000000..78d2179a0 --- /dev/null +++ b/apps/web/src/blockingOverlayStore.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; + +interface BlockingOverlayStoreState { + blockingOverlayCount: number; + incrementBlockingOverlayCount: () => void; + decrementBlockingOverlayCount: () => void; +} + +export const useBlockingOverlayStore = create()((set) => ({ + blockingOverlayCount: 0, + incrementBlockingOverlayCount: () => + set((state) => ({ + blockingOverlayCount: state.blockingOverlayCount + 1, + })), + decrementBlockingOverlayCount: () => + set((state) => ({ + blockingOverlayCount: Math.max(0, state.blockingOverlayCount - 1), + })), +})); diff --git a/apps/web/src/components/BrowserPanel.tsx b/apps/web/src/components/BrowserPanel.tsx index c59e0c3e4..b8958bf1f 100644 --- a/apps/web/src/components/BrowserPanel.tsx +++ b/apps/web/src/components/BrowserPanel.tsx @@ -237,6 +237,7 @@ export default function BrowserPanel({ {state.tabs.map((tab, index) => { const previousTab = index > 0 ? (state.tabs[index - 1] ?? null) : null; const isActive = tab.id === state.activeTabId; + const tabLabel = getBrowserTabLabel(tab); const showDivider = previousTab !== null && previousTab.id !== state.activeTabId && @@ -247,7 +248,7 @@ export default function BrowserPanel({ {index > 0 ? : null}
{ onActivateTab(tab.id); }} - title={getBrowserTabLabel(tab)} + title={tabLabel} > - - {getBrowserTabLabel(tab)} - + {tabLabel} { diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index 5c65f261f..d5b4bf237 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -1,8 +1,10 @@ "use client"; import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; +import { useEffect } from "react"; import { cn } from "~/lib/utils"; +import { useBlockingOverlayStore } from "~/blockingOverlayStore"; const AlertDialogCreateHandle = AlertDialogPrimitive.createHandle; @@ -47,6 +49,20 @@ function AlertDialogPopup({ }: AlertDialogPrimitive.Popup.Props & { bottomStickOnMobile?: boolean; }) { + const incrementBlockingOverlayCount = useBlockingOverlayStore( + (store) => store.incrementBlockingOverlayCount, + ); + const decrementBlockingOverlayCount = useBlockingOverlayStore( + (store) => store.decrementBlockingOverlayCount, + ); + + useEffect(() => { + incrementBlockingOverlayCount(); + return () => { + decrementBlockingOverlayCount(); + }; + }, [decrementBlockingOverlayCount, incrementBlockingOverlayCount]); + return ( diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 080ac8099..e313603b3 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -2,9 +2,11 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { XIcon } from "lucide-react"; +import { useEffect } from "react"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { useBlockingOverlayStore } from "~/blockingOverlayStore"; const DialogCreateHandle = DialogPrimitive.createHandle; @@ -56,6 +58,20 @@ function DialogPopup({ showCloseButton?: boolean; bottomStickOnMobile?: boolean; }) { + const incrementBlockingOverlayCount = useBlockingOverlayStore( + (store) => store.incrementBlockingOverlayCount, + ); + const decrementBlockingOverlayCount = useBlockingOverlayStore( + (store) => store.decrementBlockingOverlayCount, + ); + + useEffect(() => { + incrementBlockingOverlayCount(); + return () => { + decrementBlockingOverlayCount(); + }; + }, [decrementBlockingOverlayCount, incrementBlockingOverlayCount]); + return ( diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index fd643a2fe..7d109e944 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -135,6 +135,7 @@ function errorDetails(error: unknown): string { function EventRouter() { const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setProjectExpanded = useStore((store) => store.setProjectExpanded); + const updateThreadBrowserState = useBrowserStateStore((store) => store.updateThreadBrowserState); const removeOrphanedBrowserStates = useBrowserStateStore( (store) => store.removeOrphanedBrowserStates, ); @@ -174,6 +175,12 @@ function EventRouter() { snapshotThreads: snapshot.threads, draftThreadIds, }); + const orphanedBrowserThreadIds = Object.keys( + useBrowserStateStore.getState().browserStateByThreadId, + ).filter((id) => !activeThreadIds.has(id as ThreadId)) as ThreadId[]; + for (const orphanedThreadId of orphanedBrowserThreadIds) { + void api.browser.clearThread({ threadId: orphanedThreadId }).catch(() => undefined); + } removeOrphanedBrowserStates(activeThreadIds); removeOrphanedRightPanelStates(activeThreadIds); removeOrphanedTerminalStates(activeThreadIds); @@ -239,6 +246,39 @@ function EventRouter() { hasRunningSubprocess, ); }); + const unsubBrowserEvent = api.browser.onEvent((event) => { + if (event.type !== "tab-state") { + return; + } + updateThreadBrowserState(event.threadId, (state) => { + const tabIndex = state.tabs.findIndex((tab) => tab.id === event.tabId); + if (tabIndex < 0) { + return state; + } + const existingTab = state.tabs[tabIndex]; + if (!existingTab) { + return state; + } + if ( + existingTab.url === event.state.url && + (existingTab.title ?? null) === event.state.title && + (existingTab.faviconUrl ?? null) === event.state.faviconUrl && + existingTab.isLoading === event.state.isLoading && + existingTab.canGoBack === event.state.canGoBack && + existingTab.canGoForward === event.state.canGoForward && + (existingTab.lastError ?? null) === event.state.lastError + ) { + return state; + } + const nextTab = { + ...existingTab, + ...event.state, + }; + const nextTabs = [...state.tabs]; + nextTabs[tabIndex] = nextTab; + return { ...state, tabs: nextTabs }; + }); + }); const unsubWelcome = onServerWelcome((payload) => { void (async () => { await syncSnapshot(); @@ -313,6 +353,7 @@ function EventRouter() { domainEventFlushThrottler.cancel(); unsubDomainEvent(); unsubTerminalEvent(); + unsubBrowserEvent(); unsubWelcome(); unsubServerConfigUpdated(); }; @@ -324,6 +365,7 @@ function EventRouter() { removeOrphanedTerminalStates, setProjectExpanded, syncServerReadModel, + updateThreadBrowserState, ]); return null; diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 46424030b..b9669f7b8 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -1,4 +1,4 @@ -import { type ResolvedKeybindingsConfig, ThreadId } from "@t3tools/contracts"; +import { type BrowserBounds, type ResolvedKeybindingsConfig, ThreadId } from "@t3tools/contracts"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { @@ -8,7 +8,9 @@ import { type ReactNode, useCallback, useEffect, + useLayoutEffect, useMemo, + useState, } from "react"; import ChatView from "../components/ChatView"; @@ -47,6 +49,32 @@ const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; const COMPOSER_COMPACT_MIN_LEFT_CONTROLS_WIDTH_PX = 208; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; +function getViewportBounds(element: HTMLDivElement): BrowserBounds { + const rect = element.getBoundingClientRect(); + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; +} + +function hasVisibleBlockingDialog(): boolean { + if (typeof document === "undefined") { + return false; + } + return ( + document.querySelector( + [ + '[data-slot="dialog-backdrop"]:not([data-closed]):not([hidden])', + '[data-slot="dialog-popup"]:not([data-closed]):not([hidden])', + '[data-slot="alert-dialog-backdrop"]:not([data-closed]):not([hidden])', + '[data-slot="alert-dialog-popup"]:not([data-closed]):not([hidden])', + ].join(", "), + ) !== null + ); +} + function resolveSelectedSidePanel(search: DiffRouteSearch): RightPanelKind | null { if (search.diff === "1" || search.diffTurnId) { return "diff"; @@ -211,6 +239,7 @@ function ChatThreadRouteView() { selectThreadBrowserState(state.browserStateByThreadId, threadId), ); const updateThreadBrowserState = useBrowserStateStore((state) => state.updateThreadBrowserState); + const [browserViewportElement, setBrowserViewportElement] = useState(null); const activeBrowserTab = useMemo( () => browserThreadState.tabs.find((tab) => tab.id === browserThreadState.activeTabId) ?? null, [browserThreadState.activeTabId, browserThreadState.tabs], @@ -242,6 +271,123 @@ function ChatThreadRouteView() { ); }, [activeBrowserTab?.id, activeBrowserTab?.url, threadId, updateThreadBrowserState]); + useEffect(() => { + const api = readNativeApi(); + if (!api) { + return; + } + for (const tab of browserThreadState.tabs) { + void api.browser.ensureTab({ threadId, tabId: tab.id, url: tab.url }).catch(() => undefined); + } + }, [browserThreadState.tabs, threadId]); + + const syncBrowserHost = useCallback(() => { + const api = readNativeApi(); + if (!api) { + return; + } + const visible = selectedPanel === "browser" && !hasVisibleBlockingDialog(); + const bounds = + visible && browserViewportElement ? getViewportBounds(browserViewportElement) : null; + void api.browser + .syncHost({ + threadId, + tabId: visible ? (activeBrowserTab?.id ?? null) : null, + visible, + bounds, + }) + .catch(() => undefined); + }, [activeBrowserTab?.id, browserViewportElement, selectedPanel, threadId]); + + useLayoutEffect(() => { + syncBrowserHost(); + }, [syncBrowserHost]); + + useEffect(() => { + if (!browserViewportElement) { + return; + } + const sync = () => { + syncBrowserHost(); + }; + const observer = new ResizeObserver(sync); + observer.observe(browserViewportElement); + window.addEventListener("resize", sync); + return () => { + observer.disconnect(); + window.removeEventListener("resize", sync); + }; + }, [browserViewportElement, syncBrowserHost]); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + const observer = new MutationObserver(() => { + syncBrowserHost(); + }); + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["data-closed", "hidden"], + }); + return () => { + observer.disconnect(); + }; + }, [syncBrowserHost]); + + useEffect(() => { + if (selectedPanel !== "browser" || !browserViewportElement) { + return; + } + + let frameId = 0; + let frameCount = 0; + let previousBoundsKey: string | null = null; + let stableFrameCount = 0; + + const tick = () => { + const { x, y, width, height } = getViewportBounds(browserViewportElement); + const nextBoundsKey = `${x}:${y}:${width}:${height}`; + if (nextBoundsKey === previousBoundsKey) { + stableFrameCount += 1; + } else { + stableFrameCount = 0; + previousBoundsKey = nextBoundsKey; + } + + syncBrowserHost(); + frameCount += 1; + if (frameCount >= 30 || stableFrameCount >= 4) { + return; + } + frameId = window.requestAnimationFrame(tick); + }; + + frameId = window.requestAnimationFrame(tick); + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [browserViewportElement, selectedPanel, syncBrowserHost]); + + useEffect(() => { + const api = readNativeApi(); + if (!api) { + return; + } + return () => { + void api.browser + .syncHost({ + threadId, + tabId: null, + visible: false, + bounds: null, + }) + .catch(() => undefined); + }; + }, [threadId]); + useEffect(() => { if (!threadsHydrated) { return; @@ -296,14 +442,43 @@ function ChatThreadRouteView() { }, [threadId, updateThreadBrowserState]); const activateTab = useCallback( (tabId: string) => { + const nextTab = browserThreadState.tabs.find((tab) => tab.id === tabId) ?? null; + const nextInputValue = normalizeBrowserDisplayUrl(nextTab?.url); updateThreadBrowserState(threadId, (state) => - state.activeTabId === tabId ? state : { ...state, activeTabId: tabId }, + state.activeTabId === tabId && state.inputValue === nextInputValue + ? state + : { + ...state, + activeTabId: tabId, + inputValue: nextInputValue, + }, ); + + const api = readNativeApi(); + if (!api || selectedPanel !== "browser") { + return; + } + const bounds = browserViewportElement ? getViewportBounds(browserViewportElement) : null; + void api.browser + .syncHost({ + threadId, + tabId, + visible: !hasVisibleBlockingDialog(), + bounds, + }) + .catch(() => undefined); }, - [threadId, updateThreadBrowserState], + [ + browserThreadState.tabs, + browserViewportElement, + selectedPanel, + threadId, + updateThreadBrowserState, + ], ); const closeTab = useCallback( (tabId: string) => { + const api = readNativeApi(); updateThreadBrowserState(threadId, (state) => { const closedIndex = state.tabs.findIndex((tab) => tab.id === tabId); if (closedIndex < 0) { @@ -316,11 +491,14 @@ function ChatThreadRouteView() { : state.activeTabId; return { ...state, activeTabId, tabs }; }); + void api?.browser.closeTab({ threadId, tabId }).catch(() => undefined); }, [threadId, updateThreadBrowserState], ); const submitBrowserInput = useCallback(() => { const parsedUrl = parseSubmittedBrowserUrl(browserThreadState.inputValue); + const currentActiveTabId = browserThreadState.activeTabId; + const api = readNativeApi(); updateThreadBrowserState(threadId, (state) => { if (!parsedUrl.ok) { if (!state.activeTabId) { @@ -378,7 +556,21 @@ function ChatThreadRouteView() { ), }; }); - }, [browserThreadState.inputValue, threadId, updateThreadBrowserState]); + if (parsedUrl.ok && currentActiveTabId) { + void api?.browser + .navigate({ + threadId, + tabId: currentActiveTabId, + url: parsedUrl.url, + }) + .catch(() => undefined); + } + }, [ + browserThreadState.activeTabId, + browserThreadState.inputValue, + threadId, + updateThreadBrowserState, + ]); const openActiveTabExternally = useCallback(() => { const url = activeBrowserTab?.url; const api = readNativeApi(); @@ -395,6 +587,9 @@ function ChatThreadRouteView() { () => shortcutLabelForCommand(keybindings, "browser.closeTab"), [keybindings], ); + const browserViewportRef = useCallback((element: HTMLDivElement | null) => { + setBrowserViewportElement((current) => (current === element ? current : element)); + }, []); useEffect(() => { const isTerminalFocused = (): boolean => { @@ -465,10 +660,31 @@ function ChatThreadRouteView() { onActivateTab={activateTab} onCloseTab={closeTab} onSubmit={submitBrowserInput} - onBack={() => undefined} - onForward={() => undefined} - onReload={() => undefined} + onBack={() => { + if (!activeBrowserTab) { + return; + } + const api = readNativeApi(); + void api?.browser.goBack({ threadId, tabId: activeBrowserTab.id }).catch(() => undefined); + }} + onForward={() => { + if (!activeBrowserTab) { + return; + } + const api = readNativeApi(); + void api?.browser + .goForward({ threadId, tabId: activeBrowserTab.id }) + .catch(() => undefined); + }} + onReload={() => { + if (!activeBrowserTab) { + return; + } + const api = readNativeApi(); + void api?.browser.reload({ threadId, tabId: activeBrowserTab.id }).catch(() => undefined); + }} onOpenExternal={openActiveTabExternally} + viewportRef={browserViewportRef} /> ) : ( }> @@ -519,10 +735,35 @@ function ChatThreadRouteView() { onActivateTab={activateTab} onCloseTab={closeTab} onSubmit={submitBrowserInput} - onBack={() => undefined} - onForward={() => undefined} - onReload={() => undefined} + onBack={() => { + if (!activeBrowserTab) { + return; + } + const api = readNativeApi(); + void api?.browser + .goBack({ threadId, tabId: activeBrowserTab.id }) + .catch(() => undefined); + }} + onForward={() => { + if (!activeBrowserTab) { + return; + } + const api = readNativeApi(); + void api?.browser + .goForward({ threadId, tabId: activeBrowserTab.id }) + .catch(() => undefined); + }} + onReload={() => { + if (!activeBrowserTab) { + return; + } + const api = readNativeApi(); + void api?.browser + .reload({ threadId, tabId: activeBrowserTab.id }) + .catch(() => undefined); + }} onOpenExternal={openActiveTabExternally} + viewportRef={browserViewportRef} /> ) : ( { + await window.desktopBridge?.browserEnsureTab(input); + }, + navigate: async (input) => { + await window.desktopBridge?.browserNavigate(input); + }, + goBack: async (input) => { + await window.desktopBridge?.browserGoBack(input); + }, + goForward: async (input) => { + await window.desktopBridge?.browserGoForward(input); + }, + reload: async (input) => { + await window.desktopBridge?.browserReload(input); + }, + closeTab: async (input) => { + await window.desktopBridge?.browserCloseTab(input); + }, + syncHost: async (input) => { + await window.desktopBridge?.browserSyncHost(input); + }, + clearThread: async (input) => { + await window.desktopBridge?.browserClearThread(input); + }, + onEvent: (callback) => window.desktopBridge?.onBrowserEvent(callback) ?? (() => undefined), + }, git: { pull: (input) => transport.request(WS_METHODS.gitPull, input), status: (input) => transport.request(WS_METHODS.gitStatus, input), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..01f8d9150 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -46,6 +46,7 @@ import type { OrchestrationReadModel, } from "./orchestration"; import { EditorId } from "./editor"; +import type { ThreadId } from "./baseSchemas"; export interface ContextMenuItem { id: T; @@ -94,6 +95,60 @@ export interface DesktopUpdateActionResult { state: DesktopUpdateState; } +export interface BrowserBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface BrowserTabRuntimeState { + url: string; + title: string | null; + faviconUrl: string | null; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + lastError: string | null; +} + +export interface BrowserEnsureTabInput { + threadId: ThreadId; + tabId: string; + url?: string; +} + +export interface BrowserNavigateInput { + threadId: ThreadId; + tabId: string; + url: string; +} + +export interface BrowserTabTargetInput { + threadId: ThreadId; + tabId: string; +} + +export interface BrowserSyncHostInput { + threadId: ThreadId; + tabId: string | null; + visible: boolean; + bounds: BrowserBounds | null; +} + +export interface BrowserClearThreadInput { + threadId: ThreadId; +} + +export interface BrowserTabStateEvent { + type: "tab-state"; + threadId: ThreadId; + tabId: string; + state: BrowserTabRuntimeState; +} + +export type BrowserEvent = BrowserTabStateEvent; + export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; @@ -104,6 +159,15 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + browserEnsureTab: (input: BrowserEnsureTabInput) => Promise; + browserNavigate: (input: BrowserNavigateInput) => Promise; + browserGoBack: (input: BrowserTabTargetInput) => Promise; + browserGoForward: (input: BrowserTabTargetInput) => Promise; + browserReload: (input: BrowserTabTargetInput) => Promise; + browserCloseTab: (input: BrowserTabTargetInput) => Promise; + browserSyncHost: (input: BrowserSyncHostInput) => Promise; + browserClearThread: (input: BrowserClearThreadInput) => Promise; + onBrowserEvent: (listener: (event: BrowserEvent) => void) => () => void; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; downloadUpdate: () => Promise; @@ -133,6 +197,17 @@ export interface NativeApi { openInEditor: (cwd: string, editor: EditorId) => Promise; openExternal: (url: string) => Promise; }; + browser: { + ensureTab: (input: BrowserEnsureTabInput) => Promise; + navigate: (input: BrowserNavigateInput) => Promise; + goBack: (input: BrowserTabTargetInput) => Promise; + goForward: (input: BrowserTabTargetInput) => Promise; + reload: (input: BrowserTabTargetInput) => Promise; + closeTab: (input: BrowserTabTargetInput) => Promise; + syncHost: (input: BrowserSyncHostInput) => Promise; + clearThread: (input: BrowserClearThreadInput) => Promise; + onEvent: (callback: (event: BrowserEvent) => void) => () => void; + }; git: { // Existing branch/worktree API listBranches: (input: GitListBranchesInput) => Promise; From 97245a04cda177b437523dfce8504d4591f361ea Mon Sep 17 00:00:00 2001 From: Andrea Coiro Date: Thu, 12 Mar 2026 09:02:46 +0100 Subject: [PATCH 5/8] Guard malformed persisted browser tab ids --- apps/web/src/browserStateStore.test.ts | 27 ++++++++++++++++++++++++++ apps/web/src/browserStateStore.ts | 7 ++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/web/src/browserStateStore.test.ts b/apps/web/src/browserStateStore.test.ts index f3bb702ba..11af75508 100644 --- a/apps/web/src/browserStateStore.test.ts +++ b/apps/web/src/browserStateStore.test.ts @@ -50,4 +50,31 @@ describe("browserStateStore actions", () => { expect(afterEntry).toBe(beforeEntry); expect(afterEntry?.tabs).toBe(beforeEntry?.tabs); }); + + it("drops malformed persisted tabs without throwing", () => { + expect(() => { + useBrowserStateStore.getState().updateThreadBrowserState(THREAD_ID, () => ({ + activeTabId: "bad-tab", + tabs: [ + { id: null, url: "http://localhost:3000" } as unknown as ReturnType< + typeof createBrowserTab + >, + ], + inputValue: "", + focusRequestId: 0, + })); + }).not.toThrow(); + + const browserState = selectThreadBrowserState( + useBrowserStateStore.getState().browserStateByThreadId, + THREAD_ID, + ); + + expect(browserState).toEqual({ + activeTabId: null, + tabs: [], + inputValue: "", + focusRequestId: 0, + }); + }); }); diff --git a/apps/web/src/browserStateStore.ts b/apps/web/src/browserStateStore.ts index 9f3302059..ea5f102c3 100644 --- a/apps/web/src/browserStateStore.ts +++ b/apps/web/src/browserStateStore.ts @@ -37,7 +37,12 @@ function threadBrowserStateEqual(left: ThreadBrowserState, right: ThreadBrowserS } function isValidBrowserTab(tab: BrowserTab): boolean { - return tab.id.trim().length > 0 && typeof tab.url === "string" && tab.url.length > 0; + return ( + typeof tab.id === "string" && + tab.id.trim().length > 0 && + typeof tab.url === "string" && + tab.url.length > 0 + ); } function normalizeThreadBrowserState(state: ThreadBrowserState): ThreadBrowserState { From eed4138673f1c9c755d8473eadd33533f0ca0d9b Mon Sep 17 00:00:00 2001 From: Andrea Coiro Date: Thu, 12 Mar 2026 09:04:18 +0100 Subject: [PATCH 6/8] Control browser and diff toggle group state --- apps/web/src/components/chat/ChatHeader.tsx | 30 +++++++++------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index e7b9b6cb1..45bf9c48c 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -96,19 +96,20 @@ export const ChatHeader = memo(function ChatHeader({ /> )} {activeProjectName && } - + { + const nextPanel = groupValue[0]; + onSelectSidePanel(nextPanel === "diff" || nextPanel === "browser" ? nextPanel : null); + }} + > { - onSelectSidePanel(pressed ? "diff" : null); - }} - aria-label="Toggle diff panel" - disabled={!isGitRepo} - > + } @@ -124,14 +125,7 @@ export const ChatHeader = memo(function ChatHeader({ { - onSelectSidePanel(pressed ? "browser" : null); - }} - aria-label="Toggle in-app browser" - > + } From b7e518b5e626a6e775b2db1d98464fd0793037f6 Mon Sep 17 00:00:00 2001 From: Andrea Coiro Date: Thu, 12 Mar 2026 09:06:43 +0100 Subject: [PATCH 7/8] Remove dead blocking overlay dialog hooks --- apps/web/src/blockingOverlayStore.ts | 19 ------------------- apps/web/src/components/ui/alert-dialog.tsx | 16 ---------------- apps/web/src/components/ui/dialog.tsx | 16 ---------------- 3 files changed, 51 deletions(-) delete mode 100644 apps/web/src/blockingOverlayStore.ts diff --git a/apps/web/src/blockingOverlayStore.ts b/apps/web/src/blockingOverlayStore.ts deleted file mode 100644 index 78d2179a0..000000000 --- a/apps/web/src/blockingOverlayStore.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { create } from "zustand"; - -interface BlockingOverlayStoreState { - blockingOverlayCount: number; - incrementBlockingOverlayCount: () => void; - decrementBlockingOverlayCount: () => void; -} - -export const useBlockingOverlayStore = create()((set) => ({ - blockingOverlayCount: 0, - incrementBlockingOverlayCount: () => - set((state) => ({ - blockingOverlayCount: state.blockingOverlayCount + 1, - })), - decrementBlockingOverlayCount: () => - set((state) => ({ - blockingOverlayCount: Math.max(0, state.blockingOverlayCount - 1), - })), -})); diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index d5b4bf237..5c65f261f 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -1,10 +1,8 @@ "use client"; import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; -import { useEffect } from "react"; import { cn } from "~/lib/utils"; -import { useBlockingOverlayStore } from "~/blockingOverlayStore"; const AlertDialogCreateHandle = AlertDialogPrimitive.createHandle; @@ -49,20 +47,6 @@ function AlertDialogPopup({ }: AlertDialogPrimitive.Popup.Props & { bottomStickOnMobile?: boolean; }) { - const incrementBlockingOverlayCount = useBlockingOverlayStore( - (store) => store.incrementBlockingOverlayCount, - ); - const decrementBlockingOverlayCount = useBlockingOverlayStore( - (store) => store.decrementBlockingOverlayCount, - ); - - useEffect(() => { - incrementBlockingOverlayCount(); - return () => { - decrementBlockingOverlayCount(); - }; - }, [decrementBlockingOverlayCount, incrementBlockingOverlayCount]); - return ( diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index e313603b3..080ac8099 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -2,11 +2,9 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { XIcon } from "lucide-react"; -import { useEffect } from "react"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; import { ScrollArea } from "~/components/ui/scroll-area"; -import { useBlockingOverlayStore } from "~/blockingOverlayStore"; const DialogCreateHandle = DialogPrimitive.createHandle; @@ -58,20 +56,6 @@ function DialogPopup({ showCloseButton?: boolean; bottomStickOnMobile?: boolean; }) { - const incrementBlockingOverlayCount = useBlockingOverlayStore( - (store) => store.incrementBlockingOverlayCount, - ); - const decrementBlockingOverlayCount = useBlockingOverlayStore( - (store) => store.decrementBlockingOverlayCount, - ); - - useEffect(() => { - incrementBlockingOverlayCount(); - return () => { - decrementBlockingOverlayCount(); - }; - }, [decrementBlockingOverlayCount, incrementBlockingOverlayCount]); - return ( From ee7f71e8a00917080f7798177be48e20a3317639 Mon Sep 17 00:00:00 2001 From: Andrea Coiro Date: Thu, 12 Mar 2026 09:12:05 +0100 Subject: [PATCH 8/8] Unify right panel selection state --- apps/web/src/components/ChatView.tsx | 43 +++++++------------------ apps/web/src/routes/_chat.$threadId.tsx | 24 ++++++++++++-- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 05fca88fc..2d29f4179 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -31,12 +31,12 @@ import { import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; -import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { stripDiffSearchParams } from "../diffRouteSearch"; import { type ComposerTrigger, detectComposerTrigger, @@ -125,7 +125,7 @@ import { useComposerThreadDraft, } from "../composerDraftStore"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; -import { selectThreadRightPanelState, useRightPanelStateStore } from "../rightPanelStateStore"; +import type { RightPanelKind } from "../rightPanelStateStore"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -173,9 +173,15 @@ const SCRIPT_TERMINAL_ROWS = 30; interface ChatViewProps { threadId: ThreadId; + selectedSidePanel: RightPanelKind | null; + onSelectSidePanel: (panel: RightPanelKind | null) => void; } -export default function ChatView({ threadId }: ChatViewProps) { +export default function ChatView({ + threadId, + selectedSidePanel, + onSelectSidePanel, +}: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); const markThreadVisited = useStore((store) => store.markThreadVisited); @@ -184,14 +190,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); const navigate = useNavigate(); - const rawSearch = useSearch({ - strict: false, - select: (params) => parseDiffRouteSearch(params), - }); - const rightPanelState = useRightPanelStateStore((state) => - selectThreadRightPanelState(state.rightPanelStateByThreadId, threadId), - ); - const setSelectedPanel = useRightPanelStateStore((state) => state.setSelectedPanel); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); @@ -1001,23 +999,6 @@ export default function ChatView({ threadId }: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "browser.toggle"), [keybindings], ); - const forcedSelectedSidePanel = rawSearch.diff === "1" || rawSearch.diffTurnId ? "diff" : null; - const selectedSidePanel = forcedSelectedSidePanel ?? rightPanelState.selectedPanel; - const onSelectSidePanel = useCallback( - (panel: "diff" | "browser" | null) => { - setSelectedPanel(threadId, panel); - void navigate({ - to: "/$threadId", - params: { threadId }, - replace: true, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return rest; - }, - }); - }, - [navigate, setSelectedPanel, threadId], - ); const onToggleDiff = useCallback(() => { onSelectSidePanel(selectedSidePanel === "diff" ? null : "diff"); }, [onSelectSidePanel, selectedSidePanel]); @@ -3129,7 +3110,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { - setSelectedPanel(threadId, "diff"); + onSelectSidePanel("diff"); void navigate({ to: "/$threadId", params: { threadId }, @@ -3141,7 +3122,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, }); }, - [navigate, setSelectedPanel, threadId], + [navigate, onSelectSidePanel, threadId], ); const onRevertUserMessage = (messageId: MessageId) => { const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index b9669f7b8..1ccd8613e 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -430,6 +430,16 @@ function ChatThreadRouteView() { const reopenPanel = useCallback(() => { openPanel(rightPanelState.lastSelectedPanel); }, [openPanel, rightPanelState.lastSelectedPanel]); + const handleSelectSidePanel = useCallback( + (panel: RightPanelKind | null) => { + if (panel === null) { + closePanel(); + return; + } + openPanel(panel); + }, + [closePanel, openPanel], + ); const createTab = useCallback(() => { const nextTab = createBrowserTab(); updateThreadBrowserState(threadId, (state) => ({ @@ -696,7 +706,12 @@ function ChatThreadRouteView() { return ( <> - + - + {selectedPanel === null ? null : selectedPanel === "browser" ? (