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 @@
+
+
+
+
+
+ {{ slot.to }}
+
+ {{ slot.to }}
+ {{ slot.from }}
+ {{ slot.from }}
+ {{ slot.to }}
+
+
+
+
+
+
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/);
+});