@genesislcap/ai-assistant 15.26.0 → 15.27.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.
Files changed (43) hide show
  1. package/dist/ai-assistant.api.json +150 -7
  2. package/dist/ai-assistant.d.ts +82 -99
  3. package/dist/chat-driver.cjs +460 -373
  4. package/dist/chat-driver.cjs.map +4 -4
  5. package/dist/chat-driver.mjs +460 -373
  6. package/dist/chat-driver.mjs.map +4 -4
  7. package/dist/custom-elements.json +682 -202
  8. package/dist/dts/components/chat-driver/chat-driver.d.ts +28 -200
  9. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  10. package/dist/dts/components/chat-driver/interaction-controller.d.ts +99 -0
  11. package/dist/dts/components/chat-driver/interaction-controller.d.ts.map +1 -0
  12. package/dist/dts/components/chat-driver/turn-recorder.d.ts +191 -0
  13. package/dist/dts/components/chat-driver/turn-recorder.d.ts.map +1 -0
  14. package/dist/dts/components/settings-modal/settings-modal.template.d.ts +20 -0
  15. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  16. package/dist/dts/main/main.d.ts +32 -1
  17. package/dist/dts/main/main.d.ts.map +1 -1
  18. package/dist/dts/main/main.styles.d.ts.map +1 -1
  19. package/dist/dts/main/main.template.d.ts +19 -0
  20. package/dist/dts/main/main.template.d.ts.map +1 -1
  21. package/dist/dts/state/ai-assistant-slice.d.ts +7 -1
  22. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  23. package/dist/dts/state/persistence/session-persistence-provider.d.ts +5 -0
  24. package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
  25. package/dist/dts/state/session-store.d.ts +1 -0
  26. package/dist/dts/state/session-store.d.ts.map +1 -1
  27. package/dist/dts/utils/format-message-timestamp.d.ts +77 -0
  28. package/dist/dts/utils/format-message-timestamp.d.ts.map +1 -0
  29. package/dist/dts/utils/resolve-preference-baseline.d.ts +18 -1
  30. package/dist/dts/utils/resolve-preference-baseline.d.ts.map +1 -1
  31. package/dist/esm/components/chat-driver/chat-driver.js +68 -404
  32. package/dist/esm/components/chat-driver/interaction-controller.js +235 -0
  33. package/dist/esm/components/chat-driver/turn-recorder.js +206 -0
  34. package/dist/esm/components/settings-modal/settings-modal.template.js +53 -2
  35. package/dist/esm/main/main.js +80 -17
  36. package/dist/esm/main/main.styles.js +75 -0
  37. package/dist/esm/main/main.template.js +43 -2
  38. package/dist/esm/state/ai-assistant-slice.js +5 -0
  39. package/dist/esm/utils/format-message-timestamp.js +126 -0
  40. package/dist/esm/utils/resolve-preference-baseline.js +54 -2
  41. package/package.json +16 -16
  42. package/sandbox/README.md +45 -2
  43. package/sandbox/sandbox.ts +95 -3
@@ -58,7 +58,7 @@ import { deleteBankedBaseline } from '../utils/banked-usage-baselines';
58
58
  import { estimateContextTokens, isContextMeasurable, } from '../utils/context-tokens';
59
59
  import { logger } from '../utils/logger';
60
60
  import { filterVisibleMessages, trailingInteractionRow } from '../utils/message-partition';
61
- import { resolvePreferenceBaseline } from '../utils/resolve-preference-baseline';
61
+ import { DEFAULT_TIMESTAMP_DISPLAY, preferenceUnchanged, resolvePreferenceBaseline, } from '../utils/resolve-preference-baseline';
62
62
  import { stripAgentHandlers } from '../utils/strip-agent-handlers';
63
63
  import { emptyUsage, sumUsage, totalTokens } from '../utils/sum-usage';
64
64
  import { AttachmentController } from './attachment-controller';
@@ -114,6 +114,14 @@ const DEFAULT_PERSISTENCE_SOURCE = 'host';
114
114
  * cannot drift apart.
115
115
  */
116
116
  const BLOCKED_PLACEHOLDER = 'AI usage limit reached';
117
+ /**
118
+ * How often the transcript's relative timestamps are recomputed (GENC-1257).
119
+ *
120
+ * The relative tier changes at minute granularity, so a 30s tick caps how stale a label
121
+ * can look at half a minute — close enough that a user watching one never sees it lag,
122
+ * without a per-second timer. The work per tick is one string format per visible row.
123
+ */
124
+ const TIMESTAMP_CLOCK_INTERVAL_MS = 30000;
117
125
  /**
118
126
  * Budget formatting helpers, re-exported so they stay on this module's public
119
127
  * surface (`src/index.ts` does `export * from './main/main'`). They live in
@@ -276,6 +284,16 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
276
284
  this.persistence = {};
277
285
  /** When set, enables Redux DevTools for this instance's session store. */
278
286
  this.debugRedux = false;
287
+ /**
288
+ * Epoch ms the transcript's relative timestamps are measured against.
289
+ *
290
+ * A relative label is only correct at the instant it renders: FAST evaluates the
291
+ * binding once, and an idle conversation never re-renders, so "2 minutes ago" would
292
+ * still read "2 minutes ago" an hour later. Making the formatter read this observable
293
+ * instead of `Date.now()` gives every row one shared, invalidatable clock — a tick
294
+ * re-evaluates them all.
295
+ */
296
+ this.timestampClock = Date.now();
279
297
  // ---- Transient UI state (stays as @observable on the component) ----
280
298
  this._suggestionsGeneration = 0;
281
299
  /** Whether the loading spinner is currently visible. Controlled by the loading delay timer. */
@@ -622,12 +640,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
622
640
  }
623
641
  set showToolCalls(value) {
624
642
  var _a;
625
- // Idempotent: a same-value set is a no-op. The design-system switch fires
626
- // `@change` when its `:checked` binding is applied on render (not just on user
627
- // interaction), which would otherwise spuriously mark prefs "user-changed" and
628
- // save config defaults — freezing the reconciler so real stored prefs never apply
629
- // on reload (GENC-1351 #1).
630
- if (this.showToolCalls === value)
643
+ if (preferenceUnchanged(this.showToolCalls, value))
631
644
  return;
632
645
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setShowToolCalls(value);
633
646
  this.persistPreferences();
@@ -639,8 +652,8 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
639
652
  }
640
653
  set showThinkingSteps(value) {
641
654
  var _a;
642
- if (this.showThinkingSteps === value)
643
- return; // idempotent — see showToolCalls
655
+ if (preferenceUnchanged(this.showThinkingSteps, value))
656
+ return;
644
657
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setShowThinkingSteps(value);
645
658
  this.persistPreferences();
646
659
  }
@@ -651,8 +664,8 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
651
664
  }
652
665
  set showNarration(value) {
653
666
  var _a;
654
- if (this.showNarration === value)
655
- return; // idempotent — see showToolCalls
667
+ if (preferenceUnchanged(this.showNarration, value))
668
+ return;
656
669
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setShowNarration(value);
657
670
  this.persistPreferences();
658
671
  }
@@ -663,11 +676,55 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
663
676
  }
664
677
  set showAgentSwitchIndicator(value) {
665
678
  var _a;
666
- if (this.showAgentSwitchIndicator === value)
667
- return; // idempotent — see showToolCalls
679
+ if (preferenceUnchanged(this.showAgentSwitchIndicator, value))
680
+ return;
668
681
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setShowAgentSwitchIndicator(value);
669
682
  this.persistPreferences();
670
683
  }
