diff --git a/web/scripts/check-bundle-budget.mjs b/web/scripts/check-bundle-budget.mjs index a8732d51..4c630f6a 100644 --- a/web/scripts/check-bundle-budget.mjs +++ b/web/scripts/check-bundle-budget.mjs @@ -32,7 +32,9 @@ const DIST = resolve(import.meta.dirname, "../dist"); // Native compaction animation and exact boundary replacement add <1 KiB of // startup JS. Keep entry, compressed-size and request-count limits unchanged. const MAX_ENTRY_BYTES = 537 * 1024; -const MAX_INITIAL_BYTES = 938 * 1024; +// Viewport portals for image/paste overlays keep mobile page layers stable. +// They add <0.1 KiB, with no extra request/dependency; round the cap to a KiB. +const MAX_INITIAL_BYTES = 939 * 1024; // Cross-thread provenance, scoped navigation and receipts add <2 KiB gzip. // The history reader and link UI load on demand; other caps stay unchanged. const MAX_INITIAL_GZIP_BYTES = 282 * 1024; diff --git a/web/src/App.tsx b/web/src/App.tsx index 01209889..15c3bfa9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -35,6 +35,8 @@ import { ChatView } from "./components/ChatView"; import { Composer } from "./components/Composer"; import { usePanelWidthPreference } from "./use-panel-width"; import { SidebarToggle } from "./components/SidebarToggle"; +import { SidebarShell, SidebarState } from "./components/SidebarLayout"; +import { useSidebarController } from "./sidebar-state"; const SessionMessagePreview = lazy(() => import("./components/SessionMessagePreview").then((m) => ({ default: m.SessionMessagePreview }))); const BackgroundTaskControl = lazy(() => import("./components/BackgroundTaskControl")); @@ -348,7 +350,8 @@ export default function App() { const spacesByEngineRef = useRef>(initialSpacesRef.current); const [authed, setAuthed] = useState(false); const [authReady, setAuthReady] = useState(false); - const [sidebarOpen, setSidebarOpen] = useState(false); + const sidebar = useSidebarController(); + const setSidebarOpen = sidebar.setOpen; const [dirPickerOpen, setDirPickerOpen] = useState(false); const [createError, setCreateError] = useState(null); const [newChatAutoFocus, setNewChatAutoFocus] = useState(true); @@ -1967,7 +1970,7 @@ export default function App() { wsRef.current?.sendGetWorkArtifacts(selectedEngine, id); } if (isMobile()) setSidebarOpen(false); - }, [clearForkFocusLease, machineId, requestHistory, resumeListedSession]); + }, [clearForkFocusLease, machineId, requestHistory, resumeListedSession, setSidebarOpen]); useEffect(() => { const target = pendingNotificationTarget; @@ -3848,6 +3851,7 @@ export default function App() { requestSkillCatalog, sendContextRequestTo, setBtwOpeningFor, + setSidebarOpen, settleCancelledHistoryBrowse, settleTerminalHistoryRepair, startForkFocusLease, @@ -4130,7 +4134,7 @@ export default function App() { }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [authed]); + }, [authed, setSidebarOpen]); // Shift+Tab follows each engine's real mode control: Claude cycles permission // modes; Codex toggles collaboration mode without touching approvalPolicy. @@ -5542,8 +5546,8 @@ export default function App() { -
- + {sidebarOpen => + />} {dirPickerOpen &&
- + {sidebarOpen => {space === "work" ? "Work" : "Code"} - + } {focusedWorkProfile && ( setDeviceSheetOpen(false)} /> -
+ ); diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index a5894341..236789c4 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -28,6 +28,7 @@ import { claudeContinuations, type ClaudeContinuation } from "../claude-continua import { TimedMessageTag } from "./TimedMessageTag"; import { Icon, ClaudeMark, ClaudeWorking, ClaudeSpark } from "../icons"; import { canForkTurn } from "../session-worktree"; +import { sidebarDragIntent } from "../responsive-layout"; import { GeneratedImagePreview, ProcessTimeline, @@ -454,6 +455,7 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud const lastScrollTopRef = useRef(0); const renderedScrollScopeRef = useRef(null); const touchYRef = useRef(null); + const touchOriginRef = useRef<{ x: number; y: number; vertical: boolean } | null>(null); const touchMomentumActiveRef = useRef(false); const touchEventClockOffsetRef = useRef(null); const touchTransactionBoundaryRef = @@ -1693,6 +1695,7 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud cancelHistoryAnchor(); clearHistoryRequestTimeout(); touchYRef.current = null; + touchOriginRef.current = null; touchTransactionBoundaryRef.current = null; touchEventClockOffsetRef.current = null; userScrollIntentRef.current = false; @@ -2132,9 +2135,26 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud touchEventClockOffsetRef.current = window.performance.now() - event.timeStamp; touchYRef.current = event.touches[0]?.clientY ?? null; + const touch = event.touches[0]; + touchOriginRef.current = touch + ? { x: touch.clientX, y: touch.clientY, vertical: false } : null; }; const onTouchMove = (event: TouchEvent) => { + // The shell's native listener claims sidebar navigation before React's + // delegated listener runs. Do not read layout after it writes transforms, + // cancel history transactions, or pause output for a horizontal swipe. + if (event.defaultPrevented || event.touches.length !== 1) return; + const touch = event.touches[0]; + const origin = touchOriginRef.current; + if (origin && !origin.vertical) { + // Even before the sidebar reaches its claim threshold, small vertical + // finger noise must not be mistaken for reading older messages. Once a + // vertical pan wins, keep its history semantics through later reversals. + if (sidebarDragIntent(touch.clientX - origin.x, touch.clientY - origin.y) + !== "vertical") return; + origin.vertical = true; + } const currentY = event.touches[0]?.clientY; const previousY = touchYRef.current; if (currentY == null || previousY == null) return; @@ -2207,7 +2227,14 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud }); }; - const clearTouch = () => { + const clearTouch = (event: TouchEvent) => { + const origin = touchOriginRef.current; + const touch = event.changedTouches[0]; + // Chrome may make touchend non-cancelable after a pending first movement, + // even for a CSS-reserved horizontal gesture. It still must not rebuild + // history on the first release-animation frame. + const navigationTouch = origin && !origin.vertical && (event.defaultPrevented + || (touch && sidebarDragIntent(touch.clientX - origin.x, touch.clientY - origin.y) === "horizontal")); // Mobile WebKit may defer React's scroll event until touchend. Capture the // DOM's already-moved reading row before clearing the touch lock, otherwise // the residual prepend correction can restore the pre-gesture row first. @@ -2217,6 +2244,7 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud touchTransactionBoundaryRef.current = null; touchEventClockOffsetRef.current = null; touchYRef.current = null; + touchOriginRef.current = null; historyLoadGateRef.current.endGesture(); // touchend only releases the finger. Mobile WebKit can keep scrolling for // several native frames afterwards, so restart the complete idle window @@ -2227,7 +2255,10 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud // A page can finish while the finger is still down. Re-render after the // native touch ends so the retained history transaction can correct and // release its exact reading boundary without fighting the gesture. - setScrollPolicyEpoch((value) => value + 1); + // A horizontal navigation gesture did not move the reading boundary. Its + // normal idle lease still flushes pending history, but rebuilding the chat + // here would block the sidebar's first automatic-animation frame. + if (!navigationTouch) setScrollPolicyEpoch((value) => value + 1); const anchor = historyAnchorRef.current.current(); if (anchor?.phase === "applied") { scheduleHistoryAnchorRelease(anchor.generation); diff --git a/web/src/components/ImageLightbox.tsx b/web/src/components/ImageLightbox.tsx index 169b9c9e..0939c7ef 100644 --- a/web/src/components/ImageLightbox.tsx +++ b/web/src/components/ImageLightbox.tsx @@ -6,6 +6,7 @@ import { type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, } from "react"; +import { createPortal } from "react-dom"; import { Icon } from "../icons"; import { panImageTransform, @@ -310,7 +311,7 @@ export function ImageLightbox(props: ImageLightboxProps) { requestClose(); }; - return
{ event.stopPropagation(); requestClose(); }}> -
; +
, document.body); } diff --git a/web/src/components/MermaidBlock.tsx b/web/src/components/MermaidBlock.tsx index b28bdfa1..5bd47417 100644 --- a/web/src/components/MermaidBlock.tsx +++ b/web/src/components/MermaidBlock.tsx @@ -5,7 +5,6 @@ import { useRef, useState, } from "react"; -import { createPortal } from "react-dom"; import { Icon } from "../icons"; import { mermaidPreviewSvg, @@ -174,12 +173,9 @@ export function MermaidBlock({ source }: { source: string }) {
} {preview && typeof document !== "undefined" - ? createPortal( - , - document.body, - ) + ? : null} ); diff --git a/web/src/components/MessageBlock.tsx b/web/src/components/MessageBlock.tsx index 1683dbdf..5fb55dfa 100644 --- a/web/src/components/MessageBlock.tsx +++ b/web/src/components/MessageBlock.tsx @@ -1,4 +1,4 @@ -import { createContext, isValidElement, useContext, useEffect, useId, +import { createContext, isValidElement, memo, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type ReactNode } from "react"; @@ -28,6 +28,12 @@ import { MermaidBlock } from "./MermaidBlock"; import { PreviewAuthorizationPrompt } from "./PreviewAuthorizationPrompt"; import { useMarkdownExtras } from "../use-markdown-extras"; +// Keep the parser behind its own memo boundary. MessageBlock still receives +// fresh callbacks/assets through context, but an unchanged displayed string +// must not run the Markdown pipeline on each raw delta, scroll or drawer update. +// Context consumers inside the cached tree continue to update independently. +const MessageMarkdown = memo(ReactMarkdown); + const CODEX_DIRECTIVE_LABELS: Record = { "git-stage": "Git 变更已暂存", "git-commit": "Git 提交已创建", @@ -644,6 +650,9 @@ export function MessageBlock({ text, done, onOpenFile, imageAssets, useEffect(() => () => { if (timer.current) clearTimeout(timer.current); + // StrictMode reconnects effects on mount; a canceled timer must not keep + // subsequent text updates waiting for a callback that will never run. + timer.current = null; }, []); const markdownContext = useMemo(() => ({ @@ -677,11 +686,11 @@ export function MessageBlock({ text, done, onOpenFile, imageAssets,
{parts.map((part, index) => { - if (part.kind === "markdown") return {part.text}; + components={MESSAGE_MARKDOWN_COMPONENTS}>{part.text}; if (part.kind === "visualization") return ; diff --git a/web/src/components/PasteCards.tsx b/web/src/components/PasteCards.tsx index 08ae00e4..9e7042ec 100644 --- a/web/src/components/PasteCards.tsx +++ b/web/src/components/PasteCards.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import type { ComposerPaste } from "../composer-pastes"; import { @@ -85,7 +86,7 @@ export function PasteCards({ pastes, onChange, disabled = false }: Props) { ))} - {editingPaste && ( + {editingPaste && createPortal(
{ if (event.target === event.currentTarget) closeEditor(); @@ -127,7 +128,7 @@ export function PasteCards({ pastes, onChange, disabled = false }: Props) { }}>保存 -
+
, document.body, )} ; } diff --git a/web/src/components/SessionsSidebar.tsx b/web/src/components/SessionsSidebar.tsx index a6b6b906..62504d55 100644 --- a/web/src/components/SessionsSidebar.tsx +++ b/web/src/components/SessionsSidebar.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useRef, useState, type TouchEvent } from "react"; +import { lazy, Suspense, useCallback, useEffect, useRef, useState, type TouchEvent } from "react"; import type { ClaudeProfileInfo, CodexProfileInfo, Engine, SessionInfo, Space, State } from "../protocol"; import type { CompletionBadgeKind } from "../completion-badges"; import { Icon, ClaudeMark } from "../icons"; @@ -96,6 +96,11 @@ export function SessionsSidebar({ open, engine, space, const [expandedDirectories, setExpandedDirectories] = useState>({}); const pressTimer = useRef(null); const pressStart = useRef<{ x: number; y: number } | null>(null); + const cancelPress = useCallback(() => { + if (pressTimer.current !== null) window.clearTimeout(pressTimer.current); + pressTimer.current = null; + pressStart.current = null; + }, []); const accountProfiles = engine === "codex" ? codexProfiles : claudeProfiles; const defaultAccountProfileId = engine === "codex" @@ -192,7 +197,10 @@ export function SessionsSidebar({ open, engine, space, const closeMenu = () => { setMenuCardId(null); setLifting(false); }; // reset menu/lift state when the sidebar closes (so it doesn't linger into the next open) - useEffect(() => { if (!open) { setMenuCardId(null); setLifting(false); } }, [open]); + useEffect(() => { + if (!open) { cancelPress(); setMenuCardId(null); setLifting(false); } + return cancelPress; + }, [open, cancelPress]); useEffect(() => { if (!showProfileManagement @@ -226,26 +234,26 @@ export function SessionsSidebar({ open, engine, space, }, [menuCardId, lifting]); // long-press (500ms hold, no >10px move) => lift the card + dim the rest + show actions. - // A swipe (handled by the shell) moves >10px and cancels the timer. + // Browser scroll/zoom ownership can cancel a touch before a >10px move + // reaches React. Clear on cancellation and scroll too, including momentum; + // a timer left behind must not lift a card halfway through a native fling. const onCardTouchStart = (s: SessionInfo, e: TouchEvent) => { + cancelPress(); + if (e.touches.length !== 1) return; pressStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }; pressTimer.current = window.setTimeout(() => { + cancelPress(); setMenuCardId(s.session_id); setLifting(true); if (navigator.vibrate) navigator.vibrate(15); }, 500); }; const onCardTouchMove = (e: TouchEvent) => { + if (e.touches.length !== 1) { cancelPress(); return; } if (!pressStart.current) return; const dx = Math.abs(e.touches[0].clientX - pressStart.current.x); const dy = Math.abs(e.touches[0].clientY - pressStart.current.y); - if ((dx > 10 || dy > 10) && pressTimer.current) { - clearTimeout(pressTimer.current); - pressTimer.current = null; - } - }; - const onCardTouchEnd = () => { - if (pressTimer.current) { clearTimeout(pressTimer.current); pressTimer.current = null; } + if (dx > 10 || dy > 10) cancelPress(); }; const renderCard = (s: SessionInfo, showLocation = false) => { @@ -319,7 +327,9 @@ export function SessionsSidebar({ open, engine, space, className={"scard" + (profilePresentation ? " has-profile-ribbon" : "") + (isActive ? " active" : "") + (isArchived ? " archived" : "") + (isMenu ? " menu-open" : "") + (isMenu && lifting ? " lifting" : "")} onTouchStart={(e) => onCardTouchStart(s, e)} onTouchMove={onCardTouchMove} - onTouchEnd={onCardTouchEnd} + onTouchEnd={cancelPress} + onTouchCancel={cancelPress} + onPointerCancel={cancelPress} onClick={onTitleClick} > {profilePresentation && ( @@ -461,6 +471,8 @@ export function SessionsSidebar({ open, engine, space, <>