diff --git a/src/story-mode.ts b/src/story-mode.ts index 8b95b840..7231d59d 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -726,6 +726,7 @@ function originOf(turnOrigin: unknown, kind: 'prompt' | 'task' | 'peer'): StoryT if (turnOrigin === 'task_notification' || turnOrigin === 'task-notification' || kind === 'task') return 'task'; if (turnOrigin === 'peer' || turnOrigin === 'coordinator' || kind === 'peer') return 'peer'; if (turnOrigin === 'human') return 'human'; + if (turnOrigin === 'scheduled') return 'scheduled'; if (typeof turnOrigin === 'string') return 'system'; return 'human'; } @@ -977,7 +978,9 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean } if (results.length) return out; } - if (entry.isMeta === true || entry.turnCompanion === true) return out; + // A scheduled task's prompt is written as a meta line; it still starts a turn of its own. + const scheduled = entry.turnOrigin === 'scheduled' || typeof entry.scheduledTaskId === 'string'; + if ((entry.isMeta === true && !scheduled) || entry.turnCompanion === true) return out; const local = /^\s*([\s\S]*?)<\/local-command-\1>/.exec(blockText(content)); if (local) { // eslint-disable-next-line no-control-regex @@ -1148,6 +1151,9 @@ export interface StoryTurn { endedBy?: 'turn_duration' | 'stop_hook_summary' | 'local command' | 'interrupted' | 'next prompt'; } +/** How long after a turn's end a line may still belong to it (Claude Code flushes some lines late). */ +const LATE_FLUSH_MS = 5000; + /** Plan mode keeps its plan in a file under `~/.claude/plans/`: writing it is planning, not changing code. */ const PLAN_FILE = /\/\.claude\/plans\/[^/]+\.md$/; @@ -1374,13 +1380,16 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { if (report.status && !/^(completed|done|finished|success)/i.test(report.status)) ev.ok = false; }; const ensure = (at: string): StoryTurn => { - if (!cur) { + // Work that comes well after a turn ended (no prompt of its own) is a turn of its own; a line + // Claude Code flushed a moment late still joins the turn it belongs to. + const after = cur?.ended ? Date.parse(at) - Date.parse(cur.endedAt ?? at) : 0; + if (!cur || (cur.ended && Number.isFinite(after) && after > LATE_FLUSH_MS)) { cur = { - id: `partial-${at}`, + id: cur ? `continued-${at}` : `partial-${at}`, at, prompt: '', - origin: 'human', - partial: true, + origin: cur ? 'system' : 'human', + partial: !cur, events: [], ended: false, interrupted: false, @@ -1729,15 +1738,20 @@ function capPanels(panels: DraftPanel[], max: number): DraftPanel[] { const firstMergeable = out[0]?.kind === 'mission' ? 1 : 0; while (out.length > max) { let best = -1; - search: for (const strict of [true, false]) { + // Three passes: quiet kinds; then anything but a prompt or a question; then, as a last resort, a + // prompt you added mid-turn (the turn's own prompt and a question to you are never folded). + search: for (const pass of [0, 1, 2]) { for (const lastStart of [out.length - 3, out.length - 2]) { let oldest = -1; for (let k = firstMergeable; k <= lastStart; k++) { const a = out[k]; const b = out[k + 1]; - const ok = strict - ? MINOR.has(a.kind) && MINOR.has(b.kind) - : ![a.kind, b.kind].some((kind) => kind === 'mission' || kind === 'ask'); + const ok = + pass === 0 + ? MINOR.has(a.kind) && MINOR.has(b.kind) + : pass === 1 + ? ![a.kind, b.kind].some((kind) => kind === 'mission' || kind === 'ask') + : ![a, b].some((d) => d.kind === 'ask' || (d.kind === 'mission' && d.events[0] === out[0]?.events[0])); if (!ok) continue; if (a.kind === 'montage' || b.kind === 'montage') { best = k; @@ -1820,6 +1834,7 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde human: 'You give Claude a job.', task: 'A background job reports back.', peer: 'Another session sends a message.', + scheduled: 'A scheduled task starts this turn.', system: 'Something automatic starts this turn.', }; const origin = evs[0]?.origin ?? turn.origin; @@ -2053,7 +2068,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde break; case 'montage': panel.notes = notesOf(evs.filter((e) => e.tool === 'Bash')); - story = `Claude keeps going: ${montageSummary(evs)}.`; + const added = evs.filter((e) => e.intent === 'prompt').length; + story = `Claude keeps going${added ? ` (you add ${plural(added, 'thing')})` : ''}: ${montageSummary(evs)}.`; break; case 'complete': break; diff --git a/src/types/story.ts b/src/types/story.ts index aec2e709..e97e814d 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -52,7 +52,7 @@ export type StoryPanelKind = export type StoryActor = 'you' | 'claude' | 'helper' | 'system'; /** Who started a turn: you, a background task reporting back, another session, or a schedule/SDK. */ -export type StoryTurnOrigin = 'human' | 'task' | 'peer' | 'system'; +export type StoryTurnOrigin = 'human' | 'task' | 'peer' | 'scheduled' | 'system'; /** One hunk of an edit, in the shape of Claude Code's `structuredPatch` (lines keep their ` `/`+`/`-` prefix). */ export interface StoryHunk { diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index f74927e5..8e9bd75a 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -1975,7 +1975,10 @@ Object.assign(CodemanApp.prototype, { ...turns.map((t, i) => { const o = document.createElement('option'); o.value = t.id; - o.textContent = `${i + 1}. ${t.prompt || (t.origin === 'task' ? 'Background task' : 'Earlier work')}`; + o.textContent = `${i + 1}. ${ + t.prompt || + (t.origin === 'task' ? 'Background task' : t.origin === 'system' ? 'Claude carries on' : 'Earlier work') + }`; return o; }) ); diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 6b28cf0f..a29aa1de 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -617,6 +617,72 @@ describe('buildStoryTurns', () => { expect(page.filesChanged).toBe(1); }); + it('a scheduled task (its prompt a meta line) is a turn of its own, not part of the one before', () => { + const turns = buildStoryTurns( + entriesOf([ + prompt('/compact', { promptId: 'p1' }), + prompt('/compact', { promptId: 'p1' }), + prompt('Compacted', { promptId: 'p1' }), + { + type: 'system', + subtype: 'scheduled_task_fire', + timestamp: '2026-10-18T11:00:00.000Z', + prompt: 'Resume the work', + }, + prompt('Resume the work', { + isMeta: true, + turnOrigin: 'scheduled', + promptSource: 'system', + scheduledTaskId: 'abc', + }), + ...bash('b', 'ls', 'List', 'a'), + end(5000), + ]) + ); + expect(turns.map((t) => [t.prompt, t.origin, t.endedBy])).toEqual([ + ['/compact', 'human', 'local command'], + ['Resume the work', 'scheduled', 'turn_duration'], + ]); + expect(buildStoryPage(turns[1]).panels[0].caption.story).toBe('A scheduled task starts this turn.'); + }); + + it('work long after a turn ended is a turn of its own; a line flushed a moment late still joins', () => { + const turns = buildStoryTurns( + entriesOf([ + prompt('one'), + end(1000), + { ...said('Late line.'), timestamp: undefined }, + { + type: 'assistant', + timestamp: '2026-10-18T12:00:00.000Z', + message: { content: [{ type: 'tool_use', id: 'x', name: 'Bash', input: { command: 'ls' } }] }, + }, + ]) + ); + expect(turns).toHaveLength(2); + expect(turns[1]).toMatchObject({ origin: 'system', prompt: '', ended: false }); + expect(turns[1].events.map((e) => e.tool)).toEqual(['Bash']); + }); + + it('a page with many prompts added mid-turn still fits, folding added prompts last', () => { + const queued = (text: string, i: number) => ({ + type: 'attachment', + timestamp: `2026-10-18T09:5${i}:00.000Z`, + attachment: { type: 'queued_command', prompt: text, commandMode: 'prompt', origin: { kind: 'human' } }, + }); + const lines: unknown[] = [prompt('big job')]; + for (let i = 0; i < 8; i++) { + lines.push(queued(`also do ${i}`, i)); + lines.push(...edit(`e${i}`, `/w/f${i}.ts`, ['+x'])); + lines.push(...bash(`b${i}`, 'npm test', 'Run tests', ' Tests 4 passed (4)')); + } + lines.push(end(60_000)); + const page = pageOf(lines); + expect(page.panels.length).toBeLessThanOrEqual(STORY_LIMITS.panelsPerPage); + expect(page.panels[0].kind).toBe('mission'); + expect(page.panels.some((p) => /you add \d+ things?/.test(p.caption.story))).toBe(true); + }); + it('an interrupt ends the turn', () => { const turns = buildStoryTurns( entriesOf([prompt('go'), ...bash('b', 'ls', 'List', 'a'), prompt('[Request interrupted by user]')])