@tokentop/ttop 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -15
- package/package.json +8 -6
- package/src/agents/aggregator.test.ts +306 -0
- package/src/agents/aggregator.ts +40 -5
- package/src/cli.ts +14 -0
- package/src/config/schema.ts +1 -1
- package/src/demo/simulator.ts +1 -1
- package/src/plugins/agents/index.ts +1 -0
- package/src/plugins/lifecycle.ts +1 -1
- package/src/plugins/notification-bus.ts +27 -0
- package/src/plugins/notifications/terminal-bell.ts +80 -16
- package/src/plugins/notifications/visual-flash.ts +5 -18
- package/src/plugins/plugin-host.test.ts +197 -0
- package/src/plugins/plugin-host.ts +45 -1
- package/src/plugins/providers/anthropic.test.ts +202 -0
- package/src/plugins/providers/anthropic.ts +19 -0
- package/src/plugins/providers/antigravity.ts +16 -0
- package/src/plugins/providers/chutes.ts +16 -0
- package/src/plugins/providers/codex.ts +16 -0
- package/src/plugins/providers/gemini.ts +16 -0
- package/src/plugins/providers/github-copilot.ts +16 -0
- package/src/plugins/providers/minimax.ts +16 -0
- package/src/plugins/providers/openai-api.ts +52 -0
- package/src/plugins/providers/perplexity.ts +10 -1
- package/src/plugins/providers/zai.ts +16 -0
- package/src/pricing/fallback.ts +4 -0
- package/src/tui/App.tsx +22 -8
- package/src/tui/components/HelpOverlay.tsx +1 -1
- package/src/tui/components/LimitGauge.tsx +12 -4
- package/src/tui/components/NotificationFlash.tsx +141 -0
- package/src/tui/components/ProviderCard.tsx +38 -22
- package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
- package/src/tui/components/SessionsTable.tsx +54 -36
- package/src/tui/components/SettingsModal.tsx +20 -4
- package/src/tui/components/Skeleton.tsx +9 -6
- package/src/tui/components/SortOverlay.tsx +167 -0
- package/src/tui/components/Spinner.tsx +16 -11
- package/src/tui/components/Toast.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +69 -16
- package/src/tui/contexts/PluginContext.tsx +72 -10
- package/src/tui/contexts/ThemeContext.tsx +4 -1
- package/src/tui/createApp.tsx +2 -0
- package/src/tui/hooks/index.ts +1 -0
- package/src/tui/hooks/useAnimatedValue.ts +29 -26
- package/src/tui/hooks/useAnimationTick.ts +93 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
- package/src/tui/hooks/useEntranceAnimation.ts +16 -10
- package/src/tui/hooks/useNotificationBridge.ts +29 -0
- package/src/tui/hooks/usePulse.ts +13 -7
- package/src/tui/hooks/useValueFlash.ts +25 -33
- package/src/tui/index.tsx +4 -0
- package/src/tui/types/projectSort.ts +35 -0
- package/src/tui/types/sort.ts +61 -0
- package/src/tui/views/ProjectsView.tsx +104 -38
- package/src/tui/views/RealTimeDashboard.tsx +91 -8
- package/src/tui/views/SettingsView.tsx +26 -8
- package/src/utils/__tests__/error-category.test.ts +147 -0
- package/src/utils/currency.ts +4 -2
- package/src/utils/error-category.ts +91 -0
- package/src/version.ts +1 -1
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
useRef,
|
|
9
9
|
useState,
|
|
10
10
|
} from "react";
|
|
11
|
-
import { aggregateSessionUsage } from "@/agents/aggregator.ts";
|
|
11
|
+
import { aggregateSessionUsage, deduplicateAggregates } from "@/agents/aggregator.ts";
|
|
12
12
|
import { priceSessions } from "@/agents/costing.ts";
|
|
13
13
|
import type { AgentId, AgentInfo, AgentSessionAggregate } from "@/agents/types.ts";
|
|
14
14
|
import { createPluginContext } from "@/plugins/plugin-context-factory.ts";
|
|
@@ -21,6 +21,10 @@ import { useLogs } from "./LogContext.tsx";
|
|
|
21
21
|
import { usePlugins } from "./PluginContext.tsx";
|
|
22
22
|
import { useTimeWindow } from "./TimeWindowContext.tsx";
|
|
23
23
|
|
|
24
|
+
/** Polling interval when at least one session is active. */
|
|
25
|
+
const ACTIVE_INTERVAL_MS = 3_000;
|
|
26
|
+
/** Polling interval when all sessions are idle/historical. */
|
|
27
|
+
const IDLE_INTERVAL_MS = 15_000;
|
|
24
28
|
interface AgentSessionContextValue {
|
|
25
29
|
sessions: AgentSessionAggregate[];
|
|
26
30
|
agents: AgentInfo[];
|
|
@@ -35,14 +39,24 @@ const AgentSessionContext = createContext<AgentSessionContextValue | null>(null)
|
|
|
35
39
|
interface AgentSessionProviderProps {
|
|
36
40
|
children: ReactNode;
|
|
37
41
|
autoRefresh?: boolean;
|
|
38
|
-
refreshInterval?: number;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Compute a lightweight fingerprint of session state.
|
|
46
|
+
* If the fingerprint hasn't changed, we skip setSessions() to avoid
|
|
47
|
+
* unnecessary React re-renders of the entire session list.
|
|
48
|
+
*/
|
|
49
|
+
function computeSessionFingerprint(sessions: AgentSessionAggregate[]): string {
|
|
50
|
+
// Build a compact string from the fields that actually affect the UI.
|
|
51
|
+
// This is intentionally simple — no crypto hashing needed, just string comparison.
|
|
52
|
+
let fp = `${sessions.length}:`;
|
|
53
|
+
for (const s of sessions) {
|
|
54
|
+
fp += `${s.sessionId},${s.status},${s.lastActivityAt},${s.totals.input},${s.totals.output},${s.totalCostUsd ?? 0},${s.requestCount};`;
|
|
55
|
+
}
|
|
56
|
+
return fp;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function AgentSessionProvider({ children, autoRefresh = false }: AgentSessionProviderProps) {
|
|
46
60
|
const [sessions, setSessions] = useState<AgentSessionAggregate[]>([]);
|
|
47
61
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
48
62
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -54,6 +68,7 @@ export function AgentSessionProvider({
|
|
|
54
68
|
const { windowMs } = useTimeWindow();
|
|
55
69
|
const hasBackfilled = useRef(false);
|
|
56
70
|
const isRefreshingRef = useRef(false);
|
|
71
|
+
const lastFingerprintRef = useRef<string>("");
|
|
57
72
|
|
|
58
73
|
const discoverAgents = useCallback(async (): Promise<AgentInfo[]> => {
|
|
59
74
|
const agentPlugins = pluginRegistry.getAll("agent");
|
|
@@ -178,10 +193,34 @@ export function AgentSessionProvider({
|
|
|
178
193
|
}
|
|
179
194
|
}
|
|
180
195
|
|
|
181
|
-
|
|
196
|
+
// Deduplicate sessions claimed by multiple agents scanning the same directory.
|
|
197
|
+
const dedupedAggregates = deduplicateAggregates(allAggregates);
|
|
198
|
+
if (dedupedAggregates.length < allAggregates.length) {
|
|
199
|
+
debug(
|
|
200
|
+
`Deduplicated sessions: ${allAggregates.length} \u2192 ${dedupedAggregates.length}`,
|
|
201
|
+
undefined,
|
|
202
|
+
"agent-sessions",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const pricedSessions = await priceSessions(dedupedAggregates);
|
|
182
207
|
|
|
183
208
|
pricedSessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
184
209
|
|
|
210
|
+
// --- Change detection: skip React update if nothing changed ---
|
|
211
|
+
const newFingerprint = computeSessionFingerprint(pricedSessions);
|
|
212
|
+
const changed = newFingerprint !== lastFingerprintRef.current;
|
|
213
|
+
lastFingerprintRef.current = newFingerprint;
|
|
214
|
+
|
|
215
|
+
if (!changed) {
|
|
216
|
+
debug(
|
|
217
|
+
"Session refresh: no changes detected, skipping render",
|
|
218
|
+
undefined,
|
|
219
|
+
"agent-sessions",
|
|
220
|
+
);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
185
224
|
// Persist to storage — the persistence service handles throttling
|
|
186
225
|
// internally (fingerprint gate + 5-minute per-session throttle).
|
|
187
226
|
// Safe to call every tick; state lives on globalThis, survives HMR.
|
|
@@ -260,18 +299,32 @@ export function AgentSessionProvider({
|
|
|
260
299
|
useEffect(() => {
|
|
261
300
|
if (!autoRefresh) return;
|
|
262
301
|
|
|
263
|
-
|
|
302
|
+
// Adaptive polling: poll faster when sessions are active, slower when idle.
|
|
303
|
+
// The interval is re-evaluated after each tick based on current session state.
|
|
304
|
+
let timerId: ReturnType<typeof setTimeout> | null = null;
|
|
264
305
|
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
306
|
+
const scheduleNext = () => {
|
|
307
|
+
const hasActive = sessions.some((s) => s.status === "active");
|
|
308
|
+
const interval = hasActive ? ACTIVE_INTERVAL_MS : IDLE_INTERVAL_MS;
|
|
309
|
+
|
|
310
|
+
timerId = setTimeout(() => {
|
|
311
|
+
debug(
|
|
312
|
+
`Adaptive tick (${hasActive ? "active" : "idle"}, ${interval}ms)`,
|
|
313
|
+
undefined,
|
|
314
|
+
"agent-sessions",
|
|
315
|
+
);
|
|
316
|
+
refreshSessionsRef.current().then(scheduleNext);
|
|
317
|
+
}, interval);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
scheduleNext();
|
|
269
321
|
|
|
270
322
|
return () => {
|
|
271
|
-
|
|
272
|
-
|
|
323
|
+
if (timerId !== null) {
|
|
324
|
+
clearTimeout(timerId);
|
|
325
|
+
}
|
|
273
326
|
};
|
|
274
|
-
}, [autoRefresh,
|
|
327
|
+
}, [autoRefresh, sessions, debug]);
|
|
275
328
|
|
|
276
329
|
const value: AgentSessionContextValue = {
|
|
277
330
|
sessions,
|
|
@@ -44,6 +44,8 @@ export interface ProviderState {
|
|
|
44
44
|
loading: boolean;
|
|
45
45
|
lastFetchAt: number | null;
|
|
46
46
|
history: UsageSnapshot[];
|
|
47
|
+
/** Timestamp (ms) until which this provider should be skipped due to rate limiting. 0 = not rate limited. */
|
|
48
|
+
rateLimitUntil: number;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
interface PluginContextValue {
|
|
@@ -190,6 +192,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
190
192
|
loading: false,
|
|
191
193
|
lastFetchAt: null,
|
|
192
194
|
history: [],
|
|
195
|
+
rateLimitUntil: 0,
|
|
193
196
|
});
|
|
194
197
|
|
|
195
198
|
if (configured) {
|
|
@@ -238,6 +241,14 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
238
241
|
initialize();
|
|
239
242
|
}, []);
|
|
240
243
|
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (!isInitialized) return;
|
|
246
|
+
notificationBus.setPluginConfig("terminal-bell", {
|
|
247
|
+
...((config.pluginConfig ?? {})["terminal-bell"] ?? {}),
|
|
248
|
+
enabled: config.notifications.soundEnabled,
|
|
249
|
+
});
|
|
250
|
+
}, [config.notifications.soundEnabled, isInitialized]);
|
|
251
|
+
|
|
241
252
|
const prevPluginConfigRef = useRef<Record<string, Record<string, unknown>>>({});
|
|
242
253
|
useEffect(() => {
|
|
243
254
|
if (!isInitialized) return;
|
|
@@ -264,6 +275,18 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
264
275
|
return;
|
|
265
276
|
}
|
|
266
277
|
|
|
278
|
+
// Skip providers that are currently rate-limited (backoff)
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
if (state.rateLimitUntil > now) {
|
|
281
|
+
const remainingSec = Math.round((state.rateLimitUntil - now) / 1000);
|
|
282
|
+
debug(
|
|
283
|
+
`Skipping ${providerId}: rate limited for ${remainingSec}s more`,
|
|
284
|
+
undefined,
|
|
285
|
+
"refresh",
|
|
286
|
+
);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
267
290
|
info(`Refreshing ${providerId}...`, undefined, "refresh");
|
|
268
291
|
|
|
269
292
|
setProviders((prev) => {
|
|
@@ -310,6 +333,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
310
333
|
usage,
|
|
311
334
|
loading: false,
|
|
312
335
|
lastFetchAt: Date.now(),
|
|
336
|
+
rateLimitUntil: 0,
|
|
313
337
|
history: addToHistory(currentHistory, snapshotEntry),
|
|
314
338
|
});
|
|
315
339
|
return next;
|
|
@@ -337,16 +361,24 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
337
361
|
const http = createSandboxedHttpClient(providerId, state.plugin.permissions);
|
|
338
362
|
const logger = createPluginLogger(providerId);
|
|
339
363
|
|
|
340
|
-
const fetchResult = await safeInvoke(
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
364
|
+
const fetchResult = await safeInvoke(
|
|
365
|
+
providerId,
|
|
366
|
+
"fetchUsage",
|
|
367
|
+
() =>
|
|
368
|
+
runInPluginGuard(providerId, state.plugin.permissions, () =>
|
|
369
|
+
state.plugin.fetchUsage({
|
|
370
|
+
credentials: creds,
|
|
371
|
+
http,
|
|
372
|
+
logger,
|
|
373
|
+
config: {},
|
|
374
|
+
signal: AbortSignal.timeout(30_000),
|
|
375
|
+
}),
|
|
376
|
+
),
|
|
377
|
+
{
|
|
378
|
+
// Count non-rate-limit errors as circuit breaker failures.
|
|
379
|
+
// Rate limits are handled separately via backoff scheduling.
|
|
380
|
+
isFailure: (usage) => !!usage.error && !usage.rateLimited,
|
|
381
|
+
},
|
|
350
382
|
);
|
|
351
383
|
|
|
352
384
|
if (!fetchResult.ok) {
|
|
@@ -355,6 +387,34 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
355
387
|
|
|
356
388
|
const usage = fetchResult.value;
|
|
357
389
|
|
|
390
|
+
// Handle rate-limited responses: set backoff, preserve last good data
|
|
391
|
+
if (usage.rateLimited) {
|
|
392
|
+
const backoffMs = usage.retryAfterMs ?? 300_000; // default 5 min
|
|
393
|
+
const backoffUntil = Date.now() + backoffMs;
|
|
394
|
+
warn(
|
|
395
|
+
`${providerId} rate limited, backing off for ${Math.round(backoffMs / 1000)}s`,
|
|
396
|
+
{ retryAfterMs: backoffMs, until: new Date(backoffUntil).toISOString() },
|
|
397
|
+
"refresh",
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
setProviders((prev) => {
|
|
401
|
+
const next = new Map(prev);
|
|
402
|
+
const current = next.get(providerId);
|
|
403
|
+
// Preserve the last successful usage data if we have it
|
|
404
|
+
const preservedUsage = current?.usage && !current.usage.error ? current.usage : usage;
|
|
405
|
+
next.set(providerId, {
|
|
406
|
+
...state,
|
|
407
|
+
usage: preservedUsage,
|
|
408
|
+
loading: false,
|
|
409
|
+
lastFetchAt: Date.now(),
|
|
410
|
+
rateLimitUntil: backoffUntil,
|
|
411
|
+
history: current?.history ?? [],
|
|
412
|
+
});
|
|
413
|
+
return next;
|
|
414
|
+
});
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
358
418
|
if (usage.error) {
|
|
359
419
|
warn(`${providerId} returned error: ${usage.error}`, undefined, "refresh");
|
|
360
420
|
} else {
|
|
@@ -399,6 +459,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
399
459
|
usage,
|
|
400
460
|
loading: false,
|
|
401
461
|
lastFetchAt: Date.now(),
|
|
462
|
+
rateLimitUntil: 0,
|
|
402
463
|
history: addToHistory(currentHistory, snapshot),
|
|
403
464
|
});
|
|
404
465
|
return next;
|
|
@@ -418,6 +479,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
418
479
|
},
|
|
419
480
|
loading: false,
|
|
420
481
|
lastFetchAt: Date.now(),
|
|
482
|
+
rateLimitUntil: current?.rateLimitUntil ?? 0,
|
|
421
483
|
history: current?.history ?? [],
|
|
422
484
|
});
|
|
423
485
|
return next;
|
|
@@ -16,6 +16,7 @@ interface ThemeContextValue {
|
|
|
16
16
|
setTheme: (theme: ThemePlugin) => void;
|
|
17
17
|
previewTheme: ThemePlugin | null;
|
|
18
18
|
setPreviewTheme: (theme: ThemePlugin | null) => void;
|
|
19
|
+
cliTheme: string | undefined;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
const defaultComponents: ThemeComponents = {
|
|
@@ -29,10 +30,11 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
|
29
30
|
|
|
30
31
|
interface ThemeProviderProps {
|
|
31
32
|
initialTheme?: ThemePlugin;
|
|
33
|
+
cliTheme?: string;
|
|
32
34
|
children: ReactNode;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
export function ThemeProvider({ initialTheme, children }: ThemeProviderProps) {
|
|
37
|
+
export function ThemeProvider({ initialTheme, cliTheme, children }: ThemeProviderProps) {
|
|
36
38
|
const [theme, setTheme] = useState<ThemePlugin>(initialTheme ?? tokyoNightTheme);
|
|
37
39
|
const [previewTheme, setPreviewTheme] = useState<ThemePlugin | null>(null);
|
|
38
40
|
|
|
@@ -46,6 +48,7 @@ export function ThemeProvider({ initialTheme, children }: ThemeProviderProps) {
|
|
|
46
48
|
setTheme,
|
|
47
49
|
previewTheme,
|
|
48
50
|
setPreviewTheme,
|
|
51
|
+
cliTheme,
|
|
49
52
|
};
|
|
50
53
|
|
|
51
54
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
package/src/tui/createApp.tsx
CHANGED
|
@@ -13,6 +13,7 @@ export interface CreateAppOptions {
|
|
|
13
13
|
demoSeed?: number;
|
|
14
14
|
demoPreset?: DemoPreset;
|
|
15
15
|
cliPlugins?: string[];
|
|
16
|
+
cliTheme?: string;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export const DEFAULT_APP_OPTIONS = {
|
|
@@ -32,6 +33,7 @@ export function createAppElement(options: CreateAppOptions = {}): ReactNode {
|
|
|
32
33
|
{...(options.demoSeed !== undefined ? { demoSeed: options.demoSeed } : {})}
|
|
33
34
|
{...(options.demoPreset !== undefined ? { demoPreset: options.demoPreset } : {})}
|
|
34
35
|
{...(options.cliPlugins ? { cliPlugins: options.cliPlugins } : {})}
|
|
36
|
+
{...(options.cliTheme ? { cliTheme: options.cliTheme } : {})}
|
|
35
37
|
/>
|
|
36
38
|
);
|
|
37
39
|
|
package/src/tui/hooks/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export {
|
|
|
6
6
|
useAnimatedTokens,
|
|
7
7
|
useAnimatedValue,
|
|
8
8
|
} from "./useAnimatedValue.ts";
|
|
9
|
+
export { animationTick, useAnimationTick } from "./useAnimationTick.ts";
|
|
9
10
|
export type { DashboardDeltas, UseDashboardStateResult } from "./useDashboardState.ts";
|
|
10
11
|
export { useDashboardState } from "./useDashboardState.ts";
|
|
11
12
|
export type { ActivityDebugData, ActivityState, UseActivityRateResult } from "./useEmaActivity.ts";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { animationTick } from "./useAnimationTick.ts";
|
|
2
3
|
|
|
3
4
|
export type EasingFunction = (t: number) => number;
|
|
4
5
|
|
|
@@ -14,6 +15,7 @@ export interface UseAnimatedValueOptions {
|
|
|
14
15
|
durationMs?: number;
|
|
15
16
|
easing?: keyof typeof easings | EasingFunction;
|
|
16
17
|
precision?: number;
|
|
18
|
+
/** @deprecated Ignored — animations now use the global 30 Hz tick. */
|
|
17
19
|
frameRate?: number;
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -21,57 +23,58 @@ export function useAnimatedValue(
|
|
|
21
23
|
targetValue: number,
|
|
22
24
|
options: UseAnimatedValueOptions = {},
|
|
23
25
|
): number {
|
|
24
|
-
const { durationMs = 300, easing = "easeOutQuad", precision = 2
|
|
26
|
+
const { durationMs = 300, easing = "easeOutQuad", precision = 2 } = options;
|
|
25
27
|
|
|
26
28
|
const [displayValue, setDisplayValue] = useState(targetValue);
|
|
27
|
-
const
|
|
29
|
+
const displayRef = useRef(targetValue);
|
|
28
30
|
const startValueRef = useRef(targetValue);
|
|
29
|
-
const startTimeRef = useRef
|
|
31
|
+
const startTimeRef = useRef(0);
|
|
30
32
|
const targetRef = useRef(targetValue);
|
|
33
|
+
const unsubRef = useRef<(() => void) | null>(null);
|
|
31
34
|
|
|
32
35
|
const easingFn = typeof easing === "function" ? easing : easings[easing];
|
|
33
|
-
const frameInterval = Math.round(1000 / frameRate);
|
|
34
36
|
|
|
35
37
|
useEffect(() => {
|
|
36
|
-
|
|
38
|
+
// Already at target — nothing to animate
|
|
39
|
+
if (targetRef.current === targetValue && displayRef.current === targetValue) {
|
|
37
40
|
return;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
+
// Clean up any existing subscription
|
|
44
|
+
unsubRef.current?.();
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
// Set up animation from current display value to new target
|
|
47
|
+
startValueRef.current = displayRef.current;
|
|
45
48
|
startTimeRef.current = Date.now();
|
|
46
49
|
targetRef.current = targetValue;
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
// Subscribe to global tick — auto-starts the shared timer
|
|
52
|
+
unsubRef.current = animationTick.subscribe((now) => {
|
|
53
|
+
const elapsed = now - startTimeRef.current;
|
|
50
54
|
const progress = Math.min(elapsed / durationMs, 1);
|
|
51
55
|
const easedProgress = easingFn(progress);
|
|
52
|
-
|
|
53
56
|
const startVal = startValueRef.current;
|
|
54
|
-
const
|
|
55
|
-
const
|
|
57
|
+
const target = targetRef.current;
|
|
58
|
+
const currentValue = startVal + (target - startVal) * easedProgress;
|
|
59
|
+
const rounded = Number(currentValue.toFixed(precision));
|
|
56
60
|
|
|
57
|
-
|
|
61
|
+
displayRef.current = rounded;
|
|
62
|
+
setDisplayValue(rounded);
|
|
58
63
|
|
|
59
64
|
if (progress >= 1) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
displayRef.current = target;
|
|
66
|
+
setDisplayValue(target);
|
|
67
|
+
// Unsubscribe — if no other animations are active, the global timer stops
|
|
68
|
+
unsubRef.current?.();
|
|
69
|
+
unsubRef.current = null;
|
|
65
70
|
}
|
|
66
|
-
}
|
|
71
|
+
});
|
|
67
72
|
|
|
68
73
|
return () => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
intervalRef.current = null;
|
|
72
|
-
}
|
|
74
|
+
unsubRef.current?.();
|
|
75
|
+
unsubRef.current = null;
|
|
73
76
|
};
|
|
74
|
-
}, [targetValue, durationMs, easingFn, precision
|
|
77
|
+
}, [targetValue, durationMs, easingFn, precision]);
|
|
75
78
|
|
|
76
79
|
return displayValue;
|
|
77
80
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global animation tick manager.
|
|
3
|
+
*
|
|
4
|
+
* Replaces per-component setInterval timers with a single shared 30 Hz tick.
|
|
5
|
+
* This eliminates the ~90 concurrent timers that were causing ~2,400 state
|
|
6
|
+
* updates/sec and ~60% CPU usage at idle.
|
|
7
|
+
*
|
|
8
|
+
* The tick automatically starts when the first subscriber joins and stops
|
|
9
|
+
* when the last subscriber leaves, consuming zero CPU when no animations
|
|
10
|
+
* are active.
|
|
11
|
+
*
|
|
12
|
+
* @see https://github.com/tokentopapp/tokentop/issues/55
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef } from "react";
|
|
15
|
+
|
|
16
|
+
type TickCallback = (now: number) => void;
|
|
17
|
+
|
|
18
|
+
const TICK_RATE_HZ = 30;
|
|
19
|
+
const TICK_INTERVAL_MS = Math.round(1000 / TICK_RATE_HZ);
|
|
20
|
+
|
|
21
|
+
class AnimationTickManager {
|
|
22
|
+
private callbacks = new Set<TickCallback>();
|
|
23
|
+
private interval: ReturnType<typeof setInterval> | null = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Subscribe a callback to the global tick.
|
|
27
|
+
* Returns an unsubscribe function. When the last subscriber leaves,
|
|
28
|
+
* the underlying setInterval is cleared automatically.
|
|
29
|
+
*/
|
|
30
|
+
subscribe(callback: TickCallback): () => void {
|
|
31
|
+
this.callbacks.add(callback);
|
|
32
|
+
if (this.callbacks.size === 1) {
|
|
33
|
+
this.start();
|
|
34
|
+
}
|
|
35
|
+
return () => {
|
|
36
|
+
this.callbacks.delete(callback);
|
|
37
|
+
if (this.callbacks.size === 0) {
|
|
38
|
+
this.stop();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Number of active subscribers (useful for debugging). */
|
|
44
|
+
get subscriberCount(): number {
|
|
45
|
+
return this.callbacks.size;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether the global interval is currently running. */
|
|
49
|
+
get isRunning(): boolean {
|
|
50
|
+
return this.interval !== null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private start(): void {
|
|
54
|
+
if (this.interval !== null) return;
|
|
55
|
+
this.interval = setInterval(() => {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
for (const cb of this.callbacks) {
|
|
58
|
+
cb(now);
|
|
59
|
+
}
|
|
60
|
+
}, TICK_INTERVAL_MS);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private stop(): void {
|
|
64
|
+
if (this.interval !== null) {
|
|
65
|
+
clearInterval(this.interval);
|
|
66
|
+
this.interval = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Global animation tick singleton — single 30 Hz timer shared by all hooks. */
|
|
72
|
+
export const animationTick = new AnimationTickManager();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* React hook to subscribe to the global animation tick.
|
|
76
|
+
*
|
|
77
|
+
* The callback fires at ~30 Hz while `active` is true.
|
|
78
|
+
* Uses a ref to always call the latest callback (no stale closures).
|
|
79
|
+
*
|
|
80
|
+
* @param callback - Called with current timestamp (Date.now()) on each tick
|
|
81
|
+
* @param active - Whether to subscribe (default: true). When false, the
|
|
82
|
+
* subscription is paused and the global timer may stop if no other
|
|
83
|
+
* subscribers remain.
|
|
84
|
+
*/
|
|
85
|
+
export function useAnimationTick(callback: TickCallback, active = true): void {
|
|
86
|
+
const callbackRef = useRef(callback);
|
|
87
|
+
callbackRef.current = callback;
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!active) return;
|
|
91
|
+
return animationTick.subscribe((now) => callbackRef.current(now));
|
|
92
|
+
}, [active]);
|
|
93
|
+
}
|
|
@@ -7,6 +7,7 @@ import { useAgentSessions } from "../contexts/AgentSessionContext.tsx";
|
|
|
7
7
|
import { useInputFocus } from "../contexts/InputContext.tsx";
|
|
8
8
|
import { useTimeWindow } from "../contexts/TimeWindowContext.tsx";
|
|
9
9
|
import { useToastContext } from "../contexts/ToastContext.tsx";
|
|
10
|
+
import type { SortDirection, SortField } from "../types/sort.ts";
|
|
10
11
|
|
|
11
12
|
function formatSessionSummary(session: AgentSessionAggregate): string {
|
|
12
13
|
const effectiveTokens = session.totals.input + session.totals.output;
|
|
@@ -54,7 +55,9 @@ interface DashboardKeyboardState {
|
|
|
54
55
|
sidebarCollapsed: boolean;
|
|
55
56
|
filterQuery: string;
|
|
56
57
|
isFiltering: boolean;
|
|
57
|
-
sortField:
|
|
58
|
+
sortField: SortField;
|
|
59
|
+
sortDirection: SortDirection;
|
|
60
|
+
showSortOverlay: boolean;
|
|
58
61
|
pendingG: boolean;
|
|
59
62
|
scrollOffset: number;
|
|
60
63
|
limitSelectedIndex: number;
|
|
@@ -76,13 +79,15 @@ interface DashboardKeyboardActions {
|
|
|
76
79
|
setSidebarCollapsed: (fn: (prev: boolean) => boolean) => void;
|
|
77
80
|
setFilterQuery: (fn: (prev: string) => string) => void;
|
|
78
81
|
setIsFiltering: (val: boolean) => void;
|
|
79
|
-
setSortField: (fn: (prev:
|
|
82
|
+
setSortField: (fn: (prev: SortField) => SortField) => void;
|
|
83
|
+
setSortDirection: (fn: (prev: SortDirection) => SortDirection) => void;
|
|
80
84
|
setPendingG: (val: boolean) => void;
|
|
81
85
|
setScrollOffset: (val: number) => void;
|
|
82
86
|
setLimitSelectedIndex: (fn: (prev: number) => number) => void;
|
|
83
87
|
setDriverDimension: (fn: (prev: DriverDimension) => DriverDimension) => void;
|
|
84
88
|
setSelectedDriverIndex: (fn: (prev: number) => number) => void;
|
|
85
89
|
setActiveDriverFilter: (val: string | null) => void;
|
|
90
|
+
setShowSortOverlay: (val: boolean) => void;
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
interface UseDashboardKeyboardProps {
|
|
@@ -115,7 +120,7 @@ export function useDashboardKeyboard({
|
|
|
115
120
|
|
|
116
121
|
useEffect(() => {
|
|
117
122
|
isFilteringRef.current = state.isFiltering;
|
|
118
|
-
modalOpenRef.current = state.showHelp || state.showSessionDrawer;
|
|
123
|
+
modalOpenRef.current = state.showHelp || state.showSessionDrawer || state.showSortOverlay;
|
|
119
124
|
pendingGRef.current = state.pendingG;
|
|
120
125
|
sessionsRef.current = processedSessions;
|
|
121
126
|
focusedPanelRef.current = state.focusedPanel;
|
|
@@ -129,6 +134,7 @@ export function useDashboardKeyboard({
|
|
|
129
134
|
state.isFiltering,
|
|
130
135
|
state.showHelp,
|
|
131
136
|
state.showSessionDrawer,
|
|
137
|
+
state.showSortOverlay,
|
|
132
138
|
state.pendingG,
|
|
133
139
|
processedSessions,
|
|
134
140
|
state.focusedPanel,
|
|
@@ -242,8 +248,9 @@ export function useDashboardKeyboard({
|
|
|
242
248
|
return;
|
|
243
249
|
}
|
|
244
250
|
|
|
251
|
+
// Open sort overlay menu
|
|
245
252
|
if (key.name === "s") {
|
|
246
|
-
actions.
|
|
253
|
+
actions.setShowSortOverlay(true);
|
|
247
254
|
return;
|
|
248
255
|
}
|
|
249
256
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { animationTick } from "./useAnimationTick.ts";
|
|
2
3
|
|
|
3
4
|
export interface UseEntranceAnimationOptions {
|
|
4
5
|
durationMs?: number;
|
|
6
|
+
/** @deprecated Ignored — animations now use the global 30 Hz tick. */
|
|
5
7
|
fps?: number;
|
|
6
8
|
}
|
|
7
9
|
|
|
@@ -11,26 +13,30 @@ export interface UseEntranceAnimationOptions {
|
|
|
11
13
|
* Use this to fade in new items in a list.
|
|
12
14
|
*/
|
|
13
15
|
export function useEntranceAnimation(options: UseEntranceAnimationOptions = {}): number {
|
|
14
|
-
const { durationMs = 400
|
|
16
|
+
const { durationMs = 400 } = options;
|
|
15
17
|
const mountTime = useRef(Date.now());
|
|
16
18
|
const [intensity, setIntensity] = useState(0);
|
|
19
|
+
const unsubRef = useRef<(() => void) | null>(null);
|
|
17
20
|
|
|
18
21
|
useEffect(() => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const tick = () => {
|
|
22
|
-
const elapsed = Date.now() - mountTime.current;
|
|
22
|
+
unsubRef.current = animationTick.subscribe((now) => {
|
|
23
|
+
const elapsed = now - mountTime.current;
|
|
23
24
|
const progress = Math.min(1, elapsed / durationMs);
|
|
24
25
|
const eased = 1 - (1 - progress) ** 2;
|
|
25
26
|
setIntensity(eased);
|
|
26
27
|
|
|
27
|
-
if (progress
|
|
28
|
-
|
|
28
|
+
if (progress >= 1) {
|
|
29
|
+
setIntensity(1);
|
|
30
|
+
unsubRef.current?.();
|
|
31
|
+
unsubRef.current = null;
|
|
29
32
|
}
|
|
30
|
-
};
|
|
33
|
+
});
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
return () => {
|
|
36
|
+
unsubRef.current?.();
|
|
37
|
+
unsubRef.current = null;
|
|
38
|
+
};
|
|
39
|
+
}, [durationMs]);
|
|
34
40
|
|
|
35
41
|
return intensity;
|
|
36
42
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { NotificationEvent } from "@tokentop/plugin-sdk";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
import { notificationBus } from "@/plugins/notification-bus.ts";
|
|
4
|
+
import { useToastContext } from "../contexts/ToastContext.tsx";
|
|
5
|
+
|
|
6
|
+
function severityToToastType(
|
|
7
|
+
severity: NotificationEvent["severity"],
|
|
8
|
+
): "info" | "warning" | "error" {
|
|
9
|
+
switch (severity) {
|
|
10
|
+
case "critical":
|
|
11
|
+
return "error";
|
|
12
|
+
case "warning":
|
|
13
|
+
return "warning";
|
|
14
|
+
default:
|
|
15
|
+
return "info";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useNotificationBridge(): void {
|
|
20
|
+
const { showToast } = useToastContext();
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const handler = (event: NotificationEvent) => {
|
|
24
|
+
showToast(event.title, severityToToastType(event.severity));
|
|
25
|
+
};
|
|
26
|
+
notificationBus.setToastHandler(handler);
|
|
27
|
+
return () => notificationBus.setToastHandler(null);
|
|
28
|
+
}, [showToast]);
|
|
29
|
+
}
|