@genesislcap/ai-assistant 15.26.0 → 15.27.0-agent-log-submit.3

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 (52) hide show
  1. package/dist/ai-assistant.api.json +1014 -110
  2. package/dist/ai-assistant.d.ts +236 -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 +1171 -248
  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.d.ts.map +1 -1
  15. package/dist/dts/components/settings-modal/settings-modal.styles.d.ts.map +1 -1
  16. package/dist/dts/components/settings-modal/settings-modal.template.d.ts +28 -0
  17. package/dist/dts/components/settings-modal/settings-modal.template.d.ts.map +1 -1
  18. package/dist/dts/index.d.ts +1 -0
  19. package/dist/dts/index.d.ts.map +1 -1
  20. package/dist/dts/main/main.d.ts +180 -1
  21. package/dist/dts/main/main.d.ts.map +1 -1
  22. package/dist/dts/main/main.styles.d.ts.map +1 -1
  23. package/dist/dts/main/main.template.d.ts +19 -0
  24. package/dist/dts/main/main.template.d.ts.map +1 -1
  25. package/dist/dts/state/ai-assistant-slice.d.ts +7 -1
  26. package/dist/dts/state/ai-assistant-slice.d.ts.map +1 -1
  27. package/dist/dts/state/persistence/session-persistence-provider.d.ts +5 -0
  28. package/dist/dts/state/persistence/session-persistence-provider.d.ts.map +1 -1
  29. package/dist/dts/state/session-store.d.ts +1 -0
  30. package/dist/dts/state/session-store.d.ts.map +1 -1
  31. package/dist/dts/utils/format-message-timestamp.d.ts +77 -0
  32. package/dist/dts/utils/format-message-timestamp.d.ts.map +1 -0
  33. package/dist/dts/utils/resolve-debug-log-note.d.ts +25 -0
  34. package/dist/dts/utils/resolve-debug-log-note.d.ts.map +1 -0
  35. package/dist/dts/utils/resolve-preference-baseline.d.ts +18 -1
  36. package/dist/dts/utils/resolve-preference-baseline.d.ts.map +1 -1
  37. package/dist/esm/components/chat-driver/chat-driver.js +68 -404
  38. package/dist/esm/components/chat-driver/interaction-controller.js +235 -0
  39. package/dist/esm/components/chat-driver/turn-recorder.js +206 -0
  40. package/dist/esm/components/settings-modal/settings-modal.js +1 -0
  41. package/dist/esm/components/settings-modal/settings-modal.styles.js +85 -5
  42. package/dist/esm/components/settings-modal/settings-modal.template.js +220 -25
  43. package/dist/esm/main/main.js +510 -23
  44. package/dist/esm/main/main.styles.js +75 -0
  45. package/dist/esm/main/main.template.js +43 -2
  46. package/dist/esm/state/ai-assistant-slice.js +5 -0
  47. package/dist/esm/utils/format-message-timestamp.js +126 -0
  48. package/dist/esm/utils/resolve-debug-log-note.js +37 -0
  49. package/dist/esm/utils/resolve-preference-baseline.js +54 -2
  50. package/package.json +16 -16
  51. package/sandbox/README.md +45 -2
  52. package/sandbox/sandbox.ts +95 -3
