fix(story-mode): a phone you can use with a thumb, and saves that say what they saved

From review round 5 (a phone with touch):

- Touch screens: every control at least 44 px (prev/next, close, the
  Story/Director's cut toggle, Save image, the final panel's chips, drawer
  buttons and links), and the small type at 13 px.
- The drawer on a phone starts below the bar (it cut through it), closes by
  dragging its head down, and keeps its head small in landscape.
- The turn picker says where you are ("Turn 2 of 5"), learns about new
  turns while you look at an older one, and a turn you picked yourself is
  not announced as news.
- A check whose command also wrote files offers "See the 2 files it wrote".
- Test counts are read from the whole output: a later command's lines had
  pushed pytest's "5 passed" out of the kept tail.
- A save names its commit: "Claude saves the work: "feat: add wrap() for
  modular wrapping into a range"." (from -m, -am, --message or the
  here-document of git commit -F -; a trailer is never the subject).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 12:43:59 +02:00
1 parent cdb7b5d55f
commit 3f1ecfe83a
11 files changed
+118 -22

No files matched your search

+31 -4
View File
@@ -80,6 +80,8 @@ interface ReducedInput {
headline?: string;
/** How many commits a save makes, when more than one. */
commits?: number;
/** The first commit's message, when the command spells it out. */
subject?: string;
/** Files a Bash call writes through here-documents. */
writes?: StoryEdit[];
description?: string;
@@ -458,6 +460,21 @@ function echoWrites(command: string, cwd?: string): StoryEdit[] {
return out;
}
/**
* The first commit's message in a command: `-m "..."` / `-m '...'`, or the first line of the
* here-document a `git commit -F -` reads.
*/
function commitSubject(command: string): string | undefined {
// `-m`, a combined `-am` / `-qm`, or `--message`; a trailer (`Co-Authored-By:`) is never the subject.
const m = /\bgit\b[^\n;&|]*\bcommit\b[^\n;&|]*?\s-(?:[a-zA-Z]*m|-message=?)\s*(["'])((?:(?!\1)[\s\S])*)\1/.exec(
command
);
const first = m?.[2].split('\n')[0].trim();
if (first && !/^[\w-]+-By:/i.test(first)) return clip(first, 100);
const f = /\bgit\b[^\n;&|]*\bcommit\b[^\n]*\s-F\s*-[^\n]*<<-?\s*(['"]?)(\w+)\1[^\n]*\n([^\n]*)/.exec(command);
return f && f[3].trim() && f[3].trim() !== f[2] ? clip(f[3].trim(), 100) : undefined;
}
/** A here-document's body is data, not commands: `cat > a.py <<'EOF' ... EOF` keeps only its first line. */
function withoutHeredocBodies(command: string): string {
const out: string[] = [];
@@ -632,7 +649,8 @@ export function reportsProblems(output: string): boolean {
* Scans the last lines only; returns null when no summary is recognisable.
*/
export function parseTestCounts(output: string): StoryTests | null {
const lines = output.split('\n').slice(-40).reverse();
// The newest summary wins; a long output is searched from its end (the last 2,000 lines).
const lines = output.split('\n').slice(-2000).reverse();
// node:test (TAP `# pass 7` / spec reporter `ℹ pass 7`), with its own fail line.
const nodePass = lines.map((l) => /^\s*[#ℹ]\s*pass\s+(\d+)\s*$/.exec(l)).find(Boolean);
if (nodePass) {
@@ -807,6 +825,10 @@ function reduceInput(name: string, input: unknown, cwd?: string): ReducedInput {
else if (heads.some((h) => /^git push\b/.test(h))) input.ships = 'push';
const commits = heads.filter((h) => /^git commit\b/.test(h)).length;
if (commits > 1 || (commits && intent !== 'save')) input.commits = commits;
if (commits) {
const subject = commitSubject(full);
if (subject) input.subject = subject;
}
}
return input;
}
@@ -1004,7 +1026,8 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
if (name === 'Bash' && /^\s*Command running in background with ID:/.test(text)) {
result.background = true;
} else if (name === 'Bash') {
const tests = parseTestCounts(tail.text);
// Counted on the whole output: a later command's lines can push the summary out of the tail.
const tests = parseTestCounts(text);
if (tests) result.tests = tests;
const intent = call?.input.intent;
if ((intent === 'build' || intent === 'test') && reportsProblems(tail.text)) result.problems = true;
@@ -1299,6 +1322,7 @@ function toolEvent(e: Extract<StoryEntry, { k: 'tool' }>, actor: StoryActor): St
if (i.ships) ev.ships = i.ships;
if (i.headline) ev.headline = i.headline;
if (i.commits) ev.commits = i.commits;
if (i.subject) ev.subject = i.subject;
if (i.writes) ev.edits = i.writes;
if (i.offset) ev.line = i.offset;
if (e.name === 'Bash' && i.description) ev.description = i.description;
@@ -2159,6 +2183,9 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
if (savedAnyway) panel.ok = true;
const commits = evs.reduce((n, e) => n + (e.commits ?? (e.intent === 'save' ? 1 : 0)), 0);
const inCommits = commits > 1 ? ` in ${commits} commits` : '';
// One commit: its message says what was saved.
const subject = commits === 1 ? evs.find((e) => e.subject)?.subject : undefined;
const said = subject ? `: "${clip(subject, 60).replace(/[.]$/, '')}"` : '';
story = savedAnyway
? ships === 'push'
? `Claude saves the work${inCommits} and uploads it, but a step after that fails.`
@@ -2172,8 +2199,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
: ships === 'pr'
? 'Claude opens a pull request.'
: ships === 'push'
? `Claude saves the work${inCommits} and uploads it.`
: `Claude saves the work${inCommits}.`;
? `Claude saves the work${inCommits} and uploads it${said}.`
: `Claude saves the work${inCommits}${said}.`;
const wrote = unique(edits.map((d) => d.file)).length;
if (wrote && story.startsWith('Claude ')) story = `Claude writes ${plural(wrote, 'file')} and ${story.slice(7)}`;
director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}${
+2
View File
@@ -130,6 +130,8 @@ export interface StoryEvent {
ships?: 'pr' | 'push';
/** How many commits a Bash call makes (`git commit` segments). */
commits?: number;
/** The first commit's message, when the command spells it out (`-m "..."` or a here-document). */
subject?: string;
/** Who a prompt came from (a prompt added mid-turn can come from someone other than the turn's starter). */
origin?: StoryTurnOrigin;
output?: string;
+38 -2
View File
@@ -731,10 +731,10 @@ body.story-open .toast-container {
padding-right: max(16px, env(safe-area-inset-right, 0px));
}
.story-drawer {
top: auto;
top: calc(var(--story-bar-h, 18vh) + 6px);
left: 0;
width: auto;
height: 82vh;
height: auto;
border-left: 0;
border-top: 3px solid var(--story-ink);
padding-left: env(safe-area-inset-left, 0px);
@@ -933,6 +933,42 @@ body.story-open .toast-container {
content: '✓ ';
}
/* Touch screens: every control a finger can hit (44 px), and the small type a little larger. */
@media (pointer: coarse) {
.story-btn,
.story-drawer-close {
min-width: 44px;
min-height: 44px;
}
.story-seg-btn,
.story-save,
.story-btn-s,
.story-chips li > * {
min-height: 44px;
display: inline-flex;
align-items: center;
}
.story-link {
padding-block: 6px;
}
.story-cap-dir,
.story-ev,
.story-code,
.story-pre {
font-size: 13px;
}
}
/* A phone on its side: the sheet's head takes less of the little height there is. */
@media (max-height: 480px) {
.story-drawer-head {
gap: 2px;
padding-block: 6px;
}
.story-drawer-sub {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.story-fresh *,
.story-arrive,
+33 -2
View File
@@ -1702,6 +1702,18 @@ Object.assign(CodemanApp.prototype, {
drawer.hidden = true;
drawer.setAttribute('role', 'dialog');
const drawerHead = el('header', 'story-drawer-head');
// A sheet comes down the way it went up: drag its head down to close it.
let dragFrom = null;
drawerHead.addEventListener('touchstart', (e) => (dragFrom = e.touches[0]?.clientY ?? null), { passive: true });
drawerHead.addEventListener(
'touchend',
(e) => {
const y = e.changedTouches[0]?.clientY;
if (dragFrom !== null && y !== undefined && y - dragFrom > 70) this._storyCloseDrawer();
dragFrom = null;
},
{ passive: true }
);
const drawerKind = el('span', 'story-drawer-kind');
const drawerLive = el('span', 'story-live story-drawer-live');
drawerLive.append(el('i'), el('span', null, 'LIVE'));
@@ -1808,6 +1820,7 @@ Object.assign(CodemanApp.prototype, {
_storyPickTurn(id) {
const st = this._story;
st.picked = true; // a turn you chose is not news to announce
const turns = st.data?.turns || [];
st.turnId = id && id !== turns[turns.length - 1]?.id ? id : null;
st.first = true;
@@ -1947,7 +1960,8 @@ Object.assign(CodemanApp.prototype, {
}
// A finished newest page: look again now and then (a local command like /compact raises no
// hook). An unchanged page is a 304.
if (!st.turnId && Date.now() - (st.lastIdlePoll || 0) > STORY_IDLE_POLL_MS) {
// Parked on an older turn: the turn list still learns about new turns now and then.
if (Date.now() - (st.lastIdlePoll || 0) > STORY_IDLE_POLL_MS) {
st.lastIdlePoll = Date.now();
this._storyRefresh();
}
@@ -2066,6 +2080,7 @@ Object.assign(CodemanApp.prototype, {
if (page) sel.value = page.id;
sel.disabled = turns.length < 2;
const idx = page ? turns.findIndex((t) => t.id === page.id) : -1;
sel.setAttribute('aria-label', idx >= 0 ? `Turn ${idx + 1} of ${turns.length}` : 'Turn');
st.prev.disabled = idx <= 0;
st.next.disabled = idx < 0 || idx >= turns.length - 1;
st.live.hidden = !(page && !page.complete && !page.interrupted && !st.turnId);
@@ -2102,7 +2117,9 @@ Object.assign(CodemanApp.prototype, {
const first = st.announced === undefined;
const newTurn = !first && !st.announced.startsWith(`${page.id}|`);
st.announced = key;
if (first) return;
const picked = st.picked;
st.picked = false;
if (first || (picked && newTurn)) return;
const last = page.panels[page.panels.length - 1];
const finished = `${done ? `Turn finished. ${last?.caption.story || ''}` : ''}`.trim();
box.textContent = newTurn ? `New turn: ${page.prompt.slice(0, 140)}${finished ? `. ${finished}` : ''}` : finished;
@@ -2893,6 +2910,9 @@ Object.assign(CodemanApp.prototype, {
st.drawerSeen = new Set();
st.drawerEl.hidden = false;
st.root.classList.add('has-drawer');
// On a phone the drawer is a sheet from the bottom: it starts below the bar, whatever its height.
const bar = st.root.querySelector('.story-bar');
if (bar) st.root.style.setProperty('--story-bar-h', `${bar.offsetHeight}px`);
this._storyRenderDrawer(true);
st.drawerEl.querySelector('.story-drawer-close')?.focus({ preventScroll: true });
},
@@ -3251,6 +3271,17 @@ Object.assign(CodemanApp.prototype, {
if (ev.output !== undefined) {
section.append(this._storyLinkifiedPre(ev.output));
if (ev.outputTruncated) section.append(el('p', 'story-empty', 'Only the end of this output is kept.'));
// A command that also wrote files (a here-document) offers the change, not only what it printed.
if (ev.edits?.length) {
const b = el(
'button',
'story-btn-s',
`See the ${ev.edits.length === 1 ? 'file' : `${ev.edits.length} files`} it wrote`
);
b.type = 'button';
b.dataset.storyOpen = `diff:event:${index}`;
section.append(b);
}
} else if (ev.outputTruncated) {
section.append(el('p', 'story-empty', 'This output was left out to keep a long turn fast.'));
} else if (target && !target.startsWith('output:')) {