From 81d3587a9120b6bc987a20e0d15d0cb50383b36d Mon Sep 17 00:00:00 2001 From: byGalax Date: Tue, 12 May 2026 22:22:49 +0200 Subject: [PATCH] feat(chat): per-conversation scroll memory + version badge on changelog page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small UX polishes: 1. Switching between chats no longer slams you to the bottom. Each conversation's scroll position (pixel offset + stickToBottom flag) is remembered in a module-scoped Map for the lifetime of the renderer process. Discord-style: leave Chat A scrolled up, peek at another conversation, come back — same spot you were reading. Chats left at the bottom keep auto-following new messages on return. Reload resets everything (session-only, no localStorage). The restore runs once messages.length > 0 to avoid the browser clamping scrollTop to a near-zero scrollHeight before the message list has rendered. A small isRestoringRef guard prevents the programmatic scroll event from immediately overwriting the saved position with a clamped value. 2. Changelog page now shows a version badge in the header that compares the installed app version against entries[0].version from the server-side changelog feed. Three states: * `vX.Y.Z · aktuell` (emerald) — installed matches latest * `vX.Y.Z · Update verfügbar` + `neueste: vA.B.C` (amber) — outdated * `vX.Y.Z` neutral — installed is ahead of the published feed (dev/test builds) Semver compare is integer-major.minor.patch with a graceful garbage-fallback so a malformed version string doesn't false-flag a current install as outdated. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/desktop/src/pages/ChangelogPage.tsx | 99 ++++++++++++++++++++- apps/desktop/src/pages/ConversationPage.tsx | 54 ++++++++++- 2 files changed, 147 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/pages/ChangelogPage.tsx b/apps/desktop/src/pages/ChangelogPage.tsx index 1c26423..5a9cce3 100644 --- a/apps/desktop/src/pages/ChangelogPage.tsx +++ b/apps/desktop/src/pages/ChangelogPage.tsx @@ -1,15 +1,33 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { fetchChangelog, type ChangelogEntry } from '../lib/changelog'; import { SparklesIcon, SpinnerIcon } from '../components/icons'; const PAGE_SIZE = 10; +// Installed app version comes from the preload bridge (process.env.npm_- +// package_version at preload build time). Falls back to '0.0.0' outside +// Electron so the page still renders in a browser preview. +const installedVersion = window.electronAPI?.appVersion ?? '0.0.0'; + export function ChangelogPage() { const [entries, setEntries] = useState(null); const [error, setError] = useState(null); const [visible, setVisible] = useState(PAGE_SIZE); + // Compare the installed version against the top changelog entry. The + // server-side changelog is sorted newest-first by the release script, so + // entries[0] is always the published latest. + const latestVersion = entries?.[0]?.version ?? null; + const versionStatus = useMemo<'loading' | 'current' | 'outdated' | 'ahead'>(() => { + if (entries === null) return 'loading'; + if (!latestVersion) return 'current'; + const cmp = compareSemver(installedVersion, latestVersion); + if (cmp === 0) return 'current'; + if (cmp < 0) return 'outdated'; + return 'ahead'; + }, [entries, latestVersion]); + useEffect(() => { let cancelled = false; void (async () => { @@ -34,7 +52,7 @@ export function ChangelogPage() {
-
+

Was ist neu

