openpencil/src/components/chat/transcript/useScrollFollowing.ts
Danila Poyarkov b4e479d9d0
feat: persist AI conversations and add chat history (#661)
* 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
2026-09-08 11:19:07 +03:00

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 }
}