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.
package/lib/core.js CHANGED
@@ -1179,9 +1179,8 @@
1179
1179
  }
1180
1180
 
1181
1181
  /**
1182
- * Which full-deflection distance to use: the SETTING when it is positive, otherwise the caller's own
1183
- * frame. Keeping that decision here rather than in each surface is what keeps "0 = 自动" meaning the
1184
- * same thing in the floating window (the screen's width) and in the app overlay (its window's width).
1182
+ * A positive setting overrides automatic distance. Both axes and both surfaces
1183
+ * pass the screen width here; automatic distance is half of that width.
1185
1184
  */
1186
1185
  function resolveGazeSpan(span, fallback) {
1187
1186
  var wanted = Number(span);
@@ -1189,7 +1188,7 @@
1189
1188
  return clamp(wanted, GAZE_SPAN_RANGE.min, GAZE_SPAN_RANGE.max);
1190
1189
  }
1191
1190
  var base = Number(fallback);
1192
- return isFiniteNumber(base) && base > 0 ? base : GAZE_SPAN.x;
1191
+ return isFiniteNumber(base) && base > 0 ? base / 2 : GAZE_SPAN.x;
1193
1192
  }
1194
1193
 
1195
1194
  /**
@@ -1211,7 +1210,9 @@
1211
1210
  * Pure numbers, no DOM, so the logic suite covers the clamping and the signs.
1212
1211
  *
1213
1212
  * @param {{pointerX:number, pointerY:number, centreX:number, centreY:number,
1214
- * spanX?:number, spanY?:number, curve?:number}} input
1213
+ * spanX?:number, spanY?:number, curve?:number, curveY?:number,
1214
+ * curveRadial?:number, curveSpatial?:number, distance?:number,
1215
+ * mode?:'axes'|'radial'|'spatial'}} input
1215
1216
  * @returns {{x:number, y:number}|null} `null` when the input is unusable; `y` is
1216
1217
  * positive BELOW the pet, i.e. in screen coordinates
1217
1218
  */
@@ -1230,12 +1231,39 @@
1230
1231
  : GAZE_SPAN.y;
1231
1232
  // The settings' curve, unless the caller passes its own (the suite does, to pin a shape).
1232
1233
  var edgeSlope = isFiniteNumber(Number(input.curve)) ? Number(input.curve) : DEFAULTS.gazeCurve;
1234
+ var slopeY = isFiniteNumber(Number(input.curveY)) ? Number(input.curveY) : edgeSlope;
1235
+ if (input.mode === 'spatial') {
1236
+ var spatialX = (pointerX - centreX) / spanX;
1237
+ var spatialY = (pointerY - centreY) / spanY;
1238
+ if (!isFiniteNumber(spatialX) || !isFiniteNumber(spatialY)) return null;
1239
+ var depth = isFiniteNumber(Number(input.distance)) && Number(input.distance) > 0
1240
+ ? clamp(Number(input.distance), 0.1, 5) : DEFAULTS.gazeDistance;
1241
+ // A virtual target plane in span units: sideways distance reduces pitch
1242
+ // at a fixed pointer height. Curves shape angles, rather than pixel offsets.
1243
+ var yawAngle = Math.atan2(spatialX, depth) / (Math.PI / 2);
1244
+ var pitchAngle = Math.atan2(spatialY, Math.hypot(spatialX, depth)) / (Math.PI / 2);
1245
+ var spatialSlope = isFiniteNumber(Number(input.curveSpatial)) ? Number(input.curveSpatial) : DEFAULTS.gazeCurveSpatial;
1246
+ return {
1247
+ x: (yawAngle < 0 ? -1 : 1) * gazeCurve(Math.abs(yawAngle), spatialSlope),
1248
+ y: (pitchAngle < 0 ? -1 : 1) * gazeCurve(Math.abs(pitchAngle), spatialSlope),
1249
+ };
1250
+ }
1251
+ if (input.mode === 'radial') {
1252
+ var dx = (pointerX - centreX) / spanX;
1253
+ var dy = (pointerY - centreY) / spanY;
1254
+ var distance = Math.hypot(dx, dy);
1255
+ if (!isFiniteNumber(distance)) return null;
1256
+ if (distance === 0) return { x: 0, y: 0 };
1257
+ var radialSlope = isFiniteNumber(Number(input.curveRadial)) ? Number(input.curveRadial) : edgeSlope;
1258
+ var gain = gazeCurve(Math.min(1, distance), radialSlope) / distance;
1259
+ return { x: dx * gain, y: dy * gain };
1260
+ }
1233
1261
  var rawX = clamp((pointerX - centreX) / spanX, -1, 1);
1234
1262
  var rawY = clamp((pointerY - centreY) / spanY, -1, 1);
1235
1263
  // Mirrored through `gazeCurve` so the sign survives and the response is the curve on both sides.
1236
1264
  return {
1237
1265
  x: rawX < 0 ? -gazeCurve(-rawX, edgeSlope) : gazeCurve(rawX, edgeSlope),
1238
- y: rawY < 0 ? -gazeCurve(-rawY, edgeSlope) : gazeCurve(rawY, edgeSlope)
1266
+ y: rawY < 0 ? -gazeCurve(-rawY, slopeY) : gazeCurve(rawY, slopeY)
1239
1267
  };
1240
1268
  }
1241
1269
 
@@ -1348,12 +1376,21 @@
1348
1376
  * operator's own (2026-10-13: 「视线:曲率设置默认为0.1,提供0.05-1的选择(原先是0.1-1),静止停看默认为2秒」).
1349
1377
  * `gazeCurve` is the curve's slope at the frame's edge — 1 is a straight line, smaller is a
1350
1378
  * steeper start and a flatter tail — and `gazeSpanX`/`gazeSpanY` are the full-deflection
