fix: stabilize live session UI and skill publishing

Keep message navigation and progress state synchronized, and preserve
sequential split-flap count updates with a bounded queue.

Publish the Obelisk skill under skills/obelisk for npx skills, sharing
the same staging layout between CI and local releases with regression coverage.
This commit is contained in:
tommy0103
2026-07-13 22:02:44 +08:00
parent a9687ba8b7
commit f963f14b10
9 changed files with 248 additions and 30 deletions
+65 -14
View File
@@ -2,6 +2,52 @@ function normalizeFlapValue(value) {
return String(value ?? '');
}
const MAX_QUEUED_FLAPS = 4;
const MAX_CONSECUTIVE_STEPS = 4;
function normalizeQueue(queued) {
if (Array.isArray(queued)) return queued;
return queued === null || queued === undefined ? [] : [normalizeFlapValue(queued)];
}
function numericDirection(from, to) {
if (!/^(0|[1-9]\d*)$/.test(from) || !/^(0|[1-9]\d*)$/.test(to)) return null;
const fromNumber = Number(from);
const toNumber = Number(to);
if (!Number.isSafeInteger(fromNumber) || !Number.isSafeInteger(toNumber)) return null;
return Math.sign(toNumber - fromNumber);
}
function targetsBetween(from, to) {
const direction = numericDirection(from, to);
if (direction === null) return [to];
if (direction === 0) return [];
const distance = Math.abs(Number(to) - Number(from));
if (distance > MAX_CONSECUTIVE_STEPS) return [to];
return Array.from(
{ length: distance },
(_, index) => String(Number(from) + direction * (index + 1)),
);
}
function boundedQueue(targets) {
if (targets.length <= MAX_QUEUED_FLAPS) return targets;
return [...targets.slice(0, MAX_QUEUED_FLAPS - 1), targets.at(-1)];
}
function startFlap(state, targets) {
const [to, ...queued] = targets;
if (to === undefined) return state;
return {
...state,
from: state.settled,
to,
animating: true,
queued: boundedQueue(queued),
version: state.version + 1,
};
}
export function createFlapState(value) {
const settled = normalizeFlapValue(value);
return {
@@ -9,7 +55,7 @@ export function createFlapState(value) {
from: settled,
to: settled,
animating: false,
queued: null,
queued: [],
version: 0,
};
}
@@ -18,33 +64,38 @@ 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 };
const queued = normalizeQueue(state.queued);
if (next === state.to) return { ...state, queued: [] };
const queuedIndex = queued.indexOf(next);
if (queuedIndex >= 0) return { ...state, queued: queued.slice(0, queuedIndex + 1) };
const tail = queued.at(-1) ?? state.to;
const activeDirection = numericDirection(state.from, tail);
const incomingDirection = numericDirection(tail, next);
const additions = targetsBetween(tail, next);
if (activeDirection !== null && incomingDirection !== null
&& (activeDirection === 0 || incomingDirection === activeDirection)) {
return { ...state, queued: boundedQueue([...queued, ...additions]) };
}
return { ...state, queued: boundedQueue(targetsBetween(state.to, next)) };
}
if (next === state.settled) return state;
return {
...state,
from: state.settled,
to: next,
animating: true,
queued: null,
version: state.version + 1,
};
return startFlap(state, targetsBetween(state.settled, next));
}
export function finishFlap(state) {
if (!state.animating) return state;
const settled = state.to;
const queued = state.queued;
const queued = normalizeQueue(state.queued);
const stable = {
...state,
settled,
from: settled,
to: settled,
animating: false,
queued: null,
queued: [],
};
return queued !== null && queued !== settled ? requestFlap(stable, queued) : stable;
return queued.length ? startFlap(stable, queued) : stable;
}
export function flapSlots(fromValue, toValue) {
+7 -4
View File
@@ -222,6 +222,11 @@ function onScroll(event) {
});
}
function setMessagePosition(index, total) {
currentMsgIdx.value = index;
progressPct.value = total <= 1 ? 100 : Math.round((index / (total - 1)) * 100);
}
function updateScrollProgress() {
if (!wrapRef.value || !detailRef.value) return;
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
@@ -235,9 +240,7 @@ function updateScrollProgress() {
const navHeight = 52;
const bottomLine = el.getBoundingClientRect().bottom - navHeight;
const bottomMsgIdx = findLastMessageAtOrAbove(msgs, bottomLine);
currentMsgIdx.value = bottomMsgIdx;
const pct = msgs.length <= 1 ? 100 : Math.round((bottomMsgIdx / (msgs.length - 1)) * 100);
progressPct.value = pct;
setMessagePosition(bottomMsgIdx, msgs.length);
}
function navTo(target) {
@@ -250,7 +253,7 @@ function navTo(target) {
else if (target === 'prev') idx = Math.max(0, currentMsgIdx.value - 1);
else if (target === 'next') idx = Math.min(msgs.length - 1, currentMsgIdx.value + 1);
else return;
currentMsgIdx.value = idx;
setMessagePosition(idx, msgs.length);
navLock = true;
const navHeight = 52;
const el = wrapRef.value;