From f963f14b102c6077492b4b70677d8e61d6994c2a Mon Sep 17 00:00:00 2001 From: tommy0103 Date: Mon, 13 Jul 2026 22:02:44 +0800 Subject: [PATCH] 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. --- .github/workflows/publish-skill.yml | 7 +- README.md | 3 + app/src/renderer/src/flap-number.mjs | 79 ++++++++++++++++---- app/src/renderer/src/views/SessionDetail.vue | 11 ++- packaging/publish-skill.sh | 10 +-- packaging/stage-skill-repo.sh | 34 +++++++++ tests/flap-number.test.mjs | 58 +++++++++++++- tests/publish-skill-layout.test.mjs | 64 ++++++++++++++++ tests/session-view-state.test.mjs | 12 +++ 9 files changed, 248 insertions(+), 30 deletions(-) create mode 100644 packaging/stage-skill-repo.sh create mode 100644 tests/publish-skill-layout.test.mjs diff --git a/.github/workflows/publish-skill.yml b/.github/workflows/publish-skill.yml index dd983fa..1f8d7bb 100644 --- a/.github/workflows/publish-skill.yml +++ b/.github/workflows/publish-skill.yml @@ -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 diff --git a/README.md b/README.md index 3363a62..7c41165 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/src/renderer/src/flap-number.mjs b/app/src/renderer/src/flap-number.mjs index b3ae09d..fec1088 100644 --- a/app/src/renderer/src/flap-number.mjs +++ b/app/src/renderer/src/flap-number.mjs @@ -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) { diff --git a/app/src/renderer/src/views/SessionDetail.vue b/app/src/renderer/src/views/SessionDetail.vue index 3b85e68..9741978 100644 --- a/app/src/renderer/src/views/SessionDetail.vue +++ b/app/src/renderer/src/views/SessionDetail.vue @@ -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; diff --git a/packaging/publish-skill.sh b/packaging/publish-skill.sh index 91ecc65..e6c68da 100755 --- a/packaging/publish-skill.sh +++ b/packaging/publish-skill.sh @@ -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" diff --git a/packaging/stage-skill-repo.sh b/packaging/stage-skill-repo.sh new file mode 100644 index 0000000..b5ba7fb --- /dev/null +++ b/packaging/stage-skill-repo.sh @@ -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 [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" diff --git a/tests/flap-number.test.mjs b/tests/flap-number.test.mjs index 63355ac..281bb8c 100644 --- a/tests/flap-number.test.mjs +++ b/tests/flap-number.test.mjs @@ -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); diff --git a/tests/publish-skill-layout.test.mjs b/tests/publish-skill-layout.test.mjs new file mode 100644 index 0000000..cdb8d14 --- /dev/null +++ b/tests/publish-skill-layout.test.mjs @@ -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/); +}); diff --git a/tests/session-view-state.test.mjs b/tests/session-view-state.test.mjs index 7e34aa0..346ad78 100644 --- a/tests/session-view-state.test.mjs +++ b/tests/session-view-state.test.mjs @@ -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\)/); +});