feat: improve chat overview with persistent viewport and pinning

Introduces viewport state persistence and a pin toggle for the chat overview (tree view), along with minor fix and cleanup.

- Added a "Viewport Pinned" toggle to disable auto-focus on active node.
- Persists viewport coordinates (x, y, zoom), layout direction, and pin state to localStorage.
  - Debounced the localStorage viewport save (300ms) to prevent blocking during smooth pan/zoom gestures.
  - Structured overviewViewports cache as a self-pruning 50-item LRU to prevent unbounded storage bloat.
- Resolved an existing memory leak by properly cleaning up nodesInitialized, width, and height store subscriptions on component destroy.
- Fixed a scroll alignment bug when navigating from the Overview tree. Defers scrollIntoView to the next event loop tick (0ms) so dynamic elements can layout properly before calculating the target scroll coordinate.
- Removed unused imports.
This commit is contained in:
King-of-Infinite-Space 2026-06-05 00:06:15 -07:00
parent 1a97751e37
commit 7d935efa44
3 changed files with 136 additions and 50 deletions

View file

@ -454,10 +454,12 @@
await tick();
if (($settings?.scrollOnBranchChange ?? true) && scroll) {
const messageElement = document.getElementById(`message-${message.id}`);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
setTimeout(() => {
const messageElement = document.getElementById(`message-${message.id}`);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 0);
}
await tick();

View file

@ -1,7 +1,7 @@
<script>
import { createEventDispatcher } from 'svelte';
import { getContext } from 'svelte';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
import { theme } from '$lib/stores';
import {
@ -11,22 +11,23 @@
BackgroundVariant,
ControlButton
} from '@xyflow/svelte';
import BarsArrowUp from '$lib/components/icons/BarsArrowUp.svelte';
import Bars3BottomLeft from '$lib/components/icons/Bars3BottomLeft.svelte';
import AlignVertical from '$lib/components/icons/AlignVertical.svelte';
import AlignHorizontal from '$lib/components/icons/AlignHorizontal.svelte';
import Pin from '$lib/components/icons/Pin.svelte';
import PinSlash from '$lib/components/icons/PinSlash.svelte';
export let nodes;
export let nodeTypes;
export let edges;
export let setLayoutDirection;
export let viewportPinned = false;
export let onNodeClick;
</script>
<SvelteFlow
{nodes}
{nodeTypes}
{edges}
fitView
minZoom={0.001}
colorMode={$theme.includes('dark')
? 'dark'
@ -37,12 +38,20 @@
: 'light'}
nodesConnectable={false}
nodesDraggable={false}
on:nodeclick={(e) => dispatch('nodeclick', e.detail)}
oninit={() => {
console.log('Flow initialized');
}}
on:nodeclick={(e) => onNodeClick?.(e.detail)}
oninit={() => {}}
>
<Controls showLock={false}>
<ControlButton
on:click={() => (viewportPinned = !viewportPinned)}
title={viewportPinned ? $i18n.t('Viewport Pinned') : $i18n.t('Viewport Unpinned')}
>
{#if viewportPinned}
<Pin />
{:else}
<PinSlash />
{/if}
</ControlButton>
<ControlButton on:click={() => setLayoutDirection('vertical')} title="Vertical Layout">
<AlignVertical className="size-4" />
</ControlButton>

View file

@ -1,25 +1,20 @@
<script lang="ts">
import { getContext, createEventDispatcher, onDestroy } from 'svelte';
import { onDestroy } from 'svelte';
import { useSvelteFlow, useNodesInitialized, useStore } from '@xyflow/svelte';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
import { onMount, tick } from 'svelte';
import { writable } from 'svelte/store';
import { models, theme, user } from '$lib/stores';
import { models, user, chatId } from '$lib/stores';
import '@xyflow/svelte/dist/style.css';
import CustomNode from './Node.svelte';
import Flow from './Flow.svelte';
import XMark from '../../icons/XMark.svelte';
import ArrowLeft from '../../icons/ArrowLeft.svelte';
const { width, height } = useStore();
const { width, height, viewport } = useStore();
const { fitView, getViewport } = useSvelteFlow();
const { fitView, setViewport } = useSvelteFlow();
const nodesInitialized = useNodesInitialized();
export let history;
@ -27,6 +22,10 @@
export let onNodeClick;
let selectedMessageId = null;
let viewportPinned = false;
let isInitialized = false;
let viewportSaveTimeout;
let unsubs = [];
const nodes = writable([]);
const edges = writable([]);
@ -45,11 +44,48 @@
focusNode();
}
$: if (isInitialized) {
try {
localStorage.setItem('overviewPinned', String(viewportPinned));
} catch (e) {
console.warn('Failed to save pinned state to local storage', e);
}
}
$: if (isInitialized && $viewport && $chatId) {
clearTimeout(viewportSaveTimeout);
const vp = $viewport;
const id = $chatId;
viewportSaveTimeout = setTimeout(() => {
try {
let viewports = JSON.parse(localStorage.getItem('overviewViewports') || '{}');
// Delete and re-insert to maintain insertion order for LRU eviction
if (id in viewports) {
delete viewports[id];
}
viewports[id] = vp;
const keys = Object.keys(viewports);
if (keys.length > 50) {
delete viewports[keys[0]];
}
localStorage.setItem('overviewViewports', JSON.stringify(viewports));
} catch (e) {
console.warn('Failed to save viewport to local storage', e);
}
}, 300);
}
const focusNode = async () => {
if (selectedMessageId === null) {
await fitView({ nodes: [{ id: history.currentId }] });
} else {
await fitView({ nodes: [{ id: selectedMessageId }] });
if (isInitialized && !viewportPinned) {
if (selectedMessageId === null) {
await fitView({ nodes: [{ id: history.currentId }] });
} else {
await fitView({ nodes: [{ id: selectedMessageId }] });
}
}
selectedMessageId = null;
@ -135,37 +171,73 @@
const setLayoutDirection = (direction) => {
layoutDirection = direction;
try {
localStorage.setItem('overviewLayoutDirection', direction);
} catch (e) {
console.warn('Failed to save layout direction to local storage', e);
}
drawFlow(layoutDirection);
};
onMount(() => {
try {
viewportPinned = localStorage.getItem('overviewPinned') === 'true';
layoutDirection = localStorage.getItem('overviewLayoutDirection') || 'vertical';
} catch (e) {
viewportPinned = false;
layoutDirection = 'vertical';
}
drawFlow(layoutDirection);
nodesInitialized.subscribe(async (initialized) => {
if (initialized) {
await tick();
const res = await fitView({ nodes: [{ id: history.currentId }] });
}
});
unsubs.push(
nodesInitialized.subscribe(async (initialized) => {
if (initialized) {
await tick();
let restored = false;
if ($chatId) {
try {
const viewports = JSON.parse(localStorage.getItem('overviewViewports') || '{}');
const saved = viewports[$chatId];
width.subscribe((value) => {
if (value) {
// fitView();
fitView({ nodes: [{ id: history.currentId }] });
}
});
if (saved) {
const { x, y, zoom } = saved;
await setViewport({ x, y, zoom });
restored = true;
}
} catch (e) {
// Ignored
}
}
height.subscribe((value) => {
if (value) {
// fitView();
fitView({ nodes: [{ id: history.currentId }] });
}
});
if (!restored) {
await fitView({ nodes: [{ id: history.currentId }] });
}
isInitialized = true;
}
})
);
unsubs.push(
width.subscribe((value) => {
if (isInitialized && value && !viewportPinned) {
fitView({ nodes: [{ id: history.currentId }] });
}
})
);
unsubs.push(
height.subscribe((value) => {
if (isInitialized && value && !viewportPinned) {
fitView({ nodes: [{ id: history.currentId }] });
}
})
);
});
onDestroy(() => {
console.log('Overview destroyed');
unsubs.forEach((unsub) => unsub());
clearTimeout(viewportSaveTimeout);
nodes.set([]);
edges.set([]);
});
@ -178,10 +250,13 @@
{nodeTypes}
{edges}
{setLayoutDirection}
on:nodeclick={(e) => {
onNodeClick(e.detail);
selectedMessageId = e.detail.node.data.message.id;
fitView({ nodes: [{ id: selectedMessageId }] });
bind:viewportPinned
onNodeClick={(detail) => {
onNodeClick?.(detail);
selectedMessageId = detail.node.data.message.id;
if (!viewportPinned) {
fitView({ nodes: [{ id: selectedMessageId }] });
}
}}
/>
{/if}