mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
1 parent
cdb7b5d55f
commit
3f1ecfe83a
11 files changed
+118
-22
No files matched your search
+31
-4
@@ -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')}` : ''}${
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:')) {
|
||||
|
||||
Reference in new issue
Block a user