@linto-ai/transcript-ui-core 0.9.23 → 0.9.24-unstable.202610151359

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.
@@ -0,0 +1,94 @@
1
+ <script setup lang="ts">
2
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
3
+
4
+ defineProps<{ active: boolean }>()
5
+
6
+ const { t } = useI18n()
7
+ </script>
8
+
9
+ <template>
10
+ <!-- Always rendered, only hidden when nobody is talking: the panel's
11
+ stick-to-bottom watches .turns-container's height, so an element that
12
+ came and went in here would rescroll the whole transcript at every
13
+ silence. `visibility` keeps the box and drops it from the
14
+ accessibility tree.
15
+
16
+ Not a live region either: it comes back with every pause, and a screen
17
+ reader announcing it each time would talk over the transcript it is
18
+ supposed to accompany. -->
19
+ <p
20
+ class="speech-activity"
21
+ :class="{ 'speech-activity--idle': !active }"
22
+ role="img"
23
+ :aria-label="t('transcription.speaking')">
24
+ <span class="speech-activity-dot"></span>
25
+ <span class="speech-activity-dot"></span>
26
+ <span class="speech-activity-dot"></span>
27
+ </p>
28
+ </template>
29
+
30
+ <style scoped>
31
+ /* Typing bubble, as in messaging apps. The box never changes size, only the
32
+ dots move inside it, so the transcript above stays still. */
33
+ .speech-activity {
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 5px;
37
+ width: fit-content;
38
+ margin: var(--spacing-sm) var(--spacing-lg);
39
+ padding: var(--spacing-sm) var(--spacing-md);
40
+ border-radius: var(--radius-lg) var(--radius-lg) var(--radius-lg)
41
+ var(--radius-sm);
42
+ background-color: var(--color-surface-hover);
43
+ transition:
44
+ opacity 0.4s ease,
45
+ visibility 0.4s;
46
+ }
47
+
48
+ .speech-activity-dot {
49
+ width: 7px;
50
+ height: 7px;
51
+ border-radius: 50%;
52
+ background-color: var(--color-text-muted);
53
+ animation: speech-activity-hop 1.2s infinite ease-in-out;
54
+ }
55
+
56
+ .speech-activity-dot:nth-child(2) {
57
+ animation-delay: 0.15s;
58
+ }
59
+
60
+ .speech-activity-dot:nth-child(3) {
61
+ animation-delay: 0.3s;
62
+ }
63
+
64
+ @keyframes speech-activity-hop {
65
+ 0%,
66
+ 60%,
67
+ 100% {
68
+ transform: translateY(0);
69
+ opacity: 0.55;
70
+ }
71
+ 30% {
72
+ transform: translateY(-5px);
73
+ opacity: 1;
74
+ }
75
+ }
76
+
77
+ .speech-activity--idle {
78
+ opacity: 0;
79
+ visibility: hidden;
80
+ }
81
+
82
+ @media (prefers-reduced-motion: reduce) {
83
+ .speech-activity-dot {
84
+ animation: none;
85
+ opacity: 0.8;
86
+ }
87
+ }
88
+
89
+ @media (max-width: 767px) {
90
+ .speech-activity {
91
+ margin-inline: var(--spacing-md);
92
+ }
93
+ }
94
+ </style>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch, onBeforeUnmount } from "vue"
2
+ import { computed, ref, watch, onBeforeUnmount } from "vue"
3
3
  import Layout from "./Layout.vue"
4
4
  import EditorLoadingOverlay from "./EditorLoadingOverlay.vue"
5
5
  import EditorErrorOverlay from "./EditorErrorOverlay.vue"
@@ -16,11 +16,17 @@ const props = withDefaults(
16
16
  // tab left — see TabBar.vue) — a live session has no finished verbatim
17
17
  // to show, see SessionLiveNG.vue.
18
18
  noVerbatim?: boolean
19
+ // Hides the high-contrast switch. The theme it drives is the host's as
20
+ // much as the editor's — the host follows "theme:change" to darken its
21
+ // own chrome — so an app that has no dark values of its own turns the
22
+ // switch off rather than offering a half-dark screen.
23
+ noThemeToggle?: boolean
19
24
  }>(),
20
25
  {
21
26
  locale: "fr",
22
27
  noHeader: false,
23
28
  noVerbatim: false,
29
+ noThemeToggle: false,
24
30
  },
