oh-my-opencode-dashboard 0.0.3 → 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/server/dev.ts CHANGED
@@ -26,14 +26,16 @@ const resolvedProjectPath = projectPath ?? process.cwd()
26
26
 
27
27
  const app = new Hono()
28
28
 
29
+ const storageRoot = getOpenCodeStorageDir()
30
+
29
31
  const store = createDashboardStore({
30
32
  projectRoot: resolvedProjectPath,
31
- storageRoot: getOpenCodeStorageDir(),
33
+ storageRoot,
32
34
  watch: true,
33
35
  pollIntervalMs: 2000,
34
36
  })
35
37
 
36
- app.route("/api", createApi(store))
38
+ app.route("/api", createApi({ store, storageRoot }))
37
39
 
38
40
  Bun.serve({
39
41
  fetch: app.fetch,
@@ -21,14 +21,16 @@ const port = parseInt(values.port || '51234')
21
21
 
22
22
  const app = new Hono()
23
23
 
24
+ const storageRoot = getOpenCodeStorageDir()
25
+
24
26
  const store = createDashboardStore({
25
27
  projectRoot: project,
26
- storageRoot: getOpenCodeStorageDir(),
28
+ storageRoot,
27
29
  watch: true,
28
30
  pollIntervalMs: 2000,
29
31
  })
30
32
 
31
- app.route('/api', createApi(store))
33
+ app.route('/api', createApi({ store, storageRoot }))
32
34
 
33
35
  const distRoot = join(import.meta.dir, '../../dist')
34
36
 
package/src/styles.css CHANGED
@@ -349,6 +349,133 @@ body {
349
349
  color: rgba(31, 36, 38, 0.62);
350
350
  }
351
351
 
352
+ .bgTaskRowTitleWrap {
353
+ display: flex;
354
+ align-items: flex-start;
355
+ gap: 10px;
356
+ }
357
+
358
+ .bgTaskRowTitleText {
359
+ min-width: 0;
360
+ }
361
+
362
+ .bgTaskToggle {
363
+ appearance: none;
364
+ width: 22px;
365
+ height: 22px;
366
+ border-radius: 10px;
367
+ border: 1px solid rgba(31, 36, 38, 0.16);
368
+ background: rgba(255, 255, 255, 0.62);
369
+ box-shadow: 0 6px 14px rgba(29, 32, 33, 0.06);
370
+ color: rgba(31, 36, 38, 0.72);
371
+ display: inline-flex;
372
+ align-items: center;
373
+ justify-content: center;
374
+ padding: 0;
375
+ cursor: pointer;
376
+ flex: 0 0 auto;
377
+ margin-top: 2px;
378
+ transition: transform 120ms ease, background 120ms ease, border-color 120ms ease;
379
+ }
380
+
381
+ .bgTaskToggle::before {
382
+ content: "\25B8";
383
+ font-size: 12px;
384
+ line-height: 1;
385
+ transform: translateX(0.5px);
386
+ }
387
+
388
+ .bgTaskToggle:hover {
389
+ transform: translateY(-1px);
390
+ background: rgba(255, 255, 255, 0.78);
391
+ border-color: rgba(31, 36, 38, 0.20);
392
+ }
393
+
394
+ .bgTaskToggle:active {
395
+ transform: translateY(0px);
396
+ }
397
+
398
+ .bgTaskToggle:focus-visible {
399
+ outline: 2px solid rgba(15, 90, 81, 0.30);
400
+ outline-offset: 2px;
401
+ }
402
+
403
+ .bgTaskToggle[aria-expanded="true"] {
404
+ background: rgba(15, 90, 81, 0.10);
405
+ border-color: rgba(15, 90, 81, 0.22);
406
+ color: var(--teal-ink);
407
+ }
408
+
409
+ .bgTaskToggle[aria-expanded="true"]::before {
410
+ content: "\25BE";
411
+ transform: translateY(-0.5px);
412
+ }
413
+
414
+ .table td.bgTaskDetailCell {
415
+ padding: 0 10px 14px;
416
+ }
417
+
418
+ .bgTaskDetail {
419
+ padding: 12px 12px 10px;
420
+ border-radius: 14px;
421
+ border: 1px solid rgba(31, 36, 38, 0.08);
422
+ background: rgba(255, 255, 255, 0.54);
423
+ box-shadow:
424
+ inset 0 1px 0 rgba(255, 255, 255, 0.28),
425
+ 0 10px 22px rgba(29, 32, 33, 0.05);
426
+ backdrop-filter: blur(8px);
427
+ }
428
+
429
+ .bgTaskDetailHeader {
430
+ font-size: 12px;
431
+ margin-bottom: 10px;
432
+ }
433
+
434
+ .bgTaskDetailEmpty {
435
+ font-size: 13px;
436
+ }
437
+
438
+ .bgTaskToolCallsGrid {
439
+ display: grid;
440
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
441
+ gap: 10px;
442
+ }
443
+
444
+ .bgTaskToolCall {
445
+ border-radius: 14px;
446
+ border: 1px solid rgba(31, 36, 38, 0.10);
447
+ background:
448
+ radial-gradient(120px 50px at 12% 18%, rgba(15, 90, 81, 0.06), transparent 70%),
449
+ rgba(255, 255, 255, 0.64);
450
+ padding: 10px 11px;
451
+ }
452
+
453
+ .bgTaskToolCallRow {
454
+ display: grid;
455
+ grid-template-columns: minmax(140px, 1fr) max-content;
456
+ gap: 10px;
457
+ align-items: baseline;
458
+ }
459
+
460
+ .bgTaskToolCallTool,
461
+ .bgTaskToolCallStatus,
462
+ .bgTaskToolCallId {
463
+ overflow: hidden;
464
+ text-overflow: ellipsis;
465
+ white-space: nowrap;
466
+ }
467
+
468
+ .bgTaskToolCallTime {
469
+ margin-top: 8px;
470
+ font-size: 12px;
471
+ }
472
+
473
+ .bgTaskToolCallId {
474
+ margin-top: 6px;
475
+ font-size: 12px;
476
+ opacity: 0.78;
477
+ }
478
+
352
479
  .details {
353
480
  border-radius: var(--radius);
354
481
  border: 1px solid rgba(31, 36, 38, 0.12);
@@ -655,4 +782,8 @@ body {
655
782
  flex-direction: column;
656
783
  align-items: flex-start;
657
784
  }
785
+
786
+ .bgTaskToolCallsGrid {
787
+ grid-template-columns: 1fr;
788
+ }
658
789
  }
@@ -0,0 +1,261 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { computeStackedSegments, AgentCounts, StackedSegment } from "./timeseries-stacked";
3
+
4
+ describe("computeStackedSegments", () => {
5
+ describe("Edge cases", () => {
6
+ it("should return empty array when chartHeight <= 0", () => {
7
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 5, atlas: 3 };
8
+
9
+ expect(computeStackedSegments(counts, 20, 0)).toEqual([]);
10
+ expect(computeStackedSegments(counts, 20, -5)).toEqual([]);
11
+ });
12
+
13
+ it("should return empty array when scaleMax <= 0", () => {
14
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 5, atlas: 3 };
15
+
16
+ expect(computeStackedSegments(counts, 0, 100)).toEqual([]);
17
+ expect(computeStackedSegments(counts, -10, 100)).toEqual([]);
18
+ });
19
+
20
+ it("should return empty array when all counts are zero", () => {
21
+ const counts: AgentCounts = { sisyphus: 0, prometheus: 0, atlas: 0 };
22
+
23
+ const result = computeStackedSegments(counts, 20, 100);
24
+ expect(result).toEqual([]);
25
+ });
26
+
27
+ it("should handle invalid/missing counts gracefully", () => {
28
+ const invalidCounts = {
29
+ sisyphus: NaN,
30
+ prometheus: Infinity,
31
+ atlas: -5,
32
+ } as unknown as AgentCounts;
33
+
34
+ const result = computeStackedSegments(invalidCounts, 20, 100);
35
+ expect(result).toEqual([]);
36
+ });
37
+
38
+ it("should handle mixed valid/invalid counts", () => {
39
+ const mixedCounts = {
40
+ sisyphus: 10,
41
+ prometheus: NaN,
42
+ atlas: -3,
43
+ } as unknown as AgentCounts;
44
+
45
+ const result = computeStackedSegments(mixedCounts, 20, 100);
46
+ expect(result).toHaveLength(1);
47
+ expect(result[0]).toEqual({
48
+ tone: "teal",
49
+ y: 50,
50
+ height: 50,
51
+ });
52
+ });
53
+ });
54
+
55
+ describe("Single agent scenarios", () => {
56
+ it("should return one segment when only sisyphus is non-zero", () => {
57
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 0, atlas: 0 };
58
+
59
+ const result = computeStackedSegments(counts, 20, 100);
60
+ expect(result).toHaveLength(1);
61
+ expect(result[0]).toEqual({
62
+ tone: "teal",
63
+ y: 50,
64
+ height: 50,
65
+ });
66
+ });
67
+
68
+ it("should return one segment when only prometheus is non-zero", () => {
69
+ const counts: AgentCounts = { sisyphus: 0, prometheus: 15, atlas: 0 };
70
+
71
+ const result = computeStackedSegments(counts, 30, 120);
72
+ expect(result).toHaveLength(1);
73
+ expect(result[0]).toEqual({
74
+ tone: "red",
75
+ y: 60,
76
+ height: 60,
77
+ });
78
+ });
79
+
80
+ it("should return one segment when only atlas is non-zero", () => {
81
+ const counts: AgentCounts = { sisyphus: 0, prometheus: 0, atlas: 8 };
82
+
83
+ const result = computeStackedSegments(counts, 16, 80);
84
+ expect(result).toHaveLength(1);
85
+ expect(result[0]).toEqual({
86
+ tone: "green",
87
+ y: 40,
88
+ height: 40,
89
+ });
90
+ });
91
+
92
+ it("should round to at least 1px for non-zero values", () => {
93
+ const counts: AgentCounts = { sisyphus: 1, prometheus: 0, atlas: 0 };
94
+
95
+ const result = computeStackedSegments(counts, 1000, 100);
96
+ expect(result).toHaveLength(1);
97
+ expect(result[0].height).toBeGreaterThanOrEqual(1);
98
+ });
99
+ });
100
+
101
+ describe("Multiple agent scenarios", () => {
102
+ it("should return multiple segments in correct order (bottom to top)", () => {
103
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 20, atlas: 15 };
104
+
105
+ const result = computeStackedSegments(counts, 50, 100);
106
+ expect(result).toHaveLength(3);
107
+
108
+ // Check order: teal (sisyphus) -> red (prometheus) -> green (atlas)
109
+ expect(result[0].tone).toBe("teal");
110
+ expect(result[1].tone).toBe("red");
111
+ expect(result[2].tone).toBe("green");
112
+
113
+ // Check positions (y increases upward, so atlas should have smallest y)
114
+ expect(result[0].y).toBeGreaterThan(result[1].y);
115
+ expect(result[1].y).toBeGreaterThan(result[2].y);
116
+ });
117
+
118
+ it("should correctly calculate heights for all agents", () => {
119
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 20, atlas: 15 };
120
+
121
+ const result = computeStackedSegments(counts, 50, 100);
122
+ const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
123
+
124
+ expect(totalHeight).toBeLessThanOrEqual(100);
125
+
126
+ // Expected heights: 20, 40, 30
127
+ expect(result[0].height).toBe(20); // sisyphus
128
+ expect(result[1].height).toBe(40); // prometheus
129
+ expect(result[2].height).toBe(30); // atlas
130
+ });
131
+
132
+ it("should handle zero values mixed with non-zero values", () => {
133
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 0, atlas: 15 };
134
+
135
+ const result = computeStackedSegments(counts, 30, 90);
136
+ expect(result).toHaveLength(2);
137
+
138
+ // Should only have teal (sisyphus) and green (atlas)
139
+ expect(result[0].tone).toBe("teal");
140
+ expect(result[1].tone).toBe("green");
141
+
142
+ // Check positioning
143
+ expect(result[0].y).toBeGreaterThan(result[1].y);
144
+ });
145
+ });
146
+
147
+ describe("Clamping and overflow behavior", () => {
148
+ it("should ensure sum of heights never exceeds chartHeight", () => {
149
+ const counts: AgentCounts = { sisyphus: 100, prometheus: 100, atlas: 100 };
150
+
151
+ const result = computeStackedSegments(counts, 100, 50); // Should overflow
152
+ const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
153
+
154
+ expect(totalHeight).toBeLessThanOrEqual(50);
155
+ });
156
+
157
+ it("should preserve at least 1px for non-zero agents when possible", () => {
158
+ const counts: AgentCounts = { sisyphus: 1, prometheus: 1, atlas: 1 };
159
+
160
+ const result = computeStackedSegments(counts, 100, 10);
161
+
162
+ // All agents should be visible with at least 1px each
163
+ expect(result).toHaveLength(3);
164
+ expect(result.every(seg => seg.height >= 1)).toBe(true);
165
+ });
166
+
167
+ it("should distribute overflow reduction fairly", () => {
168
+ const counts: AgentCounts = { sisyphus: 40, prometheus: 35, atlas: 25 };
169
+
170
+ const result = computeStackedSegments(counts, 100, 80);
171
+ const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
172
+
173
+ expect(totalHeight).toBeLessThanOrEqual(80);
174
+
175
+ // Larger segments should be reduced more
176
+ const heights = result.map(seg => seg.height);
177
+ expect(Math.max(...heights)).toBeLessThanOrEqual(40); // Original largest was 40
178
+ });
179
+
180
+ it("should handle extreme overflow gracefully", () => {
181
+ const counts: AgentCounts = { sisyphus: 1000, prometheus: 1000, atlas: 1000 };
182
+
183
+ const result = computeStackedSegments(counts, 100, 5);
184
+ const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
185
+
186
+ expect(totalHeight).toBeLessThanOrEqual(5);
187
+ // Should still try to show all agents
188
+ expect(result.length).toBeGreaterThanOrEqual(1);
189
+ });
190
+ });
191
+
192
+ describe("Deterministic behavior", () => {
193
+ it("should produce identical results for identical inputs", () => {
194
+ const counts: AgentCounts = { sisyphus: 15, prometheus: 25, atlas: 10 };
195
+
196
+ const result1 = computeStackedSegments(counts, 60, 100);
197
+ const result2 = computeStackedSegments(counts, 60, 100);
198
+
199
+ expect(result1).toEqual(result2);
200
+ });
201
+
202
+ it("should maintain consistent segment order regardless of input magnitudes", () => {
203
+ const testCases = [
204
+ { sisyphus: 100, prometheus: 1, atlas: 1 },
205
+ { sisyphus: 1, prometheus: 100, atlas: 1 },
206
+ { sisyphus: 1, prometheus: 1, atlas: 100 },
207
+ { sisyphus: 50, prometheus: 25, atlas: 75 },
208
+ ] as AgentCounts[];
209
+
210
+ testCases.forEach(counts => {
211
+ const result = computeStackedSegments(counts, 100, 100);
212
+ const tones = result.map(seg => seg.tone);
213
+
214
+ if (result.length === 3) {
215
+ expect(tones).toEqual(["teal", "red", "green"]);
216
+ } else if (result.length === 2) {
217
+ // Should still be in correct order, just missing zero-valued agents
218
+ expect(tones).toEqual(expect.arrayContaining([
219
+ expect.stringMatching(/teal|red|green/)
220
+ ]));
221
+ // Check that order is preserved for present agents
222
+ const toneIndex = (t: string) => ["teal", "red", "green"].indexOf(t);
223
+ for (let i = 1; i < tones.length; i++) {
224
+ expect(toneIndex(tones[i])).toBeGreaterThan(toneIndex(tones[i-1]));
225
+ }
226
+ }
227
+ });
228
+ });
229
+ });
230
+
231
+ describe("Boundary conditions", () => {
232
+ it("should handle very small chartHeight", () => {
233
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 5, atlas: 3 };
234
+
235
+ const result = computeStackedSegments(counts, 20, 1);
236
+ const totalHeight = result.reduce((sum, seg) => sum + seg.height, 0);
237
+
238
+ expect(totalHeight).toBeLessThanOrEqual(1);
239
+ });
240
+
241
+ it("should handle very large scaleMax", () => {
242
+ const counts: AgentCounts = { sisyphus: 10, prometheus: 5, atlas: 3 };
243
+
244
+ const result = computeStackedSegments(counts, 1000000, 100);
245
+
246
+ // Should produce very small but non-zero heights
247
+ expect(result.length).toBeGreaterThan(0);
248
+ expect(result.every(seg => seg.height >= 1)).toBe(true);
249
+ });
250
+
251
+ it("should handle fractional results correctly", () => {
252
+ const counts: AgentCounts = { sisyphus: 1, prometheus: 1, atlas: 1 };
253
+
254
+ const result = computeStackedSegments(counts, 3, 10);
255
+
256
+ // All heights should be integers
257
+ expect(result.every(seg => Number.isInteger(seg.height))).toBe(true);
258
+ expect(result.every(seg => Number.isInteger(seg.y))).toBe(true);
259
+ });
260
+ });
261
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Pure helper for computing stacked bar segment dimensions.
3
+ * Independent of React/DOM for testability.
4
+ */
5
+
6
+ export type AgentTone = "teal" | "red" | "green";
7
+
8
+ export interface StackedSegment {
9
+ tone: AgentTone;
10
+ y: number;
11
+ height: number;
12
+ }
13
+
14
+ export interface AgentCounts {
15
+ sisyphus: number;
16
+ prometheus: number;
17
+ atlas: number;
18
+ }
19
+
20
+ /**
21
+ * Compute stacked bar segments for a single bucket.
22
+ *
23
+ * Segment order (bottom to top):
24
+ * 1. Sisyphus (teal) - bottom
25
+ * 2. Prometheus (red) - middle
26
+ * 3. Atlas (green) - top
27
+ *
28
+ * @param counts - Agent counts for this bucket
29
+ * @param scaleMax - Maximum value for scaling (must be > 0)
30
+ * @param chartHeight - Available height in pixels
31
+ * @returns Ordered segments from bottom to top
32
+ */
33
+ export function computeStackedSegments(
34
+ counts: AgentCounts,
35
+ scaleMax: number,
36
+ chartHeight: number
37
+ ): StackedSegment[] {
38
+ // Handle edge cases
39
+ if (chartHeight <= 0 || scaleMax <= 0) {
40
+ return [];
41
+ }
42
+
43
+ // Validate and sanitize counts
44
+ const sanitized = {
45
+ sisyphus: Math.max(0, Number.isFinite(counts.sisyphus) ? counts.sisyphus : 0),
46
+ prometheus: Math.max(0, Number.isFinite(counts.prometheus) ? counts.prometheus : 0),
47
+ atlas: Math.max(0, Number.isFinite(counts.atlas) ? counts.atlas : 0),
48
+ };
49
+
50
+ const total = sanitized.sisyphus + sanitized.prometheus + sanitized.atlas;
51
+ if (total === 0) {
52
+ return [];
53
+ }
54
+
55
+ // Compute raw heights
56
+ const rawHeights = {
57
+ sisyphus: (sanitized.sisyphus / scaleMax) * chartHeight,
58
+ prometheus: (sanitized.prometheus / scaleMax) * chartHeight,
59
+ atlas: (sanitized.atlas / scaleMax) * chartHeight,
60
+ };
61
+
62
+ // Round to pixels, ensuring sum never exceeds chartHeight
63
+ const roundedHeights = {
64
+ sisyphus: Math.max(1, Math.round(rawHeights.sisyphus)) * (sanitized.sisyphus > 0 ? 1 : 0),
65
+ prometheus: Math.max(1, Math.round(rawHeights.prometheus)) * (sanitized.prometheus > 0 ? 1 : 0),
66
+ atlas: Math.max(1, Math.round(rawHeights.atlas)) * (sanitized.atlas > 0 ? 1 : 0),
67
+ };
68
+
69
+ // Ensure non-zero agents remain visible when possible
70
+ let totalRounded = roundedHeights.sisyphus + roundedHeights.prometheus + roundedHeights.atlas;
71
+
72
+ // Distribute any overflow reduction fairly
73
+ if (totalRounded > chartHeight) {
74
+ const excess = totalRounded - chartHeight;
75
+ const weights = [
76
+ { key: 'sisyphus' as keyof typeof roundedHeights, height: roundedHeights.sisyphus },
77
+ { key: 'prometheus' as keyof typeof roundedHeights, height: roundedHeights.prometheus },
78
+ { key: 'atlas' as keyof typeof roundedHeights, height: roundedHeights.atlas },
79
+ ].filter(w => w.height > 0);
80
+
81
+ if (weights.length > 0) {
82
+ let remainingExcess = excess;
83
+ const totalWeight = weights.reduce((sum, w) => sum + w.height, 0);
84
+
85
+ for (const weight of weights) {
86
+ if (remainingExcess <= 0) break;
87
+ const reduction = Math.min(
88
+ Math.max(1, weight.height - 1), // Keep at least 1px for non-zero agents
89
+ Math.round((weight.height / totalWeight) * excess)
90
+ );
91
+ roundedHeights[weight.key] -= reduction;
92
+ remainingExcess -= reduction;
93
+ }
94
+
95
+ // If still over, trim from largest segments
96
+ totalRounded = roundedHeights.sisyphus + roundedHeights.prometheus + roundedHeights.atlas;
97
+ while (totalRounded > chartHeight) {
98
+ const sortedWeights = weights
99
+ .map(w => ({ ...w, height: roundedHeights[w.key] }))
100
+ .filter(w => w.height > 1) // Only trim segments > 1px
101
+ .sort((a, b) => b.height - a.height);
102
+
103
+ if (sortedWeights.length === 0) break;
104
+ roundedHeights[sortedWeights[0].key]--;
105
+ totalRounded--;
106
+ }
107
+ }
108
+ }
109
+
110
+ // Build segments from bottom to top
111
+ const segments: StackedSegment[] = [];
112
+ let currentY = chartHeight; // Start from bottom
113
+
114
+ // Sisyphus (teal) - bottom
115
+ if (roundedHeights.sisyphus > 0) {
116
+ currentY -= roundedHeights.sisyphus;
117
+ segments.push({
118
+ tone: "teal",
119
+ y: currentY,
120
+ height: roundedHeights.sisyphus,
121
+ });
122
+ }
123
+
124
+ // Prometheus (red) - middle
125
+ if (roundedHeights.prometheus > 0) {
126
+ currentY -= roundedHeights.prometheus;
127
+ segments.push({
128
+ tone: "red",
129
+ y: currentY,
130
+ height: roundedHeights.prometheus,
131
+ });
132
+ }
133
+
134
+ // Atlas (green) - top
135
+ if (roundedHeights.atlas > 0) {
136
+ currentY -= roundedHeights.atlas;
137
+ segments.push({
138
+ tone: "green",
139
+ y: currentY,
140
+ height: roundedHeights.atlas,
141
+ });
142
+ }
143
+
144
+ return segments;
145
+ }