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
44 changes: 38 additions & 6 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1792,6 +1792,7 @@ export function VirtualTranscript({
needsYou,
dock,
queueWithdrawn,
queueIds,
focusComposer,
openAt,
onAnchor,
Expand Down Expand Up @@ -1823,6 +1824,9 @@ export function VirtualTranscript({
/// Prompts withdrawn from the queue (snapshot `queueWithdrawn`): no row comes for one whose card
/// leaves the stack, so the view keeps no room for it.
queueWithdrawn?: readonly string[];
/// IDs from the queue snapshot: a folded prompt has no card in the DOM, but remains queued and
/// must not be recorded as withdrawn when the fold changes.
queueIds?: readonly string[];
/// Where a keyboard Jump to latest leaves focus, since the button hides (inert) once there.
focusComposer?: () => void;
onToggleActivity: (id: string) => void;
Expand Down Expand Up @@ -1921,14 +1925,35 @@ export function VirtualTranscript({
withdrawn.current = withdrawnSet;
const dockRef = useRef(dock);
dockRef.current = dock;
const queueSession = useRef(sessionId);
queueSession.current = sessionId;
/// The chat and the stack `queuedIds` were noted in.
const notedIn = useRef<{ sessionId?: string; dock?: HTMLElement | null }>({});
/// Notes the stack's queued prompts; called first by each size observer (the stack's, and the
/// view's, for a stack laid out under the transcript), so the render a size change makes knows.
/// view's, for a stack laid out under the transcript), so the render a size change makes knows,
/// and by each change of the queue (below). A prompt the queue's fold hides (ComposerQueue
/// `data-folded-ids`) is still queued: closing the fold is not its card leaving, and no room is
/// kept under the last row for a prompt that stays queued (Leo 2026-10-21: the queue closed left a
/// hole over the stack, then the rows dropped).
const noteQueue = useCallback(() => {
const now = new Set(
[...(dockRef.current?.querySelectorAll<HTMLElement>(".acpmux-queued") ?? [])].map(
(card) => card.dataset.promptId ?? "",
const dock = dockRef.current;
const now = new Set([
...[...(dock?.querySelectorAll<HTMLElement>(".acpmux-queued") ?? [])].map((card) => card.dataset.promptId ?? ""),
...[...(dock?.querySelectorAll<HTMLElement>("[data-folded-ids]") ?? [])].flatMap((list) =>
(list.dataset.foldedIds ?? "").split(" "),
),
);
...(queueIds ?? []),
]);
// Another chat's queue, or another stack, starts over: the last one's prompts never left this
// one, and no room is held for them (#24182: switched to a chat with its own queue, the last
// chat's prompts read as cards that left, and a prompt withdrawn later kept room as a sent one).
if (notedIn.current.sessionId !== queueSession.current || notedIn.current.dock !== dock) {
notedIn.current = { sessionId: queueSession.current, dock };
queuedIds.current = now;
heldFor.current.clear();
queueLeft.current = false;
return;
}
const left = [...queuedIds.current].filter((id) => !now.has(id));
queuedIds.current = now;
if (!left.length) return;
Expand All @@ -1937,7 +1962,7 @@ export function VirtualTranscript({
queueLeft.current = true;
// Only the size change of this frame is the card leaving.
requestAnimationFrame(() => requestAnimationFrame(() => (queueLeft.current = false)));
}, []);
}, [queueIds]);
useLayoutEffect(() => {
if (!dock) {
setDockSpace(0);
Expand Down Expand Up @@ -1973,6 +1998,11 @@ export function VirtualTranscript({
setDockCard(card(space));
return () => observer.disconnect();
}, [dock, noteQueue]);
// And each time the queue changes, in the commit that draws it. A change that leaves the stack's
// size as it was (a prompt the fold hides, or one an opened list's cap cuts off, run by another
// client) runs neither observer: still noted as queued, the next resize took it for a card that
// left then, and kept room above the stack for FLOOR_HOLD_MS that then glided away (#24182).
useLayoutEffect(() => noteQueue(), [sessionId, queueIds, noteQueue]);
const [width, setWidth] = useState(() => lastTranscriptBox.width);
// The column's cap (layout.json `chatWidth`): a new one measures every row again.
const chatWidth = useChatWidth();
Expand Down Expand Up @@ -3955,6 +3985,7 @@ function AcpmuxPane() {
),
[clientSnapshot, switchView, catalog],
);
const queueIds = useMemo(() => snapshot.queue.map((entry) => entry.id), [snapshot.queue]);
// Another chat ends the replay: coming back to this one opens it live.
if (replaying && snapshot.sessionId !== replaying) setReplaying(undefined);
// A /btw failure belongs to its chat: another chat drops it.
Expand Down Expand Up @@ -7551,6 +7582,7 @@ function AcpmuxPane() {
// Quick Chat's panel stacks its composer below the transcript.
dock={quick ? undefined : dock}
queueWithdrawn={snapshot.queueWithdrawn}
queueIds={quick ? undefined : queueIds}
find={quick ? undefined : find}
sessionId={
childRows
Expand Down
10 changes: 10 additions & 0 deletions webviews/src/agent-session/acpmux/ComposerQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,16 @@ export function ComposerQueue({
ref={list}
className="ui-glass ui-glass-floats acpmux-composer-queue"
data-unfolded={foldable && !folded ? "" : undefined}
// The prompts the fold hides, still queued: the transcript keeps no room for them as it
// does for a card that leaves (VirtualTranscript `noteQueue`).
data-folded-ids={
folded
? shown
.slice(visible.length)
.map((entry) => entry.id)
.join(" ")
: undefined
}
// A row that takes focus from the keyboard (keys, a move, ↑ from the prompt) scrolls in whole.
onFocus={(event) => {
const row = (event.target as HTMLElement).closest("li");
Expand Down
Loading
Loading