@meistrari/chat-nuxt 3.16.0 → 3.17.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/chat-nuxt",
3
3
  "configKey": "chatNuxt",
4
- "version": "3.16.0-rc.5",
4
+ "version": "3.17.0-rc.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -1 +1 @@
1
- .markstream-vue{font-family:inherit;--at-apply:"body-16-regular leading-24px text-primary";--list-item-marker:#a3a3a3;--list-item-counter-marker:#262626}.markstream-vue code,.markstream-vue kbd,.markstream-vue pre,.markstream-vue samp{font-family:Geist Mono Variable,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:13px;font-variation-settings:normal;letter-spacing:0;line-height:1.5}.markstream-vue ol,.markstream-vue ul{margin:8px 0;padding-left:1.5em}.markstream-vue .hr-node{border-color:#e5e5e5;border-top-width:.5px;margin-bottom:24px;margin-top:24px}.markstream-vue .strong-node{--at-apply:"font-580"}.markstream-vue .list-node.list-node{margin-bottom:8px;margin-left:4px;margin-top:8px;padding-left:1em}.markstream-vue ul{list-style:disc;list-style-position:outside}.markstream-vue ol{list-style:decimal;list-style-position:outside}.markstream-vue li{margin:4px 0}.markstream-vue .paragraph-node{--at-apply:"body-16-regular leading-24px";margin:0}.markstream-vue>.node-slot[data-node-type=paragraph]:not(:first-child){margin-top:12px}.markstream-vue .my-8{margin-bottom:8px;margin-top:8px}.markstream-vue .heading-node.heading-1,.markstream-vue h1{--at-apply:"heading-h2-semibold text-primary";margin:24px 0 8px}.markstream-vue .heading-node.heading-2,.markstream-vue h2{--at-apply:"heading-h3-semibold text-primary";margin:24px 0 8px}.markstream-vue .heading-node.heading-3,.markstream-vue h3{--at-apply:"heading-h4-semibold text-primary";margin:20px 0 8px}.markstream-vue .heading-node.heading-4,.markstream-vue .heading-node.heading-5,.markstream-vue .heading-node.heading-6,.markstream-vue h4,.markstream-vue h5,.markstream-vue h6{--at-apply:"heading-h5-semibold text-primary";margin:16px 0 8px}.markstream-vue>.node-slot:first-child .heading-node{margin-top:0}.markstream-vue [class*=tooltip],[class*=tooltip-element]{background-color:var(--neutral-900,#1a1a1a)!important;border-radius:4px;color:#fff!important;font-size:12px;padding:4px 8px}.mention-tag{align-items:center;border-radius:9999px;display:inline-flex;font-size:.875em;font-weight:500;gap:4px;padding:2px 8px;vertical-align:middle;white-space:nowrap}.mention-tag-file{background-color:rgba(127,189,247,.12);color:#7fbdf7}.mention-tag-skill{background-color:rgba(155,111,222,.12);color:#9b6fde}.mention-tag-canvas{background-color:rgba(234,161,65,.12);color:#d4882e}.mention-tag>span[class*=i-ph]{flex-shrink:0}
1
+ .markstream-vue{font-family:inherit;--at-apply:"body-16-regular leading-24px text-primary";--list-item-marker:#a3a3a3;--list-item-counter-marker:#262626}.markstream-vue code,.markstream-vue kbd,.markstream-vue pre,.markstream-vue samp{font-family:Geist Mono Variable,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:13px;font-variation-settings:normal;letter-spacing:0;line-height:1.5}.markstream-vue ol,.markstream-vue ul{margin:8px 0;padding-left:1.5em}.markstream-vue .hr-node{border-color:#e5e5e5;border-top-width:.5px;margin-bottom:24px;margin-top:24px}.markstream-vue .strong-node{--at-apply:"font-580"}.markstream-vue .list-node.list-node{margin-bottom:8px;margin-left:4px;margin-top:8px;padding-left:1em}.markstream-vue ul{list-style:disc;list-style-position:outside}.markstream-vue ol{list-style:decimal;list-style-position:outside}.markstream-vue li{margin:4px 0}.markstream-vue .paragraph-node{--at-apply:"body-16-regular leading-24px";margin:0}.markstream-vue>.node-slot[data-node-type=paragraph]:not(:first-child){margin-top:12px}@keyframes chat-stream-chunk-in{0%{opacity:0}to{opacity:1}}.markstream-vue .chat-stream-chunk{animation:chat-stream-chunk-in var(--chat-stream-fade-duration,.32s) cubic-bezier(.2,0,0,1) both}@media (prefers-reduced-motion:reduce){.markstream-vue .chat-stream-chunk{animation:none}}.markstream-vue .typewriter-cursor,.markstream-vue .typewriter-simple-cursor .typewriter-simple-cursor-target:after{display:none}.markstream-vue .my-8{margin-bottom:8px;margin-top:8px}.markstream-vue .heading-node.heading-1,.markstream-vue h1{--at-apply:"heading-h2-semibold text-primary";margin:24px 0 8px}.markstream-vue .heading-node.heading-2,.markstream-vue h2{--at-apply:"heading-h3-semibold text-primary";margin:24px 0 8px}.markstream-vue .heading-node.heading-3,.markstream-vue h3{--at-apply:"heading-h4-semibold text-primary";margin:20px 0 8px}.markstream-vue .heading-node.heading-4,.markstream-vue .heading-node.heading-5,.markstream-vue .heading-node.heading-6,.markstream-vue h4,.markstream-vue h5,.markstream-vue h6{--at-apply:"heading-h5-semibold text-primary";margin:16px 0 8px}.markstream-vue>.node-slot:first-child .heading-node{margin-top:0}.markstream-vue [class*=tooltip],[class*=tooltip-element]{background-color:var(--neutral-900,#1a1a1a)!important;border-radius:4px;color:#fff!important;font-size:12px;padding:4px 8px}.mention-tag{align-items:center;border-radius:9999px;display:inline-flex;font-size:.875em;font-weight:500;gap:4px;padding:2px 8px;vertical-align:middle;white-space:nowrap}.mention-tag-file{background-color:rgba(127,189,247,.12);color:#7fbdf7}.mention-tag-skill{background-color:rgba(155,111,222,.12);color:#9b6fde}.mention-tag-canvas{background-color:rgba(234,161,65,.12);color:#d4882e}.mention-tag>span[class*=i-ph]{flex-shrink:0}
@@ -286,7 +286,7 @@ function eventToneClass(tone) {
286
286
  <dl class="metric-grid">
287
287
  <div>
288
288
  <dt body-10-regular text-tertiary>
289
- Event → paint
289
+ Event → frame
290
290
  </dt>
291
291
  <dd heading-h3-semibold tabular-nums>
292
292
  {{ diagnostics.paintLatencyMs.toFixed(1) }}<small>ms</small>
@@ -294,7 +294,7 @@ function eventToneClass(tone) {
294
294
  </div>
295
295
  <div>
296
296
  <dt body-10-regular text-tertiary>
297
- Dropped frames
297
+ Est. missed frames
298
298
  </dt>
299
299
  <dd heading-h3-semibold tabular-nums>
300
300
  {{ diagnostics.droppedFrames }}
@@ -390,7 +390,7 @@ function eventToneClass(tone) {
390
390
  </div>
391
391
  <div>
392
392
  <dt text-tertiary>
393
- Largest move
393
+ Unexpected move
394
394
  </dt><dd text-primary tabular-nums>
395
395
  {{ diagnostics.maxScrollJump.toFixed(0) }} px
396
396
  </dd>
@@ -55,6 +55,7 @@ defineExpose({
55
55
  <template>
56
56
  <div
57
57
  class="chat-message-list"
58
+ :data-chat-bottom-pinned="isPinned"
58
59
  :data-variant="variant ?? 'page'"
59
60
  flex="~ col"
60
61
  flex-1
@@ -1,7 +1,12 @@
1
1
  <script setup>
2
- defineProps({
2
+ const props = defineProps({
3
3
  open: { type: Boolean, required: true }
4
4
  });
5
+ const hasOpened = ref(props.open);
6
+ watch(() => props.open, (open) => {
7
+ if (open)
8
+ hasOpened.value = true;
9
+ });
5
10
  </script>
6
11
 
7
12
  <template>
@@ -15,7 +20,7 @@ defineProps({
15
20
  :inert="open ? void 0 : true"
16
21
  :aria-hidden="!open"
17
22
  >
18
- <slot />
23
+ <slot v-if="hasOpened" />
19
24
  </TelaCollapsibleContent>
20
25
  </div>
21
26
  </template>
@@ -13,7 +13,12 @@ const groupedReasoning = computed(() => {
13
13
  </script>
14
14
 
15
15
  <template>
16
- <div v-if="groupedReasoning.length > 0" flex="~ col" gap-2px w-full>
16
+ <div
17
+ v-if="groupedReasoning.length > 0"
18
+ class="chat-reasoning-steps"
19
+ :class="{ 'chat-reasoning-steps--streaming': !completed }"
20
+ flex="~ col" gap-2px w-full
21
+ >
17
22
  <ChatReasoningMessage
18
23
  v-for="(step, index) in groupedReasoning"
19
24
  :key="getReasoningStepKey(step, index)"
@@ -23,3 +28,7 @@ const groupedReasoning = computed(() => {
23
28
  />
24
29
  </div>
25
30
  </template>
31
+
32
+ <style scoped>
33
+ @keyframes chat-reasoning-step-in{0%{opacity:0}to{opacity:1}}.chat-reasoning-steps--streaming>:deep(*){animation:chat-reasoning-step-in .24s cubic-bezier(.2,0,0,1) both}@media (prefers-reduced-motion:reduce){.chat-reasoning-steps--streaming>:deep(*){animation:none}}
34
+ </style>
@@ -94,93 +94,96 @@ onUnmounted(() => {
94
94
  </script>
95
95
 
96
96
  <template>
97
- <div flex="~ col" w-full>
98
- <!-- Sem reasoning ainda: ChatThinkingIndicator padrão -->
99
- <ChatThinkingIndicator v-if="!completed && !hasReasoning" key="thinking" />
97
+ <div class="chat-reasoning-stage" w-full>
98
+ <Transition name="chat-reasoning-presence" :appear="!completed">
99
+ <div v-if="!completed && !hasReasoning" key="thinking" class="chat-reasoning-placeholder">
100
+ <ChatThinkingIndicator />
101
+ </div>
100
102
 
101
- <!-- Com reasoning (loading ou completo): header clicável + steps colapsáveis -->
102
- <TelaCollapsible
103
- v-else-if="hasReasoning"
104
- key="reasoning"
105
- v-model:open="isExpanded"
106
- class="chat-reasoning-disclosure"
107
- data-chat-scroll-preserve
108
- >
109
- <TelaCollapsibleTrigger as-child>
110
- <button
111
- type="button"
112
- data-chat-scroll-preserve
113
- class="group"
114
- data-chat-scroll-preserve-trigger
115
- inline-flex
116
- self-start
117
- items-center
118
- gap-4px
119
- min-h-32px
120
- max-w-full
121
- py-6px
122
- text-left
123
- transition-colors
124
- cursor-pointer
125
- :aria-busy="!completed"
126
- :aria-label="isExpanded ? 'Ocultar detalhes do trabalho' : 'Mostrar detalhes do trabalho'"
127
- >
128
- <span
129
- v-if="completed"
130
- key="completed-text"
131
- body-14-regular
132
- text-secondary
133
- tabular-nums
134
- group-hover:text-primary
135
- transition-colors
136
- >
137
- Trabalhou por {{ formattedDuration || "alguns momentos" }}
138
- </span>
139
- <div
140
- v-else
141
- key="loading-text"
142
- flex
143
- min-w-0
103
+ <!-- Com reasoning (loading ou completo): header clicável + steps colapsáveis -->
104
+ <TelaCollapsible
105
+ v-else-if="hasReasoning"
106
+ key="reasoning"
107
+ v-model:open="isExpanded"
108
+ class="chat-reasoning-disclosure"
109
+ data-chat-scroll-preserve
110
+ >
111
+ <TelaCollapsibleTrigger as-child>
112
+ <button
113
+ type="button"
114
+ data-chat-scroll-preserve
115
+ class="group"
116
+ data-chat-scroll-preserve-trigger
117
+ inline-flex
118
+ self-start
144
119
  items-center
145
- gap-6px
120
+ gap-4px
121
+ min-h-32px
122
+ max-w-full
123
+ py-6px
124
+ text-left
125
+ transition-colors
126
+ cursor-pointer
127
+ :aria-busy="!completed"
128
+ :aria-label="isExpanded ? 'Ocultar detalhes do trabalho' : 'Mostrar detalhes do trabalho'"
146
129
  >
147
- <span v-if="hasReasoning" body-14-regular text-secondary truncate>
148
- {{ currentStepName }}
149
- </span>
150
130
  <span
151
- v-if="liveFormattedDuration"
152
- body-12-regular
153
- text-tertiary
154
- shrink-0
131
+ v-if="completed"
132
+ key="completed-text"
133
+ body-14-regular
134
+ text-secondary
135
+ tabular-nums
136
+ group-hover:text-primary
137
+ transition-colors
155
138
  >
156
- {{ liveFormattedDuration }}
139
+ Trabalhou por {{ formattedDuration || "alguns momentos" }}
157
140
  </span>
158
- </div>
141
+ <div
142
+ v-else
143
+ key="loading-text"
144
+ flex
145
+ min-w-0
146
+ items-center
147
+ gap-6px
148
+ >
149
+ <span v-if="hasReasoning" body-14-regular text-secondary truncate>
150
+ {{ currentStepName }}
151
+ </span>
152
+ <span
153
+ v-if="liveFormattedDuration"
154
+ body-12-regular
155
+ text-tertiary
156
+ shrink-0
157
+ >
158
+ {{ liveFormattedDuration }}
159
+ </span>
160
+ </div>
159
161
 
160
- <TelaIcon
161
- name="i-ph-caret-right"
162
- size="14px"
163
- color="icon-tertiary"
164
- shrink-0
165
- class="chat-reasoning-disclosure__caret"
166
- :class="{ 'rotate-90': isExpanded }"
167
- />
168
- </button>
169
- </TelaCollapsibleTrigger>
162
+ <TelaIcon
163
+ name="i-ph-caret-right"
164
+ size="14px"
165
+ color="icon-tertiary"
166
+ shrink-0
167
+ class="chat-reasoning-disclosure__caret"
168
+ :class="{ 'rotate-90': isExpanded }"
169
+ />
170
+ </button>
171
+ </TelaCollapsibleTrigger>
170
172
 
171
- <!-- Open while running, collapsible once done -->
172
- <ChatReasoningCollapsibleContent :open="isExpanded">
173
- <div class="chat-reasoning-details" pb-8px>
174
- <ChatReasoningSteps
175
- :reasoning-data="displayedReasoning"
176
- :completed="completed"
177
- />
178
- </div>
179
- </ChatReasoningCollapsibleContent>
180
- </TelaCollapsible>
173
+ <!-- Open while running, collapsible once done -->
174
+ <ChatReasoningCollapsibleContent :open="isExpanded">
175
+ <div class="chat-reasoning-details" pb-8px>
176
+ <ChatReasoningSteps
177
+ :reasoning-data="displayedReasoning"
178
+ :completed="completed"
179
+ />
180
+ </div>
181
+ </ChatReasoningCollapsibleContent>
182
+ </TelaCollapsible>
183
+ </Transition>
181
184
  </div>
182
185
  </template>
183
186
 
184
187
  <style scoped>
185
- .chat-reasoning-details{contain:layout style}.chat-reasoning-disclosure__caret{transition:transform .2s cubic-bezier(.2,0,0,1)}@media (prefers-reduced-motion:reduce){.chat-reasoning-disclosure__caret{transition-duration:0s}}
188
+ .chat-reasoning-stage{display:grid}.chat-reasoning-stage>*{grid-area:1/1;min-width:0}.chat-reasoning-placeholder{align-items:center;align-self:start;display:flex;min-height:32px}.chat-reasoning-presence-enter-active,.chat-reasoning-presence-leave-active{transition:opacity .22s cubic-bezier(.2,0,0,1)}.chat-reasoning-presence-leave-active{pointer-events:none}.chat-reasoning-presence-enter-from,.chat-reasoning-presence-leave-to{opacity:0}.chat-reasoning-details{contain:layout style}.chat-reasoning-disclosure__caret{transition:transform .2s cubic-bezier(.2,0,0,1)}@media (prefers-reduced-motion:reduce){.chat-reasoning-disclosure__caret,.chat-reasoning-presence-enter-active,.chat-reasoning-presence-leave-active{transition-duration:0s}}
186
189
  </style>
@@ -12,7 +12,8 @@ defineProps({
12
12
  <AppMarkdownRender
13
13
  :content="content"
14
14
  :render-code-blocks-as-pre="true"
15
- :typewriter="false"
15
+ :final="!isActive || !!completed"
16
+ :typewriter="!!isActive && !completed"
16
17
  />
17
18
  </ClientOnly>
18
19
  </div>
@@ -11,6 +11,14 @@ export declare function shouldIgnoreChatDebugLayoutShift(entry: {
11
11
  hadRecentInput: boolean;
12
12
  startTime: number;
13
13
  }, ignoreUntil: number): boolean;
14
+ export interface ChatScrollSample {
15
+ top: number;
16
+ height: number;
17
+ viewport: number;
18
+ pinned?: boolean;
19
+ }
20
+ export declare function unexpectedChatScrollMovement(previous: ChatScrollSample, next: ChatScrollSample): number;
21
+ export declare function findChatDebugScrollRoot(stage: HTMLElement): HTMLElement | null;
14
22
  type ListenerTarget = {
15
23
  addEventListener: (type: string, listener: unknown, options?: unknown) => void;
16
24
  removeEventListener: (type: string, listener: unknown, options?: unknown) => void;
@@ -2,6 +2,25 @@ import { nextTick, onBeforeUnmount, onMounted, reactive, watch } from "vue";
2
2
  export function shouldIgnoreChatDebugLayoutShift(entry, ignoreUntil) {
3
3
  return entry.hadRecentInput || entry.startTime <= ignoreUntil;
4
4
  }
5
+ export function unexpectedChatScrollMovement(previous, next) {
6
+ const movement = next.top - previous.top;
7
+ const previousBottom = previous.height - previous.viewport - previous.top;
8
+ const nextBottom = next.height - next.viewport - next.top;
9
+ const following = (previous.pinned ?? previousBottom <= 64) && (next.pinned ?? nextBottom <= 64);
10
+ const targetMovement = Math.max(0, next.height - next.viewport) - previous.top;
11
+ const expected = following ? Math.max(Math.min(0, targetMovement), Math.min(Math.max(0, targetMovement), movement)) : 0;
12
+ return Math.abs(movement - expected);
13
+ }
14
+ export function findChatDebugScrollRoot(stage) {
15
+ let element = stage.querySelector("[data-chat-conversation]");
16
+ while (element) {
17
+ if (element.matches("[data-reka-scroll-area-viewport]") || ["auto", "scroll"].includes(getComputedStyle(element).overflowY)) {
18
+ return element;
19
+ }
20
+ element = element.parentElement;
21
+ }
22
+ return stage.ownerDocument?.scrollingElement ?? null;
23
+ }
5
24
  function resolveListenerCapture(options) {
6
25
  if (typeof options === "boolean")
7
26
  return options;
@@ -76,12 +95,19 @@ export function useChatDebugDiagnostics(options) {
76
95
  let probeInterval;
77
96
  let layoutShiftObserver;
78
97
  let paintMeasurement = 0;
79
- let previousScrollTop = null;
98
+ let previousScroll = null;
99
+ let previousScrollRoot = null;
100
+ const frameSamples = [];
101
+ let frameIntervalMs = 16.67;
102
+ let ignoreScrollUntil = 0;
80
103
  let clockBaselineDriftMs = null;
81
104
  let lastSeenSeekRevision = options.seekRevision.value;
82
105
  let ignoreLayoutShiftUntil = 0;
83
106
  let observedStage = null;
84
107
  function reset() {
108
+ paintMeasurement += 1;
109
+ frameSamples.length = 0;
110
+ frameIntervalMs = 16.67;
85
111
  diagnostics.paintLatencyMs = 0;
86
112
  diagnostics.droppedFrames = 0;
87
113
  diagnostics.domNodes = 0;
@@ -92,7 +118,7 @@ export function useChatDebugDiagnostics(options) {
92
118
  diagnostics.maxScrollJump = 0;
93
119
  diagnostics.clockDriftMs = 0;
94
120
  diagnostics.findings = [];
95
- previousScrollTop = null;
121
+ previousScroll = null;
96
122
  clockBaselineDriftMs = null;
97
123
  lastSeenSeekRevision = options.seekRevision.value;
98
124
  ignoreLayoutShiftUntil = performance.now() + 100;
@@ -103,19 +129,31 @@ export function useChatDebugDiagnostics(options) {
103
129
  diagnostics.findings.push({ kind, label, atMs: options.elapsedMs.value });
104
130
  }
105
131
  function recordFrame(frameDeltaMs) {
106
- if (frameDeltaMs <= 50)
132
+ if (frameDeltaMs <= 0)
133
+ return;
134
+ if (frameDeltaMs < 50) {
135
+ frameSamples.push(frameDeltaMs);
136
+ if (frameSamples.length > 60)
137
+ frameSamples.shift();
138
+ const sorted = [...frameSamples].sort((a, b) => a - b);
139
+ if (sorted.length >= 10)
140
+ frameIntervalMs = sorted[Math.floor(sorted.length * 0.2)];
141
+ }
142
+ if (frameDeltaMs <= Math.max(50, frameIntervalMs * 1.5))
107
143
  return;
108
- diagnostics.droppedFrames += Math.max(1, Math.round(frameDeltaMs / 16.67) - 1);
144
+ diagnostics.droppedFrames += Math.max(1, Math.round(frameDeltaMs / frameIntervalMs) - 1);
109
145
  addFinding("frame", "A long frame interrupted playback");
110
146
  }
111
- function findScrollableElement(root) {
112
- const elements = [root, ...Array.from(root.querySelectorAll("*"))];
113
- return elements.find((element) => {
114
- if (element.scrollHeight <= element.clientHeight + 2)
115
- return false;
116
- const overflowY = getComputedStyle(element).overflowY;
117
- return overflowY === "auto" || overflowY === "scroll";
118
- }) ?? null;
147
+ function rebaseProbes() {
148
+ previousScroll = null;
149
+ clockBaselineDriftMs = null;
150
+ diagnostics.clockDriftMs = 0;
151
+ paintMeasurement += 1;
152
+ ignoreLayoutShiftUntil = performance.now() + 100;
153
+ }
154
+ function handleScrollIntent() {
155
+ previousScroll = null;
156
+ ignoreScrollUntil = performance.now() + 500;
119
157
  }
120
158
  function sampleStage() {
121
159
  const stage = options.stageRef.value;
@@ -132,7 +170,7 @@ export function useChatDebugDiagnostics(options) {
132
170
  const virtualAge = options.elapsedMs.value - mountEvent.atMs;
133
171
  return actualAge - virtualAge;
134
172
  });
135
- const shouldProbeClock = options.playing.value && options.speed.value === "1x" && pendingClockDrifts.length > 0;
173
+ const shouldProbeClock = (typeof document === "undefined" || document.visibilityState !== "hidden") && options.playing.value && options.speed.value === "1x" && pendingClockDrifts.length > 0;
136
174
  if (lastSeenSeekRevision !== options.seekRevision.value) {
137
175
  diagnostics.clockDriftMs = 0;
138
176
  clockBaselineDriftMs = null;
@@ -152,26 +190,34 @@ export function useChatDebugDiagnostics(options) {
152
190
  }
153
191
  if (diagnostics.clockDriftMs > 1e3)
154
192
  addFinding("clock", "A wall-clock timer diverged from virtual playback");
155
- const scrollable = findScrollableElement(stage);
193
+ const scrollable = findChatDebugScrollRoot(stage);
156
194
  if (!scrollable) {
157
195
  diagnostics.scrollPosition = "idle";
158
196
  diagnostics.scrollTop = 0;
159
197
  diagnostics.scrollHeight = stage.scrollHeight;
160
- previousScrollTop = null;
198
+ previousScroll = null;
161
199
  return;
162
200
  }
163
201
  const nextScrollTop = scrollable.scrollTop;
164
202
  const distanceToBottom = scrollable.scrollHeight - scrollable.clientHeight - nextScrollTop;
165
- diagnostics.scrollPosition = distanceToBottom <= 24 ? "pinned" : "detached";
203
+ diagnostics.scrollPosition = scrollable.scrollHeight <= scrollable.clientHeight + 2 ? "idle" : distanceToBottom <= 64 ? "pinned" : "detached";
166
204
  diagnostics.scrollTop = Math.round(nextScrollTop);
167
205
  diagnostics.scrollHeight = scrollable.scrollHeight;
168
- if (previousScrollTop !== null) {
169
- const jump = Math.abs(nextScrollTop - previousScrollTop);
206
+ const pinned = stage.querySelector("[data-chat-bottom-pinned]")?.getAttribute("data-chat-bottom-pinned");
207
+ const nextScroll = {
208
+ top: nextScrollTop,
209
+ height: scrollable.scrollHeight,
210
+ viewport: scrollable.clientHeight,
211
+ pinned: pinned === void 0 || pinned === null ? void 0 : pinned === "true"
212
+ };
213
+ if (previousScroll && previousScrollRoot === scrollable && performance.now() >= ignoreScrollUntil) {
214
+ const jump = unexpectedChatScrollMovement(previousScroll, nextScroll);
170
215
  diagnostics.maxScrollJump = Math.max(diagnostics.maxScrollJump, jump);
171
216
  if (jump > 120)
172
- addFinding("scroll", "Scroll position moved by more than 120 px");
217
+ addFinding("scroll", "Unexpected scroll movement exceeded 120 px");
173
218
  }
174
- previousScrollTop = nextScrollTop;
219
+ previousScroll = nextScroll;
220
+ previousScrollRoot = scrollable;
175
221
  }
176
222
  function setupLayoutShiftProbe() {
177
223
  if (!("PerformanceObserver" in window) || !PerformanceObserver.supportedEntryTypes.includes("layout-shift"))
@@ -199,6 +245,7 @@ export function useChatDebugDiagnostics(options) {
199
245
  if (!target.closest("[data-chat-scroll-preserve], [data-chat-scroll-preserve-trigger]"))
200
246
  return;
201
247
  ignoreLayoutShiftUntil = performance.now() + 500;
248
+ handleScrollIntent();
202
249
  }
203
250
  watch(options.appliedEventCount, async () => {
204
251
  const measurement = ++paintMeasurement;
@@ -208,10 +255,12 @@ export function useChatDebugDiagnostics(options) {
208
255
  if (measurement !== paintMeasurement)
209
256
  return;
210
257
  diagnostics.paintLatencyMs = performance.now() - startedAt;
211
- if (diagnostics.paintLatencyMs > 32)
212
- addFinding("paint", "Event-to-paint latency exceeded 32 ms");
258
+ if (options.playing.value && performance.now() >= ignoreLayoutShiftUntil && diagnostics.paintLatencyMs > Math.max(50, frameIntervalMs * 2)) {
259
+ addFinding("paint", "Event-to-frame latency exceeded the frame budget");
260
+ }
213
261
  });
214
262
  });
263
+ watch(options.seekRevision, rebaseProbes, { flush: "sync" });
215
264
  watch(options.scenario, () => {
216
265
  nextTick(sampleStage);
217
266
  });
@@ -220,12 +269,20 @@ export function useChatDebugDiagnostics(options) {
220
269
  setupLayoutShiftProbe();
221
270
  observedStage = options.stageRef.value;
222
271
  observedStage?.addEventListener?.("click", handleStageClick, true);
272
+ for (const event of ["wheel", "touchstart", "pointerdown", "keydown"])
273
+ observedStage?.addEventListener?.(event, handleScrollIntent, true);
274
+ if (typeof document !== "undefined")
275
+ document.addEventListener("visibilitychange", rebaseProbes);
223
276
  sampleStage();
224
277
  });
225
278
  onBeforeUnmount(() => {
226
279
  if (probeInterval)
227
280
  clearInterval(probeInterval);
228
281
  observedStage?.removeEventListener?.("click", handleStageClick, true);
282
+ for (const event of ["wheel", "touchstart", "pointerdown", "keydown"])
283
+ observedStage?.removeEventListener?.(event, handleScrollIntent, true);
284
+ if (typeof document !== "undefined")
285
+ document.removeEventListener("visibilitychange", rebaseProbes);
229
286
  layoutShiftObserver?.disconnect();
230
287
  });
231
288
  return { diagnostics, reset, recordFrame };
@@ -16,11 +16,11 @@ export function useChatDebugPlayback(scenarios, options = {}) {
16
16
  () => scenarios.find((scenario) => scenario.id === selectedScenarioId.value) ?? scenarios[0]
17
17
  );
18
18
  const manualEvents = shallowRef([]);
19
+ const appliedEventCount = computed(() => countAppliedChatDebugEvents(selectedScenario.value, elapsedMs.value));
19
20
  const playbackState = computed(() => manualEvents.value.reduce(
20
21
  applyChatDebugEvent,
21
- resolveChatDebugPlayback(selectedScenario.value, elapsedMs.value)
22
+ resolveChatDebugPlayback(selectedScenario.value, selectedScenario.value.events[appliedEventCount.value - 1]?.atMs ?? -1)
22
23
  ));
23
- const appliedEventCount = computed(() => countAppliedChatDebugEvents(selectedScenario.value, elapsedMs.value));
24
24
  const currentEvent = computed(() => selectedScenario.value.events[appliedEventCount.value - 1] ?? null);
25
25
  const progress = computed(() => selectedScenario.value.durationMs === 0 ? 0 : elapsedMs.value / selectedScenario.value.durationMs);
26
26
  const uniqueEventTimes = computed(() => Array.from(new Set(selectedScenario.value.events.map((event) => event.atMs))));
@@ -40,7 +40,8 @@ export function useChatDebugPlayback(scenarios, options = {}) {
40
40
  }
41
41
  function seek(nextElapsedMs) {
42
42
  seekRevision.value += 1;
43
- manualEvents.value = [];
43
+ if (manualEvents.value.length > 0)
44
+ manualEvents.value = [];
44
45
  setElapsed(nextElapsedMs);
45
46
  }
46
47
  function restart() {
@@ -89,8 +90,13 @@ export function useChatDebugPlayback(scenarios, options = {}) {
89
90
  previousFrameAt = 0;
90
91
  return;
91
92
  }
93
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
94
+ previousFrameAt = 0;
95
+ animationFrame = requestAnimationFrame(tick);
96
+ return;
97
+ }
92
98
  if (previousFrameAt > 0) {
93
- const frameDelta = Math.min(timestamp - previousFrameAt, 250);
99
+ const frameDelta = timestamp - previousFrameAt;
94
100
  options.onFrame?.(frameDelta);
95
101
  const nextElapsed = speed.value === "gap" ? advanceGapCompressed(frameDelta) : elapsedMs.value + frameDelta * (speed.value === "4x" ? 4 : 1);
96
102
  setElapsed(nextElapsed);
@@ -120,11 +126,18 @@ export function useChatDebugPlayback(scenarios, options = {}) {
120
126
  if (isPlaying)
121
127
  animationFrame = requestAnimationFrame(tick);
122
128
  });
129
+ function handleVisibilityChange() {
130
+ previousFrameAt = 0;
131
+ }
123
132
  onMounted(() => {
124
133
  window.addEventListener("keydown", handleKeyboard);
134
+ if (typeof document !== "undefined")
135
+ document.addEventListener("visibilitychange", handleVisibilityChange);
125
136
  });
126
137
  onBeforeUnmount(() => {
127
138
  window.removeEventListener("keydown", handleKeyboard);
139
+ if (typeof document !== "undefined")
140
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
128
141
  cancelAnimationFrame(animationFrame);
129
142
  });
130
143
  return {
@@ -1,6 +1,8 @@
1
1
  <script setup>
2
2
  import MarkdownRender, { removeCustomComponents, setCustomComponents } from "markstream-vue";
3
3
  import { wrapCustomMarkdownComponents } from "../markdown/custom-components";
4
+ import { STREAMING_TEXT_FADE_MS } from "../markdown/streaming-text-segments";
5
+ import StreamingTextNode from "../markdown/streaming-text-node.vue";
4
6
  const props = defineProps({
5
7
  content: { type: [String, null], required: false, default: "" },
6
8
  final: { type: Boolean, required: false, default: true },
@@ -21,8 +23,27 @@ function escapeSingleDollarSigns(text) {
21
23
  }
22
24
  const escapedContent = computed(() => escapeSingleDollarSigns(props.content ?? ""));
23
25
  const customMarkdownComponents = computed(() => wrapCustomMarkdownComponents(props.customComponents));
24
- watch(customMarkdownComponents, (components) => setCustomComponents(scopeId, components), { immediate: true });
26
+ const streamingTextActive = ref(props.typewriter);
27
+ let streamingTextTimer;
28
+ watch(() => props.typewriter, (typewriter) => {
29
+ if (streamingTextTimer !== void 0) {
30
+ clearTimeout(streamingTextTimer);
31
+ streamingTextTimer = void 0;
32
+ }
33
+ if (typewriter) {
34
+ streamingTextActive.value = true;
35
+ return;
36
+ }
37
+ streamingTextTimer = setTimeout(() => {
38
+ streamingTextTimer = void 0;
39
+ streamingTextActive.value = false;
40
+ }, STREAMING_TEXT_FADE_MS);
41
+ });
42
+ const registeredComponents = computed(() => streamingTextActive.value ? { text: StreamingTextNode, ...customMarkdownComponents.value } : customMarkdownComponents.value);
43
+ watch(registeredComponents, (components) => setCustomComponents(scopeId, components), { immediate: true });
25
44
  onBeforeUnmount(() => {
45
+ if (streamingTextTimer !== void 0)
46
+ clearTimeout(streamingTextTimer);
26
47
  removeCustomComponents(scopeId);
27
48
  });
28
49
  </script>
@@ -0,0 +1,9 @@
1
+ type __VLS_Props = {
2
+ node: {
3
+ content?: string | null;
4
+ center?: boolean;
5
+ };
6
+ };
7
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
8
+ declare const _default: typeof __VLS_export;
9
+ export default _default;
@@ -0,0 +1,82 @@
1
+ <script setup>
2
+ import {
3
+ STREAMING_TEXT_FADE_MS,
4
+ applyStreamingTextContent,
5
+ createStreamingTextState,
6
+ nextStreamingTextSettleAt,
7
+ renderedStreamingText,
8
+ settleStreamingTextSegments
9
+ } from "./streaming-text-segments";
10
+ const props = defineProps({
11
+ node: { type: Object, required: true }
12
+ });
13
+ const attrs = useAttrs();
14
+ const providedFade = inject("markstreamFade", void 0);
15
+ const streamState = inject("markstreamTextStreamState", void 0);
16
+ const indexKey = computed(() => {
17
+ const key = attrs["index-key"] ?? attrs.indexKey;
18
+ return key === void 0 || key === null || key === "" ? "" : String(key);
19
+ });
20
+ const reducedMotion = typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)").matches : false;
21
+ const fadeEnabled = computed(() => {
22
+ const attrFade = attrs.fade;
23
+ if (attrFade === "" || attrFade === true || attrFade === "true")
24
+ return true;
25
+ if (attrFade === false || attrFade === "false")
26
+ return false;
27
+ return typeof providedFade?.value === "boolean" ? providedFade.value : true;
28
+ });
29
+ const animate = computed(() => fadeEnabled.value && !reducedMotion);
30
+ function content() {
31
+ return String(props.node.content ?? "");
32
+ }
33
+ const state = shallowRef(createStreamingTextState(content(), {
34
+ persisted: indexKey.value ? streamState?.get(indexKey.value) : void 0,
35
+ now: Date.now(),
36
+ animate: animate.value
37
+ }));
38
+ let settleTimer;
39
+ function clearSettleTimer() {
40
+ if (settleTimer !== void 0) {
41
+ clearTimeout(settleTimer);
42
+ settleTimer = void 0;
43
+ }
44
+ }
45
+ function scheduleSettle() {
46
+ if (settleTimer !== void 0)
47
+ return;
48
+ const settleAt = nextStreamingTextSettleAt(state.value);
49
+ if (settleAt === null)
50
+ return;
51
+ settleTimer = setTimeout(() => {
52
+ settleTimer = void 0;
53
+ state.value = settleStreamingTextSegments(state.value, Date.now());
54
+ scheduleSettle();
55
+ }, Math.max(0, settleAt - Date.now()) + 16);
56
+ }
57
+ function persist() {
58
+ if (indexKey.value)
59
+ streamState?.set(indexKey.value, renderedStreamingText(state.value));
60
+ }
61
+ watch([content, animate], ([nextContent]) => {
62
+ state.value = applyStreamingTextContent(state.value, nextContent, { now: Date.now(), animate: animate.value });
63
+ persist();
64
+ scheduleSettle();
65
+ });
66
+ persist();
67
+ onMounted(scheduleSettle);
68
+ onBeforeUnmount(clearSettleTimer);
69
+ const fadeStyle = { "--chat-stream-fade-duration": `${STREAMING_TEXT_FADE_MS}ms` };
70
+ </script>
71
+
72
+ <template>
73
+ <span
74
+ class="text-node"
75
+ :class="{ 'text-node-center': node.center }"
76
+ :style="fadeStyle"
77
+ ><span>{{ state.settled }}</span><span
78
+ v-for="segment in state.segments"
79
+ :key="segment.id"
80
+ class="chat-stream-chunk"
81
+ >{{ segment.text }}</span></span>
82
+ </template>
@@ -0,0 +1,9 @@
1
+ type __VLS_Props = {
2
+ node: {
3
+ content?: string | null;
4
+ center?: boolean;
5
+ };
6
+ };
7
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
8
+ declare const _default: typeof __VLS_export;
9
+ export default _default;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Pure state for the streaming text node. Text that has already been shown
3
+ * lives in `settled`; every append while streaming becomes its own segment so
4
+ * several chunks can fade in at once instead of the newest one cutting the
5
+ * previous fade short. Segments are folded back into `settled` once they have
6
+ * been on screen for the fade duration, keeping the DOM small.
7
+ */
8
+ /** How long a streamed chunk takes to fade in. Mirrored to CSS via a custom property. */
9
+ export declare const STREAMING_TEXT_FADE_MS = 320;
10
+ /** Upper bound on live segments so a paused animation clock cannot grow the DOM forever. */
11
+ export declare const STREAMING_TEXT_MAX_SEGMENTS = 48;
12
+ export interface StreamingTextSegment {
13
+ id: number;
14
+ text: string;
15
+ startedAt: number;
16
+ }
17
+ export interface StreamingTextState {
18
+ settled: string;
19
+ segments: readonly StreamingTextSegment[];
20
+ nextId: number;
21
+ }
22
+ export declare function renderedStreamingText(state: StreamingTextState): string;
23
+ /**
24
+ * Initial state for a node. Content that was already rendered by a previous
25
+ * instance of the same node (markstream persists it per index key while it
26
+ * re-parses inline markup) stays settled; only the unseen tail animates.
27
+ */
28
+ export declare function createStreamingTextState(content: string, options: {
29
+ persisted?: string;
30
+ now: number;
31
+ animate: boolean;
32
+ }): StreamingTextState;
33
+ /**
34
+ * Apply the next content snapshot. A pure append becomes a new segment; any
35
+ * other change (rewrite, shrink, animation disabled) settles everything so
36
+ * the text never lags behind the parser.
37
+ */
38
+ export declare function applyStreamingTextContent(state: StreamingTextState, content: string, options: {
39
+ now: number;
40
+ animate: boolean;
41
+ }): StreamingTextState;
42
+ /** Fold segments that finished fading into `settled`. Order is preserved, so only a settled head can be folded. */
43
+ export declare function settleStreamingTextSegments(state: StreamingTextState, now: number, durationMs?: number): StreamingTextState;
44
+ /** Fold the oldest segments until at most `max` remain live. */
45
+ export declare function capStreamingTextSegments(state: StreamingTextState, max: number): StreamingTextState;
46
+ /** Time at which the oldest live segment finishes fading, or null when nothing is animating. */
47
+ export declare function nextStreamingTextSettleAt(state: StreamingTextState, durationMs?: number): number | null;
@@ -0,0 +1,51 @@
1
+ export const STREAMING_TEXT_FADE_MS = 320;
2
+ export const STREAMING_TEXT_MAX_SEGMENTS = 48;
3
+ export function renderedStreamingText(state) {
4
+ let text = state.settled;
5
+ for (const segment of state.segments)
6
+ text += segment.text;
7
+ return text;
8
+ }
9
+ export function createStreamingTextState(content, options) {
10
+ const { persisted, now, animate } = options;
11
+ if (animate && persisted !== void 0 && content.length > persisted.length && content.startsWith(persisted)) {
12
+ return {
13
+ settled: persisted,
14
+ segments: [{ id: 0, text: content.slice(persisted.length), startedAt: now }],
15
+ nextId: 1
16
+ };
17
+ }
18
+ return { settled: content, segments: [], nextId: 0 };
19
+ }
20
+ export function applyStreamingTextContent(state, content, options) {
21
+ const { now, animate } = options;
22
+ const rendered = renderedStreamingText(state);
23
+ if (content === rendered && (animate || state.segments.length === 0))
24
+ return state;
25
+ if (!animate || !content.startsWith(rendered))
26
+ return { settled: content, segments: [], nextId: state.nextId };
27
+ const segments = [...state.segments, { id: state.nextId, text: content.slice(rendered.length), startedAt: now }];
28
+ return capStreamingTextSegments({ settled: state.settled, segments, nextId: state.nextId + 1 }, STREAMING_TEXT_MAX_SEGMENTS);
29
+ }
30
+ export function settleStreamingTextSegments(state, now, durationMs = STREAMING_TEXT_FADE_MS) {
31
+ let settledCount = 0;
32
+ while (settledCount < state.segments.length && state.segments[settledCount].startedAt + durationMs <= now)
33
+ settledCount += 1;
34
+ if (settledCount === 0)
35
+ return state;
36
+ return foldStreamingTextSegments(state, settledCount);
37
+ }
38
+ export function capStreamingTextSegments(state, max) {
39
+ const excess = state.segments.length - max;
40
+ return excess > 0 ? foldStreamingTextSegments(state, excess) : state;
41
+ }
42
+ export function nextStreamingTextSettleAt(state, durationMs = STREAMING_TEXT_FADE_MS) {
43
+ const head = state.segments[0];
44
+ return head ? head.startedAt + durationMs : null;
45
+ }
46
+ function foldStreamingTextSegments(state, count) {
47
+ let settled = state.settled;
48
+ for (let index = 0; index < count; index += 1)
49
+ settled += state.segments[index].text;
50
+ return { settled, segments: state.segments.slice(count), nextId: state.nextId };
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/chat-nuxt",
3
- "version": "3.16.0",
3
+ "version": "3.17.0-rc.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {