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
+2 -5
View File
@@ -36,11 +36,8 @@ jobs:
git -C "$SKILL_DIR" remote add origin git@github.com:tommy0103/obelisk-skill.git
}
# Replace all content with the fresh build
find "$SKILL_DIR" -mindepth 1 -not -path "$SKILL_DIR/.git*" -delete
cp -R dist/obelisk-skill/* "$SKILL_DIR/"
cp packaging/skill-README.md "$SKILL_DIR/README.md"
cp packaging/skill-LICENSE "$SKILL_DIR/LICENSE"
# Keep repository metadata, then stage the skill under skills/obelisk.
bash packaging/stage-skill-repo.sh "$SKILL_DIR"
cd "$SKILL_DIR"
git add -A
+3
View File
@@ -182,6 +182,9 @@ It starts at `references/recap/overview.md` and proceeds card-by-card:
- `dist/obelisk-skill/` is produced by `npm run build:skill`. It is the
install-ready skill artifact: readable plain JavaScript under `scripts/`,
`SKILL.md`, references, and the skill package metadata.
- Skill publishing stages that artifact at `skills/obelisk/` in the
`obelisk-skill` repository; only `README.md` and `LICENSE` remain at the
repository root for `npx skills` discovery.
Both directories are generated and should not be edited by hand. The Electron
app imports `packages/core/src/` directly so electron-vite can bundle Core.
+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;
+5 -5
View File
@@ -1,18 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
SKILL_DIR="dist/obelisk-skill"
SKILL_ARTIFACT="dist/obelisk-skill"
SKILL_REPO="dist/obelisk-skill-repo"
REMOTE="git@github.com:tommy0103/obelisk-skill.git"
if [ ! -d "$SKILL_DIR/scripts" ]; then
if [ ! -d "$SKILL_ARTIFACT/scripts" ]; then
echo "Error: run 'npm run build:skill' first" >&2
exit 1
fi
cp packaging/skill-README.md "$SKILL_DIR/README.md"
cp packaging/skill-LICENSE "$SKILL_DIR/LICENSE"
bash packaging/stage-skill-repo.sh "$SKILL_REPO" "$SKILL_ARTIFACT"
cd "$SKILL_DIR"
cd "$SKILL_REPO"
rm -rf .git
git init
git remote add origin "$REMOTE"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TARGET_DIR="${1:-}"
ARTIFACT_DIR="${2:-$ROOT_DIR/dist/obelisk-skill}"
if [ -z "$TARGET_DIR" ]; then
echo "Usage: packaging/stage-skill-repo.sh <target-repo> [skill-artifact]" >&2
exit 1
fi
if [ "$TARGET_DIR" = "/" ] || [ "$TARGET_DIR" = "." ] || [ "$TARGET_DIR" = "$ROOT_DIR" ]; then
echo "Error: refusing to replace unsafe target directory: $TARGET_DIR" >&2
exit 1
fi
for required in SKILL.md package.json references scripts; do
if [ ! -e "$ARTIFACT_DIR/$required" ]; then
echo "Error: skill artifact missing $required at $ARTIFACT_DIR" >&2
exit 1
fi
done
mkdir -p "$TARGET_DIR"
find "$TARGET_DIR" -mindepth 1 \
! -path "$TARGET_DIR/.git" \
! -path "$TARGET_DIR/.git/*" \
-delete
mkdir -p "$TARGET_DIR/skills/obelisk"
cp -R "$ARTIFACT_DIR"/. "$TARGET_DIR/skills/obelisk/"
cp "$ROOT_DIR/packaging/skill-README.md" "$TARGET_DIR/README.md"
cp "$ROOT_DIR/packaging/skill-LICENSE" "$TARGET_DIR/LICENSE"
+56 -2
View File
@@ -24,11 +24,12 @@ test('flap state queues rapid updates without interrupting the active flap', ()
assert.equal(state.from, '822');
assert.equal(state.to, '823');
assert.equal(state.queued, '824');
assert.deepEqual(state.queued, ['824']);
state = finishFlap(state);
assert.equal(state.from, '823');
assert.equal(state.to, '824');
assert.deepEqual(state.queued, []);
assert.equal(state.animating, true);
state = finishFlap(state);
@@ -36,6 +37,59 @@ test('flap state queues rapid updates without interrupting the active flap', ()
assert.equal(state.animating, false);
});
test('flap queue preserves every rapid numeric step in order', () => {
let state = createFlapState(907);
state = requestFlap(state, 908);
state = requestFlap(state, 909);
state = requestFlap(state, 910);
assert.equal(state.to, '908');
assert.deepEqual(state.queued, ['909', '910']);
state = finishFlap(state);
assert.equal(state.from, '908');
assert.equal(state.to, '909');
assert.deepEqual(state.queued, ['910']);
});
test('a small direct numeric jump expands into consecutive flap targets', () => {
const state = requestFlap(createFlapState(908), 910);
assert.equal(state.from, '908');
assert.equal(state.to, '909');
assert.deepEqual(state.queued, ['910']);
});
test('the flap queue stays bounded while retaining the latest target', () => {
let state = createFlapState(0);
for (let value = 1; value <= 10; value++) state = requestFlap(state, value);
assert.ok(state.queued.length <= 4);
assert.equal(state.queued.at(-1), '10');
while (state.animating) state = finishFlap(state);
assert.equal(state.settled, '10');
});
test('large numeric jumps go directly to the latest value', () => {
const state = requestFlap(createFlapState(908), 1000);
assert.equal(state.to, '1000');
assert.deepEqual(state.queued, []);
});
test('a reversing update discards stale forward targets', () => {
let state = createFlapState(907);
state = requestFlap(state, 908);
state = requestFlap(state, 909);
state = requestFlap(state, 907);
assert.deepEqual(state.queued, ['907']);
state = finishFlap(state);
assert.equal(state.from, '908');
assert.equal(state.to, '907');
});
test('the latest request clears an older queued value when it matches the active target', () => {
let state = createFlapState(822);
state = requestFlap(state, 823);
@@ -43,7 +97,7 @@ test('the latest request clears an older queued value when it matches the active
state = requestFlap(state, 823);
assert.equal(state.to, '823');
assert.equal(state.queued, null);
assert.deepEqual(state.queued, []);
state = finishFlap(state);
assert.equal(state.settled, '823');
assert.equal(state.animating, false);
+64
View File
@@ -0,0 +1,64 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const stageScript = join(repoRoot, 'packaging', 'stage-skill-repo.sh');
test('skill release staging produces the npx skills repository layout', () => {
const root = mkdtempSync(join(tmpdir(), 'obelisk-skill-release-'));
const artifact = join(root, 'artifact');
const target = join(root, 'repo');
try {
mkdirSync(join(artifact, 'references'), { recursive: true });
mkdirSync(join(artifact, 'scripts'), { recursive: true });
mkdirSync(join(target, '.git'), { recursive: true });
writeFileSync(join(artifact, 'SKILL.md'), '---\nname: obelisk\ndescription: test\n---\n');
writeFileSync(join(artifact, 'package.json'), '{"type":"module"}\n');
writeFileSync(join(artifact, 'references', 'api-reference.md'), '# API\n');
writeFileSync(join(artifact, 'scripts', 'runtime.js'), 'export {};\n');
writeFileSync(join(target, '.git', 'keep'), 'preserved\n');
writeFileSync(join(target, 'stale.txt'), 'remove me\n');
const result = spawnSync('bash', [stageScript, target, artifact], {
cwd: repoRoot,
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.deepEqual(readdirSync(target).sort(), ['.git', 'LICENSE', 'README.md', 'skills']);
assert.deepEqual(readdirSync(join(target, 'skills')).sort(), ['obelisk']);
for (const relativePath of [
'SKILL.md',
'package.json',
'references/api-reference.md',
'scripts/runtime.js',
]) {
assert.equal(existsSync(join(target, 'skills', 'obelisk', relativePath)), true);
}
assert.equal(existsSync(join(target, '.git', 'keep')), true);
assert.equal(existsSync(join(target, 'stale.txt')), false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('CI and local publish use the same skill repository staging step', () => {
const workflow = readFileSync(join(repoRoot, '.github', 'workflows', 'publish-skill.yml'), 'utf8');
const localPublish = readFileSync(join(repoRoot, 'packaging', 'publish-skill.sh'), 'utf8');
assert.match(workflow, /packaging\/stage-skill-repo\.sh/);
assert.match(localPublish, /packaging\/stage-skill-repo\.sh/);
});
+12
View File
@@ -186,3 +186,15 @@ test('live totals and scroll position remain isolated across interleaved updates
assert.match(updateScrollProgress, /currentMsgIdx\.value\s*=/);
assert.doesNotMatch(updateScrollProgress, /totalMsgs\.value\s*=/);
});
test('message navigation keeps the top progress bar aligned with the current position', () => {
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
const setMessagePosition = functionSource(source, 'setMessagePosition');
const updateScrollProgress = functionSource(source, 'updateScrollProgress');
const navTo = functionSource(source, 'navTo');
assert.match(setMessagePosition, /currentMsgIdx\.value\s*=\s*index/);
assert.match(setMessagePosition, /progressPct\.value\s*=/);
assert.match(updateScrollProgress, /setMessagePosition\(bottomMsgIdx,\s*msgs\.length\)/);
assert.match(navTo, /setMessagePosition\(idx,\s*msgs\.length\)/);
});