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
6 changes: 6 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# unplugin-icons loads the JSX compiler via local-pkg's
# `import('@svgr/core')`. Node ESM resolves that bare specifier from
# local-pkg's own file (under node_modules/.pnpm/...), not from the
# workspace package that lists the dependency. Hoist the SVGR packages
# to the workspace root so that walk-up resolution succeeds.
public-hoist-pattern[]=*@svgr/*
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ WORKDIR /src
RUN npm install -g pnpm@10.32.1

COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY crates/virtual-core/package.json crates/virtual-core/package.json
COPY crates/agent-ui/package.json crates/agent-ui/package.json
COPY crates/agent-gui/package.json crates/agent-gui/package.json
COPY crates/agent-gateway/web/package.json crates/agent-gateway/web/package.json
RUN pnpm install --frozen-lockfile --filter @liveagent/gateway-webui...

# The vendored @tanstack/virtual-core ships TypeScript source (exports point
# at src/ and types/), so the build stage needs the whole package.
COPY crates/virtual-core crates/virtual-core
COPY crates/agent-ui crates/agent-ui
COPY crates/agent-gateway/web crates/agent-gateway/web
RUN pnpm --filter @liveagent/gateway-webui build
Expand Down
31 changes: 27 additions & 4 deletions crates/agent-gateway/web/src/components/GatewayTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,24 @@ export type GatewayTranscriptNavHandle = TranscriptNavigationHandle;
const TRANSCRIPT_ROW_ESTIMATED_HEIGHT = 260;
const TRANSCRIPT_ROW_GAP = 18;

// Bump when the transcript row model or its measurement semantics change:
// persisted snapshots outlive releases, and stale heights keyed only by
// widths would seed wrong layouts (and scroll-compensation churn) after an
// upgrade. Mirrors the GUI's versioned key.
const TRANSCRIPT_MEASUREMENT_LAYOUT_VERSION = "gateway-rows-v1";

function buildVersionedTranscriptLayoutKey(viewportWidth: number, contentWidth: number) {
const layoutKey = buildTranscriptLayoutKey(viewportWidth, contentWidth);
return layoutKey ? `${layoutKey}:${TRANSCRIPT_MEASUREMENT_LAYOUT_VERSION}` : "";
}

// Measured row heights survive conversation switches: saved on unmount,
// restored (width-gated) on the next open so the switch lays out with exact
// heights instead of estimates.
const transcriptMeasurementsLru = createTranscriptMeasurementsLru();
// heights instead of estimates. Persisted so revisited conversations skip
// the estimate→measure correction churn across page reloads too.
const transcriptMeasurementsLru = createTranscriptMeasurementsLru({
persistNamespace: "webui-transcript",
});

type GatewayTranscriptVirtualItem =
| { key: string; kind: "loadRemoteHistory" }
Expand Down Expand Up @@ -652,7 +666,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
(conversationId && scrollViewport
? transcriptMeasurementsLru.restore(
conversationId,
buildTranscriptLayoutKey(scrollViewport.clientWidth, contentWidth),
buildVersionedTranscriptLayoutKey(scrollViewport.clientWidth, contentWidth),
)
: null) ?? [],
);
Expand All @@ -673,6 +687,15 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
// virtualizer's bottom correction and leaves live growth to useScrollFollow.
anchorTo: viewportFollowing ? "start" : "end",
scrollEndThreshold: 8,
// Above-viewport estimate corrections and history-page prepends are
// absorbed into the layout origin instead of written to scrollTop, so
// no programmatic scroll can race the user's wheel gesture; the debt
// settles with one verified write when scrolling is idle.
scrollAnchoring: "origin",
// Compositors paint scrolls ahead of the main thread; keep roughly half
// a viewport of pre-rendered rows toward the scroll direction so fast
// wheel ticks reveal content instead of blank space.
directionalOverscanPx: 480,
initialMeasurementsCache,
rangeExtractor: extractTranscriptRange,
});
Expand Down Expand Up @@ -784,7 +807,7 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
if (!conversationId || !scrollViewport) return;
transcriptMeasurementsLru.save(
conversationId,
buildTranscriptLayoutKey(scrollViewport.clientWidth, contentWidth),
buildVersionedTranscriptLayoutKey(scrollViewport.clientWidth, contentWidth),
transcriptVirtualizer.takeSnapshot(),
);
};
Expand Down
2 changes: 1 addition & 1 deletion crates/agent-gateway/web/test/measurements-lru.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ test("empty snapshots, blank ids, and blank layout keys are not stored", () => {
});

test("capacity evicts the least recently used entry", () => {
const lru = createTranscriptMeasurementsLru(2);
const lru = createTranscriptMeasurementsLru({ capacity: 2 });
lru.save("conv-1", layoutKey(800, 768), [item("a", 1)]);
lru.save("conv-2", layoutKey(800, 768), [item("b", 2)]);
// Touch conv-1 so conv-2 becomes the eviction candidate.
Expand Down
18 changes: 16 additions & 2 deletions crates/agent-gui/src/pages/chat/transcript/TranscriptList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,11 @@ function buildVersionedTranscriptLayoutKey(viewportWidth: number, contentWidth:

// Measured row heights survive conversation switches: saved on unmount,
// restored (width-gated) on the next open so the switch lays out with exact
// heights instead of estimates.
const transcriptMeasurementsLru = createTranscriptMeasurementsLru();
// heights instead of estimates. Persisted so revisited conversations skip
// the estimate→measure correction churn across app restarts too.
const transcriptMeasurementsLru = createTranscriptMeasurementsLru({
persistNamespace: "gui-transcript",
});

const SummaryCard = memo(function SummaryCard(props: { item: RenderSummaryCard }) {
const { item } = props;
Expand Down Expand Up @@ -223,6 +226,17 @@ export const TranscriptList = memo(function TranscriptList(props: TranscriptList
// virtualizer's bottom correction and leaves live growth to useScrollFollow.
anchorTo: viewportFollowing ? "start" : "end",
scrollEndThreshold: 8,
// Above-viewport estimate corrections are absorbed into the layout
// origin instead of written to scrollTop: on WKWebView the compositor
// owns the viewport during a wheel gesture and can silently swallow
// programmatic scrolls, leaving the virtualizer rendering a window the
// viewport never reached (a blank band until the next scroll). The debt
// settles with one verified write when scrolling is idle.
scrollAnchoring: "origin",
// WKWebView paints compositor scrolls ahead of the main thread; keep
// roughly a half viewport of pre-rendered rows toward the scroll
// direction so fast wheel ticks reveal content instead of blank space.
directionalOverscanPx: 480,
rangeExtractor: extractVirtualRange,
});

Expand Down
100 changes: 99 additions & 1 deletion crates/agent-gui/test/chat/measurements-lru.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ test("empty snapshots, blank ids, and blank layout keys are not stored", () => {
});

test("capacity evicts the least recently used entry", () => {
const lru = createTranscriptMeasurementsLru(2);
const lru = createTranscriptMeasurementsLru({ capacity: 2 });
lru.save("conv-1", layoutKey(800, 768), [item("a", 1)]);
lru.save("conv-2", layoutKey(800, 768), [item("b", 2)]);
// Touch conv-1 so conv-2 becomes the eviction candidate.
Expand All @@ -155,3 +155,101 @@ test("re-saving a conversation replaces its snapshot", () => {
assert.equal(lru.restore("conv-1", layoutKey(800, 768)), null);
assert.equal(lru.restore("conv-1", layoutKey(820, 960)), next);
});

function withFakeLocalStorage(run) {
const store = new Map();
const previous = globalThis.localStorage;
globalThis.localStorage = {
getItem: (key) => (store.has(key) ? store.get(key) : null),
setItem: (key, value) => {
store.set(key, String(value));
},
removeItem: (key) => {
store.delete(key);
},
};
try {
run(store);
} finally {
if (previous === undefined) {
delete globalThis.localStorage;
} else {
globalThis.localStorage = previous;
}
}
}

test("persisted snapshots round-trip across LRU instances (app restarts)", () => {
withFakeLocalStorage(() => {
const first = createTranscriptMeasurementsLru({ persistNamespace: "test" });
first.save("conv-1", layoutKey(800, 768), [item("a", 120), item("b", 300)]);

const second = createTranscriptMeasurementsLru({ persistNamespace: "test" });
const restored = second.restore("conv-1", layoutKey(800, 768));
assert.equal(restored.length, 2);
assert.equal(restored[0].key, "a");
assert.equal(restored[0].size, 120);
// Layout gating still applies to persisted entries.
assert.equal(second.restore("conv-1", layoutKey(900, 768)), null);
});
});

test("persisted payload stores compact [key, size] rows, not full items", () => {
withFakeLocalStorage((store) => {
const lru = createTranscriptMeasurementsLru({ persistNamespace: "test" });
lru.save("conv-1", layoutKey(800, 768), [item("a", 120)]);
const [raw] = [...store.values()];
const parsed = JSON.parse(raw);
assert.deepEqual(parsed.entries[0][1].rows, [["a", 120]]);
});
});

test("malformed persisted payloads degrade to an empty cache", () => {
withFakeLocalStorage((store) => {
const probe = createTranscriptMeasurementsLru({ persistNamespace: "test" });
probe.save("conv-1", layoutKey(800, 768), [item("a", 120)]);
const [persistKey] = [...store.keys()];
store.set(persistKey, "{not json");

const lru = createTranscriptMeasurementsLru({ persistNamespace: "test" });
assert.equal(lru.restore("conv-1", layoutKey(800, 768)), null);
// The cache still works (memory-only) after the failed read.
lru.save("conv-2", layoutKey(800, 768), [item("b", 60)]);
assert.ok(lru.restore("conv-2", layoutKey(800, 768)));
});
});

test("storage write failures degrade to memory-only", () => {
withFakeLocalStorage(() => {
globalThis.localStorage.setItem = () => {
throw new Error("quota exceeded");
};
const lru = createTranscriptMeasurementsLru({ persistNamespace: "test" });
const measurements = [item("a", 120)];
lru.save("conv-1", layoutKey(800, 768), measurements);
assert.equal(lru.restore("conv-1", layoutKey(800, 768)), measurements);
});
});

test("oversized snapshots skip persistence and prune their stale persisted copy", () => {
withFakeLocalStorage(() => {
const first = createTranscriptMeasurementsLru({ persistNamespace: "test" });
first.save("conv-1", layoutKey(800, 768), [item("a", 120)]);
first.save("conv-2", layoutKey(800, 768), [item("b", 60)]);

// conv-1 grows past the per-entry cap: memory keeps serving it, but the
// persisted copy must not stay frozen at the old (now stale) snapshot.
const oversized = Array.from({ length: 5001 }, (_, i) => item(`row-${i}`, 40));
first.save("conv-1", layoutKey(800, 768), oversized);
assert.equal(first.restore("conv-1", layoutKey(800, 768)), oversized);

const second = createTranscriptMeasurementsLru({ persistNamespace: "test" });
assert.equal(
second.restore("conv-1", layoutKey(800, 768)),
null,
"a restart must not resurrect the pre-growth snapshot",
);
// Small entries in the same namespace survive the oversized save.
assert.equal(second.restore("conv-2", layoutKey(800, 768)).length, 1);
});
});
Loading
Loading