@dipertq/dsh-openviking-status 0.2.2 → 0.3.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/lib/client.cjs CHANGED
@@ -37,26 +37,37 @@ var client_exports = {};
37
37
  __export(client_exports, {
38
38
  COMMIT_THRESHOLD: () => COMMIT_THRESHOLD,
39
39
  DEFAULT_OPENVIKING_ENDPOINT: () => DEFAULT_OPENVIKING_ENDPOINT,
40
+ ExtractionSection: () => ExtractionSection,
40
41
  OpenVikingClient: () => OpenVikingClient,
41
42
  OpenVikingSettingsSection: () => OpenVikingSettingsSection,
42
43
  OpenVikingStatusChip: () => OpenVikingStatusChip,
43
44
  OpenVikingStatusPopover: () => OpenVikingStatusPopover,
45
+ POLL_INTERVAL_ACTIVE_MS: () => POLL_INTERVAL_ACTIVE_MS,
46
+ POLL_INTERVAL_IDLE_MS: () => POLL_INTERVAL_IDLE_MS,
44
47
  PROXY_OPENVIKING_ENDPOINT: () => PROXY_OPENVIKING_ENDPOINT,
48
+ TASKS_LIMIT_CAP: () => TASKS_LIMIT_CAP,
45
49
  THEME: () => THEME,
50
+ WarningGlyph: () => WarningGlyph,
46
51
  apply: () => apply,
47
52
  chatNodesToText: () => chatNodesToText,
48
53
  checkHealth: () => checkHealth,
49
54
  commitSession: () => commitSession,
55
+ computeBreakdown: () => computeBreakdown,
50
56
  defaultOpenVikingClient: () => defaultOpenVikingClient,
51
57
  fetchSession: () => fetchSession,
58
+ formatBacklog: () => formatBacklog,
52
59
  formatDaemonVersion: () => formatDaemonVersion,
60
+ formatDuration: () => formatDuration,
53
61
  formatEndpoint: () => formatEndpoint,
62
+ formatExtractionSummary: () => formatExtractionSummary,
54
63
  formatMemoryLeafName: () => formatMemoryLeafName,
55
64
  formatPendingTokens: () => formatPendingTokens,
56
65
  formatRelativeTime: () => formatRelativeTime,
57
66
  formatStatusLabel: () => formatStatusLabel,
58
67
  formatTooltipTitle: () => formatTooltipTitle,
59
68
  getCategoryBadgeStyle: () => getCategoryBadgeStyle,
69
+ getChipDotState: () => getChipDotState,
70
+ getDotColorForState: () => getDotColorForState,
60
71
  getProgressBarColor: () => getProgressBarColor,
61
72
  getProgressBarPercent: () => getProgressBarPercent,
62
73
  getSession: () => getSession,
@@ -65,9 +76,13 @@ __export(client_exports, {
65
76
  inferCategory: () => inferCategory,
66
77
  inject: () => inject,
67
78
  name: () => name,
79
+ normalizeExecutionEvent: () => normalizeExecutionEvent,
80
+ normalizeExtractionTask: () => normalizeExtractionTask,
81
+ normalizeTaskList: () => normalizeTaskList,
68
82
  parseRecalledMemories: () => parseRecalledMemories,
69
83
  resolveApiKey: () => resolveApiKey,
70
84
  resolveEndpoint: () => resolveEndpoint,
85
+ seedRunningTask: () => seedRunningTask,
71
86
  themeVar: () => themeVar,
72
87
  truncateSessionId: () => truncateSessionId
73
88
  });
@@ -78,6 +93,71 @@ var import_react2 = __toESM(require("react"), 1);
78
93
  var import_react_dom = __toESM(require("react-dom"), 1);
79
94
 
80
95
  // src/client/api.ts
96
+ var TASKS_LIMIT_CAP = 200;
97
+ function normalizeExtractionTask(raw) {
98
+ if (!raw || typeof raw !== "object") return null;
99
+ const taskId = typeof raw.task_id === "string" && raw.task_id || typeof raw.id === "string" && raw.id || "";
100
+ if (!taskId) return null;
101
+ const statusRaw = typeof raw.status === "string" ? raw.status : "";
102
+ const status = statusRaw === "running" || statusRaw === "pending" || statusRaw === "completed" || statusRaw === "failed" ? statusRaw : "pending";
103
+ const result = raw.result ?? {};
104
+ const extracted = result.memories_extracted ?? {};
105
+ const numberOf = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
106
+ const tokenUsageRaw = result.token_usage ?? raw.token_usage;
107
+ const tokenUsage = typeof tokenUsageRaw === "number" ? tokenUsageRaw : numberOf(
108
+ tokenUsageRaw?.total_tokens
109
+ );
110
+ const errorRaw = raw.error;
111
+ const errorMsg = typeof errorRaw === "string" ? errorRaw : typeof errorRaw?.message === "string" ? errorRaw.message : void 0;
112
+ return {
113
+ task_id: taskId,
114
+ status,
115
+ resource_id: typeof raw.resource_id === "string" ? raw.resource_id : void 0,
116
+ created_at: typeof raw.created_at === "string" ? raw.created_at : void 0,
117
+ updated_at: typeof raw.updated_at === "string" ? raw.updated_at : void 0,
118
+ memory_write: numberOf(extracted.memory_write ?? extracted.written),
119
+ memory_edit: numberOf(extracted.memory_edit ?? extracted.edited),
120
+ token_usage: tokenUsage,
121
+ error: errorMsg
122
+ };
123
+ }
124
+ function computeBreakdown(tasks) {
125
+ const b = {
126
+ running: 0,
127
+ pending: 0,
128
+ completed: 0,
129
+ failed: 0,
130
+ total: tasks.length,
131
+ firstRunning: null,
132
+ lastCompleted: null,
133
+ lastFailed: null
134
+ };
135
+ for (const t of tasks) {
136
+ if (t.status === "running") {
137
+ b.running += 1;
138
+ if (!b.firstRunning) b.firstRunning = t;
139
+ } else if (t.status === "pending") b.pending += 1;
140
+ else if (t.status === "completed") {
141
+ b.completed += 1;
142
+ if (!b.lastCompleted) b.lastCompleted = t;
143
+ } else if (t.status === "failed") {
144
+ b.failed += 1;
145
+ if (!b.lastFailed) b.lastFailed = t;
146
+ }
147
+ }
148
+ return b;
149
+ }
150
+ function normalizeExecutionEvent(raw) {
151
+ return {
152
+ seq: typeof raw.seq === "number" ? raw.seq : void 0,
153
+ recorded_at: typeof raw.recorded_at === "string" ? raw.recorded_at : void 0,
154
+ kind: typeof raw.kind === "string" ? raw.kind : void 0,
155
+ status: typeof raw.status === "string" ? raw.status : void 0,
156
+ stage: typeof raw.stage === "string" ? raw.stage : null,
157
+ operation: typeof raw.operation === "string" ? raw.operation : null,
158
+ error: typeof raw.error === "string" ? raw.error : null
159
+ };
160
+ }
81
161
  var DEFAULT_OPENVIKING_ENDPOINT = "http://127.0.0.1:1933";
82
162
  var PROXY_OPENVIKING_ENDPOINT = "/openviking-status/api";
83
163
  function resolveEndpoint(endpoint) {
@@ -386,7 +466,9 @@ var OpenVikingClient = class {
386
466
  const data = await res.json().catch(() => ({}));
387
467
  return {
388
468
  ok: data.ok === true,
389
- error: typeof data.error === "string" ? data.error : void 0
469
+ error: typeof data.error === "string" ? data.error : void 0,
470
+ task_id: typeof data.task_id === "string" ? data.task_id : void 0,
471
+ resource_id: typeof data.resource_id === "string" ? data.resource_id : void 0
390
472
  };
391
473
  } catch (err) {
392
474
  return {
@@ -418,7 +500,14 @@ var OpenVikingClient = class {
418
500
  return { ok: false, error: String(errorMsg) };
419
501
  }
420
502
  this.resolvedSessionIds.set(sessionId.trim(), candidateId);
421
- return { ok: true };
503
+ const okBody = await res.json().catch(() => ({}));
504
+ const container = okBody.result ?? okBody.data ?? okBody;
505
+ const taskId = container.task_id ?? container.id;
506
+ return {
507
+ ok: true,
508
+ task_id: typeof taskId === "string" && taskId.trim() ? taskId.trim() : void 0,
509
+ resource_id: candidateId
510
+ };
422
511
  } catch (err) {
423
512
  return {
424
513
  ok: false,
@@ -428,7 +517,193 @@ var OpenVikingClient = class {
428
517
  }
429
518
  return { ok: false, error: lastError };
430
519
  }
520
+ /**
521
+ * Список задач Phase 2 (Memory Extraction) текущей сессии со сводной
522
+ * разбивкой по статусам.
523
+ *
524
+ * Через прокси: один запрос `GET /tasks?session=<id>`, разбивка считается
525
+ * здесь. Напрямую: разрешаем `resource_id` через кандидатов и запрашиваем
526
+ * `GET /api/v1/tasks?resource_id=<id>&task_type=session_commit&limit=200`.
527
+ */
528
+ async listTasks(sessionId) {
529
+ if (!sessionId || !sessionId.trim()) {
530
+ return { status: "missing" };
531
+ }
532
+ if (this.isProxy()) {
533
+ try {
534
+ const res = await fetch(
535
+ `${this.endpoint}/tasks?session=${encodeURIComponent(
536
+ sessionId.trim()
537
+ )}&limit=${TASKS_LIMIT_CAP}`,
538
+ { method: "GET", headers: this.getHeaders() }
539
+ );
540
+ if (res.status === 401 || res.status === 403) {
541
+ return { status: "unauthorized" };
542
+ }
543
+ if (!res.ok) {
544
+ return { status: "error", detail: `HTTP ${res.status}` };
545
+ }
546
+ const data = await res.json();
547
+ if (data.status === "unauthorized") return { status: "unauthorized" };
548
+ if (data.status === "missing") return { status: "missing" };
549
+ if (data.status === "unreachable")
550
+ return {
551
+ status: "unreachable",
552
+ detail: typeof data.detail === "string" ? data.detail : void 0
553
+ };
554
+ if (data.status === "error")
555
+ return {
556
+ status: "error",
557
+ detail: typeof data.detail === "string" ? data.detail : void 0
558
+ };
559
+ const tasks = normalizeTaskList(data.tasks);
560
+ return { status: "ok", breakdown: computeBreakdown(tasks), tasks };
561
+ } catch (err) {
562
+ return {
563
+ status: "unreachable",
564
+ detail: err instanceof Error ? err.message : String(err)
565
+ };
566
+ }
567
+ }
568
+ const candidates = this.getCandidateSessionIds(sessionId);
569
+ let resourceId = null;
570
+ for (const candidateId of candidates) {
571
+ try {
572
+ const probe = await fetch(
573
+ `${this.endpoint}/api/v1/sessions/${encodeURIComponent(candidateId)}`,
574
+ { method: "GET", headers: this.getHeaders() }
575
+ );
576
+ if (probe.status === 404) continue;
577
+ if (probe.status === 401 || probe.status === 403) {
578
+ return { status: "unauthorized" };
579
+ }
580
+ if (probe.ok) {
581
+ resourceId = candidateId;
582
+ this.resolvedSessionIds.set(sessionId.trim(), candidateId);
583
+ break;
584
+ }
585
+ } catch (err) {
586
+ return {
587
+ status: "unreachable",
588
+ detail: err instanceof Error ? err.message : String(err)
589
+ };
590
+ }
591
+ }
592
+ if (!resourceId) return { status: "missing" };
593
+ try {
594
+ const query = new URLSearchParams({
595
+ resource_id: resourceId,
596
+ task_type: "session_commit",
597
+ limit: String(TASKS_LIMIT_CAP)
598
+ });
599
+ const res = await fetch(
600
+ `${this.endpoint}/api/v1/tasks?${query.toString()}`,
601
+ { method: "GET", headers: this.getHeaders() }
602
+ );
603
+ if (res.status === 401 || res.status === 403) {
604
+ return { status: "unauthorized" };
605
+ }
606
+ if (!res.ok) {
607
+ return { status: "error", detail: `HTTP ${res.status}` };
608
+ }
609
+ const data = await res.json();
610
+ const items = data?.items ?? data?.tasks ?? data?.result?.items ?? (Array.isArray(data) ? data : []);
611
+ const tasks = normalizeTaskList(items);
612
+ return { status: "ok", breakdown: computeBreakdown(tasks), tasks };
613
+ } catch (err) {
614
+ return {
615
+ status: "unreachable",
616
+ detail: err instanceof Error ? err.message : String(err)
617
+ };
618
+ }
619
+ }
620
+ /**
621
+ * Лениво загрузить одну задачу с лентой `execution_events` для «Show log».
622
+ */
623
+ async fetchTaskEvents(taskId) {
624
+ if (!taskId || !taskId.trim()) {
625
+ return { status: "missing" };
626
+ }
627
+ const buildOk = (taskRaw) => {
628
+ const task = normalizeExtractionTask(taskRaw);
629
+ const eventsContainer = taskRaw.execution_events ?? {};
630
+ const rawEvents = eventsContainer.items ?? (Array.isArray(taskRaw.execution_events) ? taskRaw.execution_events : []);
631
+ const events = (Array.isArray(rawEvents) ? rawEvents : []).filter(
632
+ (e) => !!e && typeof e === "object"
633
+ ).map(normalizeExecutionEvent);
634
+ if (!task) return { status: "error", detail: "malformed task" };
635
+ return { status: "ok", task, events };
636
+ };
637
+ if (this.isProxy()) {
638
+ try {
639
+ const res = await fetch(
640
+ `${this.endpoint}/task?id=${encodeURIComponent(
641
+ taskId.trim()
642
+ )}&events=1`,
643
+ { method: "GET", headers: this.getHeaders() }
644
+ );
645
+ if (res.status === 401 || res.status === 403) {
646
+ return { status: "unauthorized" };
647
+ }
648
+ if (!res.ok) {
649
+ return { status: "error", detail: `HTTP ${res.status}` };
650
+ }
651
+ const data = await res.json();
652
+ if (data.status === "unauthorized") return { status: "unauthorized" };
653
+ if (data.status === "missing") return { status: "missing" };
654
+ if (data.status === "unreachable")
655
+ return {
656
+ status: "unreachable",
657
+ detail: typeof data.detail === "string" ? data.detail : void 0
658
+ };
659
+ if (data.status === "error")
660
+ return {
661
+ status: "error",
662
+ detail: typeof data.detail === "string" ? data.detail : void 0
663
+ };
664
+ return buildOk(data.task ?? {});
665
+ } catch (err) {
666
+ return {
667
+ status: "unreachable",
668
+ detail: err instanceof Error ? err.message : String(err)
669
+ };
670
+ }
671
+ }
672
+ try {
673
+ const res = await fetch(
674
+ `${this.endpoint}/api/v1/tasks/${encodeURIComponent(
675
+ taskId.trim()
676
+ )}?include_events=true`,
677
+ { method: "GET", headers: this.getHeaders() }
678
+ );
679
+ if (res.status === 404) return { status: "missing" };
680
+ if (res.status === 401 || res.status === 403) {
681
+ return { status: "unauthorized" };
682
+ }
683
+ if (!res.ok) {
684
+ return { status: "error", detail: `HTTP ${res.status}` };
685
+ }
686
+ const data = await res.json();
687
+ const taskRaw = data?.result ?? data?.data ?? data;
688
+ return buildOk(taskRaw);
689
+ } catch (err) {
690
+ return {
691
+ status: "unreachable",
692
+ detail: err instanceof Error ? err.message : String(err)
693
+ };
694
+ }
695
+ }
431
696
  };
697
+ function normalizeTaskList(raw) {
698
+ if (!Array.isArray(raw)) return [];
699
+ const tasks = [];
700
+ for (const entry of raw) {
701
+ if (!entry || typeof entry !== "object") continue;
702
+ const task = normalizeExtractionTask(entry);
703
+ if (task) tasks.push(task);
704
+ }
705
+ return tasks;
706
+ }
432
707
  var defaultOpenVikingClient = new OpenVikingClient();
433
708
  function checkHealth(endpoint, apiKey) {
434
709
  const client = endpoint || apiKey ? new OpenVikingClient(endpoint, apiKey) : defaultOpenVikingClient;
@@ -802,6 +1077,39 @@ function dshIcon(name2) {
802
1077
  return null;
803
1078
  }
804
1079
  }
1080
+ function formatDuration(startIso, endIso) {
1081
+ if (!startIso || !endIso) return void 0;
1082
+ const start = new Date(startIso).getTime();
1083
+ const end = new Date(endIso).getTime();
1084
+ if (isNaN(start) || isNaN(end) || end < start) return void 0;
1085
+ const ms = end - start;
1086
+ const sec = ms / 1e3;
1087
+ if (sec < 60) {
1088
+ return `${sec < 10 ? sec.toFixed(1) : Math.round(sec)}s`;
1089
+ }
1090
+ const min = Math.floor(sec / 60);
1091
+ const rem = Math.round(sec % 60);
1092
+ return `${min}m ${rem}s`;
1093
+ }
1094
+ function formatExtractionSummary(task, now = Date.now()) {
1095
+ const write = task.memory_write ?? 0;
1096
+ const edit = task.memory_edit ?? 0;
1097
+ const parts = [`${write} written, ${edit} edited`];
1098
+ const duration = formatDuration(task.created_at, task.updated_at);
1099
+ if (duration) parts.push(duration);
1100
+ const rel = formatRelativeTime(task.updated_at, now);
1101
+ if (rel) parts.push(rel);
1102
+ return parts.join(" \xB7 ");
1103
+ }
1104
+ function formatBacklog(breakdown) {
1105
+ if (!breakdown) return null;
1106
+ const { pending, failed } = breakdown;
1107
+ if (pending <= 0 && failed <= 0) return null;
1108
+ const parts = [];
1109
+ if (pending > 0) parts.push(`${pending} pending`);
1110
+ if (failed > 0) parts.push(`${failed} failed`);
1111
+ return parts.join(" / ");
1112
+ }
805
1113
  function getProgressBarPercent(pendingTokens, threshold = COMMIT_THRESHOLD) {
806
1114
  if (!threshold || threshold <= 0) return 0;
807
1115
  const ratio = (pendingTokens || 0) / threshold;
@@ -868,6 +1176,229 @@ function handleEscapeKey(event, onClose) {
868
1176
  }
869
1177
  return false;
870
1178
  }
1179
+ function ExtractionSection({
1180
+ breakdown,
1181
+ client
1182
+ }) {
1183
+ const [showLog, setShowLog] = (0, import_react.useState)(false);
1184
+ const [events, setEvents] = (0, import_react.useState)(null);
1185
+ const [logError, setLogError] = (0, import_react.useState)(null);
1186
+ const [loadingLog, setLoadingLog] = (0, import_react.useState)(false);
1187
+ if (!breakdown) return null;
1188
+ const isRunning = breakdown.running >= 1;
1189
+ const lastCompleted = breakdown.lastCompleted;
1190
+ const lastFailed = breakdown.lastFailed;
1191
+ const backlog = formatBacklog(breakdown);
1192
+ const failedActive = !isRunning && breakdown.failed >= 1;
1193
+ const logTaskId = (isRunning ? breakdown.firstRunning?.task_id : null) ?? (failedActive ? lastFailed?.task_id : null) ?? lastCompleted?.task_id ?? lastFailed?.task_id ?? null;
1194
+ const mutedStyle = { color: themeVar("labelTertiary") };
1195
+ const toggleLog = (0, import_react.useCallback)(async () => {
1196
+ const next = !showLog;
1197
+ setShowLog(next);
1198
+ if (next && events === null && logTaskId && client) {
1199
+ setLoadingLog(true);
1200
+ setLogError(null);
1201
+ const res = await client.fetchTaskEvents(logTaskId);
1202
+ setLoadingLog(false);
1203
+ if (res.status === "ok") {
1204
+ setEvents(res.events);
1205
+ } else {
1206
+ setLogError(
1207
+ res.status === "unauthorized" ? "No access to task log" : "Could not load log"
1208
+ );
1209
+ }
1210
+ }
1211
+ }, [showLog, events, logTaskId, client]);
1212
+ if (!isRunning && !failedActive && !lastCompleted && !backlog) {
1213
+ return null;
1214
+ }
1215
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { "data-testid": "extraction-section", style: { marginBottom: "12px" }, children: [
1216
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1217
+ "div",
1218
+ {
1219
+ style: {
1220
+ marginBottom: "4px",
1221
+ color: themeVar("labelPrimary"),
1222
+ fontWeight: 500
1223
+ },
1224
+ children: "Memory Extraction"
1225
+ }
1226
+ ),
1227
+ isRunning ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
1228
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1229
+ "div",
1230
+ {
1231
+ style: {
1232
+ display: "flex",
1233
+ alignItems: "center",
1234
+ gap: "6px",
1235
+ marginBottom: "4px"
1236
+ },
1237
+ children: [
1238
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1239
+ "span",
1240
+ {
1241
+ "data-testid": "extraction-status-dot",
1242
+ "aria-hidden": "true",
1243
+ style: {
1244
+ width: "6px",
1245
+ height: "6px",
1246
+ borderRadius: "50%",
1247
+ background: themeVar("stateSuccess"),
1248
+ animation: "ov-pulse 1.2s ease-in-out infinite",
1249
+ flexShrink: 0
1250
+ }
1251
+ }
1252
+ ),
1253
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "data-testid": "extraction-running-label", children: "Extracting\u2026" })
1254
+ ]
1255
+ }
1256
+ ),
1257
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1258
+ "div",
1259
+ {
1260
+ "data-testid": "extraction-indeterminate-track",
1261
+ style: {
1262
+ width: "100%",
1263
+ height: "6px",
1264
+ borderRadius: "3px",
1265
+ backgroundColor: themeVar("insetSurface"),
1266
+ overflow: "hidden",
1267
+ position: "relative"
1268
+ },
1269
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1270
+ "div",
1271
+ {
1272
+ "data-testid": "extraction-indeterminate-fill",
1273
+ style: {
1274
+ position: "absolute",
1275
+ left: 0,
1276
+ top: 0,
1277
+ height: "100%",
1278
+ width: "40%",
1279
+ borderRadius: "3px",
1280
+ backgroundColor: themeVar("stateSuccess"),
1281
+ animation: "ov-indeterminate 1.2s ease-in-out infinite"
1282
+ }
1283
+ }
1284
+ )
1285
+ }
1286
+ )
1287
+ ] }) : failedActive && lastFailed ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1288
+ "div",
1289
+ {
1290
+ "data-testid": "extraction-failed-line",
1291
+ style: {
1292
+ display: "flex",
1293
+ alignItems: "center",
1294
+ gap: "6px",
1295
+ color: themeVar("stateWarning")
1296
+ },
1297
+ children: [
1298
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1299
+ "span",
1300
+ {
1301
+ "aria-hidden": "true",
1302
+ style: { display: "inline-flex", flexShrink: 0 },
1303
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(WarningGlyph, { size: 12, testId: "extraction-warning-glyph" })
1304
+ }
1305
+ ),
1306
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "data-testid": "extraction-failed-text", children: lastFailed.error || "extraction failed" })
1307
+ ]
1308
+ }
1309
+ ) : lastCompleted ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1310
+ "div",
1311
+ {
1312
+ style: {
1313
+ display: "flex",
1314
+ alignItems: "center",
1315
+ gap: "6px"
1316
+ },
1317
+ children: [
1318
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1319
+ "span",
1320
+ {
1321
+ "data-testid": "extraction-status-dot",
1322
+ "aria-hidden": "true",
1323
+ style: {
1324
+ width: "6px",
1325
+ height: "6px",
1326
+ borderRadius: "50%",
1327
+ background: themeVar("stateSuccess"),
1328
+ flexShrink: 0
1329
+ }
1330
+ }
1331
+ ),
1332
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "data-testid": "extraction-last-line", children: formatExtractionSummary(lastCompleted) })
1333
+ ]
1334
+ }
1335
+ ) : null,
1336
+ backlog && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1337
+ "div",
1338
+ {
1339
+ "data-testid": "extraction-backlog-line",
1340
+ style: { ...mutedStyle, marginTop: "4px" },
1341
+ children: backlog
1342
+ }
1343
+ ),
1344
+ logTaskId && client && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { marginTop: "6px" }, children: [
1345
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1346
+ "button",
1347
+ {
1348
+ type: "button",
1349
+ "data-testid": "extraction-show-log-btn",
1350
+ onClick: () => void toggleLog(),
1351
+ style: {
1352
+ background: "none",
1353
+ border: "none",
1354
+ padding: 0,
1355
+ cursor: "pointer",
1356
+ font: "inherit",
1357
+ color: themeVar("labelTertiary"),
1358
+ textDecoration: "underline"
1359
+ },
1360
+ "aria-expanded": showLog,
1361
+ children: showLog ? "Hide log" : "Show log"
1362
+ }
1363
+ ),
1364
+ showLog && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1365
+ "div",
1366
+ {
1367
+ "data-testid": "extraction-log",
1368
+ style: {
1369
+ marginTop: "6px",
1370
+ display: "flex",
1371
+ flexDirection: "column",
1372
+ gap: "2px",
1373
+ maxHeight: "120px",
1374
+ overflowY: "auto"
1375
+ },
1376
+ children: [
1377
+ loadingLog && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "data-testid": "extraction-log-loading", style: mutedStyle, children: "Loading\u2026" }),
1378
+ logError && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "data-testid": "extraction-log-error", style: mutedStyle, children: logError }),
1379
+ events && events.length === 0 && !loadingLog && !logError && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "data-testid": "extraction-log-empty", style: mutedStyle, children: "No events" }),
1380
+ events?.map((ev, idx) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1381
+ "div",
1382
+ {
1383
+ "data-testid": "extraction-log-event",
1384
+ style: {
1385
+ display: "flex",
1386
+ justifyContent: "space-between",
1387
+ gap: "8px"
1388
+ },
1389
+ children: [
1390
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: ev.status || ev.kind || "event" }),
1391
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: mutedStyle, children: formatRelativeTime(ev.recorded_at) || "" })
1392
+ ]
1393
+ },
1394
+ `${ev.seq ?? idx}-${ev.recorded_at ?? idx}`
1395
+ ))
1396
+ ]
1397
+ }
1398
+ )
1399
+ ] })
1400
+ ] });
1401
+ }
871
1402
  function OpenVikingStatusPopover({
872
1403
  sessionId,
873
1404
  health,
@@ -877,6 +1408,8 @@ function OpenVikingStatusPopover({
877
1408
  endpoint,
878
1409
  isCommitting = false,
879
1410
  commitError = null,
1411
+ breakdown = null,
1412
+ client,
880
1413
  onCommitNow,
881
1414
  onClose,
882
1415
  className,
@@ -957,7 +1490,7 @@ function OpenVikingStatusPopover({
957
1490
  ...style
958
1491
  },
959
1492
  children: [
960
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: `@keyframes ov-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }` }),
1493
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: `@keyframes ov-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes ov-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.72); } } @keyframes ov-indeterminate { 0% { left: -40%; } 100% { left: 100%; } }` }),
961
1494
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
962
1495
  "div",
963
1496
  {
@@ -1240,6 +1773,7 @@ function OpenVikingStatusPopover({
1240
1773
  }
1241
1774
  )
1242
1775
  ] }),
