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
13 changes: 13 additions & 0 deletions packages/features/src/coding-agent-wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,16 @@ test("a page joins the messages held: one heard again is replaced in place, a ne
);
assert.equal(applyMessagesPage(held, []), held);
});

test("a page saying again what is held keeps the messages held, so only one that changed is drawn again", () => {
const held = [message("a", "first"), message("b", "second")];

assert.equal(
applyMessagesPage(held, [message("a", "first"), message("b", "second")]),
held,
"a transcript heard again whole, as it was, is the one held",
);
const amended = applyMessagesPage(held, [message("a", "first"), message("b", "second, amended")]);
assert.equal(amended[0], held[0], "a message heard again as it was is the one held");
assert.deepEqual(amended[1]?.parts, [{ type: "text", text: "second, amended" }]);
});
41 changes: 35 additions & 6 deletions packages/features/src/coding-agent-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1895,21 +1895,50 @@ export const codingAgentMessagesAnswerSchema = EffectSchema.Struct({

export type CodingAgentMessagesAnswer = typeof codingAgentMessagesAnswerSchema.Type;

/** Whether two JSON values, as a page decodes them, say the same. */
function sameJson(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
if (Array.isArray(a) || Array.isArray(b)) {
return (
Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every((each, index) => sameJson(each, b[index]))
);
}
const theirs = new Map(Object.entries(b));
const ours = Object.entries(a);
return (
ours.length === theirs.size &&
ours.every(([key, value]) => theirs.has(key) && sameJson(value, theirs.get(key)))
);
}

/**
* A page of an agent's messages joined to the ones held: a message heard
* again replaces the one held under its id, in place, and a new one joins
* at the end, so a journal that grows in place is redrawn where it stands.
* The agent's tab and the host's read of a task's conversation join pages
* the same way, so both draw one transcript from the same answers.
* again that says something new replaces the one held under its id, in
* place, and a new one joins at the end, so a journal that grows in place is
* redrawn where it stands. A message heard again as it was held is kept as
* held, and a page that changes nothing answers what was held, because a
* transcript followed afresh is heard again whole, and what draws a message
* by its identity then draws only what changed. The agent's tab and the
* host's read of a task's conversation join pages the same way, so both
* draw one transcript from the same answers.
*/
export function applyMessagesPage(
held: readonly CodingAgentMessage[],
page: readonly CodingAgentMessage[],
): readonly CodingAgentMessage[] {
if (page.length === 0) return held;
const heard = new Map(page.map((message) => [message.id, message]));
const joined = held.map((message) => heard.get(message.id) ?? message);
const joined = held.map((message) => {
const again = heard.get(message.id);
return again === undefined || sameJson(message, again) ? message : again;
});
const known = new Set(held.map((message) => message.id));
const appended = page.filter((message) => !known.has(message.id));
return [...joined, ...appended];
const unchanged =
appended.length === 0 && joined.every((message, index) => message === held[index]);
return unchanged ? held : [...joined, ...appended];
}
66 changes: 57 additions & 9 deletions packages/panel/src/desktop/column-dock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import { useColumnDock } from "./column-dock.js";
* its foot, and never a thread's reply box open among the messages above it,
* nor a composer left in a hidden pane. Beside the work, where only the
* folded sidebar's card reads it, the composer docks only where it reaches
* under the card, past a rail narrower than the card. jsdom lays nothing
* out, so each box is staged where the window lays it.
* under the card, past a rail narrower than the card. A part landing after
* the column stands, a to-do list over an agent's composer, raises the dock
* at the frame after it lands. jsdom lays nothing out, so each box is staged
* where the window lays it, and each frame is the test's.
*/

let root: Root | undefined;
Expand Down Expand Up @@ -92,13 +94,8 @@ function RailShell({ narrow }: { narrow: boolean }): React.JSX.Element {
);
}

/** Draws `shell` in the room given, over a rail ending where given, answering the dock it writes on the document's root. */
function dockOf(
shell: (props: { narrow: boolean }) => React.JSX.Element,
narrow: boolean,
railEnd = 212,
): string {
const boxes = staged(railEnd);
/** Lays each element out where `boxes` stages it by its `data-staged` name, and nowhere else. */
function stage(boxes: ReadonlyMap<string, Staged>): void {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (
this: HTMLElement,
) {
Expand All @@ -111,6 +108,15 @@ function dockOf(
height: box.bottom - box.top,
});
});
}

