mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-04 02:33:43 +00:00
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:
parent
1a97751e37
commit
7d935efa44
3 changed files with 136 additions and 50 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue