oh-my-opencode-dashboard 0.0.4 → 0.0.5

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/src/App.tsx CHANGED
@@ -14,12 +14,30 @@ type BackgroundTask = {
14
14
  subline?: string;
15
15
  agent: string;
16
16
  lastModel: string;
17
+ sessionId?: string | null;
17
18
  status: "queued" | "running" | "done" | "error" | "cancelled" | string;
18
19
  toolCalls: number;
19
20
  lastTool: string;
20
21
  timeline: string;
21
22
  };
22
23
 
24
+ type ToolCallSummary = {
25
+ sessionId?: string;
26
+ messageId: string;
27
+ callId: string;
28
+ tool: string;
29
+ status: string;
30
+ createdAtMs: number | null;
31
+ };
32
+
33
+ type ToolCallsResponse = {
34
+ ok: boolean;
35
+ sessionId: string;
36
+ toolCalls: ToolCallSummary[];
37
+ caps?: { maxMessages: number; maxToolCalls: number };
38
+ truncated?: boolean;
39
+ };
40
+
23
41
  type TimeSeriesTone = "muted" | "teal" | "red" | "green";
24
42
 
25
43
  type TimeSeriesSeriesId =
@@ -198,6 +216,7 @@ const FALLBACK_DATA: DashboardPayload = {
198
216
  subline: "task-1",
199
217
  agent: "explore",
200
218
  lastModel: "opencode/gpt-5-nano",
219
+ sessionId: null,
201
220
  status: "running",
202
221
  toolCalls: 3,
203
222
  lastTool: "grep",
@@ -282,6 +301,62 @@ function toNonEmptyString(value: unknown): string | null {
282
301
  return trimmed.length > 0 ? trimmed : null;
283
302
  }
284
303
 
304
+ function toToolCallSummary(value: unknown): ToolCallSummary | null {
305
+ if (!value || typeof value !== "object") return null;
306
+ const rec = value as Record<string, unknown>;
307
+
308
+ const messageId = toNonEmptyString(rec.messageId ?? rec.message_id);
309
+ const callId = toNonEmptyString(rec.callId ?? rec.call_id);
310
+ const tool = toNonEmptyString(rec.tool);
311
+ const status = toNonEmptyString(rec.status) ?? "unknown";
312
+ const createdAtRaw = toFiniteNumber(rec.createdAtMs ?? rec.created_at_ms ?? rec.createdAt ?? rec.created_at);
313
+
314
+ if (!messageId || !callId || !tool) return null;
315
+
316
+ return {
317
+ sessionId: toNonEmptyString(rec.sessionId ?? rec.session_id) ?? undefined,
318
+ messageId,
319
+ callId,
320
+ tool,
321
+ status,
322
+ createdAtMs: typeof createdAtRaw === "number" ? Math.floor(createdAtRaw) : null,
323
+ };
324
+ }
325
+
326
+ function toToolCallsResponse(value: unknown): ToolCallsResponse | null {
327
+ if (!value || typeof value !== "object") return null;
328
+ const rec = value as Record<string, unknown>;
329
+
330
+ const ok = typeof rec.ok === "boolean" ? rec.ok : false;
331
+ const sessionId = toNonEmptyString(rec.sessionId ?? rec.session_id);
332
+ const toolCallsRaw = rec.toolCalls ?? rec.tool_calls;
333
+
334
+ if (!sessionId || !Array.isArray(toolCallsRaw)) return null;
335
+
336
+ const toolCalls: ToolCallSummary[] = toolCallsRaw
337
+ .map(toToolCallSummary)
338
+ .filter((t): t is ToolCallSummary => t !== null);
339
+
340
+ const capsRaw = rec.caps;
341
+ const capsObj = capsRaw && typeof capsRaw === "object" ? (capsRaw as Record<string, unknown>) : null;
342
+ const maxMessagesRaw = capsObj ? toFiniteNumber(capsObj.maxMessages ?? capsObj.max_messages) : null;
343
+ const maxToolCallsRaw = capsObj ? toFiniteNumber(capsObj.maxToolCalls ?? capsObj.max_tool_calls) : null;
344
+ const caps =
345
+ typeof maxMessagesRaw === "number" && typeof maxToolCallsRaw === "number"
346
+ ? { maxMessages: Math.max(0, Math.floor(maxMessagesRaw)), maxToolCalls: Math.max(0, Math.floor(maxToolCallsRaw)) }
347
+ : undefined;
348
+
349
+ const truncated = typeof rec.truncated === "boolean" ? rec.truncated : undefined;
350
+
351
+ return {
352
+ ok,
353
+ sessionId,
354
+ toolCalls,
355
+ caps,
356
+ truncated,
357
+ };
358
+ }
359
+
285
360
  export function formatBackgroundTaskTimelineCell(status: unknown, timeline: unknown): string {
286
361
  const s = typeof status === "string" ? status.trim().toLowerCase() : "";
287
362
  if (s === "unknown") return "";
@@ -290,6 +365,50 @@ export function formatBackgroundTaskTimelineCell(status: unknown, timeline: unkn
290
365
  return toNonEmptyString(timeline) ?? "-";
291
366
  }
292
367
 
368
+ export function computeToolCallsFetchPlan(params: {
369
+ sessionId: string | null;
370
+ status: string;
371
+ cachedState: "idle" | "loading" | "ok" | "error" | null;
372
+ cachedDataOk: boolean;
373
+ isExpanded: boolean;
374
+ }): { shouldFetch: boolean; force: boolean } {
375
+ const { sessionId, status, cachedState, cachedDataOk, isExpanded } = params;
376
+
377
+ if (!sessionId) {
378
+ return { shouldFetch: false, force: false };
379
+ }
380
+
381
+ if (!isExpanded) {
382
+ return { shouldFetch: false, force: false };
383
+ }
384
+
385
+ const isRunning = String(status ?? "").toLowerCase().trim() === "running";
386
+
387
+ if (isRunning) {
388
+ return { shouldFetch: true, force: true };
389
+ }
390
+
391
+ if (cachedDataOk) {
392
+ return { shouldFetch: false, force: false };
393
+ }
394
+
395
+ if (cachedState === "loading") {
396
+ return { shouldFetch: false, force: false };
397
+ }
398
+
399
+ return { shouldFetch: true, force: false };
400
+ }
401
+
402
+ export function toggleIdInSet(id: string, currentSet: Set<string>): Set<string> {
403
+ const next = new Set(currentSet);
404
+ if (next.has(id)) {
405
+ next.delete(id);
406
+ } else {
407
+ next.add(id);
408
+ }
409
+ return next;
410
+ }
411
+
293
412
  function toDashboardPayload(json: unknown): DashboardPayload {
294
413
  if (!json || typeof json !== "object") {
295
414
  return { ...FALLBACK_DATA, raw: json };
@@ -331,6 +450,7 @@ function toDashboardPayload(json: unknown): DashboardPayload {
331
450
  : undefined,
332
451
  agent: String(rec.agent ?? rec.worker ?? "unknown"),
333
452
  lastModel: toNonEmptyString(rec.lastModel ?? rec.last_model) ?? "-",
453
+ sessionId: toNonEmptyString(rec.sessionId ?? rec.session_id),
334
454
  status: String(rec.status ?? "queued"),
335
455
  toolCalls: Number(rec.toolCalls ?? rec.tool_calls ?? 0) || 0,
336
456
  lastTool: String(rec.lastTool ?? rec.last_tool ?? "-") || "-",
@@ -379,6 +499,12 @@ export default function App() {
379
499
  const [planOpen, setPlanOpen] = React.useState(false);
380
500
  const [errorHint, setErrorHint] = React.useState<string | null>(null);
381
501
 
502
+ const [expandedBgTaskIds, setExpandedBgTaskIds] = React.useState<Set<string>>(() => new Set());
503
+ const [toolCallsBySession, setToolCallsBySession] = React.useState<
504
+ Map<string, { state: "idle" | "loading" | "ok" | "error"; data: ToolCallsResponse | null; lastFetchedAtMs: number | null }>
505
+ >(() => new Map());
506
+ const toolCallsSeqRef = React.useRef<Map<string, number>>(new Map());
507
+
382
508
  const timerRef = React.useRef<number | null>(null);
383
509
  const hadSuccessRef = React.useRef(false);
384
510
  const soundEnabledRef = React.useRef(false);
@@ -580,6 +706,75 @@ export default function App() {
580
706
  return map;
581
707
  }, [data.timeSeries.series]);
582
708
 
709
+ async function fetchToolCalls(sessionId: string, opts: { force: boolean }) {
710
+ const existing = toolCallsBySession.get(sessionId);
711
+ if (!opts.force && existing?.data?.ok) return;
712
+
713
+ const seq = (toolCallsSeqRef.current.get(sessionId) ?? 0) + 1;
714
+ toolCallsSeqRef.current.set(sessionId, seq);
715
+
716
+ setToolCallsBySession((prev) => {
717
+ const next = new Map(prev);
718
+ const prior = next.get(sessionId);
719
+ next.set(sessionId, {
720
+ state: "loading",
721
+ data: prior?.data ?? null,
722
+ lastFetchedAtMs: prior?.lastFetchedAtMs ?? null,
723
+ });
724
+ return next;
725
+ });
726
+
727
+ try {
728
+ const raw = await safeFetchJson(`/api/tool-calls/${encodeURIComponent(sessionId)}`);
729
+ const parsed = toToolCallsResponse(raw);
730
+ if (!parsed?.ok) throw new Error("tool calls not ok");
731
+ if (toolCallsSeqRef.current.get(sessionId) !== seq) return;
732
+ setToolCallsBySession((prev) => {
733
+ const next = new Map(prev);
734
+ next.set(sessionId, { state: "ok", data: parsed, lastFetchedAtMs: Date.now() });
735
+ return next;
736
+ });
737
+ } catch {
738
+ if (toolCallsSeqRef.current.get(sessionId) !== seq) return;
739
+ setToolCallsBySession((prev) => {
740
+ const next = new Map(prev);
741
+ const prior = next.get(sessionId);
742
+ next.set(sessionId, {
743
+ state: "error",
744
+ data: prior?.data ?? null,
745
+ lastFetchedAtMs: prior?.lastFetchedAtMs ?? null,
746
+ });
747
+ return next;
748
+ });
749
+ }
750
+ }
751
+
752
+ function toggleBackgroundTaskExpanded(t: BackgroundTask) {
753
+ const nextExpanded = !expandedBgTaskIds.has(t.id);
754
+ setExpandedBgTaskIds((prev) => {
755
+ const next = new Set(prev);
756
+ if (nextExpanded) next.add(t.id);
757
+ else next.delete(t.id);
758
+ return next;
759
+ });
760
+
761
+ if (!nextExpanded) return;
762
+
763
+ const sessionId = toNonEmptyString(t.sessionId);
764
+ if (!sessionId) return;
765
+
766
+ const isRunning = String(t.status ?? "").toLowerCase().trim() === "running";
767
+ const cached = toolCallsBySession.get(sessionId);
768
+ if (isRunning) {
769
+ void fetchToolCalls(sessionId, { force: true });
770
+ return;
771
+ }
772
+
773
+ if (cached?.data?.ok) return;
774
+ if (cached?.state === "loading") return;
775
+ void fetchToolCalls(sessionId, { force: false });
776
+ }
777
+
583
778
  const buckets = Math.max(1, data.timeSeries.buckets);
584
779
  const bucketMs = Math.max(1, data.timeSeries.bucketMs);
585
780
  const viewBox = `0 0 ${buckets} 28`;
@@ -931,22 +1126,102 @@ export default function App() {
931
1126
  </tr>
932
1127
  </thead>
933
1128
  <tbody>
934
- {data.backgroundTasks.map((t) => (
935
- <tr key={t.id}>
936
- <td>
937
- <div className="taskTitle">{t.description}</div>
938
- {t.subline ? <div className="taskSub mono">{t.subline}</div> : null}
939
- </td>
940
- <td className="mono">{t.agent}</td>
941
- <td className="mono">{t.lastModel}</td>
942
- <td>
943
- <span className={`pill pill-${statusTone(t.status)}`}>{t.status}</span>
944
- </td>
945
- <td className="mono">{t.toolCalls}</td>
946
- <td className="mono">{t.lastTool}</td>
947
- <td className="mono muted">{formatBackgroundTaskTimelineCell(t.status, t.timeline)}</td>
948
- </tr>
949
- ))}
1129
+ {data.backgroundTasks.map((t) => {
1130
+ const expanded = expandedBgTaskIds.has(t.id);
1131
+ const sessionId = toNonEmptyString(t.sessionId);
1132
+ const detailId = `bg-toolcalls-${t.id}`;
1133
+ const entry = sessionId ? toolCallsBySession.get(sessionId) : null;
1134
+ const toolCalls = entry?.data?.ok ? entry.data.toolCalls : [];
1135
+ const showCapped = Boolean(entry?.data?.truncated);
1136
+ const caps = entry?.data?.caps;
1137
+ const showLoading = entry?.state === "loading";
1138
+ const showError = entry?.state === "error" && !entry?.data?.ok;
1139
+ const empty = sessionId ? toolCalls.length === 0 && !showLoading && !showError : true;
1140
+
1141
+ return (
1142
+ <React.Fragment key={t.id}>
1143
+ <tr>
1144
+ <td>
1145
+ <div className="bgTaskRowTitleWrap">
1146
+ <button
1147
+ type="button"
1148
+ className="bgTaskToggle"
1149
+ onClick={() => toggleBackgroundTaskExpanded(t)}
1150
+ aria-expanded={expanded}
1151
+ aria-controls={detailId}
1152
+ title={expanded ? "Collapse" : "Expand"}
1153
+ aria-label={expanded ? "Collapse tool calls" : "Expand tool calls"}
1154
+ />
1155
+ <div className="bgTaskRowTitleText">
1156
+ <div className="taskTitle">{t.description}</div>
1157
+ {t.subline ? <div className="taskSub mono">{t.subline}</div> : null}
1158
+ </div>
1159
+ </div>
1160
+ </td>
1161
+ <td className="mono">{t.agent}</td>
1162
+ <td className="mono">{t.lastModel}</td>
1163
+ <td>
1164
+ <span className={`pill pill-${statusTone(t.status)}`}>{t.status}</span>
1165
+ </td>
1166
+ <td className="mono">{t.toolCalls}</td>
1167
+ <td className="mono">{t.lastTool}</td>
1168
+ <td className="mono muted">{formatBackgroundTaskTimelineCell(t.status, t.timeline)}</td>
1169
+ </tr>
1170
+
1171
+ {expanded ? (
1172
+ <tr>
1173
+ <td colSpan={7} className="bgTaskDetailCell">
1174
+ <section id={detailId} aria-label="Tool calls" className="bgTaskDetail">
1175
+ <div className="mono muted bgTaskDetailHeader">
1176
+ Tool calls (metadata only){showLoading && toolCalls.length > 0 ? " - refreshing" : ""}
1177
+ {showCapped
1178
+ ? ` - capped${caps ? ` (max ${caps.maxMessages} messages / ${caps.maxToolCalls} tool calls)` : ""}`
1179
+ : ""}
1180
+ </div>
1181
+
1182
+ {!sessionId ? (
1183
+ <div className="muted bgTaskDetailEmpty">
1184
+ No session id available for this task.
1185
+ </div>
1186
+ ) : showError ? (
1187
+ <div className="muted bgTaskDetailEmpty">
1188
+ Tool calls unavailable.
1189
+ </div>
1190
+ ) : showLoading && toolCalls.length === 0 ? (
1191
+ <div className="muted bgTaskDetailEmpty">
1192
+ Loading tool calls...
1193
+ </div>
1194
+ ) : empty ? (
1195
+ <div className="muted bgTaskDetailEmpty">
1196
+ No tool calls recorded.
1197
+ </div>
1198
+ ) : (
1199
+ <div className="bgTaskToolCallsGrid">
1200
+ {toolCalls.map((c) => (
1201
+ <div key={c.callId} className="bgTaskToolCall">
1202
+ <div className="bgTaskToolCallRow">
1203
+ <div className="mono bgTaskToolCallTool" title={c.tool}>
1204
+ {c.tool}
1205
+ </div>
1206
+ <div className="mono muted bgTaskToolCallStatus" title={c.status}>
1207
+ {c.status}
1208
+ </div>
1209
+ </div>
1210
+ <div className="mono muted bgTaskToolCallTime">{formatTime(c.createdAtMs)}</div>
1211
+ <div className="mono muted bgTaskToolCallId" title={c.callId}>
1212
+ {c.callId}
1213
+ </div>
1214
+ </div>
1215
+ ))}
1216
+ </div>
1217
+ )}
1218
+ </section>
1219
+ </td>
1220
+ </tr>
1221
+ ) : null}
1222
+ </React.Fragment>
1223
+ );
1224
+ })}
950
1225
  </tbody>
951
1226
  </table>
952
1227
  </div>
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { computeToolCallsFetchPlan, toggleIdInSet } from "./App"
3
+
4
+ describe('computeToolCallsFetchPlan', () => {
5
+ it('should not fetch when sessionId is missing', () => {
6
+ // #given: no sessionId
7
+ const params = {
8
+ sessionId: null,
9
+ status: "running",
10
+ cachedState: "idle" as const,
11
+ cachedDataOk: false,
12
+ isExpanded: true
13
+ }
14
+
15
+ // #when
16
+ const result = computeToolCallsFetchPlan(params)
17
+
18
+ // #then
19
+ expect(result.shouldFetch).toBe(false)
20
+ expect(result.force).toBe(false)
21
+ })
22
+
23
+ it('should not fetch when not expanded', () => {
24
+ // #given: expanded is false
25
+ const params = {
26
+ sessionId: "session-123",
27
+ status: "done",
28
+ cachedState: "idle" as const,
29
+ cachedDataOk: false,
30
+ isExpanded: false
31
+ }
32
+
33
+ // #when
34
+ const result = computeToolCallsFetchPlan(params)
35
+
36
+ // #then
37
+ expect(result.shouldFetch).toBe(false)
38
+ expect(result.force).toBe(false)
39
+ })
40
+
41
+ it('should force fetch when status is "running" and expanded', () => {
42
+ // #given: running status
43
+ const params = {
44
+ sessionId: "session-123",
45
+ status: "running",
46
+ cachedState: "ok" as const,
47
+ cachedDataOk: true,
48
+ isExpanded: true
49
+ }
50
+
51
+ // #when
52
+ const result = computeToolCallsFetchPlan(params)
53
+
54
+ // #then
55
+ expect(result.shouldFetch).toBe(true)
56
+ expect(result.force).toBe(true)
57
+ })
58
+
59
+ it('should not fetch when non-running status and cached data is ok', () => {
60
+ // #given: done status with good cache
61
+ const params = {
62
+ sessionId: "session-123",
63
+ status: "done",
64
+ cachedState: "ok" as const,
65
+ cachedDataOk: true,
66
+ isExpanded: true
67
+ }
68
+
69
+ // #when
70
+ const result = computeToolCallsFetchPlan(params)
71
+
72
+ // #then
73
+ expect(result.shouldFetch).toBe(false)
74
+ expect(result.force).toBe(false)
75
+ })
76
+
77
+ it('should not fetch when already loading', () => {
78
+ // #given: loading state
79
+ const params = {
80
+ sessionId: "session-123",
81
+ status: "done",
82
+ cachedState: "loading" as const,
83
+ cachedDataOk: false,
84
+ isExpanded: true
85
+ }
86
+
87
+ // #when
88
+ const result = computeToolCallsFetchPlan(params)
89
+
90
+ // #then
91
+ expect(result.shouldFetch).toBe(false)
92
+ expect(result.force).toBe(false)
93
+ })
94
+
95
+ it('should fetch when non-running, not cached, not loading, and expanded', () => {
96
+ // #given: done status with no cache
97
+ const params = {
98
+ sessionId: "session-123",
99
+ status: "done",
100
+ cachedState: "idle" as const,
101
+ cachedDataOk: false,
102
+ isExpanded: true
103
+ }
104
+
105
+ // #when
106
+ const result = computeToolCallsFetchPlan(params)
107
+
108
+ // #then
109
+ expect(result.shouldFetch).toBe(true)
110
+ expect(result.force).toBe(false)
111
+ })
112
+
113
+ it('should handle case-insensitive status values', () => {
114
+ // #given: RUNNING in uppercase
115
+ const params = {
116
+ sessionId: "session-123",
117
+ status: "RUNNING",
118
+ cachedState: "idle" as const,
119
+ cachedDataOk: false,
120
+ isExpanded: true
121
+ }
122
+
123
+ // #when
124
+ const result = computeToolCallsFetchPlan(params)
125
+
126
+ // #then
127
+ expect(result.shouldFetch).toBe(true)
128
+ expect(result.force).toBe(true)
129
+ })
130
+
131
+ it('should handle whitespace in status values', () => {
132
+ // #given: running with whitespace
133
+ const params = {
134
+ sessionId: "session-123",
135
+ status: " running ",
136
+ cachedState: "idle" as const,
137
+ cachedDataOk: false,
138
+ isExpanded: true
139
+ }
140
+
141
+ // #when
142
+ const result = computeToolCallsFetchPlan(params)
143
+
144
+ // #then
145
+ expect(result.shouldFetch).toBe(true)
146
+ expect(result.force).toBe(true)
147
+ })
148
+ })
149
+
150
+ describe('toggleIdInSet', () => {
151
+ it('should add id when not present in set', () => {
152
+ // #given: empty set
153
+ const currentSet = new Set<string>()
154
+
155
+ // #when
156
+ const result = toggleIdInSet("task-1", currentSet)
157
+
158
+ // #then
159
+ expect(result.has("task-1")).toBe(true)
160
+ expect(result.size).toBe(1)
161
+ })
162
+
163
+ it('should remove id when already present in set', () => {
164
+ // #given: set with id already present
165
+ const currentSet = new Set(["task-1", "task-2"])
166
+
167
+ // #when
168
+ const result = toggleIdInSet("task-1", currentSet)
169
+
170
+ // #then
171
+ expect(result.has("task-1")).toBe(false)
172
+ expect(result.has("task-2")).toBe(true)
173
+ expect(result.size).toBe(1)
174
+ })
175
+
176
+ it('should not modify original set', () => {
177
+ // #given: original set
178
+ const originalSet = new Set(["task-1"])
179
+
180
+ // #when
181
+ const result = toggleIdInSet("task-2", originalSet)
182
+
183
+ // #then
184
+ expect(originalSet.has("task-1")).toBe(true)
185
+ expect(originalSet.has("task-2")).toBe(false)
186
+ expect(originalSet.size).toBe(1)
187
+ expect(result.has("task-1")).toBe(true)
188
+ expect(result.has("task-2")).toBe(true)
189
+ expect(result.size).toBe(2)
190
+ })
191
+ })
@@ -981,7 +981,16 @@ describe("deriveBackgroundTasks", () => {
981
981
  "utf8"
982
982
  )
983
983
 
984
- const readdirSync = vi.fn(fs.readdirSync)
984
+ // Create typed wrapper that records calls and delegates to fs.readdirSync
985
+ const readdirCalls: Array<[fs.PathLike]> = []
986
+ const readdirSync = ((path: fs.PathLike, options?: any): any => {
987
+ if (typeof options === 'object' && options.withFileTypes) {
988
+ return fs.readdirSync(path, options)
989
+ }
990
+ readdirCalls.push([path])
991
+ return fs.readdirSync(path, 'utf8')
992
+ }) as typeof fs.readdirSync
993
+
985
994
  const fsLike = {
986
995
  readFileSync: fs.readFileSync,
987
996
  readdirSync,
@@ -993,7 +1002,7 @@ describe("deriveBackgroundTasks", () => {
993
1002
  const rows = deriveBackgroundTasks({ storage, mainSessionId, fs: fsLike })
994
1003
 
995
1004
  // #then
996
- const backgroundReads = readdirSync.mock.calls.filter((call) => call[0] === childMsgDir)
1005
+ const backgroundReads = readdirCalls.filter((call) => call[0] === childMsgDir)
997
1006
  expect(rows.length).toBe(2)
998
1007
  expect(backgroundReads.length).toBe(1)
999
1008
  })