Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ vi.mock("@semoss/sdk/react", async (importOriginal) => ({
vi.mock("@/features/dashboard/use-chat-history", () => ({
useChatHistory: () => remote.history,
}));
vi.mock("@/features/dashboard/use-visible-resource", () => ({
vi.mock("@/lib/use-visible-resource", () => ({
useVisibleResource: () => ({
data: [],
error: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ export function CollaborationNavigation({
)}
>
{/* under Routines, the last link, but scrolling with Chats so a long list never pushes Settings off screen */}
<RoutineRecentRuns onNavigate={onNavigate} />
{/* stays mounted (rooms collapse the nav) but stops polling while collapsed */}
<RoutineRecentRuns
onNavigate={onNavigate}
paused={isCollapsed}
/>
<ChatHistoryList
isOpen={isSessionsOpen}
onOpenChange={onSessionsOpenChange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,16 @@ export function WorkBuildMenu({ onDone }: { onDone: () => void }) {
const { actions } = useInsight();
const [status, setStatus] = useState<Status | null>(null);
const timer = useRef<number | undefined>(undefined);
// a poll in flight when the menu unmounts must not schedule the next one
const mounted = useRef(true);
const busy = Boolean(status && ACTIVE.has(String(status.state)));

const poll = useCallback(async () => {
try {
const [out] = (await runBatch(actions, [
pixel("WorkBuildItems", { status: true }),
])) as Status[];
if (!mounted.current) return;
const next = out ?? {};
setStatus(next);
if (ACTIVE.has(String(next.state))) {
Expand All @@ -59,22 +62,27 @@ export function WorkBuildMenu({ onDone }: { onDone: () => void }) {
toast.error(String(next.error ?? "Building work items failed"));
} catch {
// a failed status check leaves the button usable
setStatus(null);
if (mounted.current) setStatus(null);
}
}, [actions, onDone]);

// a build already running (from another tab or a sync) shows its progress
useEffect(() => {
mounted.current = true;
void runBatch(actions, [pixel("WorkBuildItems", { status: true })])
.then(([out]) => {
if (!mounted.current) return;
const s = (out ?? {}) as Status;
if (ACTIVE.has(String(s.state))) {
setStatus(s);
timer.current = window.setTimeout(() => void poll(), 3000);
}
})
.catch(() => {});
return () => window.clearTimeout(timer.current);
return () => {
mounted.current = false;
window.clearTimeout(timer.current);
};
}, [actions, poll]);

const start = async (args: Record<string, unknown>) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -914,7 +914,9 @@ const SYNC_OUTCOMES = new Set<string>([
"quiet",
]);

const SYNC_POLL_MS = 1500;
// a sync can classify for minutes; check every 3 s, and slower from a hidden tab
const SYNC_POLL_MS = 3000;
const SYNC_HIDDEN_POLL_MS = 10_000;
const SYNC_TIMEOUT_MS = 10 * 60_000;

/** Pull new mail (and Teams when on) from Microsoft 365 through the shared sync job, then return its counts. */
Expand All @@ -934,7 +936,12 @@ export async function syncMail(
while (current.status === "running") {
if (Date.now() > deadline)
throw new Error("Checking for new mail is taking too long.");
await wait(SYNC_POLL_MS);
await wait(
typeof document !== "undefined" &&
document.visibilityState === "hidden"
? SYNC_HIDDEN_POLL_MS
: SYNC_POLL_MS,
);
const [polled] = await runBatch(actions, [
pixel("BrainGetJob", { kind: "sync" }),
]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { MailCheck } from "./live-state";

// Every open Collaboration tab shares one mail/Teams sync clock: one sync a minute for the browser, not per tab.
const LAST_ATTEMPT_KEY = "collaboration:last-mail-attempt";
const CHANNEL = "collaboration:sync";

export interface SyncDone {
type: "synced";
/** Nothing came in, nothing closed: other tabs need no reload. */
quiet: boolean;
lastMailCheck: MailCheck | null;
}

// this tab's own last attempt: the clock when storage is off, and never behind it when storage is on
let memoryAttempt = 0;

/** When any tab last started a sync, in ms; 0 when unknown. */
export function sharedLastAttempt(): number {
let at = 0;
try {
at = Number(window.localStorage.getItem(LAST_ATTEMPT_KEY)) || 0;
} catch {
// storage off (private mode): only this tab's clock is known
}
// a time ahead of this clock (another machine's, a bad write) must not hold syncs off
return Math.max(at > Date.now() ? 0 : at, memoryAttempt);
}

function markAttempt(at: number): void {
memoryAttempt = at;
try {
window.localStorage.setItem(LAST_ATTEMPT_KEY, String(at));
} catch {
// storage off (private mode): memoryAttempt is this tab's clock
}
}

/** Record a sync this tab starts regardless of the clock (the Refresh button). */
export function noteSyncStarted(): void {
markAttempt(Date.now());
}

/**
* Claim the next sync when no tab started one within gapMs. A Web Lock makes the check and the write one step,
* so two tabs waking together do not both sync.
*/
export async function claimSync(gapMs: number): Promise<boolean> {
const claim = () => {
const now = Date.now();
if (now - sharedLastAttempt() < gapMs) return false;
markAttempt(now);
return true;
};
if (!navigator.locks) return claim();
return navigator.locks.request(
LAST_ATTEMPT_KEY,
{ ifAvailable: true },
(lock) => (lock ? claim() : false),
);
}

let channel: BroadcastChannel | null | undefined;
function openChannel(): BroadcastChannel | null {
if (channel === undefined)
channel =
typeof BroadcastChannel === "undefined"
? null
: new BroadcastChannel(CHANNEL);
return channel;
}

/** Tell the other tabs a sync finished. */
export function announceSync(done: Omit<SyncDone, "type">): void {
openChannel()?.postMessage({ type: "synced", ...done } satisfies SyncDone);
}

/** Hear syncs other tabs finish; returns the unsubscribe. */
export function onSyncElsewhere(
listener: (done: SyncDone) => void,
): () => void {
const open = openChannel();
if (!open) return () => undefined;
const handle = (event: MessageEvent) => {
if (event.data?.type === "synced") listener(event.data as SyncDone);
};
open.addEventListener("message", handle);
return () => open.removeEventListener("message", handle);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { changedSince } from "./work-updates-provider";

const POLL_MS = 2000;
const HIDDEN_POLL_MS = 10_000;
const TIMEOUT_MS = 5 * 60_000;
const FAILED = "Could not summarize this thread. Try again.";
const pause = (ms: number) =>
Expand Down Expand Up @@ -76,7 +77,11 @@ export function ThreadInsightsProvider({
throw new Error(
"Summarizing is taking too long. Try again in a moment.",
);
await wait(POLL_MS);
await wait(
document.visibilityState === "hidden"
? HIDDEN_POLL_MS
: POLL_MS,
);
if (!mounted.current) return null;
result = await read(false);
}
Expand Down
Loading
Loading