@hoox-sh/hoox-tui 0.3.2 → 0.4.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.
Files changed (51) hide show
  1. package/dist/main.js +1069 -2734
  2. package/package.json +3 -3
  3. package/src/app.tsx +15 -11
  4. package/src/components/shared/shared.test.tsx +2 -2
  5. package/src/components/ui/connection-toasts.ts +6 -0
  6. package/src/components/ui/dialog-state.ts +2 -0
  7. package/src/components/ui/dialog.tsx +0 -13
  8. package/src/components/views/ai-chat.tsx +6 -1
  9. package/src/components/views/config-editor/code-editor.tsx +8 -6
  10. package/src/components/views/config-editor.test.tsx +20 -17
  11. package/src/components/views/config-editor.tsx +8 -6
  12. package/src/components/views/dashboard/model-health-section.tsx +38 -46
  13. package/src/components/views/dashboard/pyne-health-section.tsx +36 -41
  14. package/src/components/views/dashboard.tsx +25 -28
  15. package/src/components/views/kv-viewer.test.tsx +4 -16
  16. package/src/components/views/kv-viewer.tsx +9 -13
  17. package/src/components/views/logs-viewer.test.tsx +4 -4
  18. package/src/components/views/queue-depth.test.tsx +5 -7
  19. package/src/components/views/queue-depth.tsx +10 -14
  20. package/src/components/views/secrets-viewer.test.tsx +1 -1
  21. package/src/components/views/secrets-viewer.tsx +11 -15
  22. package/src/components/views/service-manager.tsx +1 -0
  23. package/src/components/views/settings.test.tsx +2 -7
  24. package/src/components/views/settings.tsx +43 -60
  25. package/src/components/views/setup-wizard.test.tsx +12 -14
  26. package/src/components/views/setup-wizard.tsx +61 -59
  27. package/src/components/views/worker-detail.test.tsx +2 -2
  28. package/src/components/views/worker-settings.tsx +6 -6
  29. package/src/components/views/workers-overview.tsx +1 -1
  30. package/src/hooks/hooks.test.ts +10 -174
  31. package/src/hooks/index.ts +1 -3
  32. package/src/hooks/renderer-ref.ts +1 -0
  33. package/src/hooks/use-polling.ts +12 -0
  34. package/src/services/cli-bridge/index.ts +56 -12
  35. package/src/services/cli-bridge/standalone.ts +4 -1
  36. package/src/services/cli-bridge/types.ts +6 -9
  37. package/src/services/cli-bridge.test.ts +20 -4
  38. package/src/services/clipboard.ts +2 -2
  39. package/src/stores/service-store.test.ts +29 -26
  40. package/src/stores/session-restore.test.ts +2 -0
  41. package/src/test-setup.ts +5 -13
  42. package/src/test-utils.ts +4 -24
  43. package/src/types.ts +9 -47
  44. package/src/utils/colors.test.ts +8 -3
  45. package/src/view-registry.test.ts +2 -19
  46. package/src/view-registry.tsx +7 -20
  47. package/src/components/shared/animated-border.tsx +0 -58
  48. package/src/components/shared/keybinding-hint.tsx +0 -37
  49. package/src/components/ui/select.tsx +0 -49
  50. package/src/hooks/use-keyboard.ts +0 -130
  51. package/src/hooks/use-service-data.ts +0 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoox-sh/hoox-tui",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Hoox algorithmic trading terminal operations center — OpenTUI dashboard",
5
5
  "type": "module",
6
6
  "main": "./dist/main.js",
@@ -32,7 +32,7 @@
32
32
  "prepublishOnly": "bun run typecheck && bun run build"
33
33
  },