684
+ /**
685
+ * When each message shows its timestamp — `'off'`, `'hover'` (default) or `'always'`.
686
+ *
687
+ * Falls back to `DEFAULT_TIMESTAMP_DISPLAY` rather than `'off'` when there is no
688
+ * store yet: this preference defaults ON, so the pre-store render must match the state
689
+ * the baseline is about to resolve to, or timestamps would visibly pop in on connect.
690
+ */
691
+ get timestampDisplay() {
692
+ var _a, _b;
693
+ return (_b = (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.store.aiAssistant.timestampDisplay) !== null && _b !== void 0 ? _b : DEFAULT_TIMESTAMP_DISPLAY;
694
+ }
695
+ set timestampDisplay(value) {
696
+ var _a;
697
+ if (preferenceUnchanged(this.timestampDisplay, value))
698
+ return;
699
+ (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setTimestampDisplay(value);
700
+ this.persistPreferences();
701
+ // The mode decides whether the clock needs to run at all.
702
+ this.syncTimestampClock();
703
+ }
704
+ /**
705
+ * Start or stop the timestamp clock to match the current mode and connection state.
706
+ *
707
+ * Idempotent, and safe to call from either lifecycle end — it is driven from
708
+ * `connectedCallback`, `disconnectedCallback` and the `timestampDisplay` setter, which
709
+ * can fire in any order. Nothing runs while the mode is `'off'` (no binding reads the
710
+ * clock) or while disconnected (a timer outliving its element is a leak, and a
711
+ * popped-out view disconnects without the session ending).
712
+ */
713
+ syncTimestampClock() {
714
+ const wanted = this._sessionActive && this.timestampDisplay !== 'off';
715
+ if (wanted === !!this._timestampClockTimer)
716
+ return;
717
+ if (!wanted) {
718
+ clearInterval(this._timestampClockTimer);
719
+ this._timestampClockTimer = undefined;
720
+ return;
721
+ }
722
+ // Re-sync immediately: the element may have sat disconnected across several ticks.
723
+ this.timestampClock = Date.now();
724
+ this._timestampClockTimer = setInterval(() => {
725
+ this.timestampClock = Date.now();
726
+ }, TIMESTAMP_CLOCK_INTERVAL_MS);
727
+ }
671
728
  /** Currently enabled animations. */
672
729
  get enabledAnimations() {
673
730
  var _a, _b;
@@ -675,10 +732,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
675
732
  }
676
733
  set enabledAnimations(value) {
677
734
  var _a;
678
- // Idempotent (order-insensitive) — see showToolCalls: the per-animation switches
679
- // fire the same spurious render-time `@change`.
680
- const current = this.enabledAnimations;
681
- if (current.length === value.length && current.every((a) => value.includes(a)))
735
+ if (preferenceUnchanged(this.enabledAnimations, value))
682
736
  return;
683
737
  (_a = this._sessionRef) === null || _a === void 0 ? void 0 : _a.actions.aiAssistant.setEnabledAnimations(value);
684
738
  this.persistPreferences();
@@ -2062,6 +2116,8 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2062
2116
  window.addEventListener('pagehide', this._handlePageHide);
2063
2117
  logger.debug('FoundationAiAssistant connected');
2064
2118
  this._sessionActive = true;
2119
+ // After `_sessionActive`, which `syncTimestampClock` reads as "connected".
2120
+ this.syncTimestampClock();
2065
2121
  }
2066
2122
  /**
2067
2123
  * Switch this element to a different session **in place**, without a page reload
@@ -2151,6 +2207,8 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2151
2207
  this.costLedger.flush();
2152
2208
  super.disconnectedCallback();
2153
2209
  this._sessionActive = false;
2210
+ // After `_sessionActive` is cleared, so the sync sees "disconnected" and stops.
2211
+ this.syncTimestampClock();
2154
2212
  this.stopLoadingTimer();
2155
2213
  this.state = 'idle';
2156
2214
  // Note: `compacting`/`restoring` are NOT cleared here. They're written through the
@@ -2740,6 +2798,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2740
2798
  showThinkingSteps: this.showThinkingSteps,
2741
2799
  showNarration: this.showNarration,
2742
2800
  showAgentSwitchIndicator: this.showAgentSwitchIndicator,
2801
+ timestampDisplay: this.timestampDisplay,
2743
2802
  animations: [...this.enabledAnimations],
2744
2803
  };
2745
2804
  }
@@ -2800,6 +2859,7 @@ let FoundationAiAssistant = FoundationAiAssistant_1 = class FoundationAiAssistan
2800
2859
  this.showThinkingSteps = resolved.showThinkingSteps;
2801
2860
  this.showNarration = resolved.showNarration;
2802
2861
  this.showAgentSwitchIndicator = resolved.showAgentSwitchIndicator;
2862
+ this.timestampDisplay = resolved.timestampDisplay;
2803
2863
  this.enabledAnimations = resolved.enabledAnimations;
2804
2864
  }
2805
2865
  finally {
@@ -3896,6 +3956,9 @@ __decorate([
3896
3956
  __decorate([
3897
3957
  volatile
3898
3958
  ], FoundationAiAssistant.prototype, "busy", null);
3959
+ __decorate([
3960
+ observable
3961
+ ], FoundationAiAssistant.prototype, "timestampClock", void 0);
3899
3962
  __decorate([
3900
3963
  volatile
3901
3964
  ], FoundationAiAssistant.prototype, "effectiveChatInputDuringExecution", null);
@@ -354,11 +354,86 @@ const baseStyles = css `
354
354
  }
355
355
 
356
356
  .sender {
357
+ /* Flex so the timestamp sits on the sender's baseline with a real gap, rather than
358
+ butting against the name or needing a margin that a missing timestamp would leave
359
+ behind. Wraps, so a narrow bubble drops the timestamp to its own line instead of
360
+ stretching the bubble to fit both. */
361
+ display: flex;
362
+ align-items: baseline;
363
+ flex-wrap: wrap;
364
+ gap: 2px 8px;
357
365
  font-weight: bold;
358
366
  font-size: 0.9em;
359
367
  margin-bottom: 4px;
360
368
  }
361
369
 
370
+ /* Recessive against the name it sits beside: lighter, smaller and dimmed. Deliberately
371
+ not a colour of its own — it inherits the bubble's foreground, so it stays legible on
372
+ the accent-filled user bubble and the neutral assistant one without a token per
373
+ variant. */
374
+ .message-timestamp {
375
+ font-weight: normal;
376
+ font-size: 0.85em;
377
+ opacity: 60%;
378
+ white-space: nowrap;
379
+
380
+ /* Chrome, not message content. Without this, opacity 0 still leaves the text
381
+ selectable, so dragging across the transcript and copying picks up a timestamp on
382
+ every message — including the ones the reader never hovered and never saw. */
383
+ user-select: none;
384
+ }
385
+
386
+ /* Removed from paint and from flow, but kept in the accessibility tree — the standard
387
+ clip-rect idiom rather than display:none or visibility:hidden, both of which would
388
+ take it out of the tree and defeat the point. Carries the absolute time behind the
389
+ relative text; see messageTimestampTemplate. */
390
+ .visually-hidden {
391
+ position: absolute;
392
+ width: 1px;
393
+ height: 1px;
394
+ margin: -1px;
395
+ padding: 0;
396
+ overflow: hidden;
397
+ clip-path: inset(50%);
398
+ white-space: nowrap;
399
+ border: 0;
400
+ }
401
+
402
+ /* 'hover' — the default. Space is RESERVED at all times (opacity, not display) so
403
+ revealing the timestamp cannot reflow the bubble under the pointer; only the paint
404
+ changes. The sender line is rarely the widest line in a bubble, so this costs no
405
+ width except on very short messages. */
406
+ .messages-content.timestamps-hover .message-timestamp {
407
+ opacity: 0%;
408
+ transition: opacity 0.12s ease-out;
409
+ }
410
+
411
+ .messages-content.timestamps-hover .message-row:hover .message-timestamp {
412
+ opacity: 60%;
413
+ }
414
+
415
+ /* Keyboard and touch reach the same reveal: neither produces a hover. The
416
+ focus-within rule costs nothing today (message rows are not focusable) and starts
417
+ working the moment anything inside one is. */
418
+ .messages-content.timestamps-hover .message-row:focus-within .message-timestamp {
419
+ opacity: 60%;
420
+ }
421
+
422
+ /* A pointer that cannot hover (touch) would otherwise never see a timestamp, and
423
+ hover on touch is sticky and unpredictable where it fires at all. Show them. */
424
+ @media (hover: none) {
425
+ .messages-content.timestamps-hover .message-timestamp {
426
+ opacity: 60%;
427
+ }
428
+ }
429
+
430
+ /* Respect a reduced-motion preference: the fade is decorative, the reveal is not. */
431
+ @media (prefers-reduced-motion: reduce) {
432
+ .messages-content.timestamps-hover .message-timestamp {
433
+ transition: none;
434
+ }
435
+ }
436
+
362
437
  .payload {
363
438
  font-family: monospace;
364
439
  white-space: pre-wrap;
@@ -17,6 +17,7 @@
17
17
  import { isChatToolCallUnknown } from '@genesislcap/foundation-ai';
18
18
  import { classNames, html, ref, repeat, when } from '@genesislcap/web-core';
19
19
  import { SettingsModalTemplate } from '../components/settings-modal';
20
+ import { formatAbsoluteTimestamp, formatMessageTimestamp, messageTimestampDateTime, } from '../utils/format-message-timestamp';
20
21
  import { compactionDividerLabel } from '../utils/history-transform';
21
22
  import { LOADING_STYLE_ANIMATIONS } from './main.types';
22
23
  function unknownToolPayload(tc) {
@@ -126,6 +127,35 @@ const messageParts = (m) => {
126
127
  ? `message message-interaction message-interaction-${interactionPresentation(m)}`
127
128
  : `message message-${type}`;
128
129
  };
130
+ /**
131
+ * The timestamp shown beside the sender name (GENC-1257).
132
+ *
133
+ * Two strings, not one. A screen reader announces a `<time>` element's TEXT — it has no
134
+ * implicit ARIA role, and the `datetime` attribute is never surfaced to the user — so the
135
+ * relative text is hidden from assistive tech and paired with a visually-hidden absolute
136
+ * string. Otherwise AT would hear "5 minutes ago" with nothing to anchor it, and in
137
+ * `'hover'` mode would hear it for a timestamp sighted users never asked to see.
138
+ * `datetime` stays because it is correct HTML and useful to tooling, not for a11y.
139
+ *
140
+ * Only the VISUAL reveal is hover-gated, in CSS — see `.message-timestamp`. `opacity: 0`
141
+ * leaves the element in the accessibility tree, so AT gets a time in both visible modes;
142
+ * `'off'` is the only mode that removes it.
143
+ *
144
+ * Reads `timestampClock` rather than `Date.now()` so every row shares one invalidatable
145
+ * clock and a tick re-evaluates them all; see `FoundationAiAssistant.timestampClock`.
146
+ */
147
+ export const messageTimestampTemplate = html `
148
+ <time
149
+ class="message-timestamp"
150
+ part="message-timestamp"
151
+ datetime="${(m) => messageTimestampDateTime(m.timestamp)}"
152
+ >
153
+ <span aria-hidden="true">
154
+ ${(m, c) => formatMessageTimestamp(m.timestamp, c.parent.timestampClock)}
155
+ </span>
156
+ <span class="visually-hidden">${(m) => formatAbsoluteTimestamp(m.timestamp)}</span>
157
+ </time>
158
+ `;
129
159
  // ─── Sub-agent trace fragments ────────────────────────────────────────────────
130
160
  const subAgentAssistantTemplate = html `
131
161
  <div class="sub-agent-message sub-agent-assistant">${(m) => m.content}</div>
@@ -499,7 +529,8 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
499
529
  // host sender label. Every other message keeps it.
500
530
  (m) => !(m.interaction && interactionPresentation(m) === 'bare'), html `
501
531
  <div class="sender">
502
- ${(m, c) => {
532
+ <span class="sender-label">
533
+ ${(m, c) => {
503
534
  var _a;
504
535
  return messageType(m) === 'ai-function' &&
505
536
  m.agentName &&
@@ -507,6 +538,13 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
507
538
  ? `Tool Call · ${(_a = m.agentLabel) !== null && _a !== void 0 ? _a : m.agentName}`
508
539
  : senderLabelFor(m);
509
540
  }}
541
+ </span>
542
+ ${when(
543
+ // Guarded on the stamp too: a session persisted before timestamp
544
+ // support restores without one, and an empty <time> would still
545
+ // draw the gap the flex row reserves for it.
546
+ (m, c) => !!m.timestamp &&
547
+ c.parent.timestampDisplay !== 'off', messageTimestampTemplate)}
510
548
  </div>
511
549
  `)}
512
550
  <div class="content">
@@ -826,7 +864,10 @@ ${(tc) => { var _a; return (((_a = tc.foldPath) === null || _a === void 0 ? void
826
864
  </div>
827
865
 
828
866
  <div class="messages" part="messages" ${ref('messagesEl')}>
829
- <div class="messages-content" part="messages-content">
867
+ <div
868
+ class="messages-content ${(x) => `timestamps-${x.timestampDisplay}`}"
869
+ part="messages-content"
870
+ >
830
871
  ${when((x) => x.compacting, compactingTemplate)}
831
872
  ${when((x) => x.restoring, restoringTemplate)}
832
873
  ${repeat((x) => (x.compacting || x.restoring ? [] : x.visibleMessages), messageRowTemplate)}
@@ -13,6 +13,7 @@ export function createDefaultSessionState() {
13
13
  showThinkingSteps: false,
14
14
  showNarration: false,
15
15
  showAgentSwitchIndicator: false,
16
+ timestampDisplay: 'hover',
16
17
  enabledAnimations: [],
17
18
  suggestionsState: { status: 'idle' },
18
19
  contextTokens: undefined,
@@ -64,6 +65,9 @@ export const aiAssistantSlice = createSlice({
64
65
  setShowAgentSwitchIndicator(state, action) {
65
66
  state.showAgentSwitchIndicator = action.payload;
66
67
  },
68
+ setTimestampDisplay(state, action) {
69
+ state.timestampDisplay = action.payload;
70
+ },
67
71
  setEnabledAnimations(state, action) {
68
72
  state.enabledAnimations = action.payload;
69
73
  },
@@ -322,6 +326,7 @@ export const aiAssistantSlice = createSlice({
322
326
  showThinkingSteps: state.showThinkingSteps,
323
327
  showNarration: state.showNarration,
324
328
  showAgentSwitchIndicator: state.showAgentSwitchIndicator,
329
+ timestampDisplay: state.timestampDisplay,
325
330
  enabledAnimations: state.enabledAnimations,
326
331
  providerStatuses: state.providerStatuses,
327
332
  blocked: state.blocked,
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The ONE place this package turns a message's `timestamp` into display text
3
+ * (GENC-1257).
4
+ *
5
+ * Three tiers, deliberately coarse — the transcript shows this beside the sender
6
+ * name, where a long string competes with the name itself:
7
+ *
8
+ * | Age | Reads |
9
+ * | -------------- | ------------------------------------------------------------------- |
10
+ * | under a minute | `Now` |
11
+ * | under an hour | `12 minutes ago` |
12
+ * | anything older | an absolute date+time in the user's locale, e.g. `Sep 10, 2026, 14:18` |
13
+ *
14
+ * The absolute tier is deliberately locale-driven, so its exact rendering is the
15
+ * runtime's to choose — `en-US` gives `Sep 10, 2026, 14:18` and `en-GB` gives
16
+ * `10 Sept 2026, 14:18`. Only the FIELDS are ours; see {@link ABSOLUTE_FORMAT_OPTIONS}.
17
+ *
18
+ * Split out as a pure function (as `format-usd.ts` was) so the tier boundaries are
19
+ * testable without a clock, an element or a render: every entry point takes `now`
20
+ * rather than reading `Date.now()` itself.
21
+ *
22
+ * @internal
23
+ */
24
+ /** Milliseconds in a minute. */
25
+ const MS_PER_MINUTE = 60000;
26
+ /** Minutes in an hour. Named for the lint rule that forbids a bare literal in an expression. */
27
+ const MINUTES_PER_HOUR = 60;
28
+ /** Milliseconds in an hour — the boundary between the relative and absolute tiers. */
29
+ const MS_PER_HOUR = MINUTES_PER_HOUR * MS_PER_MINUTE;
30
+ /**
31
+ * Field selection for the absolute tier.
32
+ *
33
+ * Exported so tests can build their expectation from the same options rather than from a
34
+ * literal: every field below renders differently per locale and time zone, so asserting
35
+ * `"Sep"` or `"10"` only asserts the machine the test happened to run on.
36
+ *
37
+ * Locale is left undefined so it follows the user's, and `hourCycle: 'h23'` pins the clock
38
+ * to 24-hour regardless — a locale-chosen 12-hour clock would add "am"/"pm" and widen the
39
+ * string next to the sender name.
40
+ */
41
+ export const ABSOLUTE_FORMAT_OPTIONS = {
42
+ day: 'numeric',
43
+ month: 'short',
44
+ year: 'numeric',
45
+ hour: '2-digit',
46
+ minute: '2-digit',
47
+ hourCycle: 'h23',
48
+ };
49
+ /**
50
+ * Built once at module load rather than per message: a transcript re-renders on every
51
+ * store change, and `Intl.DateTimeFormat` construction is the expensive half of the API
52
+ * (formatting against an existing instance is cheap).
53
+ */
54
+ const absoluteFormatter = new Intl.DateTimeFormat(undefined, ABSOLUTE_FORMAT_OPTIONS);
55
+ /**
56
+ * Display text for a message timestamp, per the tiers above.
57
+ *
58
+ * A timestamp slightly in the future (clock skew between a server-stamped message and the
59
+ * browser) reads `Now` rather than a negative count — "-3 minutes ago" looks broken and
60
+ * tells the user nothing. That indulgence is bounded at an hour: beyond it the stamp is
61
+ * not skew but bad data (a machine with a badly wrong clock, a hand-edited session), and
62
+ * showing `Now` forever on every message would hide that. Past the bound it falls through
63
+ * to the absolute tier, which shows the user what the data actually says.
64
+ *
65
+ * @param iso - ISO-8601 timestamp, as stamped by the driver on append.
66
+ * @param now - Current epoch ms. Passed in rather than read, so tests pin the clock.
67
+ * @returns Display text, or `''` when there is nothing sensible to show.
68
+ */
69
+ export function formatMessageTimestamp(iso, now) {
70
+ const ms = toEpochMs(iso);
71
+ if (ms === undefined)
72
+ return '';
73
+ const age = now - ms;
74
+ if (age < MS_PER_MINUTE && age > -MS_PER_HOUR)
75
+ return 'Now';
76
+ if (age >= MS_PER_MINUTE && age < MS_PER_HOUR) {
77
+ const minutes = Math.floor(age / MS_PER_MINUTE);
78
+ return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`;
79
+ }
80
+ return absoluteFormatter.format(ms);
81
+ }
82
+ /**
83
+ * The absolute rendering of a stamp, whatever its age — the accessible text behind a
84
+ * message timestamp.
85
+ *
86
+ * Exists because the visible text is relative and a screen reader announces the element's
87
+ * TEXT, not its `datetime` attribute: `<time>` carries no implicit ARIA role, so the
88
+ * machine-readable value reaches tooling and nobody else. The template pairs this with
89
+ * `aria-hidden` on the relative string so assistive tech hears one unambiguous time
90
+ * instead of "5 minutes ago" with no anchor.
91
+ *
92
+ * @param iso - ISO-8601 timestamp, as stamped by the driver on append.
93
+ * @returns The absolute string, or `''` when there is nothing sensible to show.
94
+ */
95
+ export function formatAbsoluteTimestamp(iso) {
96
+ const ms = toEpochMs(iso);
97
+ return ms === undefined ? '' : absoluteFormatter.format(ms);
98
+ }
99
+ /**
100
+ * The `datetime` attribute value for the rendered `<time>` element — the raw ISO string,
101
+ * which is what the HTML spec wants and what date-aware tooling reads. It is NOT an
102
+ * accessibility affordance: screen readers announce the element's text content instead.
103
+ *
104
+ * Returns `null`, not `''`, for a missing or unparseable stamp: FAST removes an attribute
105
+ * only for `null`/`undefined` (see `DOM.setAttribute`), so an empty string would emit
106
+ * `datetime=""`, which is invalid per spec and flagged by HTML validators.
107
+ *
108
+ * @param iso - ISO-8601 timestamp, as stamped by the driver on append.
109
+ */
110
+ export function messageTimestampDateTime(iso) {
111
+ return toEpochMs(iso) === undefined ? null : iso;
112
+ }
113
+ /**
114
+ * Parse a stamp to epoch ms, rejecting both absence and garbage.
115
+ *
116
+ * `timestamp` is optional on {@link ChatMessage} for one real reason — sessions persisted
117
+ * before timestamp support restore without it — and a persisted blob is host-supplied data
118
+ * that can also be malformed. `new Date(junk).getTime()` is `NaN`, which would otherwise
119
+ * flow into the arithmetic and render "NaN minutes ago".
120
+ */
121
+ function toEpochMs(iso) {
122
+ if (!iso)
123
+ return undefined;
124
+ const ms = new Date(iso).getTime();
125
+ return Number.isNaN(ms) ? undefined : ms;
126
+ }
@@ -1,5 +1,48 @@
1
1
  import { DEFAULT_ANIMATIONS } from '../main/main.types';
2
2
  import { resolveExclusiveLoadingStyle } from './animation-exclusivity';
3
+ /**
4
+ * Whether a preference write is a no-op, so a setter can bail before touching the store.
5
+ *
6
+ * Load-bearing rather than an optimisation. The design-system `switch` fires `@change` when its
7
+ * `:checked` binding is applied on render, not only on user interaction — so an unguarded setter
8
+ * marks preferences "user-changed" on every render and saves config defaults, which freezes the
9
+ * reconciler so real stored prefs never apply on reload (GENC-1351 #1).
10
+ *
11
+ * List preferences compare order-insensitively: the per-animation switches fire the same spurious
12
+ * render-time `@change`, and `enabledAnimations` is a set rather than a sequence.
13
+ *
14
+ * @internal
15
+ */
16
+ export function preferenceUnchanged(current, value) {
17
+ if (Array.isArray(current) && Array.isArray(value)) {
18
+ return current.length === value.length && current.every((item) => value.includes(item));
19
+ }
20
+ return current === value;
21
+ }
22
+ /** The mode used when neither the host config nor stored prefs name a valid one. */
23
+ export const DEFAULT_TIMESTAMP_DISPLAY = 'hover';
24
+ /**
25
+ * Every valid {@link TimestampDisplay}, for narrowing untrusted input.
26
+ *
27
+ * Keyed by the union rather than typed `readonly string[]`, so adding a mode is a compile
28
+ * error here. A plain string array severs the link: the new mode would type-check
29
+ * everywhere, then be rejected at runtime as junk and silently fall back to `'hover'` —
30
+ * surfacing as "my new mode doesn't stick" rather than as a type error.
31
+ */
32
+ const TIMESTAMP_DISPLAY_MODES = {
33
+ off: true,
34
+ hover: true,
35
+ always: true,
36
+ };
37
+ /**
38
+ * Narrow an untrusted value to a {@link TimestampDisplay}, or `undefined` if it is not one.
39
+ *
40
+ * Both inputs need this: the config field is typed but comes from plain JS hosts, and the
41
+ * stored field is a bare `string` read back out of a persistence blob.
42
+ */
43
+ const asTimestampDisplay = (value) => typeof value === 'string' && value in TIMESTAMP_DISPLAY_MODES
44
+ ? value
45
+ : undefined;
3
46
  /**
4
47
  * Resolve the effective preference baseline for a fresh store, applying the
5
48
  * precedence (lowest → highest):
@@ -20,7 +63,7 @@ import { resolveExclusiveLoadingStyle } from './animation-exclusivity';
20
63
  * @internal
21
64
  */
22
65
  export function resolvePreferenceBaseline(ui, stored) {
23
- var _a, _b;
66
+ var _a, _b, _c;
24
67
  const u = ui !== null && ui !== void 0 ? ui : {};
25
68
  // Layer 1 — chatConfig.ui defaults.
26
69
  const baseline = {
@@ -28,7 +71,10 @@ export function resolvePreferenceBaseline(ui, stored) {
28
71
  showThinkingSteps: u.showThinkingSteps === true,
29
72
  showNarration: u.showNarration === true,
30
73
  showAgentSwitchIndicator: u.showAgentSwitchIndicator === true,
31
- enabledAnimations: resolveExclusiveLoadingStyle((_b = (_a = u.animations) === null || _a === void 0 ? void 0 : _a.enabled) !== null && _b !== void 0 ? _b : (u.animations ? [...DEFAULT_ANIMATIONS] : [])),
74
+ // Defaults ON, unlike the booleans above — so this reads the config value when it is
75
+ // a valid mode and falls back to `'hover'`, rather than testing `=== true`.
76
+ timestampDisplay: (_a = asTimestampDisplay(u.showTimestamps)) !== null && _a !== void 0 ? _a : DEFAULT_TIMESTAMP_DISPLAY,
77
+ enabledAnimations: resolveExclusiveLoadingStyle((_c = (_b = u.animations) === null || _b === void 0 ? void 0 : _b.enabled) !== null && _c !== void 0 ? _c : (u.animations ? [...DEFAULT_ANIMATIONS] : [])),
32
78
  };
33
79
  // Layer 2 — stored user preferences override the fields they carry.
34
80
  if (stored) {
@@ -43,6 +89,12 @@ export function resolvePreferenceBaseline(ui, stored) {
43
89
  if (stored.showAgentSwitchIndicator !== undefined) {
44
90
  baseline.showAgentSwitchIndicator = stored.showAgentSwitchIndicator;
45
91
  }
92
+ // Validated, not just presence-checked: `SessionPreferences.timestampDisplay` is a
93
+ // plain `string` off a host-supplied blob, so a stale or hand-edited value must fall
94
+ // through to the config layer instead of poking a junk mode into the store.
95
+ const storedMode = asTimestampDisplay(stored.timestampDisplay);
96
+ if (storedMode)
97
+ baseline.timestampDisplay = storedMode;
46
98
  if (Array.isArray(stored.animations)) {
47
99
  // De-dupe defensively — historical blobs may hold duplicates from the old
48
100
  // settings-modal append bug (now fixed at the `setEnabledAnimations` source).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@genesislcap/ai-assistant",
3
3
  "description": "Genesis AI Assistant micro-frontend",
4
- "version": "15.26.0",
4
+ "version": "15.27.0",
5
5
  "license": "SEE LICENSE IN license.txt",
6
6
  "main": "dist/esm/index.js",
7
7
  "types": "dist/ai-assistant.d.ts",
@@ -74,26 +74,26 @@
74
74
  }
75
75
  },
76
76
  "devDependencies": {
77
- "@genesislcap/foundation-testing": "15.26.0",
78
- "@genesislcap/genx": "15.26.0",
79
- "@genesislcap/rollup-builder": "15.26.0",
80
- "@genesislcap/ts-builder": "15.26.0",
81
- "@genesislcap/uvu-playwright-builder": "15.26.0",
82
- "@genesislcap/vite-builder": "15.26.0",
83
- "@genesislcap/webpack-builder": "15.26.0",
77
+ "@genesislcap/foundation-testing": "15.27.0",
78
+ "@genesislcap/genx": "15.27.0",
79
+ "@genesislcap/rollup-builder": "15.27.0",
80
+ "@genesislcap/ts-builder": "15.27.0",
81
+ "@genesislcap/uvu-playwright-builder": "15.27.0",
82
+ "@genesislcap/vite-builder": "15.27.0",
83
+ "@genesislcap/webpack-builder": "15.27.0",
84
84
  "@types/dompurify": "^3.0.5",
85
85
  "@types/marked": "^5.0.2",
86
86
  "esbuild": "0.25.12"
87
87
  },
88
88
  "dependencies": {
89
- "@genesislcap/foundation-ai": "15.26.0",
90
- "@genesislcap/foundation-logger": "15.26.0",
91
- "@genesislcap/foundation-notifications": "15.26.0",
92
- "@genesislcap/foundation-redux": "15.26.0",
93
- "@genesislcap/foundation-ui": "15.26.0",
94
- "@genesislcap/foundation-utils": "15.26.0",
95
- "@genesislcap/rapid-design-system": "15.26.0",
96
- "@genesislcap/web-core": "15.26.0",
89
+ "@genesislcap/foundation-ai": "15.27.0",
90
+ "@genesislcap/foundation-logger": "15.27.0",
91
+ "@genesislcap/foundation-notifications": "15.27.0",
92
+ "@genesislcap/foundation-redux": "15.27.0",
93
+ "@genesislcap/foundation-ui": "15.27.0",
94
+ "@genesislcap/foundation-utils": "15.27.0",
95
+ "@genesislcap/rapid-design-system": "15.27.0",
96
+ "@genesislcap/web-core": "15.27.0",
97
97
  "dompurify": "^3.3.1",
98
98
  "marked": "^17.0.3"
99
99
  },