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/dist/assets/index--GqzhA4-.css +1 -0
- package/dist/assets/index-CiC6k4Yg.js +40 -0
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/src/App.tsx +443 -56
- package/src/app-payload.test.ts +158 -0
- package/src/background-task-timeline.test.ts +32 -0
- package/src/background-task-toolcalls-policy.test.ts +191 -0
- package/src/ingest/background-tasks.test.ts +304 -2
- package/src/ingest/background-tasks.ts +67 -28
- package/src/ingest/model.ts +79 -0
- package/src/ingest/session.test.ts +119 -0
- package/src/ingest/session.ts +4 -0
- package/src/ingest/tool-calls.test.ts +161 -0
- package/src/ingest/tool-calls.ts +157 -0
- package/src/server/api.test.ts +162 -53
- package/src/server/api.ts +39 -2
- package/src/server/dashboard.test.ts +139 -0
- package/src/server/dashboard.ts +40 -3
- package/src/server/dev.ts +4 -2
- package/src/server/start.ts +4 -2
- package/src/styles.css +131 -0
- package/src/timeseries-stacked.test.ts +261 -0
- package/src/timeseries-stacked.ts +145 -0
- package/dist/assets/index-Cs5xePn_.js +0 -40
- package/dist/assets/index-RAZRO3YN.css +0 -1
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
|
|
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,
|
package/src/server/start.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|