dsh-plugin-vrmpet3d 0.1.1 → 0.1.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.
@@ -1187,9 +1187,8 @@
1187
1187
  }
1188
1188
 
1189
1189
  /**
1190
- * Which full-deflection distance to use: the SETTING when it is positive, otherwise the caller's own
1191
- * frame. Keeping that decision here rather than in each surface is what keeps "0 = 自动" meaning the
1192
- * same thing in the floating window (the screen's width) and in the app overlay (its window's width).
1190
+ * A positive setting overrides automatic distance. Both axes and both surfaces
1191
+ * pass the screen width here; automatic distance is half of that width.
1193
1192
  */
1194
1193
  function resolveGazeSpan(span, fallback) {
1195
1194
  var wanted = Number(span);
@@ -1197,7 +1196,7 @@
1197
1196
  return clamp(wanted, GAZE_SPAN_RANGE.min, GAZE_SPAN_RANGE.max);
1198
1197
  }
1199
1198
  var base = Number(fallback);
1200
- return isFiniteNumber(base) && base > 0 ? base : GAZE_SPAN.x;
1199
+ return isFiniteNumber(base) && base > 0 ? base / 2 : GAZE_SPAN.x;
1201
1200
  }
1202
1201
 
1203
1202
  /**
@@ -1219,7 +1218,9 @@
1219
1218
  * Pure numbers, no DOM, so the logic suite covers the clamping and the signs.
1220
1219
  *
1221
1220
  * @param {{pointerX:number, pointerY:number, centreX:number, centreY:number,
1222
- * spanX?:number, spanY?:number, curve?:number}} input
1221
+ * spanX?:number, spanY?:number, curve?:number, curveY?:number,
1222
+ * curveRadial?:number, curveSpatial?:number, distance?:number,
1223
+ * mode?:'axes'|'radial'|'spatial'}} input
1223
1224
  * @returns {{x:number, y:number}|null} `null` when the input is unusable; `y` is
1224
1225
  * positive BELOW the pet, i.e. in screen coordinates
1225
1226
  */
@@ -1238,12 +1239,39 @@
1238
1239
  : GAZE_SPAN.y;
1239
1240
  // The settings' curve, unless the caller passes its own (the suite does, to pin a shape).
1240
1241
  var edgeSlope = isFiniteNumber(Number(input.curve)) ? Number(input.curve) : DEFAULTS.gazeCurve;
1242
+ var slopeY = isFiniteNumber(Number(input.curveY)) ? Number(input.curveY) : edgeSlope;
1243
+ if (input.mode === 'spatial') {
1244
+ var spatialX = (pointerX - centreX) / spanX;
1245
+ var spatialY = (pointerY - centreY) / spanY;
1246
+ if (!isFiniteNumber(spatialX) || !isFiniteNumber(spatialY)) return null;
1247
+ var depth = isFiniteNumber(Number(input.distance)) && Number(input.distance) > 0
1248
+ ? clamp(Number(input.distance), 0.1, 5) : DEFAULTS.gazeDistance;
1249
+ // A virtual target plane in span units: sideways distance reduces pitch
1250
+ // at a fixed pointer height. Curves shape angles, rather than pixel offsets.
1251
+ var yawAngle = Math.atan2(spatialX, depth) / (Math.PI / 2);
1252
+ var pitchAngle = Math.atan2(spatialY, Math.hypot(spatialX, depth)) / (Math.PI / 2);
1253
+ var spatialSlope = isFiniteNumber(Number(input.curveSpatial)) ? Number(input.curveSpatial) : DEFAULTS.gazeCurveSpatial;
1254
+ return {
1255
+ x: (yawAngle < 0 ? -1 : 1) * gazeCurve(Math.abs(yawAngle), spatialSlope),
1256
+ y: (pitchAngle < 0 ? -1 : 1) * gazeCurve(Math.abs(pitchAngle), spatialSlope),
1257
+ };
1258
+ }
1259
+ if (input.mode === 'radial') {
1260
+ var dx = (pointerX - centreX) / spanX;
1261
+ var dy = (pointerY - centreY) / spanY;
1262
+ var distance = Math.hypot(dx, dy);
1263
+ if (!isFiniteNumber(distance)) return null;
1264
+ if (distance === 0) return { x: 0, y: 0 };
1265
+ var radialSlope = isFiniteNumber(Number(input.curveRadial)) ? Number(input.curveRadial) : edgeSlope;
1266
+ var gain = gazeCurve(Math.min(1, distance), radialSlope) / distance;
1267
+ return { x: dx * gain, y: dy * gain };
1268
+ }
1241
1269
  var rawX = clamp((pointerX - centreX) / spanX, -1, 1);
1242
1270
  var rawY = clamp((pointerY - centreY) / spanY, -1, 1);
1243
1271
  // Mirrored through `gazeCurve` so the sign survives and the response is the curve on both sides.
1244
1272
  return {
1245
1273
  x: rawX < 0 ? -gazeCurve(-rawX, edgeSlope) : gazeCurve(rawX, edgeSlope),
1246
- y: rawY < 0 ? -gazeCurve(-rawY, edgeSlope) : gazeCurve(rawY, edgeSlope)
1274
+ y: rawY < 0 ? -gazeCurve(-rawY, slopeY) : gazeCurve(rawY, slopeY)
1247
1275
  };
1248
1276
  }
1249
1277
 
@@ -1356,12 +1384,21 @@
1356
1384
  * operator's own (2026-10-13: 「视线:曲率设置默认为0.1,提供0.05-1的选择(原先是0.1-1),静止停看默认为2秒」).
1357
1385
  * `gazeCurve` is the curve's slope at the frame's edge — 1 is a straight line, smaller is a
1358
1386
  * steeper start and a flatter tail — and `gazeSpanX`/`gazeSpanY` are the full-deflection
