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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user