@shendeguize/dsh-agent-sidecar 0.1.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.
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- package/src/supervisor.ts +463 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure view-model logic for the cross-agent session board (design §5.1
|
|
3
|
+
* view 1) and the footer status widget (view 4). No React, no I/O, no
|
|
4
|
+
* imports from the data layer (api/sse) — everything arrives as plain
|
|
5
|
+
* values and leaves as plain values, so this whole module is unit-testable
|
|
6
|
+
* in a bare node environment.
|
|
7
|
+
*
|
|
8
|
+
* Decoupling contract (T2.2 ↔ T2.4): the types below are this module's OWN
|
|
9
|
+
* view models. The integration task maps the host wire shapes
|
|
10
|
+
* (`SessionView` / `StateSnapshot` from the state endpoint) onto them.
|
|
11
|
+
* Notably every timestamp here is **epoch milliseconds** — the sidecar
|
|
12
|
+
* snapshot carries epoch seconds (`updated_at`), so the mapping layer
|
|
13
|
+
* multiplies by 1000 once at the boundary.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { BOARD_STRINGS } from './strings.ts'
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// View-model types (the props contract consumed by Board.tsx / widget.tsx).
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Normalized session status vocabulary; anything else maps to 'unknown'. */
|
|
25
|
+
export type SessionStatusToken = 'working' | 'waiting' | 'idle' | 'dead' | 'unknown'
|
|
26
|
+
|
|
27
|
+
/** Mirror of the host's StreamHealth union (kept local by design). */
|
|
28
|
+
export type StreamHealthToken = 'ok' | 'degraded' | 'unknown'
|
|
29
|
+
|
|
30
|
+
/** Mirror of the host's SupervisorState union (kept local by design). */
|
|
31
|
+
export type DaemonStateToken =
|
|
32
|
+
| 'probe'
|
|
33
|
+
| 'adopted'
|
|
34
|
+
| 'defer'
|
|
35
|
+
| 'reprobe'
|
|
36
|
+
| 'hosting'
|
|
37
|
+
| 'hosted'
|
|
38
|
+
| 'backoff'
|
|
39
|
+
| 'failed'
|
|
40
|
+
|
|
41
|
+
/** Color-token buckets the CSS maps to `--dsw-alias-state-*` variables. */
|
|
42
|
+
export type BadgeTone = 'success' | 'warn' | 'neutral' | 'muted' | 'danger'
|
|
43
|
+
|
|
44
|
+
/** One session card as fed by the integration layer (raw, underived). */
|
|
45
|
+
export interface SessionCardVM {
|
|
46
|
+
agent: string
|
|
47
|
+
sessionId: string
|
|
48
|
+
/** Raw observed status string from the sidecar snapshot (open vocabulary). */
|
|
49
|
+
status: string
|
|
50
|
+
title: string
|
|
51
|
+
/** Working directory path; empty string groups under 未知项目. */
|
|
52
|
+
project: string
|
|
53
|
+
/** Epoch milliseconds (mapping layer converts sidecar epoch seconds). */
|
|
54
|
+
updatedAtMs: number
|
|
55
|
+
/** Most recent normalized event summary, if any. */
|
|
56
|
+
lastEvent: { kind: string; text: string } | null
|
|
57
|
+
/** True when a dsh seq discontinuity was observed since the last reconcile. */
|
|
58
|
+
gap: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Board filter controls (wired to ui.time-window-hours / ui.show-dead). */
|
|
62
|
+
export interface BoardFilterState {
|
|
63
|
+
timeWindowHours: number
|
|
64
|
+
showDead: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Derived status badge: color token + label + attention marker. */
|
|
68
|
+
export interface StatusBadgeVM {
|
|
69
|
+
status: SessionStatusToken
|
|
70
|
+
tone: BadgeTone
|
|
71
|
+
label: string
|
|
72
|
+
/** 'gap' (per-session data hole) outranks 'stale' (global stream health). */
|
|
73
|
+
attention: 'gap' | 'stale' | null
|
|
74
|
+
attentionLabel: string | null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A session card with every render-ready derivation attached. */
|
|
78
|
+
export interface DerivedSessionCardVM extends SessionCardVM {
|
|
79
|
+
badge: StatusBadgeVM
|
|
80
|
+
glyph: string
|
|
81
|
+
shortId: string
|
|
82
|
+
relativeTime: string
|
|
83
|
+
/** Hover title: observed-value disclaimer + raw status + lastReconcileAt. */
|
|
84
|
+
hoverTitle: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One project section on the board. */
|
|
88
|
+
export interface ProjectGroupVM<T extends SessionCardVM = DerivedSessionCardVM> {
|
|
89
|
+
/** Raw project path; '' for the unknown-project bucket. */
|
|
90
|
+
key: string
|
|
91
|
+
/** Display name (path basename, or 未知项目). */
|
|
92
|
+
label: string
|
|
93
|
+
/** Full path for the hover title; '' when unknown. */
|
|
94
|
+
fullPath: string
|
|
95
|
+
cards: T[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface BoardBannerVM {
|
|
99
|
+
tone: 'danger' | 'warn'
|
|
100
|
+
text: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type BoardEmptyKind = 'daemon-failed' | 'daemon-defer' | 'filtered' | 'no-sessions'
|
|
104
|
+
|
|
105
|
+
export interface BoardEmptyStateVM {
|
|
106
|
+
kind: BoardEmptyKind
|
|
107
|
+
title: string
|
|
108
|
+
hint: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface DaemonBadgeVM {
|
|
112
|
+
tone: BadgeTone
|
|
113
|
+
label: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Footer widget connection dot state. */
|
|
117
|
+
export type WidgetConnection = 'ok' | 'degraded' | 'off'
|
|
118
|
+
|
|
119
|
+
/** Everything `buildBoardViewModel` needs (all plain values). */
|
|
120
|
+
export interface BoardComputeInput {
|
|
121
|
+
sessions: SessionCardVM[]
|
|
122
|
+
filters: BoardFilterState
|
|
123
|
+
daemonState: DaemonStateToken
|
|
124
|
+
streamHealth: StreamHealthToken
|
|
125
|
+
/** Epoch ms of the last authoritative snapshot reconcile, or null. */
|
|
126
|
+
lastReconcileAtMs: number | null
|
|
127
|
+
/** Clock injection (epoch ms) for deterministic derivation. */
|
|
128
|
+
nowMs: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Fully derived board render model. */
|
|
132
|
+
export interface BoardViewModel {
|
|
133
|
+
groups: Array<ProjectGroupVM<DerivedSessionCardVM>>
|
|
134
|
+
banner: BoardBannerVM | null
|
|
135
|
+
emptyState: BoardEmptyStateVM | null
|
|
136
|
+
daemonBadge: DaemonBadgeVM
|
|
137
|
+
streamLabel: string
|
|
138
|
+
streamTone: BadgeTone
|
|
139
|
+
visibleCount: number
|
|
140
|
+
totalCount: number
|
|
141
|
+
workingCount: number
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Small shared helpers.
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
const MINUTE_MS = 60_000
|
|
149
|
+
const HOUR_MS = 3_600_000
|
|
150
|
+
const DAY_MS = 86_400_000
|
|
151
|
+
|
|
152
|
+
/** Default board time window, aligned with the ui.time-window-hours setting. */
|
|
153
|
+
export const DEFAULT_TIME_WINDOW_HOURS = 48
|
|
154
|
+
|
|
155
|
+
/** Resolve `{name}` placeholders in a message template. */
|
|
156
|
+
export function formatTemplate(
|
|
157
|
+
template: string,
|
|
158
|
+
params: Record<string, string | number>,
|
|
159
|
+
): string {
|
|
160
|
+
return template.replace(/\{(\w+)\}/g, (match, key: string) => {
|
|
161
|
+
const value = params[key]
|
|
162
|
+
return value === undefined ? match : String(value)
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Status normalization and ordering.
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
const KNOWN_STATUSES: readonly SessionStatusToken[] = ['working', 'waiting', 'idle', 'dead']
|
|
171
|
+
|
|
172
|
+
/** Map a raw observed status onto the badge vocabulary ('unknown' fallback). */
|
|
173
|
+
export function normalizeStatus(raw: string): SessionStatusToken {
|
|
174
|
+
const cleaned = raw.trim().toLowerCase()
|
|
175
|
+
return (KNOWN_STATUSES as readonly string[]).includes(cleaned)
|
|
176
|
+
? (cleaned as SessionStatusToken)
|
|
177
|
+
: 'unknown'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const STATUS_RANK: Record<SessionStatusToken, number> = {
|
|
181
|
+
working: 0,
|
|
182
|
+
waiting: 1,
|
|
183
|
+
idle: 2,
|
|
184
|
+
unknown: 3,
|
|
185
|
+
dead: 4,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Sort rank: working > waiting > idle > unknown > dead (lower sorts first). */
|
|
189
|
+
export function statusRank(status: SessionStatusToken): number {
|
|
190
|
+
return STATUS_RANK[status]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const STATUS_TONE: Record<SessionStatusToken, BadgeTone> = {
|
|
194
|
+
working: 'success',
|
|
195
|
+
waiting: 'warn',
|
|
196
|
+
idle: 'neutral',
|
|
197
|
+
unknown: 'neutral',
|
|
198
|
+
dead: 'muted',
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Time-window filtering.
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Visibility rules (task spec):
|
|
207
|
+
* - dead sessions are hidden unless `showDead`;
|
|
208
|
+
* - working sessions are always visible (even outside the window);
|
|
209
|
+
* - everything else hides once `updatedAtMs` falls strictly beyond the
|
|
210
|
+
* window (age === window is still visible);
|
|
211
|
+
* - a non-finite or non-positive window disables the age filter entirely.
|
|
212
|
+
*/
|
|
213
|
+
export function isSessionVisible(
|
|
214
|
+
session: SessionCardVM,
|
|
215
|
+
filters: BoardFilterState,
|
|
216
|
+
nowMs: number,
|
|
217
|
+
): boolean {
|
|
218
|
+
const status = normalizeStatus(session.status)
|
|
219
|
+
if (status === 'dead' && !filters.showDead) return false
|
|
220
|
+
if (status === 'working') return true
|
|
221
|
+
const windowMs = filters.timeWindowHours * HOUR_MS
|
|
222
|
+
if (!Number.isFinite(windowMs) || windowMs <= 0) return true
|
|
223
|
+
return nowMs - session.updatedAtMs <= windowMs
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Apply {@link isSessionVisible} across a session list (order preserved). */
|
|
227
|
+
export function filterSessions<T extends SessionCardVM>(
|
|
228
|
+
sessions: readonly T[],
|
|
229
|
+
filters: BoardFilterState,
|
|
230
|
+
nowMs: number,
|
|
231
|
+
): T[] {
|
|
232
|
+
return sessions.filter((s) => isSessionVisible(s, filters, nowMs))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Project grouping.
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/** Human display name for a project path ('' → 未知项目). */
|
|
240
|
+
export function projectDisplayName(project: string): string {
|
|
241
|
+
const trimmed = project.trim().replace(/[/\\]+$/, '')
|
|
242
|
+
if (trimmed === '') return BOARD_STRINGS.unknownProject
|
|
243
|
+
const segments = trimmed.split(/[/\\]/)
|
|
244
|
+
const base = segments[segments.length - 1] ?? ''
|
|
245
|
+
return base === '' ? trimmed : base
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Card ordering inside a group: status rank, then recency, then id. */
|
|
249
|
+
export function compareCards(a: SessionCardVM, b: SessionCardVM): number {
|
|
250
|
+
const rankDelta = statusRank(normalizeStatus(a.status)) - statusRank(normalizeStatus(b.status))
|
|
251
|
+
if (rankDelta !== 0) return rankDelta
|
|
252
|
+
if (a.updatedAtMs !== b.updatedAtMs) return b.updatedAtMs - a.updatedAtMs
|
|
253
|
+
return a.sessionId.localeCompare(b.sessionId)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Group sessions by project. Empty/whitespace projects share the
|
|
258
|
+
* unknown-project bucket (key ''). Groups are ordered by their most recent
|
|
259
|
+
* `updatedAtMs` descending, except the unknown bucket which always sorts
|
|
260
|
+
* last (named projects are more actionable than the catch-all). Cards
|
|
261
|
+
* inside each group follow {@link compareCards}.
|
|
262
|
+
*/
|
|
263
|
+
export function groupSessions<T extends SessionCardVM>(
|
|
264
|
+
sessions: readonly T[],
|
|
265
|
+
): Array<ProjectGroupVM<T>> {
|
|
266
|
+
const buckets = new Map<string, T[]>()
|
|
267
|
+
for (const session of sessions) {
|
|
268
|
+
const key = session.project.trim() === '' ? '' : session.project
|
|
269
|
+
const bucket = buckets.get(key)
|
|
270
|
+
if (bucket === undefined) buckets.set(key, [session])
|
|
271
|
+
else bucket.push(session)
|
|
272
|
+
}
|
|
273
|
+
const groups: Array<ProjectGroupVM<T>> = []
|
|
274
|
+
for (const [key, cards] of buckets) {
|
|
275
|
+
cards.sort(compareCards)
|
|
276
|
+
groups.push({
|
|
277
|
+
key,
|
|
278
|
+
label: key === '' ? BOARD_STRINGS.unknownProject : projectDisplayName(key),
|
|
279
|
+
fullPath: key,
|
|
280
|
+
cards,
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
const newest = (group: ProjectGroupVM<T>): number =>
|
|
284
|
+
group.cards.reduce((max, card) => Math.max(max, card.updatedAtMs), Number.NEGATIVE_INFINITY)
|
|
285
|
+
groups.sort((a, b) => {
|
|
286
|
+
if (a.key === '') return b.key === '' ? 0 : 1
|
|
287
|
+
if (b.key === '') return -1
|
|
288
|
+
return newest(b) - newest(a) || a.key.localeCompare(b.key)
|
|
289
|
+
})
|
|
290
|
+
return groups
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Badge derivation.
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* status + gap + streamHealth → badge tone/label/attention.
|
|
299
|
+
*
|
|
300
|
+
* Priority: a per-session `gap` marker (a known data hole for THIS session)
|
|
301
|
+
* outranks the global stale marker (stream reconnecting affects everyone
|
|
302
|
+
* and is already surfaced by the top banner). Unknown raw statuses keep
|
|
303
|
+
* their raw text as the label — the board never invents a state.
|
|
304
|
+
*/
|
|
305
|
+
export function deriveBadge(
|
|
306
|
+
rawStatus: string,
|
|
307
|
+
gap: boolean,
|
|
308
|
+
streamHealth: StreamHealthToken,
|
|
309
|
+
): StatusBadgeVM {
|
|
310
|
+
const status = normalizeStatus(rawStatus)
|
|
311
|
+
const trimmed = rawStatus.trim()
|
|
312
|
+
const label =
|
|
313
|
+
status === 'unknown'
|
|
314
|
+
? trimmed === ''
|
|
315
|
+
? BOARD_STRINGS.status.unknown
|
|
316
|
+
: trimmed
|
|
317
|
+
: BOARD_STRINGS.status[status]
|
|
318
|
+
let attention: StatusBadgeVM['attention'] = null
|
|
319
|
+
if (gap) attention = 'gap'
|
|
320
|
+
else if (streamHealth !== 'ok') attention = 'stale'
|
|
321
|
+
return {
|
|
322
|
+
status,
|
|
323
|
+
tone: STATUS_TONE[status],
|
|
324
|
+
label,
|
|
325
|
+
attention,
|
|
326
|
+
attentionLabel: attention === null ? null : BOARD_STRINGS.attention[attention],
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Hover title for a status badge: the observed-value disclaimer (design
|
|
332
|
+
* §5.3 wording), the raw observed status, and the last reconcile time.
|
|
333
|
+
*/
|
|
334
|
+
export function badgeHoverTitle(
|
|
335
|
+
rawStatus: string,
|
|
336
|
+
lastReconcileAtMs: number | null,
|
|
337
|
+
nowMs: number,
|
|
338
|
+
): string {
|
|
339
|
+
const observed = rawStatus.trim() === '' ? BOARD_STRINGS.status.unknown : rawStatus.trim()
|
|
340
|
+
const reconcile =
|
|
341
|
+
lastReconcileAtMs === null
|
|
342
|
+
? BOARD_STRINGS.card.neverReconciled
|
|
343
|
+
: formatTemplate(BOARD_STRINGS.card.lastReconcile, {
|
|
344
|
+
time: formatRelativeTime(lastReconcileAtMs, nowMs),
|
|
345
|
+
})
|
|
346
|
+
return [
|
|
347
|
+
BOARD_STRINGS.card.observedDisclaimer,
|
|
348
|
+
formatTemplate(BOARD_STRINGS.card.observedValue, { status: observed }),
|
|
349
|
+
reconcile,
|
|
350
|
+
].join('\n')
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Time formatting.
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Coarse relative time: <60s (including clock skew into the future) is
|
|
359
|
+
* 刚刚, then whole minutes/hours/days. Non-finite input renders empty.
|
|
360
|
+
*/
|
|
361
|
+
export function formatRelativeTime(thenMs: number, nowMs: number): string {
|
|
362
|
+
if (!Number.isFinite(thenMs)) return ''
|
|
363
|
+
const delta = nowMs - thenMs
|
|
364
|
+
if (delta < MINUTE_MS) return BOARD_STRINGS.time.justNow
|
|
365
|
+
if (delta < HOUR_MS) {
|
|
366
|
+
return formatTemplate(BOARD_STRINGS.time.minutesAgo, { n: Math.floor(delta / MINUTE_MS) })
|
|
367
|
+
}
|
|
368
|
+
if (delta < DAY_MS) {
|
|
369
|
+
return formatTemplate(BOARD_STRINGS.time.hoursAgo, { n: Math.floor(delta / HOUR_MS) })
|
|
370
|
+
}
|
|
371
|
+
return formatTemplate(BOARD_STRINGS.time.daysAgo, { n: Math.floor(delta / DAY_MS) })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Label for a time-window option: whole days as 天, otherwise 小时. */
|
|
375
|
+
export function timeWindowLabel(hours: number): string {
|
|
376
|
+
if (hours >= 24 && hours % 24 === 0) {
|
|
377
|
+
return formatTemplate(BOARD_STRINGS.timeWindow.days, { n: hours / 24 })
|
|
378
|
+
}
|
|
379
|
+
return formatTemplate(BOARD_STRINGS.timeWindow.hours, { n: hours })
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Top bar / banner / empty-state derivation.
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
const DAEMON_TONE: Record<DaemonStateToken, BadgeTone> = {
|
|
387
|
+
probe: 'neutral',
|
|
388
|
+
adopted: 'success',
|
|
389
|
+
defer: 'warn',
|
|
390
|
+
reprobe: 'neutral',
|
|
391
|
+
hosting: 'neutral',
|
|
392
|
+
hosted: 'success',
|
|
393
|
+
backoff: 'warn',
|
|
394
|
+
failed: 'danger',
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Daemon state → top-bar badge (tone + label). */
|
|
398
|
+
export function deriveDaemonBadge(state: DaemonStateToken): DaemonBadgeVM {
|
|
399
|
+
return { tone: DAEMON_TONE[state], label: BOARD_STRINGS.daemon[state] }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Stream health → indicator tone. */
|
|
403
|
+
export function streamHealthTone(health: StreamHealthToken): BadgeTone {
|
|
404
|
+
if (health === 'ok') return 'success'
|
|
405
|
+
if (health === 'degraded') return 'warn'
|
|
406
|
+
return 'neutral'
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Top banner: daemon FAILED (red, last-snapshot notice) outranks a
|
|
411
|
+
* degraded stream (yellow, may-lag notice). 'unknown' stream health alone
|
|
412
|
+
* raises no banner — it is the pre-first-connect startup state and a
|
|
413
|
+
* warning bar on every mount would be noise.
|
|
414
|
+
*/
|
|
415
|
+
export function deriveBanner(
|
|
416
|
+
daemonState: DaemonStateToken,
|
|
417
|
+
streamHealth: StreamHealthToken,
|
|
418
|
+
): BoardBannerVM | null {
|
|
419
|
+
if (daemonState === 'failed') {
|
|
420
|
+
return { tone: 'danger', text: BOARD_STRINGS.banner.daemonFailed }
|
|
421
|
+
}
|
|
422
|
+
if (streamHealth === 'degraded') {
|
|
423
|
+
return { tone: 'warn', text: BOARD_STRINGS.banner.streamDegraded }
|
|
424
|
+
}
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Empty-state guidance, only when nothing is visible. Daemon trouble
|
|
430
|
+
* (failed > defer) explains an empty board better than filter settings;
|
|
431
|
+
* otherwise distinguish "all filtered out" from "nothing observed yet".
|
|
432
|
+
*/
|
|
433
|
+
export function deriveEmptyState(
|
|
434
|
+
daemonState: DaemonStateToken,
|
|
435
|
+
visibleCount: number,
|
|
436
|
+
totalCount: number,
|
|
437
|
+
): BoardEmptyStateVM | null {
|
|
438
|
+
if (visibleCount > 0) return null
|
|
439
|
+
if (daemonState === 'failed') {
|
|
440
|
+
return {
|
|
441
|
+
kind: 'daemon-failed',
|
|
442
|
+
title: BOARD_STRINGS.empty.daemonFailedTitle,
|
|
443
|
+
hint: BOARD_STRINGS.empty.daemonFailedHint,
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (daemonState === 'defer') {
|
|
447
|
+
return {
|
|
448
|
+
kind: 'daemon-defer',
|
|
449
|
+
title: BOARD_STRINGS.empty.daemonDeferTitle,
|
|
450
|
+
hint: BOARD_STRINGS.empty.daemonDeferHint,
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (totalCount > 0) {
|
|
454
|
+
return {
|
|
455
|
+
kind: 'filtered',
|
|
456
|
+
title: BOARD_STRINGS.empty.filteredTitle,
|
|
457
|
+
hint: BOARD_STRINGS.empty.filteredHint,
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
kind: 'no-sessions',
|
|
462
|
+
title: BOARD_STRINGS.empty.noSessionsTitle,
|
|
463
|
+
hint: BOARD_STRINGS.empty.noSessionsHint,
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
// Card presentation helpers.
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
const AGENT_GLYPHS: Record<string, string> = {
|
|
472
|
+
dsh: '◆',
|
|
473
|
+
claude: '✳',
|
|
474
|
+
codex: '▣',
|
|
475
|
+
cursor: '▮',
|
|
476
|
+
'cursor-cli': '▮',
|
|
477
|
+
'cursor-ide': '▮',
|
|
478
|
+
copilot: '◉',
|
|
479
|
+
kimi: '◐',
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Single-character agent marker; unknown agents get a neutral dot. */
|
|
483
|
+
export function agentGlyph(agent: string): string {
|
|
484
|
+
return AGENT_GLYPHS[agent.trim().toLowerCase()] ?? '●'
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Head…tail abbreviation for long session ids (full id goes in `title`). */
|
|
488
|
+
export function abbreviateSessionId(id: string, max = 20): string {
|
|
489
|
+
if (id.length <= max) return id
|
|
490
|
+
return `${id.slice(0, 12)}…${id.slice(-6)}`
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// Footer widget derivation.
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Connection dot: green only when the daemon is connected (adopted/hosted)
|
|
499
|
+
* AND the event stream is healthy; FAILED is the only hard-off state;
|
|
500
|
+
* every transitional state shows the cautious yellow.
|
|
501
|
+
*/
|
|
502
|
+
export function deriveWidgetConnection(
|
|
503
|
+
daemonState: DaemonStateToken,
|
|
504
|
+
streamHealth: StreamHealthToken,
|
|
505
|
+
): WidgetConnection {
|
|
506
|
+
if (daemonState === 'failed') return 'off'
|
|
507
|
+
if ((daemonState === 'adopted' || daemonState === 'hosted') && streamHealth === 'ok') {
|
|
508
|
+
return 'ok'
|
|
509
|
+
}
|
|
510
|
+
return 'degraded'
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Count of sessions currently observed as working. */
|
|
514
|
+
export function countWorking(sessions: ReadonlyArray<{ status: string }>): number {
|
|
515
|
+
let count = 0
|
|
516
|
+
for (const session of sessions) {
|
|
517
|
+
if (normalizeStatus(session.status) === 'working') count += 1
|
|
518
|
+
}
|
|
519
|
+
return count
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Widget hover/aria text: connection state, plus the count when nonzero. */
|
|
523
|
+
export function widgetTitle(connection: WidgetConnection, workingCount: number): string {
|
|
524
|
+
const base = `${BOARD_STRINGS.widget.label}: ${BOARD_STRINGS.widget.connection[connection]}`
|
|
525
|
+
if (workingCount <= 0) return base
|
|
526
|
+
return `${base} · ${formatTemplate(BOARD_STRINGS.widget.working, { n: workingCount })}`
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
// Full pipeline.
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
/** filter → group → per-card derive; the one call Board.tsx renders from. */
|
|
534
|
+
export function buildBoardViewModel(input: BoardComputeInput): BoardViewModel {
|
|
535
|
+
const { sessions, filters, daemonState, streamHealth, lastReconcileAtMs, nowMs } = input
|
|
536
|
+
const visible = filterSessions(sessions, filters, nowMs)
|
|
537
|
+
const derived: DerivedSessionCardVM[] = visible.map((session) => ({
|
|
538
|
+
...session,
|
|
539
|
+
badge: deriveBadge(session.status, session.gap, streamHealth),
|
|
540
|
+
glyph: agentGlyph(session.agent),
|
|
541
|
+
shortId: abbreviateSessionId(session.sessionId),
|
|
542
|
+
relativeTime: formatRelativeTime(session.updatedAtMs, nowMs),
|
|
543
|
+
hoverTitle: badgeHoverTitle(session.status, lastReconcileAtMs, nowMs),
|
|
544
|
+
}))
|
|
545
|
+
return {
|
|
546
|
+
groups: groupSessions(derived),
|
|
547
|
+
banner: deriveBanner(daemonState, streamHealth),
|
|
548
|
+
emptyState: deriveEmptyState(daemonState, visible.length, sessions.length),
|
|
549
|
+
daemonBadge: deriveDaemonBadge(daemonState),
|
|
550
|
+
streamLabel: BOARD_STRINGS.stream[streamHealth],
|
|
551
|
+
streamTone: streamHealthTone(streamHealth),
|
|
552
|
+
visibleCount: visible.length,
|
|
553
|
+
totalCount: sessions.length,
|
|
554
|
+
workingCount: countWorking(sessions),
|
|
555
|
+
}
|
|
556
|
+
}
|