@@ -42,6 +60,11 @@ export function ChangelogPage() { Alle Änderungen in dieser App, neueste zuerst.

+ {entries === null && !error && ( @@ -118,3 +141,75 @@ function formatDate(iso: string): string { return iso; } } + +// Compact status chip in the header that tells the user whether their +// installed build matches the latest published version. Three visual +// tones: emerald (current), amber (outdated → update available), neutral +// (loading / unknown). The "ahead" case (dev build > released) shares the +// neutral tone since users running it always know what they're doing. +function VersionBadge({ + status, + installed, + latest, +}: { + status: 'loading' | 'current' | 'outdated' | 'ahead'; + installed: string; + latest: string | null; +}) { + if (status === 'loading') { + return ( + + v{installed} + + ); + } + if (status === 'current') { + return ( + + + v{installed} · aktuell + + ); + } + if (status === 'outdated' && latest) { + return ( + + v{installed} · Update verfügbar + neueste: v{latest} + + ); + } + return ( + + v{installed} + + ); +} + +// Lightweight semver comparator: parses major.minor.patch as ints and +// compares numerically. Returns negative if a < b, zero if equal, positive +// if a > b. Handles malformed inputs by treating non-numeric segments as +// 0 so a typo doesn't flag a perfectly current install as outdated. +function compareSemver(a: string, b: string): number { + const parse = (s: string): [number, number, number] => { + const parts = s.split('.').map((p) => { + const n = parseInt(p, 10); + return Number.isFinite(n) ? n : 0; + }); + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; + }; + const [aMaj, aMin, aPat] = parse(a); + const [bMaj, bMin, bPat] = parse(b); + if (aMaj !== bMaj) return aMaj - bMaj; + if (aMin !== bMin) return aMin - bMin; + return aPat - bPat; +} diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx index 2ac7f8f..9d7351d 100644 --- a/apps/desktop/src/pages/ConversationPage.tsx +++ b/apps/desktop/src/pages/ConversationPage.tsx @@ -49,6 +49,15 @@ import { useTypingChannel } from '../lib/useTypingChannel'; const STICK_THRESHOLD = 80; +// Per-conversation scroll memory. Module-scoped so it survives re-mounts +// of ConversationPage when the route param (`id`) changes — switching +// chats unmounts/remounts the page in our router setup. Session-only +// (lost on reload, like Discord). The `stickToBottom` flag is preserved +// alongside the pixel offset so a chat the user left at the bottom keeps +// auto-following new messages when they return; a chat scrolled up +// returns to the exact spot the user was reading. +const scrollPositions = new Map(); + export function ConversationPage() { const { t } = useTranslation(['app', 'errors']); const { id } = useParams<{ id: string }>(); @@ -403,11 +412,41 @@ export function ConversationPage() { el.scrollTop = el.scrollHeight; }, [messages.length, stickToBottom]); + // Restore saved scroll position once the conversation's messages have + // actually rendered. The earlier version fired on `[id]` alone and ran + // before the message list populated — scrollHeight was still tiny, so + // `el.scrollTop = saved.scrollTop` got clamped to 0 by the browser + // and the user landed at the top instead of the saved position. By + // waiting for `messages.length > 0` we know the rendered scrollHeight + // is meaningful. `restoredForRef` ensures the restore runs at most + // once per chat switch (subsequent message arrivals don't re-trigger). + const restoredForRef = useRef(null); + const isRestoringRef = useRef(false); + useEffect(() => { - setStickToBottom(true); const el = scrollRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [id]); + if (!el || !id) return; + if (restoredForRef.current === id) return; + // Wait for the conversation's messages to populate; for a chat that + // truly has zero messages the bottom and the top are the same anyway. + if (messages.length === 0) return; + restoredForRef.current = id; + const saved = scrollPositions.get(id); + // Suppress handleScroll's persistence during the programmatic scroll + // below — otherwise the browser's clamp/normalisation could write a + // different scrollTop back into the Map and lose the saved position. + isRestoringRef.current = true; + if (saved && !saved.stickToBottom) { + el.scrollTop = saved.scrollTop; + setStickToBottom(false); + } else { + setStickToBottom(true); + el.scrollTop = el.scrollHeight; + } + requestAnimationFrame(() => { + isRestoringRef.current = false; + }); + }, [id, messages.length]); const handleScroll = useCallback(() => { const el = scrollRef.current; @@ -416,7 +455,14 @@ export function ConversationPage() { const nextStick = distanceFromBottom < STICK_THRESHOLD; setStickToBottom(nextStick); if (nextStick) setNewMessagesWhileAway(0); - }, []); + // Persist position per chat so re-entering this conversation lands + // where the user left off (see scrollPositions module-level Map). + // Skipped during the in-flight restore so we don't immediately + // overwrite the saved position with a clamped value. + if (id && !isRestoringRef.current) { + scrollPositions.set(id, { scrollTop: el.scrollTop, stickToBottom: nextStick }); + } + }, [id]); const jumpToBottom = useCallback(() => { const el = scrollRef.current;