push architecture slice 7: graph-status + practice surfaces ride the events channel; coverage polls accepted-by-design; stale poll comments + architecture.md observability section rewritten to the push model
This commit is contained in:
@@ -17,15 +17,16 @@ import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||
* the observability the learner can't otherwise see; an in-flight teaching
|
||||
* turn is fully visible where it happens (user decision, 2026-09).
|
||||
*
|
||||
* Data: ONE poll of GET /api/activity (jobs across ALL chats, title-joined,
|
||||
* oldest-first + extraction runs), folded client-side with core/prices.ts.
|
||||
* Data: ONE feed of GET /api/activity (jobs across ALL chats, title-joined,
|
||||
* oldest-first + extraction runs), fetched when truth moves (events
|
||||
* channel, S5) and folded client-side with core/prices.ts.
|
||||
* Cost is INFERRED (jobCost); the context line is the LAST model call's
|
||||
* input+output against the model's limit (JobUsage.lastStep — pending
|
||||
* contract) — never a cumulative sum, which would be the wrong number.
|
||||
* Absent data renders "—", never zero (the doctrine).
|
||||
*
|
||||
* Quiet/ambient by design: no entry animations, stable keys, updates land
|
||||
* in place — polling never flickers. ALWAYS present (owner, sidebar round):
|
||||
* in place — refreshes never flicker. ALWAYS present (owner, sidebar round):
|
||||
* empty means EMPTY — the header sits alone, nothing hides; it populates and
|
||||
* GROWS when processes run (max-h + internal scroll so a pile never crowds
|
||||
* the session list). The sidebar's closed state means the learner opted
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ArtifactPartProps, DemoResult } from "@/src/core";
|
||||
|
||||
/**
|
||||
* Demo artifact card (W6): while the part is input-available, a job activity
|
||||
* card (poll /api/jobs — the job-card idiom); when output-available, the demo
|
||||
* card (the chat view's jobs feed — the job-card idiom); when output-available, the demo
|
||||
* runs in a SANDBOXED iframe (sandbox="allow-scripts", no allow-same-origin →
|
||||
* no access to app storage/cookies/DOM).
|
||||
*
|
||||
@@ -70,7 +70,7 @@ export function reduceDemoBridgeState(state: DemoBridgeState, data: unknown): De
|
||||
// ─── the demo card ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The demo tool-call box: the part's output is the dispatch ack — the box
|
||||
* is the DispatchCard (live status via the central jobs poll). The demo
|
||||
* is the DispatchCard (live status via the chat view's jobs feed). The demo
|
||||
* itself renders from the steer message via DemoOutputCard. */
|
||||
export function DemoCard({ part }: ArtifactPartProps) {
|
||||
if (part.state !== "output-available") return null;
|
||||
|
||||
@@ -11,9 +11,9 @@ import { BrailleSpinner } from "@/components/braille-spinner";
|
||||
* The dispatch card (contracts.ts "THE ASYNC PROTOCOL" §4): the tool-call
|
||||
* box for a background sub-agent. The tool part's output is the FIRE-AND-
|
||||
* FORGET ACK (a string) — never the result. Live status comes from the
|
||||
* central jobs poll via JobsContext, matched by toolCallId:
|
||||
* chat-view jobs feed via JobsContext, matched by toolCallId:
|
||||
*
|
||||
* job unknown → "dispatched" pulse (the poll hasn't seen it yet)
|
||||
* job unknown → "dispatched" pulse (the jobs feed hasn't seen it yet)
|
||||
* running → live activity card (activity line + elapsed)
|
||||
* done/error → quiet one-liner ("delivered" / "failed") — the artifact
|
||||
* itself arrives in the steer message right below, so the
|
||||
@@ -85,7 +85,7 @@ function DispatchRefusedCard({ label, line, button }: { label: string; line: str
|
||||
}
|
||||
|
||||
function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: string }) {
|
||||
// Elapsed from createdAt — jobs appear here only via the poll (post-mount),
|
||||
// Elapsed from createdAt — jobs appear here only via the jobs feed (post-mount),
|
||||
// so no SSR/hydration mismatch.
|
||||
const elapsed =
|
||||
job && job.createdAt ? Math.max(0, Math.round((Date.now() - Date.parse(job.createdAt)) / 1000)) : null;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ToolCallCard } from "@/components/artifacts/tool-call-card";
|
||||
* edit chat.tsx (see docs/plan.md file-ownership rules).
|
||||
*
|
||||
* Typical shape: while the part is input-available render an activity card
|
||||
* (poll GET /api/jobs?chatId=…; on completion call
|
||||
* (the chat view's jobs feed reads GET /api/jobs?chatId=…; on completion call
|
||||
* ChatActionsContext.syncFromServer()); when output-available render the
|
||||
* artifact itself from part.output.
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ArtifactPartProps, PdfResult, ResearchResult } from "@/src/core";
|
||||
* The research tool-call box (contracts.ts "THE ASYNC PROTOCOL" §4).
|
||||
*
|
||||
* The part's output is the fire-and-forget dispatch ACK — the box itself is
|
||||
* the DispatchCard (live status via the central jobs poll; quiet one-liner
|
||||
* the DispatchCard (live status via the chat view's jobs feed; quiet one-liner
|
||||
* once delivered). The research brief itself no longer renders from the
|
||||
* tool part at all: it rides the steer message (the user-role delivery
|
||||
* injected after the wake turn) and renders there via ResearchBriefCard.
|
||||
@@ -21,7 +21,7 @@ export function JobCard({ part }: ArtifactPartProps) {
|
||||
/**
|
||||
* The pdf dispatch box (sources v0) — the same async-protocol idiom as
|
||||
* JobCard: the ack output drives a DispatchCard (live "PDF reader" status
|
||||
* via the central jobs poll); the brief itself rides the steer message
|
||||
* via the chat view's jobs feed); the brief itself rides the steer message
|
||||
* and renders under the PDF-complete badge in the transcript.
|
||||
*/
|
||||
export function PdfJobCard({ part }: ArtifactPartProps) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ArtifactPartProps, VisualResult } from "@/src/core";
|
||||
/**
|
||||
* Visual artifact card (W6 → ack protocol): the tool part's output is the
|
||||
* dispatch ack, so the tool-call box is the DispatchCard (live status via
|
||||
* the central jobs poll). The SVG itself renders from the steer message
|
||||
* the chat view's jobs feed). The SVG itself renders from the steer message
|
||||
* (sub-agent result delivery) via VisualOutputCard.
|
||||
*
|
||||
* The SVG is model-authored for the learner's own machine (their own teacher
|
||||
|
||||
@@ -138,6 +138,9 @@ export function CoverageMenuBlock({ chatId }: { chatId: string }) {
|
||||
// While a run is in flight the block polls at 2s (the old panel's
|
||||
// open-only cadence, bounded to the running state) — the numbers
|
||||
// settle live and the block ends in "fully extracted".
|
||||
// ACCEPTED BY DESIGN (owner, 2026-10-02): the ring drains per fold,
|
||||
// and the events channel is per phase — that granularity gap is why
|
||||
// this bounded, run-scoped poll stays.
|
||||
useEffect(() => {
|
||||
if (coverage?.running !== true && !extracting) return;
|
||||
const timer = setInterval(() => void load(), 2000);
|
||||
@@ -274,6 +277,9 @@ export function CoverageRing({ chatId, refreshKey = 0 }: { chatId: string; refre
|
||||
// While a run is in flight: poll at 2s so the ring DRAINS as folds land
|
||||
// and settles at zero when the run completes (the "resets on
|
||||
// completion" requirement — coverage re-derives per request).
|
||||
// ACCEPTED BY DESIGN (owner, 2026-10-02): the ring drains per fold,
|
||||
// and the events channel is per phase — that granularity gap is why
|
||||
// this bounded, run-scoped poll stays.
|
||||
useEffect(() => {
|
||||
if (coverage?.running !== true) return;
|
||||
const timer = setInterval(() => {
|
||||
|
||||
+37
-22
@@ -70,6 +70,7 @@ import { incrementalLayout, type GraphCostSummary, type GraphNode, type GraphSna
|
||||
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
||||
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
||||
import { useActivity } from "@/components/activity-dock";
|
||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||
import { MenuShell } from "@/components/menu-shell.tsx";
|
||||
import { DeleteNodeDialog } from "@/components/delete-node-dialog.tsx";
|
||||
import { ChainTableOverlay } from "@/components/chain-table.tsx";
|
||||
@@ -1069,7 +1070,8 @@ export function GraphView() {
|
||||
// The dock's start-site nudge (accuracy wave, 2026-09-19): the import
|
||||
// POST awaits the WHOLE pipeline server-side, so the run registers long
|
||||
// before the response — nudge at click and again at +600ms to catch the
|
||||
// start; the dock's fast active interval carries the run from there.
|
||||
// start; from its first setPhase the run announces itself on the events
|
||||
// channel (S5), and the dock rides those announcements thereafter.
|
||||
const refreshActivity = useActivity().refresh;
|
||||
const onImportFiles = (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
@@ -1088,7 +1090,7 @@ export function GraphView() {
|
||||
.finally(() => setImporting(false));
|
||||
};
|
||||
|
||||
// Background pipeline status: poll /api/graph/status; whenever a run
|
||||
// Background pipeline status: read /api/graph/status; whenever a run
|
||||
// LEAVES the feed (finished / aborted / failed), refetch the graph.
|
||||
// A run only leaves the registry after its merge + settle + usage
|
||||
// persist complete, so disappearance ⇒ the graph change is already on
|
||||
@@ -1102,27 +1104,40 @@ export function GraphView() {
|
||||
// into one end-of-import reveal (the exact bug this rule exists to
|
||||
// prevent — a chunk's row leaving IS its merge landing).
|
||||
const [pipelineRuns, setPipelineRuns] = useState<{ id: string; chatId: string; title: string; phase: string; startedAt: number }[]>([]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let prevIds = new Set<string>();
|
||||
const poll = () =>
|
||||
fetch("/api/graph/status")
|
||||
.then((res) => res.json() as Promise<{ runs: typeof pipelineRuns }>)
|
||||
.then(({ runs }) => {
|
||||
if (cancelled) return;
|
||||
setPipelineRuns(runs);
|
||||
const ids = new Set(runs.map((r) => r.id));
|
||||
if ([...prevIds].some((id) => !ids.has(id))) setRefreshTick((t) => t + 1);
|
||||
prevIds = ids;
|
||||
})
|
||||
.catch(() => {}); // status is cosmetic — never surface poll errors
|
||||
poll();
|
||||
const timer = setInterval(poll, 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
const rowIdsRef = useRef<Set<string>>(new Set());
|
||||
const readPipelineStatus = useCallback(() => {
|
||||
fetch("/api/graph/status")
|
||||
.then((res) => res.json() as Promise<{ runs: typeof pipelineRuns }>)
|
||||
.then(({ runs }) => {
|
||||
setPipelineRuns(runs);
|
||||
const ids = new Set(runs.map((r) => r.id));
|
||||
if ([...rowIdsRef.current].some((id) => !ids.has(id))) setRefreshTick((t) => t + 1);
|
||||
rowIdsRef.current = ids;
|
||||
})
|
||||
.catch(() => {}); // status is cosmetic — never surface read errors
|
||||
}, []);
|
||||
// PUSH (S7, 2026-10-02): the standing 2s poll is RETIRED — the
|
||||
// extraction registry announces every phase transition and every
|
||||
// finish (activity-changed), so each read lands exactly when truth
|
||||
// moved. The per-chunk reveal semantics are PRESERVED event-for-event:
|
||||
// setPhase + finishExtraction fire per part, so a chunk's row leaving
|
||||
// is still its own, immediately-visible merge landing. Debounced (the
|
||||
// S4 law); resync heals a reconnect that ate events.
|
||||
const statusDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useServerEvent("activity-changed", () => {
|
||||
if (statusDebounceRef.current) clearTimeout(statusDebounceRef.current);
|
||||
statusDebounceRef.current = setTimeout(() => {
|
||||
statusDebounceRef.current = undefined;
|
||||
readPipelineStatus();
|
||||
}, 250);
|
||||
});
|
||||
useServerResync(() => readPipelineStatus());
|
||||
useEffect(() => {
|
||||
readPipelineStatus();
|
||||
return () => {
|
||||
if (statusDebounceRef.current) clearTimeout(statusDebounceRef.current);
|
||||
};
|
||||
}, [readPipelineStatus]);
|
||||
|
||||
// Graph pipeline costs (2026-09 cost metrics wave): the cumulative fold
|
||||
// of the usage channels (chat + notes extraction runs; the dead namer's
|
||||
|
||||
@@ -6,11 +6,13 @@ import type { JobRecord } from "@/src/core";
|
||||
/**
|
||||
* Background-job observability channel (contracts.ts "THE ASYNC PROTOCOL" §4).
|
||||
*
|
||||
* ONE central poller (in Chat) fetches GET /api/jobs?chatId=… and publishes
|
||||
* the list here; every job surface consumes it — the transcript dispatch
|
||||
* cards (the ack box with live status) and the sub-agents sidebar panel.
|
||||
* Cards used to each run their own poller on input-available parts; under
|
||||
* the ack-protocol the tool part resolves immediately, so the poller moved
|
||||
* ONE central fetch (in Chat) reads GET /api/jobs?chatId=… and publishes
|
||||
* the list here — on mount, per jobs-changed announcement (the store emits
|
||||
* post-write), and on channel resync (push-architecture S4, 2026-10-02);
|
||||
* every job surface consumes it — the transcript dispatch cards (the ack
|
||||
* box with live status) and the sub-agents sidebar panel. Cards used to
|
||||
* each run their own poller on input-available parts; under the
|
||||
* ack-protocol the tool part resolves immediately, so the fetch moved
|
||||
* to the composition root and the cards became pure renderers of this data.
|
||||
*/
|
||||
export const JobsContext = createContext<JobRecord[]>([]);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* thinking out loud is the primary answering mode). ONE ATTEMPT: each
|
||||
* problem has its OWN submit button; once submitted the box locks and
|
||||
* the MODEL SOLUTION reveals instantly (rubric collapsed by default).
|
||||
* The grade (score 0–10 + brief reasoning) lands via the poll and
|
||||
* The grade (score 0–10 + brief reasoning) lands via the events channel and
|
||||
* updates live — no reload.
|
||||
*
|
||||
* TOP BAR (owner, UI round 2026-09-29): the chat pattern — an h-15
|
||||
@@ -27,6 +27,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||
import type { Problem, ProblemSet } from "@/src/core";
|
||||
import { CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "@/src/core";
|
||||
import { restoreMathEscapes } from "@/src/core";
|
||||
@@ -89,7 +90,7 @@ export function PracticeExamBody({
|
||||
submittingId: string | null;
|
||||
/** Per-problem submit failures, keyed by problem id. */
|
||||
submitErrors: Record<string, string>;
|
||||
/** Problems whose grade rows are LIVE (the poll's activity feed). */
|
||||
/** Problems whose grade rows are LIVE (the activity feed read). */
|
||||
liveGradeIds: ReadonlySet<string>;
|
||||
}) {
|
||||
return (
|
||||
@@ -310,8 +311,7 @@ export function PracticeExam({ initial }: { initial: ProblemSet }) {
|
||||
// per-problem "Grading…" spinners and the status line.
|
||||
const [liveGradeIds, setLiveGradeIds] = useState<Set<string>>(new Set());
|
||||
// True from a successful submit until the feed shows no live grade
|
||||
// rows for this set — keeps the poll alive across refetches.
|
||||
const [gradingLive, setGradingLive] = useState(false);
|
||||
// rows for this set — kept fresh across refetches (S7: the tick).
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
@@ -329,54 +329,61 @@ export function PracticeExam({ initial }: { initial: ProblemSet }) {
|
||||
const d = (await res.json()) as { set: ProblemSet };
|
||||
if (mounted.current) setSet(d.set);
|
||||
} catch {
|
||||
// transient poll failures keep the last snapshot
|
||||
// transient fetch failures keep the last snapshot
|
||||
}
|
||||
}, [initial.id]);
|
||||
|
||||
// The poll: while not ready (generation), or while a grading run is
|
||||
// live for this set with outstanding ungraded answers. The activity
|
||||
// feed's practice rows are ROW-KEYED (setId:grade:problemId) — the
|
||||
// live rows name exactly which grades are pending; a dropped grade
|
||||
// (no live row) settles the page instead of polling forever.
|
||||
const pendingGrade = set.problems.some(
|
||||
(p) => p.answer !== undefined && !p.grade,
|
||||
);
|
||||
const needsPoll = set.status !== "ready" || (gradingLive && pendingGrade);
|
||||
useEffect(() => {
|
||||
if (!needsPoll) return;
|
||||
let stopped = false;
|
||||
const tick = async () => {
|
||||
await refetch();
|
||||
try {
|
||||
const res = await fetch("/api/activity", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const feed = (await res.json()) as {
|
||||
practice?: Array<{ id: string; kind: string }>;
|
||||
};
|
||||
if (!stopped && mounted.current) {
|
||||
const prefix = `${initial.id}:grade:`;
|
||||
const pids = (feed.practice ?? [])
|
||||
.filter((row) => row.id.startsWith(prefix))
|
||||
.map((row) => row.id.slice(prefix.length));
|
||||
setLiveGradeIds(new Set(pids));
|
||||
setGradingLive(pids.length > 0);
|
||||
}
|
||||
} catch {
|
||||
// keep the last liveness read
|
||||
// PUSH (S7, 2026-10-02): the 2s grading-liveness interval is RETIRED —
|
||||
// the practice registry announces every grade-row transition
|
||||
// (activity-changed), and tick() reads exactly that liveness, so it
|
||||
// fires when truth moved: debounced (the S4 law) + on mount + on
|
||||
// resync. Ungated by design: a fresh grade registration must be seen
|
||||
// even from a fully-settled page (the old needsPoll gate would have
|
||||
// missed it — gradingLive fed the gate, and registration is what
|
||||
// flips it). The activity feed's practice rows are ROW-KEYED
|
||||
// (setId:grade:problemId) — the live rows name exactly which grades
|
||||
// are pending; a dropped grade (no live row) settles the page.
|
||||
const tick = useCallback(async () => {
|
||||
await refetch();
|
||||
try {
|
||||
const res = await fetch("/api/activity", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const feed = (await res.json()) as {
|
||||
practice?: Array<{ id: string; kind: string }>;
|
||||
};
|
||||
if (mounted.current) {
|
||||
const prefix = `${initial.id}:grade:`;
|
||||
const pids = (feed.practice ?? [])
|
||||
.filter((row) => row.id.startsWith(prefix))
|
||||
.map((row) => row.id.slice(prefix.length));
|
||||
setLiveGradeIds(new Set(pids));
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
const t = setInterval(() => void tick(), 2000);
|
||||
} catch {
|
||||
// keep the last liveness read
|
||||
}
|
||||
}, [refetch, initial.id]);
|
||||
const tickRef = useRef(tick);
|
||||
tickRef.current = tick;
|
||||
const tickDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
void tickRef.current();
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(t);
|
||||
if (tickDebounceRef.current) clearTimeout(tickDebounceRef.current);
|
||||
};
|
||||
}, [needsPoll, refetch, initial.id]);
|
||||
}, []);
|
||||
useServerEvent("activity-changed", () => {
|
||||
if (tickDebounceRef.current) clearTimeout(tickDebounceRef.current);
|
||||
tickDebounceRef.current = setTimeout(() => {
|
||||
tickDebounceRef.current = undefined;
|
||||
void tickRef.current();
|
||||
}, 250);
|
||||
});
|
||||
useServerResync(() => void tickRef.current());
|
||||
|
||||
// ONE attempt per problem (owner 2026-09-29): the submit posts THAT
|
||||
// problem's answer alone; the server refuses a second submission
|
||||
// forever after. On success the box locks (answer persisted) and the
|
||||
// reveal shows instantly; the grade arrives via the poll.
|
||||
// reveal shows instantly; the grade arrives via the events channel.
|
||||
const submit = useCallback(
|
||||
async (problemId: string) => {
|
||||
if (submittingId !== null) return;
|
||||
@@ -408,7 +415,8 @@ export function PracticeExam({ initial }: { initial: ProblemSet }) {
|
||||
return;
|
||||
}
|
||||
setSubmittingId(null);
|
||||
setGradingLive(true); // the grade lands via the poll
|
||||
// The grade lands via the events channel: the grade row's register
|
||||
// announces activity-changed (S7), which triggers tick() above.
|
||||
await refetch();
|
||||
} catch {
|
||||
setSubmitErrors((prev) => ({
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
* The problem-set list (practice slice 5): rows of sets — title, date,
|
||||
* status, problem/graded counts — click through to the exam page; a
|
||||
* delete affordance in the delete-session dialog's shape; "New problem
|
||||
* set" opens the creation dialog. Polls /api/practice/sets (2s, the
|
||||
* graph-view reveal-poll idiom) while ANY set is still generating, so
|
||||
* statuses live-update; a settled list stops polling.
|
||||
* set" opens the creation dialog. Refetches /api/practice/sets when a
|
||||
* practice phase moves (events channel, S7), so statuses live-update;
|
||||
* a settled list goes quiet.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createPortal } from "react-dom";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
@@ -220,20 +221,33 @@ export function PracticeListPage({
|
||||
const d = (await res.json()) as { sets: ProblemSet[] };
|
||||
setSets(d.sets);
|
||||
} catch {
|
||||
// a transient failure on the poll keeps the last snapshot
|
||||
// a transient failure keeps the last snapshot
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The reveal poll: while any set is partitioning/generating, statuses
|
||||
// live-update; a settled list goes quiet.
|
||||
const anyRunning = sets.some(
|
||||
(s) => s.status === "partitioning" || s.status === "generating",
|
||||
);
|
||||
// PUSH (S7, 2026-10-02): the bounded 2s reveal poll is RETIRED — the
|
||||
// practice registry announces every phase transition (register,
|
||||
// generating, grading, finish) as activity-changed, so the list
|
||||
// refetches exactly when a set's status moved. Debounced (the S4
|
||||
// law); resync heals a reconnect; the mount read catches drift since
|
||||
// the server-rendered snapshot.
|
||||
const refreshRef = useRef(refresh);
|
||||
refreshRef.current = refresh;
|
||||
const listDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!anyRunning) return;
|
||||
const t = setInterval(() => void refresh(), 2000);
|
||||
return () => clearInterval(t);
|
||||
}, [anyRunning, refresh]);
|
||||
void refreshRef.current();
|
||||
return () => {
|
||||
if (listDebounceRef.current) clearTimeout(listDebounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
useServerEvent("activity-changed", () => {
|
||||
if (listDebounceRef.current) clearTimeout(listDebounceRef.current);
|
||||
listDebounceRef.current = setTimeout(() => {
|
||||
listDebounceRef.current = undefined;
|
||||
void refreshRef.current();
|
||||
}, 250);
|
||||
});
|
||||
useServerResync(() => void refreshRef.current());
|
||||
|
||||
const runDelete = useCallback(async () => {
|
||||
if (!deleting || deleteBusy) return;
|
||||
|
||||
Reference in New Issue
Block a user