1359
- * distances in CSS pixels, where **0 means AUTO**: the caller's own frame (the screen's width in
1360
- * the floating window, the window's width in the app overlay) and, vertically, the fallback below.
1387
+ * distances in CSS pixels, where **0 means AUTO**: half the screen width on both axes.
1388
+ * `gazeCurve` / `gazeCurveY` remain readable for older delivered clients; the current
1389
+ * settings expose only spatial (default) and radial modes.
1361
1390
  */
1362
1391
  gazeCurve: 0.1,
1392
+ gazeCurveMode: 'spatial',
1393
+ gazeCurveY: 0.1,
1394
+ gazeCurveRadial: 0.1,
1395
+ gazeCurveSpatial: 1,
1396
+ gazeDistance: 0.1,
1363
1397
  gazeSpanX: 0,
1364
1398
  gazeSpanY: 0,
1399
+ // Actual head pitch in degrees, after the renderer's gaze weight.
1400
+ gazePitchUp: 60,
1401
+ gazePitchDown: 60,
1365
1402
  /**
1366
1403
  * How long the cursor may sit still before the pet stops following it, in seconds.
1367
1404
  *
@@ -1473,6 +1510,7 @@
1473
1510
  * not silently pin drag and gravity to the numbers the panel happened to be showing.
1474
1511
  */
1475
1512
  springs: {},
1513
+ toon: {},
1476
1514
  /**
1477
1515
  * Per-model size multiplier for BOTH texts: `{ [modelId]: number }`.
1478
1516
  *
@@ -2207,12 +2245,25 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
2207
2245
  out.gazeCurve = Number.isFinite(Number(source.gazeCurve))
2208
2246
  ? clamp(Number(source.gazeCurve), GAZE_CURVE_RANGE.min, GAZE_CURVE_RANGE.max)
2209
2247
  : DEFAULTS.gazeCurve;
2248
+ out.gazeCurveMode = source.gazeCurveMode === 'radial' ? 'radial' : 'spatial';
2249
+ ['gazeCurveY', 'gazeCurveRadial'].forEach(function (key) {
2250
+ out[key] = typeof source[key] === 'number' && Number.isFinite(source[key])
2251
+ ? clamp(source[key], GAZE_CURVE_RANGE.min, GAZE_CURVE_RANGE.max) : DEFAULTS[key];
2252
+ });
2253
+ out.gazeCurveSpatial = typeof source.gazeCurveSpatial === 'number' && Number.isFinite(source.gazeCurveSpatial)
2254
+ ? clamp(source.gazeCurveSpatial, GAZE_CURVE_RANGE.min, GAZE_CURVE_RANGE.max) : DEFAULTS.gazeCurveSpatial;
2255
+ out.gazeDistance = typeof source.gazeDistance === 'number' && Number.isFinite(source.gazeDistance)
2256
+ ? clamp(source.gazeDistance, 0.1, 5) : DEFAULTS.gazeDistance;
2210
2257
  out.gazeSpanX = Number.isFinite(Number(source.gazeSpanX))
2211
2258
  ? clamp(Number(source.gazeSpanX), GAZE_SPAN_RANGE.min, GAZE_SPAN_RANGE.max)
2212
2259
  : DEFAULTS.gazeSpanX;
2213
2260
  out.gazeSpanY = Number.isFinite(Number(source.gazeSpanY))
2214
2261
  ? clamp(Number(source.gazeSpanY), GAZE_SPAN_RANGE.min, GAZE_SPAN_RANGE.max)
2215
2262
  : DEFAULTS.gazeSpanY;
2263
+ ['gazePitchUp', 'gazePitchDown'].forEach(function (key) {
2264
+ out[key] = typeof source[key] === 'number' && Number.isFinite(source[key])
2265
+ ? clamp(source[key], 0, 60) : DEFAULTS[key];
2266
+ });
2216
2267
  // 0 is a legitimate value here — it turns the idle rule off — so this cannot fall back to the default the
2217
2268
  // way an unparseable value does. A negative number is unparseable for this purpose and becomes 0.
2218
2269
  out.gazeIdleSeconds = Number.isFinite(Number(source.gazeIdleSeconds))
@@ -2267,6 +2318,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
2267
2318
  ? source.expressions.emotion.parts
2268
2319
  : null);
2269
2320
  out.springs = cleanSprings(source.springs);
2321
+ out.toon = cleanToon(source.toon);
2270
2322
  return out;
2271
2323
  }
2272
2324
 
@@ -2277,6 +2329,25 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
2277
2329
  * directory's manifest says `loop: false`) has to be OVERRIDABLE back to looping, and "no entry" cannot
2278
2330
  * express that — absence means "ask the catalog".
2279
2331
  */
