mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(story-mode): the rest of review round 3
- A report's turn links to the helper it answers: its drawer shows what the
helper did. Reports arrive HTML-escaped (`<a@b.c>`) and now read as
text.
- Screen readers: a polite status region says when a new turn starts and
when one finishes; the note is a status, the drawer a dialog named by its
title; "Open file" buttons name their file.
- Save image draws the balloons' tails (from their CSS triangles) and is
disabled with nothing to save.
- App toasts move to the bottom while Story Mode is open: at the top they
covered its bar and turn picker on phones.
- The bar names the session as its tab does; a click on the mission balloon
opens the prompt.
- A heading ends at its line (a plan's "# Plan: add wrap" no longer runs
into its next paragraph) and a short one ("## Summary") is dropped; the
plan you approved stamps "You approved it".
- "Claude's part is done in 3 minutes. A helper is still working."; the
helper steps say "still working" while the helper runs; the advisor's
labels call it a second opinion; a check run twice the same way reads once
with a count; the session folder reads "." in commands only, never in
prose; the loop's POW keeps off its board on a narrow panel; a shell
session's note says it runs a shell or another CLI.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
757e14e991
commit
873b899c52
6 files changed
+198
-38
No files matched your search
+43
-13
@@ -210,13 +210,20 @@ const unique = <T>(items: T[]): T[] => [...new Set(items)];
|
||||
* markdown or as the end of a sentence.
|
||||
*/
|
||||
function plainMarkdown(text: string, spans: string[]): string {
|
||||
return text
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/`([^`\n]*)`/g, (_, code: string) => `\u0000${spans.push(code) - 1}\u0000`)
|
||||
.replace(/!?\[([^\]\n]*)\]\([^)\n]*\)/g, '$1')
|
||||
.replace(/(^|[\s(])(\*\*|__)(?=\S)([^\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3')
|
||||
.replace(/(^|[\s(])([*_])(?=\S)([^*_\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3')
|
||||
.replace(/^[ \t]{0,3}(#{1,6}|>|[-*+]|\d+[.)])[ \t]+/gm, '');
|
||||
return (
|
||||
text
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/`([^`\n]*)`/g, (_, code: string) => `\u0000${spans.push(code) - 1}\u0000`)
|
||||
.replace(/!?\[([^\]\n]*)\]\([^)\n]*\)/g, '$1')
|
||||
.replace(/(^|[\s(])(\*\*|__)(?=\S)([^\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3')
|
||||
.replace(/(^|[\s(])([*_])(?=\S)([^*_\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3')
|
||||
// A heading ends at its line. A short one (`## Summary`) is a label and goes; a longer one
|
||||
// (`# Plan: add wrap to mathx.py`) is a sentence of its own.
|
||||
.replace(/^[ \t]{0,3}#{1,6}[ \t]+(.*?)[ \t]*$/gm, (_, h: string) =>
|
||||
h.split(/\s+/).length <= 3 ? '' : /[.!?:]$/.test(h) ? h : `${h}.`
|
||||
)
|
||||
.replace(/^[ \t]{0,3}(>|[-*+]|\d+[.)])[ \t]+/gm, '')
|
||||
);
|
||||
}
|
||||
|
||||
const restoreSpans = (text: string, spans: string[]): string =>
|
||||
@@ -629,8 +636,9 @@ function readTeammateMessages(text: string): StoryTeammateReport[] {
|
||||
for (const m of text.matchAll(/<teammate-message\b([^>]*)>([\s\S]*?)<\/teammate-message>/g)) {
|
||||
const from = /teammate_id="([^"]*)"/.exec(m[1])?.[1]?.split('@')[0];
|
||||
if (!from) continue;
|
||||
const summary = /summary="([^"]*)"/.exec(m[1])?.[1];
|
||||
let body: string | undefined = m[2].trim();
|
||||
const rawSummary = /summary="([^"]*)"/.exec(m[1])?.[1];
|
||||
const summary = rawSummary === undefined ? undefined : unescapeXml(rawSummary);
|
||||
let body: string | undefined = unescapeXml(m[2].trim());
|
||||
if (body.startsWith('{')) {
|
||||
try {
|
||||
const note: unknown = JSON.parse(body);
|
||||
@@ -648,6 +656,13 @@ function readTeammateMessages(text: string): StoryTeammateReport[] {
|
||||
return reports;
|
||||
}
|
||||
|
||||
/** Claude Code escapes a notification's text (`<noreply@anthropic.com>`); undo the five it uses. */
|
||||
const unescapeXml = (s: string): string =>
|
||||
s.replace(
|
||||
/&(lt|gt|quot|apos|amp);/g,
|
||||
(_, e: string) => ({ lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' })[e] ?? _
|
||||
);
|
||||
|
||||
function readPrompt(raw: string): {
|
||||
text: string;
|
||||
kind: 'prompt' | 'task' | 'peer' | 'interrupt';
|
||||
@@ -685,7 +700,10 @@ function readPrompt(raw: string): {
|
||||
return { text: `${command[1].trim()} ${args.trim()}`.trim(), kind: 'prompt', slash: true };
|
||||
}
|
||||
if (/^<task-notification>/.test(text)) {
|
||||
const tag = (name: string) => new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(text)?.[1]?.trim();
|
||||
const tag = (name: string) => {
|
||||
const value = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(text)?.[1]?.trim();
|
||||
return value === undefined ? undefined : unescapeXml(value);
|
||||
};
|
||||
const count = (name: string) => {
|
||||
const n = Number(tag(name));
|
||||
return tag(name) !== undefined && Number.isFinite(n) ? n : undefined;
|
||||
@@ -1348,6 +1366,8 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
|
||||
if (report.toolUses !== undefined) helper.toolUses = report.toolUses;
|
||||
if (report.durationMs !== undefined) helper.durationMs = report.durationMs;
|
||||
ev.helper = helper;
|
||||
// The report's own turn links back to the helper: its drawer shows what the helper did.
|
||||
prompt.helper = { ...helper };
|
||||
if (report.status && !/^(completed|done|finished|success)/i.test(report.status)) ev.ok = false;
|
||||
};
|
||||
const ensure = (at: string): StoryTurn => {
|
||||
@@ -1950,9 +1970,17 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
? `${tests.failed} tests fail.`
|
||||
: 'A test fails.'
|
||||
: 'A check fails.';
|
||||
director = checks
|
||||
// The same command with the same outcome twice in a row reads once, with a count.
|
||||
const runs: Array<{ text: string; n: number }> = [];
|
||||
for (const e of checks) {
|
||||
const text = `${shortCommand(e)} → ${outcome(e)}`;
|
||||
const prev = runs[runs.length - 1];
|
||||
if (prev?.text === text) prev.n++;
|
||||
else runs.push({ text, n: 1 });
|
||||
}
|
||||
director = runs
|
||||
.slice(-3)
|
||||
.map((e) => `${shortCommand(e)} → ${outcome(e)}`)
|
||||
.map((r) => (r.n > 1 ? `${r.text} ×${r.n}` : r.text))
|
||||
.join(' · ');
|
||||
if (tests)
|
||||
director += ` · ${tests.passed} passed${tests.failed ? `, ${tests.failed} failed` : ''}${
|
||||
@@ -2035,7 +2063,9 @@ function completePanel(turn: StoryTurn, page: Omit<StoryPage, 'panels'>, trailin
|
||||
const story = turn.interrupted
|
||||
? 'You stop Claude here.'
|
||||
: running
|
||||
? `${done.slice(0, -1)} for now. ${running === 1 ? 'A helper is' : `${running} helpers are`} still working.`
|
||||
? `Claude's part is done${turn.durationMs !== undefined ? ` in ${roughDuration(turn.durationMs)}` : ''}. ${
|
||||
running === 1 ? 'A helper is' : `${running} helpers are`
|
||||
} still working.`
|
||||
: done;
|
||||
const director = [
|
||||
turn.interrupted ? 'interrupted' : 'turn_duration',
|
||||
|
||||
@@ -342,6 +342,23 @@ body.story-open {
|
||||
.story-balloon[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.story-panel[data-kind='mission'] .story-balloon {
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Spoken, not shown: the status region a screen reader announces from. */
|
||||
.story-sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* App toasts move to the bottom while Story Mode is open: at the top they cover its bar. */
|
||||
body.story-open .toast-container {
|
||||
top: auto;
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 0.75rem);
|
||||
}
|
||||
/* story-mode.js sets --story-lines when a balloon would leave its panel or cover Claude's head. */
|
||||
.story-balloon-text {
|
||||
display: -webkit-box;
|
||||
|
||||
+103
-21
@@ -989,7 +989,14 @@ const StoryArt = (() => {
|
||||
p.director,
|
||||
(pr - 0.85) / 0.15
|
||||
);
|
||||
hit(`helper:${h.event}`, x - 30, y - 112, 60, 142, `See what the helper did: ${h.task || h.name}`);
|
||||
hit(
|
||||
`helper:${h.event}`,
|
||||
x - 30,
|
||||
y - 112,
|
||||
60,
|
||||
142,
|
||||
h.type === 'advisor' ? 'See the second opinion' : `See what the helper did: ${h.task || h.name}`
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -1153,7 +1160,8 @@ const StoryArt = (() => {
|
||||
-0.1,
|
||||
spring(f - 20, { damping: 9, stiffness: 160 })
|
||||
);
|
||||
if (p.panel.ok !== undefined) {
|
||||
// The verdict stamp only where it clears the board (a narrow panel shows the board alone).
|
||||
if (p.panel.ok !== undefined && W - 108 > bx + 131) {
|
||||
const word = p.panel.ok ? 'POW!' : 'KRAK!';
|
||||
sfx(
|
||||
ctx,
|
||||
@@ -1509,7 +1517,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.body.classList.add('story-open');
|
||||
this._storyApplyTheme();
|
||||
const session = this.sessions?.get(sessionId);
|
||||
st.sessionLabel.textContent = session?.name || sessionId.slice(0, 8);
|
||||
st.sessionLabel.textContent = session
|
||||
? this.getSessionName?.(session) || session.name || sessionId.slice(0, 8)
|
||||
: sessionId.slice(0, 8);
|
||||
this._storyRender();
|
||||
this._storyStartRuntime();
|
||||
this._storyRefresh();
|
||||
@@ -1605,9 +1615,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
saveBtn.title = 'Save this page as a PNG to share';
|
||||
const closeBtn = btn('story-btn story-close', '×', 'Close Story Mode');
|
||||
bar.append(brand, nav, seg, live, saveBtn, closeBtn);
|
||||
// What a screen reader hears as the page changes: a new turn, a finished one.
|
||||
const announcer = el('p', 'story-sr');
|
||||
announcer.setAttribute('role', 'status');
|
||||
announcer.setAttribute('aria-live', 'polite');
|
||||
|
||||
const main = el('div', 'story-main');
|
||||
const note = el('p', 'story-note');
|
||||
note.setAttribute('role', 'status');
|
||||
note.hidden = true;
|
||||
note.dataset.i18nSkip = '';
|
||||
const stage = el('div', 'story-stage');
|
||||
@@ -1628,7 +1643,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const drawer = el('aside', 'story-drawer');
|
||||
drawer.hidden = true;
|
||||
drawer.setAttribute('aria-label', 'Details');
|
||||
drawer.setAttribute('role', 'dialog');
|
||||
const drawerHead = el('header', 'story-drawer-head');
|
||||
const drawerKind = el('span', 'story-drawer-kind');
|
||||
const drawerLive = el('span', 'story-live story-drawer-live');
|
||||
@@ -1637,6 +1652,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const drawerMeta = el('div', 'story-drawer-meta');
|
||||
drawerMeta.append(drawerKind, drawerLive);
|
||||
const drawerTitle = el('p', 'story-drawer-title');
|
||||
drawerTitle.id = 'storyDrawerTitle';
|
||||
drawer.setAttribute('aria-labelledby', 'storyDrawerTitle');
|
||||
const drawerSub = el('p', 'story-drawer-sub');
|
||||
drawerTitle.dataset.i18nSkip = '';
|
||||
drawerSub.dataset.i18nSkip = '';
|
||||
@@ -1646,7 +1663,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
drawerBody.dataset.i18nSkip = '';
|
||||
drawer.append(drawerHead, drawerBody);
|
||||
|
||||
root.append(bar, main, drawer);
|
||||
root.append(bar, main, drawer, announcer);
|
||||
document.body.appendChild(root);
|
||||
|
||||
const st = {
|
||||
@@ -1677,6 +1694,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
dirBtn,
|
||||
live,
|
||||
closeBtn,
|
||||
saveBtn,
|
||||
note,
|
||||
stage,
|
||||
pageEl,
|
||||
@@ -1980,13 +1998,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
st.note.textContent = note;
|
||||
st.note.hidden = !note;
|
||||
|
||||
st.saveBtn.disabled = !page || !page.panels.length;
|
||||
this._storyRenderPanels(page);
|
||||
if (st.director) this._storyRenderWire();
|
||||
this._storyAnnounce(page);
|
||||
if (st.drawer) this._storyRenderDrawer(false);
|
||||
// Panels on screen when a page first loads are drawn finished; only later arrivals animate in.
|
||||
if (data) st.first = false;
|
||||
},
|
||||
|
||||
/** Says a new turn or a finished one out loud (the polite status region), once each. */
|
||||
_storyAnnounce(page) {
|
||||
const st = this._story;
|
||||
const box = st.root.querySelector('.story-sr');
|
||||
if (!box || !page) return;
|
||||
const done = page.complete || page.interrupted;
|
||||
const key = `${page.id}|${done}`;
|
||||
if (st.announced === key) return;
|
||||
const first = st.announced === undefined;
|
||||
st.announced = key;
|
||||
if (first) return;
|
||||
const last = page.panels[page.panels.length - 1];
|
||||
box.textContent = done
|
||||
? `Turn finished. ${last?.caption.story || ''}`.trim()
|
||||
: `New turn: ${page.prompt.slice(0, 140)}`;
|
||||
},
|
||||
|
||||
_storyRenderPanels(page) {
|
||||
const st = this._story;
|
||||
const want = page ? page.panels : [];
|
||||
@@ -2153,6 +2190,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// A prompt somebody else sent (a background job, another session) is a notice, not your speech.
|
||||
const origin = page.events[p.events[0]]?.origin ?? (index === 0 ? page.origin : 'human');
|
||||
const notice = p.kind === 'mission' && origin && origin !== 'human';
|
||||
rt.balloon.onclick =
|
||||
p.kind === 'mission'
|
||||
? () =>
|
||||
this._storyOpenDrawer(
|
||||
{ view: 'prompt', panel: index, kind: 'mission', first: page.events[p.events[0]]?.id },
|
||||
null
|
||||
)
|
||||
: null;
|
||||
rt.balloon.className = `story-balloon ${
|
||||
notice
|
||||
? 'story-balloon--note'
|
||||
@@ -2165,7 +2210,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
rt.el.setAttribute('aria-label', `Panel ${index + 1}: ${p.caption.story}`);
|
||||
if (rt.answer) {
|
||||
rt.answer.hidden = !p.question?.answer;
|
||||
rt.answer.textContent = p.question?.answer ? `You chose: ${p.question.answer}` : '';
|
||||
const planned = page.events[p.events[0]]?.tool === 'ExitPlanMode';
|
||||
rt.answer.textContent = !p.question?.answer
|
||||
? ''
|
||||
: planned
|
||||
? p.question.answer === 'Approved'
|
||||
? 'You approved it'
|
||||
: 'You sent it back'
|
||||
: `You chose: ${p.question.answer}`;
|
||||
}
|
||||
if (rt.splash) {
|
||||
rt.splash.querySelector('.story-splash-title').textContent = page.interrupted ? 'Stopped!' : 'Mission complete!';
|
||||
@@ -2459,6 +2511,30 @@ Object.assign(CodemanApp.prototype, {
|
||||
g.setLineDash([]);
|
||||
}
|
||||
}
|
||||
// A balloon's tail is two CSS triangles (::before ink, ::after fill): draw them from their style.
|
||||
if (el.classList.contains('story-balloon')) {
|
||||
for (const which of ['::before', '::after']) {
|
||||
const ps = getComputedStyle(el, which);
|
||||
const poly = /polygon\(([^)]*)\)/.exec(ps.clipPath || '');
|
||||
if (ps.position !== 'absolute' || !poly) continue;
|
||||
const w = parseFloat(ps.width) || 0;
|
||||
const h = parseFloat(ps.height) || 0;
|
||||
const left = ps.left !== 'auto' ? parseFloat(ps.left) : r.width - parseFloat(ps.right) - w;
|
||||
const top = ps.top !== 'auto' ? parseFloat(ps.top) : r.height - parseFloat(ps.bottom) - h;
|
||||
const at = (v, size) => (v.endsWith('%') ? (parseFloat(v) / 100) * size : parseFloat(v) || 0);
|
||||
g.beginPath();
|
||||
poly[1].split(',').forEach((pt, k) => {
|
||||
const [px, py] = pt.trim().split(/\s+/);
|
||||
const X = x + left + at(px, w);
|
||||
const Y = y + top + at(py ?? '0', h);
|
||||
if (k) g.lineTo(X, Y);
|
||||
else g.moveTo(X, Y);
|
||||
});
|
||||
g.closePath();
|
||||
g.fillStyle = ps.backgroundColor;
|
||||
g.fill();
|
||||
}
|
||||
}
|
||||
// The text the reader sees (hidden spans, like the other reading level, are left out).
|
||||
const text = (el.innerText || el.textContent || '').trim();
|
||||
if (!text) return;
|
||||
@@ -2591,14 +2667,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
* A label as the reader wants it: paths inside the session's folder lose that prefix. Display only;
|
||||
* clicks keep the full path.
|
||||
*/
|
||||
_storyShortPath(text) {
|
||||
_storyShortPath(text, { dot = false } = {}) {
|
||||
const dir = this.sessions?.get(this._story?.sessionId)?.workingDir;
|
||||
if (!dir || dir === '/' || !text) return text;
|
||||
const base = dir.replace(/\/+$/, '');
|
||||
return text
|
||||
.split(`${base}/`)
|
||||
.join('')
|
||||
.replace(new RegExp(`${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s'"\`:;,)])`, 'g'), '.');
|
||||
const short = text.split(`${base}/`).join('');
|
||||
// In a command the folder itself reads as `.` (`ls .`); in prose that would say nothing.
|
||||
if (!dot) return short;
|
||||
return short.replace(new RegExp(`${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s'"\`:;,)])`, 'g'), '.');
|
||||
},
|
||||
|
||||
/** What a click on an event opens, as a `data-story-open` target, or null. */
|
||||
@@ -2768,6 +2844,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
out.push(el('h4', 'story-h', 'What it reported'), this._storyLinkifiedPre(ev.output, { rooted: true }));
|
||||
if (ev.outputTruncated) out.push(el('p', 'story-empty', 'The report is cut here.'));
|
||||
}
|
||||
if (ev.helper?.agentId) {
|
||||
const box = el('div', 'story-helper-steps');
|
||||
out.push(el('h4', 'story-h', 'What the helper did'), box);
|
||||
this._storyFillHelperSteps(box, ev.helper.agentId, false);
|
||||
}
|
||||
}
|
||||
if (prompts.some((x) => x.ev.label.length >= 3999))
|
||||
out.push(el('p', 'story-empty', 'A long prompt is cut here; the terminal has all of it.'));
|
||||
@@ -2807,7 +2888,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
} else if (view === 'helper') {
|
||||
const ev = items[0]?.ev;
|
||||
kind = 'Helper';
|
||||
kind = ev?.helper?.type === 'advisor' ? 'Advisor' : 'Helper';
|
||||
title = ev?.helper ? ev.helper.task || ev.helper.name : 'Helper';
|
||||
const h = ev?.helper;
|
||||
const working = !!(ev?.pending || h?.running);
|
||||
@@ -2844,7 +2925,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (h.agentId) {
|
||||
const box = el('div', 'story-helper-steps');
|
||||
out.push(el('h4', 'story-h', 'What the helper did'), box);
|
||||
this._storyFillHelperSteps(box, h.agentId);
|
||||
this._storyFillHelperSteps(box, h.agentId, working);
|
||||
}
|
||||
}
|
||||
} else if (view === 'plan') {
|
||||
@@ -2896,8 +2977,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/** A helper's own steps (GET …/story/helpers/:agentId), cached per helper and revalidated by ETag. */
|
||||
async _storyFillHelperSteps(box, agentId) {
|
||||
async _storyFillHelperSteps(box, agentId, working = false) {
|
||||
const st = this._story;
|
||||
box.dataset.working = working ? '1' : '';
|
||||
const cached = st.helperSteps?.get(agentId);
|
||||
if (cached) this._storyRenderHelperSteps(box, cached.data);
|
||||
else {
|
||||
@@ -2943,7 +3025,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
const summary = [
|
||||
`${steps.length} step${steps.length === 1 ? '' : 's'}`,
|
||||
page.filesChanged ? `${page.filesChanged} file${page.filesChanged === 1 ? '' : 's'} changed` : '',
|
||||
data.helper?.finished === false ? 'still working' : '',
|
||||
data.helper?.finished === false || (box.dataset.working === '1' && data.helper?.finished !== true)
|
||||
? 'still working'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
@@ -2986,6 +3070,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
open.type = 'button';
|
||||
open.className = 'story-btn-s';
|
||||
open.textContent = 'Open file';
|
||||
open.setAttribute('aria-label', `Open ${this._storyShortPath(edit.file)}`);
|
||||
open.addEventListener('click', (e) => this._storyOpenFile(edit.file, e.currentTarget));
|
||||
head.append(name, cnt, open);
|
||||
section.append(head);
|
||||
@@ -3019,12 +3104,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
_storyOutputSection(ev, index, el) {
|
||||
const section = el('section', 'story-out');
|
||||
const head = el('div', 'story-out-head');
|
||||
const cmd = el(
|
||||
'span',
|
||||
'story-out-cmd',
|
||||
`${ev.tool === 'Bash' ? '$ ' : `${ev.tool} `}${this._storyShortPath(ev.label)}`
|
||||
);
|
||||
if (ev.label !== this._storyShortPath(ev.label)) cmd.title = ev.label;
|
||||
const shown = this._storyShortPath(ev.label, { dot: ev.tool === 'Bash' });
|
||||
const cmd = el('span', 'story-out-cmd', `${ev.tool === 'Bash' ? '$ ' : `${ev.tool} `}${shown}`);
|
||||
if (ev.label !== shown) cmd.title = ev.label;
|
||||
const status = el(
|
||||
'span',
|
||||
`story-tag story-tag--${ev.ok === false ? 'bad' : ev.pending ? 'pending' : 'ok'}`,
|
||||
|
||||
@@ -56,7 +56,7 @@ type Source = { unsupported: string } | { path: string | null };
|
||||
async function storySource(ctx: SessionPort & ConfigPort, id: string, req: FastifyRequest): Promise<Source> {
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') {
|
||||
return { unsupported: 'Story Mode draws Claude Code sessions. This session runs a different CLI.' };
|
||||
return { unsupported: 'Story Mode draws Claude Code sessions, and this one runs a shell or another CLI.' };
|
||||
}
|
||||
if (session.remote || session.docker) {
|
||||
return {
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('GET /api/sessions/:id/story', () => {
|
||||
const { app } = await setup({ mode: 'codex' });
|
||||
const data = (await app.inject({ method: 'GET', url: URL })).json().data;
|
||||
expect(data.supported).toBe(false);
|
||||
expect(data.reason).toMatch(/different CLI/);
|
||||
expect(data.reason).toMatch(/shell or another CLI/);
|
||||
});
|
||||
|
||||
it('answers unsupported for remote and Docker sessions, whose transcript is not on this host', async () => {
|
||||
|
||||
+33
-2
@@ -148,7 +148,13 @@ describe('text helpers', () => {
|
||||
'Renamed rename_by_date.sh to __init__.py style.'
|
||||
);
|
||||
expect(firstSentence('It is *really* done and _very_ fast.')).toBe('It is really done and very fast.');
|
||||
expect(firstSentence('## Summary\n- Fixed the 2*3 bug.')).toBe('Summary Fixed the 2*3 bug.');
|
||||
expect(firstSentence('## Summary\n- Fixed the 2*3 bug.')).toBe('Fixed the 2*3 bug.');
|
||||
});
|
||||
|
||||
it('firstSentence ends a heading at its line', () => {
|
||||
expect(firstSentence('# Plan: add `wrap` to mathx.py\n\n**Context:** it has clamp().')).toBe(
|
||||
'Plan: add wrap to mathx.py.'
|
||||
);
|
||||
});
|
||||
|
||||
it('firstSentence treats code spans as words and an ellipsis or e.g. as no end', () => {
|
||||
@@ -347,7 +353,7 @@ describe('buildStoryTurns', () => {
|
||||
'Claude sends 1 helper off to work in the background.'
|
||||
);
|
||||
expect(page.panels[page.panels.length - 1].caption.story).toBe(
|
||||
'Done in 1 second for now. A helper is still working.'
|
||||
"Claude's part is done in 1 second. A helper is still working."
|
||||
);
|
||||
|
||||
const notification = [
|
||||
@@ -371,6 +377,8 @@ describe('buildStoryTurns', () => {
|
||||
const second = buildStoryPage(turns[1]);
|
||||
expect(second.prompt).toBe('Agent "Write README" finished');
|
||||
expect(second.events[0].output).toBe('I wrote README.md.');
|
||||
// The report's turn links back to the helper it answers.
|
||||
expect(second.events[0].helper).toMatchObject({ agentId: 'h9', result: 'I wrote README.md.' });
|
||||
});
|
||||
|
||||
it('a prompt typed while Claude works (a queued_command attachment, no user line) joins the turn', () => {
|
||||
@@ -489,6 +497,29 @@ describe('buildStoryTurns', () => {
|
||||
expect(first.helpersRunning).toBe(0);
|
||||
});
|
||||
|
||||
it('a report written HTML-escaped reads as text', () => {
|
||||
const [turn] = buildStoryTurns(
|
||||
entriesOf([
|
||||
prompt(
|
||||
'<task-notification>\n<summary>Agent "Docs" finished</summary>\n<result>Co-Authored-By: A <a@b.c> & more</result>\n</task-notification>',
|
||||
{ turnOrigin: 'task_notification' }
|
||||
),
|
||||
])
|
||||
);
|
||||
expect(turn.prompt).toBe('Agent "Docs" finished');
|
||||
expect(turn.events[0].output).toBe('Co-Authored-By: A <a@b.c> & more');
|
||||
});
|
||||
|
||||
it('a check run twice the same way reads once, with a count', () => {
|
||||
const page = pageOf([
|
||||
prompt('test twice'),
|
||||
...bash('b1', 'pytest -q', 'Run tests', '3 passed in 0.1s'),
|
||||
...bash('b2', 'pytest -q', 'Run tests again', '3 passed in 0.1s'),
|
||||
end(1000),
|
||||
]);
|
||||
expect(page.panels.find((p) => p.kind === 'check')!.caption.director).toBe('pytest -q → ok ×2 · 3 passed');
|
||||
});
|
||||
|
||||
it('a message from another session names its sender; pasted text loses its wrapper', () => {
|
||||
const [peer] = buildStoryTurns(
|
||||
entriesOf([
|
||||
|
||||
Reference in new issue
Block a user