/** Draws `shell` in the room given, over a rail ending where given, answering the dock it writes on the document's root. */
function dockOf(
shell: (props: { narrow: boolean }) => React.JSX.Element,
narrow: boolean,
railEnd = 212,
): string {
stage(staged(railEnd));
const container = document.body.appendChild(document.createElement("div"));
root = createRoot(container);
act(() => root?.render(createElement(shell, { narrow })));
Expand All @@ -132,3 +138,45 @@ test("beside the work, a rail narrower than the folded sidebar's card leaves the
test("beside the work, a rail as wide as the card holds it over its rows, and the composer beside it docks nothing", () => {
assert.equal(dockOf(ChatShell, false, 300), "0px");
});

/** An agent's page in a narrow room, its composer at its foot and, where it stands, the to-do list over it. */
function AgentShell({ todos }: { todos: boolean }): React.JSX.Element {
const shell = useRef<HTMLDivElement>(null);
useColumnDock(shell, false, true, "agent");
return createElement(
"div",
{ ref: shell, className: "desktop-shell" },
createElement(
"div",
{ className: "agent-page", "data-staged": "page" },
todos
? createElement("div", { className: "agent-todos-dock", "data-staged": "todos" })
: null,
createElement("div", { className: "agent-composer", "data-staged": "composer" }),
),
);
}

test("a to-do list landing over an agent's composer raises the dock at the frame after it lands", async () => {
const frames: FrameRequestCallback[] = [];
vi.spyOn(window, "requestAnimationFrame").mockImplementation((heard) => frames.push(heard));
stage(
new Map([
["page", { left: 0, right: 1000, top: 0, bottom: 800 }],
["todos", { left: 0, right: 1000, top: 600, bottom: 700 }],
["composer", { left: 0, right: 1000, top: 700, bottom: 800 }],
]),
);
const container = document.body.appendChild(document.createElement("div"));
root = createRoot(container);
act(() => root?.render(createElement(AgentShell, { todos: false })));
const dock = () => document.documentElement.style.getPropertyValue("--column-dock");
assert.equal(dock(), "100px", "the composer alone docks");

await act(async () => root?.render(createElement(AgentShell, { todos: true })));
act(() => {
for (const heard of frames.splice(0)) heard(0);
});

assert.equal(dock(), "200px", "the to-do list over it docks with it");
});
69 changes: 55 additions & 14 deletions packages/panel/src/desktop/column-dock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ const COLUMN_DOCK = "--column-dock";
/** The card that floats over the column while the sidebar is folded. */
const FLOATING_CARD = '.call-card[data-surface="floating"]';

/** The length the floating card's own height is written in. */
const FLOATING_CARD_HEIGHT = "--floating-card";

/** Writes `length` on `element` as `value`, or takes it away where there is none, touching the style only when it changes. */
function writeLength(element: HTMLElement, length: string, value: string | undefined): void {
const was = element.style.getPropertyValue(length);
if (value === undefined) {
if (was !== "") element.style.removeProperty(length);
return;
}
if (was !== value) element.style.setProperty(length, value);
}

/** Whether two lists hold the same elements in the same order. */
function sameElements(a: readonly Element[], b: readonly Element[]): boolean {
return a.length === b.length && a.every((element, index) => element === b[index]);
}

/** From the column's foot to the top of its highest docked part, or nothing where none stands. */
function dockHeight(column: Element, docked: string, card: DOMRect | undefined): number {
const foot = column.getBoundingClientRect().bottom;
Expand Down Expand Up @@ -58,6 +76,13 @@ function dockHeight(column: Element, docked: string, card: DOMRect | undefined):
* the composer. `key` names what the shell is drawing,
* so a page or a panel coming and going finds its column afresh. A shell
* with no such column (Settings, a version history) says no dock at all.
*
* Note that every change under the shell (a transcript's message landing, a
* hover card) wakes the MutationObserver, and each measure lays out the whole
* window, so the changes of one frame are read once, at that frame, the
* column and its parts followed afresh only where they are others than were
* followed; and that a length is written only when it changes, since a
* length on the document's root restyles every element under it.
*/
export function useColumnDock(
shell: RefObject<HTMLElement | null>,
Expand All @@ -78,39 +103,55 @@ export function useColumnDock(
// Beside the work only the card reads the dock, so a chat's composer docks only under it.
const besideCard = !panelFull && !narrow;
let column = host.querySelector(columnSelector);
// What the observer follows, so a change that leaves them standing follows nothing anew.
let followed: readonly Element[] = [];
const sized = new ResizeObserver(() => write());
function write(): void {
if (host === null) return;
const card = host.querySelector(FLOATING_CARD);
const under = besideCard ? cardSpan(card) : undefined;
if (column === null) root.style.removeProperty(COLUMN_DOCK);
else root.style.setProperty(COLUMN_DOCK, `${dockHeight(column, docked, under)}px`);
if (card === null) host.style.removeProperty("--floating-card");
else host.style.setProperty("--floating-card", `${card.getBoundingClientRect().height}px`);
writeLength(
root,
COLUMN_DOCK,
column === null ? undefined : `${dockHeight(column, docked, under)}px`,
);
writeLength(
host,
FLOATING_CARD_HEIGHT,
card === null ? undefined : `${card.getBoundingClientRect().height}px`,
);
}
function follow(): void {
if (host === null) return;
sized.disconnect();
if (column !== null) {
sized.observe(column);
for (const part of column.querySelectorAll(docked)) sized.observe(part);
}
const card = host.querySelector(FLOATING_CARD);
if (card !== null) sized.observe(card);
const standing = [
...(column === null ? [] : [column, ...column.querySelectorAll(docked)]),
...(card === null ? [] : [card]),
];
if (sameElements(standing, followed)) return;
followed = standing;
sized.disconnect();
for (const element of standing) sized.observe(element);
}
let frame: number | undefined;
const arrived = new MutationObserver(() => {
column = host.querySelector(columnSelector);
follow();
write();
if (frame !== undefined) return;
frame = requestAnimationFrame(() => {
frame = undefined;
column = host.querySelector(columnSelector);
follow();
write();
});
});
arrived.observe(host, { childList: true, subtree: true });
follow();
write();
return () => {
if (frame !== undefined) cancelAnimationFrame(frame);
sized.disconnect();
arrived.disconnect();
root.style.removeProperty(COLUMN_DOCK);
host.style.removeProperty("--floating-card");
host.style.removeProperty(FLOATING_CARD_HEIGHT);
};
}, [shell, panelFull, narrow, key]);
}
3 changes: 2 additions & 1 deletion packages/panel/src/tasks/mention-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1434,8 +1434,9 @@ export function useMentionPicker({
];

// The list stands by the word's line, on the side asked, inside the room's width.
// Note that the room is measured only while the list is shown, since a measure in a draw lays out the whole window.
const box = room.current;
const placed = box?.getBoundingClientRect();
const placed = shown ? box?.getBoundingClientRect() : undefined;
const placeOf = (word: ComposerTrigger, at: DOMRect, width: number): MenuPlace => {
const left = Math.max(0, Math.min(word.rect.left - at.left, width - MENU_WIDTH));
return side === MENTION_MENU_SIDE.BELOW
Expand Down
3 changes: 2 additions & 1 deletion packages/panel/src/tasks/skill-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,8 +323,9 @@ export function useSkillPicker({
};

// The list stands by the slash's line, on the side asked, measured from the box's field, which it keeps inside.
// Note that the field is measured only while the list is open, since a measure in a draw lays out the whole window.
const box = room.current;
const placed = box?.getBoundingClientRect();
const placed = open ? box?.getBoundingClientRect() : undefined;
const placeOf = (word: ComposerTrigger, at: DOMRect, width: number): SkillMenuPlace => {
const left = Math.max(0, Math.min(word.rect.left - at.left, width - MENU_WIDTH));
return side === MENTION_MENU_SIDE.BELOW
Expand Down
51 changes: 50 additions & 1 deletion packages/ui/src/components/choice-bar/choice-bar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ import {
* option is not chosen; a count's glyph says its state in words; the
* app's `data-` attributes reach the bar; and a bar folding together folds
* every option once their names no longer fit, and unfolds once they would;
* and an option that says more hangs the app's hint saying it.
* an option that says more hangs the app's hint saying it; and the thumb
* stands under the chosen option, slides to one chosen next, and follows it
* where an option before it goes.
*/

declare global {
Expand Down Expand Up @@ -437,3 +439,50 @@ test("an option that says more hangs the app's hint saying it, and one that says
assert.equal(option(page, "Conversation").closest("[data-hint]"), null);
assert.ok(heard.every((label) => label === "+60 −7 total"));
});

test("the thumb stands under the chosen option, slides to one chosen next, and follows it where an option before it goes", () => {
// Note that jsdom lays nothing out and has no ResizeObserver, so each option's box is the test's, 100px wide in a row, and only a draw places the thumb.
const optionsOf = (element: HTMLElement) => [
...(element.parentElement?.querySelectorAll(".luke-choice-bar-option") ?? []),
];
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(() => 100);
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(() => 28);
vi.spyOn(HTMLElement.prototype, "offsetTop", "get").mockImplementation(() => 0);
vi.spyOn(HTMLElement.prototype, "offsetLeft", "get").mockImplementation(function (
this: HTMLElement,
) {
return optionsOf(this).indexOf(this) * 100;
});
const container = document.body.appendChild(document.createElement("div"));
const root = createRoot(container);
roots.push(root);
const draw = (items: readonly ChoiceItem<View>[], value: View) =>
act(() =>
root.render(
createElement(ChoiceBar<View>, {
label: "Pull request",
items,
value,
onChange: () => undefined,
look: CHOICE_BAR_LOOK.SEGMENTED,
}),
),
);
const thumbAt = () =>
container
.querySelector<HTMLElement>(".luke-choice-bar")
?.style.getPropertyValue("--luke-choice-bar-thumb-x");
try {
draw(OPEN_ITEMS, VIEW.CHECKS);
assert.equal(thumbAt(), "100px", "the thumb stands under the chosen option");
draw(OPEN_ITEMS, VIEW.FILES);
assert.equal(thumbAt(), "200px", "it slides to the option chosen next");
draw(
OPEN_ITEMS.filter((item) => item.value !== VIEW.CONVERSATION),
VIEW.FILES,
);
assert.equal(thumbAt(), "100px", "an option before it gone, it follows the chosen one");
} finally {
vi.restoreAllMocks();
}
});
Loading
Loading