2332
+ function cleanToon(raw) {
2333
+ var out = {};
2334
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
2335
+ Object.keys(raw).slice(0, 256).forEach(function (id) {
2336
+ if (id === '__proto__' || id === 'constructor' || id === 'prototype') return;
2337
+ var entry = raw[id];
2338
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return;
2339
+ var next = {};
2340
+ if (typeof entry.enabled === 'boolean') next.enabled = entry.enabled;
2341
+ var ranges = { shadingToonyFactor: [0, 1], shadingShiftFactor: [-1, 1], shadeStrength: [0, 1], outlineScale: [0, 3] };
2342
+ Object.keys(ranges).forEach(function (key) {
2343
+ var value = entry[key];
2344
+ if (typeof value === 'number' && Number.isFinite(value)) next[key] = clamp(value, ranges[key][0], ranges[key][1]);
2345
+ });
2346
+ if (Object.keys(next).length) out[id] = next;
2347
+ });
2348
+ return out;
2349
+ }
2350
+
2280
2351
  function cleanMotionLoop(raw) {
2281
2352
  var out = {};
2282
2353
  var source = raw && typeof raw === 'object' ? raw : {};
@@ -3560,6 +3631,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
3560
3631
  * @returns {string|null} the line to show, or null for silence
3561
3632
  */
3562
3633
  SpeechMachine.prototype.observe = function (preferences, state, pendingKind, language, random, motion) {
3634
+ if (state === 'dancing') {
3635
+ this.current = null;
3636
+ this.wantsAi = null;
3637
+ this.lastEvent = null;
3638
+ this.idleAt = null;
3639
+ this.chatterAt = null;
3640
+ return null;
3641
+ }
3563
3642
  var speech = cleanSpeech(preferences && preferences.speech);
3564
3643
  var t = this.now();
3565
3644
  if (!speech.enabled) {
@@ -3824,7 +3903,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
3824
3903
  *
3825
3904
  * @returns {{state: string, text: string, tag: string, animation: string, seconds: number}|null}
3826
3905
  */
3827
- function statusFor(preferences, state, enteredAt, now, clock, language) {
3906
+ function statusFor(preferences, state, enteredAt, now, clock, language, completedCount) {
3828
3907
  if (STATUS_STATES.indexOf(state) === -1) return null;
3829
3908
  var entry = statusEntryFor(preferences, state, language);
3830
3909
  if (!entry.text && !entry.tag) return null;
@@ -3881,6 +3960,9 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
3881
3960
  ? durationClauseRemoved(text)
3882
3961
  : text.split(STATUS_DURATION_TOKEN).join(formatRunDuration(elapsed, language));
3883
3962
  }
3963
+ if (state === 'done' && Number(completedCount) > 1 && text) {
3964
+ text = language === 'en' ? (text === 'Task complete' ? 'All tasks complete' : 'All ' + text) : '全部' + text;
3965
+ }
3884
3966
  var look = statusTone(state);
3885
3967
  return {
3886
3968
  state: state,
@@ -4914,6 +4996,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
4914
4996
  * @param {object} options url, intervalMs, fetchImpl, onPreview(preview|null), onUnsupported
4915
4997
  */
4916
4998
  function watchHostPreview(options) {
4999
+ var navigationSeen = null;
4917
5000
  var settings = options || {};
4918
5001
  var url = settings.url || '/vrmpet3d/preview';
4919
5002
  // Four hundred milliseconds: this drives a hover preview, so it has to feel immediate, and the route is a
@@ -4922,11 +5005,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
4922
5005
  var doFetch = settings.fetchImpl || (typeof fetch === 'function' ? fetch : null);
4923
5006
  var timer = null;
4924
5007
  var seen = -1;
5008
+ var issued = 0;
5009
+ var delivered = 0;
4925
5010
  var supported = Boolean(doFetch);
4926
5011
  var stopped = false;
4927
5012
 
4928
5013
  function load() {
4929
5014
  if (!supported || stopped) return Promise.resolve(null);
5015
+ var request = ++issued;
4930
5016
  return doFetch(url, { credentials: 'omit' })
4931
5017
  .then(function (response) {
4932
5018
  if (response.status === 404 || response.status === 405) {
@@ -4938,6 +5024,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
4938
5024
  return response.json ? response.json() : null;
4939
5025
  })
4940
5026
  .then(function (body) {
5027
+ if (request < delivered) return null;
5028
+ if (body && typeof body.seq === 'number') delivered = request;
5029
+ if (body && body.navigation && typeof body.navigation.seq === 'number') {
5030
+ var navigation = body.navigation;
5031
+ if (navigationSeen !== null && navigationSeen !== navigation.seq && typeof navigation.sessionId === 'string'
5032
+ && typeof settings.onNavigate === 'function') settings.onNavigate(navigation.sessionId);
5033
+ navigationSeen = navigation.seq;
5034
+ }
4941
5035
  if (!body || typeof body.seq !== 'number' || body.seq === seen) return null;
4942
5036
  seen = body.seq;
4943
5037
  var preview = cleanPreview(body.preview);
@@ -5075,11 +5169,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5075
5169
  var doFetch = settings.fetchImpl || (typeof fetch === 'function' ? fetch : null);
5076
5170
  var timer = null;
5077
5171
  var seen = -1;
5172
+ var issued = 0;
5173
+ var delivered = 0;
5078
5174
  var supported = Boolean(doFetch);
5079
5175
  var stopped = false;
5080
5176
 
5081
5177
  function load() {
5082
5178
  if (!supported || stopped) return Promise.resolve(null);
5179
+ var request = ++issued;
5083
5180
  return doFetch(url, { credentials: 'omit' })
5084
5181
  .then(function (response) {
5085
5182
  if (response.status === 404 || response.status === 405) {
@@ -5091,6 +5188,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5091
5188
  return response.json ? response.json() : null;
5092
5189
  })
5093
5190
  .then(function (body) {
5191
+ if (request < delivered) return null;
5192
+ if (body && typeof body.seq === 'number') delivered = request;
5094
5193
  if (!body || typeof body.seq !== 'number' || body.seq === seen) return null;
5095
5194
  seen = body.seq;
5096
5195
  var state = STATES.indexOf(body.state) === -1 ? null : body.state;
@@ -5200,6 +5299,12 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5200
5299
  }
5201
5300
  if (typeof why.scope === 'string' && why.scope) out.scope = why.scope.slice(0, 16);
5202
5301
  if (typeof why.session === 'string' && why.session) out.session = why.session.slice(0, 64);
5302
+ if (typeof why.statusSessionId === 'string' && why.statusSessionId) out.statusSessionId = why.statusSessionId.slice(0, 64);
5303
+ if (typeof why.completedSessionId === 'string' && why.completedSessionId) out.completedSessionId = why.completedSessionId.slice(0, 64);
5304
+ ['waitingSessionIds', 'completedSessionIds'].forEach(function (key) {
5305
+ if (Array.isArray(why[key])) out[key] = why[key].filter(function (id) { return typeof id === 'string' && id; })
5306
+ .slice(0, 256).map(function (id) { return id.slice(0, 64); });
5307
+ });
5203
5308
  if (typeof why.interaction === 'string' && why.interaction) out.interaction = why.interaction.slice(0, 16);
5204
5309
  if (Array.isArray(why.runningIds)) {
5205
5310
  out.runningIds = why.runningIds
@@ -5357,15 +5462,19 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5357
5462
  * its 进行中 line has to travel with the state. A whole number, or nothing — an older host that does not send it
5358
5463
  * leaves the window's dot as it was rather than inventing a count.
5359
5464
  */
5360
- var nextUnread = Number.isFinite(Number(unread)) && Number(unread) > 0 ? Math.floor(Number(unread)) : null;
5465
+ var nextUnread = typeof unread === 'number' && Number.isFinite(unread) && unread >= 0 ? Math.floor(unread) : null;
5361
5466
  /**
5362
5467
  * The diagnostic, kept OUT of the change key on purpose: it carries counts and clock readings that move
5363
5468
  * constantly, and letting them force a report would turn "the pet announces a change" into a 1 Hz feed. It
5364
5469
  * rides along with the reports the state and the line already earn, and the heartbeat re-states the last one.
5365
5470
  */
5366
5471
  var nextWhy = cleanWhy(why);
5472
+ var navigationKey = function (value) {
5473
+ return JSON.stringify(value ? [value.waitingSessionIds || [], value.completedSessionIds || [],
5474
+ value.statusSessionId || null, value.completedSessionId || null] : [[], [], null, null]);
5475
+ };
5367
5476
  if (force !== true && next === sent && nextLine === sentLine && nextTouchKey === sentTouchKey
5368
- && nextClockKey === sentClockKey && nextUnread === sentUnread) {
5477
+ && nextClockKey === sentClockKey && nextUnread === sentUnread && navigationKey(nextWhy) === navigationKey(sentWhy)) {
5369
5478
  return Promise.resolve(null);
5370
5479
  }
5371
5480
  sent = next;
@@ -5490,10 +5599,15 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5490
5599
  var doFetch = settings.fetchImpl || (typeof fetch === 'function' ? fetch : null);
5491
5600
  var timer = null;
5492
5601
  var seen = -1;
5602
+ var issued = 0;
5603
+ var delivered = 0;
5493
5604
  var supported = Boolean(doFetch);
5494
5605
  var stopped = false;
5495
5606
 
5496
- function deliver(snapshot) {
5607
+ function deliver(snapshot, request) {
5608
+ // A slow poll/write must not roll a live gesture back to an older reply.
5609
+ if (request < delivered) return null;
5610
+ if (snapshot && typeof snapshot.version === 'number') delivered = request;
5497
5611
  if (!snapshot || typeof snapshot.version !== 'number' || snapshot.version === seen) return null;
5498
5612
  seen = snapshot.version;
5499
5613
  if (typeof settings.onSnapshot === 'function') settings.onSnapshot(snapshot);
@@ -5503,6 +5617,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5503
5617
  /** One look at the host. Returns a promise for the snapshot it delivered, if any. */
5504
5618
  function load() {
5505
5619
  if (!supported || stopped) return Promise.resolve(null);
5620
+ var request = ++issued;
5506
5621
  return doFetch(url, { credentials: 'omit' })
5507
5622
  .then(function (response) {
5508
5623
  if (response.status === 404 || response.status === 405) {
@@ -5514,7 +5629,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5514
5629
  if (!response.ok) return null;
5515
5630
  return response.json ? response.json() : null;
5516
5631
  })
5517
- .then(deliver)
5632
+ .then(function (snapshot) { return deliver(snapshot, request); })
5518
5633
  .catch(function () { return null; });
5519
5634
  }
5520
5635
 
@@ -5526,6 +5641,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5526
5641
  */
5527
5642
  function push(body) {
5528
5643
  if (!supported || stopped) return Promise.resolve(null);
5644
+ var request = ++issued;
5529
5645
  return doFetch(url, {
5530
5646
  method: 'POST',
5531
5647
  credentials: 'omit',
@@ -5533,7 +5649,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5533
5649
  body: JSON.stringify(body || {}),
5534
5650
  })
5535
5651
  .then(function (response) { return response.ok ? response.json() : null; })
5536
- .then(deliver)
5652
+ .then(function (snapshot) { return deliver(snapshot, request); })
5537
5653
  .catch(function () { return null; });
5538
5654
  }
5539
5655
 
@@ -5746,6 +5862,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5746
5862
  * sits above the watermark and is adopted as usual.
5747
5863
  */
5748
5864
  this.dismissedNoticeAt = 0;
5865
+ this.dismissedSessions = new Map();
5749
5866
  /**
5750
5867
  * The TURN the pet is currently describing, and the one that just ended.
5751
5868
  *
@@ -6028,6 +6145,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6028
6145
  PetMachine.prototype.dismissNotice = function (keepDebt) {
6029
6146
  if (this.notice && Number.isFinite(this.notice.at)) {
6030
6147
  this.dismissedNoticeAt = Math.max(this.dismissedNoticeAt, this.notice.at);
6148
+ if (this.notice.sessionId) this.dismissedSessions.set(this.notice.sessionId, this.notice.at);
6031
6149
  }
6032
6150
  if (keepDebt !== true && this.notice && this.notice.sessionId) {
6033
6151
  delete this.retractions[this.notice.sessionId];
@@ -6150,7 +6268,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6150
6268
  * well as in the acknowledgement rule in `resolve`. (The floating window never takes focus, which is why the same
6151
6269
  * drag could not do this there.)
6152
6270
  */
6153
- if (this.notice && (!this.notice.sessionId || !presenceKnown) && this.interaction === null) {
6271
+ if (this.notice && !this.notice.sessionId && !presenceKnown && this.interaction === null) {
6154
6272
  this.noteDismissal('focus-return');
6155
6273
  this.dismissNotice();
6156
6274
  }
@@ -6262,6 +6380,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6262
6380
  // reports as its own `at`, so the minimum hold starts when the user can first see it.
6263
6381
  endedAt: at,
6264
6382
  settledAt: this.now(),
6383
+ confirmed: payload.confirmed === true,
6265
6384
  sessionId: sessionId,
6266
6385
  };
6267
6386
  }
@@ -6287,8 +6406,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6287
6406
  * than a startup that has not finished.
6288
6407
  */
6289
6408
  if (snap.connected === true) this.everConnected = true;
6290
- var busy = snap.available === true && (snap.running === true || snap.pending === true);
6409
+ var busy = snap.pending === true || (snap.available === true && snap.running === true);
6291
6410
  if (busy) this.lastBusyAt = t;
6411
+ var viewedCompletions = Array.isArray(snap.viewedCompletions) ? snap.viewedCompletions : [];
6412
+ if (this.pendingNotice && viewedCompletions.indexOf(this.pendingNotice.sessionId) !== -1) this.pendingNotice = undefined;
6413
+ if (this.notice && viewedCompletions.indexOf(this.notice.sessionId) !== -1) {
6414
+ this.noteDismissal('acknowledged');
6415
+ this.dismissNotice();
6416
+ }
6292
6417
 
6293
6418
  /**
6294
6419
  * Adopt the SNAPSHOT's own notice when it is newer than this machine's.
@@ -6307,9 +6432,11 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6307
6432
  * what the aggregate watches, and it falls between the rounds of one task exactly like a turn does.
6308
6433
  */
6309
6434
  if (snap.notice && typeof snap.notice.at === 'number'
6310
- && snap.notice.at > this.dismissedNoticeAt
6311
- && (!this.notice || snap.notice.at > this.notice.at)
6312
- && (!this.pendingNotice || snap.notice.at > this.pendingNotice.endedAt)) {
6435
+ && (snap.notice.queued === true ? (!this.notice && !this.pendingNotice
6436
+ && (!this.dismissedSessions.has(snap.notice.sessionId) || snap.notice.at > this.dismissedSessions.get(snap.notice.sessionId)))
6437
+ : (snap.notice.at > this.dismissedNoticeAt
6438
+ && (!this.notice || snap.notice.at > this.notice.at)
6439
+ && (!this.pendingNotice || snap.notice.at > this.pendingNotice.endedAt)))) {
6313
6440
  var snapSession = typeof snap.notice.sessionId === 'string' && snap.notice.sessionId ? snap.notice.sessionId : null;
6314
6441
  if (snap.notice.reason === 'error') {
6315
6442
  // A failure is not a completion and is never settled: it is shown at once, exactly as a `turn/end` failure is.
@@ -6319,7 +6446,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6319
6446
  this.pendingNotice = {
6320
6447
  reason: snap.notice.reason,
6321
6448
  endedAt: snap.notice.at,
6322
- settledAt: t,
6449
+ settledAt: snap.notice.queued === true ? snap.notice.at : t,
6450
+ confirmed: snap.notice.confirmed === true,
6323
6451
  // The session the notice is ABOUT. Without it there is nothing to come back to, and the notice falls
6324
6452
  // back to the older focus/timer rules below.
6325
6453
  sessionId: snapSession,
@@ -6335,6 +6463,9 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6335
6463
  * (a `turn/end` frame and the store's falling edge) are decided together.
6336
6464
  */
6337
6465
  if (this.pendingNotice) {
6466
+ if (snap.notice && snap.notice.sessionId === this.pendingNotice.sessionId && snap.notice.confirmed === true) {
6467
+ this.pendingNotice.confirmed = true;
6468
+ }
6338
6469
  var pendingSession = this.pendingNotice.sessionId;
6339
6470
  /**
6340
6471
  * Cancelled by WORK RESUMING, and only that — the ordering is the whole point.
@@ -6357,7 +6488,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6357
6488
  * never produced one.
6358
6489
  */
6359
6490
  this.pendingNotice = undefined;
6360
- } else if (t - this.pendingNotice.settledAt >= this.settleFor(this.pendingNotice.sessionId)) {
6491
+ } else if (this.pendingNotice.confirmed || t - this.pendingNotice.settledAt >= this.settleFor(this.pendingNotice.sessionId)) {
6361
6492
  this.notice = {
6362
6493
  reason: this.pendingNotice.reason,
6363
6494
  // Visible from NOW: the settle window was spent waiting, and a hold measured from the turn's end would
@@ -6433,8 +6564,10 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6433
6564
  */
6434
6565
  var unreadDropped = isFiniteNumber(this.unreadAtNotice) && this.unreadCount < this.unreadAtNotice;
6435
6566
  var neverUnread = this.unreadAtNotice === 0 && this.unreadCount === 0;
6436
- acknowledged = notice.sessionId && presentId
6437
- ? (presentId === notice.sessionId && this.focused === true)
6567
+ var viewed = Array.isArray(snap.viewedCompletions) && snap.viewedCompletions.indexOf(notice.sessionId) !== -1;
6568
+ acknowledged = notice.sessionId
6569
+ ? (viewed || ((!this.requireViewedAcknowledgement || notice.reason !== 'completed')
6570
+ && presentId === notice.sessionId && this.focused === true))
6438
6571
  : (notice.reason !== 'error' && this.focused === true
6439
6572
  && (unreadDropped || (neverUnread && this.interaction === null)));
6440
6573
  this.pendingDismissalKind = notice.sessionId && presentId ? 'acknowledged' : 'no-session';
@@ -6456,8 +6589,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6456
6589
  * still on screen would flip the pet to 出错 and hide the very prompt the user has to answer, and a
6457
6590
  * turn that completes would show 任务达成 over an unanswered approval.
6458
6591
  *
6459
- * So while the session reports a pending interaction, EVERY other branch is skipped except the two the
6460
- * user owns by hand (a drag, and a click they just made). When the interaction is answered the session
6592
+ * While the session reports a pending interaction, EVERY other state is skipped, including gestures
6593
+ * and greetings. When the interaction is answered the session
6461
6594
  * stops reporting it, this latch releases, and the same frame's cascade decides where the pet goes.
6462
6595
  */
6463
6596
  var pending = snap.pending === true;
@@ -6484,7 +6617,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6484
6617
  /**
6485
6618
  * 跳舞 — the user's own state, and the two rules that make it a good guest (operator, 2026-10-14).
6486
6619
  *
6487
- * It sits here, AFTER the drag states and BEFORE greeting/waiting/offline — so a drag still owns the pet while it happens —
6620
+ * Attention comes first, followed by drag states, dance, greeting and offline.
6488
6621
  * and it is dropped the moment the session is BUSY, which is the operator's 「跳舞也会被进入工作状态打断」 without a rule of its
6489
6622
  * own: work outranks it because the busy branch below is reached first once this field is cleared. A dance also ends when the
6490
6623
  * clip ends (the client watches the clip and calls `stopDancing`) or when the user double-clicks the pet, and both of those
@@ -6495,14 +6628,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6495
6628
  this.stopDancing();
6496
6629
  dance = null;
6497
6630
  }
6498
- if (this.interaction) {
6631
+ if (pending) {
6632
+ state = 'waiting';
6633
+ } else if (this.interaction) {
6499
6634
  state = this.interaction;
6500
6635
  } else if (dance) {
6501
6636
  state = 'dancing';
6502
6637
  } else if (t < this.greetingUntil) {
6503
6638
  state = 'greeting';
6504
- } else if (pending) {
6505
- state = 'waiting';
6506
6639
  } else if (snap.connected === false && this.everConnected) {
6507
6640
  /**
6508
6641
  * 离线 means the host is GONE — not that it has nothing to say yet.
@@ -6602,6 +6735,11 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6602
6735
  this.records = new Map();
6603
6736
  /** Each session's own `running` flag as the store last reported it (see `observeStoreEdge`). */
6604
6737
  this.storeRunning = new Map();
6738
+ this.completionChecks = new Map();
6739
+ this.attentionSessions = new Map();
6740
+ this.completionQueue = new Map();
6741
+ this.completionSeen = new Set();
6742
+ this.viewedCompletions = new Set();
6605
6743
  /**
6606
6744
  * When this aggregate's `running` answer last became true, and what it was last published as.
6607
6745
  *
@@ -6661,6 +6799,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6661
6799
  var status = statuses && statuses.get ? statuses.get(id) : undefined;
6662
6800
  var running = status && typeof status.running === 'boolean' ? status.running : undefined;
6663
6801
  if (running === undefined) return;
6802
+ if (running || status.pendingInteraction != null) self.completionChecks.delete(id);
6803
+ if (running) self.forgetCompletion(id);
6664
6804
  var previous = self.storeRunning.get(id);
6665
6805
  self.storeRunning.set(id, running);
6666
6806
  if (previous === running) return;
@@ -6684,13 +6824,18 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6684
6824
  * stream covers that case when it is wired, and a false 任务完成 on every boot is worse than a missed one.
6685
6825
  */
6686
6826
  if (previous !== true) return;
6827
+ var boundaryRecord = self.records.get(id);
6828
+ if (boundaryRecord && boundaryRecord.lastEndReason && boundaryRecord.lastEndReason !== 'completed') return;
6687
6829
  // Stopped: a completion. A failure already latched outranks it, the same rule the boundary path uses.
6830
+ var completion = { id: id + ':store', reason: 'completed', sessionId: id, at: self.now() };
6831
+ self.rememberCompletion(completion);
6688
6832
  if (self.notice && self.notice.reason === 'error') return;
6689
- self.notice = { id: id + ':store', reason: 'completed', sessionId: id, at: self.now() };
6833
+ self.notice = completion;
6690
6834
  });
6691
6835
  };
6692
6836
 
6693
6837
  SessionAggregate.prototype.reset = function (identities) {
6838
+ this.completionChecks.clear();
6694
6839
  this.records.clear();
6695
6840
  this.notice = undefined;
6696
6841
  if (Array.isArray(identities)) {
@@ -6704,6 +6849,34 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6704
6849
  }
6705
6850
  };
6706
6851
 
6852
+ SessionAggregate.prototype.rememberCompletion = function (notice, announce) {
6853
+ var id = notice && notice.sessionId;
6854
+ if (!id || this.completionSeen.has(id)) return;
6855
+ this.completionSeen.add(id);
6856
+ this.completionQueue.set(id, Object.assign({}, notice, {
6857
+ announce: announce !== false, queued: true, confirmed: announce !== false,
6858
+ }));
6859
+ this.completionChecks.set(id, this.now());
6860
+ };
6861
+
6862
+ SessionAggregate.prototype.forgetCompletion = function (id) {
6863
+ this.completionQueue.delete(id);
6864
+ this.completionChecks.delete(id);
6865
+ this.completionSeen.delete(id);
6866
+ this.viewedCompletions.delete(id);
6867
+ };
6868
+
6869
+ SessionAggregate.prototype.acknowledgeViewed = function (id) {
6870
+ if (!id || this.running(id) !== false) return;
6871
+ var status = this.input.statuses.get ? this.input.statuses.get(id) : null;
6872
+ if (!this.completionQueue.has(id) && !(status && status.completionUnread === true)) return;
6873
+ this.completionSeen.add(id);
6874
+ this.viewedCompletions.add(id);
6875
+ this.completionQueue.delete(id);
6876
+ this.completionChecks.delete(id);
6877
+ if (this.notice && this.notice.sessionId === id && this.notice.reason === 'completed') this.notice = undefined;
6878
+ };
6879
+
6707
6880
  SessionAggregate.prototype.boundary = function (event) {
6708
6881
  if (!event || typeof event !== 'object') return this.snapshot();
6709
6882
  var id = event.sessionId;
@@ -6730,12 +6903,16 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6730
6903
  // The host's live answer for a session it vouches for: every root it classified arrives as `false`.
6731
6904
  if (event.isSubagent === false) record.isSubagent = false;
6732
6905
  if (event.type === 'turn/start') {
6906
+ this.forgetCompletion(id);
6733
6907
  record.running = true;
6908
+ record.lastEndReason = null;
6909
+ this.completionChecks.delete(id);
6734
6910
  record.candidate = undefined;
6735
6911
  // A new turn supersedes whatever the last one reported, error included.
6736
6912
  this.notice = undefined;
6737
6913
  } else if (event.type === 'turn/end') {
6738
6914
  record.running = false;
6915
+ record.lastEndReason = event.reason;
6739
6916
  record.candidate = { id: id + ':' + event.seq, reason: event.reason, at: this.now() };
6740
6917
  }
6741
6918
  this.records.set(id, record);
@@ -6745,7 +6922,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6745
6922
  * a frame for a session nobody can vouch for — not classified, not listed by the app — is silence rather than a
6746
6923
  * 任务达成, because the pet has no way to tell it from a helper's turn ending (2026-10-14).
6747
6924
  */
6748
- if (candidate && (candidate.reason === 'completed' || record.running === false) && this.mainSession(id)) {
6925
+ if (candidate && (candidate.reason === 'completed' || candidate.reason === 'error') && this.mainSession(id)) {
6926
+ if (candidate.reason === 'completed') this.rememberCompletion({ id: candidate.id, reason: candidate.reason, sessionId: id, at: candidate.at });
6749
6927
  record.candidate = undefined;
6750
6928
  // A failure latches: a success arriving moments later must not erase the
6751
6929
  // fact that a turn just died. The machine owns the same rule, so the two
@@ -6766,6 +6944,19 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6766
6944
  };
6767
6945
 
6768
6946
  SessionAggregate.prototype.subagent = function (id) {
6947
+ var list = this.input.catalog || {};
6948
+ var row = list.byId && list.byId[id];
6949
+ // The current DSH store publishes origin and its parents' subagent catalogs, not isSubagent.
6950
+ // These explicit identities outrank an older boundary baseline that called the agent a root.
6951
+ if (row && (row.origin === 'subagent' || row.isSubagent === true)) return true;
6952
+ var parents = Object.keys(list.byId || {});
6953
+ for (var p = 0; p < parents.length; p += 1) {
6954
+ var parent = list.byId[parents[p]];
6955
+ var projection = list.projectionsBySession && list.projectionsBySession[parents[p]];
6956
+ var values = (projection && projection.values) || (parent && parent.projectionValues);
6957
+ var children = values && values.subagentCatalog;
6958
+ if (Array.isArray(children) && children.some(function (child) { return child && child.id === id; })) return true;
6959
+ }
6769
6960
  var record = this.records.get(id);
6770
6961
  if (record && record.isSubagent !== undefined) return record.isSubagent === true;
6771
6962
  var catalog = this.input.catalog && this.input.catalog.byId ? this.input.catalog.byId[id] : undefined;
@@ -6847,7 +7038,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6847
7038
  * only ever make the answer STRICTER on a store that has the list.
6848
7039
  */
6849
7040
  var listed = catalog.ids;
6850
- if (listed && typeof listed.length === 'number' && listed.length > 0) {
7041
+ if (listed && typeof listed.length === 'number') {
6851
7042
  for (var i = 0; i < listed.length; i += 1) {
6852
7043
  if (listed[i] === id) return true;
6853
7044
  }
@@ -6865,6 +7056,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6865
7056
 
6866
7057
  var workingCount = 0;
6867
7058
  var waitingCount = 0;
7059
+ var waitingSessionId = null;
6868
7060
  /**
6869
7061
  * How many sessions have a completion the user has NOT been back to.
6870
7062
  *
@@ -6881,6 +7073,9 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6881
7073
  * contributes nothing.
6882
7074
  */
6883
7075
  var unreadCount = 0;
7076
+ var completedUnreadCount = 0;
7077
+ var completedSessionId = null;
7078
+ var completedSessionIds = [];
6884
7079
  /**
6885
7080
  * WHY that count is what it is, as counts rather than ids or titles — added with the 绿点 fix of 2026-10-14, so
6886
7081
  * "the dot is lit and no session finished" is answerable from `/vrmpet3d/state` alone instead of by guesswork.
@@ -6896,9 +7091,32 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6896
7091
  /** Which pending interaction wins when several sessions are waiting at once. Approval is the rarer. */
6897
7092
  var waitingKind = null;
6898
7093
  var self = this;
7094
+ this.attentionSessions.forEach(function (_kind, id) {
7095
+ var status = statuses.get ? statuses.get(id) : undefined;
7096
+ // Navigating away, a missing report, or losing focus is not an answer to the problem.
7097
+ if (status) {
7098
+ if (status.pendingInteraction == null) self.attentionSessions.delete(id);
7099
+ else self.attentionSessions.set(id, pendingInteractionKind(status.pendingInteraction));
7100
+ }
7101
+ });
6899
7102
  ids.forEach(function (id) {
6900
7103
  if (!self.inScope(id)) return;
6901
7104
  var status = statuses.get ? statuses.get(id) : undefined;
7105
+ var record = self.records.get(id);
7106
+ if (status && status.completionUnread === true && self.mainSession(id) && !self.completionSeen.has(id)
7107
+ && self.running(id) === false && status.pendingInteraction == null) {
7108
+ self.rememberCompletion({ sessionId: id, reason: 'completed', at: self.now() }, false);
7109
+ }
7110
+ var completed = self.completionQueue.has(id) && !self.viewedCompletions.has(id) && self.mainSession(id)
7111
+ && self.running(id) === false && (!status || status.pendingInteraction == null)
7112
+ && !(record && record.lastEndReason && record.lastEndReason !== 'completed');
7113
+ if (!completed) self.completionChecks.delete(id);
7114
+ else {
7115
+ if (!self.completionChecks.has(id)) self.completionChecks.set(id, self.now());
7116
+ if (self.completionQueue.get(id).confirmed || self.now() - self.completionChecks.get(id) >= COMPLETION_SETTLE_MS) {
7117
+ completedSessionIds.push(id);
7118
+ }
7119
+ }
6902
7120
  if (status && status.completionUnread === true) {
6903
7121
  unreadDetail.candidates += 1;
6904
7122
  var entry = (catalog.byId || {})[id];
@@ -6921,14 +7139,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6921
7139
  }
6922
7140
  var pending = status ? status.pendingInteraction != null : false;
6923
7141
  if (pending) {
6924
- waitingCount++;
6925
- var kind = pendingInteractionKind(status.pendingInteraction);
6926
- // Ranked rather than first-wins: a global-scope pet watching ten sessions must not report
6927
- // "a question" just because that session happens to come out of the map first when another one
6928
- // is blocked on a permission prompt.
6929
- if (waitingKind === null || PENDING_KIND_RANK.indexOf(kind) < PENDING_KIND_RANK.indexOf(waitingKind)) {
6930
- waitingKind = kind;
6931
- }
7142
+ self.attentionSessions.set(id, pendingInteractionKind(status.pendingInteraction));
6932
7143
  }
6933
7144
  // One rule for "this is the user's own session", shared with the two completion paths (`mainSession`).
6934
7145
  if (self.mainSession(id) && self.running(id) === true) {
@@ -6939,7 +7150,23 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6939
7150
  }
6940
7151
  });
6941
7152
 
7153
+ this.attentionSessions.forEach(function (kind, id) {
7154
+ waitingCount++;
7155
+ if (waitingKind === null || PENDING_KIND_RANK.indexOf(kind) < PENDING_KIND_RANK.indexOf(waitingKind)) {
7156
+ waitingKind = kind;
7157
+ waitingSessionId = id;
7158
+ }
7159
+ });
7160
+ completedSessionIds.sort(function (a, b) { return self.completionQueue.get(a).at - self.completionQueue.get(b).at; });
7161
+ completedUnreadCount = completedSessionIds.length;
7162
+ completedSessionId = completedSessionIds[0] || null;
7163
+ var waitingSessionIds = Array.from(this.attentionSessions.keys()).sort(function (a, b) {
7164
+ return PENDING_KIND_RANK.indexOf(self.attentionSessions.get(a)) - PENDING_KIND_RANK.indexOf(self.attentionSessions.get(b));
7165
+ });
6942
7166
  var ready = catalog.phase === 'ready';
7167
+ self.completionChecks.forEach(function (_at, id) {
7168
+ if (!ids.has(id) || !self.inScope(id)) self.completionChecks.delete(id);
7169
+ });
6943
7170
 
6944
7171
  // Availability is "there is a session whose activity this pet could show".
6945
7172
  // In `current` scope that must be proved by the selected session actually
@@ -6952,7 +7179,12 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6952
7179
 
6953
7180
  var hasAnything = ready || workingCount > 0 || waitingCount > 0 || Boolean(this.notice);
6954
7181
  var available = this.input.connected === true && hasAnything && hasSelectedSession;
6955
- var notice = available && this.notice ? this.notice : undefined;
7182
+ var queuedNotice;
7183
+ this.completionQueue.forEach(function (entry, id) {
7184
+ if (!queuedNotice && entry.announce && self.mainSession(id) && self.inScope(id) && self.running(id) === false) queuedNotice = entry;
7185
+ });
7186
+ var notice = available ? (this.notice && this.notice.reason === 'error' ? this.notice
7187
+ : (queuedNotice || (this.notice && !this.viewedCompletions.has(this.notice.sessionId) ? this.notice : undefined))) : undefined;
6956
7188
  /**
6957
7189
  * `connected` rides along, because 离线 is decided by the CONNECTION and not by `available`.
6958
7190
  *
@@ -7008,6 +7240,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
7008
7240
  pendingKind: waitingCount > 0 ? waitingKind : null,
7009
7241
  workingCount: workingCount,
7010
7242
  waitingCount: waitingCount,
7243
+ waitingSessionId: waitingSessionId,
7244
+ waitingSessionIds: waitingSessionIds,
7011
7245
  /**
7012
7246
  * Sessions whose completion is still UNREAD (see above), and whether there are any.
7013
7247
  *
@@ -7015,6 +7249,10 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
7015
7249
  * surface could say more later without another plumbing round.
7016
7250
  */
7017
7251
  unreadCount: unreadCount,
7252
+ completedUnreadCount: completedUnreadCount,
7253
+ completedSessionId: completedSessionId,
7254
+ completedSessionIds: completedSessionIds,
7255
+ viewedCompletions: Array.from(this.viewedCompletions),
7018
7256
  hasUnread: unreadCount > 0,
7019
7257
  /** The breakdown behind that count — see `unreadDetail` where it is built. */
7020
7258
  unreadDetail: unreadDetail,