34
34
  "dependencies": {
35
- "@hoox-sh/hoox-shared": "^1.3.0",
35
+ "@hoox-sh/hoox-shared": "^1.6.0",
36
36
  "@opentui/core": "0.5.1",
37
37
  "@opentui/react": "0.5.1",
38
38
  "immer": "^11.1.8",
@@ -41,7 +41,7 @@
41
41
  "zustand": "^5.0.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@cloudflare/workers-types": "^5.20260708.1",
44
+ "@cloudflare/workers-types": "^5.20261001.1",
45
45
  "@hoox/test-utils": "^0.0.1",
46
46
  "@types/bun": "latest",
47
47
  "typescript": "^6.0.3"
package/src/app.tsx CHANGED
@@ -20,7 +20,12 @@
20
20
  */
21
21
  import { useState, useEffect, useCallback } from "react";
22
22
  import { useKeyboard } from "@opentui/react";
23
- import { useServiceStore, useUIStore, Colors } from "@hoox-sh/hoox-shared";
23
+ import {
24
+ useServiceStore,
25
+ useUIStore,
26
+ useConfigStore,
27
+ Colors,
28
+ } from "@hoox-sh/hoox-shared";
24
29
  import { restoreSession, saveSession } from "@hoox-sh/hoox-shared";
25
30
  import type { ViewId } from "@hoox-sh/hoox-shared";
26
31
 
@@ -41,11 +46,7 @@ import {
41
46
  toastReconnectedMode,
42
47
  } from "./components/ui/connection-toasts";
43
48
  import { getRendererRef } from "./hooks";
44
- import {
45
- DialogProvider,
46
- useDialog,
47
- type DialogHandle,
48
- } from "./components/ui/dialog";
49
+ import { DialogProvider, useDialog } from "./components/ui/dialog";
49
50
  import {
50
51
  getViewFactory,
51
52
  getViewShortcutMap,
@@ -132,11 +133,15 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
132
133
  // Shared restoreSession validates all 16 ViewIds (unknown → dashboard).
133
134
  useEffect(() => {
134
135
  let cancelled = false;
135
- (async () => {
136
+ void (async () => {
136
137
  const session = await restoreSession();
137
138
  if (cancelled) return;
138
139
 
139
- const view: ViewId = session.activeView;
140
+ const preferred = useConfigStore.getState().defaultView;
141
+ const view: ViewId =
142
+ !session.fromDisk && isRegisteredViewId(preferred)
143
+ ? preferred
144
+ : session.activeView;
140
145
  const expanded = session.sidebarExpanded;
141
146
 
142
147
  if (isRegisteredViewId(view)) {
@@ -170,7 +175,7 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
170
175
  }
171
176
  let cancelled = false;
172
177
 
173
- (async () => {
178
+ void (async () => {
174
179
  const store = useServiceStore.getState();
175
180
  const conn = resolveTuiConnectionEnv();
176
181
  await tuiDevLog.info("connection", "startup data load begin", {
@@ -228,8 +233,7 @@ function AppRootInner({ safeMode = false }: { safeMode?: boolean }) {
228
233
  if (result.success && result.data) {
229
234
  const raw = result.data as Record<string, unknown>;
230
235
  const rawWorkers = (raw.workers ?? raw.status ?? raw) as
231
- | unknown[]
232
- | Record<string, unknown>;
236
+ unknown[] | Record<string, unknown>;
233
237
  const parsed = Array.isArray(rawWorkers)
234
238
  ? rawWorkers
235
239
  : typeof rawWorkers === "object" && rawWorkers !== null
@@ -395,13 +395,13 @@ describe("Shared Components", () => {
395
395
  it("filters to matching commands", () => {
396
396
  const result = filterCommands(SAMPLE_COMMANDS, "dash");
397
397
  expect(result.length).toBe(1);
398
- expect(result[0].name).toBe("Dashboard");
398
+ expect(result[0]?.name).toBe("Dashboard");
399
399
  });
400
400
 
401
401
  it("sorts by score (best match first)", () => {
402
402
  const result = filterCommands(SAMPLE_COMMANDS, "theme");
403
403
  expect(result.length).toBe(2);
404
- expect(result[0].name).toBe("Dark Theme"); // or Light Theme
404
+ expect(result[0]?.name).toBe("Dark Theme"); // or Light Theme
405
405
  });
406
406
 
407
407
  it("deduplicates by id", () => {
@@ -17,16 +17,19 @@ import { remoteAuthMissingHint } from "../../services/tui-connection";
17
17
 
18
18
  // ─── Pure message builders (testable without toast side effects) ─────────────
19
19
 
20
+ /** Status-bar toast copy for a successful LOCAL/REMOTE connect. */
20
21
  export function messageConnected(mode: TuiMode, host: string): string {
21
22
  const label = mode === "remote" ? "REMOTE" : "LOCAL";
22
23
  return `Connected · ${label} · ${host}`;
23
24
  }
24
25
 
26
+ /** Status-bar toast copy when the LOCAL/REMOTE session drops. */
25
27
  export function messageConnectionLost(mode: TuiMode, host: string): string {
26
28
  const label = mode === "remote" ? "REMOTE" : "LOCAL";
27
29
  return `Connection lost · ${label} · ${host}`;
28
30
  }
29
31
 
32
+ /** Status-bar toast copy after a reconnect, including downtime. */
30
33
  export function messageReconnected(
31
34
  mode: TuiMode,
32
35
  host: string,
@@ -38,15 +41,18 @@ export function messageReconnected(
38
41
  return `Reconnected · ${label} · ${host} · ${duration} downtime`;
39
42
  }
40
43
 
44
+ /** Status-bar toast copy when auth is rejected. */
41
45
  export function messageAuthRequired(mode: TuiMode, host: string): string {
42
46
  const label = mode === "remote" ? "REMOTE" : "LOCAL";
43
47
  return `Auth failed · ${label} · ${host}`;
44
48
  }
45
49
 
50
+ /** Status-bar toast copy when remote mode has no API credentials. */
46
51
  export function messageAuthMissing(host: string): string {
47
52
  return `No API credentials · REMOTE · ${host}`;
48
53
  }
49
54
 
55
+ /** Status-bar toast copy when the TUI starts without a live connection. */
50
56
  export function messageOfflineStartup(
51
57
  mode: TuiMode,
52
58
  host: string,
@@ -6,10 +6,12 @@
6
6
  /** Module-level open count for DialogProvider (avoids React store cycles). */
7
7
  let openDialogCount = 0;
8
8
 
9
+ /** Record how many Hoox dialogs are currently open (shell overlay gate). */
9
10
  export function setOpenDialogCount(n: number): void {
10
11
  openDialogCount = Math.max(0, n);
11
12
  }
12
13
 
14
+ /** True when any in-house dialog owns the keyboard. */
13
15
  export function isHooxDialogOpen(): boolean {
14
16
  return openDialogCount > 0;
15
17
  }
@@ -541,16 +541,3 @@ export function showLoading(dialog: DialogHandle, message: string): () => void {
541
541
  const id = dialog.show({ message });
542
542
  return () => dialog.close(id);
543
543
  }
544
-
545
- // Re-export for select.tsx / tests that referenced dialog keyboard helpers
546
- export type DialogId = string | number;
547
-
548
- /** @deprecated No-op; local keyboard is handled inside overlays. */
549
- export function useDialogKeyboard(
550
- _handler: (key: { name?: string }) => void,
551
- _dialogId?: DialogId
552
- ): void {
553
- // Overlays own their own useKeyboard; this stub avoids @opentui-ui/dialog.
554
- void _handler;
555
- void _dialogId;
556
- }
@@ -229,7 +229,12 @@ export function AiChatView() {
229
229
  const [historyReady, setHistoryReady] = useState(false);
230
230
  const [inputText, setInputText] = useState("");
231
231
  const [selectedModel, setSelectedModel] = useState<AiModelOption>(
232
- AI_MODEL_OPTIONS[0]
232
+ () =>
233
+ AI_MODEL_OPTIONS[0] ?? {
234
+ id: "workers-ai",
235
+ label: "Workers AI (Llama 3.1)",
236
+ provider: "cloudflare",
237
+ }
233
238
  );
234
239
  const [streamingContent, setStreamingContent] = useState("");
235
240
  const [isStreaming, setIsStreaming] = useState(false);
@@ -159,7 +159,7 @@ export function tokenizeTomlLine(line: string): TokenSpan[] {
159
159
  // ─── Syntax Highlighting — JSON ───────────────────────────────────────────────
160
160
 
161
161
  /** Color map for JSON token types. */
162
- const JSON_COLORS: Record<string, { fg: string; bold: boolean }> = {
162
+ const JSON_COLORS = {
163
163
  brace: { fg: Colors.foreground, bold: false },
164
164
  key: { fg: Colors.accent, bold: false },
165
165
  colon: { fg: Colors.foreground, bold: false },
@@ -171,7 +171,7 @@ const JSON_COLORS: Record<string, { fg: string; bold: boolean }> = {
171
171
  whitespace: { fg: Colors.foreground, bold: false },
172
172
  error: { fg: Colors.error, bold: true },
173
173
  comment: { fg: Colors.muted, bold: false },
174
- };
174
+ } as const;
175
175
 
176
176
  /**
177
177
  * Tokenize a single line of JSON text into colored spans.
@@ -202,8 +202,9 @@ export function tokenizeJsonLine(line: string): TokenSpan[] {
202
202
  }
203
203
 
204
204
  // Braces/brackets at start
205
- if ("{]}[".includes(trimmed[0])) {
206
- spans.push({ text: trimmed[0], color: JSON_COLORS.brace.fg, bold: true });
205
+ const firstChar = trimmed[0];
206
+ if (firstChar !== undefined && "{]}[".includes(firstChar)) {
207
+ spans.push({ text: firstChar, color: JSON_COLORS.brace.fg, bold: true });
207
208
  const rest = tokenizeJsonLine(trimmed.slice(1));
208
209
  spans.push(...rest);
209
210
  return spans;
@@ -218,6 +219,7 @@ export function tokenizeJsonLine(line: string): TokenSpan[] {
218
219
  trimmed.endsWith("]")
219
220
  ) {
220
221
  const last = trimmed[trimmed.length - 1];
222
+ if (last === undefined) return spans;
221
223
  const prefix = trimmed.slice(0, -1);
222
224
  const prefixSpans = tokenizeJsonLine(prefix);
223
225
  spans.push(...prefixSpans);
@@ -400,8 +402,8 @@ export function validateSyntax(
400
402
  const colMatch =
401
403
  msg.match(/column\s+(\d+)/i) ?? msg.match(/position\s+(\d+)/i);
402
404
  errors.push({
403
- line: lineMatch ? parseInt(lineMatch[1]) : 1,
404
- column: colMatch ? parseInt(colMatch[1]) : 0,
405
+ line: lineMatch?.[1] !== undefined ? parseInt(lineMatch[1], 10) : 1,
406
+ column: colMatch?.[1] !== undefined ? parseInt(colMatch[1], 10) : 0,
405
407
  message: msg,
406
408
  });
407
409
  }
@@ -101,22 +101,25 @@ describe("ConfigEditor", () => {
101
101
  describe("CONFIG_TREE_BLUEPRINT", () => {
102
102
  it("has config directory as root", async () => {
103
103
  expect(CONFIG_TREE_BLUEPRINT.length).toBeGreaterThan(0);
104
- expect(CONFIG_TREE_BLUEPRINT[0].name).toBe("config");
105
- expect(CONFIG_TREE_BLUEPRINT[0].type).toBe("directory");
104
+ expect(CONFIG_TREE_BLUEPRINT[0]?.name).toBe("config");
105
+ expect(CONFIG_TREE_BLUEPRINT[0]?.type).toBe("directory");
106
106
  });
107
107
 
108
108
  it("contains config files without secret-like .env entries", async () => {
109
- const children = CONFIG_TREE_BLUEPRINT[0].children!;
109
+ const root = CONFIG_TREE_BLUEPRINT[0];
110
+ expect(root?.children).toBeDefined();
111
+ const children = root!.children!;
110
112
  // wrangler.toml, trade.config.json, risk.config.json, strategies (no .env)
111
113
  expect(children.length).toBe(4);
112
114
  expect(children.some((c) => c.name === ".env")).toBe(false);
113
115
  });
114
116
 
115
117
  it("strategies directory has 3 config files", async () => {
116
- const strategies = CONFIG_TREE_BLUEPRINT[0].children!.find(
117
- (c) => c.name === "strategies"
118
- );
118
+ const root = CONFIG_TREE_BLUEPRINT[0];
119
+ expect(root?.children).toBeDefined();
120
+ const strategies = root!.children!.find((c) => c.name === "strategies");
119
121
  expect(strategies).toBeDefined();
122
+ expect(strategies!.children).toBeDefined();
120
123
  expect(strategies!.children!.length).toBe(3);
121
124
  const names = strategies!.children!.map((c) => c.name);
122
125
  expect(names).toContain("grid.config.json");
@@ -139,10 +142,10 @@ describe("ConfigEditor", () => {
139
142
  ];
140
143
  const flat = flattenTree(nodes);
141
144
  expect(flat.length).toBe(2); // directory + file
142
- expect(flat[0].node.name).toBe("dir");
143
- expect(flat[0].level).toBe(0);
144
- expect(flat[1].node.name).toBe("file.txt");
145
- expect(flat[1].level).toBe(1);
145
+ expect(flat[0]?.node.name).toBe("dir");
146
+ expect(flat[0]?.level).toBe(0);
147
+ expect(flat[1]?.node.name).toBe("file.txt");
148
+ expect(flat[1]?.level).toBe(1);
146
149
  });
147
150
 
148
151
  it("returns empty array for empty input", async () => {
@@ -180,8 +183,8 @@ describe("ConfigEditor", () => {
180
183
  it("highlights full-line comments as muted", async () => {
181
184
  const tokens = tokenizeTomlLine("# this is a comment");
182
185
  expect(tokens.length).toBeGreaterThan(0);
183
- expect(tokens[0].text).toContain("#");
184
- expect(tokens[0].color).toBe(Colors.muted);
186
+ expect(tokens[0]?.text).toContain("#");
187
+ expect(tokens[0]?.color).toBe(Colors.muted);
185
188
  });
186
189
 
187
190
  it("highlights section headers as accent bold", async () => {
@@ -389,8 +392,8 @@ describe("ConfigEditor", () => {
389
392
  // line 3 (unclosed string); the parser may point to the same
390
393
  // line or to the first line where it becomes unrecoverable.
391
394
  // Either way, the error must NOT be on a known-valid line.
392
- expect(errors[0].line).toBeGreaterThanOrEqual(3);
393
- expect(errors[0].line).toBeLessThanOrEqual(4);
395
+ expect(errors[0]?.line).toBeGreaterThanOrEqual(3);
396
+ expect(errors[0]?.line).toBeLessThanOrEqual(4);
394
397
  });
395
398
 
396
399
  it("reports 1-based column for invalid TOML", async () => {
@@ -398,16 +401,16 @@ describe("ConfigEditor", () => {
398
401
  const errors = validateSyntax(toml, "toml");
399
402
  expect(errors.length).toBeGreaterThan(0);
400
403
  // Column must be at least 1 (1-based) for a useful error marker.
401
- expect(errors[0].column).toBeGreaterThanOrEqual(1);
404
+ expect(errors[0]?.column).toBeGreaterThanOrEqual(1);
402
405
  });
403
406
 
404
407
  it("includes the parser's error message verbatim", async () => {
405
408
  const toml = 'key = = "invalid"\n';
406
409
  const errors = validateSyntax(toml, "toml");
407
410
  expect(errors.length).toBeGreaterThan(0);
408
- expect(errors[0].message.length).toBeGreaterThan(0);
411
+ expect(errors[0]?.message.length).toBeGreaterThan(0);
409
412
  // Must not be the old generic "Unbalanced brackets" message.
410
- expect(errors[0].message).not.toMatch(/unbalanced/i);
413
+ expect(errors[0]?.message).not.toMatch(/unbalanced/i);
411
414
  });
412
415
 
413
416
  it("returns empty errors for empty TOML", async () => {
@@ -544,20 +544,22 @@ export function ConfigEditor() {
544
544
  const handleUndo = useCallback(() => {
545
545
  if (undoStack.length === 0 || !selectedFile) return;
546
546
  const prev = undoStack[undoStack.length - 1];
547
+ if (prev === undefined) return;
547
548
  setUndoStack((s) => s.slice(0, -1));
548
549
  setRedoStack((s) => [...s, currentContent]);
549
- setFileContents((contents) => new Map(contents).set(selectedFile!, prev));
550
- setUnsavedPaths((paths) => new Set(paths).add(selectedFile!));
550
+ setFileContents((contents) => new Map(contents).set(selectedFile, prev));
551
+ setUnsavedPaths((paths) => new Set(paths).add(selectedFile));
551
552
  setStatusMessage("Undo");
552
553
  }, [undoStack, currentContent, selectedFile]);
553
554
 
554
555
  const handleRedo = useCallback(() => {
555
556
  if (redoStack.length === 0 || !selectedFile) return;
556
557
  const next = redoStack[redoStack.length - 1];
558
+ if (next === undefined) return;
557
559
  setRedoStack((s) => s.slice(0, -1));
558
560
  setUndoStack((s) => [...s, currentContent]);
559
- setFileContents((prev) => new Map(prev).set(selectedFile!, next));
560
- setUnsavedPaths((paths) => new Set(paths).add(selectedFile!));
561
+ setFileContents((prev) => new Map(prev).set(selectedFile, next));
562
+ setUnsavedPaths((paths) => new Set(paths).add(selectedFile));
561
563
  setStatusMessage("Redo");
562
564
  }, [redoStack, currentContent, selectedFile]);
563
565
 
@@ -624,7 +626,7 @@ export function ConfigEditor() {
624
626
  setFocusedTreeIdx((i) => Math.min(flatFiles.length - 1, i + 1));
625
627
  break;
626
628
  case "enter":
627
- if (focusedFilePath) selectFile(focusedFilePath);
629
+ if (focusedFilePath) void selectFile(focusedFilePath);
628
630
  break;
629
631
  }
630
632
  }
@@ -641,7 +643,7 @@ export function ConfigEditor() {
641
643
  }
642
644
 
643
645
  if (key.ctrl && key.name === "s" && selectedFile) {
644
- handleSave();
646
+ void handleSave();
645
647
  }
646
648
  });
647
649
 
@@ -4,14 +4,12 @@
4
4
  */
5
5
 
6
6
  /** @jsxImportSource @opentui/react */
7
- import { useState, useCallback, useEffect } from "react";
7
+ import { useState, useCallback, useEffect, useRef } from "react";
8
8
  import { Colors, WorkerStatusColor } from "@hoox-sh/hoox-shared";
9
9
  import { StatusDot } from "../../shared/status-dot";
10
10
  import { cliBridge } from "../../../services/cli-bridge";
11
11
  import type { ModelHealth } from "../../../services/cli-bridge";
12
-
13
- /** Polling interval for AI model health checks (30 seconds). */
14
- const MODEL_HEALTH_POLL_MS = 30_000;
12
+ import { usePolling } from "../../../hooks/use-polling";
15
13
 
16
14
  /**
17
15
  * Map model status to a StatusDot-compatible status string.
@@ -116,7 +114,7 @@ function ModelHealthRow({
116
114
  /**
117
115
  * ModelHealthSection — displays health status of all configured AI providers.
118
116
  *
119
- * Auto-refreshes every 30 seconds when the dashboard is active.
117
+ * Auto-refreshes on the Settings interval when the dashboard is active.
120
118
  * Supports multiple providers: Workers AI, OpenAI, Anthropic, Google, Azure.
121
119
  * Click on a provider to expand detailed stats (latency, daily usage, error).
122
120
  * Cancels in-flight probes on unmount; race-safe via generation counter.
@@ -127,51 +125,45 @@ function ModelHealthSection() {
127
125
  const [loading, setLoading] = useState(true);
128
126
  const [error, setError] = useState<string | null>(null);
129
127
  const [lastRefresh, setLastRefresh] = useState<number | null>(null);
128
+ const genRef = useRef(0);
129
+ const inFlightRef = useRef(false);
130
130
 
131
- // Initial fetch + 30s polling with cancel + race safety
132
- useEffect(() => {
133
- let cancelled = false;
134
- let generation = 0;
135
- let inFlight = false;
136
-
137
- const fetchHealth = async () => {
138
- if (cancelled || inFlight) return;
139
- inFlight = true;
140
- const gen = ++generation;
141
- try {
142
- const result = await cliBridge.agentHealthCheck();
143
- if (cancelled || gen !== generation) return;
144
- if (result.success && result.data) {
145
- setProviders(result.data.providers ?? []);
146
- setError(null);
147
- setLastRefresh(Date.now());
148
- } else {
149
- // Keep prior providers if any; surface probe failure distinctly
150
- setError(
151
- result.stderr ||
152
- result.stdout ||
153
- "AI health probe failed (CLI offline?)"
154
- );
155
- }
156
- } catch (err) {
157
- if (cancelled || gen !== generation) return;
158
- setError(err instanceof Error ? err.message : String(err));
159
- } finally {
160
- inFlight = false;
161
- if (!cancelled && gen === generation) setLoading(false);
131
+ const fetchHealth = useCallback(async () => {
132
+ if (inFlightRef.current) return;
133
+ inFlightRef.current = true;
134
+ const gen = ++genRef.current;
135
+ try {
136
+ const result = await cliBridge.agentHealthCheck();
137
+ if (gen !== genRef.current) return;
138
+ if (result.success && result.data) {
139
+ setProviders(result.data.providers ?? []);
140
+ setError(null);
141
+ setLastRefresh(Date.now());
142
+ } else {
143
+ // Keep prior providers if any; surface probe failure distinctly
144
+ setError(
145
+ result.stderr ||
146
+ result.stdout ||
147
+ "AI health probe failed (CLI offline?)"
148
+ );
162
149
  }
163
- };
164
-
165
- void fetchHealth();
166
- const interval = setInterval(() => {
167
- void fetchHealth();
168
- }, MODEL_HEALTH_POLL_MS);
169
- return () => {
170
- cancelled = true;
171
- clearInterval(interval);
172
- };
150
+ } catch (err) {
151
+ if (gen !== genRef.current) return;
152
+ setError(err instanceof Error ? err.message : String(err));
153
+ } finally {
154
+ inFlightRef.current = false;
155
+ if (gen === genRef.current) setLoading(false);
156
+ }
173
157
  }, []);
174
158
 
159
+ useEffect(
160
+ () => () => {
161
+ genRef.current += 1;
162
+ },
163
+ []
164
+ );
165
+ usePolling({ callback: fetchHealth, immediate: true });
166
+
175
167
  const toggleExpand = useCallback((index: number) => {
176
168
  setExpandedIndex((prev) => (prev === index ? null : index));
177
169
  }, []);
@@ -4,13 +4,12 @@
4
4
  */
5
5
 
6
6
  /** @jsxImportSource @opentui/react */
7
- import { useState, useEffect } from "react";
7
+ import { useState, useEffect, useCallback, useRef } from "react";
8
8
  import { Colors, WorkerStatusColor } from "@hoox-sh/hoox-shared";
9
9
  import { StatusDot } from "../../shared/status-dot";
10
10
  import { cliBridge } from "../../../services/cli-bridge";
11
11
  import type { PyneHealthResult } from "../../../services/cli-bridge";
12
-
13
- const POLL_MS = 30_000;
12
+ import { usePolling } from "../../../hooks/use-polling";
14
13
 
15
14
  function statusToDot(
16
15
  status: PyneHealthResult["status"]
@@ -27,54 +26,50 @@ function statusToDot(
27
26
  export function PyneHealthSection() {
28
27
  const [result, setResult] = useState<PyneHealthResult | null>(null);
29
28
  const [loading, setLoading] = useState(true);
29
+ const genRef = useRef(0);
30
+ const inFlightRef = useRef(false);
30
31
 
31
- // Poll with cancel-on-unmount and skip overlapping probes
32
- useEffect(() => {
33
- let cancelled = false;
34
- let generation = 0;
35
- let inFlight = false;
36
-
37
- const refresh = async () => {
38
- if (cancelled || inFlight) return;
39
- inFlight = true;
40
- const gen = ++generation;
41
- try {
42
- const res = await cliBridge.pyneHealthCheck();
43
- if (cancelled || gen !== generation) return;
44
- if (res.data) {
45
- setResult(res.data);
46
- } else {
47
- setResult({
48
- worker: "pyne-worker",
49
- url: "",
50
- status: "down",
51
- error: res.stderr || "probe failed",
52
- timestamp: new Date().toISOString(),
53
- });
54
- }
55
- } catch (err) {
56
- if (cancelled || gen !== generation) return;
32
+ const refresh = useCallback(async () => {
33
+ if (inFlightRef.current) return;
34
+ inFlightRef.current = true;
35
+ const gen = ++genRef.current;
36
+ try {
37
+ const res = await cliBridge.pyneHealthCheck();
38
+ if (gen !== genRef.current) return;
39
+ if (res.data) {
40
+ setResult(res.data);
41
+ } else {
57
42
  setResult({
58
43
  worker: "pyne-worker",
59
44
  url: "",
60
45
  status: "down",
61
- error: err instanceof Error ? err.message : "probe failed",
46
+ error: res.stderr || "probe failed",
62
47
  timestamp: new Date().toISOString(),
63
48
  });
64
- } finally {
65
- inFlight = false;
66
- if (!cancelled && gen === generation) setLoading(false);
67
49
  }
68
- };
69
-
70
- void refresh();
71
- const id = setInterval(() => void refresh(), POLL_MS);
72
- return () => {
73
- cancelled = true;
74
- clearInterval(id);
75
- };
50
+ } catch (err) {
51
+ if (gen !== genRef.current) return;
52
+ setResult({
53
+ worker: "pyne-worker",
54
+ url: "",
55
+ status: "down",
56
+ error: err instanceof Error ? err.message : "probe failed",
57
+ timestamp: new Date().toISOString(),
58
+ });
59
+ } finally {
60
+ inFlightRef.current = false;
61
+ if (gen === genRef.current) setLoading(false);
62
+ }
76
63
  }, []);
77
64
 
65
+ useEffect(
66
+ () => () => {
67
+ genRef.current += 1;
68
+ },
69
+ []
70
+ );
71
+ usePolling({ callback: refresh, immediate: true });
72
+
78
73
  const status = result?.status ?? "down";
79
74
  const dot = statusToDot(status);
80
75
  const color = WorkerStatusColor[dot];