fix(story-mode): no celebration when the turn is not a clean finish

From review round 8: "Mission complete!" with confetti and cheering
helpers sat over balloons saying the page will not work as opened, a
helper's tests have not run, or a problem is left. The final panel is now
calm (no confetti, a neutral robot) whenever work was still out at the
turn's end, nothing worked, or Claude's last words name an open problem
(`page.openNote`: a key point about something wrong, not done, not
checked or not fixed); the last of these reads "Done, with a catch".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 15:23:19 +02:00
1 parent faca7571e2
commit 88040c997d
17 files changed
+80 -7

No files matched your search

+1 -1
View File
@@ -1074,7 +1074,7 @@ A turn is one prompt and everything Claude did for it. `origin` says who sent th
### Background work
A helper started in the background (`run_in_background`, or as a teammate: `status: teammate_spawned`) has `helper.background` and `helper.running: true` until it reports back. A `<task-notification>` (matched by tool call) or a `<teammate-message>` (matched by name) joins the answer, tool count and duration back onto the helper in the turn that started it. A report that arrives after that turn ended starts a turn of its own (origin `task`) whose prompt event carries the report as `output` and the helper as `helper`; one that lands mid-turn is an event with intent `notice` instead of a new mission. `page.helpersRunning` counts the helpers still out, and `page.finishedLater` the helpers and background commands that finished only after their turn ended (each such event carries `finishedLater: true`); the final panel then says so instead of a clean finish.
A helper started in the background (`run_in_background`, or as a teammate: `status: teammate_spawned`) has `helper.background` and `helper.running: true` until it reports back. A `<task-notification>` (matched by tool call) or a `<teammate-message>` (matched by name) joins the answer, tool count and duration back onto the helper in the turn that started it. A report that arrives after that turn ended starts a turn of its own (origin `task`) whose prompt event carries the report as `output` and the helper as `helper`; one that lands mid-turn is an event with intent `notice` instead of a new mission. `page.helpersRunning` counts the helpers still out, and `page.finishedLater` the helpers and background commands that finished only after their turn ended (each such event carries `finishedLater: true`); the final panel then says so instead of a clean finish. `page.openNote` says Claude's last words name an open problem or something not done or checked (one of its `keyPoints`), so that panel is not a celebration either.
A Bash call sent to the background (its result is only `Command running in background with ID: …`) has `running: true` and no `ok`: it started, nothing more is known, and its `output` says so instead of carrying the launch note meant for Claude. Its `<task-notification>` settles `ok` from the exit code in the summary (the `output` then names the exit code, and `background: true` stays, so the page says the run happened in the background), and reading the job's output file later is a test run when that output reports test counts.
+15 -4
View File
@@ -275,9 +275,14 @@ export function balloonText(text: string): string {
* expected, a correction of an earlier warning. WEAK: a bare negation ("I didn't push"), kept only when
* nothing strong is there.
*/
/** The open-problem part of a key point: something wrong, not done, not checked, or still out. */
const OPEN_PROBLEM = new RegExp(
String.raw`\b(problem|known (issue|quirk)s?|quirks?|caveat|limitation|heads up|warning|not yet|haven't|have not|never|unfixed|left (open|unfixed|out)|still (fails?|failing|broken|missing|open|running|working)|must be (served|run)|(won't|doesn't|does not|will not) (work|fix)|(isn't|is not|not) fixed|can't tell|isn't (confirmed|committed|pushed|tested|done|checked)|not (checked|tested|verified|confirmed|committed|pushed|opened))\b`,
'i'
);
const KEY_POINT_STRONG = new RegExp(
[
String.raw`\b(problem|known (issue|quirk)s?|quirks?|caveat|limitation|heads up|warning|not yet|haven't|have not|never|unfixed|left (open|unfixed|out)|still (fails?|failing|broken|missing|open|running|working)|must be (served|run)|(won't|doesn't|does not|will not) work|can't tell|isn't (confirmed|committed|pushed|tested|done|checked)|not (checked|tested|verified|confirmed|committed|pushed|opened))\b`,
OPEN_PROBLEM.source,
String.raw`\b(as you (asked|expected|said|wanted|suggested)|you asked|your (--?[\w-]+|request|option|flag|change))\b`,
String.raw`\b(out of date|looked too early|turned out|was wrong)\b`,
].join('|'),
@@ -1991,7 +1996,7 @@ const REPORT_TALK: Partial<Record<StoryPanelKind, RegExp>> = {
};
const ANNOUNCES = /\b(I'll|I will|let me|let's|I'm going to|next,? I|now I)\b/i;
function groupPanels(events: StoryEvent[]): { panels: DraftPanel[]; trailingSay?: string } {
function groupPanels(events: StoryEvent[]): { panels: DraftPanel[]; trailingSay?: string; openNote?: boolean } {
const panels: DraftPanel[] = [];
let pendingSay: string | undefined;
let lastSaid: StoryEvent | undefined;
@@ -2037,7 +2042,12 @@ function groupPanels(events: StoryEvent[]): { panels: DraftPanel[]; trailingSay?
pendingSay = undefined;
});
// The closing words keep a caveat that follows them ("Nothing is committed: this is no git repo.").
return { panels, trailingSay: lastSaid !== undefined ? closingText(lastSaid.label, lastSaid.keyPoints) : pendingSay };
return {
panels,
trailingSay: lastSaid !== undefined ? closingText(lastSaid.label, lastSaid.keyPoints) : pendingSay,
// Claude's last words name an open problem: the final panel is not a celebration.
openNote: !!lastSaid?.keyPoints?.some((k) => OPEN_PROBLEM.test(k)),
};
}
/** Fix → check → fix → check (4 panels or more) is one "loop" panel: try, test, repeat. */
@@ -2611,7 +2621,7 @@ export function buildStoryPage(turn: StoryTurn): StoryPage {
if (todos) e.todos = todos;
}
const { panels: grouped, trailingSay } = groupPanels(events);
const { panels: grouped, trailingSay, openNote } = groupPanels(events);
const room = STORY_LIMITS.panelsPerPage - (turn.ended ? 1 : 0);
const drafts = capPanels(collapseLoops(grouped), room);
const panels = drafts.map((d, i) => decorate(d, events, turn, i));
@@ -2628,6 +2638,7 @@ export function buildStoryPage(turn: StoryTurn): StoryPage {
// The advisor is a second opinion, not a helper that does work.
helpers: events.filter((e) => e.helper && e.tool !== 'Advisor' && e.intent !== 'prompt').length,
helpersRunning: events.filter((e) => e.helper?.running).length,
...(openNote && turn.ended && !turn.interrupted ? { openNote: true } : {}),
...(events.some((e) => e.finishedLater && e.intent !== 'prompt')
? { finishedLater: events.filter((e) => e.finishedLater && e.intent !== 'prompt').length }
: {}),
+2
View File
@@ -210,6 +210,8 @@ export interface StoryPage extends StoryTurnSummary {
helpersRunning: number;
/** Helpers and background commands of this turn that finished only after it ended. */
finishedLater?: number;
/** Claude's last words name an open problem or something not done or checked: no clean finish. */
openNote?: boolean;
/** Every tool call of the turn failed (a blocked command, nothing that worked): no celebration. */
noneWorked?: boolean;
}
+11 -2
View File
@@ -1436,7 +1436,14 @@ const StoryArt = (() => {
}
return;
}
if (p.page.noneWorked) {
// Not a clean finish (nothing worked, work still out, or Claude named an open problem): no party.
const calm =
p.page.noneWorked ||
p.page.helpersRunning ||
p.page.finishedLater ||
p.page.openNote ||
p.page.events.some((e) => e.running && !e.helper);
if (calm) {
agent(ctx, ax, 250, { frame: f, scale: sc, mood: 'neutral', l: [-34, -48], r: [34, -48] });
return;
}
@@ -2484,7 +2491,9 @@ Object.assign(CodemanApp.prototype, {
: page.helpersRunning || page.finishedLater || page.events.some((e) => e.running && !e.helper)
? // Work was still out when the turn ended (and maybe still is): not a clean finish.
'Done for now'
: 'Mission complete!';
: page.openNote
? 'Done, with a catch'
: 'Mission complete!';
const chips = rt.splash.querySelector('.story-chips');
const items = [];
if (page.durationMs !== undefined) items.push({ text: this._storyDuration(page.durationMs) });
@@ -12,6 +12,7 @@
"helpers": 2,
"helpersRunning": 0,
"finishedLater": 2,
"openNote": true,
"panels": [
{
"kind": "mission",
@@ -81,6 +82,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -127,6 +129,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -176,6 +179,7 @@
"helpers": 1,
"helpersRunning": 0,
"finishedLater": 2,
"openNote": true,
"panels": [
{
"kind": "mission",
@@ -242,6 +246,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -291,6 +296,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -337,6 +343,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -396,6 +403,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -432,6 +440,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -12,6 +12,7 @@
"helpers": 1,
"helpersRunning": 0,
"finishedLater": 1,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -108,6 +109,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -164,6 +166,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -12,6 +12,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -101,6 +102,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -147,6 +149,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -12,6 +12,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -155,6 +156,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -227,6 +229,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -306,6 +309,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -12,6 +12,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -104,6 +105,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -12,6 +12,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -160,6 +161,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -12,6 +12,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -12,6 +12,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": true,
"panels": [
{
"kind": "mission",
@@ -12,6 +12,7 @@
"helpers": 2,
"helpersRunning": 0,
"finishedLater": 2,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -111,6 +112,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": true,
"panels": [
{
"kind": "mission",
@@ -167,6 +169,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -226,6 +229,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -318,6 +322,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": false,
"panels": [
{
"kind": "mission",
@@ -12,6 +12,7 @@
"helpers": 1,
"helpersRunning": 0,
"finishedLater": 1,
"openNote": true,
"panels": [
{
"kind": "mission",
@@ -121,6 +122,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": true,
"panels": [
{
"kind": "mission",
@@ -12,6 +12,7 @@
"helpers": 0,
"helpersRunning": 0,
"finishedLater": 0,
"openNote": true,
"panels": [
{
"kind": "mission",
+3
View File
@@ -40,6 +40,7 @@ function pagesOf(name: string) {
helpers: p.helpers,
helpersRunning: p.helpersRunning,
finishedLater: p.finishedLater ?? 0,
openNote: p.openNote ?? false,
panels: p.panels.map((x) => ({
kind: x.kind,
ok: x.ok ?? null,
@@ -159,6 +160,8 @@ describe('Story Mode on real sessions', () => {
const last = (name: string, turn: number) => pagesOf(name)[turn].panels.at(-1)!;
// An open problem, said further into the final message than its first sentence.
expect(last('web-page-node-test', 0).balloon).toMatch(/won't work if you double-click index\.html/);
// ...and the turn is not drawn as a clean finish.
expect(pagesOf('web-page-node-test')[0].openNote).toBe(true);
expect(last('text-plan-dry-run-helper', 1).balloon).toMatch(/doesn't fix it/);
// What you expected, and what you asked for mid-turn.
expect(last('errors-push-append', 2).balloon).toMatch(/As you expected, git describe --tags then failed/);
+15
View File
@@ -851,6 +851,21 @@ describe('story-mode.js overlay', () => {
expect($$('.story-ln--add').map((l) => l.textContent)).toEqual(['+more']);
});
it('a turn whose last words name an open problem ends "Done, with a catch", not "Mission complete!"', async () => {
const words = [
'I built the pomodoro timer and its tests, and all of them pass.',
'It keeps the time in memory and redraws the clock every second while it runs. '.repeat(3),
"Opening the page: it won't work if you double-click index.html.",
].join(' ');
boot(storyOf([prompt('build it'), ...edit('e1', '/work/demo-shop/t.js', ['+x']), said(words), end(1000)]));
app.openStoryMode(SID);
await settle();
expect($('.story-panel[data-kind="complete"] .story-splash-title')!.textContent).toBe('Done, with a catch');
expect($('.story-panel[data-kind="complete"] .story-balloon-text')!.textContent).toMatch(
/won't work if you double-click/
);
});
it('the final panel counts the commits of the whole turn, and says when the work was uploaded', async () => {
boot(
storyOf([