From 78aacfac6cdae9c682703878da1606e8ea5a4fed Mon Sep 17 00:00:00 2001 From: tommy0103 Date: Sun, 12 Jul 2026 20:00:07 +0800 Subject: [PATCH] feat(session): animate total count with split flap --- .../renderer/src/components/FlapNumber.vue | 151 ++++++++++++++++++ app/src/renderer/src/flap-number.mjs | 61 +++++++ app/src/renderer/src/views/SessionDetail.vue | 3 +- tests/flap-number.test.mjs | 84 ++++++++++ 4 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 app/src/renderer/src/components/FlapNumber.vue create mode 100644 app/src/renderer/src/flap-number.mjs create mode 100644 tests/flap-number.test.mjs diff --git a/app/src/renderer/src/components/FlapNumber.vue b/app/src/renderer/src/components/FlapNumber.vue new file mode 100644 index 0000000..ae8dd0b --- /dev/null +++ b/app/src/renderer/src/components/FlapNumber.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/app/src/renderer/src/flap-number.mjs b/app/src/renderer/src/flap-number.mjs new file mode 100644 index 0000000..b3ae09d --- /dev/null +++ b/app/src/renderer/src/flap-number.mjs @@ -0,0 +1,61 @@ +function normalizeFlapValue(value) { + return String(value ?? ''); +} + +export function createFlapState(value) { + const settled = normalizeFlapValue(value); + return { + settled, + from: settled, + to: settled, + animating: false, + queued: null, + version: 0, + }; +} + +export function requestFlap(state, value, { reducedMotion = false } = {}) { + const next = normalizeFlapValue(value); + if (reducedMotion) return createFlapState(next); + if (state.animating) { + if (next === state.to) return { ...state, queued: null }; + return { ...state, queued: next }; + } + if (next === state.settled) return state; + return { + ...state, + from: state.settled, + to: next, + animating: true, + queued: null, + version: state.version + 1, + }; +} + +export function finishFlap(state) { + if (!state.animating) return state; + const settled = state.to; + const queued = state.queued; + const stable = { + ...state, + settled, + from: settled, + to: settled, + animating: false, + queued: null, + }; + return queued !== null && queued !== settled ? requestFlap(stable, queued) : stable; +} + +export function flapSlots(fromValue, toValue) { + const from = normalizeFlapValue(fromValue); + const to = normalizeFlapValue(toValue); + const width = Math.max(from.length, to.length); + const oldText = from.padStart(width, ' '); + const newText = to.padStart(width, ' '); + return Array.from({ length: width }, (_, index) => ({ + from: oldText[index], + to: newText[index], + changed: oldText[index] !== newText[index], + })); +} diff --git a/app/src/renderer/src/views/SessionDetail.vue b/app/src/renderer/src/views/SessionDetail.vue index 85e3ca6..cf6d027 100644 --- a/app/src/renderer/src/views/SessionDetail.vue +++ b/app/src/renderer/src/views/SessionDetail.vue @@ -4,6 +4,7 @@ import { useRouter, useRoute } from 'vue-router'; import { state, FOLDER_SVG } from '../store.js'; import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js'; import { clearSessionDirty, consumeGlobalSessionDirty } from '../session-live.mjs'; +import FlapNumber from '../components/FlapNumber.vue'; import { escapeHTML, fmtRelative, @@ -954,7 +955,7 @@ function getToolCallParsedInput(tc) { - {{ currentMsgIdx + 1 }} / {{ totalMsgs }} + {{ currentMsgIdx + 1 }} / diff --git a/tests/flap-number.test.mjs b/tests/flap-number.test.mjs new file mode 100644 index 0000000..63355ac --- /dev/null +++ b/tests/flap-number.test.mjs @@ -0,0 +1,84 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { + createFlapState, + finishFlap, + flapSlots, + requestFlap, +} from '../app/src/renderer/src/flap-number.mjs'; + +test('flap slots animate only digits that changed', () => { + assert.deepEqual(flapSlots(822, 823), [ + { from: '8', to: '8', changed: false }, + { from: '2', to: '2', changed: false }, + { from: '2', to: '3', changed: true }, + ]); +}); + +test('flap state queues rapid updates without interrupting the active flap', () => { + let state = createFlapState(822); + state = requestFlap(state, 823); + state = requestFlap(state, 824); + + assert.equal(state.from, '822'); + assert.equal(state.to, '823'); + assert.equal(state.queued, '824'); + + state = finishFlap(state); + assert.equal(state.from, '823'); + assert.equal(state.to, '824'); + assert.equal(state.animating, true); + + state = finishFlap(state); + assert.equal(state.settled, '824'); + assert.equal(state.animating, false); +}); + +test('the latest request clears an older queued value when it matches the active target', () => { + let state = createFlapState(822); + state = requestFlap(state, 823); + state = requestFlap(state, 824); + state = requestFlap(state, 823); + + assert.equal(state.to, '823'); + assert.equal(state.queued, null); + state = finishFlap(state); + assert.equal(state.settled, '823'); + assert.equal(state.animating, false); +}); + +test('reduced motion updates the settled value immediately', () => { + const state = requestFlap(createFlapState(822), 823, { reducedMotion: true }); + + assert.equal(state.settled, '823'); + assert.equal(state.animating, false); +}); + +test('queued empty string remains a valid latest value for the generic component', () => { + let state = createFlapState('1'); + state = requestFlap(state, '2'); + state = requestFlap(state, ''); + state = finishFlap(state); + + assert.equal(state.to, ''); + assert.equal(state.animating, true); +}); + +test('SessionDetail applies flap motion only to the total message count', () => { + const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8'); + + assert.match(source, /]*>\{\{ currentMsgIdx \+ 1 \}\}/); +}); + +test('FlapNumber shares one relative geometry and finishes from animationend', () => { + const source = readFileSync(new URL('../app/src/renderer/src/components/FlapNumber.vue', import.meta.url), 'utf8'); + + assert.match(source, /width:\s*1ch/); + assert.match(source, /height:\s*1lh/); + assert.match(source, /@animationend\.stop=/); + assert.doesNotMatch(source, /height:\s*15px/); + assert.doesNotMatch(source, /setTimeout/); +});