fix(sync): show complete workspace and agent history (#2263)

Cached focused state was leaking into the sidebar before authoritative workspace hydration, while catch-up limits counted raw events instead of projected entries. Keep the focused cache fast, gate directory presentation on hydration, and preserve canonical cursors while paging projected history.
This commit is contained in:
Mohamed Boudra
2026-07-20 23:24:45 +08:00
committed by GitHub
parent 0c68b26a8b
commit 07d988488e
9 changed files with 331 additions and 43 deletions
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
import { useHostProjects } from "@/projects/host-projects"; import { useHostProjects } from "@/projects/host-projects";
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching"; import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime"; import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
@@ -89,13 +88,9 @@ export function useSidebarWorkspacesList(options?: {
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER); const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
const hydratedServerIds = useStoreWithEqualityFn( const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds);
useSessionStore,
(state) => serverIds.filter((id) => state.sessions[id]?.hasHydratedWorkspaces ?? false),
shallow,
);
const hostProjects = useHostProjects(serverIds); const hostProjects = useHostProjects(hydratedServerIds);
const sidebarModel = useMemo( const sidebarModel = useMemo(
() => () =>
+24 -27
View File
@@ -1350,7 +1350,7 @@ export class HostRuntimeStore {
private hostRegistryStatus: HostRegistryStatus = "loading"; private hostRegistryStatus: HostRegistryStatus = "loading";
private deps: HostRuntimeControllerDeps; private deps: HostRuntimeControllerDeps;
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>(); private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>(); private directoryBootstrapInFlight = new Map<string, Promise<void>>();
private queuedAgentDrainInFlight = new Set<string>(); private queuedAgentDrainInFlight = new Set<string>();
private directorySyncByServer = new Map<string, DirectorySync>(); private directorySyncByServer = new Map<string, DirectorySync>();
private configuredOverrideBootstrapInFlight: Promise<void> | null = null; private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
@@ -1586,17 +1586,14 @@ export class HostRuntimeStore {
controller.adoptReconciledServerId(newServerId); controller.adoptReconciledServerId(newServerId);
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId); rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
rekeyMap(this.agentDirectoryBootstrapInFlight, oldServerId, newServerId); rekeyMap(this.directoryBootstrapInFlight, oldServerId, newServerId);
this.replicaCache.reconcileServerId(oldServerId, newServerId); this.replicaCache.reconcileServerId(oldServerId, newServerId);
this.directorySyncByServer.get(oldServerId)?.dispose(); this.directorySyncByServer.get(oldServerId)?.dispose();
this.directorySyncByServer.delete(oldServerId); this.directorySyncByServer.delete(oldServerId);
const directory = new DirectorySync(newServerId, { const directory = new DirectorySync(newServerId, {
drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId), drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(), markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => { markAgentReady: () => controller.markAgentDirectorySyncReady(),
this.agentDirectoryBootstrapInFlight.delete(newServerId);
controller.markAgentDirectorySyncReady();
},
markAgentError: (error) => controller.markAgentDirectorySyncError(error), markAgentError: (error) => controller.markAgentDirectorySyncError(error),
}); });
this.directorySyncByServer.set(newServerId, directory); this.directorySyncByServer.set(newServerId, directory);
@@ -1899,7 +1896,7 @@ export class HostRuntimeStore {
} }
this.controllers.delete(serverId); this.controllers.delete(serverId);
this.lastConnectionStatusByServer.delete(serverId); this.lastConnectionStatusByServer.delete(serverId);
this.agentDirectoryBootstrapInFlight.delete(serverId); this.directoryBootstrapInFlight.delete(serverId);
this.directorySyncByServer.get(serverId)?.dispose(); this.directorySyncByServer.get(serverId)?.dispose();
this.directorySyncByServer.delete(serverId); this.directorySyncByServer.delete(serverId);
void controller.stop(); void controller.stop();
@@ -1930,10 +1927,7 @@ export class HostRuntimeStore {
drainQueuedAgentMessage: (agentId) => drainQueuedAgentMessage: (agentId) =>
this.drainQueuedAgentMessage(host.serverId, agentId), this.drainQueuedAgentMessage(host.serverId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(), markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => { markAgentReady: () => controller.markAgentDirectorySyncReady(),
this.agentDirectoryBootstrapInFlight.delete(host.serverId);
controller.markAgentDirectorySyncReady();
},
markAgentError: (error) => controller.markAgentDirectorySyncError(error), markAgentError: (error) => controller.markAgentDirectorySyncError(error),
}), }),
); );
@@ -1942,7 +1936,7 @@ export class HostRuntimeStore {
controller.getSnapshot().connectionStatus, controller.getSnapshot().connectionStatus,
); );
controller.subscribe(() => { controller.subscribe(() => {
this.maybeAutoBootstrapAgentDirectory(host.serverId); this.maybeAutoBootstrapDirectories(host.serverId);
this.emit(host.serverId); this.emit(host.serverId);
}); });
void controller void controller
@@ -1961,11 +1955,11 @@ export class HostRuntimeStore {
} }
} }
private maybeAutoBootstrapAgentDirectory(serverId: string): void { private maybeAutoBootstrapDirectories(serverId: string): void {
const controller = this.controllers.get(serverId); const controller = this.controllers.get(serverId);
if (!controller) { if (!controller) {
this.lastConnectionStatusByServer.delete(serverId); this.lastConnectionStatusByServer.delete(serverId);
this.agentDirectoryBootstrapInFlight.delete(serverId); this.directoryBootstrapInFlight.delete(serverId);
return; return;
} }
const snapshot = controller.getSnapshot(); const snapshot = controller.getSnapshot();
@@ -2000,7 +1994,7 @@ export class HostRuntimeStore {
if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) { if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) {
return; return;
} }
if (this.agentDirectoryBootstrapInFlight.has(serverId) && !directorySourceChanged) { if (this.directoryBootstrapInFlight.has(serverId) && !directorySourceChanged) {
return; return;
} }
@@ -2011,25 +2005,28 @@ export class HostRuntimeStore {
serverId, serverId,
subscribe: { subscriptionId: `app:${serverId}` }, subscribe: { subscriptionId: `app:${serverId}` },
page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT }, page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT },
}).catch((error) => {
console.error("[HostRuntime] agent directory bootstrap failed", {
serverId,
error: toErrorMessage(error),
});
}), }),
this.refreshWorkspaceDirectory({ serverId, subscribe: true }), this.refreshWorkspaceDirectory({ serverId, subscribe: true }).catch((error) => {
]), console.error("[HostRuntime] workspace directory bootstrap failed", {
serverId,
error: toErrorMessage(error),
});
}),
]).then(() => undefined),
) )
.then(() => undefined)
.catch((error) => {
console.error("[HostRuntime] agent directory bootstrap failed", {
serverId,
error: toErrorMessage(error),
});
})
.finally(() => { .finally(() => {
const inFlight = this.agentDirectoryBootstrapInFlight.get(serverId); const inFlight = this.directoryBootstrapInFlight.get(serverId);
if (inFlight === bootstrap) { if (inFlight === bootstrap) {
this.agentDirectoryBootstrapInFlight.delete(serverId); this.directoryBootstrapInFlight.delete(serverId);
} }
}); });
this.agentDirectoryBootstrapInFlight.set(serverId, bootstrap); this.directoryBootstrapInFlight.set(serverId, bootstrap);
} }
drainQueuedAgentMessage(serverId: string, agentId: string): void { drainQueuedAgentMessage(serverId: string, agentId: string): void {
@@ -4,6 +4,7 @@ import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { import {
composeWorkspaceStructure, composeWorkspaceStructure,
selectHasHydratedWorkspaces, selectHasHydratedWorkspaces,
selectHydratedWorkspaceServerIds,
selectHasWorkspaces, selectHasWorkspaces,
selectProjectOrder, selectProjectOrder,
selectRecommendedProjectPaths, selectRecommendedProjectPaths,
@@ -70,6 +71,14 @@ export function useHasHydratedWorkspaces(serverId: string | null): boolean {
); );
} }
export function useHydratedWorkspaceServerIds(serverIds: string[]): string[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectHydratedWorkspaceServerIds(state, serverIds),
workspaceEqualityFns.deep,
);
}
export function useWorkspaceDirectory( export function useWorkspaceDirectory(
serverId: string | null, serverId: string | null,
workspaceId: string | null, workspaceId: string | null,
@@ -4,6 +4,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { import {
composeWorkspaceStructure, composeWorkspaceStructure,
selectHasWorkspaces, selectHasWorkspaces,
selectHydratedWorkspaceServerIds,
selectProjectOrder, selectProjectOrder,
selectRecommendedProjectPaths, selectRecommendedProjectPaths,
selectWorkspace, selectWorkspace,
@@ -101,6 +102,75 @@ afterEach(() => {
useSessionStore.getState().clearSession(SERVER_ID); useSessionStore.getState().clearSession(SERVER_ID);
}); });
describe("workspace replica authority", () => {
it("keeps a cached workspace addressable without publishing it as an authoritative directory", () => {
const cachedWorkspace = createWorkspace({ id: "cached-workspace" });
initializeWorkspaces([cachedWorkspace]);
const cachedServerIds = selectHydratedWorkspaceServerIds(useSessionStore.getState(), [
SERVER_ID,
]);
expect(selectWorkspace(useSessionStore.getState(), SERVER_ID, cachedWorkspace.id)).toBe(
cachedWorkspace,
);
expect(selectWorkspaceStructureProjects(useSessionStore.getState(), cachedServerIds)).toEqual(
[],
);
const authoritativeWorkspace = createWorkspace({
id: "authoritative-workspace",
projectId: "authoritative-project",
});
useSessionStore
.getState()
.setWorkspaces(SERVER_ID, new Map([[authoritativeWorkspace.id, authoritativeWorkspace]]));
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
const hydratedServerIds = selectHydratedWorkspaceServerIds(useSessionStore.getState(), [
SERVER_ID,
]);
expect(
selectWorkspaceStructureProjects(useSessionStore.getState(), hydratedServerIds).map(
(project) => project.workspaceKeys,
),
).toEqual([[`${SERVER_ID}:${authoritativeWorkspace.id}`]]);
});
it("publishes each host to the workspace directory independently", () => {
const loadingServerId = "loading-server";
const hydratedServerId = "hydrated-server";
const loadingWorkspace = createWorkspace({ id: "loading-workspace" });
const hydratedWorkspace = createWorkspace({
id: "hydrated-workspace",
projectId: "hydrated-project",
});
const state = {
sessions: {
[loadingServerId]: {
hasHydratedWorkspaces: false,
workspaces: new Map([[loadingWorkspace.id, loadingWorkspace]]),
},
[hydratedServerId]: {
hasHydratedWorkspaces: true,
workspaces: new Map([[hydratedWorkspace.id, hydratedWorkspace]]),
},
},
};
const directoryServerIds = selectHydratedWorkspaceServerIds(state, [
loadingServerId,
hydratedServerId,
]);
const directoryProjects = selectWorkspaceStructureProjects(state, directoryServerIds);
expect(directoryServerIds).toEqual([hydratedServerId]);
expect(directoryProjects.map((project) => project.workspaceKeys)).toEqual([
[`${hydratedServerId}:${hydratedWorkspace.id}`],
]);
});
});
describe("selectWorkspace", () => { describe("selectWorkspace", () => {
it("resolves a descriptor when the route id matches workspace identity but not the map key", () => { it("resolves a descriptor when the route id matches workspace identity but not the map key", () => {
const workspace = createWorkspace({ id: "workspace-a" }); const workspace = createWorkspace({ id: "workspace-a" });
@@ -131,6 +131,13 @@ export function selectHasHydratedWorkspaces(
return serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false; return serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false;
} }
export function selectHydratedWorkspaceServerIds(
state: SessionsSnapshot,
serverIds: readonly string[],
): string[] {
return serverIds.filter((serverId) => state.sessions[serverId]?.hasHydratedWorkspaces === true);
}
export function selectWorkspaceStructureProjects( export function selectWorkspaceStructureProjects(
state: SessionsSnapshot, state: SessionsSnapshot,
serverIds: readonly string[], serverIds: readonly string[],
@@ -572,6 +572,61 @@ describe("selectProjectedTimelinePage", () => {
expect(page.hasNewer).toBe(true); expect(page.hasNewer).toBe(true);
}); });
test("after limit counts projected entries while preserving their canonical sequence coverage", () => {
const rows: AgentTimelineRow[] = [
...Array.from({ length: 200 }, (_, index) => ({
seq: index + 1,
timestamp: new Date(1000 + index).toISOString(),
item: { type: "assistant_message" as const, text: "x" },
})),
...Array.from({ length: 150 }, (_, index) => ({
seq: index + 201,
timestamp: new Date(2000 + index).toISOString(),
item: { type: "user_message" as const, text: `message ${index + 1}` },
})),
];
const page = selectProjectedTimelinePage({
rows,
direction: "after",
cursorSeq: 0,
limit: 100,
});
expect({
entryCount: page.entries.length,
firstEntry: page.entries[0],
lastEntry: page.entries.at(-1),
startSeq: page.startSeq,
endSeq: page.endSeq,
hasNewer: page.hasNewer,
}).toEqual({
entryCount: 100,
firstEntry: {
item: {
type: "assistant_message",
text: "x".repeat(200),
},
timestamp: new Date(1199).toISOString(),
seqStart: 1,
seqEnd: 200,
sourceSeqRanges: [{ startSeq: 1, endSeq: 200 }],
collapsed: ["assistant_merge"],
},
lastEntry: {
item: { type: "user_message", text: "message 99" },
timestamp: new Date(2098).toISOString(),
seqStart: 299,
seqEnd: 299,
sourceSeqRanges: [{ startSeq: 299, endSeq: 299 }],
collapsed: [],
},
startSeq: 1,
endSeq: 299,
hasNewer: true,
});
});
test("before page includes a wide tool whose earlier source range is before the cursor", () => { test("before page includes a wide tool whose earlier source range is before the cursor", () => {
const rows: AgentTimelineRow[] = [ const rows: AgentTimelineRow[] = [
toolRow(1, "running"), toolRow(1, "running"),
@@ -376,6 +376,69 @@ function selectEntriesOverlappingSeqRange(input: {
); );
} }
function firstSourceSeqInRange(
entry: TimelineProjectionEntry,
startSeq: number,
endSeq: number,
): number | null {
for (const range of entry.sourceSeqRanges) {
const firstSeq = Math.max(range.startSeq, startSeq);
if (firstSeq <= Math.min(range.endSeq, endSeq)) return firstSeq;
}
return null;
}
interface ProjectedEntryCandidate {
entry: TimelineProjectionEntry;
index: number;
firstSourceSeq: number;
}
function selectProjectedEntriesAfter(input: {
entries: readonly TimelineProjectionEntry[];
rows: readonly AgentTimelineRow[];
startSeq: number;
maxSeq: number;
limit: number;
}): { entries: TimelineProjectionEntry[]; endSeq: number | null } {
const eligible = input.entries
.map((entry, index) => ({
entry,
index,
firstSourceSeq: firstSourceSeqInRange(entry, input.startSeq, input.maxSeq),
}))
.filter((candidate): candidate is ProjectedEntryCandidate => candidate.firstSourceSeq !== null)
.sort((left, right) => left.firstSourceSeq - right.firstSourceSeq || left.index - right.index);
const selected = input.limit === 0 ? eligible : eligible.slice(0, input.limit);
const selectedEntries = selected
.sort((left, right) => left.index - right.index)
.map((candidate) => candidate.entry);
if (selectedEntries.length === 0) return { entries: [], endSeq: null };
const selectedRanges = selectedEntries
.flatMap((entry) => entry.sourceSeqRanges)
.sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq);
// Wide projected entries can include future, discontiguous source ranges. The
// page cursor advances only through source rows covered without a gap.
let endSeq = input.startSeq - 1;
let rangeIndex = 0;
for (const row of input.rows) {
if (row.seq < input.startSeq) continue;
if (row.seq > input.maxSeq || row.seq !== endSeq + 1) break;
while (selectedRanges[rangeIndex] && selectedRanges[rangeIndex].endSeq < row.seq) {
rangeIndex += 1;
}
const range = selectedRanges[rangeIndex];
if (!range || row.seq < range.startSeq || row.seq > range.endSeq) break;
endSeq = row.seq;
}
return {
entries: selectedEntries,
endSeq: endSeq >= input.startSeq ? endSeq : null,
};
}
export function selectProjectedTimelinePage(input: { export function selectProjectedTimelinePage(input: {
rows: readonly AgentTimelineRow[]; rows: readonly AgentTimelineRow[];
bounds?: { minSeq: number; maxSeq: number }; bounds?: { minSeq: number; maxSeq: number };
@@ -446,7 +509,20 @@ export function selectProjectedTimelinePage(input: {
if (input.direction === "after") { if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
startSeq = Math.max(bounds.minSeq, cursorSeq + 1); startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
endSeq = limit === 0 ? bounds.maxSeq : Math.min(bounds.maxSeq, cursorSeq + limit); const selected = selectProjectedEntriesAfter({
entries: projectedAll,
rows: input.rows,
startSeq,
maxSeq: bounds.maxSeq,
limit,
});
return {
entries: selected.entries,
startSeq: selected.endSeq === null ? null : startSeq,
endSeq: selected.endSeq,
hasOlder: startSeq > bounds.minSeq,
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
};
} else { } else {
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
endSeq = Math.min(bounds.maxSeq, cursorSeq - 1); endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
@@ -362,6 +362,75 @@ describe("daemon E2E - timeline window", () => {
} }
}); });
test("projected after limit returns 100 projected entries with their canonical coverage", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Timeline Projected Page Limit Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 200; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: "x",
});
}
for (let seq = 201; seq <= 350; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "user_message",
text: `message ${seq - 200}`,
});
}
const epoch = ctx.daemon.daemon.agentManager.fetchTimeline(agent.id, {
direction: "tail",
limit: 1,
}).epoch;
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { epoch, seq: 0 },
limit: 100,
projection: "projected",
});
expect({
entryCount: timeline.entries.length,
firstEntry: timeline.entries[0],
lastEntry: timeline.entries.at(-1),
startCursor: timeline.startCursor,
endCursor: timeline.endCursor,
hasNewer: timeline.hasNewer,
}).toEqual({
entryCount: 100,
firstEntry: {
provider: "codex",
item: { type: "assistant_message", text: "x".repeat(200) },
timestamp: expect.any(String),
seqStart: 1,
seqEnd: 200,
sourceSeqRanges: [{ startSeq: 1, endSeq: 200 }],
collapsed: ["assistant_merge"],
},
lastEntry: {
provider: "codex",
item: { type: "user_message", text: "message 99" },
timestamp: expect.any(String),
seqStart: 299,
seqEnd: 299,
sourceSeqRanges: [{ startSeq: 299, endSeq: 299 }],
collapsed: [],
},
startCursor: { epoch, seq: 1 },
endCursor: { epoch, seq: 299 },
hasNewer: true,
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("projected empty after fetch preserves older history availability", async () => { test("projected empty after fetch preserves older history availability", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
try { try {
+17 -7
View File
@@ -5686,22 +5686,31 @@ export class Session {
private shouldUseFullTimelineForProjectedPage(input: { private shouldUseFullTimelineForProjectedPage(input: {
timeline: AgentTimelineFetchResult; timeline: AgentTimelineFetchResult;
pageLimit: number;
}): boolean { }): boolean {
const { timeline } = input; const { timeline } = input;
if (timeline.reset || timeline.rows.length === 0 || !timeline.hasOlder) { if (timeline.rows.length === 0) return false;
return false;
} if (timeline.rows.some((row) => row.item.type === "tool_call")) return true;
const firstRow = timeline.rows[0]; const firstRow = timeline.rows[0];
if ( if (
firstRow?.item.type === "assistant_message" || timeline.hasOlder &&
firstRow?.item.type === "reasoning" || (firstRow?.item.type === "assistant_message" || firstRow?.item.type === "reasoning")
firstRow?.item.type === "tool_call"
) { ) {
return true; return true;
} }
return timeline.rows.some((row) => row.item.type === "tool_call"); const lastRow = timeline.rows.at(-1);
if (
timeline.hasNewer &&
(lastRow?.item.type === "assistant_message" || lastRow?.item.type === "reasoning")
) {
return true;
}
if (!timeline.hasNewer || input.pageLimit === 0) return false;
return projectTimelineRows({ rows: timeline.rows, mode: "projected" }).length < input.pageLimit;
} }
private selectCanonicalTimelineProjection(input: { private selectCanonicalTimelineProjection(input: {
@@ -5727,6 +5736,7 @@ export class Session {
}): AgentTimelineProjectionSelection { }): AgentTimelineProjectionSelection {
const timeline = this.shouldUseFullTimelineForProjectedPage({ const timeline = this.shouldUseFullTimelineForProjectedPage({
timeline: input.controlTimeline, timeline: input.controlTimeline,
pageLimit: input.pageLimit,
}) })
? this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 }) ? this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 })
: input.controlTimeline; : input.controlTimeline;