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
2 changes: 2 additions & 0 deletions scripts/cmux-next/check-switch-fetches.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
"page is focused with nothing in it focused (#22456), as composerFocus.ts does; reads no data",
(f"{ACPMUX}/threadMinimap/scrollMotion.ts", "visibilitychange"): "cancels the wheel's own "
"motion when the page hides; reads no data",
(f"{ACPMUX}/threadMinimap/ThreadMinimap.tsx", "visibilitychange"): "clears the occluded "
"rail's one-shot leave grace when the page hides; reads no data",
(f"{ACPMUX}/newtab/listMotion.ts", "visibilitychange"): "cancels the list's own motion when "
"the page hides; reads no data",
(f"{ACPMUX}/avatars/AnimatedAvatar.tsx", "visibilitychange"): "stops the avatar's own CSS "
Expand Down
8 changes: 7 additions & 1 deletion webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2278,6 +2278,9 @@ export function VirtualTranscript({
const wheelGlide = useRef<ReturnType<typeof installWheelGlide>>(undefined);
// The last transcript box on this page until this one is measured (before its first paint, below).
const [height, setHeight] = useState(() => lastTranscriptBox.height);
// The native pane's width, distinct from the transcript scroller's width when Push always reserves
// a Session Info column. The pane-pinned rail uses this so its inset never follows that column.
const [paneWidth, setPaneWidth] = useState(() => lastTranscriptBox.width);
/// The scrollbar's lane (scrollers.ts SHOWN_SCROLLER_ATTRIBUTE: always there, its thumb drawn while
/// the chat overflows). The rows lay out across it, against the scroller's full width as the
/// composer does, so the column matches the composer's and nothing reflows as a chat starts to scroll.
Expand Down Expand Up @@ -2761,6 +2764,8 @@ export function VirtualTranscript({
const nextLane = Math.max(0, node.offsetWidth - node.clientWidth);
setWidth(node.clientWidth + nextLane);
setLane(nextLane);
const shellWidth = node.closest<HTMLElement>(".acpmux-shell")?.clientWidth;
setPaneWidth(shellWidth && shellWidth > 0 ? shellWidth : node.clientWidth + nextLane);
if (node.clientHeight > 0) lastTranscriptBox.height = node.clientHeight;
if (node.clientWidth > 0) lastTranscriptBox.width = node.clientWidth + nextLane;
};
Expand Down Expand Up @@ -4186,7 +4191,8 @@ export function VirtualTranscript({
// rail's rows stay above its bottom band.
foot={height - dockSpace}
scrolls={layout.totalHeight - height > 0.5}
width={width}
width={paneWidth}
columnWidth={width}
placed={minimapPlaced}
change={rowsChange.current}
/>
Expand Down
3 changes: 3 additions & 0 deletions webviews/src/agent-session/acpmux/chatWidth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const COLUMNS: Record<ChatWidth, ChatColumn> = {
const isChatWidth = (value: unknown): value is ChatWidth =>
value === "standard" || value === "wide" || value === "wideBlocks";

/// A pane changed its overlay shift without resizing the transcript. Scoped to its shell.
export const CHAT_LAYOUT_EVENT = "cmux-chat-layout";

let current: ChatWidth = "standard";
const listeners = new Set<() => void>();

Expand Down
14 changes: 8 additions & 6 deletions webviews/src/agent-session/acpmux/inspector/ChatInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
import { flushSync } from "react-dom";
import { usePresence, type Presence } from "../../../ui/presence";
import { useChatDisplay } from "../chatDisplay";
import { chatColumnOf, useChatWidth, type ChatColumn } from "../chatWidth";
import { CHAT_LAYOUT_EVENT, chatColumnOf, useChatWidth, type ChatColumn } from "../chatWidth";
import { PanelToggle } from "../header/PanelToggle";
import { useHeaderState, type HeaderPullRequest } from "../header/headerState";
import { useTabRow } from "../header/tabRow";
Expand Down Expand Up @@ -95,7 +95,6 @@ import { WorkflowTasks, type WorkflowAgentRowVariant } from "./WorkflowTasks";
import type { WorkflowAgent, WorkflowSummary } from "../summary/workflow";
import type { Subagent } from "../subagents/subagentFold";
import { subagentProgress } from "../subagents/subagentProgress";
import { RAIL_MIN_GUTTER } from "../threadMinimap/model";
import { useUnderInspector } from "./underInspector";
import "./inspector.css";

Expand Down Expand Up @@ -125,9 +124,9 @@ export const pushedWidth = (paneWidth: number, width: number) =>
/// part of the true agent pane area except in the case when both would fit if only the agent pane
/// shifted left". When the right gutter (model.ts transcriptColumn) holds the panel and its margins,
/// nothing moves; when the two gutters together hold it, the column moves left by what the right one
/// lacks; otherwise nothing moves and the panel floats over the chat. The left gutter keeps the
/// thread rail's usable gutter (threadMinimap/model.ts RAIL_MIN_GUTTER), so the column only slides
/// as far as the rail can follow it. The rail itself moves toward the text as the gutter shrinks.
/// lacks; otherwise nothing moves and the panel floats over the chat. The pane's left edge is
/// independent of this shift. The thread rail is pinned to that edge and
/// keeps its own inset, so the column can use the full left gutter when it needs to move.
/// A handoff review's form (handoff/styles.css: 760px, 18px from each edge) is wider than the column:
/// with `wide`, the gutter is the form's. The form takes the transcript's place, so no thread rail
/// draws beside it: the shift keeps the form's own edge clearance instead of the rail's room.
Expand All @@ -141,7 +140,7 @@ export const inspectorChatShift = (paneWidth: number, width: number, column?: Ch
? Math.min(columnInset, (paneWidth - Math.min(HANDOFF_FORM_WIDTH, paneWidth - 2 * HANDOFF_FORM_EDGE)) / 2)
: columnInset;
const lacks = width + 2 * FLOAT_MARGIN - inset;
const room = inset - (wide ? HANDOFF_FORM_EDGE : RAIL_MIN_GUTTER);
const room = inset - (wide ? HANDOFF_FORM_EDGE : 0);
return lacks > 0 && lacks <= room ? lacks : 0;
};

Expand Down Expand Up @@ -628,12 +627,14 @@ function InspectorPresence({
delete shell.dataset.inspectorChatResizing;
shell.style.removeProperty("--acpmux-chat-shift");
shell.style.removeProperty("--acpmux-header-room");
shell.dispatchEvent(new (shell.ownerDocument.defaultView?.Event ?? Event)(CHAT_LAYOUT_EVENT));
return;
}
shell.dataset.inspectorChatPlacement = push ? "push" : "overlay";
if (suppressShiftTransition) shell.dataset.inspectorChatResizing = "true";
else delete shell.dataset.inspectorChatResizing;
shell.style.setProperty("--acpmux-chat-shift", `${chatShift}px`);
shell.dispatchEvent(new (shell.ownerDocument.defaultView?.Event ?? Event)(CHAT_LAYOUT_EVENT));
// Push always narrows the transcript, not the header: native tabs reserve the tools at the
// pane's trailing edge. Reuse the measured room (excluding an artifact beside the chat).
if (paneWidth > 0) shell.style.setProperty("--acpmux-header-room", `${paneWidth}px`);
Expand All @@ -645,6 +646,7 @@ function InspectorPresence({
delete shell.dataset.inspectorChatResizing;
shell.style.removeProperty("--acpmux-chat-shift");
shell.style.removeProperty("--acpmux-header-room");
shell.dispatchEvent(new (shell.ownerDocument.defaultView?.Event ?? Event)(CHAT_LAYOUT_EVENT));
};
}, [shell]);
// A card under the panel (the computer use live preview) reads where it goes from the shell.
Expand Down
28 changes: 9 additions & 19 deletions webviews/src/agent-session/acpmux/inspector/inspector.css
Original file line number Diff line number Diff line change
Expand Up @@ -104,48 +104,38 @@
flex: 0 1 auto;
}
/* When the chat and the floating card both fit with the chat moved left (ChatInspector.tsx
inspectorChatShift), the conversation and the composer's stack under it move left together, so the
card lies over neither. The header stays outside that shift: its top-right controls stay anchored in
the pane. */
.acpmux-main > .acpmux-stage > .acpmux-scroll > :is(.acpmux-spacer, .acpmux-find, .acpmux-minimap),
inspectorChatShift), the conversation and the composer's stack under it move left together. The
header and pane-pinned rail stay outside that shift: their controls stay anchored in the pane. */
.acpmux-main > .acpmux-stage > .acpmux-scroll > :is(.acpmux-spacer, .acpmux-find),
.acpmux-main > .acpmux-stage .acpmux-handoff-review,
.acpmux-main > .acpmux-dock > * {
translate: calc(-1 * var(--acpmux-chat-shift, 0px)) 0;
}
/* Move content and the rail without moving the native scrollbar under Session Info. Clamp the
rail's final left edge to the pane: its 28px gutter can use up to 8px of text-edge overlap.
Its portaled card shares the same shift and transition, staying aligned on every painted frame. */
.acpmux-main > .acpmux-stage > .acpmux-scroll > .acpmux-minimap,
.acpmux-main > .acpmux-stage > .acpmux-minimap__card {
translate: max(calc(-1 * var(--acpmux-chat-shift, 0px)), calc(-1 * var(--acpmux-rail-left, 0px))) 0;
}
/* The rail is pinned to the pane's left edge; its portaled card follows its own rail origin. Neither
inherits Session Info's chat shift, so opening or closing that panel cannot strand the rail. */
/* A part that joins the stack while the chat is moved rises from below its moved place
(bottomStack.css ui-enter), not from the unmoved one. Only the dock's own animated parts (the same
list as bottomStack.css): the parts nested in them enter inside a part already moved. */
.acpmux-main > .acpmux-dock > :not(.acpmux-composer, .acpmux-home-area, .acpmux-asks-stack) {
--ui-enter-x: calc(-1 * var(--acpmux-chat-shift, 0px));
}
@media (prefers-reduced-motion: no-preference) {
.acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-stage > .acpmux-scroll > :is(.acpmux-spacer, .acpmux-find, .acpmux-minimap),
.acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-stage > .acpmux-minimap__card,
.acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-stage > .acpmux-scroll > :is(.acpmux-spacer, .acpmux-find),
.acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-dock > *,
.acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-stage .acpmux-handoff-review {
transition: translate var(--agent-motion-move, var(--cmux-motion-quick, 80ms))
var(--acpmux-motion-ease, ease-out);
}
}
:root[data-cmux-motion="reduced"] .acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-stage > .acpmux-scroll > :is(.acpmux-spacer, .acpmux-find, .acpmux-minimap),
:root[data-cmux-motion="reduced"] .acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-stage > .acpmux-minimap__card,
:root[data-cmux-motion="reduced"] .acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-stage > .acpmux-scroll > :is(.acpmux-spacer, .acpmux-find),
:root[data-cmux-motion="reduced"] .acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-dock > *,
:root[data-cmux-motion="reduced"] .acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-stage .acpmux-handoff-review,
:root[data-cmux-motion="off"] .acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-stage > .acpmux-scroll > :is(.acpmux-spacer, .acpmux-find, .acpmux-minimap),
:root[data-cmux-motion="off"] .acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-stage > .acpmux-minimap__card,
:root[data-cmux-motion="off"] .acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-stage > .acpmux-scroll > :is(.acpmux-spacer, .acpmux-find),
:root[data-cmux-motion="off"] .acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-dock > *,
:root[data-cmux-motion="off"] .acpmux-shell:not([data-inspector-chat-placement="push"]) .acpmux-main > .acpmux-stage .acpmux-handoff-review {
transition: none;
}
.acpmux-shell[data-inspector-chat-placement="push"] .acpmux-main > .acpmux-stage > .acpmux-scroll > :is(.acpmux-spacer, .acpmux-find, .acpmux-minimap),
.acpmux-shell[data-inspector-chat-placement="push"] .acpmux-main > .acpmux-stage > .acpmux-minimap__card,
.acpmux-shell[data-inspector-chat-placement="push"] .acpmux-main > .acpmux-stage > .acpmux-scroll > :is(.acpmux-spacer, .acpmux-find),
.acpmux-shell[data-inspector-chat-placement="push"] .acpmux-main > .acpmux-dock > *,
.acpmux-shell[data-inspector-chat-placement="push"] .acpmux-main > .acpmux-stage .acpmux-handoff-review {
transition: none;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const PreviewCard = React.forwardRef<
/// The rail's unshifted left edge, for the same pane-edge clamp in both CSS translations.
railLeft?: number;
bookmark?: PreviewBookmark;
onPointerEnter?(): void;
onPointerLeave(event: React.PointerEvent<HTMLDivElement>): void;
onSizeChange?(): void;
onFocus?(): void;
Expand All @@ -103,6 +104,7 @@ export const PreviewCard = React.forwardRef<
metadata,
railLeft = 0,
bookmark,
onPointerEnter,
onPointerLeave,
onSizeChange,
onFocus,
Expand Down Expand Up @@ -195,6 +197,7 @@ export const PreviewCard = React.forwardRef<
transform: `translate3d(${at.x + x}px, ${at.y + y}px, 0)`,
} as React.CSSProperties
}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onWheel={layer ? (event) => forwardWheel(scroller!, event.nativeEvent) : undefined}
onFocus={onFocus}
Expand Down
21 changes: 15 additions & 6 deletions webviews/src/agent-session/acpmux/threadMinimap/ScrollMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// mark jumps to it (a prompt as the compact rail's click does); a click on the track jumps there;
// dragging anywhere scrubs the transcript. Geometry is the transcript's layout (mapGeometry.ts).
import React, { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { transcriptColumn, type AcpmuxRow, type ConversationLayout } from "../model";
import { type AcpmuxRow, type ConversationLayout } from "../model";
import { useT } from "../i18n";
import { MinimapTick } from "../../../ui/MinimapTick";
import { useHoverIntent } from "../../../ui/hoverIntent";
Expand All @@ -20,7 +20,6 @@ import {
JUMP_CURVE,
jumpDuration,
LANDING_OFFSET,
railPlacement,
replyPreview,
ROW_WIDTH,
THREAD_PADDING_TOP,
Expand All @@ -41,6 +40,8 @@ import {

/// A pointer that moves this far after pressing scrubs instead of clicking.
const SCRUB_SLOP = 3;
/// How far the full-height map may lie over the transcript text, as the compact rail could on base.
const MAP_MAX_OVERLAP = 8;
/// The drawn lengths, at rest and fully grown: a prompt's tick, and a reply's.
const USER_LENGTH = [11, TICK_BOX] as const;
const AGENT_LENGTH = [5, 15] as const;
Expand All @@ -58,7 +59,7 @@ export function ScrollMap({
scroller,
viewportHeight,
scrolls,
width,
placement,
marks: bookmarks,
jump,
}: {
Expand All @@ -68,7 +69,8 @@ export function ScrollMap({
scroller: React.RefObject<HTMLElement | null>;
viewportHeight: number;
scrolls: boolean;
width: number;
/// The compact rail's pane-edge placement (useRailPlacement), measured against the actual text.
placement: { left: number; width: number; overlapsText: boolean; gutter: number };
marks: PromptMarks;
/// The compact rail's jump to a prompt (ThreadMinimap), so a click lands and flashes the same.
jump: (turn: number, how: "click" | "scrub") => number | undefined;
Expand Down Expand Up @@ -100,8 +102,15 @@ export function ScrollMap({
const card = useRef<HTMLDivElement>(null);
const cardId = useId();
const press = useRef<Press | undefined>(undefined);
const placement = railPlacement(transcriptColumn(width).inset);
const shown = turns.length >= MAP_MIN_PROMPTS && scrolls && height > 4 * MAP_MARGIN && !placement.hidden;
// The full-height map keeps its 36 px box and has no reveal strip, so it shows only where that box
// overlaps the text by at most MAP_MAX_OVERLAP: past that its click and drag handlers would take
// text selection and links (the compact rail compresses and reveals itself instead).
const shown =
turns.length >= MAP_MIN_PROMPTS &&
scrolls &&
height > 4 * MAP_MARGIN &&
!placement.overlapsText &&
placement.width >= ROW_WIDTH - MAP_MAX_OVERLAP;

/// The marks the view bar covers (first and last place in `marks`), for their "current" tone.
/// The bar moves by style on each scroll frame; this renders only when the covered marks change.
Expand Down
Loading
Loading