25
31
  )
26
32
 
@@ -37,6 +43,21 @@ watch(
37
43
  const core = createCore()
38
44
  provideCore(core)
39
45
 
46
+ // A host cannot theme the editor from outside: the token declarations sit on
47
+ // this very root, which is closer than the custom element, so they win over
48
+ // anything set on <linto-editor>. An inline style is the one thing that
49
+ // outranks them — hence the brand colour travels through core.primaryColor
50
+ // rather than through CSS. The hover shade is derived so the pair stays
51
+ // coherent instead of leaving the editor's own blue behind.
52
+ const brandStyle = computed(() =>
53
+ core.primaryColor.value
54
+ ? {
55
+ "--color-primary": core.primaryColor.value,
56
+ "--color-primary-hover": `color-mix(in srgb, ${core.primaryColor.value} 85%, #000)`,
57
+ }
58
+ : undefined,
59
+ )
60
+
40
61
  // The editor owns its loading/error overlays: loading stays up until the
41
62
  // document is loaded and (in collab mode) the first sync lands; error shows
42
63
  // when the load fails non-recoverably. Embedders don't poll or render either.
@@ -50,11 +71,20 @@ defineExpose({ core })
50
71
  </script>
51
72
 
52
73
  <template>
53
- <div class="transcript-ui-root">
74
+ <div
75
+ class="transcript-ui-root"
76
+ :data-theme="core.theme.value"
77
+ :style="brandStyle">
54
78
  <Layout
55
79
  v-if="core.channels.size"
56
80
  :show-header="!props.noHeader"
57
- :show-verbatim="!props.noVerbatim" />
81
+ :show-verbatim="!props.noVerbatim"
82
+ :show-theme-toggle="!props.noThemeToggle">
83
+ <!-- Declared on the custom element's root component so Vue renders a
84
+ native <slot>: the host's children with slot="header-start" land
85
+ in the header. -->
86
+ <template #header-start><slot name="header-start" /></template>
87
+ </Layout>
58
88
  <EditorErrorOverlay v-if="error" :message="error" />
59
89
  <EditorLoadingOverlay v-else-if="isLoading" />
60
90
  </div>
@@ -11,6 +11,7 @@ import {
11
11
  import { useStickToBottom } from "vue-stick-to-bottom"
12
12
  import TranscriptionTurn from "./TranscriptionTurn.vue"
13
13
  import TranscriptionEmpty from "./TranscriptionEmpty.vue"
14
+ import SpeechActivityIndicator from "./SpeechActivityIndicator.vue"
14
15
  import { useCore } from "../core"
15
16
  import { useI18n } from "@linto-ai/transcript-ui-i18n"
16
17
  import { useFollowPlayback } from "../composables/useFollowPlayback"
@@ -41,6 +42,21 @@ const partialTurn = computed(() => {
41
42
  })
42
43
 
43
44
  const hasLiveUpdate = computed(() => core.live?.hasLiveUpdate.value ?? false)
45
+
46
+ // Set here rather than on a global stylesheet: the value stays scoped to the
47
+ // reading surface, and the partial turn inherits it like any other.
48
+ const transcriptFontSize = computed(() => `${core.transcriptFontSize.value}px`)
49
+
50
+ // With the partial text hidden, the panel would sit perfectly still between
51
+ // two finalized turns — nothing left to say the session is still listening.
52
+ // The line is kept for the whole session rather than mounted when someone
53
+ // speaks: useStickToBottom watches .turns-container, so anything appearing
54
+ // and disappearing in there drags the transcript with it (the indicator
55
+ // hides in place instead — see SpeechActivityIndicator).
56
+ const showSpeechIndicator = computed(
57
+ () => core.live !== undefined && core.live.partialsVisible.value === false,
58
+ )
59
+ const isSpeechActive = computed(() => core.live?.isSpeechActive.value === true)
44
60
  const isPlaying = computed(() => core.audio?.isPlaying.value ?? false)
45
61
 
46
62
  const activeTranslation = computed(
@@ -142,7 +158,9 @@ onBeforeUnmount(() => {
142
158
  </script>
143
159
 
144
160
  <template>
145
- <article class="transcription-panel">
161
+ <article
162
+ class="transcription-panel"
163
+ :style="{ '--transcript-font-size': transcriptFontSize }">
146
164
  <div ref="scrollContainer" class="scroll-container">
147
165
  <div class="turns-container">
148
166
  <div v-if="isLoadingHistory" class="history-loading" role="status">
@@ -173,6 +191,9 @@ onBeforeUnmount(() => {
173
191
  key="__partial__"
174
192
  :turn="partialTurn"
175
193
  partial />
194
+ <SpeechActivityIndicator
195
+ v-if="showSpeechIndicator"
196
+ :active="isSpeechActive" />
176
197
  </div>
177
198
 
178
199
  <Transition name="fade-slide">
@@ -247,11 +268,11 @@ onBeforeUnmount(() => {
247
268
  align-items: center;
248
269
  }
249
270
 
250
- .resume-scroll-btn {
271
+ .transcript-ui-root .resume-scroll-btn {
251
272
  /* No backdrop-filter: this button is sticky inside the tall scroll
252
273
  container, where a backdrop-filter makes WebRender allocate a render
253
274
  target spanning the whole scroll height — multi-GB on a long transcript. */
254
- background: white !important;
275
+ background: var(--color-surface) !important;
255
276
  border: 1px solid var(--color-border);
256
277
  box-shadow: var(--shadow-sm);
257
278
  }
@@ -4,7 +4,7 @@ import { computed, ref, useTemplateRef } from "vue"
4
4
  import SpeakerLabel from "./SpeakerLabel.vue"
5
5
  import MergeTurnsButton from "./molecules/MergeTurnsButton.vue"
6
6
  import SpeakerPopover from "./molecules/SpeakerPopover.vue"
7
- import { useCore } from "../core"
7
+ import { useCore, CROSS_TRANSLATION_ID } from "../core"
8
8
  import { useTurnSelection } from "../composables/useTurnSelection"
9
9
  import { useI18n } from "@linto-ai/transcript-ui-i18n"
10
10
  import * as utils from "../utils"
@@ -47,6 +47,15 @@ const isTurnActive = computed(() => {
47
47
 
48
48
  const speakerColor = computed(() => props.speaker?.color ?? "transparent")
49
49
 
50
+ // On a real translation track every turn is in the same language and the
51
+ // selector already names it. The source keeps it — a channel can be
52
+ // multilingual — and so does cross mode, where turns alternate on purpose.
53
+ const showLanguage = computed(() => {
54
+ const active = core.activeChannel.value?.activeTranslation.value
55
+ if (!active) return true
56
+ return active.isSource || active.id === CROSS_TRANSLATION_ID
57
+ })
58
+
50
59
  const isSelected = computed(() => selection.isSelected(props.turn.id))
51
60
 
52
61
  const checkboxLabel = computed(() => {
@@ -78,6 +87,12 @@ const turnLock = computed(() =>
78
87
  : core.transcriptionEditor?.getTurnLock(props.turn.id),
79
88
  )
80
89
 
90
+ // Both things that can fill the header's action slot — the save/cancel pair
91
+ // while editing, another session's lock avatar otherwise — come from the
92
+ // transcription editor. Without that plugin (a live session) the row can
93
+ // never host anything, so it has no height to reserve.
94
+ const hasEditActions = computed(() => core.transcriptionEditor !== undefined)
95
+
81
96
  const lockedByLabel = computed(() =>
82
97
  turnLock.value
83
98
  ? t("transcription.lockedBy").replace("{name}", turnLock.value.userName)
@@ -202,7 +217,11 @@ function onCheckboxChange(event: MouseEvent) {
202
217
  v-if="previousTurnId && !partial && !live"
203
218
  :first-turn-id="previousTurnId"
204
219
  :second-turn-id="turn.id" />
205
- <div v-if="!partial" class="turn-header" @click="onHeaderClick">
220
+ <div
221
+ v-if="!partial"
222
+ class="turn-header"
223
+ :class="{ 'turn-header--with-actions': hasEditActions }"
224
+ @click="onHeaderClick">
206
225
  <EditorCheckbox
207
226
  v-if="selection.hasSelection.value"
208
227
  :model-value="isSelected"
@@ -218,6 +237,7 @@ function onCheckboxChange(event: MouseEvent) {
218
237
  :start-time="turn.startTime"
219
238
  :start-date="turn.startDate"
220
239
  :language="turn.language"
240
+ :show-language="showLanguage"
221
241
  interactive />
222
242
  </SpeakerPopover>
223
243
  <button
@@ -230,6 +250,7 @@ function onCheckboxChange(event: MouseEvent) {
230
250
  :start-time="turn.startTime"
231
251
  :start-date="turn.startDate"
232
252
  :language="turn.language"
253
+ :show-language="showLanguage"
233
254
  interactive />
234
255
  </button>
235
256
  <SpeakerLabel
@@ -237,7 +258,8 @@ function onCheckboxChange(event: MouseEvent) {
237
258
  :speaker="speaker"
238
259
  :start-time="turn.startTime"
239
260
  :start-date="turn.startDate"
240
- :language="turn.language" />
261
+ :language="turn.language"
262
+ :show-language="showLanguage" />
241
263
  <div v-if="isEditing || turnLock" class="turn-edit-actions">
242
264
  <template v-if="isEditing">
243
265
  <Button
@@ -308,8 +330,13 @@ function onCheckboxChange(event: MouseEvent) {
308
330
  user-select: none;
309
331
  border-radius: var(--radius-sm);
310
332
  padding: var(--spacing-xxs) 0;
311
- /* Reserve the edit-actions height (Button sm) so entering/leaving edit
312
- mode never shifts the layout. */
333
+ }
334
+
335
+ /* Reserve the edit-actions height (Button sm) so entering/leaving edit mode
336
+ never shifts the layout — but only where those actions can appear at all.
337
+ Read-only, the row holds a line of metadata and has no reason to be 36px
338
+ tall. */
339
+ .turn-header--with-actions {
313
340
  min-height: 36px;
314
341
  }
315
342
 
@@ -339,7 +366,7 @@ function onCheckboxChange(event: MouseEvent) {
339
366
 
340
367
  .turn-text {
341
368
  margin-top: var(--spacing-xs);
342
- font-size: var(--font-size-base);
369
+ font-size: var(--transcript-font-size);
343
370
  line-height: var(--line-height);
344
371
  color: var(--color-text-primary);
345
372
  }
@@ -1,4 +1,4 @@
1
- import { ref, watch, onMounted, onBeforeUnmount, type Ref } from "vue"
1
+ import { ref, watch, nextTick, onMounted, onBeforeUnmount, type Ref } from "vue"
2
2
  import { useCore } from "../core"
3
3
  import { activeWordRange } from "../utils/wordRange"
4
4
 
@@ -84,6 +84,9 @@ export function useFollowPlayback(
84
84
  // track — symmetric with the isPlaying re-enable above.
85
85
  const stopSeekListener = core.on("audio:seek", () => {
86
86
  isFollowing.value = true
87
+ // The active word is recomputed on the next flush — scroll after it, so a
88
+ // seek landing on the already-active word still brings it back into view.
89
+ nextTick(scrollToActive)
87
90
  })
88
91
 
89
92
  function onManualScroll() {
@@ -1,26 +1,9 @@
1
- import { ref, onMounted, onBeforeUnmount } from 'vue'
2
-
3
- const MOBILE_BREAKPOINT = '(max-width: 767px)'
4
-
5
- export function useIsMobile() {
6
- // Read right away, not on mount: the first render must already match the
7
- // viewport, or a phone briefly mounts desktop-only UI before swapping it.
8
- const isMobile = ref(window.matchMedia(MOBILE_BREAKPOINT).matches)
9
- let mql: MediaQueryList | null = null
10
-
11
- function onChange(e: MediaQueryListEvent) {
12
- isMobile.value = e.matches
13
- }
14
-
15
- onMounted(() => {
16
- mql = window.matchMedia(MOBILE_BREAKPOINT)
17
- isMobile.value = mql.matches
18
- mql.addEventListener('change', onChange)
19
- })
20
-
21
- onBeforeUnmount(() => {
22
- mql?.removeEventListener('change', onChange)
23
- })
24
-
25
- return { isMobile }
1
+ import type { Ref } from "vue"
2
+ import { useCore } from "../core/useCore"
3
+
4
+ /** The core owns the breakpoint (see core/modules/viewport) — this is the
5
+ * component-side reader, kept as a composable for the call sites that had
6
+ * one, and for plugin panels that switch layout at the same width. */
7
+ export function useIsMobile(): { isMobile: Readonly<Ref<boolean>> } {
8
+ return { isMobile: useCore().isMobile }
26
9
  }
@@ -0,0 +1,18 @@
1
+ import type { CoreCapabilities, SummaryCapabilities } from "./types"
2
+
3
+ /** What the reports offered before capabilities covered them: generate and
4
+ * edit, but no generation of a report that is already up to date. */
5
+ export const DEFAULT_SUMMARY_CAPABILITIES: Readonly<SummaryCapabilities> =
6
+ Object.freeze({ generate: true, edit: true, forceGenerate: false })
7
+
8
+ /** The report permissions, defaults filled in for a host that sets none. */
9
+ export function computeSummaryCapabilities(
10
+ capabilities: CoreCapabilities,
11
+ ): Readonly<SummaryCapabilities> {
12
+ return capabilities.summary ?? DEFAULT_SUMMARY_CAPABILITIES
13
+ }
14
+
15
+ /** Whether the reader may chat. A host that sets nothing keeps the chat. */
16
+ export function computeCanChat(capabilities: CoreCapabilities): boolean {
17
+ return capabilities.chat !== "disabled"
18
+ }
@@ -1,9 +1,10 @@
1
- import { ref, computed, shallowReactive, type Component } from "vue"
1
+ import { ref, computed, shallowReactive, watch, type Component } from "vue"
2
2
  import type { DownloadFormat } from "@linto-ai/transcript-ui-ui"
3
3
  import type { Channel, EditorDocument } from "../types/editor"
4
4
  import type {
5
5
  Core,
6
6
  CoreOptions,
7
+ EditorTheme,
7
8
  CoreEventMap,
8
9
  CoreCapabilities,
9
10
  CorePlugin,
@@ -11,6 +12,7 @@ import type {
11
12
  UISlot,
12
13
  } from "./types"
13
14
  import { createEventBus } from "./modules/eventBus"
15
+ import { createViewport } from "./modules/viewport"
14
16
  import { createSpeakersStore } from "./stores/speakersStore"
15
17
  import { createChannelStore } from "./stores/channelStore"
16
18
  import type { ChannelStore } from "./types"
@@ -18,6 +20,9 @@ import { ensureDocumentSpeakers } from "./helpers/ensureDocumentSpeakers"
18
20
  import { ensureSpeakersFromTurns } from "./helpers/ensureSpeakersFromTurns"
19
21
  import * as utils from "../utils"
20
22
 
23
+ /** 1.125rem, the --font-size-base the transcript used before it was tunable. */
24
+ const DEFAULT_TRANSCRIPT_FONT_SIZE = 18
25
+
21
26
  // No backend can generate this — it's just the turns joined into text — so
22
27
  // it's the one format the core always offers and fulfills itself.
23
28
  const DEFAULT_VERBATIM_FORMATS: DownloadFormat[] = [
@@ -49,6 +54,49 @@ export function createCore(options: CoreOptions = {}): Core {
49
54
 
50
55
  const { on, off, emit, clear: clearEvents } = createEventBus<CoreEventMap>()
51
56
 
57
+ // ── Reading ────────────────────────────────────────────────────────
58
+
59
+ const transcriptFontSize = ref(DEFAULT_TRANSCRIPT_FONT_SIZE)
60
+ const theme = ref<EditorTheme>("light")
61
+ const primaryColor = ref<string | null>(null)
62
+ // Announced like the reading size: the editor applies the theme to its own
63
+ // root, and a host that wants its chrome to follow listens for it.
64
+ const stopThemeSync = watch(theme, (next) =>
65
+ emit("theme:change", { theme: next }),
66
+ )
67
+ // Announced rather than stored: the editor keeps no preferences of its own
68
+ // (it holds no storage at all), so a host that wants this to survive a
69
+ // reload listens and restores it — the same deal as the watermark.
70
+ const stopFontSizeSync = watch(transcriptFontSize, (fontSize) =>
71
+ emit("transcript:fontSize", { fontSize }),
72
+ )
73
+
74
+ // ── Layout chrome ──────────────────────────────────────────────────
75
+
76
+ // The core owns the media query rather than each component watching its
77
+ // own: a host rendering its own header reads the same value the layout
78
+ // switches on, and there is a single listener to release on destroy.
79
+ const viewport = createViewport(onViewportChange)
80
+ const sidebarOpen = ref(false)
81
+
82
+ // Only the phone-width layout mounts the sidebar drawer, so "open" is
83
+ // refused anywhere else instead of being stored: a host header that isn't
84
+ // width-gated would otherwise leave the drawer armed and drop it on screen,
85
+ // focus trapped, at the next resize below the breakpoint.
86
+ function setSidebarOpen(open: boolean): void {
87
+ if (open && !viewport.isMobile.value) return
88
+ if (open === sidebarOpen.value) return
89
+ sidebarOpen.value = open
90
+ emit("sidebar:open", { open })
91
+ }
92
+
93
+ function onViewportChange(isMobile: boolean): void {
94
+ emit("viewport:change", { isMobile })
95
+ // Same invariant, the other way round: leaving phone width unmounts the
96
+ // drawer, so the state can't keep claiming it is open.
97
+ if (!isMobile) setSidebarOpen(false)
98
+ }
99
+
52
100
  // ── Speakers ───────────────────────────────────────────────────────
53
101
 
54
102
  const speakersInternal = createSpeakersStore(emit)
@@ -116,8 +164,28 @@ export function createCore(options: CoreOptions = {}): Core {
116
164
  if (channelId === activeChannelId.value) return
117
165
  activeChannelId.value = channelId
118
166
  emit("channel:change", { channelId })
167
+ // A channel change restores the track that channel was on, without a
168
+ // translation:change of its own.
169
+ emitActiveTrackChange()
119
170
  }
120
171
 
172
+ // ── Active track ───────────────────────────────────────────────────
173
+
174
+ function emitActiveTrackChange(): void {
175
+ const channel = activeChannel.value
176
+ if (!channel) return
177
+ emit("activeTrack:change", {
178
+ channelId: channel.id,
179
+ translationId: channel.activeTranslation.value.id,
180
+ })
181
+ }
182
+
183
+ // A channel store doesn't know whether it is the displayed one: the core
184
+ // relays its translation:change only for the active channel.
185
+ on("translation:change", ({ channelId }) => {
186
+ if (channelId === activeChannel.value?.id) emitActiveTrackChange()
187
+ })
188
+
121
189
  function setChannel(channelId: string, channel: Channel): void {
122
190
  if (!channels.has(channelId)) return
123
191
 
@@ -145,6 +213,9 @@ export function createCore(options: CoreOptions = {}): Core {
145
213
  cleanups.forEach((fn) => fn())
146
214
  cleanups.length = 0
147
215
  for (const channel of channels.values()) channel.dispose()
216
+ stopFontSizeSync()
217
+ stopThemeSync()
218
+ viewport.destroy()
148
219
  clearEvents()
149
220
  }
150
221
 
@@ -166,6 +237,12 @@ export function createCore(options: CoreOptions = {}): Core {
166
237
  speakers,
167
238
  channels,
168
239
  activeChannel,
240
+ transcriptFontSize,
241
+ theme,
242
+ primaryColor,
243
+ isMobile: viewport.isMobile,
244
+ sidebarOpen,
245
+ setSidebarOpen,
169
246
  components,
170
247
  onActiveTranslation,
171
248
  setDocument,
package/src/core/index.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  export { createCore } from './createCore'
2
2
  export { provideCore, useCore } from './useCore'
3
3
  export { CROSS_TRANSLATION_ID } from './stores'
4
+ export {
5
+ DEFAULT_SUMMARY_CAPABILITIES,
6
+ computeSummaryCapabilities,
7
+ computeCanChat,
8
+ } from './capabilities'
4
9
  export type {
5
10
  Core,
6
11
  CorePlugin,
7
12
  CoreEventMap,
8
13
  CoreOptions,
9
14
  CoreCapabilities,
15
+ SummaryCapabilities,
10
16
  TurnEventKey,
11
17
  UISlot,
12
18
  ReadableTranslation,
@@ -43,6 +49,7 @@ export type {
43
49
  LLMServicesPluginApi,
44
50
  ChatRole,
45
51
  ChatMessage,
52
+ ChatMessageError,
46
53
  ChatSession,
47
54
  ChatPluginApi,
48
55
  } from './types'
@@ -0,0 +1,48 @@
1
+ import { ref, type Ref } from "vue"
2
+
3
+ /** Same width the layout's CSS switches on — keep the two in sync. */
4
+ const MOBILE_BREAKPOINT = "(max-width: 767px)"
5
+
6
+ export interface Viewport {
7
+ readonly isMobile: Readonly<Ref<boolean>>
8
+ destroy(): void
9
+ }
10
+
11
+ /** Owns the mobile-width media query for the whole core: one listener for
12
+ * every consumer, released on core.destroy(). `onChange` fires on every
13
+ * crossing of the breakpoint, synchronously — what the core hangs its
14
+ * layout invariants on, rather than a watcher that lands a tick later.
15
+ *
16
+ * Outside a browser (unit tests run without a DOM) there is no viewport to
17
+ * watch, so isMobile stays false rather than the core failing to build. */
18
+ export function createViewport(
19
+ onChange: (isMobile: boolean) => void,
20
+ ): Viewport {
21
+ const isMobile = ref(false)
22
+
23
+ if (
24
+ typeof window === "undefined" ||
25
+ typeof window.matchMedia !== "function"
26
+ ) {
27
+ return { isMobile, destroy() {} }
28
+ }
29
+
30
+ // Read synchronously, not on mount: the first render must already match
31
+ // the viewport, or a phone briefly mounts desktop-only UI before swapping.
32
+ const query = window.matchMedia(MOBILE_BREAKPOINT)
33
+ isMobile.value = query.matches
34
+
35
+ function onQueryChange(event: MediaQueryListEvent): void {
36
+ isMobile.value = event.matches
37
+ onChange(event.matches)
38
+ }
39
+
40
+ query.addEventListener("change", onQueryChange)
41
+
42
+ return {
43
+ isMobile,
44
+ destroy() {
45
+ query.removeEventListener("change", onQueryChange)
46
+ },
47
+ }
48
+ }
@@ -65,7 +65,10 @@ export function createChannelStore(
65
65
  const normalized = translationId === sourceTranslation.id ? null : translationId
66
66
  if (normalized === activeTranslationId.value) return
67
67
  activeTranslationId.value = normalized
68
- emit("translation:change", { translationId: activeTranslation.value.id })
68
+ emit("translation:change", {
69
+ channelId: id,
70
+ translationId: activeTranslation.value.id,
71
+ })
69
72
  }
70
73
 
71
74
  function reset(): void {
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { createTranslationStore } from "../translationStore"
3
+
4
+ function makeStore() {
5
+ return createTranslationStore(
6
+ { id: "tr-1", languages: ["fr"], isSource: true, turns: [] },
7
+ () => {},
8
+ () => {},
9
+ )
10
+ }
11
+
12
+ describe("translationStore — advanceLastModifiedAt", () => {
13
+ it("moves forward only", () => {
14
+ const store = makeStore()
15
+ store.advanceLastModifiedAt(2000)
16
+ store.advanceLastModifiedAt(1000)
17
+ store.advanceLastModifiedAt(2000)
18
+ expect(store.lastModifiedAt.value).toBe(2000)
19
+
20
+ store.advanceLastModifiedAt(3000)
21
+ expect(store.lastModifiedAt.value).toBe(3000)
22
+ })
23
+
24
+ it("ignores null and non-finite timestamps", () => {
25
+ const store = makeStore()
26
+ store.advanceLastModifiedAt(null)
27
+ store.advanceLastModifiedAt(Number.NaN)
28
+ expect(store.lastModifiedAt.value).toBeNull()
29
+
30
+ store.advanceLastModifiedAt(1000)
31
+ store.advanceLastModifiedAt(null)
32
+ store.advanceLastModifiedAt(Number.POSITIVE_INFINITY)
33
+ expect(store.lastModifiedAt.value).toBe(1000)
34
+ })
35
+ })
@@ -28,7 +28,10 @@ export function createTranslationStore(
28
28
  const turns = shallowRef<Turn[]>(init.turns)
29
29
  const lastModifiedAt = ref<number | null>(null)
30
30
 
31
- function setLastModifiedAt(ts: number | null): void {
31
+ function advanceLastModifiedAt(ts: number | null): void {
32
+ if (ts == null || !Number.isFinite(ts)) return
33
+ const current = lastModifiedAt.value
34
+ if (current != null && ts <= current) return
32
35
  lastModifiedAt.value = ts
33
36
  }
34
37
 
@@ -118,7 +121,7 @@ export function createTranslationStore(
118
121
  audio,
119
122
  turns,
120
123
  lastModifiedAt,
121
- setLastModifiedAt,
124
+ advanceLastModifiedAt,
122
125
  addTurn,
123
126
  prependTurns,
124
127
  updateTurn,