@@ -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
+ }
@@ -0,0 +1,37 @@
1
+ /** Default cap on the user's note. Short by design — it is a caption for the log, not a report. */
2
+ export const DEBUG_LOG_NOTE_DEFAULT_MAX_LENGTH = 500;
3
+ /** Default prompt in the empty note. */
4
+ export const DEBUG_LOG_NOTE_DEFAULT_PLACEHOLDER = 'What were you doing, or what went wrong? (optional)';
5
+ /** The same prompt for a `required` note — "(optional)" would contradict the disabled Send. */
6
+ export const DEBUG_LOG_NOTE_REQUIRED_PLACEHOLDER = 'What were you doing, or what went wrong?';
7
+ /**
8
+ * Resolve `debugLogSubmit.note` — `undefined`/`true` mean "compose box with defaults",
9
+ * `false` means "no compose box, send on first click" (→ `null`), and an object customises
10
+ * the box. Keeps the template free of the boolean-or-object dance.
11
+ */
12
+ export function resolveDebugLogNoteConfig(config) {
13
+ var _a;
14
+ if (!config || config.note === false)
15
+ return null;
16
+ // `typeof null === 'object'`: a host building its config dynamically can hand us `null`
17
+ // even though the type forbids it, and that must read as "defaults", not crash.
18
+ const note = typeof config.note === 'object' && config.note !== null ? config.note : {};
19
+ // `>= 1`, not `> 0`: a fractional cap under 1 would floor to 0 and lock the textarea.
20
+ const maxLength = typeof note.maxLength === 'number' && note.maxLength >= 1
21
+ ? Math.floor(note.maxLength)
22
+ : DEBUG_LOG_NOTE_DEFAULT_MAX_LENGTH;
23
+ const required = note.required === true;
24
+ return {
25
+ placeholder: (_a = note.placeholder) !== null && _a !== void 0 ? _a : (required ? DEBUG_LOG_NOTE_REQUIRED_PLACEHOLDER : DEBUG_LOG_NOTE_DEFAULT_PLACEHOLDER),
26
+ required,
27
+ maxLength,
28
+ };
29
+ }
30
+ /**
31
+ * Normalise the typed note for the submission: trimmed, and `undefined` when empty so a
32
+ * host can `message ?? fallback` without checking for whitespace.
33
+ */
34
+ export function normalizeDebugLogNote(note) {
35
+ const trimmed = note.trim();
36
+ return trimmed.length > 0 ? trimmed : undefined;
37
+ }
@@ -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-agent-log-submit.3",
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-agent-log-submit.3",
78
+ "@genesislcap/genx": "15.27.0-agent-log-submit.3",
79
+ "@genesislcap/rollup-builder": "15.27.0-agent-log-submit.3",
80
+ "@genesislcap/ts-builder": "15.27.0-agent-log-submit.3",
81
+ "@genesislcap/uvu-playwright-builder": "15.27.0-agent-log-submit.3",
82
+ "@genesislcap/vite-builder": "15.27.0-agent-log-submit.3",
83
+ "@genesislcap/webpack-builder": "15.27.0-agent-log-submit.3",
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-agent-log-submit.3",
90
+ "@genesislcap/foundation-logger": "15.27.0-agent-log-submit.3",
91
+ "@genesislcap/foundation-notifications": "15.27.0-agent-log-submit.3",
92
+ "@genesislcap/foundation-redux": "15.27.0-agent-log-submit.3",
93
+ "@genesislcap/foundation-ui": "15.27.0-agent-log-submit.3",
94
+ "@genesislcap/foundation-utils": "15.27.0-agent-log-submit.3",
95
+ "@genesislcap/rapid-design-system": "15.27.0-agent-log-submit.3",
96
+ "@genesislcap/web-core": "15.27.0-agent-log-submit.3",
97
97
  "dompurify": "^3.3.1",
98
98
  "marked": "^17.0.3"
99
99
  },
package/sandbox/README.md CHANGED
@@ -30,8 +30,8 @@ The panel covers, roughly in the order you'll want them:
30
30
  | **Session menu (⋮)** | Whether a persistence provider is configured (the menu is gated on one) and `showSessionMenu`; a button to open the menu |
31
31
  | **Context window** | **Jump to state** (the four gate states in one click), the simulated window and fill, `chatConfig.context`, a flow-in-progress toggle, live readouts of the gate / thresholds / compaction plan, and Compact |
32
32
  | **Simulated state** | Forces `idle` / busy / cancelling / blocked / compacting / restoring onto the element |
33
- | **Transcript** | Seeds an empty, short, tool-and-markdown-heavy, 28-turn, ~20k-token, or ~22k-token tool-loop conversation |
34
- | **Chrome** | Header title, popout mode, suggestions, the tool-call / reasoning / narration toggles, animations |
33
+ | **Transcript** | Seeds an empty, short, tool-and-markdown-heavy, 28-turn, ~20k-token, or ~22k-token tool-loop conversation, and how far back to shift its timestamps |
34
+ | **Chrome** | Header title, popout mode, suggestions, the tool-call / reasoning / narration toggles, the timestamp mode, animations |
35
35
  | **Stage** | Frame size (floored at 400px — see below), light/dark, remount, full reset |
36
36
 
37
37
  Panel state persists in `localStorage`, so a reload picks up where you left off. **Reset sandbox**
@@ -122,6 +122,49 @@ A run worth doing:
122
122
  far above `block` on purpose: the reserve exists so an accepted turn can spend it, and a guard at
123
123
  the block line would kill every turn that used the headroom it was given.
124
124
 
125
+ ## Checking message timestamps (GENC-1257)
126
+
127
+ Each message can show when it was sent, beside the sender name. **Timestamps** in the Chrome
128
+ group and the cog's own picker both drive the same three modes, and whichever you moved last
129
+ wins. Moving any *other* panel control will not disturb a choice made in the cog.
130
+
131
+ The panel control writes `chatConfig.ui.showTimestamps` **and** the element's
132
+ `timestampDisplay` property. That second write is what makes it work, and is worth knowing
133
+ about before you add a control of your own: `chatConfig.ui` is read only while the store is
134
+ fresh and the user has changed nothing (`applyPreferenceBaseline`), so the moment the cog is
135
+ touched — or a stored preference exists — a config change is correctly ignored. That is right
136
+ for a real host, where a user's choice should outrank a host default, and useless for a
137
+ harness control, which has to win.
138
+
139
+ Show tool calls / reasoning / narration work the same way, through the same
140
+ `DIRECT_PREFERENCE_WRITERS` table in `sandbox.ts` — add an entry there for any new panel
141
+ control that drives a user preference, or it will quietly stop responding the first time
142
+ anyone opens the cog. **Animations is deliberately not in that table**: it is a feature gate
143
+ rather than a display preference (unsetting it removes the cog control altogether), and it has
144
+ no single element property to mirror onto.
145
+
146
+ - **On hover** (the default) — hidden until the pointer is over that message row. The space is
147
+ still reserved, so a reveal never reflows the bubble under the cursor.
148
+ - **Always** — permanently visible.
149
+ - **Off** — not rendered, and therefore absent from the accessibility tree too.
150
+
151
+ `opacity: 0` hides from paint but not from the accessibility tree, and assistive tech has no
152
+ hover, so in **both** visible modes a screen reader announces a timestamp on every message.
153
+ It hears the absolute date+time, carried in a visually-hidden span with the relative text
154
+ `aria-hidden` — a `<time>` element has no implicit ARIA role, so its `datetime` attribute is
155
+ never announced and cannot serve as the accessible value.
156
+
157
+ The display has three tiers — "Now" under a minute, "12 minutes ago" under an hour, and a full
158
+ date beyond that. **Age** in the Transcript group is what makes the third reachable: the
159
+ fixtures stamp their turns a minute apart ending "now", so a seeded conversation spans minutes
160
+ and would never cross the hour boundary on its own. Shifting the whole seed back 3 hours or 3
161
+ days is the only way to see the absolute tier without inventing a fixture nobody else needs.
162
+
163
+ The relative labels are recomputed on a 30s tick rather than at render, because FAST evaluates
164
+ a binding once and an idle conversation never re-renders — without it "2 minutes ago" would
165
+ still read "2 minutes ago" an hour later. The ticker only runs while the element is connected
166
+ and the mode is not `off`.
167
+
125
168
  ## 400px is the narrowest supported width
126
169
 
127
170
  The stage will not go below it — slider, drag handle and a restored panel state are all clamped —