mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +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`:
|
||||
|
||||
- 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
|
||||
switches do not repeatedly unsubscribe and catch up. Backgrounding, disconnecting, and disposal
|
||||
clear that grace immediately.
|
||||
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
|
||||
subscribed for a short grace period so brief tab, pane, route, and app switches do not repeatedly
|
||||
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
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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 { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
observeLastAssistantFrames,
|
||||
observeTimelineSubscriptions,
|
||||
} from "./helpers/timeline-delivery";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import {
|
||||
@@ -71,6 +75,42 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string,
|
||||
}
|
||||
|
||||
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 }) => {
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
|
||||
@@ -27,7 +27,7 @@ import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
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";
|
||||
|
||||
interface CodeLineProps {
|
||||
@@ -386,10 +386,10 @@ export function FilePane({
|
||||
);
|
||||
|
||||
// Re-read the file when this pane becomes visible again (#445). `isActive`
|
||||
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
|
||||
// gate itself lives in isFileQueryEnabled.
|
||||
// covers tab switches; active app visibility covers backgrounding and returning
|
||||
// from another window after an external edit. The gate lives in isFileQueryEnabled.
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isAppVisible = useAppVisible();
|
||||
const isAppVisible = useAppActivelyVisible();
|
||||
|
||||
const query = useQuery({
|
||||
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", () => {
|
||||
const withoutClient = canRequestFocusClaim({
|
||||
isWorkspaceFocused: true,
|
||||
isAppVisible: true,
|
||||
isAppActivelyVisible: true,
|
||||
isClientReady: false,
|
||||
isConnected: true,
|
||||
isRendererReady: true,
|
||||
});
|
||||
const withoutRenderer = canRequestFocusClaim({
|
||||
isWorkspaceFocused: true,
|
||||
isAppVisible: true,
|
||||
isAppActivelyVisible: true,
|
||||
isClientReady: true,
|
||||
isConnected: true,
|
||||
isRendererReady: false,
|
||||
@@ -37,7 +37,7 @@ describe("terminal pane focus claim", () => {
|
||||
it("does not deliver a requested claim after the host disconnects", () => {
|
||||
const disconnected = canRequestFocusClaim({
|
||||
isWorkspaceFocused: true,
|
||||
isAppVisible: true,
|
||||
isAppActivelyVisible: true,
|
||||
isClientReady: true,
|
||||
isConnected: false,
|
||||
isRendererReady: true,
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface FocusClaimStep {
|
||||
|
||||
interface FocusClaimReadiness {
|
||||
isWorkspaceFocused: boolean;
|
||||
isAppVisible: boolean;
|
||||
isAppActivelyVisible: boolean;
|
||||
isClientReady: boolean;
|
||||
isConnected: boolean;
|
||||
isRendererReady: boolean;
|
||||
@@ -24,7 +24,7 @@ export const EMPTY_FOCUS_CLAIM_STATE: FocusClaimState = {
|
||||
export function canRequestFocusClaim(input: FocusClaimReadiness): boolean {
|
||||
return (
|
||||
input.isWorkspaceFocused &&
|
||||
input.isAppVisible &&
|
||||
input.isAppActivelyVisible &&
|
||||
input.isClientReady &&
|
||||
input.isConnected &&
|
||||
input.isRendererReady
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-m
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
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 {
|
||||
hasPendingTerminalModifiers,
|
||||
@@ -177,7 +177,7 @@ export function TerminalPane({
|
||||
onOpenWorkspaceFile,
|
||||
}: TerminalPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const isAppVisible = useAppVisible();
|
||||
const isAppActivelyVisible = useAppActivelyVisible();
|
||||
const { theme } = useUnistyles();
|
||||
const { settings } = useAppSettings();
|
||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
|
||||
@@ -283,7 +283,7 @@ export function TerminalPane({
|
||||
useEffect(() => {
|
||||
const canRequest = canRequestFocusClaim({
|
||||
isWorkspaceFocused,
|
||||
isAppVisible,
|
||||
isAppActivelyVisible,
|
||||
isClientReady: client !== null,
|
||||
isConnected,
|
||||
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
|
||||
@@ -299,7 +299,7 @@ export function TerminalPane({
|
||||
}
|
||||
}, [
|
||||
client,
|
||||
isAppVisible,
|
||||
isAppActivelyVisible,
|
||||
isConnected,
|
||||
isPaneFocused,
|
||||
isWorkspaceFocused,
|
||||
@@ -654,7 +654,7 @@ export function TerminalPane({
|
||||
let sent = false;
|
||||
const canSend = canRequestFocusClaim({
|
||||
isWorkspaceFocused,
|
||||
isAppVisible,
|
||||
isAppActivelyVisible,
|
||||
isClientReady: client !== null,
|
||||
isConnected,
|
||||
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 { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
|
||||
import {
|
||||
getInitKey,
|
||||
getInitDeferred,
|
||||
@@ -773,7 +773,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
});
|
||||
viewedTimelineSyncRef.current = sync;
|
||||
setViewedTimelineSync(serverId, sync);
|
||||
sync.setActive(getIsAppActivelyVisible(appStateRef.current));
|
||||
sync.setActive(getIsAppVisible(appStateRef.current));
|
||||
|
||||
return () => {
|
||||
if (viewedTimelineSyncRef.current === sync) {
|
||||
|
||||
@@ -63,6 +63,7 @@ function createBaseInput(): AgentScreenMachineInput {
|
||||
isArchivingCurrentAgent: false,
|
||||
isHistorySyncing: false,
|
||||
needsAuthoritativeSync: false,
|
||||
visibilityCatchUpStatus: "ready",
|
||||
hasHydratedHistoryBefore: false,
|
||||
};
|
||||
}
|
||||
@@ -189,6 +190,43 @@ describe("deriveAgentScreenViewState", () => {
|
||||
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", () => {
|
||||
const memory = createBaseMemory({
|
||||
hasRenderedReady: true,
|
||||
@@ -328,7 +366,7 @@ describe("deriveAgentScreenViewState", () => {
|
||||
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 input: AgentScreenMachineInput = {
|
||||
...createBaseInput(),
|
||||
@@ -336,6 +374,7 @@ describe("deriveAgentScreenViewState", () => {
|
||||
continuity: { kind: "optimistic-create", agent: createAgent("agent-1") },
|
||||
needsAuthoritativeSync: true,
|
||||
isHistorySyncing: true,
|
||||
visibilityCatchUpStatus: "pending",
|
||||
hasHydratedHistoryBefore: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface AgentScreenMachineInput {
|
||||
isArchivingCurrentAgent: boolean;
|
||||
isHistorySyncing: boolean;
|
||||
needsAuthoritativeSync: boolean;
|
||||
visibilityCatchUpStatus: "ready" | "pending" | "error";
|
||||
continuity: AgentScreenContinuity;
|
||||
hasHydratedHistoryBefore: boolean;
|
||||
}
|
||||
@@ -147,10 +148,12 @@ function resolveAgentScreenSource(args: {
|
||||
|
||||
function resolveCatchingUpUi(args: {
|
||||
hasOptimisticCreateContinuity: boolean;
|
||||
isVisibilityCatchUpPending: boolean;
|
||||
hasHydratedHistoryBefore: boolean;
|
||||
hadInitialSyncFailure: boolean;
|
||||
}): "overlay" | "silent" {
|
||||
if (args.hasOptimisticCreateContinuity) return "silent";
|
||||
if (args.isVisibilityCatchUpPending) return "overlay";
|
||||
if (args.hasHydratedHistoryBefore) return "silent";
|
||||
if (args.hadInitialSyncFailure) return "silent";
|
||||
return "overlay";
|
||||
@@ -167,11 +170,19 @@ function resolveAgentScreenSync(args: {
|
||||
if (input.missingAgentState.kind === "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 {
|
||||
status: "catching_up",
|
||||
ui: resolveCatchingUpUi({
|
||||
hasOptimisticCreateContinuity: hasOptimisticCreateContinuity(input),
|
||||
isVisibilityCatchUpPending: input.visibilityCatchUpStatus === "pending",
|
||||
hasHydratedHistoryBefore: input.hasHydratedHistoryBefore,
|
||||
hadInitialSyncFailure,
|
||||
}),
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
|
||||
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
let current = getIsAppActivelyVisible();
|
||||
const listeners = new Set<() => void>();
|
||||
let visible = getIsAppVisible();
|
||||
let activelyVisible = getIsAppActivelyVisible();
|
||||
const visibilityListeners = new Set<() => void>();
|
||||
const activeVisibilityListeners = new Set<() => void>();
|
||||
|
||||
function notify(): void {
|
||||
const next = getIsAppActivelyVisible();
|
||||
if (next === current) {
|
||||
return;
|
||||
const nextVisible = getIsAppVisible();
|
||||
if (nextVisible !== visible) {
|
||||
visible = nextVisible;
|
||||
for (const listener of visibilityListeners) listener();
|
||||
}
|
||||
current = next;
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
|
||||
const nextActivelyVisible = getIsAppActivelyVisible();
|
||||
if (nextActivelyVisible !== activelyVisible) {
|
||||
activelyVisible = nextActivelyVisible;
|
||||
for (const listener of activeVisibilityListeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,15 +34,32 @@ if (isWeb && typeof document !== "undefined") {
|
||||
window.addEventListener("blur", notify);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
function subscribeToVisibility(listener: () => void): () => void {
|
||||
visibilityListeners.add(listener);
|
||||
return () => visibilityListeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): boolean {
|
||||
return current;
|
||||
function subscribeToActiveVisibility(listener: () => void): () => void {
|
||||
activeVisibilityListeners.add(listener);
|
||||
return () => activeVisibilityListeners.delete(listener);
|
||||
}
|
||||
|
||||
function getVisibilitySnapshot(): boolean {
|
||||
return visible;
|
||||
}
|
||||
|
||||
function getActiveVisibilitySnapshot(): boolean {
|
||||
return activelyVisible;
|
||||
}
|
||||
|
||||
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",
|
||||
failedToLoad: "فشل تحميل الوكيل",
|
||||
reconnecting: "جارٍ إعادة الاتصال...",
|
||||
timelineSyncFailed: "تعذر تحديث سجل الوكيل. جارٍ إعادة المحاولة…",
|
||||
archivingTitle: "وكيل الارشيف...",
|
||||
archivingSubtitle: "الرجاء الانتظار بينما نقوم بأرشفة هذا الوكيل.",
|
||||
},
|
||||
|
||||
@@ -188,6 +188,7 @@ export const en = {
|
||||
notFound: "Agent not found",
|
||||
failedToLoad: "Failed to load agent",
|
||||
reconnecting: "Reconnecting...",
|
||||
timelineSyncFailed: "Couldn't refresh agent history. Retrying…",
|
||||
archivingTitle: "Archiving agent...",
|
||||
archivingSubtitle: "Please wait while we archive this agent.",
|
||||
},
|
||||
|
||||
@@ -190,6 +190,7 @@ export const es: TranslationResources = {
|
||||
notFound: "Agentno encontrado",
|
||||
failedToLoad: "No se pudo cargar el agente",
|
||||
reconnecting: "Reconectando...",
|
||||
timelineSyncFailed: "No se pudo actualizar el historial del agente. Reintentando…",
|
||||
archivingTitle: "Agente de archivo...",
|
||||
archivingSubtitle: "Espere mientras archivamos este agente.",
|
||||
},
|
||||
|
||||
@@ -191,6 +191,7 @@ export const fr: TranslationResources = {
|
||||
notFound: "Agentintrouvable",
|
||||
failedToLoad: "Échec du chargement de l'agent",
|
||||
reconnecting: "Reconnexion...",
|
||||
timelineSyncFailed: "Impossible d’actualiser l’historique de l’agent. Nouvelle tentative…",
|
||||
archivingTitle: "Agent d'archivage...",
|
||||
archivingSubtitle: "Veuillez patienter pendant que nous archivons cet agent.",
|
||||
},
|
||||
|
||||
@@ -190,6 +190,7 @@ export const ja: TranslationResources = {
|
||||
notFound: "エージェントが見つかりません",
|
||||
failedToLoad: "エージェントの読み込みに失敗しました",
|
||||
reconnecting: "再接続中...",
|
||||
timelineSyncFailed: "エージェントの履歴を更新できませんでした。再試行しています…",
|
||||
archivingTitle: "エージェントをアーカイブ中...",
|
||||
archivingSubtitle: "このエージェントをアーカイブするまでお待ちください。",
|
||||
},
|
||||
|
||||
@@ -190,6 +190,7 @@ export const ptBR: TranslationResources = {
|
||||
notFound: "Agente não encontrado",
|
||||
failedToLoad: "Falha ao carregar agente",
|
||||
reconnecting: "Reconectando...",
|
||||
timelineSyncFailed: "Não foi possível atualizar o histórico do agente. Tentando novamente…",
|
||||
archivingTitle: "Arquivando agente...",
|
||||
archivingSubtitle: "Aguarde enquanto arquivamos este agente.",
|
||||
},
|
||||
|
||||
@@ -190,6 +190,7 @@ export const ru: TranslationResources = {
|
||||
notFound: "Agent не найден",
|
||||
failedToLoad: "Не удалось загрузить агент",
|
||||
reconnecting: "Повторное подключение...",
|
||||
timelineSyncFailed: "Не удалось обновить историю агента. Повторная попытка…",
|
||||
archivingTitle: "Архивный агент...",
|
||||
archivingSubtitle: "Пожалуйста, подождите, пока мы архивируем этого агента.",
|
||||
},
|
||||
|
||||
@@ -190,6 +190,7 @@ export const zhCN: TranslationResources = {
|
||||
notFound: "未找到 Agent",
|
||||
failedToLoad: "加载 Agent 失败",
|
||||
reconnecting: "正在重连...",
|
||||
timelineSyncFailed: "无法刷新代理历史记录。正在重试…",
|
||||
archivingTitle: "正在归档 Agent...",
|
||||
archivingSubtitle: "请稍候,我们正在归档这个 Agent。",
|
||||
},
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { TFunction } from "i18next";
|
||||
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 { ActivityIndicator, Text, View } from "react-native";
|
||||
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 { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { SidebarCallout } from "@/components/sidebar-callout";
|
||||
import { Composer } from "@/composer";
|
||||
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
|
||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||
@@ -751,6 +760,26 @@ function ChatAgentContent({
|
||||
const agentHistorySyncGeneration = useSessionStore((state) =>
|
||||
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) =>
|
||||
findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }),
|
||||
);
|
||||
@@ -862,6 +891,7 @@ function ChatAgentContent({
|
||||
isArchivingCurrentAgent,
|
||||
isHistorySyncing,
|
||||
needsAuthoritativeSync,
|
||||
visibilityCatchUpStatus,
|
||||
continuity,
|
||||
hasHydratedHistoryBefore,
|
||||
},
|
||||
@@ -1004,6 +1034,7 @@ function ChatAgentContent({
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
viewState.sync.ui === "overlay";
|
||||
const showHistorySyncError = viewState.tag === "ready" && viewState.sync.status === "sync_error";
|
||||
|
||||
return (
|
||||
<ChatAgentReadyContent
|
||||
@@ -1023,6 +1054,7 @@ function ChatAgentContent({
|
||||
handleComposerHeightChange={handleComposerHeightChange}
|
||||
handleMessageSent={handleMessageSent}
|
||||
showHistorySyncOverlay={showHistorySyncOverlay}
|
||||
showHistorySyncError={showHistorySyncError}
|
||||
cwd={agentCwd}
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
@@ -1048,6 +1080,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
handleComposerHeightChange,
|
||||
handleMessageSent,
|
||||
showHistorySyncOverlay,
|
||||
showHistorySyncError,
|
||||
cwd,
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
@@ -1069,6 +1102,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
handleComposerHeightChange: (height: number) => void;
|
||||
handleMessageSent: () => void;
|
||||
showHistorySyncOverlay: boolean;
|
||||
showHistorySyncError: boolean;
|
||||
cwd: string;
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
@@ -1140,6 +1174,14 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
<FileDropZone style={styles.container} disabled={isArchivingCurrentAgent}>
|
||||
{contentContainer}
|
||||
|
||||
{showHistorySyncError ? (
|
||||
<SidebarCallout
|
||||
title={t("agentPanel.states.timelineSyncFailed")}
|
||||
variant="error"
|
||||
testID="agent-timeline-sync-error"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{composerSection}
|
||||
|
||||
{showHistorySyncOverlay ? (
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -2027,7 +2028,7 @@ function WorkspaceScreenContent({
|
||||
}),
|
||||
[isFocusModeEnabled, isMobile, isRouteFocused, uiTabs, workspaceLayout],
|
||||
);
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!persistenceKey || !viewedTimelineSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
|
||||
import {
|
||||
createViewedTimelineSync,
|
||||
@@ -191,6 +191,7 @@ test("unchanged visible-set publication does not cancel paged catch-up", async (
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
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"]);
|
||||
firstPage.respond({ hasNewer: true, seq: 5 });
|
||||
const secondPage = await world.nextFetch("agent-a");
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
|
||||
secondPage.respond({ hasNewer: false });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
|
||||
});
|
||||
|
||||
expect(secondPage.request).toEqual({
|
||||
direction: "after",
|
||||
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");
|
||||
|
||||
world.sync.setConnected(false);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
|
||||
stalePage.respond({ hasNewer: true, seq: 8 });
|
||||
world.sync.setConnected(true);
|
||||
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");
|
||||
restoredPage.respond({ hasNewer: false });
|
||||
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
expect(restoredMembership.agentIds).toEqual(["agent-a"]);
|
||||
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");
|
||||
failed.fail("timeline unavailable");
|
||||
const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||
|
||||
retryCatchUp();
|
||||
const retry = await world.nextFetch("agent-a");
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||
retry.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
expect({ error, retryDirection: retry.request.direction }).toEqual({
|
||||
error: "timeline unavailable",
|
||||
@@ -396,12 +408,15 @@ test("membership failure autonomously retries without another visibility declara
|
||||
const failed = await world.nextMembership();
|
||||
failed.fail("subscription unavailable");
|
||||
const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||
|
||||
retryMembership();
|
||||
const retry = await world.nextMembership();
|
||||
retry.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
expect({ error, failed: failed.agentIds, retry: retry.agentIds }).toEqual({
|
||||
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();
|
||||
world.sync.setConnected(true);
|
||||
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 });
|
||||
|
||||
world.sync.setActive(false);
|
||||
world.expectNoPendingMembership();
|
||||
world.runUnsubscribeGrace();
|
||||
const background = await world.nextMembership();
|
||||
background.succeed();
|
||||
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 () => {
|
||||
const world = new TimelineWorld();
|
||||
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");
|
||||
initialCatchUp.respond({ hasNewer: false });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
|
||||
});
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
||||
world.expectNoPendingMembership();
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready");
|
||||
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
@@ -500,6 +541,12 @@ test("unsubscribe grace expiry removes the agent exactly once", async () => {
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
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.runUnsubscribeGrace();
|
||||
@@ -507,6 +554,7 @@ test("unsubscribe grace expiry removes the agent exactly once", async () => {
|
||||
unsubscribe.succeed();
|
||||
|
||||
expect(unsubscribe.agentIds).toEqual([]);
|
||||
expect(readinessChanges.at(-1)).toBe("pending");
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
@@ -533,7 +581,7 @@ test("a new visible agent subscribes immediately while the previous agent linger
|
||||
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();
|
||||
world.sync.setConnected(true);
|
||||
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.setActive(false);
|
||||
world.expectNoPendingMembership();
|
||||
world.runUnsubscribeGrace();
|
||||
const unsubscribe = await world.nextMembership();
|
||||
unsubscribe.succeed();
|
||||
|
||||
expect(unsubscribe.agentIds).toEqual([]);
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("disconnecting cancels pending unsubscribe grace without publishing on the closed socket", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-b"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
const [agentA, agentB] = await Promise.all([
|
||||
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);
|
||||
|
||||
expect(agentAStatuses).toEqual(["pending"]);
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
@@ -612,13 +672,17 @@ test("switching from legacy to selective delivery publishes membership and catch
|
||||
world.sync.setConnected(true);
|
||||
const legacyCatchUp = await world.nextFetch("agent-a");
|
||||
legacyCatchUp.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
world.sync.setDeliveryMode("selective");
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
expect(membership.agentIds).toEqual(["agent-a"]);
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
|
||||
@@ -25,9 +25,12 @@ interface ViewedTimelineSyncPorts {
|
||||
}
|
||||
|
||||
export type TimelineDeliveryMode = "legacy" | "selective";
|
||||
export type ViewedTimelineStatus = "ready" | "pending" | "error";
|
||||
|
||||
export interface ViewedTimelineUiBridge {
|
||||
replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void;
|
||||
subscribe(listener: () => void): () => void;
|
||||
getAgentTimelineStatus(agentId: string): ViewedTimelineStatus;
|
||||
}
|
||||
|
||||
export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||
@@ -88,6 +91,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const catchUpGenerations = new Map<string, number>();
|
||||
const pendingGaps = new Map<string, ProjectedTimelineForwardFetchPlan>();
|
||||
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 connected = false;
|
||||
let deliveryMode = ports.initialDeliveryMode;
|
||||
@@ -107,6 +113,26 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const isAcknowledged = (agentId: string) => acknowledged.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) => {
|
||||
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
|
||||
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`);
|
||||
}
|
||||
catchUps.set(agentId, { generation, status: "complete" });
|
||||
setVisibilityCatchUpReady(agentId);
|
||||
} catch (error) {
|
||||
if (catchUps.get(agentId)?.generation === generation) {
|
||||
const cancelRetry = ports.schedule(() => {
|
||||
@@ -156,6 +183,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
startCatchUp(agentId);
|
||||
}, RETRY_DELAY_MS);
|
||||
catchUps.set(agentId, { generation, status: "error", cancelRetry });
|
||||
setVisibilityCatchUpError([agentId]);
|
||||
ports.reportError(error);
|
||||
}
|
||||
}
|
||||
@@ -208,6 +236,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
await ports.setSubscription(requested);
|
||||
} catch (error) {
|
||||
membershipNeedsRetry = true;
|
||||
setVisibilityCatchUpError(requested);
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = ports.schedule(() => {
|
||||
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 (statusChanged) notifyListeners();
|
||||
if (deliveryMode === "selective" && membershipNeedsRetry) void reconcileMembership();
|
||||
retryFailedCatchUps();
|
||||
return;
|
||||
}
|
||||
|
||||
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 = null;
|
||||
desired = nextDesired;
|
||||
membershipGeneration += 1;
|
||||
notifyListeners();
|
||||
if (deliveryMode === "legacy") {
|
||||
acknowledged = connected ? desired : [];
|
||||
if (connected) startAcknowledgedCatchUps();
|
||||
@@ -302,7 +356,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
lingeringRemovals.delete(agentId);
|
||||
}
|
||||
|
||||
if (allowGrace && connected && active && deliveryMode === "selective") {
|
||||
if (allowGrace && connected && deliveryMode === "selective") {
|
||||
for (const agentId of desired) {
|
||||
if (visible.includes(agentId) || lingeringRemovals.has(agentId)) continue;
|
||||
const cancel = ports.schedule(() => {
|
||||
@@ -319,6 +373,15 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
};
|
||||
|
||||
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) {
|
||||
const normalized = normalizeAgentIds(agentIds);
|
||||
if (normalized.length === 0) sources.delete(sourceId);
|
||||
@@ -328,14 +391,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
setActive(nextActive) {
|
||||
if (active === nextActive) return;
|
||||
active = nextActive;
|
||||
publishVisibleMembership(false);
|
||||
publishVisibleMembership(true);
|
||||
},
|
||||
setConnected(nextConnected) {
|
||||
if (connected === nextConnected) return;
|
||||
connected = nextConnected;
|
||||
if (!connected) {
|
||||
clearLingeringRemovals();
|
||||
commitDesiredMembership(visibleAgentIds());
|
||||
commitDesiredMembership(visibleAgentIds(), { resetCatchUpStatus: true });
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
acknowledged = [];
|
||||
@@ -361,7 +424,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
membershipGeneration += 1;
|
||||
for (const agentId of desired) cancelCatchUp(agentId);
|
||||
desired = visibleAgentIds();
|
||||
visibilityCatchUpPending.clear();
|
||||
visibilityCatchUpErrors.clear();
|
||||
for (const agentId of desired) visibilityCatchUpPending.add(agentId);
|
||||
acknowledged = deliveryMode === "legacy" && connected ? desired : [];
|
||||
notifyListeners();
|
||||
if (deliveryMode === "selective" && connected) void reconcileMembership();
|
||||
else if (connected) startAcknowledgedCatchUps();
|
||||
},
|
||||
@@ -382,6 +449,10 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
for (const agentId of desired) cancelCatchUp(agentId);
|
||||
desired = [];
|
||||
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 { isNative } from "@/constants/platform";
|
||||
|
||||
export function getIsAppActivelyVisible(appState: string = AppState.currentState): boolean {
|
||||
if (appState !== "active") {
|
||||
return false;
|
||||
}
|
||||
interface AppVisibilityInput {
|
||||
appState: string;
|
||||
native: boolean;
|
||||
documentVisible: boolean;
|
||||
}
|
||||
|
||||
if (isNative) {
|
||||
return true;
|
||||
}
|
||||
interface ActiveAppVisibilityInput extends AppVisibilityInput {
|
||||
windowFocused: boolean;
|
||||
}
|
||||
|
||||
const documentVisible = typeof document === "undefined" || document.visibilityState === "visible";
|
||||
const windowFocused =
|
||||
export function isAppVisible(input: AppVisibilityInput): boolean {
|
||||
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.hasFocus !== "function" ||
|
||||
document.hasFocus();
|
||||
|
||||
return documentVisible && windowFocused;
|
||||
document.hasFocus()
|
||||
);
|
||||
}
|
||||
|
||||
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