fix(story-mode): a second opinion is a line in the next panel, not a panel of its own

From review round 9: "Claude asks a stronger AI model for a second
opinion" stood as a whole panel on almost every page, and the reader
never learns what it said (the answer is encrypted). An advisor call now
rides on the panel of the step after it (or the last one, when nothing
follows), counted in `secondOpinion`: a small line under the caption,
"after asking a stronger AI model for a second opinion" ("then asks…"
when it came last), and "· after 1 second opinion" in the Director's
cut. Pages lose a panel of noise and keep room for the work.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 15:49:45 +02:00
1 parent 555efef53d
commit a80638308b
17 files changed
+137 -240

No files matched your search

+30 -1
View File
@@ -2082,7 +2082,19 @@ function groupPanels(events: StoryEvent[]): {
const panels: DraftPanel[] = [];
let pendingSay: string | undefined;
let lastSaid: StoryEvent | undefined;
// A second opinion (the advisor) is not a step of its own (its answer is not readable anyway): it
// rides on the panel of the step after it, or the last one when nothing follows.
let opinions: number[] = [];
const takeOpinions = (panel: DraftPanel) => {
if (!opinions.length) return;
panel.events = [...panel.events, ...opinions].sort((a, b) => a - b);
opinions = [];
};
events.forEach((ev, idx) => {
if (ev.tool === 'Advisor') {
opinions.push(idx);
return;
}
if (ev.intent === 'say' && !ev.todos) {
pendingSay ??= balloonText(ev.label);
lastSaid ??= ev;
@@ -2098,6 +2110,7 @@ function groupPanels(events: StoryEvent[]): {
(events[last.events[0]].tool === 'Advisor') !== (ev.tool === 'Advisor');
if (last && last.kind === kind && MERGEABLE.has(kind) && !advisorMix) {
last.events.push(idx);
takeOpinions(last);
if (pendingSay && !last.balloon) last.balloon = { who: 'claude', text: pendingSay };
} else {
const panel: DraftPanel = { kind, events: [idx] };
@@ -2119,10 +2132,16 @@ function groupPanels(events: StoryEvent[]): {
if (kind === 'mission') panel.balloon = { who: 'you', text: clip(ev.label, 400) };
else if (reportsBack && last && pendingSay) last.balloon = { who: 'claude', text: pendingSay };
else if (pendingSay) panel.balloon = { who: 'claude', text: pendingSay };
takeOpinions(panel);
panels.push(panel);
}
pendingSay = undefined;
});
if (opinions.length) {
const last = panels[panels.length - 1];
if (last) takeOpinions(last);
else panels.push({ kind: 'backup', events: opinions });
}
// The closing words keep a caveat that follows them ("Nothing is committed: this is no git repo.").
return {
panels,
@@ -2282,8 +2301,12 @@ function plannedFailure(turn: StoryTurn, events: StoryEvent[]): false | 'asked'
}
function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, index: number): StoryPanel {
const evs = draft.events.map((i) => events[i]);
const all = draft.events.map((i) => events[i]);
// Second opinions riding on this step are counted, never part of its story.
const opinions = all.filter((e) => e.tool === 'Advisor').length;
const evs = opinions && opinions < all.length ? all.filter((e) => e.tool !== 'Advisor') : all;
const panel: StoryPanel = { kind: draft.kind, events: draft.events, caption: { story: '', director: '' } };
if (evs !== all) panel.secondOpinion = opinions;
if (draft.balloon) panel.balloon = draft.balloon;
const edits = evs.flatMap(editsOf);
const checks = evs.filter((e) => e.intent === 'test' || e.intent === 'build');
@@ -2666,6 +2689,12 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
case 'complete':
break;
}
if (panel.secondOpinion) {
// Asked before the step, or (nothing following) after the last one.
const lastWork = Math.max(...draft.events.filter((i) => events[i].tool !== 'Advisor'));
const trailing = draft.events.some((i) => events[i].tool === 'Advisor' && i > lastWork);
director += ` · ${trailing ? 'then' : 'after'} ${plural(panel.secondOpinion, 'second opinion')}`;
}
panel.caption = { story, director };
return panel;
}
+2
View File
@@ -174,6 +174,8 @@ export interface StoryPanel {
removed?: number;
ok?: boolean;
rounds?: number;
/** Second opinions (the advisor) Claude asked for right before this step: not a step of their own. */
secondOpinion?: number;
/** A loop's test runs in order: whether each passed, `null` for one still running. */
roundsOk?: (boolean | null)[];
tests?: StoryTests;
+13
View File
@@ -340,6 +340,19 @@ body.story-open {
border-radius: 6px;
background: transparent;
}
.story-cap-note {
display: block;
margin-top: 3px;
font: 600 10.5px/1.25 var(--story-mono);
letter-spacing: 0.01em;
text-transform: none;
color: var(--story-cap-ink);
opacity: 0.78;
}
.story-cap-note[hidden],
.story-overlay.is-director .story-cap-note {
display: none;
}
.story-hit:hover,
.story-hit:focus-visible {
outline: 3px dashed var(--story-magenta) !important;
+15 -1
View File
@@ -2375,7 +2375,11 @@ Object.assign(CodemanApp.prototype, {
capStory.className = 'story-cap-story';
const capDir = document.createElement('span');
capDir.className = 'story-cap-dir';
cap.append(capStory, capDir);
// A second opinion Claude asked for before this step: a line, not a panel of its own.
const capNote = document.createElement('span');
capNote.className = 'story-cap-note';
capNote.hidden = true;
cap.append(capStory, capDir, capNote);
const balloon = document.createElement('p');
balloon.className = 'story-balloon';
balloon.hidden = true;
@@ -2415,6 +2419,7 @@ Object.assign(CodemanApp.prototype, {
panel: null,
capStory,
capDir,
capNote,
balloon,
liveTag,
splash,
@@ -2484,6 +2489,15 @@ Object.assign(CodemanApp.prototype, {
: 'story-balloon--claude'
}`;
rt.liveTag.hidden = !p.live;
if (rt.capNote) {
// Asked before this step, or after it when nothing followed.
const work = p.events.filter((i) => page.events[i]?.tool !== 'Advisor');
const trailing = p.events.some((i) => page.events[i]?.tool === 'Advisor' && i > Math.max(...work));
rt.capNote.hidden = !p.secondOpinion;
rt.capNote.textContent = p.secondOpinion
? `${trailing ? 'then asks' : 'after asking'} a stronger AI model for a second opinion`
: '';
}
rt.el.classList.toggle('is-live', !!p.live);
// Named by its place only: the caption inside is read as the panel's content, once.
rt.el.setAttribute('aria-label', `Panel ${index + 1} of ${page.panels.length}`);