mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
feat(story-mode): save as image, a helper's own steps, a way in on phones, and polish
- Save image: the page as one PNG to share (each panel canvas, then every caption, balloon and chip redrawn from its computed style and box). - The helper drawer lists what the helper did, read live from its own transcript (GET .../story/helpers/:agentId), led by the task Claude gave it. - Session Options has a Story button (same setting), the way in on phones. - A turn a background task or another session started reads as a notice, not as your speech balloon. - Wide panels keep their scene in a centered stage instead of spreading the art to both ends; the fix panel's agent stands next to the page. - An unchanged page (same ETag) is neither parsed nor redrawn. - Paths inside the session's folder are shown short; clicks keep full paths. - Tab stays inside the overlay; a missing 2D context skips the art, not the page. - A rebindable Story Mode shortcut (no default chord). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d313b868d4
commit
0c45682136
10 files changed
+511
-16
No files matched your search
@@ -10,7 +10,7 @@ import { JSDOM } from 'jsdom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildStoryPage, buildStoryTurns, createStoryReducer, summarizeTurns } from '../src/story-mode.js';
|
||||
import type { StoryPage, StoryPanel } from '../src/types/story.js';
|
||||
import { bash, demoTurn, end, prompt, read, resetClock, said } from './story-fixtures.js';
|
||||
import { bash, demoTurn, end, prompt, read, resetClock, result, said, tool } from './story-fixtures.js';
|
||||
|
||||
const STORY_JS = readFileSync(new URL('../src/web/public/story-mode.js', import.meta.url), 'utf-8');
|
||||
// The real path matcher (findFilePathLinks / resolveLinkedFilePath) the terminal uses.
|
||||
@@ -56,6 +56,7 @@ function boot(initial: unknown) {
|
||||
dom = new JSDOM(
|
||||
`<!doctype html><body>
|
||||
<button class="btn-story-header btn-story-header--hidden"></button>
|
||||
<button id="sessionOptionsStoryBtn" hidden></button>
|
||||
<div class="file-preview-overlay" id="filePreviewOverlay"></div>
|
||||
<div class="folder-grid-overlay" id="folderGridOverlay"></div>
|
||||
</body>`,
|
||||
@@ -334,6 +335,80 @@ describe('story-mode.js overlay', () => {
|
||||
expect($('.story-note')!.textContent).toBe('Session not found');
|
||||
});
|
||||
|
||||
it('a turn a background task started gets a notice, not your speech balloon', async () => {
|
||||
boot(
|
||||
storyOf([
|
||||
prompt('<task-notification><summary>Agent "Docs" finished</summary></task-notification>', {
|
||||
turnOrigin: 'task_notification',
|
||||
}),
|
||||
said('Nice.'),
|
||||
end(1000),
|
||||
])
|
||||
);
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
const balloon = $('.story-panel[data-kind="mission"] .story-balloon')!;
|
||||
expect(balloon.className).toContain('story-balloon--note');
|
||||
expect(balloon.textContent).toBe('Agent "Docs" finished');
|
||||
});
|
||||
|
||||
it("the helper drawer fetches and lists the helper's own steps", async () => {
|
||||
const lines = [
|
||||
prompt('Docs please'),
|
||||
tool('a1', 'Agent', { description: 'Write README', subagent_type: 'general-purpose', prompt: 'Write it' }),
|
||||
result('a1', 'ok', {
|
||||
toolUseResult: { agentId: 'h1', content: [{ type: 'text', text: 'Written.' }], totalToolUseCount: 2 },
|
||||
}),
|
||||
end(3000),
|
||||
];
|
||||
boot(storyOf(lines));
|
||||
const helperPage = storyOf([prompt('Write it'), ...read('r1', '/work/demo-shop/a.md')]).page;
|
||||
const base = app._api;
|
||||
app._api = async (path: string) => {
|
||||
if (path.includes('/story/helpers/h1')) {
|
||||
calls.push(path);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: { supported: true, helper: { agentId: 'h1', finished: true }, page: helperPage },
|
||||
}),
|
||||
};
|
||||
}
|
||||
return base(path);
|
||||
};
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
sizeAndDraw();
|
||||
$('.story-panel[data-kind="backup"] .story-hit')!.click();
|
||||
await settle();
|
||||
expect($('.story-drawer-title')!.textContent).toBe('Write README');
|
||||
expect($('.story-drawer-sub')!.textContent).toContain('general-purpose');
|
||||
expect(calls).toContain(`/api/sessions/${SID}/story/helpers/h1`);
|
||||
const steps = $('.story-helper-steps')!;
|
||||
expect(steps.textContent).toContain('1 step');
|
||||
// Paths inside the session's folder are shown short; the click still opens the full path.
|
||||
const link = steps.querySelector('.story-link') as HTMLElement;
|
||||
expect(link.textContent).toBe('a.md');
|
||||
link.click();
|
||||
expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/a.md', SID);
|
||||
});
|
||||
|
||||
it('Tab stays inside the overlay', async () => {
|
||||
boot(storyOf(demoTurn()));
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
// JSDOM has no layout (offsetParent is always null), so give every control one.
|
||||
for (const el of $$('#storyOverlay button, #storyOverlay select'))
|
||||
Object.defineProperty(el, 'offsetParent', { get: () => win.document.body });
|
||||
const controls = $$('#storyOverlay button, #storyOverlay select').filter((n) => !(n as HTMLButtonElement).disabled);
|
||||
controls[controls.length - 1].focus();
|
||||
press('Tab');
|
||||
expect(win.document.activeElement).toBe(controls[0]);
|
||||
});
|
||||
|
||||
it('turning the setting off hides the button and closes an open story', async () => {
|
||||
boot(storyOf(demoTurn()));
|
||||
app.openStoryMode(SID);
|
||||
@@ -341,7 +416,11 @@ describe('story-mode.js overlay', () => {
|
||||
app.loadAppSettingsFromStorage = () => ({ showStoryButton: false });
|
||||
app.applyStoryButtonVisibility();
|
||||
expect($('.btn-story-header')!.classList.contains('btn-story-header--hidden')).toBe(true);
|
||||
expect($('#sessionOptionsStoryBtn')!.hidden).toBe(true);
|
||||
expect($('#storyOverlay')!.hidden).toBe(true);
|
||||
app.loadAppSettingsFromStorage = () => ({ showStoryButton: true });
|
||||
app.applyStoryButtonVisibility();
|
||||
expect($('#sessionOptionsStoryBtn')!.hidden).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* synthetic transcript (test/story-fixtures.ts) handed to the session the way Claude's hooks do it,
|
||||
* then grown while the overlay is open so the live redraw runs off real SSE events.
|
||||
*/
|
||||
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { appendFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
@@ -95,7 +95,9 @@ describe('Story Mode in a real browser', () => {
|
||||
it('redraws live when the transcript grows and a hook fires', async () => {
|
||||
appendFileSync(transcript, jsonl(rest));
|
||||
await hook('stop');
|
||||
await page.waitForFunction(() => document.querySelector('.story-panel[data-kind="complete"]'), null, { timeout: 15000 });
|
||||
await page.waitForFunction(() => document.querySelector('.story-panel[data-kind="complete"]'), null, {
|
||||
timeout: 15000,
|
||||
});
|
||||
expect(await kinds()).toEqual(['mission', 'investigate', 'plan', 'backup', 'loop', 'save', 'complete']);
|
||||
expect(await page.locator('.story-pencils').count()).toBe(0);
|
||||
expect(await page.locator('.story-panel.story-fresh').count()).toBeGreaterThan(0);
|
||||
@@ -113,6 +115,20 @@ describe('Story Mode in a real browser', () => {
|
||||
expect(await page.locator('#storyOverlay').isHidden()).toBe(true);
|
||||
});
|
||||
|
||||
it('Session Options opens the story, and Save image downloads the page as a PNG', async () => {
|
||||
await page.evaluate((sid) => (window as any).app.openSessionOptions(sid), sessionId);
|
||||
await page.locator('#sessionOptionsStoryBtn').click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('.story-panel').length > 0);
|
||||
expect(await page.locator('#storyOverlay').isVisible()).toBe(true);
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), page.locator('.story-save').click()]);
|
||||
expect(download.suggestedFilename()).toMatch(/^story-story-browser-.*\.png$/);
|
||||
const path = await download.path();
|
||||
const head = readFileSync(path!).subarray(0, 24);
|
||||
expect(head.subarray(1, 4).toString()).toBe('PNG');
|
||||
expect(head.readUInt32BE(16)).toBeGreaterThan(1000); // width, at 2x
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
it('fits a phone-width viewport without sideways scrolling', async () => {
|
||||
await page.setViewportSize({ width: 400, height: 860 });
|
||||
await page.evaluate((sid) => (window as any).app.openStoryMode(sid), sessionId);
|
||||
|
||||
Reference in new issue
Block a user