@tokentop/ttop 0.6.1 → 0.8.0
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 +3 -2
- package/package.json +9 -6
- package/src/agents/aggregator.test.ts +269 -1
- package/src/agents/aggregator.ts +196 -8
- package/src/agents/costing.test.ts +584 -0
- package/src/agents/costing.ts +144 -30
- package/src/agents/types.ts +7 -0
- package/src/demo/simulator.test.ts +92 -0
- package/src/demo/simulator.ts +579 -211
- package/src/plugins/agents/index.ts +1 -0
- package/src/plugins/plugin-context-factory.ts +9 -1
- package/src/plugins/providers/anthropic.test.ts +145 -0
- package/src/plugins/providers/anthropic.ts +18 -12
- package/src/plugins/providers/chutes.ts +2 -0
- package/src/plugins/providers/github-copilot.ts +3 -0
- package/src/plugins/providers/minimax.ts +2 -0
- package/src/plugins/providers/openai-api.ts +4 -0
- package/src/plugins/providers/perplexity.test.ts +24 -0
- package/src/plugins/providers/perplexity.ts +1 -0
- package/src/plugins/providers/zai.ts +2 -0
- package/src/pricing/estimator.ts +7 -0
- package/src/pricing/index.ts +5 -0
- package/src/pricing/models-dev.test.ts +117 -0
- package/src/pricing/models-dev.ts +22 -0
- package/src/storage/persistence-service.test.ts +201 -0
- package/src/storage/persistence-service.ts +54 -5
- package/src/storage/repos/agentSessions.ts +49 -0
- package/src/tui/App.tsx +4 -4
- package/src/tui/components/SessionDetailsDrawer.tsx +29 -1
- package/src/tui/components/SessionsTable.tsx +25 -7
- package/src/tui/contexts/AgentSessionContext.tsx +59 -21
- package/src/tui/contexts/PluginContext.tsx +31 -10
- package/src/tui/utils/scrollFollow.test.ts +71 -0
- package/src/tui/utils/scrollFollow.ts +12 -0
- package/src/tui/views/Dashboard.tsx +0 -6
- package/src/tui/views/RealTimeDashboard.tsx +105 -38
- package/src/version.ts +1 -1
- package/tsconfig.json +43 -0
|
@@ -5,10 +5,17 @@ import {
|
|
|
5
5
|
useCallback,
|
|
6
6
|
useContext,
|
|
7
7
|
useEffect,
|
|
8
|
+
useMemo,
|
|
8
9
|
useRef,
|
|
9
10
|
useState,
|
|
10
11
|
} from "react";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
aggregateSessionUsage,
|
|
14
|
+
deduplicateAggregates,
|
|
15
|
+
isCrossAgentShadow,
|
|
16
|
+
markCrossAgentShadows,
|
|
17
|
+
selectEffectiveSessions,
|
|
18
|
+
} from "@/agents/aggregator.ts";
|
|
12
19
|
import { priceSessions } from "@/agents/costing.ts";
|
|
13
20
|
import type { AgentId, AgentInfo, AgentSessionAggregate } from "@/agents/types.ts";
|
|
14
21
|
import { createPluginContext } from "@/plugins/plugin-context-factory.ts";
|
|
@@ -48,10 +55,12 @@ interface AgentSessionProviderProps {
|
|
|
48
55
|
*/
|
|
49
56
|
function computeSessionFingerprint(sessions: AgentSessionAggregate[]): string {
|
|
50
57
|
// Build a compact string from the fields that actually affect the UI.
|
|
51
|
-
//
|
|
58
|
+
// Shadow bit is included so that flipping a session's dedup status
|
|
59
|
+
// (gaining or losing its cross-agent shadow mark) triggers a re-render.
|
|
52
60
|
let fp = `${sessions.length}:`;
|
|
53
61
|
for (const s of sessions) {
|
|
54
|
-
|
|
62
|
+
const shadow = isCrossAgentShadow(s) ? "1" : "0";
|
|
63
|
+
fp += `${s.sessionId},${s.status},${s.lastActivityAt},${s.totals.input},${s.totals.output},${s.totalCostUsd ?? 0},${s.requestCount},${shadow};`;
|
|
55
64
|
}
|
|
56
65
|
return fp;
|
|
57
66
|
}
|
|
@@ -68,6 +77,9 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
68
77
|
const { windowMs } = useTimeWindow();
|
|
69
78
|
const hasBackfilled = useRef(false);
|
|
70
79
|
const isRefreshingRef = useRef(false);
|
|
80
|
+
// Bugfix: track first completed load separately so an empty 1h window does not
|
|
81
|
+
// keep re-entering the initial loading state forever.
|
|
82
|
+
const hasLoadedOnceRef = useRef(false);
|
|
71
83
|
const lastFingerprintRef = useRef<string>("");
|
|
72
84
|
|
|
73
85
|
const discoverAgents = useCallback(async (): Promise<AgentInfo[]> => {
|
|
@@ -110,7 +122,17 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
110
122
|
|
|
111
123
|
const http = createSandboxedHttpClient(plugin.id, plugin.permissions);
|
|
112
124
|
const logger = createPluginLogger(plugin.id);
|
|
113
|
-
|
|
125
|
+
// Lazy signal: only create the 120s timer if the plugin actually reads ctx.signal.
|
|
126
|
+
let parseSignal: AbortSignal | undefined;
|
|
127
|
+
const ctx = {
|
|
128
|
+
http,
|
|
129
|
+
logger,
|
|
130
|
+
config: {},
|
|
131
|
+
get signal(): AbortSignal {
|
|
132
|
+
if (!parseSignal) parseSignal = AbortSignal.timeout(120_000);
|
|
133
|
+
return parseSignal;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
114
136
|
|
|
115
137
|
const rawSessions = await plugin.parseSessions(options, ctx);
|
|
116
138
|
|
|
@@ -134,7 +156,7 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
134
156
|
if (isRefreshingRef.current) return;
|
|
135
157
|
isRefreshingRef.current = true;
|
|
136
158
|
|
|
137
|
-
const isInitialLoad =
|
|
159
|
+
const isInitialLoad = !hasLoadedOnceRef.current;
|
|
138
160
|
if (isInitialLoad) {
|
|
139
161
|
setIsLoading(true);
|
|
140
162
|
}
|
|
@@ -193,7 +215,6 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
193
215
|
}
|
|
194
216
|
}
|
|
195
217
|
|
|
196
|
-
// Deduplicate sessions claimed by multiple agents scanning the same directory.
|
|
197
218
|
const dedupedAggregates = deduplicateAggregates(allAggregates);
|
|
198
219
|
if (dedupedAggregates.length < allAggregates.length) {
|
|
199
220
|
debug(
|
|
@@ -203,6 +224,8 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
203
224
|
);
|
|
204
225
|
}
|
|
205
226
|
|
|
227
|
+
markCrossAgentShadows(dedupedAggregates);
|
|
228
|
+
|
|
206
229
|
const pricedSessions = await priceSessions(dedupedAggregates);
|
|
207
230
|
|
|
208
231
|
pricedSessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
@@ -221,11 +244,13 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
221
244
|
return;
|
|
222
245
|
}
|
|
223
246
|
|
|
247
|
+
const effectiveSessions = selectEffectiveSessions(pricedSessions);
|
|
248
|
+
|
|
224
249
|
// Persist to storage — the persistence service handles throttling
|
|
225
250
|
// internally (fingerprint gate + 5-minute per-session throttle).
|
|
226
251
|
// Safe to call every tick; state lives on globalThis, survives HMR.
|
|
227
252
|
const now = Date.now();
|
|
228
|
-
const persistData =
|
|
253
|
+
const persistData = effectiveSessions.map((session) => ({
|
|
229
254
|
session: {
|
|
230
255
|
agentId: session.agentId,
|
|
231
256
|
sessionId: session.sessionId,
|
|
@@ -258,7 +283,7 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
258
283
|
}));
|
|
259
284
|
const persistedCount = persistSessions(persistData);
|
|
260
285
|
debug(
|
|
261
|
-
`Persisted ${persistedCount}/${
|
|
286
|
+
`Persisted ${persistedCount}/${effectiveSessions.length} sessions to storage`,
|
|
262
287
|
undefined,
|
|
263
288
|
"agent-sessions",
|
|
264
289
|
);
|
|
@@ -266,7 +291,11 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
266
291
|
setSessions(pricedSessions);
|
|
267
292
|
setLastRefreshAt(Date.now());
|
|
268
293
|
|
|
269
|
-
debug(
|
|
294
|
+
debug(
|
|
295
|
+
`Session refresh complete`,
|
|
296
|
+
{ count: pricedSessions.length, effective: effectiveSessions.length },
|
|
297
|
+
"agent-sessions",
|
|
298
|
+
);
|
|
270
299
|
} catch (err) {
|
|
271
300
|
const errorMsg = err instanceof Error ? err.message : "Unknown error";
|
|
272
301
|
logError(`Session refresh failed: ${errorMsg}`, undefined, "agent-sessions");
|
|
@@ -275,15 +304,19 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
275
304
|
isRefreshingRef.current = false;
|
|
276
305
|
if (isInitialLoad) {
|
|
277
306
|
setIsLoading(false);
|
|
307
|
+
hasLoadedOnceRef.current = true;
|
|
278
308
|
}
|
|
279
309
|
}
|
|
280
310
|
},
|
|
281
|
-
[
|
|
311
|
+
[discoverAgents, fetchAgentSessions, debug, warn, logError, demoMode, simulator],
|
|
282
312
|
);
|
|
283
313
|
|
|
284
314
|
const refreshSessionsRef = useRef(refreshSessions);
|
|
285
315
|
refreshSessionsRef.current = refreshSessions;
|
|
286
316
|
|
|
317
|
+
const sessionsRef = useRef(sessions);
|
|
318
|
+
sessionsRef.current = sessions;
|
|
319
|
+
|
|
287
320
|
useEffect(() => {
|
|
288
321
|
if (!(demoMode || pluginsInitialized)) return;
|
|
289
322
|
|
|
@@ -304,7 +337,7 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
304
337
|
let timerId: ReturnType<typeof setTimeout> | null = null;
|
|
305
338
|
|
|
306
339
|
const scheduleNext = () => {
|
|
307
|
-
const hasActive =
|
|
340
|
+
const hasActive = sessionsRef.current.some((s) => s.status === "active");
|
|
308
341
|
const interval = hasActive ? ACTIVE_INTERVAL_MS : IDLE_INTERVAL_MS;
|
|
309
342
|
|
|
310
343
|
timerId = setTimeout(() => {
|
|
@@ -324,16 +357,21 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
|
|
|
324
357
|
clearTimeout(timerId);
|
|
325
358
|
}
|
|
326
359
|
};
|
|
327
|
-
}, [autoRefresh,
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
360
|
+
}, [autoRefresh, debug]);
|
|
361
|
+
|
|
362
|
+
const effectiveSessions = useMemo(() => selectEffectiveSessions(sessions), [sessions]);
|
|
363
|
+
|
|
364
|
+
const value: AgentSessionContextValue = useMemo(
|
|
365
|
+
() => ({
|
|
366
|
+
sessions: effectiveSessions,
|
|
367
|
+
agents,
|
|
368
|
+
isLoading,
|
|
369
|
+
lastRefreshAt,
|
|
370
|
+
error,
|
|
371
|
+
refreshSessions,
|
|
372
|
+
}),
|
|
373
|
+
[effectiveSessions, agents, isLoading, lastRefreshAt, error, refreshSessions],
|
|
374
|
+
);
|
|
337
375
|
|
|
338
376
|
return <AgentSessionContext.Provider value={value}>{children}</AgentSessionContext.Provider>;
|
|
339
377
|
}
|
|
@@ -275,7 +275,11 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
275
275
|
return;
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
-
|
|
278
|
+
if (state.loading) {
|
|
279
|
+
debug(`Skipping refresh for ${providerId}: already in flight`, undefined, "refresh");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
279
283
|
const now = Date.now();
|
|
280
284
|
if (state.rateLimitUntil > now) {
|
|
281
285
|
const remainingSec = Math.round((state.rateLimitUntil - now) / 1000);
|
|
@@ -360,19 +364,25 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
360
364
|
|
|
361
365
|
const http = createSandboxedHttpClient(providerId, state.plugin.permissions);
|
|
362
366
|
const logger = createPluginLogger(providerId);
|
|
367
|
+
// Lazy signal: only create the 30s timer if a plugin actually reads ctx.signal.
|
|
368
|
+
let fetchSignal: AbortSignal | undefined;
|
|
369
|
+
const fetchCtx = {
|
|
370
|
+
credentials: creds,
|
|
371
|
+
http,
|
|
372
|
+
logger,
|
|
373
|
+
config: {},
|
|
374
|
+
get signal(): AbortSignal {
|
|
375
|
+
if (!fetchSignal) fetchSignal = AbortSignal.timeout(30_000);
|
|
376
|
+
return fetchSignal;
|
|
377
|
+
},
|
|
378
|
+
};
|
|
363
379
|
|
|
364
380
|
const fetchResult = await safeInvoke(
|
|
365
381
|
providerId,
|
|
366
382
|
"fetchUsage",
|
|
367
383
|
() =>
|
|
368
384
|
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
|
-
}),
|
|
385
|
+
state.plugin.fetchUsage(fetchCtx),
|
|
376
386
|
),
|
|
377
387
|
{
|
|
378
388
|
// Count non-rate-limit errors as circuit breaker failures.
|
|
@@ -513,14 +523,25 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
513
523
|
await Promise.all(configuredProviders.map(refreshProvider));
|
|
514
524
|
}, [providers, refreshProvider, info]);
|
|
515
525
|
|
|
526
|
+
const refreshProviderRef = useRef(refreshProvider);
|
|
527
|
+
const refreshAllProvidersRef = useRef(refreshAllProviders);
|
|
528
|
+
refreshProviderRef.current = refreshProvider;
|
|
529
|
+
refreshAllProvidersRef.current = refreshAllProviders;
|
|
530
|
+
|
|
531
|
+
const stableRefreshProvider = useCallback(
|
|
532
|
+
(providerId: string) => refreshProviderRef.current(providerId),
|
|
533
|
+
[],
|
|
534
|
+
);
|
|
535
|
+
const stableRefreshAllProviders = useCallback(() => refreshAllProvidersRef.current(), []);
|
|
536
|
+
|
|
516
537
|
const value: PluginContextValue = {
|
|
517
538
|
providers,
|
|
518
539
|
agents,
|
|
519
540
|
themes,
|
|
520
541
|
notifications,
|
|
521
542
|
isInitialized,
|
|
522
|
-
refreshProvider,
|
|
523
|
-
refreshAllProviders,
|
|
543
|
+
refreshProvider: stableRefreshProvider,
|
|
544
|
+
refreshAllProviders: stableRefreshAllProviders,
|
|
524
545
|
};
|
|
525
546
|
|
|
526
547
|
return <PluginContext.Provider value={value}>{children}</PluginContext.Provider>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { computeScrollOffset } from "./scrollFollow.ts";
|
|
3
|
+
|
|
4
|
+
describe("computeScrollOffset", () => {
|
|
5
|
+
test("cursor inside viewport leaves offset unchanged", () => {
|
|
6
|
+
expect(computeScrollOffset(5, 0, 10, 100)).toBe(0);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("cursor at bottom edge leaves offset unchanged", () => {
|
|
10
|
+
expect(computeScrollOffset(9, 0, 10, 100)).toBe(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("cursor past bottom pulls offset down by one row", () => {
|
|
14
|
+
expect(computeScrollOffset(10, 0, 10, 100)).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("cursor past top pulls offset up", () => {
|
|
18
|
+
expect(computeScrollOffset(3, 5, 10, 100)).toBe(3);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("clamps offset to non-negative", () => {
|
|
22
|
+
expect(computeScrollOffset(0, 0, 10, 100)).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("clamps offset to total - visibleRows", () => {
|
|
26
|
+
expect(computeScrollOffset(99, 0, 10, 100)).toBe(90);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("offset stays 0 when content fits in viewport", () => {
|
|
30
|
+
expect(computeScrollOffset(3, 0, 10, 4)).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Regression test for cursor-disappears-then-jump bug: rapid j presses
|
|
34
|
+
// that React batches into a single render must still land with the cursor
|
|
35
|
+
// visible at the bottom edge. Simulating N sequential functional updates
|
|
36
|
+
// (the reducer runs each one in order) verifies the invariant.
|
|
37
|
+
test("batched rapid j-presses keep cursor at bottom edge each step", () => {
|
|
38
|
+
const vis = 10;
|
|
39
|
+
const total = 1000;
|
|
40
|
+
let state = { row: 0, offset: 0 };
|
|
41
|
+
for (let i = 0; i < 50; i++) {
|
|
42
|
+
const nextRow = Math.min(state.row + 1, total - 1);
|
|
43
|
+
const nextOffset = computeScrollOffset(nextRow, state.offset, vis, total);
|
|
44
|
+
state = { row: nextRow, offset: nextOffset };
|
|
45
|
+
expect(state.row).toBeGreaterThanOrEqual(state.offset);
|
|
46
|
+
expect(state.row).toBeLessThan(state.offset + vis);
|
|
47
|
+
}
|
|
48
|
+
expect(state.row).toBe(50);
|
|
49
|
+
expect(state.offset).toBe(41);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("batched rapid k-presses at top keep cursor at top edge", () => {
|
|
53
|
+
const vis = 10;
|
|
54
|
+
const total = 1000;
|
|
55
|
+
let state = { row: 50, offset: 41 };
|
|
56
|
+
for (let i = 0; i < 50; i++) {
|
|
57
|
+
const nextRow = Math.max(state.row - 1, 0);
|
|
58
|
+
const nextOffset = computeScrollOffset(nextRow, state.offset, vis, total);
|
|
59
|
+
state = { row: nextRow, offset: nextOffset };
|
|
60
|
+
expect(state.row).toBeGreaterThanOrEqual(state.offset);
|
|
61
|
+
expect(state.row).toBeLessThan(state.offset + vis);
|
|
62
|
+
}
|
|
63
|
+
expect(state.row).toBe(0);
|
|
64
|
+
expect(state.offset).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("re-clamp when list shrinks below current offset", () => {
|
|
68
|
+
expect(computeScrollOffset(5, 50, 10, 20)).toBe(5);
|
|
69
|
+
expect(computeScrollOffset(19, 50, 10, 20)).toBe(10);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function computeScrollOffset(
|
|
2
|
+
row: number,
|
|
3
|
+
prevOffset: number,
|
|
4
|
+
visibleRows: number,
|
|
5
|
+
total: number,
|
|
6
|
+
): number {
|
|
7
|
+
let next = prevOffset;
|
|
8
|
+
if (row < next) next = row;
|
|
9
|
+
else if (row >= next + visibleRows) next = row - visibleRows + 1;
|
|
10
|
+
const max = Math.max(0, total - visibleRows);
|
|
11
|
+
return Math.max(0, Math.min(next, max));
|
|
12
|
+
}
|
|
@@ -60,12 +60,6 @@ export function Dashboard() {
|
|
|
60
60
|
const cardRefs = useRef<(BoxRenderable | null)[]>([]);
|
|
61
61
|
const inputRef = useRef<InputRenderable>(null);
|
|
62
62
|
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
if (isInitialized) {
|
|
65
|
-
refreshAllProviders();
|
|
66
|
-
}
|
|
67
|
-
}, [isInitialized]);
|
|
68
|
-
|
|
69
63
|
const getMaxUsage = useCallback((state: ProviderState) => {
|
|
70
64
|
if (!state.usage?.limits) return 0;
|
|
71
65
|
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
2
2
|
import { useTerminalDimensions } from "@opentui/react";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type Dispatch,
|
|
5
|
+
type SetStateAction,
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from "react";
|
|
4
13
|
import { notificationBus } from "@/plugins/notification-bus.ts";
|
|
5
14
|
import { classifyProviderError } from "@/utils/error-category.ts";
|
|
6
15
|
import { HelpOverlay } from "../components/HelpOverlay.tsx";
|
|
@@ -20,6 +29,7 @@ import { useDashboardKeyboard } from "../hooks/useDashboardKeyboard.ts";
|
|
|
20
29
|
import type { SortDirection, SortField } from "../types/sort.ts";
|
|
21
30
|
import { SORT_FIELDS } from "../types/sort.ts";
|
|
22
31
|
import { getProviderColor } from "../utils/providerColor.ts";
|
|
32
|
+
import { computeScrollOffset } from "../utils/scrollFollow.ts";
|
|
23
33
|
|
|
24
34
|
export function RealTimeDashboard() {
|
|
25
35
|
const colors = useColors();
|
|
@@ -77,7 +87,13 @@ export function RealTimeDashboard() {
|
|
|
77
87
|
const { showDrawer, isOpen: showSessionDrawer } = useDrawer();
|
|
78
88
|
|
|
79
89
|
const [showHelp, setShowHelp] = useState(false);
|
|
80
|
-
|
|
90
|
+
// Cursor and scroll offset share one state so batched j-presses advance
|
|
91
|
+
// both in lockstep — see setSelectedRow below.
|
|
92
|
+
const [nav, setNav] = useState<{ selectedRow: number; scrollOffset: number }>({
|
|
93
|
+
selectedRow: 0,
|
|
94
|
+
scrollOffset: 0,
|
|
95
|
+
});
|
|
96
|
+
const { selectedRow, scrollOffset } = nav;
|
|
81
97
|
const [focusedPanel, setFocusedPanel] = useState<"sessions" | "sidebar" | "limits">("sessions");
|
|
82
98
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(config.display.sidebarCollapsed);
|
|
83
99
|
const [filterQuery, setFilterQuery] = useState("");
|
|
@@ -86,12 +102,7 @@ export function RealTimeDashboard() {
|
|
|
86
102
|
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
|
87
103
|
const [showSortOverlay, setShowSortOverlay] = useState(false);
|
|
88
104
|
const [pendingG, setPendingG] = useState(false);
|
|
89
|
-
const scrollOffsetRef = useRef(0);
|
|
90
105
|
const selectedSessionIdRef = useRef<string | null>(null);
|
|
91
|
-
const setScrollOffset = useCallback((val: number) => {
|
|
92
|
-
scrollOffsetRef.current = val;
|
|
93
|
-
sessionsScrollboxRef.current?.scrollTo(val);
|
|
94
|
-
}, []);
|
|
95
106
|
const [limitSelectedIndex, setLimitSelectedIndex] = useState(0);
|
|
96
107
|
const [driverDimension, setDriverDimension] = useState<DriverDimension>("model");
|
|
97
108
|
const [selectedDriverIndex, setSelectedDriverIndex] = useState(0);
|
|
@@ -102,6 +113,32 @@ export function RealTimeDashboard() {
|
|
|
102
113
|
|
|
103
114
|
const sessionsScrollboxRef = useRef<ScrollBoxRenderable>(null);
|
|
104
115
|
|
|
116
|
+
const visibleRowsRef = useRef(visibleRows);
|
|
117
|
+
visibleRowsRef.current = visibleRows;
|
|
118
|
+
const sessionsLengthRef = useRef(0);
|
|
119
|
+
|
|
120
|
+
const setSelectedRow = useCallback<Dispatch<SetStateAction<number>>>((arg) => {
|
|
121
|
+
setNav((prev) => {
|
|
122
|
+
const next = typeof arg === "function" ? arg(prev.selectedRow) : arg;
|
|
123
|
+
const clamped = Math.max(0, next);
|
|
124
|
+
const newOffset = computeScrollOffset(
|
|
125
|
+
clamped,
|
|
126
|
+
prev.scrollOffset,
|
|
127
|
+
visibleRowsRef.current,
|
|
128
|
+
sessionsLengthRef.current,
|
|
129
|
+
);
|
|
130
|
+
if (clamped === prev.selectedRow && newOffset === prev.scrollOffset) return prev;
|
|
131
|
+
return { selectedRow: clamped, scrollOffset: newOffset };
|
|
132
|
+
});
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
const setScrollOffset = useCallback((val: number) => {
|
|
136
|
+
setNav((prev) => {
|
|
137
|
+
if (val === prev.scrollOffset) return prev;
|
|
138
|
+
return { ...prev, scrollOffset: val };
|
|
139
|
+
});
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
105
142
|
const configuredProviders = useMemo(() => {
|
|
106
143
|
return Array.from(providers.values())
|
|
107
144
|
.filter((p) => p.configured)
|
|
@@ -123,7 +160,9 @@ export function RealTimeDashboard() {
|
|
|
123
160
|
);
|
|
124
161
|
|
|
125
162
|
const baseFilteredSessions = useMemo(() => {
|
|
126
|
-
|
|
163
|
+
if (windowMs === null && !filterQuery) return agentSessions;
|
|
164
|
+
|
|
165
|
+
let result = agentSessions;
|
|
127
166
|
|
|
128
167
|
if (windowMs !== null) {
|
|
129
168
|
const cutoff = Date.now() - windowMs;
|
|
@@ -255,8 +294,12 @@ export function RealTimeDashboard() {
|
|
|
255
294
|
return result;
|
|
256
295
|
}, [baseFilteredSessions, activeDriverFilter, driverDimension, sortField, sortDirection]);
|
|
257
296
|
|
|
258
|
-
|
|
259
|
-
|
|
297
|
+
sessionsLengthRef.current = processedSessions.length;
|
|
298
|
+
|
|
299
|
+
// A keypress and a refresh tick can batch into one render; if they do,
|
|
300
|
+
// `prev` may be the user's new row while `selectedSessionIdRef` is still
|
|
301
|
+
// the session they moved *from*. Trusting `prev` when it resolves to a
|
|
302
|
+
// valid session prevents a snap-back bounce.
|
|
260
303
|
useLayoutEffect(() => {
|
|
261
304
|
if (processedSessions.length === 0) {
|
|
262
305
|
setSelectedRow(0);
|
|
@@ -265,24 +308,40 @@ export function RealTimeDashboard() {
|
|
|
265
308
|
return;
|
|
266
309
|
}
|
|
267
310
|
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
311
|
+
setSelectedRow((prev) => {
|
|
312
|
+
const currentSession = processedSessions[prev];
|
|
313
|
+
|
|
314
|
+
if (currentSession) {
|
|
315
|
+
if (currentSession.sessionId === selectedSessionIdRef.current) {
|
|
316
|
+
return prev;
|
|
317
|
+
}
|
|
318
|
+
if (selectedSessionIdRef.current) {
|
|
319
|
+
const trackedIndex = processedSessions.findIndex(
|
|
320
|
+
(s) => s.sessionId === selectedSessionIdRef.current,
|
|
321
|
+
);
|
|
322
|
+
if (trackedIndex !== -1 && trackedIndex !== prev) {
|
|
323
|
+
return trackedIndex;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
selectedSessionIdRef.current = currentSession.sessionId;
|
|
327
|
+
return prev;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (selectedSessionIdRef.current) {
|
|
331
|
+
const trackedIndex = processedSessions.findIndex(
|
|
332
|
+
(s) => s.sessionId === selectedSessionIdRef.current,
|
|
333
|
+
);
|
|
334
|
+
if (trackedIndex !== -1) return trackedIndex;
|
|
335
|
+
selectedSessionIdRef.current = null;
|
|
275
336
|
}
|
|
276
|
-
// Tracked session was filtered out — fall through to clamp
|
|
277
|
-
selectedSessionIdRef.current = null;
|
|
278
|
-
}
|
|
279
337
|
|
|
280
|
-
|
|
281
|
-
|
|
338
|
+
const clamped = Math.min(prev, processedSessions.length - 1);
|
|
339
|
+
selectedSessionIdRef.current = processedSessions[clamped]?.sessionId ?? null;
|
|
340
|
+
return clamped;
|
|
341
|
+
});
|
|
282
342
|
}, [processedSessions, setScrollOffset]);
|
|
283
343
|
|
|
284
|
-
|
|
285
|
-
useEffect(() => {
|
|
344
|
+
useLayoutEffect(() => {
|
|
286
345
|
const session = processedSessions[selectedRow];
|
|
287
346
|
if (session) {
|
|
288
347
|
selectedSessionIdRef.current = session.sessionId;
|
|
@@ -290,19 +349,22 @@ export function RealTimeDashboard() {
|
|
|
290
349
|
}, [selectedRow, processedSessions]);
|
|
291
350
|
|
|
292
351
|
useLayoutEffect(() => {
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
352
|
+
if (processedSessions.length === 0) return;
|
|
353
|
+
setNav((prev) => {
|
|
354
|
+
const newOffset = computeScrollOffset(
|
|
355
|
+
prev.selectedRow,
|
|
356
|
+
prev.scrollOffset,
|
|
357
|
+
visibleRows,
|
|
358
|
+
processedSessions.length,
|
|
359
|
+
);
|
|
360
|
+
if (newOffset === prev.scrollOffset) return prev;
|
|
361
|
+
return { ...prev, scrollOffset: newOffset };
|
|
362
|
+
});
|
|
363
|
+
}, [processedSessions.length, visibleRows]);
|
|
302
364
|
|
|
303
|
-
|
|
304
|
-
sessionsScrollboxRef.current
|
|
305
|
-
}, [
|
|
365
|
+
useLayoutEffect(() => {
|
|
366
|
+
sessionsScrollboxRef.current?.scrollTo(scrollOffset);
|
|
367
|
+
}, [scrollOffset]);
|
|
306
368
|
|
|
307
369
|
const openSessionDrawer = useCallback(() => {
|
|
308
370
|
const session = processedSessions[selectedRow];
|
|
@@ -325,7 +387,7 @@ export function RealTimeDashboard() {
|
|
|
325
387
|
sortField,
|
|
326
388
|
sortDirection,
|
|
327
389
|
pendingG,
|
|
328
|
-
scrollOffset
|
|
390
|
+
scrollOffset,
|
|
329
391
|
limitSelectedIndex,
|
|
330
392
|
providerCount: configuredProviders.length,
|
|
331
393
|
driverDimension,
|
|
@@ -409,7 +471,10 @@ export function RealTimeDashboard() {
|
|
|
409
471
|
}
|
|
410
472
|
};
|
|
411
473
|
|
|
412
|
-
const activeCount =
|
|
474
|
+
const activeCount = useMemo(
|
|
475
|
+
() => agentSessions.reduce((n, s) => (s.status === "active" ? n + 1 : n), 0),
|
|
476
|
+
[agentSessions],
|
|
477
|
+
);
|
|
413
478
|
|
|
414
479
|
return (
|
|
415
480
|
<box flexDirection="column" flexGrow={1} padding={1} gap={1} overflow="hidden">
|
|
@@ -491,6 +556,8 @@ export function RealTimeDashboard() {
|
|
|
491
556
|
ref={sessionsScrollboxRef}
|
|
492
557
|
sessions={processedSessions}
|
|
493
558
|
selectedRow={selectedRow}
|
|
559
|
+
scrollOffset={scrollOffset}
|
|
560
|
+
visibleRows={visibleRows}
|
|
494
561
|
isLoading={isLoading}
|
|
495
562
|
isFiltering={isFiltering}
|
|
496
563
|
filterQuery={filterQuery}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.8.0"; // x-release-please-version
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
|
|
9
|
+
// OpenTUI React JSX configuration
|
|
10
|
+
"jsx": "react-jsx",
|
|
11
|
+
"jsxImportSource": "@opentui/react",
|
|
12
|
+
|
|
13
|
+
// Bundler mode
|
|
14
|
+
"moduleResolution": "bundler",
|
|
15
|
+
"allowImportingTsExtensions": true,
|
|
16
|
+
"verbatimModuleSyntax": true,
|
|
17
|
+
"noEmit": true,
|
|
18
|
+
"allowJs": true,
|
|
19
|
+
|
|
20
|
+
// Strict mode
|
|
21
|
+
"strict": true,
|
|
22
|
+
"skipLibCheck": true,
|
|
23
|
+
"noFallthroughCasesInSwitch": true,
|
|
24
|
+
"noUncheckedIndexedAccess": true,
|
|
25
|
+
"noImplicitOverride": true,
|
|
26
|
+
|
|
27
|
+
// Path mapping
|
|
28
|
+
"paths": {
|
|
29
|
+
"@/*": ["./src/*"],
|
|
30
|
+
"@plugins/*": ["./src/plugins/*"],
|
|
31
|
+
"@tui/*": ["./src/tui/*"],
|
|
32
|
+
"@utils/*": ["./src/utils/*"]
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// Additional checks
|
|
36
|
+
"noUnusedLocals": true,
|
|
37
|
+
"noUnusedParameters": true,
|
|
38
|
+
"exactOptionalPropertyTypes": true,
|
|
39
|
+
"noPropertyAccessFromIndexSignature": false
|
|
40
|
+
},
|
|
41
|
+
"include": ["src/**/*", "templates/**/*"],
|
|
42
|
+
"exclude": ["dist", "node_modules"]
|
|
43
|
+
}
|