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:
Mohamed Boudra
2026-07-18 16:30:59 +02:00
committed by GitHub
parent fcaa84f0e4
commit 7917532716
26 changed files with 528 additions and 65 deletions

View File

@@ -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.

View 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]);
});
}),
);
},
};
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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],

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,
};

View File

@@ -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,
}),

View File

@@ -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,
);
}

View File

@@ -190,6 +190,7 @@ export const ar: TranslationResources = {
notFound: "لم يتم العثور على Agent",
failedToLoad: "فشل تحميل الوكيل",
reconnecting: "جارٍ إعادة الاتصال...",
timelineSyncFailed: "تعذر تحديث سجل الوكيل. جارٍ إعادة المحاولة…",
archivingTitle: "وكيل الارشيف...",
archivingSubtitle: "الرجاء الانتظار بينما نقوم بأرشفة هذا الوكيل.",
},

View File

@@ -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.",
},

View File

@@ -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.",
},

View File

@@ -191,6 +191,7 @@ export const fr: TranslationResources = {
notFound: "Agentintrouvable",
failedToLoad: "Échec du chargement de l'agent",
reconnecting: "Reconnexion...",
timelineSyncFailed: "Impossible dactualiser lhistorique de lagent. Nouvelle tentative…",
archivingTitle: "Agent d'archivage...",
archivingSubtitle: "Veuillez patienter pendant que nous archivons cet agent.",
},

View File

@@ -190,6 +190,7 @@ export const ja: TranslationResources = {
notFound: "エージェントが見つかりません",
failedToLoad: "エージェントの読み込みに失敗しました",
reconnecting: "再接続中...",
timelineSyncFailed: "エージェントの履歴を更新できませんでした。再試行しています…",
archivingTitle: "エージェントをアーカイブ中...",
archivingSubtitle: "このエージェントをアーカイブするまでお待ちください。",
},

View File

@@ -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.",
},

View File

@@ -190,6 +190,7 @@ export const ru: TranslationResources = {
notFound: "Agent не найден",
failedToLoad: "Не удалось загрузить агент",
reconnecting: "Повторное подключение...",
timelineSyncFailed: "Не удалось обновить историю агента. Повторная попытка…",
archivingTitle: "Архивный агент...",
archivingSubtitle: "Пожалуйста, подождите, пока мы архивируем этого агента.",
},

View File

@@ -190,6 +190,7 @@ export const zhCN: TranslationResources = {
notFound: "未找到 Agent",
failedToLoad: "加载 Agent 失败",
reconnecting: "正在重连...",
timelineSyncFailed: "无法刷新代理历史记录。正在重试…",
archivingTitle: "正在归档 Agent...",
archivingSubtitle: "请稍候,我们正在归档这个 Agent。",
},

View File

@@ -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 ? (

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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();
},
};
}

View 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);
});

View File

@@ -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(),
});
}