oh-my-opencode-dashboard 0.0.1 → 0.0.2
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 +2 -0
- package/dist/assets/index-RAZRO3YN.css +1 -0
- package/dist/assets/index-Vi32E82S.js +40 -0
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/src/App.tsx +358 -9
- package/src/app-version.d.ts +1 -0
- package/src/ingest/boulder.ts +44 -0
- package/src/ingest/timeseries.test.ts +491 -0
- package/src/ingest/timeseries.ts +289 -0
- package/src/server/api.test.ts +17 -0
- package/src/server/dashboard.test.ts +43 -0
- package/src/server/dashboard.ts +13 -1
- package/src/sound.test.ts +47 -0
- package/src/sound.ts +12 -1
- package/src/styles.css +201 -0
- package/vite.config.ts +24 -1
- package/dashboard-ui.png +0 -0
- package/dist/assets/index-D6OVzN1o.css +0 -1
- package/dist/assets/index-SEmwze_4.js +0 -40
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as path from "node:path"
|
|
3
|
+
import type { OpenCodeStorageRoots, StoredMessageMeta } from "./session"
|
|
4
|
+
import { getMessageDir } from "./session"
|
|
5
|
+
import { readAllSessionMetas } from "./background-tasks"
|
|
6
|
+
|
|
7
|
+
export type TimeSeriesTone = "muted" | "teal" | "red" | "green"
|
|
8
|
+
|
|
9
|
+
export type TimeSeriesSeries = {
|
|
10
|
+
id: string
|
|
11
|
+
label: string
|
|
12
|
+
tone: TimeSeriesTone
|
|
13
|
+
values: number[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type TimeSeriesPayload = {
|
|
17
|
+
windowMs: number
|
|
18
|
+
bucketMs: number
|
|
19
|
+
buckets: number
|
|
20
|
+
anchorMs: number
|
|
21
|
+
serverNowMs: number
|
|
22
|
+
series: TimeSeriesSeries[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type CanonicalAgent = "sisyphus" | "prometheus" | "atlas" | "other"
|
|
26
|
+
|
|
27
|
+
const SERIES_ORDER: Array<Pick<TimeSeriesSeries, "id" | "label" | "tone">> = [
|
|
28
|
+
{ id: "overall-main", label: "Overall", tone: "muted" },
|
|
29
|
+
{ id: "agent:sisyphus", label: "Sisyphus", tone: "teal" },
|
|
30
|
+
{ id: "agent:prometheus", label: "Prometheus", tone: "red" },
|
|
31
|
+
{ id: "agent:atlas", label: "Atlas", tone: "green" },
|
|
32
|
+
{ id: "background-total", label: "Background tasks (total)", tone: "muted" },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
function zeroBuckets(size: number): number[] {
|
|
36
|
+
return Array.from({ length: size }, () => 0)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function listJsonFiles(dir: string): string[] {
|
|
40
|
+
try {
|
|
41
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".json"))
|
|
42
|
+
} catch {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readJsonFile<T>(filePath: string): T | null {
|
|
48
|
+
try {
|
|
49
|
+
const content = fs.readFileSync(filePath, "utf8")
|
|
50
|
+
return JSON.parse(content) as T
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readRecentMessageMetas(messageDir: string, maxMessages: number): StoredMessageMeta[] {
|
|
57
|
+
if (!messageDir || !fs.existsSync(messageDir)) return []
|
|
58
|
+
const ranked = listJsonFiles(messageDir)
|
|
59
|
+
.map((f) => ({
|
|
60
|
+
f,
|
|
61
|
+
mtime: (() => {
|
|
62
|
+
try {
|
|
63
|
+
return fs.statSync(path.join(messageDir, f)).mtimeMs
|
|
64
|
+
} catch {
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
})(),
|
|
68
|
+
}))
|
|
69
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
70
|
+
.slice(0, maxMessages)
|
|
71
|
+
|
|
72
|
+
const metas: StoredMessageMeta[] = []
|
|
73
|
+
for (const item of ranked) {
|
|
74
|
+
const meta = readJsonFile<StoredMessageMeta>(path.join(messageDir, item.f))
|
|
75
|
+
if (meta && typeof meta.id === "string") metas.push(meta)
|
|
76
|
+
}
|
|
77
|
+
return metas
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function countToolParts(partStorage: string, messageId: string): number {
|
|
81
|
+
const partDir = path.join(partStorage, messageId)
|
|
82
|
+
if (!fs.existsSync(partDir)) return 0
|
|
83
|
+
|
|
84
|
+
let count = 0
|
|
85
|
+
const files = listJsonFiles(partDir).sort()
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
const part = readJsonFile<{ type?: string }>(path.join(partDir, file))
|
|
88
|
+
if (part && part.type === "tool") count += 1
|
|
89
|
+
}
|
|
90
|
+
return count
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function canonicalizeAgent(agent: unknown): CanonicalAgent {
|
|
94
|
+
if (typeof agent !== "string") return "other"
|
|
95
|
+
const trimmed = agent.trim()
|
|
96
|
+
if (!trimmed) return "other"
|
|
97
|
+
const lowered = trimmed.toLowerCase()
|
|
98
|
+
if (lowered.startsWith("sisyphus-junior")) return "sisyphus"
|
|
99
|
+
if (lowered.startsWith("sisyphus")) return "sisyphus"
|
|
100
|
+
if (lowered.startsWith("prometheus")) return "prometheus"
|
|
101
|
+
if (lowered.startsWith("atlas")) return "atlas"
|
|
102
|
+
return "other"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function addToBucket(values: number[], bucketIndex: number, count: number): void {
|
|
106
|
+
if (bucketIndex < 0 || bucketIndex >= values.length) return
|
|
107
|
+
values[bucketIndex] += count
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getCreated(meta: StoredMessageMeta): number {
|
|
111
|
+
const created = meta.time?.created
|
|
112
|
+
return typeof created === "number" ? created : -Infinity
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function bucketMessageTools(opts: {
|
|
116
|
+
storage: OpenCodeStorageRoots
|
|
117
|
+
messageDir: string
|
|
118
|
+
startMs: number
|
|
119
|
+
anchorMs: number
|
|
120
|
+
bucketMs: number
|
|
121
|
+
overall: number[]
|
|
122
|
+
perAgent?: Record<Exclude<CanonicalAgent, "other">, number[]>
|
|
123
|
+
}): void {
|
|
124
|
+
const metas = readRecentMessageMetas(opts.messageDir, 200)
|
|
125
|
+
const ordered = [...metas].sort((a, b) => {
|
|
126
|
+
const at = getCreated(a)
|
|
127
|
+
const bt = getCreated(b)
|
|
128
|
+
if (bt !== at) return bt - at
|
|
129
|
+
return String(a.id).localeCompare(String(b.id))
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
for (const meta of ordered) {
|
|
133
|
+
const created = getCreated(meta)
|
|
134
|
+
if (created < opts.startMs) break
|
|
135
|
+
if (created >= opts.anchorMs) continue
|
|
136
|
+
|
|
137
|
+
const bucketIndex = Math.floor((created - opts.startMs) / opts.bucketMs)
|
|
138
|
+
const toolCount = countToolParts(opts.storage.part, meta.id)
|
|
139
|
+
if (toolCount <= 0) continue
|
|
140
|
+
|
|
141
|
+
addToBucket(opts.overall, bucketIndex, toolCount)
|
|
142
|
+
const perAgent = opts.perAgent
|
|
143
|
+
if (perAgent) {
|
|
144
|
+
const agent = canonicalizeAgent(meta.agent)
|
|
145
|
+
if (agent === "sisyphus" || agent === "prometheus" || agent === "atlas") {
|
|
146
|
+
addToBucket(perAgent[agent], bucketIndex, toolCount)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function bucketBackgroundTools(opts: {
|
|
153
|
+
storage: OpenCodeStorageRoots
|
|
154
|
+
sessionIds: string[]
|
|
155
|
+
startMs: number
|
|
156
|
+
anchorMs: number
|
|
157
|
+
bucketMs: number
|
|
158
|
+
output: number[]
|
|
159
|
+
}): void {
|
|
160
|
+
for (const sessionId of opts.sessionIds) {
|
|
161
|
+
const messageDir = getMessageDir(opts.storage.message, sessionId)
|
|
162
|
+
const metas = readRecentMessageMetas(messageDir, 200)
|
|
163
|
+
const ordered = [...metas].sort((a, b) => {
|
|
164
|
+
const at = getCreated(a)
|
|
165
|
+
const bt = getCreated(b)
|
|
166
|
+
if (bt !== at) return bt - at
|
|
167
|
+
return String(a.id).localeCompare(String(b.id))
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
for (const meta of ordered) {
|
|
171
|
+
const created = getCreated(meta)
|
|
172
|
+
if (created < opts.startMs) break
|
|
173
|
+
if (created >= opts.anchorMs) continue
|
|
174
|
+
|
|
175
|
+
const bucketIndex = Math.floor((created - opts.startMs) / opts.bucketMs)
|
|
176
|
+
const toolCount = countToolParts(opts.storage.part, meta.id)
|
|
177
|
+
if (toolCount <= 0) continue
|
|
178
|
+
addToBucket(opts.output, bucketIndex, toolCount)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function bucketSessionAgents(opts: {
|
|
184
|
+
storage: OpenCodeStorageRoots
|
|
185
|
+
sessionIds: string[]
|
|
186
|
+
startMs: number
|
|
187
|
+
anchorMs: number
|
|
188
|
+
bucketMs: number
|
|
189
|
+
overall: number[]
|
|
190
|
+
perAgent: Record<Exclude<CanonicalAgent, "other">, number[]>
|
|
191
|
+
}): void {
|
|
192
|
+
for (const sessionId of opts.sessionIds) {
|
|
193
|
+
const messageDir = getMessageDir(opts.storage.message, sessionId)
|
|
194
|
+
if (!messageDir) continue
|
|
195
|
+
bucketMessageTools({
|
|
196
|
+
storage: opts.storage,
|
|
197
|
+
messageDir,
|
|
198
|
+
startMs: opts.startMs,
|
|
199
|
+
anchorMs: opts.anchorMs,
|
|
200
|
+
bucketMs: opts.bucketMs,
|
|
201
|
+
overall: opts.overall,
|
|
202
|
+
// Background sessions are owned by Sisyphus; don't smear activity into Prometheus/Atlas.
|
|
203
|
+
perAgent: undefined,
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function deriveTimeSeriesActivity(opts: {
|
|
209
|
+
storage: OpenCodeStorageRoots
|
|
210
|
+
mainSessionId: string | null
|
|
211
|
+
nowMs?: number
|
|
212
|
+
windowMs?: number
|
|
213
|
+
bucketMs?: number
|
|
214
|
+
}): TimeSeriesPayload {
|
|
215
|
+
const windowMs = opts.windowMs ?? 300_000
|
|
216
|
+
const bucketMs = opts.bucketMs ?? 2_000
|
|
217
|
+
const buckets = Math.floor(windowMs / bucketMs)
|
|
218
|
+
const nowMs = opts.nowMs ?? Date.now()
|
|
219
|
+
const anchorMs = Math.floor(nowMs / bucketMs) * bucketMs
|
|
220
|
+
const startMs = anchorMs - windowMs
|
|
221
|
+
|
|
222
|
+
const overall = zeroBuckets(buckets)
|
|
223
|
+
const sisyphus = zeroBuckets(buckets)
|
|
224
|
+
const prometheus = zeroBuckets(buckets)
|
|
225
|
+
const atlas = zeroBuckets(buckets)
|
|
226
|
+
const background = zeroBuckets(buckets)
|
|
227
|
+
|
|
228
|
+
const mainSessionId = opts.mainSessionId
|
|
229
|
+
if (mainSessionId) {
|
|
230
|
+
const messageDir = getMessageDir(opts.storage.message, mainSessionId)
|
|
231
|
+
if (messageDir) {
|
|
232
|
+
bucketMessageTools({
|
|
233
|
+
storage: opts.storage,
|
|
234
|
+
messageDir,
|
|
235
|
+
startMs,
|
|
236
|
+
anchorMs,
|
|
237
|
+
bucketMs,
|
|
238
|
+
overall,
|
|
239
|
+
perAgent: { sisyphus, prometheus, atlas },
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const childSessions = readAllSessionMetas(opts.storage.session)
|
|
244
|
+
.filter((meta) => meta.parentID === mainSessionId)
|
|
245
|
+
.sort((a, b) => {
|
|
246
|
+
const at = a.time?.updated ?? 0
|
|
247
|
+
const bt = b.time?.updated ?? 0
|
|
248
|
+
if (bt !== at) return bt - at
|
|
249
|
+
return String(a.id).localeCompare(String(b.id))
|
|
250
|
+
})
|
|
251
|
+
.slice(0, 25)
|
|
252
|
+
.map((meta) => meta.id)
|
|
253
|
+
|
|
254
|
+
if (childSessions.length > 0) {
|
|
255
|
+
bucketBackgroundTools({
|
|
256
|
+
storage: opts.storage,
|
|
257
|
+
sessionIds: childSessions,
|
|
258
|
+
startMs,
|
|
259
|
+
anchorMs,
|
|
260
|
+
bucketMs,
|
|
261
|
+
output: background,
|
|
262
|
+
})
|
|
263
|
+
bucketSessionAgents({
|
|
264
|
+
storage: opts.storage,
|
|
265
|
+
sessionIds: childSessions,
|
|
266
|
+
startMs,
|
|
267
|
+
anchorMs,
|
|
268
|
+
bucketMs,
|
|
269
|
+
overall,
|
|
270
|
+
perAgent: { sisyphus, prometheus, atlas },
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
windowMs,
|
|
277
|
+
bucketMs,
|
|
278
|
+
buckets,
|
|
279
|
+
anchorMs,
|
|
280
|
+
serverNowMs: nowMs,
|
|
281
|
+
series: [
|
|
282
|
+
{ ...SERIES_ORDER[0], values: overall },
|
|
283
|
+
{ ...SERIES_ORDER[1], values: sisyphus },
|
|
284
|
+
{ ...SERIES_ORDER[2], values: prometheus },
|
|
285
|
+
{ ...SERIES_ORDER[3], values: atlas },
|
|
286
|
+
{ ...SERIES_ORDER[4], values: background },
|
|
287
|
+
],
|
|
288
|
+
}
|
|
289
|
+
}
|
package/src/server/api.test.ts
CHANGED
|
@@ -8,6 +8,14 @@ describe('API Routes', () => {
|
|
|
8
8
|
mainSession: { agent: "x", currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
|
|
9
9
|
planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started" },
|
|
10
10
|
backgroundTasks: [],
|
|
11
|
+
timeSeries: {
|
|
12
|
+
windowMs: 0,
|
|
13
|
+
bucketMs: 0,
|
|
14
|
+
buckets: 0,
|
|
15
|
+
anchorMs: 0,
|
|
16
|
+
serverNowMs: 0,
|
|
17
|
+
series: [{ id: "overall-main", label: "Overall", tone: "muted", values: [] }],
|
|
18
|
+
},
|
|
11
19
|
raw: null,
|
|
12
20
|
}),
|
|
13
21
|
})
|
|
@@ -23,6 +31,14 @@ describe('API Routes', () => {
|
|
|
23
31
|
mainSession: { agent: "x", currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
|
|
24
32
|
planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started" },
|
|
25
33
|
backgroundTasks: [{ id: "1", description: "d", agent: "a", status: "queued", toolCalls: 0, lastTool: "-", timeline: "" }],
|
|
34
|
+
timeSeries: {
|
|
35
|
+
windowMs: 0,
|
|
36
|
+
bucketMs: 0,
|
|
37
|
+
buckets: 0,
|
|
38
|
+
anchorMs: 0,
|
|
39
|
+
serverNowMs: 0,
|
|
40
|
+
series: [{ id: "overall-main", label: "Overall", tone: "muted", values: [] }],
|
|
41
|
+
},
|
|
26
42
|
raw: { ok: true },
|
|
27
43
|
}),
|
|
28
44
|
})
|
|
@@ -35,6 +51,7 @@ describe('API Routes', () => {
|
|
|
35
51
|
expect(data).toHaveProperty("mainSession")
|
|
36
52
|
expect(data).toHaveProperty("planProgress")
|
|
37
53
|
expect(data).toHaveProperty("backgroundTasks")
|
|
54
|
+
expect(data).toHaveProperty("timeSeries")
|
|
38
55
|
expect(data).toHaveProperty("raw")
|
|
39
56
|
|
|
40
57
|
const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
|
|
@@ -132,4 +132,47 @@ describe("buildDashboardPayload", () => {
|
|
|
132
132
|
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
133
133
|
}
|
|
134
134
|
})
|
|
135
|
+
|
|
136
|
+
it("includes timeSeries and sanitized raw payload when no sessions exist", () => {
|
|
137
|
+
const storageRoot = mkStorageRoot()
|
|
138
|
+
const storage = getStorageRoots(storageRoot)
|
|
139
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const payload = buildDashboardPayload({
|
|
143
|
+
projectRoot,
|
|
144
|
+
storage,
|
|
145
|
+
nowMs: 2000,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
expect(payload).toHaveProperty("timeSeries")
|
|
149
|
+
expect(payload.raw).toHaveProperty("timeSeries")
|
|
150
|
+
|
|
151
|
+
const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
|
|
152
|
+
|
|
153
|
+
const hasSensitiveKeys = (value: unknown): boolean => {
|
|
154
|
+
if (typeof value !== "object" || value === null) {
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const key of Object.keys(value)) {
|
|
159
|
+
if (sensitiveKeys.includes(key)) {
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
const nextValue = (value as Record<string, unknown>)[key]
|
|
163
|
+
if (typeof nextValue === "object" && nextValue !== null) {
|
|
164
|
+
if (hasSensitiveKeys(nextValue)) {
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
expect(hasSensitiveKeys(payload.raw)).toBe(false)
|
|
173
|
+
} finally {
|
|
174
|
+
fs.rmSync(storageRoot, { recursive: true, force: true })
|
|
175
|
+
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
176
|
+
}
|
|
177
|
+
})
|
|
135
178
|
})
|
package/src/server/dashboard.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs"
|
|
2
2
|
import * as path from "node:path"
|
|
3
|
-
import { readBoulderState, readPlanProgress } from "../ingest/boulder"
|
|
3
|
+
import { readBoulderState, readPlanProgress, readPlanSteps, type PlanStep } from "../ingest/boulder"
|
|
4
4
|
import { deriveBackgroundTasks } from "../ingest/background-tasks"
|
|
5
|
+
import { deriveTimeSeriesActivity, type TimeSeriesPayload } from "../ingest/timeseries"
|
|
5
6
|
import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
|
|
6
7
|
|
|
7
8
|
export type DashboardPayload = {
|
|
@@ -18,6 +19,7 @@ export type DashboardPayload = {
|
|
|
18
19
|
total: number
|
|
19
20
|
path: string
|
|
20
21
|
statusPill: string
|
|
22
|
+
steps: PlanStep[]
|
|
21
23
|
}
|
|
22
24
|
backgroundTasks: Array<{
|
|
23
25
|
id: string
|
|
@@ -28,6 +30,7 @@ export type DashboardPayload = {
|
|
|
28
30
|
lastTool: string
|
|
29
31
|
timeline: string
|
|
30
32
|
}>
|
|
33
|
+
timeSeries: TimeSeriesPayload
|
|
31
34
|
raw: unknown
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -68,6 +71,7 @@ export function buildDashboardPayload(opts: {
|
|
|
68
71
|
const planName = boulder?.plan_name ?? "(no active plan)"
|
|
69
72
|
const planPath = boulder?.active_plan ?? ""
|
|
70
73
|
const plan = boulder ? readPlanProgress(opts.projectRoot, boulder.active_plan) : { total: 0, completed: 0, isComplete: false, missing: true }
|
|
74
|
+
const planSteps = boulder ? readPlanSteps(opts.projectRoot, boulder.active_plan) : { missing: true, steps: [] as PlanStep[] }
|
|
71
75
|
|
|
72
76
|
const sessionId = pickActiveSessionId({
|
|
73
77
|
projectRoot: opts.projectRoot,
|
|
@@ -92,6 +96,11 @@ export function buildDashboardPayload(opts: {
|
|
|
92
96
|
: { agent: "unknown", currentTool: null, lastUpdated: null, sessionLabel: "(no session)", status: "unknown" as const }
|
|
93
97
|
|
|
94
98
|
const tasks = sessionId ? deriveBackgroundTasks({ storage: opts.storage, mainSessionId: sessionId, nowMs }) : []
|
|
99
|
+
const timeSeries = deriveTimeSeriesActivity({
|
|
100
|
+
storage: opts.storage,
|
|
101
|
+
mainSessionId: sessionId ?? null,
|
|
102
|
+
nowMs,
|
|
103
|
+
})
|
|
95
104
|
|
|
96
105
|
const payload: DashboardPayload = {
|
|
97
106
|
mainSession: {
|
|
@@ -107,6 +116,7 @@ export function buildDashboardPayload(opts: {
|
|
|
107
116
|
total: plan.total,
|
|
108
117
|
path: planPath,
|
|
109
118
|
statusPill: planStatusPill(plan),
|
|
119
|
+
steps: planSteps.missing ? [] : planSteps.steps,
|
|
110
120
|
},
|
|
111
121
|
backgroundTasks: tasks.map((t) => ({
|
|
112
122
|
id: t.id,
|
|
@@ -117,6 +127,7 @@ export function buildDashboardPayload(opts: {
|
|
|
117
127
|
lastTool: t.lastTool ?? "-",
|
|
118
128
|
timeline: typeof t.timeline === "string" ? t.timeline : "",
|
|
119
129
|
})),
|
|
130
|
+
timeSeries,
|
|
120
131
|
raw: null,
|
|
121
132
|
}
|
|
122
133
|
|
|
@@ -124,6 +135,7 @@ export function buildDashboardPayload(opts: {
|
|
|
124
135
|
mainSession: payload.mainSession,
|
|
125
136
|
planProgress: payload.planProgress,
|
|
126
137
|
backgroundTasks: payload.backgroundTasks,
|
|
138
|
+
timeSeries: payload.timeSeries,
|
|
127
139
|
}
|
|
128
140
|
return payload
|
|
129
141
|
}
|
package/src/sound.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ describe("playDing", () => {
|
|
|
5
5
|
|
|
6
6
|
afterEach(() => {
|
|
7
7
|
;(globalThis as unknown as { window?: unknown }).window = prevWindow
|
|
8
|
+
return import("./sound").then((m) => m.__resetAudioForTests())
|
|
8
9
|
})
|
|
9
10
|
|
|
10
11
|
it("plays two tones for waiting", async () => {
|
|
@@ -52,4 +53,50 @@ describe("playDing", () => {
|
|
|
52
53
|
|
|
53
54
|
expect(oscillators).toHaveLength(2)
|
|
54
55
|
})
|
|
56
|
+
|
|
57
|
+
it("plays three tones for question", async () => {
|
|
58
|
+
const oscillators: Array<{ start: ReturnType<typeof vi.fn> }> = []
|
|
59
|
+
|
|
60
|
+
class FakeAudioContext {
|
|
61
|
+
public state: AudioContextState = "suspended"
|
|
62
|
+
public currentTime = 1
|
|
63
|
+
public destination = null as unknown as AudioDestinationNode
|
|
64
|
+
|
|
65
|
+
resume = vi.fn(async () => {
|
|
66
|
+
this.state = "running"
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
createOscillator(): OscillatorNode {
|
|
70
|
+
const osc = {
|
|
71
|
+
type: "sine" as OscillatorType,
|
|
72
|
+
frequency: { setValueAtTime: vi.fn() },
|
|
73
|
+
connect: vi.fn(),
|
|
74
|
+
start: vi.fn(),
|
|
75
|
+
stop: vi.fn(),
|
|
76
|
+
}
|
|
77
|
+
oscillators.push({ start: osc.start })
|
|
78
|
+
return osc as unknown as OscillatorNode
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
createGain(): GainNode {
|
|
82
|
+
const g = {
|
|
83
|
+
gain: {
|
|
84
|
+
setValueAtTime: vi.fn(),
|
|
85
|
+
linearRampToValueAtTime: vi.fn(),
|
|
86
|
+
},
|
|
87
|
+
connect: vi.fn(),
|
|
88
|
+
}
|
|
89
|
+
return g as unknown as GainNode
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
;(globalThis as unknown as { window?: unknown }).window = {
|
|
94
|
+
AudioContext: FakeAudioContext,
|
|
95
|
+
} as unknown as Window & typeof globalThis
|
|
96
|
+
|
|
97
|
+
const { playDing } = await import("./sound")
|
|
98
|
+
await playDing("question")
|
|
99
|
+
|
|
100
|
+
expect(oscillators).toHaveLength(3)
|
|
101
|
+
})
|
|
55
102
|
})
|
package/src/sound.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
export type DingKind = "waiting" | "task" | "all"
|
|
1
|
+
export type DingKind = "waiting" | "task" | "all" | "question"
|
|
2
2
|
|
|
3
3
|
let ctx: AudioContext | null = null
|
|
4
4
|
|
|
5
|
+
export function __resetAudioForTests(): void {
|
|
6
|
+
ctx = null
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
function getCtx(): AudioContext | null {
|
|
6
10
|
if (typeof window === "undefined") return null
|
|
7
11
|
const AnyAudioContext = (window.AudioContext ?? (window as any).webkitAudioContext) as
|
|
@@ -82,6 +86,13 @@ export async function playDing(kind: DingKind): Promise<void> {
|
|
|
82
86
|
return
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
if (kind === "question") {
|
|
90
|
+
playTone({ at: t0, freq: 988, dur: 0.07, gain: baseGain })
|
|
91
|
+
playTone({ at: t0 + 0.09, freq: 740, dur: 0.11, gain: baseGain })
|
|
92
|
+
playTone({ at: t0 + 0.22, freq: 880, dur: 0.08, gain: baseGain })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
85
96
|
// all
|
|
86
97
|
playTone({ at: t0, freq: 523.25, dur: 0.10, gain: baseGain })
|
|
87
98
|
playTone({ at: t0 + 0.12, freq: 659.25, dur: 0.10, gain: baseGain })
|