mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 12:23:16 +00:00
Keep focused agent timelines live and catch up instantly (#2196)
* fix(app): keep focused agent timelines live Window focus was conflated with app visibility, so switching OS windows could remove the selected timeline subscription. Separate those signals, grace every visibility-driven removal, and cover retained history until authoritative catch-up completes. * fix(app): keep timeline catch-up failures non-blocking * fix(app): surface timeline catch-up failures * fix(app): preserve file preview refocus * fix(app): preserve optimistic catch-up flow
This commit is contained in:
@@ -56,9 +56,12 @@ When a client resumes without a cursor, it fetches the latest tail page.
|
|||||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
||||||
|
|
||||||
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
|
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
|
||||||
catch up immediately. Removals stay subscribed for a short grace period so quick tab and pane
|
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
|
||||||
switches do not repeatedly unsubscribe and catch up. Backgrounding, disconnecting, and disposal
|
subscribed for a short grace period so brief tab, pane, route, and app switches do not repeatedly
|
||||||
clear that grace immediately.
|
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
|
||||||
|
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
|
||||||
|
After grace has expired, a retained timeline stays covered when revisited until authoritative
|
||||||
|
catch-up completes; cached partial output is never presented as current history.
|
||||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||||
|
|
||||||
|
|||||||
107
packages/app/e2e/helpers/timeline-delivery.ts
Normal file
107
packages/app/e2e/helpers/timeline-delivery.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
type WebSocketMessage = string | Buffer;
|
||||||
|
|
||||||
|
interface SessionMessage {
|
||||||
|
type?: unknown;
|
||||||
|
payload?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
|
||||||
|
if (typeof message !== "string") return null;
|
||||||
|
try {
|
||||||
|
const envelope = JSON.parse(message) as {
|
||||||
|
type?: unknown;
|
||||||
|
message?: SessionMessage;
|
||||||
|
};
|
||||||
|
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function observeTimelineSubscriptions(page: Page) {
|
||||||
|
let acknowledgedAgentIds: string[] | null = null;
|
||||||
|
|
||||||
|
page.on("websocket", (socket) => {
|
||||||
|
socket.on("framereceived", ({ payload }) => {
|
||||||
|
const message = readSessionMessage(payload);
|
||||||
|
if (message?.type !== "agent.timeline.set_subscription.response") return;
|
||||||
|
const response = message.payload as { agentIds?: unknown } | undefined;
|
||||||
|
if (!Array.isArray(response?.agentIds)) return;
|
||||||
|
acknowledgedAgentIds = response.agentIds.filter(
|
||||||
|
(agentId): agentId is string => typeof agentId === "string",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
async waitForSubscribedAgents(agentIds: string[]): Promise<void> {
|
||||||
|
const expected = [...new Set(agentIds)].sort();
|
||||||
|
await expect
|
||||||
|
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { timeout: 15_000 })
|
||||||
|
.toEqual(expected);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantFrameState {
|
||||||
|
active: boolean;
|
||||||
|
lastText: string | null;
|
||||||
|
snapshots: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function observeLastAssistantFrames(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const state: AssistantFrameState = {
|
||||||
|
active: true,
|
||||||
|
lastText: null,
|
||||||
|
snapshots: [],
|
||||||
|
};
|
||||||
|
const sample = () => {
|
||||||
|
const hasPaintedHistoryOverlay = Array.from(
|
||||||
|
document.querySelectorAll('[data-testid="agent-history-overlay"]'),
|
||||||
|
).some((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0;
|
||||||
|
});
|
||||||
|
if (!hasPaintedHistoryOverlay) {
|
||||||
|
const messages = Array.from(
|
||||||
|
document.querySelectorAll('[data-testid="assistant-message"]'),
|
||||||
|
).filter((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0;
|
||||||
|
});
|
||||||
|
const text = messages.at(-1)?.textContent?.trim() ?? "";
|
||||||
|
if (text && text !== state.lastText) {
|
||||||
|
state.lastText = text;
|
||||||
|
state.snapshots.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.active) requestAnimationFrame(sample);
|
||||||
|
};
|
||||||
|
Object.assign(window, { __assistantFrameState: state });
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
async stop(): Promise<string[]> {
|
||||||
|
return page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<string[]>((resolve) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const state = (
|
||||||
|
window as typeof window & { __assistantFrameState?: AssistantFrameState }
|
||||||
|
).__assistantFrameState;
|
||||||
|
if (!state) {
|
||||||
|
resolve([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.active = false;
|
||||||
|
resolve([...state.snapshots]);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import { getServerId } from "./helpers/server-id";
|
|||||||
*
|
*
|
||||||
* The repro is the mundane user flow: with a workspace already showing a terminal, open another
|
* The repro is the mundane user flow: with a workspace already showing a terminal, open another
|
||||||
* one and switch to a different app while it spawns. We stage the blur deterministically by
|
* one and switch to a different app while it spawns. We stage the blur deterministically by
|
||||||
* stubbing `document.hasFocus()` (which `useAppVisible` consults on window focus/blur events)
|
* stubbing `document.hasFocus()` (which `useAppActivelyVisible` consults on focus/blur events)
|
||||||
* instead of racing real OS focus.
|
* instead of racing real OS focus.
|
||||||
*
|
*
|
||||||
* The assertion reads the PTY's own opinion of its size (`stty size`) with input written
|
* The assertion reads the PTY's own opinion of its size (`stty size`) with input written
|
||||||
@@ -23,7 +23,7 @@ import { getServerId } from "./helpers/server-id";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulates the window losing/regaining OS focus, which is what `useAppVisible` reads through
|
* Simulates the window losing/regaining OS focus, which is what `useAppActivelyVisible` reads through
|
||||||
* `document.hasFocus()` plus the window focus/blur events.
|
* `document.hasFocus()` plus the window focus/blur events.
|
||||||
*
|
*
|
||||||
* This has to be stubbed: headless Chromium never actually blurs. Opening a second page and
|
* This has to be stubbed: headless Chromium never actually blurs. Opening a second page and
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
|||||||
import { test } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||||
import { getServerId } from "./helpers/server-id";
|
import { getServerId } from "./helpers/server-id";
|
||||||
|
import {
|
||||||
|
observeLastAssistantFrames,
|
||||||
|
observeTimelineSubscriptions,
|
||||||
|
} from "./helpers/timeline-delivery";
|
||||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +75,42 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Viewed agent timelines", () => {
|
test.describe("Viewed agent timelines", () => {
|
||||||
|
test("a focused turn streams live and resumes its hidden remainder atomically", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const subscriptions = observeTimelineSubscriptions(page);
|
||||||
|
const scenario = await seedViewedTimelineScenario();
|
||||||
|
try {
|
||||||
|
await openAgent(page, scenario, scenario.firstAgentId);
|
||||||
|
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId]);
|
||||||
|
|
||||||
|
await scenario.client.sendAgentMessage(
|
||||||
|
scenario.firstAgentId,
|
||||||
|
"Stream while focused, then finish while hidden.",
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("assistant-message").filter({ hasText: "Cycle 1" }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
|
await selectAgent(page, "Second viewed chat");
|
||||||
|
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId]);
|
||||||
|
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000);
|
||||||
|
expect(finish.status).toBe("idle");
|
||||||
|
|
||||||
|
const assistantFrames = await observeLastAssistantFrames(page);
|
||||||
|
await selectAgent(page, "First viewed chat");
|
||||||
|
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||||
|
const snapshots = await assistantFrames.stop();
|
||||||
|
|
||||||
|
expect(snapshots[0]).toContain("(end of synthetic stream)");
|
||||||
|
} finally {
|
||||||
|
await scenario.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("a hidden retained chat catches up when shown", async ({ page }) => {
|
test("a hidden retained chat catches up when shown", async ({ page }) => {
|
||||||
const scenario = await seedViewedTimelineScenario();
|
const scenario = await seedViewedTimelineScenario();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
|||||||
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
||||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
||||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
@@ -386,10 +386,10 @@ export function FilePane({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Re-read the file when this pane becomes visible again (#445). `isActive`
|
// Re-read the file when this pane becomes visible again (#445). `isActive`
|
||||||
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
|
// covers tab switches; active app visibility covers backgrounding and returning
|
||||||
// gate itself lives in isFileQueryEnabled.
|
// from another window after an external edit. The gate lives in isFileQueryEnabled.
|
||||||
const isActive = useRetainedPanelActive();
|
const isActive = useRetainedPanelActive();
|
||||||
const isAppVisible = useAppVisible();
|
const isAppVisible = useAppActivelyVisible();
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
|
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ describe("terminal pane focus claim", () => {
|
|||||||
it("waits for both the client and renderer before requesting a claim", () => {
|
it("waits for both the client and renderer before requesting a claim", () => {
|
||||||
const withoutClient = canRequestFocusClaim({
|
const withoutClient = canRequestFocusClaim({
|
||||||
isWorkspaceFocused: true,
|
isWorkspaceFocused: true,
|
||||||
isAppVisible: true,
|
isAppActivelyVisible: true,
|
||||||
isClientReady: false,
|
isClientReady: false,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
isRendererReady: true,
|
isRendererReady: true,
|
||||||
});
|
});
|
||||||
const withoutRenderer = canRequestFocusClaim({
|
const withoutRenderer = canRequestFocusClaim({
|
||||||
isWorkspaceFocused: true,
|
isWorkspaceFocused: true,
|
||||||
isAppVisible: true,
|
isAppActivelyVisible: true,
|
||||||
isClientReady: true,
|
isClientReady: true,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
isRendererReady: false,
|
isRendererReady: false,
|
||||||
@@ -37,7 +37,7 @@ describe("terminal pane focus claim", () => {
|
|||||||
it("does not deliver a requested claim after the host disconnects", () => {
|
it("does not deliver a requested claim after the host disconnects", () => {
|
||||||
const disconnected = canRequestFocusClaim({
|
const disconnected = canRequestFocusClaim({
|
||||||
isWorkspaceFocused: true,
|
isWorkspaceFocused: true,
|
||||||
isAppVisible: true,
|
isAppActivelyVisible: true,
|
||||||
isClientReady: true,
|
isClientReady: true,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
isRendererReady: true,
|
isRendererReady: true,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface FocusClaimStep {
|
|||||||
|
|
||||||
interface FocusClaimReadiness {
|
interface FocusClaimReadiness {
|
||||||
isWorkspaceFocused: boolean;
|
isWorkspaceFocused: boolean;
|
||||||
isAppVisible: boolean;
|
isAppActivelyVisible: boolean;
|
||||||
isClientReady: boolean;
|
isClientReady: boolean;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isRendererReady: boolean;
|
isRendererReady: boolean;
|
||||||
@@ -24,7 +24,7 @@ export const EMPTY_FOCUS_CLAIM_STATE: FocusClaimState = {
|
|||||||
export function canRequestFocusClaim(input: FocusClaimReadiness): boolean {
|
export function canRequestFocusClaim(input: FocusClaimReadiness): boolean {
|
||||||
return (
|
return (
|
||||||
input.isWorkspaceFocused &&
|
input.isWorkspaceFocused &&
|
||||||
input.isAppVisible &&
|
input.isAppActivelyVisible &&
|
||||||
input.isClientReady &&
|
input.isClientReady &&
|
||||||
input.isConnected &&
|
input.isConnected &&
|
||||||
input.isRendererReady
|
input.isRendererReady
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-m
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import {
|
import {
|
||||||
hasPendingTerminalModifiers,
|
hasPendingTerminalModifiers,
|
||||||
@@ -177,7 +177,7 @@ export function TerminalPane({
|
|||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
}: TerminalPaneProps) {
|
}: TerminalPaneProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isAppVisible = useAppVisible();
|
const isAppActivelyVisible = useAppActivelyVisible();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { settings } = useAppSettings();
|
const { settings } = useAppSettings();
|
||||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
|
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
|
||||||
@@ -283,7 +283,7 @@ export function TerminalPane({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canRequest = canRequestFocusClaim({
|
const canRequest = canRequestFocusClaim({
|
||||||
isWorkspaceFocused,
|
isWorkspaceFocused,
|
||||||
isAppVisible,
|
isAppActivelyVisible,
|
||||||
isClientReady: client !== null,
|
isClientReady: client !== null,
|
||||||
isConnected,
|
isConnected,
|
||||||
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
|
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
|
||||||
@@ -299,7 +299,7 @@ export function TerminalPane({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
client,
|
client,
|
||||||
isAppVisible,
|
isAppActivelyVisible,
|
||||||
isConnected,
|
isConnected,
|
||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
isWorkspaceFocused,
|
isWorkspaceFocused,
|
||||||
@@ -654,7 +654,7 @@ export function TerminalPane({
|
|||||||
let sent = false;
|
let sent = false;
|
||||||
const canSend = canRequestFocusClaim({
|
const canSend = canRequestFocusClaim({
|
||||||
isWorkspaceFocused,
|
isWorkspaceFocused,
|
||||||
isAppVisible,
|
isAppActivelyVisible,
|
||||||
isClientReady: client !== null,
|
isClientReady: client !== null,
|
||||||
isConnected,
|
isConnected,
|
||||||
isRendererReady: true,
|
isRendererReady: true,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
|
|||||||
import { useSessionStore, type MessageEntry, type SessionState } from "@/stores/session-store";
|
import { useSessionStore, type MessageEntry, type SessionState } from "@/stores/session-store";
|
||||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||||
import { sendOsNotification } from "@/utils/os-notifications";
|
import { sendOsNotification } from "@/utils/os-notifications";
|
||||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
|
||||||
import {
|
import {
|
||||||
getInitKey,
|
getInitKey,
|
||||||
getInitDeferred,
|
getInitDeferred,
|
||||||
@@ -773,7 +773,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
});
|
});
|
||||||
viewedTimelineSyncRef.current = sync;
|
viewedTimelineSyncRef.current = sync;
|
||||||
setViewedTimelineSync(serverId, sync);
|
setViewedTimelineSync(serverId, sync);
|
||||||
sync.setActive(getIsAppActivelyVisible(appStateRef.current));
|
sync.setActive(getIsAppVisible(appStateRef.current));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (viewedTimelineSyncRef.current === sync) {
|
if (viewedTimelineSyncRef.current === sync) {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ function createBaseInput(): AgentScreenMachineInput {
|
|||||||
isArchivingCurrentAgent: false,
|
isArchivingCurrentAgent: false,
|
||||||
isHistorySyncing: false,
|
isHistorySyncing: false,
|
||||||
needsAuthoritativeSync: false,
|
needsAuthoritativeSync: false,
|
||||||
|
visibilityCatchUpStatus: "ready",
|
||||||
hasHydratedHistoryBefore: false,
|
hasHydratedHistoryBefore: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -189,6 +190,43 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
expect(sync.ui).toBe("silent");
|
expect(sync.ui).toBe("silent");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("covers already-hydrated history while a newly visible agent catches up", () => {
|
||||||
|
const memory = createBaseMemory({
|
||||||
|
hasRenderedReady: true,
|
||||||
|
lastReadyAgent: createAgent("agent-1"),
|
||||||
|
});
|
||||||
|
const input: AgentScreenMachineInput = {
|
||||||
|
...createBaseInput(),
|
||||||
|
agent: createAgent("agent-1"),
|
||||||
|
hasHydratedHistoryBefore: true,
|
||||||
|
visibilityCatchUpStatus: "pending",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = deriveAgentScreenViewState({ input, memory });
|
||||||
|
const ready = expectReadyState(result.state);
|
||||||
|
const sync = expectCatchingUpSync(ready);
|
||||||
|
|
||||||
|
expect(sync.ui).toBe("overlay");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps hydrated history readable after a visibility catch-up error", () => {
|
||||||
|
const memory = createBaseMemory({
|
||||||
|
hasRenderedReady: true,
|
||||||
|
lastReadyAgent: createAgent("agent-1"),
|
||||||
|
});
|
||||||
|
const input: AgentScreenMachineInput = {
|
||||||
|
...createBaseInput(),
|
||||||
|
agent: createAgent("agent-1"),
|
||||||
|
hasHydratedHistoryBefore: true,
|
||||||
|
visibilityCatchUpStatus: "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = deriveAgentScreenViewState({ input, memory });
|
||||||
|
const ready = expectReadyState(result.state);
|
||||||
|
|
||||||
|
expectSyncErrorSync(ready);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps sync errors non-blocking once the screen was ready", () => {
|
it("keeps sync errors non-blocking once the screen was ready", () => {
|
||||||
const memory = createBaseMemory({
|
const memory = createBaseMemory({
|
||||||
hasRenderedReady: true,
|
hasRenderedReady: true,
|
||||||
@@ -328,7 +366,7 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
expect(result.memory.lastReadyAgent).toBeNull();
|
expect(result.memory.lastReadyAgent).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still allows optimistic create flow to render before authoritative history arrives", () => {
|
it("keeps optimistic create non-blocking while timeline and authoritative history catch up", () => {
|
||||||
const memory = createBaseMemory();
|
const memory = createBaseMemory();
|
||||||
const input: AgentScreenMachineInput = {
|
const input: AgentScreenMachineInput = {
|
||||||
...createBaseInput(),
|
...createBaseInput(),
|
||||||
@@ -336,6 +374,7 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
continuity: { kind: "optimistic-create", agent: createAgent("agent-1") },
|
continuity: { kind: "optimistic-create", agent: createAgent("agent-1") },
|
||||||
needsAuthoritativeSync: true,
|
needsAuthoritativeSync: true,
|
||||||
isHistorySyncing: true,
|
isHistorySyncing: true,
|
||||||
|
visibilityCatchUpStatus: "pending",
|
||||||
hasHydratedHistoryBefore: false,
|
hasHydratedHistoryBefore: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface AgentScreenMachineInput {
|
|||||||
isArchivingCurrentAgent: boolean;
|
isArchivingCurrentAgent: boolean;
|
||||||
isHistorySyncing: boolean;
|
isHistorySyncing: boolean;
|
||||||
needsAuthoritativeSync: boolean;
|
needsAuthoritativeSync: boolean;
|
||||||
|
visibilityCatchUpStatus: "ready" | "pending" | "error";
|
||||||
continuity: AgentScreenContinuity;
|
continuity: AgentScreenContinuity;
|
||||||
hasHydratedHistoryBefore: boolean;
|
hasHydratedHistoryBefore: boolean;
|
||||||
}
|
}
|
||||||
@@ -147,10 +148,12 @@ function resolveAgentScreenSource(args: {
|
|||||||
|
|
||||||
function resolveCatchingUpUi(args: {
|
function resolveCatchingUpUi(args: {
|
||||||
hasOptimisticCreateContinuity: boolean;
|
hasOptimisticCreateContinuity: boolean;
|
||||||
|
isVisibilityCatchUpPending: boolean;
|
||||||
hasHydratedHistoryBefore: boolean;
|
hasHydratedHistoryBefore: boolean;
|
||||||
hadInitialSyncFailure: boolean;
|
hadInitialSyncFailure: boolean;
|
||||||
}): "overlay" | "silent" {
|
}): "overlay" | "silent" {
|
||||||
if (args.hasOptimisticCreateContinuity) return "silent";
|
if (args.hasOptimisticCreateContinuity) return "silent";
|
||||||
|
if (args.isVisibilityCatchUpPending) return "overlay";
|
||||||
if (args.hasHydratedHistoryBefore) return "silent";
|
if (args.hasHydratedHistoryBefore) return "silent";
|
||||||
if (args.hadInitialSyncFailure) return "silent";
|
if (args.hadInitialSyncFailure) return "silent";
|
||||||
return "overlay";
|
return "overlay";
|
||||||
@@ -167,11 +170,19 @@ function resolveAgentScreenSync(args: {
|
|||||||
if (input.missingAgentState.kind === "error") {
|
if (input.missingAgentState.kind === "error") {
|
||||||
return { status: "sync_error" };
|
return { status: "sync_error" };
|
||||||
}
|
}
|
||||||
if (input.needsAuthoritativeSync || input.isHistorySyncing) {
|
if (input.visibilityCatchUpStatus === "error") {
|
||||||
|
return { status: "sync_error" };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.visibilityCatchUpStatus === "pending" ||
|
||||||
|
input.needsAuthoritativeSync ||
|
||||||
|
input.isHistorySyncing
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
status: "catching_up",
|
status: "catching_up",
|
||||||
ui: resolveCatchingUpUi({
|
ui: resolveCatchingUpUi({
|
||||||
hasOptimisticCreateContinuity: hasOptimisticCreateContinuity(input),
|
hasOptimisticCreateContinuity: hasOptimisticCreateContinuity(input),
|
||||||
|
isVisibilityCatchUpPending: input.visibilityCatchUpStatus === "pending",
|
||||||
hasHydratedHistoryBefore: input.hasHydratedHistoryBefore,
|
hasHydratedHistoryBefore: input.hasHydratedHistoryBefore,
|
||||||
hadInitialSyncFailure,
|
hadInitialSyncFailure,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { AppState } from "react-native";
|
import { AppState } from "react-native";
|
||||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
let current = getIsAppActivelyVisible();
|
let visible = getIsAppVisible();
|
||||||
const listeners = new Set<() => void>();
|
let activelyVisible = getIsAppActivelyVisible();
|
||||||
|
const visibilityListeners = new Set<() => void>();
|
||||||
|
const activeVisibilityListeners = new Set<() => void>();
|
||||||
|
|
||||||
function notify(): void {
|
function notify(): void {
|
||||||
const next = getIsAppActivelyVisible();
|
const nextVisible = getIsAppVisible();
|
||||||
if (next === current) {
|
if (nextVisible !== visible) {
|
||||||
return;
|
visible = nextVisible;
|
||||||
|
for (const listener of visibilityListeners) listener();
|
||||||
}
|
}
|
||||||
current = next;
|
|
||||||
for (const listener of listeners) {
|
const nextActivelyVisible = getIsAppActivelyVisible();
|
||||||
listener();
|
if (nextActivelyVisible !== activelyVisible) {
|
||||||
|
activelyVisible = nextActivelyVisible;
|
||||||
|
for (const listener of activeVisibilityListeners) listener();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,15 +34,32 @@ if (isWeb && typeof document !== "undefined") {
|
|||||||
window.addEventListener("blur", notify);
|
window.addEventListener("blur", notify);
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribe(listener: () => void): () => void {
|
function subscribeToVisibility(listener: () => void): () => void {
|
||||||
listeners.add(listener);
|
visibilityListeners.add(listener);
|
||||||
return () => listeners.delete(listener);
|
return () => visibilityListeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSnapshot(): boolean {
|
function subscribeToActiveVisibility(listener: () => void): () => void {
|
||||||
return current;
|
activeVisibilityListeners.add(listener);
|
||||||
|
return () => activeVisibilityListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibilitySnapshot(): boolean {
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveVisibilitySnapshot(): boolean {
|
||||||
|
return activelyVisible;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAppVisible(): boolean {
|
export function useAppVisible(): boolean {
|
||||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
return useSyncExternalStore(subscribeToVisibility, getVisibilitySnapshot, getVisibilitySnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppActivelyVisible(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribeToActiveVisibility,
|
||||||
|
getActiveVisibilitySnapshot,
|
||||||
|
getActiveVisibilitySnapshot,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export const ar: TranslationResources = {
|
|||||||
notFound: "لم يتم العثور على Agent",
|
notFound: "لم يتم العثور على Agent",
|
||||||
failedToLoad: "فشل تحميل الوكيل",
|
failedToLoad: "فشل تحميل الوكيل",
|
||||||
reconnecting: "جارٍ إعادة الاتصال...",
|
reconnecting: "جارٍ إعادة الاتصال...",
|
||||||
|
timelineSyncFailed: "تعذر تحديث سجل الوكيل. جارٍ إعادة المحاولة…",
|
||||||
archivingTitle: "وكيل الارشيف...",
|
archivingTitle: "وكيل الارشيف...",
|
||||||
archivingSubtitle: "الرجاء الانتظار بينما نقوم بأرشفة هذا الوكيل.",
|
archivingSubtitle: "الرجاء الانتظار بينما نقوم بأرشفة هذا الوكيل.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export const en = {
|
|||||||
notFound: "Agent not found",
|
notFound: "Agent not found",
|
||||||
failedToLoad: "Failed to load agent",
|
failedToLoad: "Failed to load agent",
|
||||||
reconnecting: "Reconnecting...",
|
reconnecting: "Reconnecting...",
|
||||||
|
timelineSyncFailed: "Couldn't refresh agent history. Retrying…",
|
||||||
archivingTitle: "Archiving agent...",
|
archivingTitle: "Archiving agent...",
|
||||||
archivingSubtitle: "Please wait while we archive this agent.",
|
archivingSubtitle: "Please wait while we archive this agent.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export const es: TranslationResources = {
|
|||||||
notFound: "Agentno encontrado",
|
notFound: "Agentno encontrado",
|
||||||
failedToLoad: "No se pudo cargar el agente",
|
failedToLoad: "No se pudo cargar el agente",
|
||||||
reconnecting: "Reconectando...",
|
reconnecting: "Reconectando...",
|
||||||
|
timelineSyncFailed: "No se pudo actualizar el historial del agente. Reintentando…",
|
||||||
archivingTitle: "Agente de archivo...",
|
archivingTitle: "Agente de archivo...",
|
||||||
archivingSubtitle: "Espere mientras archivamos este agente.",
|
archivingSubtitle: "Espere mientras archivamos este agente.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ export const fr: TranslationResources = {
|
|||||||
notFound: "Agentintrouvable",
|
notFound: "Agentintrouvable",
|
||||||
failedToLoad: "Échec du chargement de l'agent",
|
failedToLoad: "Échec du chargement de l'agent",
|
||||||
reconnecting: "Reconnexion...",
|
reconnecting: "Reconnexion...",
|
||||||
|
timelineSyncFailed: "Impossible d’actualiser l’historique de l’agent. Nouvelle tentative…",
|
||||||
archivingTitle: "Agent d'archivage...",
|
archivingTitle: "Agent d'archivage...",
|
||||||
archivingSubtitle: "Veuillez patienter pendant que nous archivons cet agent.",
|
archivingSubtitle: "Veuillez patienter pendant que nous archivons cet agent.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export const ja: TranslationResources = {
|
|||||||
notFound: "エージェントが見つかりません",
|
notFound: "エージェントが見つかりません",
|
||||||
failedToLoad: "エージェントの読み込みに失敗しました",
|
failedToLoad: "エージェントの読み込みに失敗しました",
|
||||||
reconnecting: "再接続中...",
|
reconnecting: "再接続中...",
|
||||||
|
timelineSyncFailed: "エージェントの履歴を更新できませんでした。再試行しています…",
|
||||||
archivingTitle: "エージェントをアーカイブ中...",
|
archivingTitle: "エージェントをアーカイブ中...",
|
||||||
archivingSubtitle: "このエージェントをアーカイブするまでお待ちください。",
|
archivingSubtitle: "このエージェントをアーカイブするまでお待ちください。",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export const ptBR: TranslationResources = {
|
|||||||
notFound: "Agente não encontrado",
|
notFound: "Agente não encontrado",
|
||||||
failedToLoad: "Falha ao carregar agente",
|
failedToLoad: "Falha ao carregar agente",
|
||||||
reconnecting: "Reconectando...",
|
reconnecting: "Reconectando...",
|
||||||
|
timelineSyncFailed: "Não foi possível atualizar o histórico do agente. Tentando novamente…",
|
||||||
archivingTitle: "Arquivando agente...",
|
archivingTitle: "Arquivando agente...",
|
||||||
archivingSubtitle: "Aguarde enquanto arquivamos este agente.",
|
archivingSubtitle: "Aguarde enquanto arquivamos este agente.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export const ru: TranslationResources = {
|
|||||||
notFound: "Agent не найден",
|
notFound: "Agent не найден",
|
||||||
failedToLoad: "Не удалось загрузить агент",
|
failedToLoad: "Не удалось загрузить агент",
|
||||||
reconnecting: "Повторное подключение...",
|
reconnecting: "Повторное подключение...",
|
||||||
|
timelineSyncFailed: "Не удалось обновить историю агента. Повторная попытка…",
|
||||||
archivingTitle: "Архивный агент...",
|
archivingTitle: "Архивный агент...",
|
||||||
archivingSubtitle: "Пожалуйста, подождите, пока мы архивируем этого агента.",
|
archivingSubtitle: "Пожалуйста, подождите, пока мы архивируем этого агента.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export const zhCN: TranslationResources = {
|
|||||||
notFound: "未找到 Agent",
|
notFound: "未找到 Agent",
|
||||||
failedToLoad: "加载 Agent 失败",
|
failedToLoad: "加载 Agent 失败",
|
||||||
reconnecting: "正在重连...",
|
reconnecting: "正在重连...",
|
||||||
|
timelineSyncFailed: "无法刷新代理历史记录。正在重试…",
|
||||||
archivingTitle: "正在归档 Agent...",
|
archivingTitle: "正在归档 Agent...",
|
||||||
archivingSubtitle: "请稍候,我们正在归档这个 Agent。",
|
archivingSubtitle: "请稍候,我们正在归档这个 Agent。",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { SquarePen } from "lucide-react-native";
|
import { SquarePen } from "lucide-react-native";
|
||||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, Text, View } from "react-native";
|
import { ActivityIndicator, Text, View } from "react-native";
|
||||||
import ReanimatedAnimated from "react-native-reanimated";
|
import ReanimatedAnimated from "react-native-reanimated";
|
||||||
@@ -14,6 +22,7 @@ import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view
|
|||||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
|
import { SidebarCallout } from "@/components/sidebar-callout";
|
||||||
import { Composer } from "@/composer";
|
import { Composer } from "@/composer";
|
||||||
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
|
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||||
@@ -751,6 +760,26 @@ function ChatAgentContent({
|
|||||||
const agentHistorySyncGeneration = useSessionStore((state) =>
|
const agentHistorySyncGeneration = useSessionStore((state) =>
|
||||||
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
|
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
|
||||||
);
|
);
|
||||||
|
const viewedTimelineSync = useSessionStore(
|
||||||
|
(state) => state.sessions[serverId]?.viewedTimelineSync ?? null,
|
||||||
|
);
|
||||||
|
const subscribeToVisibilityCatchUp = useCallback(
|
||||||
|
(listener: () => void) => viewedTimelineSync?.subscribe(listener) ?? (() => {}),
|
||||||
|
[viewedTimelineSync],
|
||||||
|
);
|
||||||
|
const readTimelineStatus = useCallback(
|
||||||
|
() =>
|
||||||
|
!agentId || !viewedTimelineSync
|
||||||
|
? ("ready" as const)
|
||||||
|
: viewedTimelineSync.getAgentTimelineStatus(agentId),
|
||||||
|
[agentId, viewedTimelineSync],
|
||||||
|
);
|
||||||
|
const timelineStatus = useSyncExternalStore(
|
||||||
|
subscribeToVisibilityCatchUp,
|
||||||
|
readTimelineStatus,
|
||||||
|
readTimelineStatus,
|
||||||
|
);
|
||||||
|
const visibilityCatchUpStatus = isPaneVisible ? timelineStatus : "ready";
|
||||||
const hasActiveCreateHandoff = useCreateFlowStore((state) =>
|
const hasActiveCreateHandoff = useCreateFlowStore((state) =>
|
||||||
findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }),
|
findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }),
|
||||||
);
|
);
|
||||||
@@ -862,6 +891,7 @@ function ChatAgentContent({
|
|||||||
isArchivingCurrentAgent,
|
isArchivingCurrentAgent,
|
||||||
isHistorySyncing,
|
isHistorySyncing,
|
||||||
needsAuthoritativeSync,
|
needsAuthoritativeSync,
|
||||||
|
visibilityCatchUpStatus,
|
||||||
continuity,
|
continuity,
|
||||||
hasHydratedHistoryBefore,
|
hasHydratedHistoryBefore,
|
||||||
},
|
},
|
||||||
@@ -1004,6 +1034,7 @@ function ChatAgentContent({
|
|||||||
viewState.tag === "ready" &&
|
viewState.tag === "ready" &&
|
||||||
viewState.sync.status === "catching_up" &&
|
viewState.sync.status === "catching_up" &&
|
||||||
viewState.sync.ui === "overlay";
|
viewState.sync.ui === "overlay";
|
||||||
|
const showHistorySyncError = viewState.tag === "ready" && viewState.sync.status === "sync_error";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatAgentReadyContent
|
<ChatAgentReadyContent
|
||||||
@@ -1023,6 +1054,7 @@ function ChatAgentContent({
|
|||||||
handleComposerHeightChange={handleComposerHeightChange}
|
handleComposerHeightChange={handleComposerHeightChange}
|
||||||
handleMessageSent={handleMessageSent}
|
handleMessageSent={handleMessageSent}
|
||||||
showHistorySyncOverlay={showHistorySyncOverlay}
|
showHistorySyncOverlay={showHistorySyncOverlay}
|
||||||
|
showHistorySyncError={showHistorySyncError}
|
||||||
cwd={agentCwd}
|
cwd={agentCwd}
|
||||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||||
@@ -1048,6 +1080,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
handleComposerHeightChange,
|
handleComposerHeightChange,
|
||||||
handleMessageSent,
|
handleMessageSent,
|
||||||
showHistorySyncOverlay,
|
showHistorySyncOverlay,
|
||||||
|
showHistorySyncError,
|
||||||
cwd,
|
cwd,
|
||||||
onAttentionInputFocus,
|
onAttentionInputFocus,
|
||||||
onAttentionPromptSend,
|
onAttentionPromptSend,
|
||||||
@@ -1069,6 +1102,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
handleComposerHeightChange: (height: number) => void;
|
handleComposerHeightChange: (height: number) => void;
|
||||||
handleMessageSent: () => void;
|
handleMessageSent: () => void;
|
||||||
showHistorySyncOverlay: boolean;
|
showHistorySyncOverlay: boolean;
|
||||||
|
showHistorySyncError: boolean;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
onAttentionInputFocus: () => void;
|
onAttentionInputFocus: () => void;
|
||||||
onAttentionPromptSend: () => void;
|
onAttentionPromptSend: () => void;
|
||||||
@@ -1140,6 +1174,14 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
<FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}>
|
<FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}>
|
||||||
{contentContainer}
|
{contentContainer}
|
||||||
|
|
||||||
|
{showHistorySyncError ? (
|
||||||
|
<SidebarCallout
|
||||||
|
title={t("agentPanel.states.timelineSyncFailed")}
|
||||||
|
variant="error"
|
||||||
|
testID="agent-timeline-sync-error"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{composerSection}
|
{composerSection}
|
||||||
|
|
||||||
{showHistorySyncOverlay ? (
|
{showHistorySyncOverlay ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -2027,7 +2028,7 @@ function WorkspaceScreenContent({
|
|||||||
}),
|
}),
|
||||||
[isFocusModeEnabled, isMobile, isRouteFocused, uiTabs, workspaceLayout],
|
[isFocusModeEnabled, isMobile, isRouteFocused, uiTabs, workspaceLayout],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!persistenceKey || !viewedTimelineSync) {
|
if (!persistenceKey || !viewedTimelineSync) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
|
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
|
||||||
import {
|
import {
|
||||||
createViewedTimelineSync,
|
createViewedTimelineSync,
|
||||||
@@ -191,6 +191,7 @@ test("unchanged visible-set publication does not cancel paged catch-up", async (
|
|||||||
const world = new TimelineWorld();
|
const world = new TimelineWorld();
|
||||||
world.sync.setConnected(true);
|
world.sync.setConnected(true);
|
||||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||||
|
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
|
||||||
const membership = await world.nextMembership();
|
const membership = await world.nextMembership();
|
||||||
membership.succeed();
|
membership.succeed();
|
||||||
const firstPage = await world.nextFetch("agent-a");
|
const firstPage = await world.nextFetch("agent-a");
|
||||||
@@ -198,8 +199,13 @@ test("unchanged visible-set publication does not cancel paged catch-up", async (
|
|||||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-a"]);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-a"]);
|
||||||
firstPage.respond({ hasNewer: true, seq: 5 });
|
firstPage.respond({ hasNewer: true, seq: 5 });
|
||||||
const secondPage = await world.nextFetch("agent-a");
|
const secondPage = await world.nextFetch("agent-a");
|
||||||
|
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
|
||||||
secondPage.respond({ hasNewer: false });
|
secondPage.respond({ hasNewer: false });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
expect(secondPage.request).toEqual({
|
expect(secondPage.request).toEqual({
|
||||||
direction: "after",
|
direction: "after",
|
||||||
cursor: { epoch: "epoch-agent-a", seq: 5 },
|
cursor: { epoch: "epoch-agent-a", seq: 5 },
|
||||||
@@ -280,6 +286,7 @@ test("disconnect cancels paging and reconnect restores membership before fresh c
|
|||||||
const stalePage = await world.nextFetch("agent-a");
|
const stalePage = await world.nextFetch("agent-a");
|
||||||
|
|
||||||
world.sync.setConnected(false);
|
world.sync.setConnected(false);
|
||||||
|
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
|
||||||
stalePage.respond({ hasNewer: true, seq: 8 });
|
stalePage.respond({ hasNewer: true, seq: 8 });
|
||||||
world.sync.setConnected(true);
|
world.sync.setConnected(true);
|
||||||
const restoredMembership = await world.nextMembership();
|
const restoredMembership = await world.nextMembership();
|
||||||
@@ -287,6 +294,8 @@ test("disconnect cancels paging and reconnect restores membership before fresh c
|
|||||||
const restoredPage = await world.nextFetch("agent-a");
|
const restoredPage = await world.nextFetch("agent-a");
|
||||||
restoredPage.respond({ hasNewer: false });
|
restoredPage.respond({ hasNewer: false });
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||||
|
|
||||||
expect(restoredMembership.agentIds).toEqual(["agent-a"]);
|
expect(restoredMembership.agentIds).toEqual(["agent-a"]);
|
||||||
world.expectNoPendingFetch();
|
world.expectNoPendingFetch();
|
||||||
});
|
});
|
||||||
@@ -328,10 +337,13 @@ test("a failed catch-up reports once and retries through the explicit retry poli
|
|||||||
const failed = await world.nextFetch("agent-a");
|
const failed = await world.nextFetch("agent-a");
|
||||||
failed.fail("timeline unavailable");
|
failed.fail("timeline unavailable");
|
||||||
const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]);
|
const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]);
|
||||||
|
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||||
|
|
||||||
retryCatchUp();
|
retryCatchUp();
|
||||||
const retry = await world.nextFetch("agent-a");
|
const retry = await world.nextFetch("agent-a");
|
||||||
|
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||||
retry.respond({ hasNewer: false });
|
retry.respond({ hasNewer: false });
|
||||||
|
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||||
|
|
||||||
expect({ error, retryDirection: retry.request.direction }).toEqual({
|
expect({ error, retryDirection: retry.request.direction }).toEqual({
|
||||||
error: "timeline unavailable",
|
error: "timeline unavailable",
|
||||||
@@ -396,12 +408,15 @@ test("membership failure autonomously retries without another visibility declara
|
|||||||
const failed = await world.nextMembership();
|
const failed = await world.nextMembership();
|
||||||
failed.fail("subscription unavailable");
|
failed.fail("subscription unavailable");
|
||||||
const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]);
|
const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]);
|
||||||
|
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||||
|
|
||||||
retryMembership();
|
retryMembership();
|
||||||
const retry = await world.nextMembership();
|
const retry = await world.nextMembership();
|
||||||
retry.succeed();
|
retry.succeed();
|
||||||
const catchUp = await world.nextFetch("agent-a");
|
const catchUp = await world.nextFetch("agent-a");
|
||||||
|
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||||
catchUp.respond({ hasNewer: false });
|
catchUp.respond({ hasNewer: false });
|
||||||
|
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||||
|
|
||||||
expect({ error, failed: failed.agentIds, retry: retry.agentIds }).toEqual({
|
expect({ error, failed: failed.agentIds, retry: retry.agentIds }).toEqual({
|
||||||
error: "subscription unavailable",
|
error: "subscription unavailable",
|
||||||
@@ -410,7 +425,7 @@ test("membership failure autonomously retries without another visibility declara
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("background sends an empty set and foreground restores visible membership", async () => {
|
test("background waits for grace before unsubscribing and catches up on return", async () => {
|
||||||
const world = new TimelineWorld();
|
const world = new TimelineWorld();
|
||||||
world.sync.setConnected(true);
|
world.sync.setConnected(true);
|
||||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||||
@@ -420,6 +435,8 @@ test("background sends an empty set and foreground restores visible membership",
|
|||||||
initialCatchUp.respond({ hasNewer: false });
|
initialCatchUp.respond({ hasNewer: false });
|
||||||
|
|
||||||
world.sync.setActive(false);
|
world.sync.setActive(false);
|
||||||
|
world.expectNoPendingMembership();
|
||||||
|
world.runUnsubscribeGrace();
|
||||||
const background = await world.nextMembership();
|
const background = await world.nextMembership();
|
||||||
background.succeed();
|
background.succeed();
|
||||||
world.sync.setActive(true);
|
world.sync.setActive(true);
|
||||||
@@ -434,6 +451,24 @@ test("background sends an empty set and foreground restores visible membership",
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("foregrounding within grace preserves the live membership", async () => {
|
||||||
|
const world = new TimelineWorld();
|
||||||
|
world.sync.setConnected(true);
|
||||||
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||||
|
const membership = await world.nextMembership();
|
||||||
|
membership.succeed();
|
||||||
|
const catchUp = await world.nextFetch("agent-a");
|
||||||
|
catchUp.respond({ hasNewer: false });
|
||||||
|
|
||||||
|
world.sync.setActive(false);
|
||||||
|
world.expectNoPendingMembership();
|
||||||
|
world.sync.setActive(true);
|
||||||
|
|
||||||
|
world.expectNoPendingUnsubscribe();
|
||||||
|
world.expectNoPendingMembership();
|
||||||
|
world.expectNoPendingFetch();
|
||||||
|
});
|
||||||
|
|
||||||
test("stale membership retry cannot overwrite a newer effective set", async () => {
|
test("stale membership retry cannot overwrite a newer effective set", async () => {
|
||||||
const world = new TimelineWorld();
|
const world = new TimelineWorld();
|
||||||
world.sync.setConnected(true);
|
world.sync.setConnected(true);
|
||||||
@@ -483,10 +518,16 @@ test("quickly returning to an agent cancels its pending unsubscribe without anot
|
|||||||
const initialCatchUp = await world.nextFetch("agent-a");
|
const initialCatchUp = await world.nextFetch("agent-a");
|
||||||
initialCatchUp.respond({ hasNewer: false });
|
initialCatchUp.respond({ hasNewer: false });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
world.sync.replaceVisibleAgentIds("workspace", []);
|
||||||
world.expectNoPendingMembership();
|
world.expectNoPendingMembership();
|
||||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||||
|
|
||||||
|
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
|
||||||
|
|
||||||
world.expectNoPendingUnsubscribe();
|
world.expectNoPendingUnsubscribe();
|
||||||
world.expectNoPendingMembership();
|
world.expectNoPendingMembership();
|
||||||
world.expectNoPendingFetch();
|
world.expectNoPendingFetch();
|
||||||
@@ -500,6 +541,12 @@ test("unsubscribe grace expiry removes the agent exactly once", async () => {
|
|||||||
membership.succeed();
|
membership.succeed();
|
||||||
const catchUp = await world.nextFetch("agent-a");
|
const catchUp = await world.nextFetch("agent-a");
|
||||||
catchUp.respond({ hasNewer: false });
|
catchUp.respond({ hasNewer: false });
|
||||||
|
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||||
|
|
||||||
|
const readinessChanges: string[] = [];
|
||||||
|
world.sync.subscribe(() => {
|
||||||
|
readinessChanges.push(world.sync.getAgentTimelineStatus("agent-a"));
|
||||||
|
});
|
||||||
|
|
||||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
world.sync.replaceVisibleAgentIds("workspace", []);
|
||||||
world.runUnsubscribeGrace();
|
world.runUnsubscribeGrace();
|
||||||
@@ -507,6 +554,7 @@ test("unsubscribe grace expiry removes the agent exactly once", async () => {
|
|||||||
unsubscribe.succeed();
|
unsubscribe.succeed();
|
||||||
|
|
||||||
expect(unsubscribe.agentIds).toEqual([]);
|
expect(unsubscribe.agentIds).toEqual([]);
|
||||||
|
expect(readinessChanges.at(-1)).toBe("pending");
|
||||||
world.expectNoPendingUnsubscribe();
|
world.expectNoPendingUnsubscribe();
|
||||||
world.expectNoPendingMembership();
|
world.expectNoPendingMembership();
|
||||||
});
|
});
|
||||||
@@ -533,7 +581,7 @@ test("a new visible agent subscribes immediately while the previous agent linger
|
|||||||
expect(settledMembership.agentIds).toEqual(["agent-b"]);
|
expect(settledMembership.agentIds).toEqual(["agent-b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("backgrounding clears pending unsubscribe grace and membership immediately", async () => {
|
test("backgrounding preserves an existing unsubscribe grace period", async () => {
|
||||||
const world = new TimelineWorld();
|
const world = new TimelineWorld();
|
||||||
world.sync.setConnected(true);
|
world.sync.setConnected(true);
|
||||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||||
@@ -544,25 +592,37 @@ test("backgrounding clears pending unsubscribe grace and membership immediately"
|
|||||||
|
|
||||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
world.sync.replaceVisibleAgentIds("workspace", []);
|
||||||
world.sync.setActive(false);
|
world.sync.setActive(false);
|
||||||
|
world.expectNoPendingMembership();
|
||||||
|
world.runUnsubscribeGrace();
|
||||||
const unsubscribe = await world.nextMembership();
|
const unsubscribe = await world.nextMembership();
|
||||||
unsubscribe.succeed();
|
unsubscribe.succeed();
|
||||||
|
|
||||||
expect(unsubscribe.agentIds).toEqual([]);
|
expect(unsubscribe.agentIds).toEqual([]);
|
||||||
world.expectNoPendingUnsubscribe();
|
world.expectNoPendingMembership();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("disconnecting cancels pending unsubscribe grace without publishing on the closed socket", async () => {
|
test("disconnecting cancels pending unsubscribe grace without publishing on the closed socket", async () => {
|
||||||
const world = new TimelineWorld();
|
const world = new TimelineWorld();
|
||||||
world.sync.setConnected(true);
|
world.sync.setConnected(true);
|
||||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-b"]);
|
||||||
const membership = await world.nextMembership();
|
const membership = await world.nextMembership();
|
||||||
membership.succeed();
|
membership.succeed();
|
||||||
const catchUp = await world.nextFetch("agent-a");
|
const [agentA, agentB] = await Promise.all([
|
||||||
catchUp.respond({ hasNewer: false });
|
world.nextFetch("agent-a"),
|
||||||
|
world.nextFetch("agent-b"),
|
||||||
|
]);
|
||||||
|
agentA.respond({ hasNewer: false });
|
||||||
|
agentB.respond({ hasNewer: false });
|
||||||
|
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||||
|
|
||||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||||
|
const agentAStatuses: string[] = [];
|
||||||
|
world.sync.subscribe(() => {
|
||||||
|
agentAStatuses.push(world.sync.getAgentTimelineStatus("agent-a"));
|
||||||
|
});
|
||||||
world.sync.setConnected(false);
|
world.sync.setConnected(false);
|
||||||
|
|
||||||
|
expect(agentAStatuses).toEqual(["pending"]);
|
||||||
world.expectNoPendingUnsubscribe();
|
world.expectNoPendingUnsubscribe();
|
||||||
world.expectNoPendingMembership();
|
world.expectNoPendingMembership();
|
||||||
});
|
});
|
||||||
@@ -612,13 +672,17 @@ test("switching from legacy to selective delivery publishes membership and catch
|
|||||||
world.sync.setConnected(true);
|
world.sync.setConnected(true);
|
||||||
const legacyCatchUp = await world.nextFetch("agent-a");
|
const legacyCatchUp = await world.nextFetch("agent-a");
|
||||||
legacyCatchUp.respond({ hasNewer: false });
|
legacyCatchUp.respond({ hasNewer: false });
|
||||||
|
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||||
|
|
||||||
world.sync.setDeliveryMode("selective");
|
world.sync.setDeliveryMode("selective");
|
||||||
|
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
|
||||||
const membership = await world.nextMembership();
|
const membership = await world.nextMembership();
|
||||||
membership.succeed();
|
membership.succeed();
|
||||||
const catchUp = await world.nextFetch("agent-a");
|
const catchUp = await world.nextFetch("agent-a");
|
||||||
catchUp.respond({ hasNewer: false });
|
catchUp.respond({ hasNewer: false });
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||||
|
|
||||||
expect(membership.agentIds).toEqual(["agent-a"]);
|
expect(membership.agentIds).toEqual(["agent-a"]);
|
||||||
world.expectNoPendingMembership();
|
world.expectNoPendingMembership();
|
||||||
world.expectNoPendingFetch();
|
world.expectNoPendingFetch();
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ interface ViewedTimelineSyncPorts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TimelineDeliveryMode = "legacy" | "selective";
|
export type TimelineDeliveryMode = "legacy" | "selective";
|
||||||
|
export type ViewedTimelineStatus = "ready" | "pending" | "error";
|
||||||
|
|
||||||
export interface ViewedTimelineUiBridge {
|
export interface ViewedTimelineUiBridge {
|
||||||
replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void;
|
replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void;
|
||||||
|
subscribe(listener: () => void): () => void;
|
||||||
|
getAgentTimelineStatus(agentId: string): ViewedTimelineStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||||
@@ -88,6 +91,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
const catchUpGenerations = new Map<string, number>();
|
const catchUpGenerations = new Map<string, number>();
|
||||||
const pendingGaps = new Map<string, ProjectedTimelineForwardFetchPlan>();
|
const pendingGaps = new Map<string, ProjectedTimelineForwardFetchPlan>();
|
||||||
const lingeringRemovals = new Map<string, () => void>();
|
const lingeringRemovals = new Map<string, () => void>();
|
||||||
|
const visibilityCatchUpPending = new Set<string>();
|
||||||
|
const visibilityCatchUpErrors = new Set<string>();
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
let active = true;
|
let active = true;
|
||||||
let connected = false;
|
let connected = false;
|
||||||
let deliveryMode = ports.initialDeliveryMode;
|
let deliveryMode = ports.initialDeliveryMode;
|
||||||
@@ -107,6 +113,26 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
const isAcknowledged = (agentId: string) => acknowledged.includes(agentId);
|
const isAcknowledged = (agentId: string) => acknowledged.includes(agentId);
|
||||||
const isDesired = (agentId: string) => desired.includes(agentId);
|
const isDesired = (agentId: string) => desired.includes(agentId);
|
||||||
|
|
||||||
|
const notifyListeners = () => {
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVisibilityCatchUpReady = (agentId: string) => {
|
||||||
|
const wasPending = visibilityCatchUpPending.delete(agentId);
|
||||||
|
const hadError = visibilityCatchUpErrors.delete(agentId);
|
||||||
|
if (wasPending || hadError) notifyListeners();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVisibilityCatchUpError = (agentIds: string[]) => {
|
||||||
|
let changed = false;
|
||||||
|
for (const agentId of agentIds) {
|
||||||
|
if (!visibilityCatchUpPending.delete(agentId)) continue;
|
||||||
|
visibilityCatchUpErrors.add(agentId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) notifyListeners();
|
||||||
|
};
|
||||||
|
|
||||||
const cancelCatchUp = (agentId: string) => {
|
const cancelCatchUp = (agentId: string) => {
|
||||||
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
|
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
|
||||||
catchUps.get(agentId)?.cancelRetry?.();
|
catchUps.get(agentId)?.cancelRetry?.();
|
||||||
@@ -148,6 +174,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`);
|
throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`);
|
||||||
}
|
}
|
||||||
catchUps.set(agentId, { generation, status: "complete" });
|
catchUps.set(agentId, { generation, status: "complete" });
|
||||||
|
setVisibilityCatchUpReady(agentId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (catchUps.get(agentId)?.generation === generation) {
|
if (catchUps.get(agentId)?.generation === generation) {
|
||||||
const cancelRetry = ports.schedule(() => {
|
const cancelRetry = ports.schedule(() => {
|
||||||
@@ -156,6 +183,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
startCatchUp(agentId);
|
startCatchUp(agentId);
|
||||||
}, RETRY_DELAY_MS);
|
}, RETRY_DELAY_MS);
|
||||||
catchUps.set(agentId, { generation, status: "error", cancelRetry });
|
catchUps.set(agentId, { generation, status: "error", cancelRetry });
|
||||||
|
setVisibilityCatchUpError([agentId]);
|
||||||
ports.reportError(error);
|
ports.reportError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,6 +236,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
await ports.setSubscription(requested);
|
await ports.setSubscription(requested);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
membershipNeedsRetry = true;
|
membershipNeedsRetry = true;
|
||||||
|
setVisibilityCatchUpError(requested);
|
||||||
cancelMembershipRetry?.();
|
cancelMembershipRetry?.();
|
||||||
cancelMembershipRetry = ports.schedule(() => {
|
cancelMembershipRetry = ports.schedule(() => {
|
||||||
cancelMembershipRetry = null;
|
cancelMembershipRetry = null;
|
||||||
@@ -268,20 +297,45 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitDesiredMembership = (nextDesired: string[]) => {
|
const commitDesiredMembership = (
|
||||||
|
nextDesired: string[],
|
||||||
|
options: { resetCatchUpStatus?: boolean } = {},
|
||||||
|
) => {
|
||||||
|
let statusChanged = false;
|
||||||
|
if (options.resetCatchUpStatus) {
|
||||||
|
for (const agentId of nextDesired) {
|
||||||
|
if (!visibilityCatchUpPending.has(agentId)) {
|
||||||
|
visibilityCatchUpPending.add(agentId);
|
||||||
|
statusChanged = true;
|
||||||
|
}
|
||||||
|
if (visibilityCatchUpErrors.delete(agentId)) statusChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (sameAgentIds(nextDesired, desired)) {
|
if (sameAgentIds(nextDesired, desired)) {
|
||||||
|
if (statusChanged) notifyListeners();
|
||||||
if (deliveryMode === "selective" && membershipNeedsRetry) void reconcileMembership();
|
if (deliveryMode === "selective" && membershipNeedsRetry) void reconcileMembership();
|
||||||
retryFailedCatchUps();
|
retryFailedCatchUps();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const agentId of desired) {
|
for (const agentId of desired) {
|
||||||
if (!nextDesired.includes(agentId)) cancelCatchUp(agentId);
|
if (!nextDesired.includes(agentId)) {
|
||||||
|
cancelCatchUp(agentId);
|
||||||
|
visibilityCatchUpPending.delete(agentId);
|
||||||
|
visibilityCatchUpErrors.delete(agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const agentId of nextDesired) {
|
||||||
|
if (!desired.includes(agentId)) {
|
||||||
|
visibilityCatchUpPending.add(agentId);
|
||||||
|
visibilityCatchUpErrors.delete(agentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cancelMembershipRetry?.();
|
cancelMembershipRetry?.();
|
||||||
cancelMembershipRetry = null;
|
cancelMembershipRetry = null;
|
||||||
desired = nextDesired;
|
desired = nextDesired;
|
||||||
membershipGeneration += 1;
|
membershipGeneration += 1;
|
||||||
|
notifyListeners();
|
||||||
if (deliveryMode === "legacy") {
|
if (deliveryMode === "legacy") {
|
||||||
acknowledged = connected ? desired : [];
|
acknowledged = connected ? desired : [];
|
||||||
if (connected) startAcknowledgedCatchUps();
|
if (connected) startAcknowledgedCatchUps();
|
||||||
@@ -302,7 +356,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
lingeringRemovals.delete(agentId);
|
lingeringRemovals.delete(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowGrace && connected && active && deliveryMode === "selective") {
|
if (allowGrace && connected && deliveryMode === "selective") {
|
||||||
for (const agentId of desired) {
|
for (const agentId of desired) {
|
||||||
if (visible.includes(agentId) || lingeringRemovals.has(agentId)) continue;
|
if (visible.includes(agentId) || lingeringRemovals.has(agentId)) continue;
|
||||||
const cancel = ports.schedule(() => {
|
const cancel = ports.schedule(() => {
|
||||||
@@ -319,6 +373,15 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
getAgentTimelineStatus(agentId) {
|
||||||
|
if (visibilityCatchUpErrors.has(agentId)) return "error";
|
||||||
|
if (!isDesired(agentId) || visibilityCatchUpPending.has(agentId)) return "pending";
|
||||||
|
return "ready";
|
||||||
|
},
|
||||||
replaceVisibleAgentIds(sourceId, agentIds) {
|
replaceVisibleAgentIds(sourceId, agentIds) {
|
||||||
const normalized = normalizeAgentIds(agentIds);
|
const normalized = normalizeAgentIds(agentIds);
|
||||||
if (normalized.length === 0) sources.delete(sourceId);
|
if (normalized.length === 0) sources.delete(sourceId);
|
||||||
@@ -328,14 +391,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
setActive(nextActive) {
|
setActive(nextActive) {
|
||||||
if (active === nextActive) return;
|
if (active === nextActive) return;
|
||||||
active = nextActive;
|
active = nextActive;
|
||||||
publishVisibleMembership(false);
|
publishVisibleMembership(true);
|
||||||
},
|
},
|
||||||
setConnected(nextConnected) {
|
setConnected(nextConnected) {
|
||||||
if (connected === nextConnected) return;
|
if (connected === nextConnected) return;
|
||||||
connected = nextConnected;
|
connected = nextConnected;
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
clearLingeringRemovals();
|
clearLingeringRemovals();
|
||||||
commitDesiredMembership(visibleAgentIds());
|
commitDesiredMembership(visibleAgentIds(), { resetCatchUpStatus: true });
|
||||||
cancelMembershipRetry?.();
|
cancelMembershipRetry?.();
|
||||||
cancelMembershipRetry = null;
|
cancelMembershipRetry = null;
|
||||||
acknowledged = [];
|
acknowledged = [];
|
||||||
@@ -361,7 +424,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
membershipGeneration += 1;
|
membershipGeneration += 1;
|
||||||
for (const agentId of desired) cancelCatchUp(agentId);
|
for (const agentId of desired) cancelCatchUp(agentId);
|
||||||
desired = visibleAgentIds();
|
desired = visibleAgentIds();
|
||||||
|
visibilityCatchUpPending.clear();
|
||||||
|
visibilityCatchUpErrors.clear();
|
||||||
|
for (const agentId of desired) visibilityCatchUpPending.add(agentId);
|
||||||
acknowledged = deliveryMode === "legacy" && connected ? desired : [];
|
acknowledged = deliveryMode === "legacy" && connected ? desired : [];
|
||||||
|
notifyListeners();
|
||||||
if (deliveryMode === "selective" && connected) void reconcileMembership();
|
if (deliveryMode === "selective" && connected) void reconcileMembership();
|
||||||
else if (connected) startAcknowledgedCatchUps();
|
else if (connected) startAcknowledgedCatchUps();
|
||||||
},
|
},
|
||||||
@@ -382,6 +449,10 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
|||||||
for (const agentId of desired) cancelCatchUp(agentId);
|
for (const agentId of desired) cancelCatchUp(agentId);
|
||||||
desired = [];
|
desired = [];
|
||||||
acknowledged = [];
|
acknowledged = [];
|
||||||
|
visibilityCatchUpPending.clear();
|
||||||
|
visibilityCatchUpErrors.clear();
|
||||||
|
notifyListeners();
|
||||||
|
listeners.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
26
packages/app/src/utils/app-visibility.test.ts
Normal file
26
packages/app/src/utils/app-visibility.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { isAppActivelyVisible, isAppVisible } from "./app-visibility";
|
||||||
|
|
||||||
|
test("a visible desktop app remains visible when another window has focus", () => {
|
||||||
|
const input = {
|
||||||
|
appState: "active",
|
||||||
|
native: false,
|
||||||
|
documentVisible: true,
|
||||||
|
windowFocused: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isAppVisible(input)).toBe(true);
|
||||||
|
expect(isAppActivelyVisible(input)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a hidden desktop page is neither visible nor actively visible", () => {
|
||||||
|
const input = {
|
||||||
|
appState: "active",
|
||||||
|
native: false,
|
||||||
|
documentVisible: false,
|
||||||
|
windowFocused: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isAppVisible(input)).toBe(false);
|
||||||
|
expect(isAppActivelyVisible(input)).toBe(false);
|
||||||
|
});
|
||||||
@@ -1,20 +1,49 @@
|
|||||||
import { AppState } from "react-native";
|
import { AppState } from "react-native";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean {
|
interface AppVisibilityInput {
|
||||||
if (appState !== "active") {
|
appState: string;
|
||||||
return false;
|
native: boolean;
|
||||||
}
|
documentVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
if (isNative) {
|
interface ActiveAppVisibilityInput extends AppVisibilityInput {
|
||||||
return true;
|
windowFocused: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentVisible = typeof document === "undefined" || document.visibilityState === "visible";
|
export function isAppVisible(input: AppVisibilityInput): boolean {
|
||||||
const windowFocused =
|
return input.appState === "active" && (input.native || input.documentVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAppActivelyVisible(input: ActiveAppVisibilityInput): boolean {
|
||||||
|
return isAppVisible(input) && (input.native || input.windowFocused);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDocumentVisible(): boolean {
|
||||||
|
return typeof document === "undefined" || document.visibilityState === "visible";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowFocused(): boolean {
|
||||||
|
return (
|
||||||
typeof document === "undefined" ||
|
typeof document === "undefined" ||
|
||||||
typeof document.hasFocus !== "function" ||
|
typeof document.hasFocus !== "function" ||
|
||||||
document.hasFocus();
|
document.hasFocus()
|
||||||
|
);
|
||||||
return documentVisible && windowFocused;
|
}
|
||||||
|
|
||||||
|
export function getIsAppVisible(appState: string = AppState.currentState): boolean {
|
||||||
|
return isAppVisible({
|
||||||
|
appState,
|
||||||
|
native: isNative,
|
||||||
|
documentVisible: getDocumentVisible(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean {
|
||||||
|
return isAppActivelyVisible({
|
||||||
|
appState,
|
||||||
|
native: isNative,
|
||||||
|
documentVisible: getDocumentVisible(),
|
||||||
|
windowFocused: getWindowFocused(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user