Files
paseo/packages/app/src/hooks/client-activity-tracker.ts
T
Mohamed Boudra 0ad69d3eb0 fix(app): paint conversations immediately after restart (#3907)
* fix(app): paint cached conversations before reconnect

Cold startup restored every saved host before publishing any session, so a valid cached chat was blocked behind unrelated directory validation and network-only readiness. Directory and timeline owners now resolve demand through typed storage and authoritative reconciliation while consumers remain source-agnostic.

* fix(app): stabilize demand-driven cache restore

Create host projections before network availability, route cached workspace and timeline preparation through their owners, and coalesce directory reconciliation per online connection. Update browser cache fixtures for per-agent timeline rows.

* fix(app): resume cached timeline through owner

* fix(app): surface timeline owner load errors
2026-08-26 22:55:48 +02:00

116 lines
3.4 KiB
TypeScript

export const HEARTBEAT_INTERVAL_MS = 15_000;
export const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
export const DESKTOP_IDLE_POLL_INTERVAL_MS = 5_000;
export interface HeartbeatPayload {
deviceType: "web" | "mobile";
focusedAgentId: string | null;
focusedTerminalId: string | null;
lastActivityAt: string;
appVisible: boolean;
appVisibilityChangedAt?: string;
}
export interface HeartbeatClient {
readonly isConnected: boolean;
sendHeartbeat(payload: HeartbeatPayload): void;
}
export interface ClientActivityTrackerInput {
client: HeartbeatClient;
deviceType: "web" | "mobile";
initialFocusedAgentId: string | null;
initialFocusedTerminalId: string | null;
initialAppVisible: boolean;
now: () => number;
onAppResumed?: (awayMs: number) => void;
}
export interface ClientActivityTracker {
recordUserActivity(): void;
maybeSendImmediateHeartbeat(): void;
setFocusedAgentId(id: string | null): void;
setFocusedTerminalId(id: string | null): void;
notifyAppVisibility(visible: boolean): { changed: boolean };
notifySystemIdleMs(idleMs: number | null): void;
sendHeartbeat(): void;
}
export function createClientActivityTracker(
input: ClientActivityTrackerInput,
): ClientActivityTracker {
const { client, deviceType, now, onAppResumed } = input;
let lastActivityAtMs = now();
let appVisible = input.initialAppVisible;
let appVisibilityChangedAtMs = now();
let backgroundedAtMs: number | null = appVisible ? null : now();
let focusedAgentId = input.initialFocusedAgentId;
let focusedTerminalId = input.initialFocusedTerminalId;
let lastImmediateHeartbeatAtMs = 0;
function sendHeartbeat(): void {
if (!client.isConnected) return;
client.sendHeartbeat({
deviceType,
focusedAgentId,
focusedTerminalId,
lastActivityAt: new Date(lastActivityAtMs).toISOString(),
appVisible,
appVisibilityChangedAt: new Date(appVisibilityChangedAtMs).toISOString(),
});
}
function recordUserActivity(): void {
lastActivityAtMs = now();
}
function maybeSendImmediateHeartbeat(): void {
if (!client.isConnected) return;
const t = now();
if (t - lastImmediateHeartbeatAtMs < ACTIVITY_HEARTBEAT_THROTTLE_MS) return;
lastImmediateHeartbeatAtMs = t;
sendHeartbeat();
}
return {
recordUserActivity,
maybeSendImmediateHeartbeat,
setFocusedAgentId(id) {
if (focusedAgentId === id) return;
focusedAgentId = id;
recordUserActivity();
sendHeartbeat();
},
setFocusedTerminalId(id) {
if (focusedTerminalId === id) return;
focusedTerminalId = id;
recordUserActivity();
sendHeartbeat();
},
notifyAppVisibility(nextVisible) {
if (appVisible === nextVisible) return { changed: false };
appVisible = nextVisible;
appVisibilityChangedAtMs = now();
if (!nextVisible) {
backgroundedAtMs = now();
return { changed: true };
}
const at = backgroundedAtMs;
backgroundedAtMs = null;
if (at !== null) {
onAppResumed?.(Math.max(0, now() - at));
}
recordUserActivity();
return { changed: true };
},
notifySystemIdleMs(idleMs) {
if (idleMs === null) return;
const systemLastActivityAtMs = now() - idleMs;
if (systemLastActivityAtMs > lastActivityAtMs) {
lastActivityAtMs = systemLastActivityAtMs;
}
},
sendHeartbeat,
};
}