1776
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExtractionSection, { breakdown, client }),
1243
1777
  commitError && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1244
1778
  "div",
1245
1779
  {
@@ -1297,6 +1831,77 @@ function OpenVikingStatusPopover({
1297
1831
  // src/client/OpenVikingStatusChip.tsx
1298
1832
  var import_jsx_runtime2 = require("react/jsx-runtime");
1299
1833
  var COMMIT_THRESHOLD = 2e4;
1834
+ var POLL_INTERVAL_IDLE_MS = 15e3;
1835
+ var POLL_INTERVAL_ACTIVE_MS = 2500;
1836
+ function getChipDotState({
1837
+ isOnline,
1838
+ sessionUnreadable,
1839
+ breakdown
1840
+ }) {
1841
+ if (!isOnline) return "offline";
1842
+ if (sessionUnreadable) return "session-unreadable";
1843
+ if (breakdown && breakdown.running >= 1) return "busy";
1844
+ if (breakdown && breakdown.failed >= 1) return "extraction-failed";
1845
+ return "online-idle";
1846
+ }
1847
+ function WarningGlyph({
1848
+ size = 11,
1849
+ testId = "extraction-warning-glyph"
1850
+ }) {
1851
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1852
+ "svg",
1853
+ {
1854
+ "data-testid": testId,
1855
+ width: size,
1856
+ height: size,
1857
+ viewBox: "0 0 16 16",
1858
+ fill: "none",
1859
+ "aria-hidden": "true",
1860
+ style: { flexShrink: 0, display: "block" },
1861
+ children: [
1862
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("circle", { cx: "8", cy: "8", r: "6.5", stroke: "currentColor", strokeWidth: "1.5" }),
1863
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1864
+ "path",
1865
+ {
1866
+ d: "M8 5v3.5",
1867
+ stroke: "currentColor",
1868
+ strokeWidth: "1.5",
1869
+ strokeLinecap: "round"
1870
+ }
1871
+ ),
1872
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("circle", { cx: "8", cy: "11", r: "0.9", fill: "currentColor" })
1873
+ ]
1874
+ }
1875
+ );
1876
+ }
1877
+ function seedRunningTask(prev, taskId, resourceId) {
1878
+ const existing = prev && prev.status === "ok" ? prev.tasks : [];
1879
+ if (existing.some((t) => t.task_id === taskId)) {
1880
+ return prev;
1881
+ }
1882
+ const seeded = {
1883
+ task_id: taskId,
1884
+ status: "running",
1885
+ resource_id: resourceId,
1886
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1887
+ };
1888
+ const tasks = [seeded, ...existing];
1889
+ return { status: "ok", breakdown: computeBreakdown(tasks), tasks };
1890
+ }
1891
+ function getDotColorForState(state) {
1892
+ switch (state) {
1893
+ case "offline":
1894
+ return themeVar("stateError");
1895
+ case "session-unreadable":
1896
+ case "extraction-failed":
1897
+ return themeVar("stateWarning");
1898
+ case "busy":
1899
+ return themeVar("stateSuccess");
1900
+ case "online-idle":
1901
+ default:
1902
+ return themeVar("stateSuccess");
1903
+ }
1904
+ }
1300
1905
  function formatPendingTokens(pendingTokens) {
1301
1906
  const k = Math.round((pendingTokens || 0) / 1e3);
1302
1907
  return `${k}k pend`;
@@ -1399,6 +2004,7 @@ function StatusChipView({
1399
2004
  initialHealth,
1400
2005
  initialSessionData,
1401
2006
  initialSessionRead,
2007
+ initialTasksRead,
1402
2008
  initialOpen = false
1403
2009
  }) {
1404
2010
  const [health, setHealth] = (0, import_react2.useState)(
@@ -1407,6 +2013,9 @@ function StatusChipView({
1407
2013
  const [sessionRead, setSessionRead] = (0, import_react2.useState)(
1408
2014
  initialSessionRead ?? (initialSessionData ? { status: "ok", session: initialSessionData } : null)
1409
2015
  );
2016
+ const [tasksRead, setTasksRead] = (0, import_react2.useState)(
2017
+ initialTasksRead ?? null
2018
+ );
1410
2019
  const [isOpen, setIsOpen] = (0, import_react2.useState)(initialOpen);
1411
2020
  const [isCommitting, setIsCommitting] = (0, import_react2.useState)(false);
1412
2021
  const [commitError, setCommitError] = (0, import_react2.useState)(null);
@@ -1442,16 +2051,24 @@ function StatusChipView({
1442
2051
  if (!healthRes.ok) {
1443
2052
  return;
1444
2053
  }
1445
- setSessionRead(await apiClient.readSession(sessionId));
2054
+ const [session, tasks] = await Promise.all([
2055
+ apiClient.readSession(sessionId),
2056
+ apiClient.listTasks(sessionId)
2057
+ ]);
2058
+ setSessionRead(session);
2059
+ setTasksRead(tasks);
1446
2060
  } catch {
1447
2061
  setHealth({ ok: false });
1448
2062
  }
1449
2063
  }, [sessionId, apiClient]);
2064
+ const breakdown = tasksRead?.status === "ok" ? tasksRead.breakdown : null;
2065
+ const hasRunning = (breakdown?.running ?? 0) >= 1;
1450
2066
  (0, import_react2.useEffect)(() => {
1451
2067
  fetchStatus();
1452
- const timer = setInterval(fetchStatus, 15e3);
2068
+ const interval = hasRunning ? POLL_INTERVAL_ACTIVE_MS : POLL_INTERVAL_IDLE_MS;
2069
+ const timer = setInterval(fetchStatus, interval);
1453
2070
  return () => clearInterval(timer);
1454
- }, [fetchStatus]);
2071
+ }, [fetchStatus, hasRunning]);
1455
2072
  (0, import_react2.useEffect)(() => {
1456
2073
  function handleClickOutside(event) {
1457
2074
  if (popoverRef.current && !popoverRef.current.contains(event.target)) {
@@ -1481,6 +2098,11 @@ function StatusChipView({
1481
2098
  keep_recent_count: 10
1482
2099
  });
1483
2100
  if (res.ok) {
2101
+ if (res.task_id) {
2102
+ setTasksRead(
2103
+ (prev) => seedRunningTask(prev, res.task_id, res.resource_id)
2104
+ );
2105
+ }
1484
2106
  await fetchStatus();
1485
2107
  onCommit?.();
1486
2108
  } else {
@@ -1499,11 +2121,15 @@ function StatusChipView({
1499
2121
  const sessionUnreadable = isOnline && sessionRead !== null && sessionRead.status !== "ok";
1500
2122
  const pendingTokens = sessionData?.pending_tokens ?? 0;
1501
2123
  const recalledCount = recalledResult.recalledCount;
1502
- const statusColor = getStatusIndicatorColor(
2124
+ const rawDotState = getChipDotState({
1503
2125
  isOnline,
1504
- isCommitting,
1505
- sessionUnreadable
1506
- );
2126
+ sessionUnreadable,
2127
+ breakdown
2128
+ });
2129
+ const dotState = isCommitting && (rawDotState === "online-idle" || rawDotState === "busy") ? "busy" : rawDotState;
2130
+ const dotBusy = dotState === "busy";
2131
+ const dotFailed = dotState === "extraction-failed";
2132
+ const statusColor = getDotColorForState(dotState);
1507
2133
  const tooltipTitle = formatTooltipTitle({
1508
2134
  isOnline,
1509
2135
  isCommitting,
@@ -1520,6 +2146,7 @@ function StatusChipView({
1520
2146
  style: { minWidth: 0, display: "inline-flex", position: "relative" },
1521
2147
  ref: popoverRef,
1522
2148
  children: [
2149
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("style", { children: `@keyframes ov-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.72); } }` }),
1523
2150
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1524
2151
  "button",
1525
2152
  {
@@ -1550,17 +2177,32 @@ function StatusChipView({
1550
2177
  title: tooltipTitle,
1551
2178
  "aria-label": tooltipTitle,
1552
2179
  children: [
1553
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2180
+ dotFailed ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1554
2181
  "span",
1555
2182
  {
1556
2183
  "data-testid": "status-dot",
2184
+ "data-dot-state": dotState,
2185
+ "aria-hidden": "true",
2186
+ style: {
2187
+ display: "inline-flex",
2188
+ color: statusColor,
2189
+ flexShrink: 0
2190
+ },
2191
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(WarningGlyph, { size: 11, testId: "chip-warning-glyph" })
2192
+ }
2193
+ ) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2194
+ "span",
2195
+ {
2196
+ "data-testid": "status-dot",
2197
+ "data-dot-state": dotBusy ? "busy" : dotState,
1557
2198
  "aria-hidden": "true",
1558
2199
  style: {
1559
2200
  width: "6px",
1560
2201
  height: "6px",
1561
2202
  borderRadius: "50%",
1562
2203
  background: statusColor,
1563
- flexShrink: 0
2204
+ flexShrink: 0,
2205
+ ...dotBusy ? { animation: "ov-pulse 1.2s ease-in-out infinite" } : {}
1564
2206
  }
1565
2207
  }
1566
2208
  ),
@@ -1579,6 +2221,8 @@ function StatusChipView({
1579
2221
  endpoint: apiClient.endpoint,
1580
2222
  isCommitting,
1581
2223
  commitError,
2224
+ breakdown,
2225
+ client: apiClient,
1582
2226
  onCommitNow: handleCommitNow,
1583
2227
  onClose: () => setIsOpen(false)
1584
2228
  }