Archived
perf: speed up chat loading and resume
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BrowserResumeController } from "./browserResumeController";
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolveDeferred: ((value: T) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve) => { resolveDeferred = resolve; });
|
||||
if (resolveDeferred === undefined) throw new Error("Deferred promise was not initialized");
|
||||
return { promise, resolve: resolveDeferred };
|
||||
}
|
||||
|
||||
function frameHarness() {
|
||||
const frames: { callback: () => void; canceled: boolean }[] = [];
|
||||
return {
|
||||
scheduleFrame: (callback: () => void) => {
|
||||
const frame = { callback, canceled: false };
|
||||
frames.push(frame);
|
||||
return { cancel: () => { frame.canceled = true; } };
|
||||
},
|
||||
pendingCount: () => frames.filter((frame) => !frame.canceled).length,
|
||||
runNext: () => {
|
||||
const frame = frames.shift();
|
||||
if (frame === undefined) throw new Error("No scheduled frame");
|
||||
if (!frame.canceled) frame.callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("BrowserResumeController", () => {
|
||||
it("batches overlapping focus and visible signals into one app refresh", async () => {
|
||||
const windowTarget = new EventTarget();
|
||||
const documentTarget = new EventTarget();
|
||||
const frames = frameHarness();
|
||||
const refreshGate = deferred<undefined>();
|
||||
const refreshStarted = deferred<undefined>();
|
||||
const refreshCompleted = deferred<undefined>();
|
||||
const onResumeSignal = vi.fn();
|
||||
let visible = true;
|
||||
let refreshCalls = 0;
|
||||
const controller = new BrowserResumeController({
|
||||
onResumeSignal,
|
||||
refreshAfterResume: async () => {
|
||||
refreshCalls += 1;
|
||||
refreshStarted.resolve(undefined);
|
||||
await refreshGate.promise;
|
||||
refreshCompleted.resolve(undefined);
|
||||
},
|
||||
onRefreshError: (error) => { throw error; },
|
||||
}, {
|
||||
windowTarget,
|
||||
documentTarget,
|
||||
isDocumentVisible: () => visible,
|
||||
scheduleFrame: frames.scheduleFrame,
|
||||
});
|
||||
controller.connect();
|
||||
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
|
||||
expect(onResumeSignal).toHaveBeenCalledTimes(3);
|
||||
expect(frames.pendingCount()).toBe(1);
|
||||
expect(refreshCalls).toBe(0);
|
||||
|
||||
frames.runNext();
|
||||
await refreshStarted.promise;
|
||||
expect(refreshCalls).toBe(1);
|
||||
|
||||
visible = false;
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
expect(onResumeSignal).toHaveBeenCalledTimes(3);
|
||||
expect(frames.pendingCount()).toBe(0);
|
||||
|
||||
refreshGate.resolve(undefined);
|
||||
await refreshCompleted.promise;
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
expect(frames.pendingCount()).toBe(1);
|
||||
controller.disconnect();
|
||||
frames.runNext();
|
||||
await Promise.resolve();
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
expect(onResumeSignal).toHaveBeenCalledTimes(4);
|
||||
expect(refreshCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("runs one trailing refresh when another resume arrives during active work", async () => {
|
||||
const windowTarget = new EventTarget();
|
||||
const documentTarget = new EventTarget();
|
||||
const frames = frameHarness();
|
||||
const firstGate = deferred<undefined>();
|
||||
const secondGate = deferred<undefined>();
|
||||
const firstStarted = deferred<undefined>();
|
||||
const secondStarted = deferred<undefined>();
|
||||
const secondCompleted = deferred<undefined>();
|
||||
let refreshCalls = 0;
|
||||
const controller = new BrowserResumeController({
|
||||
onResumeSignal: () => undefined,
|
||||
refreshAfterResume: async () => {
|
||||
refreshCalls += 1;
|
||||
if (refreshCalls === 1) {
|
||||
firstStarted.resolve(undefined);
|
||||
await firstGate.promise;
|
||||
return;
|
||||
}
|
||||
secondStarted.resolve(undefined);
|
||||
await secondGate.promise;
|
||||
secondCompleted.resolve(undefined);
|
||||
},
|
||||
onRefreshError: (error) => { throw error; },
|
||||
}, {
|
||||
windowTarget,
|
||||
documentTarget,
|
||||
isDocumentVisible: () => true,
|
||||
scheduleFrame: frames.scheduleFrame,
|
||||
});
|
||||
controller.connect();
|
||||
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
frames.runNext();
|
||||
await firstStarted.promise;
|
||||
|
||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
||||
windowTarget.dispatchEvent(new Event("focus"));
|
||||
expect(frames.pendingCount()).toBe(1);
|
||||
frames.runNext();
|
||||
expect(refreshCalls).toBe(1);
|
||||
|
||||
firstGate.resolve(undefined);
|
||||
await secondStarted.promise;
|
||||
expect(refreshCalls).toBe(2);
|
||||
|
||||
secondGate.resolve(undefined);
|
||||
await secondCompleted.promise;
|
||||
controller.disconnect();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TrailingRefreshCoordinator } from "../controllers/trailingRefreshCoordinator";
|
||||
|
||||
interface BrowserEventTarget {
|
||||
addEventListener(type: string, listener: EventListener): void;
|
||||
removeEventListener(type: string, listener: EventListener): void;
|
||||
}
|
||||
|
||||
interface ScheduledFrame {
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export interface BrowserResumeCallbacks {
|
||||
onResumeSignal(): void;
|
||||
refreshAfterResume(): void | Promise<void>;
|
||||
onRefreshError(error: unknown): void;
|
||||
}
|
||||
|
||||
export interface BrowserResumeControllerOptions {
|
||||
windowTarget?: BrowserEventTarget | undefined;
|
||||
documentTarget?: BrowserEventTarget | undefined;
|
||||
isDocumentVisible?: (() => boolean) | undefined;
|
||||
scheduleFrame?: ((callback: () => void) => ScheduledFrame) | undefined;
|
||||
}
|
||||
|
||||
/** Owns browser resume listeners and batches focus/visibility refreshes per frame. */
|
||||
export class BrowserResumeController {
|
||||
private readonly windowTarget: BrowserEventTarget | undefined;
|
||||
private readonly documentTarget: BrowserEventTarget | undefined;
|
||||
private readonly isDocumentVisible: () => boolean;
|
||||
private readonly scheduleFrame: (callback: () => void) => ScheduledFrame;
|
||||
private readonly refreshes = new TrailingRefreshCoordinator<"browser-resume">();
|
||||
private scheduledRefresh: ScheduledFrame | undefined;
|
||||
private connected = false;
|
||||
|
||||
constructor(private readonly callbacks: BrowserResumeCallbacks, options: BrowserResumeControllerOptions = {}) {
|
||||
this.windowTarget = options.windowTarget ?? browserWindowTarget();
|
||||
this.documentTarget = options.documentTarget ?? browserDocumentTarget();
|
||||
this.isDocumentVisible = options.isDocumentVisible ?? documentIsVisible;
|
||||
this.scheduleFrame = options.scheduleFrame ?? scheduleBrowserFrame;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.connected) return;
|
||||
this.connected = true;
|
||||
this.windowTarget?.addEventListener("focus", this.onFocus);
|
||||
this.documentTarget?.addEventListener("visibilitychange", this.onVisibilityChange);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (!this.connected) return;
|
||||
this.connected = false;
|
||||
this.windowTarget?.removeEventListener("focus", this.onFocus);
|
||||
this.documentTarget?.removeEventListener("visibilitychange", this.onVisibilityChange);
|
||||
this.scheduledRefresh?.cancel();
|
||||
this.scheduledRefresh = undefined;
|
||||
}
|
||||
|
||||
private readonly onFocus: EventListener = () => {
|
||||
this.handleResumeSignal();
|
||||
};
|
||||
|
||||
private readonly onVisibilityChange: EventListener = () => {
|
||||
if (this.isDocumentVisible()) this.handleResumeSignal();
|
||||
};
|
||||
|
||||
private handleResumeSignal(): void {
|
||||
this.callbacks.onResumeSignal();
|
||||
if (this.scheduledRefresh !== undefined) return;
|
||||
this.scheduledRefresh = this.scheduleFrame(() => {
|
||||
this.scheduledRefresh = undefined;
|
||||
if (!this.connected) return;
|
||||
void this.refreshes.request("browser-resume", async () => {
|
||||
if (this.connected) await this.callbacks.refreshAfterResume();
|
||||
}).catch((error: unknown) => { this.callbacks.onRefreshError(error); });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function browserWindowTarget(): BrowserEventTarget | undefined {
|
||||
return typeof window === "undefined" ? undefined : window;
|
||||
}
|
||||
|
||||
function browserDocumentTarget(): BrowserEventTarget | undefined {
|
||||
return typeof document === "undefined" ? undefined : document;
|
||||
}
|
||||
|
||||
function documentIsVisible(): boolean {
|
||||
return typeof document === "undefined" || document.visibilityState === "visible";
|
||||
}
|
||||
|
||||
function scheduleBrowserFrame(callback: () => void): ScheduledFrame {
|
||||
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
||||
const frame = window.requestAnimationFrame(() => { callback(); });
|
||||
return { cancel: () => { window.cancelAnimationFrame(frame); } };
|
||||
}
|
||||
const timer = globalThis.setTimeout(callback, 0);
|
||||
return { cancel: () => { globalThis.clearTimeout(timer); } };
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView";
|
||||
import type { ChatLine } from "./shared";
|
||||
import { ChatView, chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView";
|
||||
|
||||
describe("chatQueuedMessageSections", () => {
|
||||
it("labels client-side pending-start sends separately from server queued messages", () => {
|
||||
@@ -35,3 +37,187 @@ describe("chatMessageMetadataLabel", () => {
|
||||
})).toBe(`${formattedTimestamp} · provider/model`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView technical-event groups", () => {
|
||||
const messages: ChatLine[] = [
|
||||
{ role: "assistant", parts: [{ type: "toolCall", toolName: "read", summary: "inspect a file" }] },
|
||||
{ role: "tool", parts: [{ type: "toolExecution", toolName: "read", summary: "inspect a file", status: "success", resultText: "large result" }] },
|
||||
];
|
||||
|
||||
it("defers a closed body while retaining native disclosure and group scroll anchors", () => {
|
||||
const view = new ChatView();
|
||||
view.sessionId = "session-1";
|
||||
const bodyCalls = observeGroupBodyRenders(view);
|
||||
|
||||
const closed = renderMessageGroup(view, messages, 40, 41, false);
|
||||
|
||||
expect(bodyCalls).toEqual([]);
|
||||
expect(templateStaticMarkup(closed)).toContain("<details");
|
||||
expect(templateStaticMarkup(closed)).toContain("<summary>");
|
||||
expect(templateStaticMarkup(closed)).toContain('aria-hidden="true"');
|
||||
expect(templateValuesAfterMarker(closed, "?open=")).toEqual([false]);
|
||||
expect(templateValuesAfterMarker(closed, "data-scroll-anchor-id=")).toEqual(["g:40"]);
|
||||
expect(templateValuesAfterMarker(closed, "data-marker-id=")).toEqual(["g:41"]);
|
||||
});
|
||||
|
||||
// Direct handler extraction keeps this node-environment test focused on the
|
||||
// native details toggle wiring without introducing a component-wide DOM shim.
|
||||
it("renders an opened body with event anchors and removes it when closed again", () => {
|
||||
const view = new ChatView();
|
||||
view.sessionId = "session-1";
|
||||
const bodyCalls = observeGroupBodyRenders(view);
|
||||
const initiallyClosed = renderMessageGroup(view, messages, 40, 41, false);
|
||||
|
||||
dispatchDetailsToggle(templateEventHandler(initiallyClosed, "@toggle="), true);
|
||||
const opened = renderMessageGroup(view, messages, 40, 41, false);
|
||||
|
||||
expect(bodyCalls).toEqual([{ messages, startIndex: 40 }]);
|
||||
expect(templateValuesAfterMarker(opened, "?open=")).toEqual([true]);
|
||||
expect(templateValuesAfterMarker(opened, "data-scroll-anchor-id=")).toEqual(["g:40", "e:40", "e:41"]);
|
||||
|
||||
bodyCalls.length = 0;
|
||||
dispatchDetailsToggle(templateEventHandler(opened, "@toggle="), false);
|
||||
const closedAgain = renderMessageGroup(view, messages, 40, 41, false);
|
||||
|
||||
expect(bodyCalls).toEqual([]);
|
||||
expect(templateValuesAfterMarker(closedAgain, "?open=")).toEqual([false]);
|
||||
expect(templateValuesAfterMarker(closedAgain, "data-scroll-anchor-id=")).toEqual(["g:40"]);
|
||||
});
|
||||
|
||||
it("renders a live tail body by default", () => {
|
||||
const view = new ChatView();
|
||||
view.sessionId = "session-1";
|
||||
const bodyCalls = observeGroupBodyRenders(view);
|
||||
|
||||
const live = renderMessageGroup(view, messages, 40, 41, true);
|
||||
|
||||
expect(bodyCalls).toEqual([{ messages, startIndex: 40 }]);
|
||||
expect(templateValuesAfterMarker(live, "?open=")).toEqual([true]);
|
||||
expect(templateValues(live)).toContain("msg event-group live");
|
||||
expect(templateValues(live)).toContain("live events");
|
||||
});
|
||||
});
|
||||
|
||||
interface GroupBodyRenderCall {
|
||||
messages: ChatLine[];
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult;
|
||||
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
|
||||
type TemplateEventHandler = (event: Event) => void;
|
||||
|
||||
function renderMessageGroup(view: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean): TemplateResult {
|
||||
const method: unknown = Reflect.get(view, "renderMessageGroup");
|
||||
if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable");
|
||||
return method.call(view, messages, startIndex, endIndex, defaultOpen);
|
||||
}
|
||||
|
||||
function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] {
|
||||
const method: unknown = Reflect.get(view, "renderMessageGroupBody");
|
||||
if (!isRenderMessageGroupBody(method)) throw new Error("ChatView.renderMessageGroupBody is not callable");
|
||||
const calls: GroupBodyRenderCall[] = [];
|
||||
const observed: RenderMessageGroupBody = function (messages, startIndex) {
|
||||
calls.push({ messages, startIndex });
|
||||
return method.call(this, messages, startIndex);
|
||||
};
|
||||
if (!Reflect.set(view, "renderMessageGroupBody", observed)) throw new Error("Could not observe ChatView.renderMessageGroupBody");
|
||||
return calls;
|
||||
}
|
||||
|
||||
function isRenderMessageGroup(value: unknown): value is RenderMessageGroup {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function isRenderMessageGroupBody(value: unknown): value is RenderMessageGroupBody {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler {
|
||||
const strings = templateStrings(template);
|
||||
const values = templateValues(template);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = values[index];
|
||||
if (strings[index]?.includes(marker) === true && isTemplateEventHandler(value)) return value;
|
||||
}
|
||||
throw new Error(`Expected template event handler after ${marker}`);
|
||||
}
|
||||
|
||||
function isTemplateEventHandler(value: unknown): value is TemplateEventHandler {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function dispatchDetailsToggle(handler: TemplateEventHandler, open: boolean): void {
|
||||
const hadDetailsElement = Reflect.has(globalThis, "HTMLDetailsElement");
|
||||
const previousDetailsElement = Reflect.get(globalThis, "HTMLDetailsElement");
|
||||
class StubDetailsElement extends EventTarget {
|
||||
constructor(readonly open: boolean) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
Reflect.set(globalThis, "HTMLDetailsElement", StubDetailsElement);
|
||||
try {
|
||||
const details = new StubDetailsElement(open);
|
||||
details.addEventListener("toggle", (event) => { handler(event); });
|
||||
details.dispatchEvent(new Event("toggle"));
|
||||
} finally {
|
||||
if (hadDetailsElement) Reflect.set(globalThis, "HTMLDetailsElement", previousDetailsElement);
|
||||
else Reflect.deleteProperty(globalThis, "HTMLDetailsElement");
|
||||
}
|
||||
}
|
||||
|
||||
function templateStaticMarkup(template: TemplateResult): string {
|
||||
const chunks: string[] = [];
|
||||
visit(template);
|
||||
return chunks.join("");
|
||||
|
||||
function visit(value: unknown): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!isTemplateResult(value)) return;
|
||||
chunks.push(...templateStrings(value));
|
||||
for (const child of templateValues(value)) visit(child);
|
||||
}
|
||||
}
|
||||
|
||||
function templateValuesAfterMarker(template: TemplateResult, marker: string): unknown[] {
|
||||
const matches: unknown[] = [];
|
||||
visit(template);
|
||||
return matches;
|
||||
|
||||
function visit(value: unknown): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!isTemplateResult(value)) return;
|
||||
const strings = templateStrings(value);
|
||||
const values = templateValues(value);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
if (strings[index]?.includes(marker) === true) matches.push(values[index]);
|
||||
visit(values[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function templateStrings(template: TemplateResult): readonly string[] {
|
||||
const strings = Reflect.get(template, "strings");
|
||||
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
|
||||
return strings;
|
||||
}
|
||||
|
||||
function templateValues(template: TemplateResult): readonly unknown[] {
|
||||
const values = Reflect.get(template, "values");
|
||||
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
|
||||
return values.map((value: unknown) => value);
|
||||
}
|
||||
|
||||
function isTemplateResult(value: unknown): value is TemplateResult {
|
||||
return typeof value === "object" && value !== null && isStringArray(Reflect.get(value, "strings")) && Array.isArray(Reflect.get(value, "values"));
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
|
||||
}
|
||||
|
||||
@@ -391,21 +391,27 @@ export class ChatView extends LitElement {
|
||||
<b class="label">${defaultOpen ? "live events" : "events"}</b>
|
||||
<span>${summarizeChatGroup(messages)}</span>
|
||||
</summary>
|
||||
<div class="group-body">
|
||||
${messages.map((message, offset) => {
|
||||
const toolOnly = this.isToolExecutionOnlyMessage(message);
|
||||
return html`
|
||||
<section class=${toolOnly ? "group-msg tool-execution-shell" : `group-msg ${message.role}`} data-index=${startIndex + offset} data-scroll-anchor-id=${this.eventAnchorKey(startIndex + offset)}>
|
||||
${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)}
|
||||
${message.parts.map((part) => this.renderPart(part, message))}
|
||||
</section>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
${open ? this.renderMessageGroupBody(messages, startIndex) : null}
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMessageGroupBody(messages: ChatLine[], startIndex: number) {
|
||||
return html`
|
||||
<div class="group-body">
|
||||
${messages.map((message, offset) => {
|
||||
const toolOnly = this.isToolExecutionOnlyMessage(message);
|
||||
return html`
|
||||
<section class=${toolOnly ? "group-msg tool-execution-shell" : `group-msg ${message.role}`} data-index=${startIndex + offset} data-scroll-anchor-id=${this.eventAnchorKey(startIndex + offset)}>
|
||||
${toolOnly ? null : this.renderMessageHeader(message, `${String(startIndex)}:${String(offset)}`)}
|
||||
${message.parts.map((part) => this.renderPart(part, message))}
|
||||
</section>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderScrollMarker(markerId: string) {
|
||||
return html`<span class="scroll-marker" data-marker-id=${markerId} aria-hidden="true"></span>`;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { loadExternalPlugins } from "../plugins/external";
|
||||
import { PluginRegistry, installPluginRuntimeScope, installWorkspacePanelScope } from "../plugins/registry";
|
||||
import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs";
|
||||
import { AppShellController } from "../appShell/appShellController";
|
||||
import { BrowserResumeController } from "../appShell/browserResumeController";
|
||||
import { NavigationSectionsController, type NavigationSection } from "../appShell/navigationState";
|
||||
import { PanelCollapseController, mainViewClass } from "../appShell/panelCollapseController";
|
||||
import { PanelResizeController, type PanelResizeConstraints, type ResizablePanelSide } from "../appShell/panelResizeController";
|
||||
@@ -148,6 +149,11 @@ export class PiWebApp extends LitElement {
|
||||
private readonly machineNavigation = new SessionStorageMachineNavigationMemory();
|
||||
private readonly terminalSelection = new SessionStorageTerminalSelectionMemory();
|
||||
private readonly appShell = new AppShellController(this);
|
||||
private readonly browserResume = new BrowserResumeController({
|
||||
onResumeSignal: () => { this.handleBrowserResumeSignal(); },
|
||||
refreshAfterResume: () => this.refreshAfterBrowserResume(),
|
||||
onRefreshError: (error) => { console.warn("Failed to refresh after browser resume", error); },
|
||||
});
|
||||
private readonly panelCollapse = new PanelCollapseController(this);
|
||||
private readonly panelResize = new PanelResizeController(this);
|
||||
private readonly navigationSections = new NavigationSectionsController(
|
||||
@@ -191,24 +197,6 @@ export class PiWebApp extends LitElement {
|
||||
this.appShell.repairViewportPosition();
|
||||
this.retryPendingRemoteRouteRestoreSoon();
|
||||
};
|
||||
private readonly onFocus = () => {
|
||||
this.appShell.repairViewportPosition();
|
||||
void this.sessions.refreshSelectedSession();
|
||||
this.schedulePiWebStatusRefresh();
|
||||
void this.refreshMachineActivities();
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
this.retryPendingRemoteRouteRestoreSoon();
|
||||
};
|
||||
private readonly onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
this.appShell.repairViewportPosition();
|
||||
void this.sessions.refreshSelectedSession();
|
||||
this.schedulePiWebStatusRefresh();
|
||||
void this.refreshMachineActivities();
|
||||
void this.refreshWorkspaceDeletionRuns();
|
||||
this.retryPendingRemoteRouteRestoreSoon();
|
||||
}
|
||||
};
|
||||
private readonly onSystemLightThemeChange = () => {
|
||||
if (this.themePreference.auto) this.applyPreferredTheme(false);
|
||||
};
|
||||
@@ -232,8 +220,7 @@ export class PiWebApp extends LitElement {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("popstate", this.onPopState);
|
||||
window.addEventListener("pageshow", this.onPageShow);
|
||||
window.addEventListener("focus", this.onFocus);
|
||||
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
||||
this.browserResume.connect();
|
||||
window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
|
||||
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
|
||||
this.applyPreferredTheme(false);
|
||||
@@ -248,8 +235,7 @@ export class PiWebApp extends LitElement {
|
||||
override disconnectedCallback(): void {
|
||||
window.removeEventListener("popstate", this.onPopState);
|
||||
window.removeEventListener("pageshow", this.onPageShow);
|
||||
window.removeEventListener("focus", this.onFocus);
|
||||
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
||||
this.browserResume.disconnect();
|
||||
window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS);
|
||||
this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange);
|
||||
this.keyboard.reset();
|
||||
@@ -294,6 +280,20 @@ export class PiWebApp extends LitElement {
|
||||
await this.refreshWorkspaceDeletionRuns();
|
||||
}
|
||||
|
||||
private handleBrowserResumeSignal(): void {
|
||||
this.appShell.repairViewportPosition();
|
||||
this.schedulePiWebStatusRefresh();
|
||||
this.retryPendingRemoteRouteRestoreSoon();
|
||||
}
|
||||
|
||||
private async refreshAfterBrowserResume(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.sessions.refreshSelectedSession(),
|
||||
this.refreshMachineActivities(),
|
||||
this.refreshWorkspaceDeletionRuns(),
|
||||
]);
|
||||
}
|
||||
|
||||
private schedulePiWebStatusRefresh(delayMs = PI_WEB_STATUS_DEFER_MS): void {
|
||||
this.clearScheduledPiWebStatusRefresh();
|
||||
this.piWebStatusDeferredTimer = window.setTimeout(() => {
|
||||
|
||||
@@ -12,6 +12,13 @@ function snapshot(...workspaces: WorkspaceActivity[]): WorkspaceActivityResponse
|
||||
return { workspaces, generatedAt: "now" };
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolveDeferred: ((value: T) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve) => { resolveDeferred = resolve; });
|
||||
if (resolveDeferred === undefined) throw new Error("Deferred promise was not initialized");
|
||||
return { promise, resolve: resolveDeferred };
|
||||
}
|
||||
|
||||
describe("ActivityController", () => {
|
||||
it("stores workspace activity under the requested machine", async () => {
|
||||
let state: AppState = { ...initialAppState(), selectedMachine: { id: "remote", name: "Remote", kind: "remote", createdAt: "now", updatedAt: "now" } };
|
||||
@@ -29,6 +36,42 @@ describe("ActivityController", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shares duplicate requests and runs one trailing refresh requested during the active fetch", async () => {
|
||||
const firstSnapshot = deferred<WorkspaceActivityResponse>();
|
||||
const trailingSnapshot = deferred<WorkspaceActivityResponse>();
|
||||
const trailingStarted = deferred<undefined>();
|
||||
let calls = 0;
|
||||
let state: AppState = { ...initialAppState(), selectedMachine: { id: "local", name: "Local", kind: "local", createdAt: "now", updatedAt: "now" } };
|
||||
const controller = new ActivityController(() => state, (patch) => { state = { ...state, ...patch }; }, {
|
||||
api: {
|
||||
workspaceActivity: () => {
|
||||
calls += 1;
|
||||
if (calls === 2) trailingStarted.resolve(undefined);
|
||||
return calls === 1 ? firstSnapshot.promise : trailingSnapshot.promise;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const first = controller.refresh("local");
|
||||
const duplicate = controller.refresh("local");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(calls).toBe(1);
|
||||
|
||||
const later = controller.refresh("local");
|
||||
const laterDuplicate = controller.refresh("local");
|
||||
firstSnapshot.resolve(snapshot(activity("/stale")));
|
||||
await trailingStarted.promise;
|
||||
|
||||
expect(calls).toBe(2);
|
||||
|
||||
trailingSnapshot.resolve(snapshot(activity("/fresh")));
|
||||
await Promise.all([first, duplicate, later, laterDuplicate]);
|
||||
|
||||
expect(calls).toBe(2);
|
||||
expect(state.workspaceActivities).toEqual({ "/fresh": activity("/fresh") });
|
||||
});
|
||||
|
||||
it("applies live activity updates to the owning machine only", () => {
|
||||
let state: AppState = { ...initialAppState(), selectedMachine: { id: "local", name: "Local", kind: "local", createdAt: "now", updatedAt: "now" } };
|
||||
const controller = new ActivityController(() => state, (patch) => { state = { ...state, ...patch }; });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { activityApi as defaultApi, type WorkspaceActivity, type WorkspaceActivityResponse } from "../api";
|
||||
import { isWorkspaceActivityActive } from "../../../shared/activity";
|
||||
import { selectedMachineId, type GetState, type SetState } from "./types";
|
||||
import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator";
|
||||
|
||||
export interface ActivityControllerDependencies {
|
||||
api?: Pick<typeof defaultApi, "workspaceActivity">;
|
||||
@@ -8,13 +9,16 @@ export interface ActivityControllerDependencies {
|
||||
|
||||
export class ActivityController {
|
||||
private readonly api: Pick<typeof defaultApi, "workspaceActivity">;
|
||||
private readonly refreshes = new TrailingRefreshCoordinator<string>();
|
||||
|
||||
constructor(private readonly getState: GetState, private readonly setState: SetState, deps: ActivityControllerDependencies = {}) {
|
||||
this.api = deps.api ?? defaultApi;
|
||||
}
|
||||
|
||||
async refresh(machineId = selectedMachineId(this.getState())): Promise<void> {
|
||||
this.applyMachineActivitySnapshot(machineId, indexWorkspaceActivities(await this.api.workspaceActivity(machineId)));
|
||||
refresh(machineId = selectedMachineId(this.getState())): Promise<void> {
|
||||
return this.refreshes.request(machineId, async () => {
|
||||
this.applyMachineActivitySnapshot(machineId, indexWorkspaceActivities(await this.api.workspaceActivity(machineId)));
|
||||
});
|
||||
}
|
||||
|
||||
applyWorkspaceActivity(activity: WorkspaceActivity, machineId = selectedMachineId(this.getState())): void {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialAppState } from "../appState";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, deferred, FakeSocket, oldSession, replacementSession, sessionLookupId, status, workspace, type AppState, type MessagePage, type SessionStatus } from "./sessionController.testSupport";
|
||||
|
||||
function page(text: string, total: number): MessagePage {
|
||||
return { messages: [{ role: "assistant", content: text }], start: 0, total };
|
||||
}
|
||||
|
||||
describe("SessionController selected-session refresh", () => {
|
||||
it("shares same-turn requests and runs one trailing refresh requested during the active fetch", async () => {
|
||||
const firstPage = deferred<MessagePage>();
|
||||
const firstStatus = deferred<SessionStatus>();
|
||||
const trailingPage = deferred<MessagePage>();
|
||||
const trailingStatus = deferred<SessionStatus>();
|
||||
const trailingStarted = deferred<undefined>();
|
||||
let messageCalls = 0;
|
||||
let statusCalls = 0;
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
messages: () => {
|
||||
messageCalls += 1;
|
||||
if (messageCalls === 2) trailingStarted.resolve(undefined);
|
||||
return messageCalls === 1 ? firstPage.promise : trailingPage.promise;
|
||||
},
|
||||
status: () => {
|
||||
statusCalls += 1;
|
||||
return statusCalls === 1 ? firstStatus.promise : trailingStatus.promise;
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const first = controller.refreshSelectedSession();
|
||||
const duplicate = controller.refreshSelectedSession();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(messageCalls).toBe(1);
|
||||
expect(statusCalls).toBe(1);
|
||||
|
||||
const later = controller.refreshSelectedSession();
|
||||
const laterDuplicate = controller.refreshSelectedSession();
|
||||
firstPage.resolve(page("stale", 1));
|
||||
firstStatus.resolve({ ...status(oldSession.id), messageCount: 1 });
|
||||
await trailingStarted.promise;
|
||||
|
||||
expect(messageCalls).toBe(2);
|
||||
expect(statusCalls).toBe(2);
|
||||
|
||||
trailingPage.resolve(page("fresh", 2));
|
||||
trailingStatus.resolve({ ...status(oldSession.id), messageCount: 2 });
|
||||
await Promise.all([first, duplicate, later, laterDuplicate]);
|
||||
|
||||
expect(messageCalls).toBe(2);
|
||||
expect(statusCalls).toBe(2);
|
||||
expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "fresh" }] }]);
|
||||
expect(state.status?.messageCount).toBe(2);
|
||||
});
|
||||
|
||||
it("does not apply an older refresh after the user selects another session", async () => {
|
||||
const stalePage = deferred<MessagePage>();
|
||||
const staleStatus = deferred<SessionStatus>();
|
||||
const replacementPage = page("replacement", 1);
|
||||
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession, replacementSession] };
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
messages: (session) => sessionLookupId(session) === oldSession.id ? stalePage.promise : Promise.resolve(replacementPage),
|
||||
status: (session) => sessionLookupId(session) === oldSession.id ? staleStatus.promise : Promise.resolve(status(replacementSession.id)),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
const staleRefresh = controller.refreshSelectedSession();
|
||||
await Promise.resolve();
|
||||
await controller.selectSession(replacementSession, { updateUrl: false });
|
||||
stalePage.resolve(page("old response", 1));
|
||||
staleStatus.resolve({ ...status(oldSession.id), messageCount: 1 });
|
||||
await staleRefresh;
|
||||
|
||||
expect(state.selectedSession?.id).toBe(replacementSession.id);
|
||||
expect(state.messages).toEqual([{ role: "assistant", parts: [{ type: "text", text: "replacement" }] }]);
|
||||
expect(state.status?.sessionId).toBe(replacementSession.id);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/ca
|
||||
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
|
||||
import { InMemorySessionSelectionMemory, markSessionArchived, markSessionsArchived, selectPreferredSession, selectionAfterArchivingSession, selectionAfterArchivingSessions, shouldDeselectAfterArchivedCollapse, type SessionSelectionMemory } from "./sessionSelection";
|
||||
import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types";
|
||||
import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator";
|
||||
|
||||
const MESSAGE_PAGE_SIZE = 100;
|
||||
const BULK_FALLBACK_CONCURRENCY = 4;
|
||||
@@ -60,6 +61,12 @@ interface SuppressedCreatedSession {
|
||||
machineId: string;
|
||||
}
|
||||
|
||||
interface SelectedSessionRefreshTarget {
|
||||
session: SessionInfo;
|
||||
machineId: string;
|
||||
selectionSeq: number;
|
||||
}
|
||||
|
||||
export class SessionController {
|
||||
private readonly socket: SessionEventSocket;
|
||||
private readonly api: typeof defaultApi;
|
||||
@@ -74,6 +81,7 @@ export class SessionController {
|
||||
private pendingQueuedSendSeq = 0;
|
||||
private readonly pendingSessionStarts = new Map<string, PendingSessionStart>();
|
||||
private readonly suppressedCreatedSessions = new Map<string, SuppressedCreatedSession>();
|
||||
private readonly selectedSessionRefreshes = new TrailingRefreshCoordinator<string>();
|
||||
|
||||
constructor(
|
||||
private readonly getState: GetState,
|
||||
@@ -95,6 +103,7 @@ export class SessionController {
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.selectionSeq += 1;
|
||||
this.socket.close();
|
||||
this.clearPendingUpdates();
|
||||
}
|
||||
@@ -163,6 +172,7 @@ export class SessionController {
|
||||
isReceivingPartialStream: false,
|
||||
status: session.archived === true ? undefined : this.getState().sessionStatuses[session.id],
|
||||
activity: session.archived === true ? undefined : this.getState().sessionActivities[session.id],
|
||||
availableThinkingLevels: [],
|
||||
});
|
||||
try {
|
||||
if (session.archived === true) {
|
||||
@@ -180,11 +190,9 @@ export class SessionController {
|
||||
() => { void this.refreshSelectedSession(session.id); },
|
||||
selectedMachineId(this.getState()),
|
||||
);
|
||||
const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]);
|
||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
||||
const history = this.transcripts.mergeHistory(transcriptKey, page);
|
||||
this.setState({ ...history, isLoadingEarlierMessages: false, ...this.setStreamCatchup(status.isStreaming ? session.id : undefined), status, activity: this.getState().sessionActivities[session.id], availableThinkingLevels: [] });
|
||||
this.applyStatus(status);
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
await this.requestSelectedSessionRefresh({ session, machineId, selectionSeq: seq });
|
||||
if (!this.isCurrentRefreshTarget({ session, machineId, selectionSeq: seq })) return;
|
||||
void this.refreshAvailableThinkingLevels();
|
||||
for (const event of buffered) this.applyEvent(event);
|
||||
this.socket.setHandler((event) => { this.applyEvent(event); });
|
||||
@@ -725,24 +733,48 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshSelectedSession(sessionId = this.getState().selectedSession?.id): Promise<void> {
|
||||
refreshSelectedSession(sessionId = this.getState().selectedSession?.id): Promise<void> {
|
||||
const session = this.getState().selectedSession;
|
||||
if (sessionId === undefined || session?.id !== sessionId || session.archived === true || isClientPendingStartSessionInfo(session)) return;
|
||||
try {
|
||||
if (sessionId === undefined || session?.id !== sessionId || session.archived === true || isClientPendingStartSessionInfo(session)) return Promise.resolve();
|
||||
const target: SelectedSessionRefreshTarget = {
|
||||
session,
|
||||
machineId: selectedMachineId(this.getState()),
|
||||
selectionSeq: this.selectionSeq,
|
||||
};
|
||||
return this.requestSelectedSessionRefresh(target).catch((error: unknown) => {
|
||||
if (this.isCurrentRefreshTarget(target)) this.setState({ error: String(error) });
|
||||
});
|
||||
}
|
||||
|
||||
private requestSelectedSessionRefresh(target: SelectedSessionRefreshTarget): Promise<void> {
|
||||
const key = machineSessionKey(target.machineId, target.session.id);
|
||||
return this.selectedSessionRefreshes.request(key, async () => {
|
||||
if (!this.isCurrentRefreshTarget(target)) return;
|
||||
this.flushPendingUpdates();
|
||||
const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]);
|
||||
if (this.getState().selectedSession?.id !== sessionId) return;
|
||||
const history = this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page);
|
||||
const [page, status] = await Promise.all([
|
||||
this.api.messages(target.session, { limit: MESSAGE_PAGE_SIZE }, target.machineId),
|
||||
this.api.status(target.session, target.machineId),
|
||||
]);
|
||||
if (!this.isCurrentRefreshTarget(target)) return;
|
||||
const history = this.transcripts.mergeHistory(key, page);
|
||||
this.setState({
|
||||
...history,
|
||||
status,
|
||||
activity: this.getState().sessionActivities[sessionId],
|
||||
...this.setStreamCatchup(status.isStreaming ? sessionId : undefined),
|
||||
activity: this.getState().sessionActivities[target.session.id],
|
||||
...this.setStreamCatchup(status.isStreaming ? target.session.id : undefined),
|
||||
});
|
||||
this.applyStatus(status);
|
||||
} catch (error) {
|
||||
if (this.getState().selectedSession?.id === sessionId) this.setState({ error: String(error) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private isCurrentRefreshTarget(target: SelectedSessionRefreshTarget): boolean {
|
||||
const state = this.getState();
|
||||
const selected = state.selectedSession;
|
||||
return target.selectionSeq === this.selectionSeq
|
||||
&& selectedMachineId(state) === target.machineId
|
||||
&& selected?.id === target.session.id
|
||||
&& selected.archived !== true
|
||||
&& !isClientPendingStartSessionInfo(selected);
|
||||
}
|
||||
|
||||
private applyBulkSessionFailures(action: string, failures: readonly string[]): void {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
interface PendingRefresh {
|
||||
promise: Promise<void>;
|
||||
latestRefresh: () => Promise<void>;
|
||||
started: boolean;
|
||||
trailing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares refresh work requested in the same task and collapses requests made
|
||||
* during an active refresh into one trailing pass, without losing later passes.
|
||||
*/
|
||||
export class TrailingRefreshCoordinator<Key> {
|
||||
private readonly pendingByKey = new Map<Key, PendingRefresh>();
|
||||
|
||||
request(key: Key, refresh: () => Promise<void>): Promise<void> {
|
||||
const existing = this.pendingByKey.get(key);
|
||||
if (existing !== undefined) {
|
||||
existing.latestRefresh = refresh;
|
||||
if (existing.started) existing.trailing = true;
|
||||
return existing.promise;
|
||||
}
|
||||
|
||||
const pending: PendingRefresh = {
|
||||
promise: Promise.resolve(),
|
||||
latestRefresh: refresh,
|
||||
started: false,
|
||||
trailing: false,
|
||||
};
|
||||
pending.promise = Promise.resolve()
|
||||
.then(async () => {
|
||||
let latestError: unknown;
|
||||
let latestFailed: boolean;
|
||||
do {
|
||||
pending.trailing = false;
|
||||
const runRefresh = pending.latestRefresh;
|
||||
pending.started = true;
|
||||
latestFailed = false;
|
||||
try {
|
||||
await runRefresh();
|
||||
} catch (error) {
|
||||
latestError = error;
|
||||
latestFailed = true;
|
||||
}
|
||||
} while (this.hasTrailingRequest(pending));
|
||||
if (latestFailed) throw latestError;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.pendingByKey.get(key) === pending) this.pendingByKey.delete(key);
|
||||
});
|
||||
this.pendingByKey.set(key, pending);
|
||||
return pending.promise;
|
||||
}
|
||||
|
||||
private hasTrailingRequest(pending: PendingRefresh): boolean {
|
||||
return pending.trailing;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user