* feat: persist AI conversations and add chat history Store transcripts and attachment previews in IndexedDB, separate conversation history from transport lifetime, and add document-aware switching with shared Storybook coverage. Preserve interrupted activity and guard stale writes and async switches. * test: group chat history tests by domain * feat: simplify chat history navigation and diagnostics Use a compact header with searchable document-scoped history and a correctly anchored conversation menu. Move diagnostic copying into the menu with copy-result feedback, and separate Storybook fixtures from composition. * fix: address chat history review findings * test: cover harness shutdown and restored chat scrolling * fix: preserve Portless proxy port in MCP routes * docs: clarify UI animation conventions * feat: configure reasoning display and animate disclosure * fix: separate transcript following from reasoning disclosure * fix: restore selected chat after document recovery * refactor: separate chat history persistence and sessions * style: format reasoning story imports
79 lines
2.3 KiB
TypeScript
79 lines
2.3 KiB
TypeScript
import { useEventListener, useResizeObserver, useScroll } from '@vueuse/core'
|
|
import { onScopeDispose, ref, watch, type Ref } from 'vue'
|
|
|
|
/** Output following is a user-controlled mode, not a distance threshold. */
|
|
export function useScrollFollowing(
|
|
viewport: Ref<HTMLElement | undefined>,
|
|
content: Ref<HTMLElement | undefined>,
|
|
submitted: Ref<boolean>
|
|
) {
|
|
const following = ref(true)
|
|
let frame: number | undefined
|
|
let userScrolling = false
|
|
const { arrivedState, measure } = useScroll(viewport, {
|
|
onScroll: () => {
|
|
if (userScrolling) following.value = arrivedState.bottom
|
|
},
|
|
onStop: () => {
|
|
userScrolling = false
|
|
}
|
|
})
|
|
|
|
function scheduleFollow() {
|
|
if (!following.value || frame !== undefined) return
|
|
frame = requestAnimationFrame(() => {
|
|
frame = undefined
|
|
if (!following.value || !viewport.value) return
|
|
viewport.value.scrollTop = viewport.value.scrollHeight
|
|
measure()
|
|
})
|
|
}
|
|
|
|
function resumeFollowing() {
|
|
following.value = true
|
|
userScrolling = false
|
|
scheduleFollow()
|
|
}
|
|
|
|
function beginUserScroll() {
|
|
userScrolling = true
|
|
following.value = false
|
|
}
|
|
|
|
useEventListener(viewport, 'wheel', beginUserScroll, { passive: true })
|
|
useEventListener(viewport, 'touchmove', beginUserScroll, { passive: true })
|
|
useEventListener(viewport, 'keydown', (event) => {
|
|
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', 'Space'].includes(event.code))
|
|
beginUserScroll()
|
|
})
|
|
// Scrollbar dragging is an explicit navigation gesture; ordinary content clicks are not.
|
|
useEventListener(viewport, 'pointerdown', (event) => {
|
|
if (event.target === viewport.value) beginUserScroll()
|
|
})
|
|
useEventListener(
|
|
content,
|
|
'click',
|
|
(event) => {
|
|
if (!(event.target instanceof Element)) return
|
|
if (event.target.closest('[data-slot="chat-reasoning-trigger"]')) {
|
|
following.value = false
|
|
}
|
|
},
|
|
{ capture: true }
|
|
)
|
|
|
|
useResizeObserver([viewport, content], () => {
|
|
scheduleFollow()
|
|
measure()
|
|
})
|
|
watch(submitted, (value) => {
|
|
if (value) resumeFollowing()
|
|
})
|
|
watch(viewport, scheduleFollow, { flush: 'post' })
|
|
onScopeDispose(() => {
|
|
if (frame !== undefined) cancelAnimationFrame(frame)
|
|
})
|
|
|
|
return { following, arrivedState, resumeFollowing }
|
|
}
|