1351
- * distances in CSS pixels, where **0 means AUTO**: the caller's own frame (the screen's width in
1352
- * the floating window, the window's width in the app overlay) and, vertically, the fallback below.
1379
+ * distances in CSS pixels, where **0 means AUTO**: half the screen width on both axes.
1380
+ * `gazeCurve` / `gazeCurveY` remain readable for older delivered clients; the current
1381
+ * settings expose only spatial (default) and radial modes.
1353
1382
  */
1354
1383
  gazeCurve: 0.1,
1384
+ gazeCurveMode: 'spatial',
1385
+ gazeCurveY: 0.1,
1386
+ gazeCurveRadial: 0.1,
1387
+ gazeCurveSpatial: 1,
1388
+ gazeDistance: 0.1,
1355
1389
  gazeSpanX: 0,
1356
1390
  gazeSpanY: 0,
1391
+ // Actual head pitch in degrees, after the renderer's gaze weight.
1392
+ gazePitchUp: 60,
1393
+ gazePitchDown: 60,
1357
1394
  /**
1358
1395
  * How long the cursor may sit still before the pet stops following it, in seconds.
1359
1396
  *
@@ -1465,6 +1502,7 @@
1465
1502
  * not silently pin drag and gravity to the numbers the panel happened to be showing.
1466
1503
  */
1467
1504
  springs: {},
1505
+ toon: {},
1468
1506
  /**
1469
1507
  * Per-model size multiplier for BOTH texts: `{ [modelId]: number }`.
1470
1508
  *
@@ -2199,12 +2237,25 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
2199
2237
  out.gazeCurve = Number.isFinite(Number(source.gazeCurve))
2200
2238
  ? clamp(Number(source.gazeCurve), GAZE_CURVE_RANGE.min, GAZE_CURVE_RANGE.max)
2201
2239
  : DEFAULTS.gazeCurve;
2240
+ out.gazeCurveMode = source.gazeCurveMode === 'radial' ? 'radial' : 'spatial';
2241
+ ['gazeCurveY', 'gazeCurveRadial'].forEach(function (key) {
2242
+ out[key] = typeof source[key] === 'number' && Number.isFinite(source[key])
2243
+ ? clamp(source[key], GAZE_CURVE_RANGE.min, GAZE_CURVE_RANGE.max) : DEFAULTS[key];
2244
+ });
2245
+ out.gazeCurveSpatial = typeof source.gazeCurveSpatial === 'number' && Number.isFinite(source.gazeCurveSpatial)
2246
+ ? clamp(source.gazeCurveSpatial, GAZE_CURVE_RANGE.min, GAZE_CURVE_RANGE.max) : DEFAULTS.gazeCurveSpatial;
2247
+ out.gazeDistance = typeof source.gazeDistance === 'number' && Number.isFinite(source.gazeDistance)
2248
+ ? clamp(source.gazeDistance, 0.1, 5) : DEFAULTS.gazeDistance;
2202
2249
  out.gazeSpanX = Number.isFinite(Number(source.gazeSpanX))
2203
2250
  ? clamp(Number(source.gazeSpanX), GAZE_SPAN_RANGE.min, GAZE_SPAN_RANGE.max)
2204
2251
  : DEFAULTS.gazeSpanX;
2205
2252
  out.gazeSpanY = Number.isFinite(Number(source.gazeSpanY))
2206
2253
  ? clamp(Number(source.gazeSpanY), GAZE_SPAN_RANGE.min, GAZE_SPAN_RANGE.max)
2207
2254
  : DEFAULTS.gazeSpanY;
2255
+ ['gazePitchUp', 'gazePitchDown'].forEach(function (key) {
2256
+ out[key] = typeof source[key] === 'number' && Number.isFinite(source[key])
2257
+ ? clamp(source[key], 0, 60) : DEFAULTS[key];
2258
+ });
2208
2259
  // 0 is a legitimate value here — it turns the idle rule off — so this cannot fall back to the default the
2209
2260
  // way an unparseable value does. A negative number is unparseable for this purpose and becomes 0.
2210
2261
  out.gazeIdleSeconds = Number.isFinite(Number(source.gazeIdleSeconds))
@@ -2259,6 +2310,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
2259
2310
  ? source.expressions.emotion.parts
2260
2311
  : null);
2261
2312
  out.springs = cleanSprings(source.springs);
2313
+ out.toon = cleanToon(source.toon);
2262
2314
  return out;
2263
2315
  }
2264
2316
 
@@ -2269,6 +2321,25 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
2269
2321
  * directory's manifest says `loop: false`) has to be OVERRIDABLE back to looping, and "no entry" cannot
2270
2322
  * express that — absence means "ask the catalog".
2271
2323
  */
