Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85feb433ae | ||
|
|
a11933b7bf | ||
|
|
fa40eb7378 |
@@ -932,6 +932,298 @@ describe("structured connection: reconnection/error/loading", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("structured connection: transcript scrolling", () => {
|
||||||
|
let frames: Map<number, FrameRequestCallback>;
|
||||||
|
let nextFrame: number;
|
||||||
|
let resizeCallback: ResizeObserverCallback;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
frames = new Map();
|
||||||
|
nextFrame = 1;
|
||||||
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||||
|
const id = nextFrame++;
|
||||||
|
frames.set(id, cb);
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
|
||||||
|
frames.delete(id);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("ResizeObserver", class {
|
||||||
|
constructor(callback: ResizeObserverCallback) {
|
||||||
|
resizeCallback = callback;
|
||||||
|
}
|
||||||
|
observe(): void {}
|
||||||
|
unobserve(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function metrics(conn: ReturnType<typeof mount>, height: number, clientHeight: number, top = 0) {
|
||||||
|
const scroll = conn.el.querySelector<HTMLElement>(".structured-scroll")!;
|
||||||
|
const values = { height, clientHeight, top };
|
||||||
|
Object.defineProperties(scroll, {
|
||||||
|
scrollHeight: { configurable: true, get: () => values.height },
|
||||||
|
clientHeight: { configurable: true, get: () => values.clientHeight },
|
||||||
|
scrollTop: {
|
||||||
|
configurable: true,
|
||||||
|
get: () => values.top,
|
||||||
|
set: (value: number) => { values.top = value; },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { scroll, values };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFrames(): void {
|
||||||
|
const pending = [...frames.entries()];
|
||||||
|
frames.clear();
|
||||||
|
for (const [, cb] of pending) cb(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeBody(width: number): void {
|
||||||
|
resizeCallback(
|
||||||
|
[{ contentRect: { width } } as ResizeObserverEntry],
|
||||||
|
{} as ResizeObserver,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shows the bottom after the initial transcript snapshot is laid out", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { values } = metrics(conn, 900, 300, 40);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 1,
|
||||||
|
rows: [
|
||||||
|
row("agent:1", "agent_text", { content: { type: "text", text: "latest" } }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(values.top).toBe(40);
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(600);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps pinned appends and patches at the bottom after layout", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { values } = metrics(conn, 600, 200);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
values.height = 720;
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "append",
|
||||||
|
row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }),
|
||||||
|
},
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(520);
|
||||||
|
|
||||||
|
values.height = 780;
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "patch",
|
||||||
|
row: row("agent:1", "agent_text", {
|
||||||
|
content: { type: "text", text: "a longer completion" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(580);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ position: "pinned", top: 800, expected: 880, manuallyScrolled: false },
|
||||||
|
{ position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true },
|
||||||
|
])("preserves the $position position when the composer grows", ({ top, expected, manuallyScrolled }) => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { scroll, values } = metrics(conn, 1000, 200);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
values.top = top;
|
||||||
|
if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll"));
|
||||||
|
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||||
|
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
|
||||||
|
input.value = "one\ntwo\nthree";
|
||||||
|
input.dispatchEvent(new Event("input"));
|
||||||
|
values.height = 1080;
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
expect(input.style.height).toBe("120px");
|
||||||
|
expect(values.top).toBe(expected);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ position: "pinned", top: 800, expected: 850, manuallyScrolled: false },
|
||||||
|
{ position: "manually scrolled", top: 180, expected: 180, manuallyScrolled: true },
|
||||||
|
])("preserves the $position position across the narrow breakpoint", ({ top, expected, manuallyScrolled }) => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { scroll, values } = metrics(conn, 1000, 200);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
values.top = top;
|
||||||
|
if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll"));
|
||||||
|
resizeBody(500);
|
||||||
|
values.clientHeight = 150;
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
expect(conn.el.querySelector(".structured-body")!.classList).toContain("st-narrow");
|
||||||
|
expect(values.top).toBe(expected);
|
||||||
|
resizeBody(500);
|
||||||
|
expect(frames.size).toBe(0);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a user-scrolled position across updates, approvals, and tool expansion", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { scroll, values } = metrics(conn, 1000, 200);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 1, rows: [toolRow("tool:1", "output")] } });
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
values.top = 180;
|
||||||
|
scroll.dispatchEvent(new Event("scroll"));
|
||||||
|
values.height = 1120;
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: { type: "append", row: row("agent:2", "agent_text", { content: { type: "text", text: "new" } }) },
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(180);
|
||||||
|
|
||||||
|
values.height = 1180;
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "append",
|
||||||
|
row: row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions }),
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
...idleState,
|
||||||
|
pending_approval: {
|
||||||
|
nonce: "n1",
|
||||||
|
request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(180);
|
||||||
|
|
||||||
|
conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!.click();
|
||||||
|
values.height = 1260;
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(180);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes following when the user scrolls back near the bottom", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { scroll, values } = metrics(conn, 1000, 200);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
values.top = 750;
|
||||||
|
scroll.dispatchEvent(new Event("scroll"));
|
||||||
|
values.height = 1100;
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "append",
|
||||||
|
row: row("agent:1", "agent_text", { content: { type: "text", text: "follow" } }),
|
||||||
|
},
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(900);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a user-scrolled position across a reconnect snapshot", () => {
|
||||||
|
const first = new FakeSocket();
|
||||||
|
const second = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
||||||
|
const { scroll, values } = metrics(conn, 1000, 200);
|
||||||
|
first.open();
|
||||||
|
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
|
runFrames();
|
||||||
|
|
||||||
|
values.top = 160;
|
||||||
|
scroll.dispatchEvent(new Event("scroll"));
|
||||||
|
first.drop();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
second.open();
|
||||||
|
values.height = 1200;
|
||||||
|
second.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 1,
|
||||||
|
rows: [
|
||||||
|
row("agent:1", "agent_text", {
|
||||||
|
content: { type: "text", text: "reconnected" },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(160);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a pending transcript frame when disposed or parked", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
const { values } = metrics(conn, 900, 300, 25);
|
||||||
|
ws.open();
|
||||||
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||||
|
expect(frames.size).toBe(1);
|
||||||
|
|
||||||
|
conn.onUnmount();
|
||||||
|
expect(frames.size).toBe(0);
|
||||||
|
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||||
|
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
|
||||||
|
input.dispatchEvent(new Event("input"));
|
||||||
|
resizeBody(500);
|
||||||
|
expect(frames.size).toBe(0);
|
||||||
|
conn.onMount({ focus: false });
|
||||||
|
expect(frames.size).toBe(2);
|
||||||
|
conn.dispose();
|
||||||
|
expect(frames.size).toBe(0);
|
||||||
|
resizeBody(700);
|
||||||
|
expect(conn.el.querySelector(".structured-body")!.classList).toContain("st-narrow");
|
||||||
|
runFrames();
|
||||||
|
expect(values.top).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("structured connection: pane lifecycle", () => {
|
describe("structured connection: pane lifecycle", () => {
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
|||||||
+135
-37
@@ -80,6 +80,7 @@ export function createStructuredConnection(
|
|||||||
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
|
||||||
const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement;
|
const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement;
|
||||||
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
|
||||||
|
const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement;
|
||||||
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
|
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
|
||||||
const railEl = el.querySelector(".structured-rail") as HTMLElement;
|
const railEl = el.querySelector(".structured-rail") as HTMLElement;
|
||||||
const bodyEl = el.querySelector(".structured-body") as HTMLElement;
|
const bodyEl = el.querySelector(".structured-body") as HTMLElement;
|
||||||
@@ -117,6 +118,65 @@ export function createStructuredConnection(
|
|||||||
let stateSocketGeneration = 0;
|
let stateSocketGeneration = 0;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
|
// Within 64px, wheel and touch imprecision should still count as following.
|
||||||
|
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
|
||||||
|
let followingBottom = true;
|
||||||
|
let receivedSnapshot = false;
|
||||||
|
let scrollWorkActive = true;
|
||||||
|
let parkedScrollTop: number | null = null;
|
||||||
|
let scrollFrame: number | null = null;
|
||||||
|
let pendingScroll: { top: number; follow: boolean } | null = null;
|
||||||
|
|
||||||
|
function cancelScrollFrame(): void {
|
||||||
|
if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame);
|
||||||
|
scrollFrame = null;
|
||||||
|
pendingScroll = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginTranscriptLayout(forceBottom = false): void {
|
||||||
|
if (!scrollWorkActive || disposed) return;
|
||||||
|
if (pendingScroll) {
|
||||||
|
if (forceBottom) pendingScroll.follow = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingScroll = {
|
||||||
|
top: scrollEl.scrollTop,
|
||||||
|
follow: forceBottom || followingBottom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishTranscriptLayout(): void {
|
||||||
|
if (!scrollWorkActive || disposed || !pendingScroll || scrollFrame !== null) return;
|
||||||
|
scrollFrame = window.requestAnimationFrame(() => {
|
||||||
|
scrollFrame = null;
|
||||||
|
const pending = pendingScroll;
|
||||||
|
pendingScroll = null;
|
||||||
|
if (!pending || disposed || !scrollWorkActive) return;
|
||||||
|
scrollEl.scrollTop = pending.follow
|
||||||
|
? Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight)
|
||||||
|
: pending.top;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTranscriptLayout(update: () => void, forceBottom = false): void {
|
||||||
|
beginTranscriptLayout(forceBottom);
|
||||||
|
update();
|
||||||
|
finishTranscriptLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTranscriptScroll(): void {
|
||||||
|
const distance = scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop;
|
||||||
|
followingBottom = distance <= FOLLOW_BOTTOM_THRESHOLD_PX;
|
||||||
|
cancelScrollFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTranscriptClick(event: Event): void {
|
||||||
|
if ((event.target as Element | null)?.closest(".st-tool-head")) beginTranscriptLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollEl.addEventListener("scroll", onTranscriptScroll);
|
||||||
|
scrollEl.addEventListener("click", onTranscriptClick, true);
|
||||||
|
|
||||||
function lastRowIdOfKind(kind: TranscriptRowKind): string | null {
|
function lastRowIdOfKind(kind: TranscriptRowKind): string | null {
|
||||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||||
return null;
|
return null;
|
||||||
@@ -160,6 +220,7 @@ export function createStructuredConnection(
|
|||||||
(expanded) => {
|
(expanded) => {
|
||||||
if (expanded) expandedRowIds.add(row.id);
|
if (expanded) expandedRowIds.add(row.id);
|
||||||
else expandedRowIds.delete(row.id);
|
else expandedRowIds.delete(row.id);
|
||||||
|
finishTranscriptLayout();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!el) return null;
|
if (!el) return null;
|
||||||
@@ -207,44 +268,50 @@ export function createStructuredConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
||||||
rows = [...snapshot.rows];
|
const forceBottom = !receivedSnapshot;
|
||||||
const expandableIds = new Set(
|
updateTranscriptLayout(() => {
|
||||||
rows.filter((row) => row.kind === "tool_call" || row.kind === "tool_diff").map((row) => row.id),
|
rows = [...snapshot.rows];
|
||||||
);
|
const expandableIds = new Set(
|
||||||
for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id);
|
rows.filter((row) => row.kind === "tool_call" || row.kind === "tool_diff").map((row) => row.id),
|
||||||
rowIndex.clear();
|
);
|
||||||
rows.forEach((row, i) => rowIndex.set(row.id, i));
|
for (const id of expandedRowIds) if (!expandableIds.has(id)) expandedRowIds.delete(id);
|
||||||
rowEls.clear();
|
rowIndex.clear();
|
||||||
rowsEl.replaceChildren();
|
rows.forEach((row, i) => rowIndex.set(row.id, i));
|
||||||
for (const row of rows) mountRow(row);
|
rowEls.clear();
|
||||||
|
rowsEl.replaceChildren();
|
||||||
|
for (const row of rows) mountRow(row);
|
||||||
|
}, forceBottom);
|
||||||
|
receivedSnapshot = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDelta(delta: TranscriptDelta | null | undefined): void {
|
function applyDelta(delta: TranscriptDelta | null | undefined): void {
|
||||||
if (!delta) return;
|
if (!delta) return;
|
||||||
if (delta.type === "append") {
|
updateTranscriptLayout(() => {
|
||||||
rowIndex.set(delta.row.id, rows.length);
|
if (delta.type === "append") {
|
||||||
rows.push(delta.row);
|
|
||||||
mountRow(delta.row);
|
|
||||||
} else if (delta.type === "patch") {
|
|
||||||
const i = rowIndex.get(delta.row.id);
|
|
||||||
if (i === undefined) {
|
|
||||||
rowIndex.set(delta.row.id, rows.length);
|
rowIndex.set(delta.row.id, rows.length);
|
||||||
rows.push(delta.row);
|
rows.push(delta.row);
|
||||||
mountRow(delta.row);
|
mountRow(delta.row);
|
||||||
|
} else if (delta.type === "patch") {
|
||||||
|
const i = rowIndex.get(delta.row.id);
|
||||||
|
if (i === undefined) {
|
||||||
|
rowIndex.set(delta.row.id, rows.length);
|
||||||
|
rows.push(delta.row);
|
||||||
|
mountRow(delta.row);
|
||||||
|
} else {
|
||||||
|
rows[i] = delta.row;
|
||||||
|
remountRow(delta.row);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
rows[i] = delta.row;
|
const i = rowIndex.get(delta.id);
|
||||||
remountRow(delta.row);
|
if (i === undefined) return;
|
||||||
|
rows.splice(i, 1);
|
||||||
|
expandedRowIds.delete(delta.id);
|
||||||
|
rowIndex.clear();
|
||||||
|
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
|
||||||
|
rowEls.get(delta.id)?.remove();
|
||||||
|
rowEls.delete(delta.id);
|
||||||
}
|
}
|
||||||
} else {
|
});
|
||||||
const i = rowIndex.get(delta.id);
|
|
||||||
if (i === undefined) return;
|
|
||||||
rows.splice(i, 1);
|
|
||||||
expandedRowIds.delete(delta.id);
|
|
||||||
rowIndex.clear();
|
|
||||||
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
|
|
||||||
rowEls.get(delta.id)?.remove();
|
|
||||||
rowEls.delete(delta.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolRowCount(): number {
|
function toolRowCount(): number {
|
||||||
@@ -293,10 +360,12 @@ export function createStructuredConnection(
|
|||||||
reconcileAwaitingBegin();
|
reconcileAwaitingBegin();
|
||||||
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
||||||
if (newPendingId !== pendingApprovalRowId) {
|
if (newPendingId !== pendingApprovalRowId) {
|
||||||
const prev = pendingApprovalRowId;
|
updateTranscriptLayout(() => {
|
||||||
pendingApprovalRowId = newPendingId;
|
const prev = pendingApprovalRowId;
|
||||||
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]);
|
pendingApprovalRowId = newPendingId;
|
||||||
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
|
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]);
|
||||||
|
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
renderPills();
|
renderPills();
|
||||||
renderRailNow();
|
renderRailNow();
|
||||||
@@ -320,8 +389,10 @@ export function createStructuredConnection(
|
|||||||
|
|
||||||
// ---- composer ----
|
// ---- composer ----
|
||||||
function autoResize(): void {
|
function autoResize(): void {
|
||||||
input.style.height = "auto";
|
updateTranscriptLayout(() => {
|
||||||
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
input.style.height = "auto";
|
||||||
|
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
input.addEventListener("input", autoResize);
|
input.addEventListener("input", autoResize);
|
||||||
input.addEventListener("keydown", (e) => {
|
input.addEventListener("keydown", (e) => {
|
||||||
@@ -377,8 +448,11 @@ export function createStructuredConnection(
|
|||||||
// ---- narrow-pane rail collapse (a split pane, not just the viewport) ----
|
// ---- narrow-pane rail collapse (a split pane, not just the viewport) ----
|
||||||
const NARROW_BREAKPOINT = 560;
|
const NARROW_BREAKPOINT = 560;
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
if (disposed) return;
|
||||||
const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth;
|
const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth;
|
||||||
bodyEl.classList.toggle("st-narrow", width < NARROW_BREAKPOINT);
|
const narrow = width < NARROW_BREAKPOINT;
|
||||||
|
if (bodyEl.classList.contains("st-narrow") === narrow) return;
|
||||||
|
updateTranscriptLayout(() => bodyEl.classList.toggle("st-narrow", narrow));
|
||||||
});
|
});
|
||||||
resizeObserver.observe(bodyEl);
|
resizeObserver.observe(bodyEl);
|
||||||
|
|
||||||
@@ -493,6 +567,12 @@ export function createStructuredConnection(
|
|||||||
// `mountToken` is bumped on every mount/unmount so a stale frame can tell
|
// `mountToken` is bumped on every mount/unmount so a stale frame can tell
|
||||||
// it is no longer the most recent one.
|
// it is no longer the most recent one.
|
||||||
let mountToken = 0;
|
let mountToken = 0;
|
||||||
|
let focusFrame: number | null = null;
|
||||||
|
|
||||||
|
function cancelFocusFrame(): void {
|
||||||
|
if (focusFrame !== null) window.cancelAnimationFrame(focusFrame);
|
||||||
|
focusFrame = null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -500,13 +580,26 @@ export function createStructuredConnection(
|
|||||||
onMount(mountOpts?: { focus?: boolean }) {
|
onMount(mountOpts?: { focus?: boolean }) {
|
||||||
const focusOnMount = mountOpts?.focus !== false;
|
const focusOnMount = mountOpts?.focus !== false;
|
||||||
const token = ++mountToken;
|
const token = ++mountToken;
|
||||||
requestAnimationFrame(() => {
|
scrollWorkActive = true;
|
||||||
|
pendingScroll = {
|
||||||
|
top: parkedScrollTop ?? scrollEl.scrollTop,
|
||||||
|
follow: followingBottom,
|
||||||
|
};
|
||||||
|
parkedScrollTop = null;
|
||||||
|
finishTranscriptLayout();
|
||||||
|
cancelFocusFrame();
|
||||||
|
focusFrame = window.requestAnimationFrame(() => {
|
||||||
|
focusFrame = null;
|
||||||
if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled
|
if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled
|
||||||
if (focusOnMount && !input.disabled) input.focus();
|
if (focusOnMount && !input.disabled) input.focus();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onUnmount() {
|
onUnmount() {
|
||||||
mountToken++;
|
mountToken++;
|
||||||
|
parkedScrollTop = pendingScroll?.top ?? scrollEl.scrollTop;
|
||||||
|
scrollWorkActive = false;
|
||||||
|
cancelScrollFrame();
|
||||||
|
cancelFocusFrame();
|
||||||
},
|
},
|
||||||
focusTerminal() {
|
focusTerminal() {
|
||||||
if (!input.disabled) input.focus();
|
if (!input.disabled) input.focus();
|
||||||
@@ -519,11 +612,16 @@ export function createStructuredConnection(
|
|||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
mountToken++;
|
mountToken++;
|
||||||
|
scrollWorkActive = false;
|
||||||
|
cancelScrollFrame();
|
||||||
|
cancelFocusFrame();
|
||||||
cancelReconnect();
|
cancelReconnect();
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
unsubscribeStore?.();
|
unsubscribeStore?.();
|
||||||
closeCurrentSocket();
|
closeCurrentSocket();
|
||||||
expandedRowIds.clear();
|
expandedRowIds.clear();
|
||||||
|
scrollEl.removeEventListener("scroll", onTranscriptScroll);
|
||||||
|
scrollEl.removeEventListener("click", onTranscriptClick, true);
|
||||||
el.remove();
|
el.remove();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user