From 791753271665c215c4f15569491d88f27d123edc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 18 Jul 2026 16:30:59 +0200 Subject: [PATCH] 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 --- docs/timeline-sync.md | 9 +- packages/app/e2e/helpers/timeline-delivery.ts | 107 ++++++++++++++++++ packages/app/e2e/terminal-stuck-size.spec.ts | 4 +- .../app/e2e/viewed-agent-timelines.spec.ts | 40 +++++++ packages/app/src/components/file-pane.tsx | 8 +- .../terminal-pane-focus-claim.test.ts | 6 +- .../components/terminal-pane-focus-claim.ts | 4 +- packages/app/src/components/terminal-pane.tsx | 10 +- packages/app/src/contexts/session-context.tsx | 4 +- .../use-agent-screen-state-machine.test.ts | 41 ++++++- .../hooks/use-agent-screen-state-machine.ts | 13 ++- packages/app/src/hooks/use-app-visible.ts | 52 ++++++--- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + packages/app/src/panels/agent-panel.tsx | 44 ++++++- .../screens/workspace/workspace-screen.tsx | 3 +- .../src/timeline/viewed-timeline-sync.test.ts | 80 +++++++++++-- .../app/src/timeline/viewed-timeline-sync.ts | 81 ++++++++++++- packages/app/src/utils/app-visibility.test.ts | 26 +++++ packages/app/src/utils/app-visibility.ts | 53 +++++++-- 26 files changed, 528 insertions(+), 65 deletions(-) create mode 100644 packages/app/e2e/helpers/timeline-delivery.ts create mode 100644 packages/app/src/utils/app-visibility.test.ts diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index dfd56a46a..e90abf412 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -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. diff --git a/packages/app/e2e/helpers/timeline-delivery.ts b/packages/app/e2e/helpers/timeline-delivery.ts new file mode 100644 index 000000000..1ac692b56 --- /dev/null +++ b/packages/app/e2e/helpers/timeline-delivery.ts @@ -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 { + 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 { + return page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => { + const state = ( + window as typeof window & { __assistantFrameState?: AssistantFrameState } + ).__assistantFrameState; + if (!state) { + resolve([]); + return; + } + state.active = false; + resolve([...state.snapshots]); + }); + }), + ); + }, + }; +} diff --git a/packages/app/e2e/terminal-stuck-size.spec.ts b/packages/app/e2e/terminal-stuck-size.spec.ts index 9c498e44a..9e80f16b5 100644 --- a/packages/app/e2e/terminal-stuck-size.spec.ts +++ b/packages/app/e2e/terminal-stuck-size.spec.ts @@ -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 diff --git a/packages/app/e2e/viewed-agent-timelines.spec.ts b/packages/app/e2e/viewed-agent-timelines.spec.ts index 7e4c7d539..67f5b3289 100644 --- a/packages/app/e2e/viewed-agent-timelines.spec.ts +++ b/packages/app/e2e/viewed-agent-timelines.spec.ts @@ -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 { diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index e8cde4141..e78ac348b 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -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], diff --git a/packages/app/src/components/terminal-pane-focus-claim.test.ts b/packages/app/src/components/terminal-pane-focus-claim.test.ts index 67e540f52..24edd4a57 100644 --- a/packages/app/src/components/terminal-pane-focus-claim.test.ts +++ b/packages/app/src/components/terminal-pane-focus-claim.test.ts @@ -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, diff --git a/packages/app/src/components/terminal-pane-focus-claim.ts b/packages/app/src/components/terminal-pane-focus-claim.ts index 03f9ec2fa..21118ad19 100644 --- a/packages/app/src/components/terminal-pane-focus-claim.ts +++ b/packages/app/src/components/terminal-pane-focus-claim.ts @@ -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 diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 3eb607857..f5ba2864f 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -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, diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index abde80934..ed2f62fb0 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -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) { diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index f2da3318b..5422bf63f 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -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, }; diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index 9882ff5cf..941e87deb 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -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, }), diff --git a/packages/app/src/hooks/use-app-visible.ts b/packages/app/src/hooks/use-app-visible.ts index 89a046b3c..2777e18af 100644 --- a/packages/app/src/hooks/use-app-visible.ts +++ b/packages/app/src/hooks/use-app-visible.ts @@ -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, + ); } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 3062b5e9c..9ca62a417 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -190,6 +190,7 @@ export const ar: TranslationResources = { notFound: "لم يتم العثور على Agent", failedToLoad: "فشل تحميل الوكيل", reconnecting: "جارٍ إعادة الاتصال...", + timelineSyncFailed: "تعذر تحديث سجل الوكيل. جارٍ إعادة المحاولة…", archivingTitle: "وكيل الارشيف...", archivingSubtitle: "الرجاء الانتظار بينما نقوم بأرشفة هذا الوكيل.", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index d04201091..147b24beb 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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.", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 58221329b..72a7cef63 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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.", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 9083dc98c..ae70cba91 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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.", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index ff5c83e9b..61f878696 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -190,6 +190,7 @@ export const ja: TranslationResources = { notFound: "エージェントが見つかりません", failedToLoad: "エージェントの読み込みに失敗しました", reconnecting: "再接続中...", + timelineSyncFailed: "エージェントの履歴を更新できませんでした。再試行しています…", archivingTitle: "エージェントをアーカイブ中...", archivingSubtitle: "このエージェントをアーカイブするまでお待ちください。", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 3d5f1f20b..45d07d737 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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.", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 57f147682..f0f2bb207 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -190,6 +190,7 @@ export const ru: TranslationResources = { notFound: "Agent не найден", failedToLoad: "Не удалось загрузить агент", reconnecting: "Повторное подключение...", + timelineSyncFailed: "Не удалось обновить историю агента. Повторная попытка…", archivingTitle: "Архивный агент...", archivingSubtitle: "Пожалуйста, подождите, пока мы архивируем этого агента.", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 2feb4684a..24650d1b9 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -190,6 +190,7 @@ export const zhCN: TranslationResources = { notFound: "未找到 Agent", failedToLoad: "加载 Agent 失败", reconnecting: "正在重连...", + timelineSyncFailed: "无法刷新代理历史记录。正在重试…", archivingTitle: "正在归档 Agent...", archivingSubtitle: "请稍候,我们正在归档这个 Agent。", }, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index d4102d30c..7613dd035 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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 ( void; handleMessageSent: () => void; showHistorySyncOverlay: boolean; + showHistorySyncError: boolean; cwd: string; onAttentionInputFocus: () => void; onAttentionPromptSend: () => void; @@ -1140,6 +1174,14 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ {contentContainer} + {showHistorySyncError ? ( + + ) : null} + {composerSection} {showHistorySyncOverlay ? ( diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 3de632990..16cf2b0cc 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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; } diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 2e7fa6dd1..42f15507f 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -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(); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index 4aa8a26dd..d928d032c 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -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(); const pendingGaps = new Map(); const lingeringRemovals = new Map void>(); + const visibilityCatchUpPending = new Set(); + const visibilityCatchUpErrors = new Set(); + 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(); }, }; } diff --git a/packages/app/src/utils/app-visibility.test.ts b/packages/app/src/utils/app-visibility.test.ts new file mode 100644 index 000000000..eec95463d --- /dev/null +++ b/packages/app/src/utils/app-visibility.test.ts @@ -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); +}); diff --git a/packages/app/src/utils/app-visibility.ts b/packages/app/src/utils/app-visibility.ts index 896d70eb1..d078f1022 100644 --- a/packages/app/src/utils/app-visibility.ts +++ b/packages/app/src/utils/app-visibility.ts @@ -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(), + }); }