2324
+ function cleanToon(raw) {
2325
+ var out = {};
2326
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
2327
+ Object.keys(raw).slice(0, 256).forEach(function (id) {
2328
+ if (id === '__proto__' || id === 'constructor' || id === 'prototype') return;
2329
+ var entry = raw[id];
2330
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return;
2331
+ var next = {};
2332
+ if (typeof entry.enabled === 'boolean') next.enabled = entry.enabled;
2333
+ var ranges = { shadingToonyFactor: [0, 1], shadingShiftFactor: [-1, 1], shadeStrength: [0, 1], outlineScale: [0, 3] };
2334
+ Object.keys(ranges).forEach(function (key) {
2335
+ var value = entry[key];
2336
+ if (typeof value === 'number' && Number.isFinite(value)) next[key] = clamp(value, ranges[key][0], ranges[key][1]);
2337
+ });
2338
+ if (Object.keys(next).length) out[id] = next;
2339
+ });
2340
+ return out;
2341
+ }
2342
+
2272
2343
  function cleanMotionLoop(raw) {
2273
2344
  var out = {};
2274
2345
  var source = raw && typeof raw === 'object' ? raw : {};
@@ -3552,6 +3623,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
3552
3623
  * @returns {string|null} the line to show, or null for silence
3553
3624
  */
3554
3625
  SpeechMachine.prototype.observe = function (preferences, state, pendingKind, language, random, motion) {
3626
+ if (state === 'dancing') {
3627
+ this.current = null;
3628
+ this.wantsAi = null;
3629
+ this.lastEvent = null;
3630
+ this.idleAt = null;
3631
+ this.chatterAt = null;
3632
+ return null;
3633
+ }
3555
3634
  var speech = cleanSpeech(preferences && preferences.speech);
3556
3635
  var t = this.now();
3557
3636
  if (!speech.enabled) {
@@ -3816,7 +3895,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
3816
3895
  *
3817
3896
  * @returns {{state: string, text: string, tag: string, animation: string, seconds: number}|null}
3818
3897
  */
3819
- function statusFor(preferences, state, enteredAt, now, clock, language) {
3898
+ function statusFor(preferences, state, enteredAt, now, clock, language, completedCount) {
3820
3899
  if (STATUS_STATES.indexOf(state) === -1) return null;
3821
3900
  var entry = statusEntryFor(preferences, state, language);
3822
3901
  if (!entry.text && !entry.tag) return null;
@@ -3873,6 +3952,9 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
3873
3952
  ? durationClauseRemoved(text)
3874
3953
  : text.split(STATUS_DURATION_TOKEN).join(formatRunDuration(elapsed, language));
3875
3954
  }
3955
+ if (state === 'done' && Number(completedCount) > 1 && text) {
3956
+ text = language === 'en' ? (text === 'Task complete' ? 'All tasks complete' : 'All ' + text) : '全部' + text;
3957
+ }
3876
3958
  var look = statusTone(state);
3877
3959
  return {
3878
3960
  state: state,
@@ -4906,6 +4988,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
4906
4988
  * @param {object} options url, intervalMs, fetchImpl, onPreview(preview|null), onUnsupported
4907
4989
  */
4908
4990
  function watchHostPreview(options) {
4991
+ var navigationSeen = null;
4909
4992
  var settings = options || {};
4910
4993
  var url = settings.url || '/vrmpet3d/preview';
4911
4994
  // Four hundred milliseconds: this drives a hover preview, so it has to feel immediate, and the route is a
@@ -4914,11 +4997,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
4914
4997
  var doFetch = settings.fetchImpl || (typeof fetch === 'function' ? fetch : null);
4915
4998
  var timer = null;
4916
4999
  var seen = -1;
5000
+ var issued = 0;
5001
+ var delivered = 0;
4917
5002
  var supported = Boolean(doFetch);
4918
5003
  var stopped = false;
4919
5004
 
4920
5005
  function load() {
4921
5006
  if (!supported || stopped) return Promise.resolve(null);
5007
+ var request = ++issued;
4922
5008
  return doFetch(url, { credentials: 'omit' })
4923
5009
  .then(function (response) {
4924
5010
  if (response.status === 404 || response.status === 405) {
@@ -4930,6 +5016,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
4930
5016
  return response.json ? response.json() : null;
4931
5017
  })
4932
5018
  .then(function (body) {
5019
+ if (request < delivered) return null;
5020
+ if (body && typeof body.seq === 'number') delivered = request;
5021
+ if (body && body.navigation && typeof body.navigation.seq === 'number') {
5022
+ var navigation = body.navigation;
5023
+ if (navigationSeen !== null && navigationSeen !== navigation.seq && typeof navigation.sessionId === 'string'
5024
+ && typeof settings.onNavigate === 'function') settings.onNavigate(navigation.sessionId);
5025
+ navigationSeen = navigation.seq;
5026
+ }
4933
5027
  if (!body || typeof body.seq !== 'number' || body.seq === seen) return null;
4934
5028
  seen = body.seq;
4935
5029
  var preview = cleanPreview(body.preview);
@@ -5067,11 +5161,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5067
5161
  var doFetch = settings.fetchImpl || (typeof fetch === 'function' ? fetch : null);
5068
5162
  var timer = null;
5069
5163
  var seen = -1;
5164
+ var issued = 0;
5165
+ var delivered = 0;
5070
5166
  var supported = Boolean(doFetch);
5071
5167
  var stopped = false;
5072
5168
 
5073
5169
  function load() {
5074
5170
  if (!supported || stopped) return Promise.resolve(null);
5171
+ var request = ++issued;
5075
5172
  return doFetch(url, { credentials: 'omit' })
5076
5173
  .then(function (response) {
5077
5174
  if (response.status === 404 || response.status === 405) {
@@ -5083,6 +5180,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5083
5180
  return response.json ? response.json() : null;
5084
5181
  })
5085
5182
  .then(function (body) {
5183
+ if (request < delivered) return null;
5184
+ if (body && typeof body.seq === 'number') delivered = request;
5086
5185
  if (!body || typeof body.seq !== 'number' || body.seq === seen) return null;
5087
5186
  seen = body.seq;
5088
5187
  var state = STATES.indexOf(body.state) === -1 ? null : body.state;
@@ -5192,6 +5291,12 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5192
5291
  }
5193
5292
  if (typeof why.scope === 'string' && why.scope) out.scope = why.scope.slice(0, 16);
5194
5293
  if (typeof why.session === 'string' && why.session) out.session = why.session.slice(0, 64);
5294
+ if (typeof why.statusSessionId === 'string' && why.statusSessionId) out.statusSessionId = why.statusSessionId.slice(0, 64);
5295
+ if (typeof why.completedSessionId === 'string' && why.completedSessionId) out.completedSessionId = why.completedSessionId.slice(0, 64);
5296
+ ['waitingSessionIds', 'completedSessionIds'].forEach(function (key) {
5297
+ if (Array.isArray(why[key])) out[key] = why[key].filter(function (id) { return typeof id === 'string' && id; })
5298
+ .slice(0, 256).map(function (id) { return id.slice(0, 64); });
5299
+ });
5195
5300
  if (typeof why.interaction === 'string' && why.interaction) out.interaction = why.interaction.slice(0, 16);
5196
5301
  if (Array.isArray(why.runningIds)) {
5197
5302
  out.runningIds = why.runningIds
@@ -5349,15 +5454,19 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5349
5454
  * its 进行中 line has to travel with the state. A whole number, or nothing — an older host that does not send it
5350
5455
  * leaves the window's dot as it was rather than inventing a count.
5351
5456
  */
5352
- var nextUnread = Number.isFinite(Number(unread)) && Number(unread) > 0 ? Math.floor(Number(unread)) : null;
5457
+ var nextUnread = typeof unread === 'number' && Number.isFinite(unread) && unread >= 0 ? Math.floor(unread) : null;
5353
5458
  /**
5354
5459
  * The diagnostic, kept OUT of the change key on purpose: it carries counts and clock readings that move
5355
5460
  * constantly, and letting them force a report would turn "the pet announces a change" into a 1 Hz feed. It
5356
5461
  * rides along with the reports the state and the line already earn, and the heartbeat re-states the last one.
5357
5462
  */
5358
5463
  var nextWhy = cleanWhy(why);
5464
+ var navigationKey = function (value) {
5465
+ return JSON.stringify(value ? [value.waitingSessionIds || [], value.completedSessionIds || [],
5466
+ value.statusSessionId || null, value.completedSessionId || null] : [[], [], null, null]);
5467
+ };
5359
5468
  if (force !== true && next === sent && nextLine === sentLine && nextTouchKey === sentTouchKey
5360
- && nextClockKey === sentClockKey && nextUnread === sentUnread) {
5469
+ && nextClockKey === sentClockKey && nextUnread === sentUnread && navigationKey(nextWhy) === navigationKey(sentWhy)) {
5361
5470
  return Promise.resolve(null);
5362
5471
  }
5363
5472
  sent = next;
@@ -5482,10 +5591,15 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5482
5591
  var doFetch = settings.fetchImpl || (typeof fetch === 'function' ? fetch : null);
5483
5592
  var timer = null;
5484
5593
  var seen = -1;
5594
+ var issued = 0;
5595
+ var delivered = 0;
5485
5596
  var supported = Boolean(doFetch);
5486
5597
  var stopped = false;
5487
5598
 
5488
- function deliver(snapshot) {
5599
+ function deliver(snapshot, request) {
5600
+ // A slow poll/write must not roll a live gesture back to an older reply.
5601
+ if (request < delivered) return null;
5602
+ if (snapshot && typeof snapshot.version === 'number') delivered = request;
5489
5603
  if (!snapshot || typeof snapshot.version !== 'number' || snapshot.version === seen) return null;
5490
5604
  seen = snapshot.version;
5491
5605
  if (typeof settings.onSnapshot === 'function') settings.onSnapshot(snapshot);
@@ -5495,6 +5609,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5495
5609
  /** One look at the host. Returns a promise for the snapshot it delivered, if any. */
5496
5610
  function load() {
5497
5611
  if (!supported || stopped) return Promise.resolve(null);
5612
+ var request = ++issued;
5498
5613
  return doFetch(url, { credentials: 'omit' })
5499
5614
  .then(function (response) {
5500
5615
  if (response.status === 404 || response.status === 405) {
@@ -5506,7 +5621,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5506
5621
  if (!response.ok) return null;
5507
5622
  return response.json ? response.json() : null;
5508
5623
  })
5509
- .then(deliver)
5624
+ .then(function (snapshot) { return deliver(snapshot, request); })
5510
5625
  .catch(function () { return null; });
5511
5626
  }
5512
5627
 
@@ -5518,6 +5633,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5518
5633
  */
5519
5634
  function push(body) {
5520
5635
  if (!supported || stopped) return Promise.resolve(null);
5636
+ var request = ++issued;
5521
5637
  return doFetch(url, {
5522
5638
  method: 'POST',
5523
5639
  credentials: 'omit',
@@ -5525,7 +5641,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5525
5641
  body: JSON.stringify(body || {}),
5526
5642
  })
5527
5643
  .then(function (response) { return response.ok ? response.json() : null; })
5528
- .then(deliver)
5644
+ .then(function (snapshot) { return deliver(snapshot, request); })
5529
5645
  .catch(function () { return null; });
5530
5646
  }
5531
5647
 
@@ -5738,6 +5854,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
5738
5854
  * sits above the watermark and is adopted as usual.
5739
5855
  */
5740
5856
  this.dismissedNoticeAt = 0;
5857
+ this.dismissedSessions = new Map();
5741
5858
  /**
5742
5859
  * The TURN the pet is currently describing, and the one that just ended.
5743
5860
  *
@@ -6020,6 +6137,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6020
6137
  PetMachine.prototype.dismissNotice = function (keepDebt) {
6021
6138
  if (this.notice && Number.isFinite(this.notice.at)) {
6022
6139
  this.dismissedNoticeAt = Math.max(this.dismissedNoticeAt, this.notice.at);
6140
+ if (this.notice.sessionId) this.dismissedSessions.set(this.notice.sessionId, this.notice.at);
6023
6141
  }
6024
6142
  if (keepDebt !== true && this.notice && this.notice.sessionId) {
6025
6143
  delete this.retractions[this.notice.sessionId];
@@ -6142,7 +6260,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6142
6260
  * well as in the acknowledgement rule in `resolve`. (The floating window never takes focus, which is why the same
6143
6261
  * drag could not do this there.)
6144
6262
  */
6145
- if (this.notice && (!this.notice.sessionId || !presenceKnown) && this.interaction === null) {
6263
+ if (this.notice && !this.notice.sessionId && !presenceKnown && this.interaction === null) {
6146
6264
  this.noteDismissal('focus-return');
6147
6265
  this.dismissNotice();
6148
6266
  }
@@ -6254,6 +6372,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6254
6372
  // reports as its own `at`, so the minimum hold starts when the user can first see it.
6255
6373
  endedAt: at,
6256
6374
  settledAt: this.now(),
6375
+ confirmed: payload.confirmed === true,
6257
6376
  sessionId: sessionId,
6258
6377
  };
6259
6378
  }
@@ -6279,8 +6398,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6279
6398
  * than a startup that has not finished.
6280
6399
  */
6281
6400
  if (snap.connected === true) this.everConnected = true;
6282
- var busy = snap.available === true && (snap.running === true || snap.pending === true);
6401
+ var busy = snap.pending === true || (snap.available === true && snap.running === true);
6283
6402
  if (busy) this.lastBusyAt = t;
6403
+ var viewedCompletions = Array.isArray(snap.viewedCompletions) ? snap.viewedCompletions : [];
6404
+ if (this.pendingNotice && viewedCompletions.indexOf(this.pendingNotice.sessionId) !== -1) this.pendingNotice = undefined;
6405
+ if (this.notice && viewedCompletions.indexOf(this.notice.sessionId) !== -1) {
6406
+ this.noteDismissal('acknowledged');
6407
+ this.dismissNotice();
6408
+ }
6284
6409
 
6285
6410
  /**
6286
6411
  * Adopt the SNAPSHOT's own notice when it is newer than this machine's.
@@ -6299,9 +6424,11 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6299
6424
  * what the aggregate watches, and it falls between the rounds of one task exactly like a turn does.
6300
6425
  */
6301
6426
  if (snap.notice && typeof snap.notice.at === 'number'
6302
- && snap.notice.at > this.dismissedNoticeAt
6303
- && (!this.notice || snap.notice.at > this.notice.at)
6304
- && (!this.pendingNotice || snap.notice.at > this.pendingNotice.endedAt)) {
6427
+ && (snap.notice.queued === true ? (!this.notice && !this.pendingNotice
6428
+ && (!this.dismissedSessions.has(snap.notice.sessionId) || snap.notice.at > this.dismissedSessions.get(snap.notice.sessionId)))
6429
+ : (snap.notice.at > this.dismissedNoticeAt
6430
+ && (!this.notice || snap.notice.at > this.notice.at)
6431
+ && (!this.pendingNotice || snap.notice.at > this.pendingNotice.endedAt)))) {
6305
6432
  var snapSession = typeof snap.notice.sessionId === 'string' && snap.notice.sessionId ? snap.notice.sessionId : null;
6306
6433
  if (snap.notice.reason === 'error') {
6307
6434
  // A failure is not a completion and is never settled: it is shown at once, exactly as a `turn/end` failure is.
@@ -6311,7 +6438,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6311
6438
  this.pendingNotice = {
6312
6439
  reason: snap.notice.reason,
6313
6440
  endedAt: snap.notice.at,
6314
- settledAt: t,
6441
+ settledAt: snap.notice.queued === true ? snap.notice.at : t,
6442
+ confirmed: snap.notice.confirmed === true,
6315
6443
  // The session the notice is ABOUT. Without it there is nothing to come back to, and the notice falls
6316
6444
  // back to the older focus/timer rules below.
6317
6445
  sessionId: snapSession,
@@ -6327,6 +6455,9 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6327
6455
  * (a `turn/end` frame and the store's falling edge) are decided together.
6328
6456
  */
6329
6457
  if (this.pendingNotice) {
6458
+ if (snap.notice && snap.notice.sessionId === this.pendingNotice.sessionId && snap.notice.confirmed === true) {
6459
+ this.pendingNotice.confirmed = true;
6460
+ }
6330
6461
  var pendingSession = this.pendingNotice.sessionId;
6331
6462
  /**
6332
6463
  * Cancelled by WORK RESUMING, and only that — the ordering is the whole point.
@@ -6349,7 +6480,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6349
6480
  * never produced one.
6350
6481
  */
6351
6482
  this.pendingNotice = undefined;
6352
- } else if (t - this.pendingNotice.settledAt >= this.settleFor(this.pendingNotice.sessionId)) {
6483
+ } else if (this.pendingNotice.confirmed || t - this.pendingNotice.settledAt >= this.settleFor(this.pendingNotice.sessionId)) {
6353
6484
  this.notice = {
6354
6485
  reason: this.pendingNotice.reason,
6355
6486
  // Visible from NOW: the settle window was spent waiting, and a hold measured from the turn's end would
@@ -6425,8 +6556,10 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6425
6556
  */
6426
6557
  var unreadDropped = isFiniteNumber(this.unreadAtNotice) && this.unreadCount < this.unreadAtNotice;
6427
6558
  var neverUnread = this.unreadAtNotice === 0 && this.unreadCount === 0;
6428
- acknowledged = notice.sessionId && presentId
6429
- ? (presentId === notice.sessionId && this.focused === true)
6559
+ var viewed = Array.isArray(snap.viewedCompletions) && snap.viewedCompletions.indexOf(notice.sessionId) !== -1;
6560
+ acknowledged = notice.sessionId
6561
+ ? (viewed || ((!this.requireViewedAcknowledgement || notice.reason !== 'completed')
6562
+ && presentId === notice.sessionId && this.focused === true))
6430
6563
  : (notice.reason !== 'error' && this.focused === true
6431
6564
  && (unreadDropped || (neverUnread && this.interaction === null)));
6432
6565
  this.pendingDismissalKind = notice.sessionId && presentId ? 'acknowledged' : 'no-session';
@@ -6448,8 +6581,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6448
6581
  * still on screen would flip the pet to 出错 and hide the very prompt the user has to answer, and a
6449
6582
  * turn that completes would show 任务达成 over an unanswered approval.
6450
6583
  *
6451
- * So while the session reports a pending interaction, EVERY other branch is skipped except the two the
6452
- * user owns by hand (a drag, and a click they just made). When the interaction is answered the session
6584
+ * While the session reports a pending interaction, EVERY other state is skipped, including gestures
6585
+ * and greetings. When the interaction is answered the session
6453
6586
  * stops reporting it, this latch releases, and the same frame's cascade decides where the pet goes.
6454
6587
  */
6455
6588
  var pending = snap.pending === true;
@@ -6476,7 +6609,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6476
6609
  /**
6477
6610
  * 跳舞 — the user's own state, and the two rules that make it a good guest (operator, 2026-10-14).
6478
6611
  *
6479
- * It sits here, AFTER the drag states and BEFORE greeting/waiting/offline — so a drag still owns the pet while it happens —
6612
+ * Attention comes first, followed by drag states, dance, greeting and offline.
6480
6613
  * and it is dropped the moment the session is BUSY, which is the operator's 「跳舞也会被进入工作状态打断」 without a rule of its
6481
6614
  * own: work outranks it because the busy branch below is reached first once this field is cleared. A dance also ends when the
6482
6615
  * clip ends (the client watches the clip and calls `stopDancing`) or when the user double-clicks the pet, and both of those
@@ -6487,14 +6620,14 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6487
6620
  this.stopDancing();
6488
6621
  dance = null;
6489
6622
  }
6490
- if (this.interaction) {
6623
+ if (pending) {
6624
+ state = 'waiting';
6625
+ } else if (this.interaction) {
6491
6626
  state = this.interaction;
6492
6627
  } else if (dance) {
6493
6628
  state = 'dancing';
6494
6629
  } else if (t < this.greetingUntil) {
6495
6630
  state = 'greeting';
6496
- } else if (pending) {
6497
- state = 'waiting';
6498
6631
  } else if (snap.connected === false && this.everConnected) {
6499
6632
  /**
6500
6633
  * 离线 means the host is GONE — not that it has nothing to say yet.
@@ -6594,6 +6727,11 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6594
6727
  this.records = new Map();
6595
6728
  /** Each session's own `running` flag as the store last reported it (see `observeStoreEdge`). */
6596
6729
  this.storeRunning = new Map();
6730
+ this.completionChecks = new Map();
6731
+ this.attentionSessions = new Map();
6732
+ this.completionQueue = new Map();
6733
+ this.completionSeen = new Set();
6734
+ this.viewedCompletions = new Set();
6597
6735
  /**
6598
6736
  * When this aggregate's `running` answer last became true, and what it was last published as.
6599
6737
  *
@@ -6653,6 +6791,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6653
6791
  var status = statuses && statuses.get ? statuses.get(id) : undefined;
6654
6792
  var running = status && typeof status.running === 'boolean' ? status.running : undefined;
6655
6793
  if (running === undefined) return;
6794
+ if (running || status.pendingInteraction != null) self.completionChecks.delete(id);
6795
+ if (running) self.forgetCompletion(id);
6656
6796
  var previous = self.storeRunning.get(id);
6657
6797
  self.storeRunning.set(id, running);
6658
6798
  if (previous === running) return;
@@ -6676,13 +6816,18 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6676
6816
  * stream covers that case when it is wired, and a false 任务完成 on every boot is worse than a missed one.
6677
6817
  */
6678
6818
  if (previous !== true) return;
6819
+ var boundaryRecord = self.records.get(id);
6820
+ if (boundaryRecord && boundaryRecord.lastEndReason && boundaryRecord.lastEndReason !== 'completed') return;
6679
6821
  // Stopped: a completion. A failure already latched outranks it, the same rule the boundary path uses.
6822
+ var completion = { id: id + ':store', reason: 'completed', sessionId: id, at: self.now() };
6823
+ self.rememberCompletion(completion);
6680
6824
  if (self.notice && self.notice.reason === 'error') return;
6681
- self.notice = { id: id + ':store', reason: 'completed', sessionId: id, at: self.now() };
6825
+ self.notice = completion;
6682
6826
  });
6683
6827
  };
6684
6828
 
6685
6829
  SessionAggregate.prototype.reset = function (identities) {
6830
+ this.completionChecks.clear();
6686
6831
  this.records.clear();
6687
6832
  this.notice = undefined;
6688
6833
  if (Array.isArray(identities)) {
@@ -6696,6 +6841,34 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6696
6841
  }
6697
6842
  };
6698
6843
 
6844
+ SessionAggregate.prototype.rememberCompletion = function (notice, announce) {
6845
+ var id = notice && notice.sessionId;
6846
+ if (!id || this.completionSeen.has(id)) return;
6847
+ this.completionSeen.add(id);
6848
+ this.completionQueue.set(id, Object.assign({}, notice, {
6849
+ announce: announce !== false, queued: true, confirmed: announce !== false,
6850
+ }));
6851
+ this.completionChecks.set(id, this.now());
6852
+ };
6853
+
6854
+ SessionAggregate.prototype.forgetCompletion = function (id) {
6855
+ this.completionQueue.delete(id);
6856
+ this.completionChecks.delete(id);
6857
+ this.completionSeen.delete(id);
6858
+ this.viewedCompletions.delete(id);
6859
+ };
6860
+
6861
+ SessionAggregate.prototype.acknowledgeViewed = function (id) {
6862
+ if (!id || this.running(id) !== false) return;
6863
+ var status = this.input.statuses.get ? this.input.statuses.get(id) : null;
6864
+ if (!this.completionQueue.has(id) && !(status && status.completionUnread === true)) return;
6865
+ this.completionSeen.add(id);
6866
+ this.viewedCompletions.add(id);
6867
+ this.completionQueue.delete(id);
6868
+ this.completionChecks.delete(id);
6869
+ if (this.notice && this.notice.sessionId === id && this.notice.reason === 'completed') this.notice = undefined;
6870
+ };
6871
+
6699
6872
  SessionAggregate.prototype.boundary = function (event) {
6700
6873
  if (!event || typeof event !== 'object') return this.snapshot();
6701
6874
  var id = event.sessionId;
@@ -6722,12 +6895,16 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6722
6895
  // The host's live answer for a session it vouches for: every root it classified arrives as `false`.
6723
6896
  if (event.isSubagent === false) record.isSubagent = false;
6724
6897
  if (event.type === 'turn/start') {
6898
+ this.forgetCompletion(id);
6725
6899
  record.running = true;
6900
+ record.lastEndReason = null;
6901
+ this.completionChecks.delete(id);
6726
6902
  record.candidate = undefined;
6727
6903
  // A new turn supersedes whatever the last one reported, error included.
6728
6904
  this.notice = undefined;
6729
6905
  } else if (event.type === 'turn/end') {
6730
6906
  record.running = false;
6907
+ record.lastEndReason = event.reason;
6731
6908
  record.candidate = { id: id + ':' + event.seq, reason: event.reason, at: this.now() };
6732
6909
  }
6733
6910
  this.records.set(id, record);
@@ -6737,7 +6914,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6737
6914
  * a frame for a session nobody can vouch for — not classified, not listed by the app — is silence rather than a
6738
6915
  * 任务达成, because the pet has no way to tell it from a helper's turn ending (2026-10-14).
6739
6916
  */
6740
- if (candidate && (candidate.reason === 'completed' || record.running === false) && this.mainSession(id)) {
6917
+ if (candidate && (candidate.reason === 'completed' || candidate.reason === 'error') && this.mainSession(id)) {
6918
+ if (candidate.reason === 'completed') this.rememberCompletion({ id: candidate.id, reason: candidate.reason, sessionId: id, at: candidate.at });
6741
6919
  record.candidate = undefined;
6742
6920
  // A failure latches: a success arriving moments later must not erase the
6743
6921
  // fact that a turn just died. The machine owns the same rule, so the two
@@ -6758,6 +6936,19 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6758
6936
  };
6759
6937
 
6760
6938
  SessionAggregate.prototype.subagent = function (id) {
6939
+ var list = this.input.catalog || {};
6940
+ var row = list.byId && list.byId[id];
6941
+ // The current DSH store publishes origin and its parents' subagent catalogs, not isSubagent.
6942
+ // These explicit identities outrank an older boundary baseline that called the agent a root.
6943
+ if (row && (row.origin === 'subagent' || row.isSubagent === true)) return true;
6944
+ var parents = Object.keys(list.byId || {});
6945
+ for (var p = 0; p < parents.length; p += 1) {
6946
+ var parent = list.byId[parents[p]];
6947
+ var projection = list.projectionsBySession && list.projectionsBySession[parents[p]];
6948
+ var values = (projection && projection.values) || (parent && parent.projectionValues);
6949
+ var children = values && values.subagentCatalog;
6950
+ if (Array.isArray(children) && children.some(function (child) { return child && child.id === id; })) return true;
6951
+ }
6761
6952
  var record = this.records.get(id);
6762
6953
  if (record && record.isSubagent !== undefined) return record.isSubagent === true;
6763
6954
  var catalog = this.input.catalog && this.input.catalog.byId ? this.input.catalog.byId[id] : undefined;
@@ -6839,7 +7030,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6839
7030
  * only ever make the answer STRICTER on a store that has the list.
6840
7031
  */
6841
7032
  var listed = catalog.ids;
6842
- if (listed && typeof listed.length === 'number' && listed.length > 0) {
7033
+ if (listed && typeof listed.length === 'number') {
6843
7034
  for (var i = 0; i < listed.length; i += 1) {
6844
7035
  if (listed[i] === id) return true;
6845
7036
  }
@@ -6857,6 +7048,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6857
7048
 
6858
7049
  var workingCount = 0;
6859
7050
  var waitingCount = 0;
7051
+ var waitingSessionId = null;
6860
7052
  /**
6861
7053
  * How many sessions have a completion the user has NOT been back to.
6862
7054
  *
@@ -6873,6 +7065,9 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6873
7065
  * contributes nothing.
6874
7066
  */
6875
7067
  var unreadCount = 0;
7068
+ var completedUnreadCount = 0;
7069
+ var completedSessionId = null;
7070
+ var completedSessionIds = [];
6876
7071
  /**
6877
7072
  * WHY that count is what it is, as counts rather than ids or titles — added with the 绿点 fix of 2026-10-14, so
6878
7073
  * "the dot is lit and no session finished" is answerable from `/vrmpet3d/state` alone instead of by guesswork.
@@ -6888,9 +7083,32 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6888
7083
  /** Which pending interaction wins when several sessions are waiting at once. Approval is the rarer. */
6889
7084
  var waitingKind = null;
6890
7085
  var self = this;
7086
+ this.attentionSessions.forEach(function (_kind, id) {
7087
+ var status = statuses.get ? statuses.get(id) : undefined;
7088
+ // Navigating away, a missing report, or losing focus is not an answer to the problem.
7089
+ if (status) {
7090
+ if (status.pendingInteraction == null) self.attentionSessions.delete(id);
7091
+ else self.attentionSessions.set(id, pendingInteractionKind(status.pendingInteraction));
7092
+ }
7093
+ });
6891
7094
  ids.forEach(function (id) {
6892
7095
  if (!self.inScope(id)) return;
6893
7096
  var status = statuses.get ? statuses.get(id) : undefined;
7097
+ var record = self.records.get(id);
7098
+ if (status && status.completionUnread === true && self.mainSession(id) && !self.completionSeen.has(id)
7099
+ && self.running(id) === false && status.pendingInteraction == null) {
7100
+ self.rememberCompletion({ sessionId: id, reason: 'completed', at: self.now() }, false);
7101
+ }
7102
+ var completed = self.completionQueue.has(id) && !self.viewedCompletions.has(id) && self.mainSession(id)
7103
+ && self.running(id) === false && (!status || status.pendingInteraction == null)
7104
+ && !(record && record.lastEndReason && record.lastEndReason !== 'completed');
7105
+ if (!completed) self.completionChecks.delete(id);
7106
+ else {
7107
+ if (!self.completionChecks.has(id)) self.completionChecks.set(id, self.now());
7108
+ if (self.completionQueue.get(id).confirmed || self.now() - self.completionChecks.get(id) >= COMPLETION_SETTLE_MS) {
7109
+ completedSessionIds.push(id);
7110
+ }
7111
+ }
6894
7112
  if (status && status.completionUnread === true) {
6895
7113
  unreadDetail.candidates += 1;
6896
7114
  var entry = (catalog.byId || {})[id];
@@ -6913,14 +7131,7 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6913
7131
  }
6914
7132
  var pending = status ? status.pendingInteraction != null : false;
6915
7133
  if (pending) {
6916
- waitingCount++;
6917
- var kind = pendingInteractionKind(status.pendingInteraction);
6918
- // Ranked rather than first-wins: a global-scope pet watching ten sessions must not report
6919
- // "a question" just because that session happens to come out of the map first when another one
6920
- // is blocked on a permission prompt.
6921
- if (waitingKind === null || PENDING_KIND_RANK.indexOf(kind) < PENDING_KIND_RANK.indexOf(waitingKind)) {
6922
- waitingKind = kind;
6923
- }
7134
+ self.attentionSessions.set(id, pendingInteractionKind(status.pendingInteraction));
6924
7135
  }
6925
7136
  // One rule for "this is the user's own session", shared with the two completion paths (`mainSession`).
6926
7137
  if (self.mainSession(id) && self.running(id) === true) {
@@ -6931,7 +7142,23 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6931
7142
  }
6932
7143
  });
6933
7144
 
7145
+ this.attentionSessions.forEach(function (kind, id) {
7146
+ waitingCount++;
7147
+ if (waitingKind === null || PENDING_KIND_RANK.indexOf(kind) < PENDING_KIND_RANK.indexOf(waitingKind)) {
7148
+ waitingKind = kind;
7149
+ waitingSessionId = id;
7150
+ }
7151
+ });
7152
+ completedSessionIds.sort(function (a, b) { return self.completionQueue.get(a).at - self.completionQueue.get(b).at; });
7153
+ completedUnreadCount = completedSessionIds.length;
7154
+ completedSessionId = completedSessionIds[0] || null;
7155
+ var waitingSessionIds = Array.from(this.attentionSessions.keys()).sort(function (a, b) {
7156
+ return PENDING_KIND_RANK.indexOf(self.attentionSessions.get(a)) - PENDING_KIND_RANK.indexOf(self.attentionSessions.get(b));
7157
+ });
6934
7158
  var ready = catalog.phase === 'ready';
7159
+ self.completionChecks.forEach(function (_at, id) {
7160
+ if (!ids.has(id) || !self.inScope(id)) self.completionChecks.delete(id);
7161
+ });
6935
7162
 
6936
7163
  // Availability is "there is a session whose activity this pet could show".
6937
7164
  // In `current` scope that must be proved by the selected session actually
@@ -6944,7 +7171,12 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
6944
7171
 
6945
7172
  var hasAnything = ready || workingCount > 0 || waitingCount > 0 || Boolean(this.notice);
6946
7173
  var available = this.input.connected === true && hasAnything && hasSelectedSession;
6947
- var notice = available && this.notice ? this.notice : undefined;
7174
+ var queuedNotice;
7175
+ this.completionQueue.forEach(function (entry, id) {
7176
+ if (!queuedNotice && entry.announce && self.mainSession(id) && self.inScope(id) && self.running(id) === false) queuedNotice = entry;
7177
+ });
7178
+ var notice = available ? (this.notice && this.notice.reason === 'error' ? this.notice
7179
+ : (queuedNotice || (this.notice && !this.viewedCompletions.has(this.notice.sessionId) ? this.notice : undefined))) : undefined;
6948
7180
  /**
6949
7181
  * `connected` rides along, because 离线 is decided by the CONNECTION and not by `available`.
6950
7182
  *
@@ -7000,6 +7232,8 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
7000
7232
  pendingKind: waitingCount > 0 ? waitingKind : null,
7001
7233
  workingCount: workingCount,
7002
7234
  waitingCount: waitingCount,
7235
+ waitingSessionId: waitingSessionId,
7236
+ waitingSessionIds: waitingSessionIds,
7003
7237
  /**
7004
7238
  * Sessions whose completion is still UNREAD (see above), and whether there are any.
7005
7239
  *
@@ -7007,6 +7241,10 @@ var MOTION_STATES = STATUS_STATES.concat(INTERACTION_STATES, ['dancing']);
7007
7241
  * surface could say more later without another plumbing round.
7008
7242
  */
7009
7243
  unreadCount: unreadCount,
7244
+ completedUnreadCount: completedUnreadCount,
7245
+ completedSessionId: completedSessionId,
7246
+ completedSessionIds: completedSessionIds,
7247
+ viewedCompletions: Array.from(this.viewedCompletions),
7010
7248
  hasUnread: unreadCount > 0,
7011
7249
  /** The breakdown behind that count — see `unreadDetail` where it is built. */
7012
7250
  unreadDetail: unreadDetail,