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
+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\)/);
});