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
87 changes: 32 additions & 55 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ import type { PermissionDecision } from "./permissions/protocol";
import { checkpointStrings } from "./checkpoints/strings";
import { QUICK_MESSAGES, readSurface, useEscapeToDismiss, type PaneSurface } from "./paneSurface";
import { QuickSurface } from "./QuickSurface";
import { chiefDock } from "./chiefDock.experiment";
import { contextAttachment, floatingStatus, type QuickFloating } from "./FloatingChat";
import { setFloatingState, useFloatingState } from "./floatingState";
import { FailedPrompt } from "./FailedPrompt";
Expand Down Expand Up @@ -971,10 +972,6 @@ const currentRegistry = (): NativeRegistry => ({
...(window.cmuxAcpmuxRegistry as unknown as NativeRegistry | undefined),
});

/// The thread's top padding (styles.css `.acpmux-thread`): rows draw this far below their places,
/// so the transcript ends this far past their total height.
const THREAD_TOP = 16;

/// Where a scroller sits, read while its content still matches `totalHeight`.
const scrollPosition = (node: HTMLElement, totalHeight: number) => ({
top: node.scrollTop,
Expand Down Expand Up @@ -1118,7 +1115,7 @@ export function VirtualTranscript({
expanded: Set<string>;
registry?: NativeRegistry;
canLoadOlder?: boolean;
/// Pages older history in (canLoadOlder): asked once per loaded page while the view is near the top.
/// Pages older history in (canLoadOlder): asked again on each render while the view is near the top.
onLoadOlder?: () => Promise<void>;
githubRepository?: string;
/// Find in Chat (chatFind.ts): its bar, the query to highlight and the match to bring into view.
Expand Down Expand Up @@ -1456,12 +1453,7 @@ export function VirtualTranscript({
};
return { layout, ms: acpmuxPerf.enabled ? performance.now() - layoutStart : 0 };
}, [estimated, drawn, registry, rows, sessionId, width]);
// The transcript's height is the rows' as drawn, under the thread's top padding: the rows' total
// alone placed the latest row's bottom that far under the view.
const layout = useMemo(
() => ({ ...measured.layout, totalHeight: measured.layout.totalHeight + THREAD_TOP }),
[measured],
);
const layout = measured.layout;
const reportedLayout = useRef<typeof measured | null>(null);
const reportedEstimate = useRef<typeof estimated | null>(null);
const sessionSwitched = previousSession.current !== sessionId;
Expand All @@ -1486,10 +1478,6 @@ export function VirtualTranscript({
const warmTop = useDeferredValue(renderScroll.top);
const warm = visibleLayoutRange(layout, warmTop - height * WARM_VIEWPORTS, height * (1 + 2 * WARM_VIEWPORTS));
const mountedRanges = mergeRowRanges(warm.last < range.first || warm.first > range.last ? ahead : [...ahead, warm]);
// Find highlights only need repainting when the query/target, row content, or mounted DOM
// window changes. Keep this signature primitive so scroll and layout state updates that leave
// the mounted rows alone do not walk every text node in the transcript again.
const mountedRangeKey = mountedRanges.map(({ first, last }) => `${first}:${last}`).join(",");
if (lastPlaced.current) {
lastPlaced.current.first = mountedRanges[0]?.first ?? 0;
lastPlaced.current.last = mountedRanges.at(-1)?.last ?? 0;
Expand Down Expand Up @@ -1755,21 +1743,19 @@ export function VirtualTranscript({
// text is drawn, the match itself; every commit repaints the highlights over the mounted rows.
const findQuery = find?.open ? find.query : "";
const findTarget = find?.matches[find.active];
const findTargetRowId = findTarget?.rowId;
const findTargetOccurrence = findTarget?.occurrence;
// Find matches the chat's rows; the transcript draws a match's row (or its copy in an open fold)
// once its fold is open.
const findIndex = findTargetRowId !== undefined ? findRowIndex(rows, findTargetRowId) : -1;
const findIndex = findTarget ? findRowIndex(rows, findTarget.rowId) : -1;
const drawnId = rows[findIndex]?.id;
const drawnCard = Boolean(rows[findIndex]?.ended);
const revealing = useRef<string | undefined>(undefined);
const targetKey =
findTargetRowId !== undefined && findTargetOccurrence !== undefined && findQuery && drawnId !== undefined
? `${drawnId}:${drawnCard}:${findTargetOccurrence}:${findQuery}`
findTarget && findQuery && drawnId !== undefined
? `${drawnId}:${drawnCard}:${findTarget.occurrence}:${findQuery}`
: undefined;
// A group or call that opens to show the match asks for a render here, so the highlights and
// the scroll below see its text.
const [revealed, setRevealed] = useState(0);
const [, setRevealed] = useState(0);
// Keyed on what it names, not the match object, which is new on each update while Find is open:
// a new value would open again every group a reader closed.
const findReveal = useMemo(
Expand Down Expand Up @@ -1805,8 +1791,8 @@ export function VirtualTranscript({
const current = paintFindHighlights(
node,
findQuery,
findTargetRowId !== undefined && findTargetOccurrence !== undefined && drawnId !== undefined
? { rowId: drawnId, card: drawnCard, occurrence: findTargetOccurrence }
findTarget && drawnId !== undefined
? { rowId: drawnId, card: drawnCard, occurrence: findTarget.occurrence }
: undefined,
);
if (!current || revealing.current !== targetKey) return;
Expand All @@ -1830,19 +1816,7 @@ export function VirtualTranscript({
if (box.scrollHeight > box.clientHeight && /auto|scroll/.test(getComputedStyle(box).overflowY))
scrollRangeIn(box, current);
scrollRangeIn(node, current);
// Rows are immutable snapshots, so their identity changes when streamed text or a revealed
// fold changes. The mounted-range signature catches virtualization entering/leaving rows.
}, [
drawnCard,
drawnId,
findQuery,
findTargetOccurrence,
findTargetRowId,
mountedRangeKey,
revealed,
rows,
targetKey,
]);
});
useEffect(() => clearFindHighlights, []);
// Where the view rests, once a scroll settles; the latest message is null.
const reportedAnchor = useRef<string | undefined>(undefined);
Expand Down Expand Up @@ -1994,27 +1968,11 @@ export function VirtualTranscript({
// Older history pages in before the reader reaches it: whenever the view is within two viewports
// of the first loaded row, including a tail too short to fill them. The anchoring above keeps the
// reader's row in place as each page lands.
const olderLoadKey = `${sessionKey}\u0000${rows[0]?.id ?? ""}`;
const olderLoadRequested = useRef<string | undefined>(undefined);
useEffect(() => {
const node = ref.current;
if (!canLoadOlder || !onLoadOlder || !node || node.clientHeight === 0) {
olderLoadRequested.current = undefined;
return;
}
if (node.scrollTop >= node.clientHeight * 2) {
// Allow the next visit to the threshold to request the next page, even when the first row
// did not change while the reader was away from the top.
olderLoadRequested.current = undefined;
return;
}
if (olderLoadRequested.current === olderLoadKey) return;
olderLoadRequested.current = olderLoadKey;
onLoadOlder().catch(() => {
// Keep transient transport failures retryable without turning every render into a request.
if (olderLoadRequested.current === olderLoadKey) olderLoadRequested.current = undefined;
});
}, [canLoadOlder, height, onLoadOlder, olderLoadKey, renderScroll.top]);
if (!canLoadOlder || !onLoadOlder || !node || node.clientHeight === 0) return;
if (node.scrollTop < node.clientHeight * 2) onLoadOlder().catch(() => {});
});
const pointAt = (target: EventTarget | null) => {
const row = target instanceof Element ? target.closest<HTMLElement>(".acpmux-row") : null;
const from = row ? Number(row.getAttribute("aria-posinset")) - 1 : -1;
Expand Down Expand Up @@ -5256,6 +5214,24 @@ function AcpmuxPane() {
.catch(() => undefined),
}
: undefined;
const chiefDockEnabled = quick && experimentArm(chiefDock) === "on";
const quickChiefDock = chiefDockEnabled
? {
sessions: composerSnapshot.sessions,
currentId: snapshot.sessionId,
queueCounts:
snapshot.sessionId && snapshot.queue.length > 0
? new Map([[snapshot.sessionId, snapshot.queue.length]])
: undefined,
steeringIds:
snapshot.summary?.steering && snapshot.sessionId && snapshot.queue.length > 0
? new Set([snapshot.sessionId])
: undefined,
onSelect: selectSession,
onNewChat: () => void callNative("chat.new", {}).catch(() => undefined),
onMoveToTab: (sessionId: string) => void callNative("chat.openTab", { sessionId }).catch(() => undefined),
}
: undefined;
if (quick)
return (
<ShortcutsContext.Provider value={shortcuts}>
Expand All @@ -5270,6 +5246,7 @@ function AcpmuxPane() {
}
composer={composer}
floating={quickFloating}
chiefDock={quickChiefDock}
/>
{imageView && (
<ImageViewer
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
173 changes: 173 additions & 0 deletions webviews/src/agent-session/acpmux/ChiefDock.gallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// l10n-allow-file: gallery fixtures, not shipped UI.
import { componentEntry } from "../../gallery/format";
import { session } from "../../gallery/fixtures/acpmux";
import type { PlayContext } from "../../gallery/play";
import { ChiefDock, type ChiefMascot } from "./ChiefDock";

const base = [
session({
sessionId: "chief-working",
title: "Prepare the release notes",
status: "running",
indicator: "working",
preview: "Checking the changelog and open pull requests…",
updatedAt: 1000,
}),
session({
sessionId: "chief-input",
title: "Review the sidebar polish",
status: "waiting",
indicator: "input",
pendingPermissions: 1,
preview: "The agent needs your approval to continue.",
updatedAt: 900,
}),
session({
sessionId: "chief-unread",
title: "Update the screenshots",
status: "idle",
indicator: "done",
unread: true,
preview: "The new gallery captures are ready.",
updatedAt: 800,
}),
session({ sessionId: "chief-quiet", title: "Draft the onboarding copy", status: "idle", updatedAt: 700 }),
session({
sessionId: "chief-child",
title: "Inspect a changed file",
status: "running",
indicator: "working",
spawnedBy: "chief-working",
preview: "A child agent is checking one file.",
updatedAt: 1100,
}),
];

const WALLPAPER_REFERENCE = new URL("../../gallery/fixtures/assets/chief-wallpaper-reference.png", import.meta.url)
.href;

const wallpaperGlassPlay = async (ctx: PlayContext) => {
const background = `url("${WALLPAPER_REFERENCE}") center / cover fixed`;
for (const node of [ctx.document.documentElement, ctx.document.body]) {
node.style.setProperty("background", background, "important");
node.style.setProperty("background-color", "#121212", "important");
}
for (const node of ctx.document.querySelectorAll<HTMLElement>("#root, .acpmux-shell, .acpmux-quick, .acpmux-main")) {
node.style.setProperty("background", "transparent", "important");
}
};

const toggleStack = async (ctx: PlayContext) => {
await ctx.click({ role: "button", name: "Agent Chat" });
await ctx.waitFor(() => ctx.document.querySelector('.acpmux-chief-dock[data-expanded="false"]'));
await ctx.click({ role: "button", name: "Agent Chat" });
await ctx.waitFor(() => ctx.document.querySelector('.acpmux-chief-dock[data-expanded="true"]'));
};

const mascotOption = (mascot: ChiefMascot, note: string) => ({
note,
props: {
sessions: base,
currentId: "chief-working",
mascot,
defaultExpanded: true,
onSelect: () => undefined,
onNewChat: () => undefined,
onMoveToTab: () => undefined,
},
});

export default componentEntry({
id: "agent-pane.chief-dock",
title: "Chief Dock",
area: "Agent pane",
pane: true,
height: 760,
widths: { narrow: 520, normal: 720, wide: 900 },
anchors: [{ selector: ".acpmux-chief-dock" }],
checks: {
anchorMovePx: { value: 0, reason: "The dock keeps its rail and card stack anchored while session status changes." },
layoutShiftMax: { value: 0.05, reason: "Opening and closing the stack changes only the dock's own overlay." },
longFrameFailMs: { value: 33, reason: "The dock is a small local list and should stay responsive." },
},
covers: ["agent-session/acpmux/ChiefDock.tsx#ChiefDock"],
styles: () => import("./styles.css"),
load: async () => ChiefDock,
variants: {
baseline: {
note: "The default Chief orb with a stacked set of working, attention and quiet agent chats; spawned child chats stay folded.",
props: {
sessions: base,
currentId: "chief-working",
defaultExpanded: true,
onSelect: () => undefined,
onNewChat: () => undefined,
onMoveToTab: () => undefined,
},
},
"wallpaper-glass": {
note: "Reference-backed glass check: the same deterministic stack over a busy wallpaper at the gallery viewport size.",
props: {
sessions: base,
currentId: "chief-working",
defaultExpanded: true,
onSelect: () => undefined,
onNewChat: () => undefined,
onMoveToTab: () => undefined,
},
play: wallpaperGlassPlay,
},
"mascot-orb-spark": mascotOption("orb-spark", "Mascot option: a soft star-marked orb with a friendly pixel face."),
"mascot-orb-hood": mascotOption("orb-hood", "Mascot option: a hooded night orb with a compact silhouette."),
"mascot-machine": mascotOption(
"machine",
"Mascot option: a tiny friendly machine with a compact, readable pixel silhouette.",
),
"mascot-machine-cyan": mascotOption("machine-cyan", "Mascot option: a round cyan-lit machine companion."),
"mascot-machine-blue": mascotOption(
"machine-blue",
"Mascot option: a blue-eared machine with a playful product silhouette.",
),
"mascot-moon": mascotOption(
"moon",
"Mascot option: a soft moon-moth companion, kept as an alternate Chief personality.",
),
"mascot-moon-blue": mascotOption("moon-blue", "Mascot option: a blue cloud companion with a simple star antenna."),
"mascot-fox": mascotOption("fox", "Mascot option: a warm pixel fox Chief with a clear, expressive silhouette."),
"attention-stack": {
note: "Two agents need input; the badge and attention dots keep the important work visible first.",
props: {
sessions: [base[1], { ...base[2], indicator: "failed", status: "failed" }, base[0]],
currentId: "chief-input",
defaultExpanded: true,
onSelect: () => undefined,
onNewChat: () => undefined,
onMoveToTab: () => undefined,
},
},
"toggle-stack": {
note: "The mascot rail folds and unfolds the glass stack while preserving its anchor and focus target.",
props: {
sessions: base,
currentId: "chief-input",
onSelect: () => undefined,
onNewChat: () => undefined,
onMoveToTab: () => undefined,
},
play: toggleStack,
},
"steering-stack": {
note: "A running chat owns two queued steering prompts; its card becomes the active lead in the overlapping stack.",
props: {
sessions: base,
currentId: "chief-working",
defaultExpanded: true,
queueCounts: new Map([["chief-working", 2]]),
steeringIds: new Set(["chief-working"]),
onSelect: () => undefined,
onNewChat: () => undefined,
onMoveToTab: () => undefined,
},
},
},
});
Loading
Loading