Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion web/scripts/check-bundle-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 13 additions & 9 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -348,7 +350,8 @@ export default function App() {
const spacesByEngineRef = useRef<Record<Engine, Space>>(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<string | null>(null);
const [newChatAutoFocus, setNewChatAutoFocus] = useState(true);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -3848,6 +3851,7 @@ export default function App() {
requestSkillCatalog,
sendContextRequestTo,
setBtwOpeningFor,
setSidebarOpen,
settleCancelledHistoryBrowse,
settleTerminalHistoryRepair,
startForkFocusLease,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -5542,8 +5546,8 @@ export default function App() {
<ViewerPagesProvider scope={visibleParentSid && authed
? { machineId, sid: visibleParentSid, space, engine } : null} onOpen={openViewerPage}>
<RemoteViewerContext.Provider value={visibleParentSid ? openViewerLink : null}>
<div className={"shell" + (sidebarOpen ? " sidebar-open" : "") + (visibleRightPanel !== null ? " panel-open" : "")}>
<Suspense fallback={null}><SessionsSidebar
<SidebarShell controller={sidebar} className={visibleRightPanel !== null ? "panel-open" : ""}>
<SidebarState controller={sidebar}>{sidebarOpen => <Suspense fallback={null}><SessionsSidebar
open={sidebarOpen}
onOpenChange={setSidebarOpen}
engine={engine}
Expand Down Expand Up @@ -5619,7 +5623,7 @@ export default function App() {
}}
onForkWorktree={openForkWorktree}
onMigrate={openSessionMigration}
/></Suspense>
/></Suspense>}</SidebarState>
{dirPickerOpen && <Suspense fallback={null}><DirPicker
open={dirPickerOpen}
path={state.dirPicker?.path ?? null}
Expand Down Expand Up @@ -5651,10 +5655,10 @@ export default function App() {
<header className={`c-head ${space}-head`}>
<div className="titlewrap">
<div className="ttl">
<SidebarToggle className="surface-head-title" open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SidebarState controller={sidebar}>{sidebarOpen => <SidebarToggle className="surface-head-title" open={sidebarOpen} onOpenChange={setSidebarOpen}>
<span className="surface-head-mark"><Icon name={space === "work" ? "work" : "code"} size={18} /></span>
<span>{space === "work" ? "Work" : "Code"}</span>
</SidebarToggle>
</SidebarToggle>}</SidebarState>
{focusedWorkProfile && (
<span className={`work-profile-owner tone-${focusedWorkProfile.tone}`}
title={`${focusedEngine === "codex" ? "Codex" : "Claude"} 账号:${focusedWorkProfile.fullLabel}`}
Expand Down Expand Up @@ -6427,7 +6431,7 @@ export default function App() {
setDeviceSheetOpen(false);
}}
onClose={() => setDeviceSheetOpen(false)} />
</div>
</SidebarShell>
</RemoteViewerContext.Provider>
</ViewerPagesProvider>
);
Expand Down
35 changes: 33 additions & 2 deletions web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -454,6 +455,7 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud
const lastScrollTopRef = useRef(0);
const renderedScrollScopeRef = useRef<string | null>(null);
const touchYRef = useRef<number | null>(null);
const touchOriginRef = useRef<{ x: number; y: number; vertical: boolean } | null>(null);
const touchMomentumActiveRef = useRef(false);
const touchEventClockOffsetRef = useRef<number | null>(null);
const touchTransactionBoundaryRef =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<HTMLDivElement>) => {
// 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;
Expand Down Expand Up @@ -2207,7 +2227,14 @@ export function ChatView({ sid, turnUsage, turns: incomingTurns, engine = "claud
});
};

const clearTouch = () => {
const clearTouch = (event: TouchEvent<HTMLDivElement>) => {
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.
Expand All @@ -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
Expand All @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions web/src/components/ImageLightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -310,7 +311,7 @@ export function ImageLightbox(props: ImageLightboxProps) {
requestClose();
};

return <div ref={stageRef}
return createPortal(<div ref={stageRef}
className={`image-lightbox${entered ? " entered" : ""}${interacting ? " interacting" : ""}`}
role="dialog" aria-modal="true" aria-label={dialogLabel}
onPointerDown={onPointerDown} onPointerMove={onPointerMove}
Expand All @@ -335,5 +336,5 @@ export function ImageLightbox(props: ImageLightboxProps) {
onClick={(event) => { event.stopPropagation(); requestClose(); }}>
<Icon name="close" size={22} />
</button>
</div>;
</div>, document.body);
}
10 changes: 3 additions & 7 deletions web/src/components/MermaidBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { Icon } from "../icons";
import {
mermaidPreviewSvg,
Expand Down Expand Up @@ -174,12 +173,9 @@ export function MermaidBlock({ source }: { source: string }) {
</div>}
</div>
{preview && typeof document !== "undefined"
? createPortal(
<ImageLightbox sanitizedSvg={preview.svg} alt="Mermaid 图表预览"
dialogLabel="Mermaid 图表预览" closeLabel="关闭 Mermaid 图表预览"
onClose={closePreview} />,
document.body,
)
? <ImageLightbox sanitizedSvg={preview.svg} alt="Mermaid 图表预览"
dialogLabel="Mermaid 图表预览" closeLabel="关闭 Mermaid 图表预览"
onClose={closePreview} />
: null}
</>
);
Expand Down
15 changes: 12 additions & 3 deletions web/src/components/MessageBlock.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, string> = {
"git-stage": "Git 变更已暂存",
"git-commit": "Git 提交已创建",
Expand Down Expand Up @@ -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<MessageMarkdownContextValue>(() => ({
Expand Down Expand Up @@ -677,11 +686,11 @@ export function MessageBlock({ text, done, onOpenFile, imageAssets,
<MessageMarkdownContext.Provider value={markdownContext}>
<div className="prose">
{parts.map((part, index) => {
if (part.kind === "markdown") return <ReactMarkdown key={`markdown-${index}`}
if (part.kind === "markdown") return <MessageMarkdown key={`markdown-${index}`}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
urlTransform={messageUrlTransform}
components={MESSAGE_MARKDOWN_COMPONENTS}>{part.text}</ReactMarkdown>;
components={MESSAGE_MARKDOWN_COMPONENTS}>{part.text}</MessageMarkdown>;
if (part.kind === "visualization") return <CodexVisualizationCard
key={`visualization-${index}`} path={part.path} title={part.title}
onOpenFile={onOpenFile} />;
Expand Down
5 changes: 3 additions & 2 deletions web/src/components/PasteCards.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";

import type { ComposerPaste } from "../composer-pastes";
import {
Expand Down Expand Up @@ -85,7 +86,7 @@ export function PasteCards({ pastes, onChange, disabled = false }: Props) {
</article>
))}

{editingPaste && (
{editingPaste && createPortal(
<div className="paste-preview-backdrop" role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) closeEditor();
Expand Down Expand Up @@ -127,7 +128,7 @@ export function PasteCards({ pastes, onChange, disabled = false }: Props) {
}}>保存</button>
</footer>
</section>
</div>
</div>, document.body,
)}
</>;
}
36 changes: 24 additions & 12 deletions web/src/components/SessionsSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -96,6 +96,11 @@ export function SessionsSidebar({ open, engine, space,
const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>({});
const pressTimer = useRef<number | null>(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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 && (
Expand Down Expand Up @@ -461,6 +471,8 @@ export function SessionsSidebar({ open, engine, space,
<>
<div className={"scrim-side" + (open ? " show" : "")} onClick={onClose} />
<aside ref={sidebarRef} className={"sessions" + (open ? " show" : "")} inert={!open} aria-hidden={!open}>
{/* Fade a sibling surface, never the native list scroller's ancestors. */}
<div className="s-fade" aria-hidden="true" />
{/* Keep the contents stable while the shell animates both surfaces. */}
<div className="s-inner">
<div className="s-head">
Expand Down Expand Up @@ -512,7 +524,7 @@ export function SessionsSidebar({ open, engine, space,
placeholder={space === "work" ? "搜索工作…" : "搜索会话、路径…"}
aria-label={space === "work" ? "搜索工作" : "搜索会话"} />
</div>
<div className="s-scroll" onClick={closeMenu}>
<div className="s-scroll" onClick={closeMenu} onScrollCapture={cancelPress}>
{filtered.length === 0 && <div className="s-group">{sessions.length === 0 ? "暂无会话" : "无匹配"}</div>}
{pinned.length > 0 && renderGroup("__pinned__", pinned, "置顶")}
{groupKeys.map((k) => renderGroup(k, groups[k]))}
Expand Down
Loading
Loading