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 527 additions and 64 deletions
@@ -63,6 +63,7 @@ function createBaseInput(): AgentScreenMachineInput {
isArchivingCurrentAgent: false,
isHistorySyncing: false,
needsAuthoritativeSync: false,
visibilityCatchUpStatus: "ready",
hasHydratedHistoryBefore: false,
};
}
@@ -189,6 +190,43 @@ describe("deriveAgentScreenViewState", () => {
expect(sync.ui).toBe("silent");
});
it("covers already-hydrated history while a newly visible agent catches up", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
agent: createAgent("agent-1"),
hasHydratedHistoryBefore: true,
visibilityCatchUpStatus: "pending",
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
const sync = expectCatchingUpSync(ready);
expect(sync.ui).toBe("overlay");
});
it("keeps hydrated history readable after a visibility catch-up error", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
lastReadyAgent: createAgent("agent-1"),
});
const input: AgentScreenMachineInput = {
...createBaseInput(),
agent: createAgent("agent-1"),
hasHydratedHistoryBefore: true,
visibilityCatchUpStatus: "error",
};
const result = deriveAgentScreenViewState({ input, memory });
const ready = expectReadyState(result.state);
expectSyncErrorSync(ready);
});
it("keeps sync errors non-blocking once the screen was ready", () => {
const memory = createBaseMemory({
hasRenderedReady: true,
@@ -328,7 +366,7 @@ describe("deriveAgentScreenViewState", () => {
expect(result.memory.lastReadyAgent).toBeNull();
});
it("still allows optimistic create flow to render before authoritative history arrives", () => {
it("keeps optimistic create non-blocking while timeline and authoritative history catch up", () => {
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
...createBaseInput(),
@@ -336,6 +374,7 @@ describe("deriveAgentScreenViewState", () => {
continuity: { kind: "optimistic-create", agent: createAgent("agent-1") },
needsAuthoritativeSync: true,
isHistorySyncing: true,
visibilityCatchUpStatus: "pending",
hasHydratedHistoryBefore: false,
};
@@ -46,6 +46,7 @@ export interface AgentScreenMachineInput {
isArchivingCurrentAgent: boolean;
isHistorySyncing: boolean;
needsAuthoritativeSync: boolean;
visibilityCatchUpStatus: "ready" | "pending" | "error";
continuity: AgentScreenContinuity;
hasHydratedHistoryBefore: boolean;
}
@@ -147,10 +148,12 @@ function resolveAgentScreenSource(args: {
function resolveCatchingUpUi(args: {
hasOptimisticCreateContinuity: boolean;
isVisibilityCatchUpPending: boolean;
hasHydratedHistoryBefore: boolean;
hadInitialSyncFailure: boolean;
}): "overlay" | "silent" {
if (args.hasOptimisticCreateContinuity) return "silent";
if (args.isVisibilityCatchUpPending) return "overlay";
if (args.hasHydratedHistoryBefore) return "silent";
if (args.hadInitialSyncFailure) return "silent";
return "overlay";
@@ -167,11 +170,19 @@ function resolveAgentScreenSync(args: {
if (input.missingAgentState.kind === "error") {
return { status: "sync_error" };
}
if (input.needsAuthoritativeSync || input.isHistorySyncing) {
if (input.visibilityCatchUpStatus === "error") {
return { status: "sync_error" };
}
if (
input.visibilityCatchUpStatus === "pending" ||
input.needsAuthoritativeSync ||
input.isHistorySyncing
) {
return {
status: "catching_up",
ui: resolveCatchingUpUi({
hasOptimisticCreateContinuity: hasOptimisticCreateContinuity(input),
isVisibilityCatchUpPending: input.visibilityCatchUpStatus === "pending",
hasHydratedHistoryBefore: input.hasHydratedHistoryBefore,
hadInitialSyncFailure,
}),
+37 -15
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,
);
}