feat(story-mode): a composer under the page, to send Claude more from Story Mode

The owner wants to keep working from the comic view: "on the bottom have an
input field! so I can send more input in that view!". Story Mode gets a bar
across the bottom (the overlay's new last grid row): a field that grows from
one line to about five, a Send button and a one-line hint.

- It sends to the session the overlay shows (st.sessionId), never the active
  tab, since Session Options opens Story for any session. Delivery reuses the
  mobile composer's tested path, KeyboardAccessoryBar._sendComposedPrompt: one
  bracketed paste on the durable exactly-once queue, then a separate delayed
  Enter. Text plus a carriage return in one burst would be read as a paste by
  Claude Code and strand the prompt.
- Enter sends, Shift+Enter adds a line, an IME composition and whitespace-only
  text never send.
- While Claude waits on a question or a permission in the terminal (a pending
  question on the newest page, or the permission/elicitation hook), sending is
  off: the message would land in the selection dialog, whose Enter picks the
  highlighted option. The field still takes a draft and the hint says why.
  A session that is gone (not in app.sessions, or a 404 from the story
  route), whose agent exited, or that Story cannot draw (a shell would run the
  text) keeps its draft readable but sends nothing. A running turn may be
  sent to: Claude Code queues the message.
- Drafts are kept per session in memory for the life of the page.
- After a send the field clears and keeps focus; a reader parked on an older
  turn (or held there by a drawer) goes to the newest, since activity is
  ignored while parked, and a refresh is scheduled.
- Escape in a field holding text steps out of it first (focus to Close), an
  empty field closes one layer as before, and an Escape that cancels an IME
  composition closes nothing.
- The bottom safe-area inset moves from the overlay onto the composer, so its
  background reaches the edge; the drawer ends above the composer and toasts
  sit above it (measured into --story-compose-h). Not part of Save image.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-19 05:13:02 +02:00
1 parent ed6c89ff98
commit 7d0b6e171c
3 files changed
+533 -5

No files matched your search

+81 -5
View File
@@ -75,13 +75,14 @@
z-index: 2400;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
/* The bar, the page, the composer. The bottom safe area is the composer's own padding, so its
background reaches the edge of the screen. */
grid-template-rows: auto minmax(0, 1fr) auto;
background: var(--story-bg);
color: var(--story-ink);
font-family: var(--story-body);
color-scheme: dark;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.story-overlay.story-paper {
--story-bg: #ecede9;
@@ -424,10 +425,11 @@ body.story-open {
clip-path: inset(50%);
white-space: nowrap;
}
/* App toasts move to the bottom while Story Mode is open: at the top they cover its bar. */
/* App toasts move to the bottom while Story Mode is open: at the top they cover its bar. They sit
above the composer, whose measured height (safe area included) story-mode.js sets on body. */
body.story-open .toast-container {
top: auto;
bottom: calc(env(safe-area-inset-bottom, 0px) + 0.75rem);
bottom: calc(var(--story-compose-h, env(safe-area-inset-bottom, 0px)) + 0.75rem);
}
/* story-mode.js sets --story-lines when a balloon would leave its panel or cover Claude's head. */
.story-balloon-text {
@@ -825,7 +827,6 @@ body.story-open .toast-container {
border-left: 0;
border-top: 3px solid var(--story-ink);
padding-left: env(safe-area-inset-left, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
}
.story-drawer-head {
@@ -1025,6 +1026,81 @@ body.story-open .toast-container {
content: '✓ ';
}
/* Composer (story-mode.js _storyCompose*): a message to the session on the page, across the bottom.
Never in Save image (that draws the page only). The drawer ends above it, so a message can be
written while a drawer is open; story-mode.js measures it into --story-compose-h. */
.story-overlay .story-drawer {
bottom: var(--story-compose-h, 0px);
}
.story-compose {
grid-row: 3;
min-width: 0;
padding: 8px max(16px, env(safe-area-inset-right, 0px)) calc(7px + env(safe-area-inset-bottom, 0px))
max(16px, env(safe-area-inset-left, 0px));
background: var(--story-sheet);
border-top: 2px solid var(--story-ink);
}
.story-compose-inner {
max-width: 1240px;
margin: 0 auto;
}
.story-compose-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.story-compose .story-compose-field {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
/* One line to about five (story-mode.js grows it to its text), then it scrolls inside. 16px keeps
iOS from zooming the page when the field takes focus. */
min-height: 44px;
max-height: calc(5 * 1.4em + 22px);
margin: 0;
padding: 9px 11px;
font: 16px/1.4 var(--story-body);
color: var(--story-ink);
background: var(--story-panel);
border: 2px solid var(--story-ink);
border-radius: 0;
resize: none;
overflow-y: auto;
}
.story-compose .story-compose-field::placeholder {
color: var(--story-muted);
opacity: 1;
}
.story-compose .story-compose-field[readonly] {
color: var(--story-muted);
border-style: dashed;
}
.story-compose .story-compose-send {
flex: none;
min-width: 76px;
height: 44px;
padding: 0 16px;
font: 20px/1 var(--story-letter);
background: var(--story-cap);
color: var(--story-cap-ink);
border: 2.5px solid var(--story-cap-ink);
border-radius: 0;
}
.story-compose .story-compose-send:disabled {
opacity: 0.4;
cursor: default;
}
.story-compose-hint {
margin: 4px 0 0;
font: 12px/1.35 var(--story-body);
color: var(--story-muted);
}
/* Claude waits on you: the one hint that needs noticing (a text token, 4.5:1 in both skins). */
.story-compose[data-state='waiting'] .story-compose-hint {
color: var(--story-wait-text);
font-weight: 650;
}
/* Touch screens: every control a finger can hit (44 px), and the small type a little larger. */
@media (pointer: coarse) {
.story-btn,
+223
View File
@@ -1846,6 +1846,7 @@ Object.assign(CodemanApp.prototype, {
drawerBody,
};
this._story = st;
this._storyComposeBuild(st);
closeBtn.addEventListener('click', () => this.closeStoryMode());
saveBtn.addEventListener('click', () => this.saveStoryImage());
@@ -1913,6 +1914,219 @@ Object.assign(CodemanApp.prototype, {
if (target) this._storyPickTurn(target.id);
},
// ── Composer: a message to the session on the page ──
/**
* The bar under the page: a field and Send. It talks to the session the overlay shows
* (`st.sessionId`), never the active tab, since Session Options opens Story for any session.
* Drafts live in memory per session id for the life of the page (prompts carry secrets, so they
* are never persisted). The bar is outside `.story-main`, so a drawer that makes the page inert
* leaves it usable, and outside the page, so Save image never draws it.
*/
_storyComposeBuild(st) {
const el = (tag, cls, text) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
};
const box = el('div', 'story-compose');
const inner = el('div', 'story-compose-inner');
const row = el('div', 'story-compose-row');
const field = el('textarea', 'story-compose-field');
field.rows = 1;
field.dataset.i18nSkip = ''; // what is typed is the user's, never translated
field.setAttribute('autocapitalize', 'sentences');
field.setAttribute('enterkeyhint', 'send');
field.spellcheck = true;
const hint = el('p', 'story-compose-hint');
hint.id = 'storyComposeHint';
field.setAttribute('aria-describedby', hint.id);
const send = el('button', 'story-compose-send', 'Send');
send.type = 'button';
send.title = 'Send to Claude (Enter). Shift+Enter adds a line.';
// i18n.js skips <textarea> subtrees, so its label and placeholder are translated here.
const t = typeof window.CodemanI18n?.t === 'function' ? (s) => window.CodemanI18n.t(s) : (s) => s;
field.setAttribute('aria-label', t('Message Claude'));
field.placeholder = t('Tell Claude what to do next');
row.append(field, send);
inner.append(row, hint);
box.append(inner);
// Between the page and the drawer: Tab walks bar, page, composer, then an open drawer.
st.root.insertBefore(box, st.drawerEl);
st.drafts = new Map();
st.compose = { el: box, field, send, hint, hintText: '', boundId: null, state: 'ready', blocked: false };
field.addEventListener('input', () => {
const id = st.compose.boundId;
if (id) {
if (field.value) st.drafts.set(id, field.value);
else st.drafts.delete(id);
}
this._storyComposeFit();
this._storyComposeSync();
});
field.addEventListener('focus', () => this._storyComposeSync());
field.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
// An IME commits what it composes with Enter: that Enter belongs to the IME, never a send.
if (e.isComposing || e.keyCode === 229) return;
// Plain Enter never inserts a line here, even when sending is blocked (a dialog waits, the
// session is gone): the hint says why, and the draft stays as it was.
e.preventDefault();
this._storySendCompose();
});
send.addEventListener('click', () => this._storySendCompose());
// The drawer ends above the composer and app toasts sit above it (story-mode.css reads the height).
// A custom property on body reaches both: the overlay inherits it, and toasts live in body.
if (typeof ResizeObserver === 'function') {
let width = 0;
new ResizeObserver(() => {
if (!st.open) return;
// A new width rewraps the text: the field takes its new height (the next callback then
// reports the bar's settled height).
if (box.offsetWidth !== width) {
width = box.offsetWidth;
this._storyComposeFit();
}
document.body.style.setProperty('--story-compose-h', `${box.offsetHeight}px`);
}).observe(box);
}
},
/**
* Whether a message can go to the session now, and the hint that says so. Blocked states keep the
* field (a draft can still be written) but never send.
* @returns {{ state: 'gone'|'exited'|'unsupported'|'waiting'|'working'|'ready', hint: string }}
*/
_storyComposeState() {
const st = this._story;
const session = this.sessions?.get(st.sessionId);
if (!session || st.goneId === st.sessionId)
return { state: 'gone', hint: 'This session is gone, so there is nothing to send to.' };
if (typeof paneExitLabel === 'function' && paneExitLabel(session.paneExit))
return { state: 'exited', hint: 'Claude has exited in this session, so nothing can be sent.' };
// The server could not draw this session (a shell, another CLI, a remote host): text plus Enter
// in a shell would run as a command, so the bar sends only to sessions Story Mode reads.
if (st.data && st.data.supported === false)
return { state: 'unsupported', hint: 'Story Mode sends only to the Claude Code sessions it can draw.' };
// Claude waits on a question or a permission in the terminal. A message there would land in the
// selection dialog, where its Enter picks whatever option is highlighted (often "Yes"), so
// sending is off until it is answered; the field still takes a draft. The page shows a pending
// question of the newest turn; a permission prompt never reaches the transcript, only the hook
// (and a reader parked on an older turn sees only the hook).
const page = st.data?.supported ? st.data.page : null;
const asking =
!st.turnId && !!page && !page.complete && !page.interrupted && page.events.some((e) => e.question && e.pending);
const hooks = this.pendingHooks?.get(st.sessionId);
if (asking || hooks?.has('permission_prompt') || hooks?.has('elicitation_dialog'))
return {
state: 'waiting',
hint: 'Claude is waiting for your answer in the terminal. Answer it there; your message waits here.',
};
const turns = st.data?.supported ? st.data.turns || [] : [];
const newest = turns[turns.length - 1];
// Claude Code queues a message typed during a turn, so sending while it works is fine.
if (newest && !newest.complete && !newest.interrupted)
return { state: 'working', hint: 'Claude is working. What you send now waits in line for it.' };
return { state: 'ready', hint: 'Enter sends. Shift+Enter adds a line.' };
},
/** Brings the bar in line with the session: its draft, whether it can send, the hint. */
_storyComposeSync() {
const st = this._story;
const c = st?.compose;
if (!c || !st.sessionId) return;
if (c.boundId !== st.sessionId) {
// Another session on the page: its own draft (the old one was saved on every keystroke).
c.boundId = st.sessionId;
c.field.value = st.drafts.get(st.sessionId) || '';
this._storyComposeFit();
}
const { state, hint } = this._storyComposeState();
c.state = state;
c.blocked = state !== 'ready' && state !== 'working';
c.el.dataset.state = state;
// Gone for good: the draft stays readable (and copyable), not editable.
c.field.readOnly = state === 'gone' || state === 'exited' || state === 'unsupported';
const off = c.blocked || !c.field.value.trim();
// A button that is about to be disabled under focus hands it to the field first.
if (off && document.activeElement === c.send) c.field.focus({ preventScroll: true });
c.send.disabled = off;
// Compared with the last English text set, never the DOM (i18n.js may have translated it).
if (c.hintText !== hint) {
c.hintText = hint;
c.hint.textContent = hint;
}
},
/** The field grows with its text up to the CSS max-height (about five lines), then scrolls. */
_storyComposeFit() {
const f = this._story?.compose?.field;
if (!f) return;
f.style.height = 'auto';
const h = f.scrollHeight;
// No layout (a hidden overlay, a test DOM): leave the field at its natural one line.
if (h > 0) f.style.height = `${h + f.offsetHeight - f.clientHeight}px`;
else f.style.height = '';
},
/**
* Sends the field to the session on the page through the mobile composer's tested path
* (KeyboardAccessoryBar._sendComposedPrompt: one bracketed paste on the durable exactly-once
* input queue, then Enter as a separate delayed write, plus the too-long toast). Never text and
* `\r` in one write: Claude Code reads a long burst as a paste and its Enter as a newline.
* @returns {boolean} whether it went out
*/
_storySendCompose() {
const st = this._story;
const c = st?.compose;
if (!st?.open || !c) return false;
// Fresh: a dialog that opened since the last poll still blocks this Enter.
this._storyComposeSync();
const text = c.field.value;
// Whitespace-only would submit blank lines; real text goes out untrimmed.
if (c.blocked || !text.trim()) return false;
const bar = typeof KeyboardAccessoryBar !== 'undefined' ? KeyboardAccessoryBar : null;
if (typeof bar?._sendComposedPrompt !== 'function') {
this.showToast?.('Sending is not available on this page. Use the terminal.', 'error');
return false;
}
const sessionId = st.sessionId;
if (!bar._sendComposedPrompt(sessionId, text)) return false;
st.drafts.delete(sessionId);
c.field.value = '';
this._storyComposeFit();
this._storyComposeSync();
c.field.focus({ preventScroll: true });
// The answer is a new turn. A reader parked on an older one (or held there by a drawer) would
// never see it, since activity is ignored while parked: go to the newest, as the picker does.
if (st.turnId || st.drawer) {
const turns = st.data?.turns || [];
this._storyPickTurn(turns[turns.length - 1]?.id ?? null);
}
// The prompt hook refreshes when Claude takes it; this one covers a workspace without hooks.
this._storyScheduleRefresh(1500);
return true;
},
/**
* Escape in the field while it holds text steps out of the field first (focus to Close) instead
* of closing a layer: an Escape mid-sentence is far more often a slip than a request to leave.
* The draft would survive a close anyway (kept per session); this keeps the reader in place.
* An empty field lets Escape close one layer, as everywhere else on the overlay.
* @returns {boolean} whether this Escape was handled
*/
_storyComposeEscape(e) {
const st = this._story;
const c = st?.compose;
if (!c || document.activeElement !== c.field || !c.field.value.trim()) return false;
e.preventDefault();
e.stopPropagation();
st.closeBtn.focus({ preventScroll: true });
return true;
},
// ── Fetching ──
_storyScheduleRefresh(delay = STORY_REFRESH_DEBOUNCE_MS) {
@@ -1958,6 +2172,9 @@ Object.assign(CodemanApp.prototype, {
body = null;
}
if (epoch !== st.epoch || !st.open) return;
// The route answers 404 only for a session that no longer exists: the composer stops sending.
// Kept with its id, so opening Story for another session never inherits it.
st.goneId = res?.status === 404 ? st.sessionId : null;
if (!res || !res.ok || !body?.success) {
st.error =
body?.error || (res?.status === 404 ? 'This session is gone.' : 'Story Mode could not read this session.');
@@ -2082,6 +2299,8 @@ Object.assign(CodemanApp.prototype, {
st.pollTimer = setInterval(() => {
const page = st.data?.page;
if (!st.open || document.hidden) return;
// A permission prompt or a closed session raises no Story event: the composer looks again.
this._storyComposeSync();
// A session Story Mode cannot draw does not change that by itself: nothing to poll.
if (st.data && st.data.supported === false) return;
if (!st.turnId && page && !page.complete && !page.interrupted) {
@@ -2108,6 +2327,9 @@ Object.assign(CodemanApp.prototype, {
return;
}
if (e.key !== 'Escape') return;
// An Escape that cancels an IME composition is the IME's, not a request to close anything.
if (e.isComposing || e.keyCode === 229) return;
if (this._storyComposeEscape(e)) return;
// A folder grid on top steps back by itself (preview, then grid), and a preview in edit mode
// keeps its keys. A plain file preview has no Escape of its own outside the grid, so Story
// closes it: one Escape per layer, the preview first.
@@ -2262,6 +2484,7 @@ Object.assign(CodemanApp.prototype, {
if (st.drawer) this._storyRenderDrawer(false);
// Panels on screen when a page first loads are drawn finished; only later arrivals animate in.
if (data) st.first = false;
this._storyComposeSync();
},
/** Says a new turn or a finished one out loud (the polite status region), once each. */
+229
View File
@@ -1183,6 +1183,235 @@ describe('story-mode.js overlay', () => {
});
});
describe('story-mode.js composer', () => {
const OTHER = 'session-0002-fedcba';
const done = (text = 'job') => storyOf([prompt(text), said('Done.'), end(1000)]);
const field = () => $('.story-compose-field') as HTMLTextAreaElement;
const sendBtn = () => $('.story-compose-send') as HTMLButtonElement;
const hint = () => $('.story-compose-hint')!.textContent;
let sent: ReturnType<typeof vi.fn>;
/** The mobile composer's send path, a global in the page (keyboard-accessory.js). */
function stubSend(ok = true) {
sent = vi.fn(() => ok);
win.KeyboardAccessoryBar = { _sendComposedPrompt: sent };
}
function type(text: string) {
field().focus();
field().value = text;
field().dispatchEvent(new win.Event('input', { bubbles: true }));
}
function enter(init: KeyboardEventInit = {}, keyCode?: number) {
const ev = new win.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, ...init });
if (keyCode !== undefined) Object.defineProperty(ev, 'keyCode', { value: keyCode });
field().dispatchEvent(ev);
return ev;
}
it('Enter sends; Shift+Enter, an IME composition and whitespace never do', async () => {
boot(done());
stubSend();
app.openStoryMode(SID);
await settle();
expect(field().getAttribute('aria-label')).toBe('Message Claude');
expect(field().hasAttribute('data-i18n-skip')).toBe(true);
expect(hint()).toBe('Enter sends. Shift+Enter adds a line.');
expect(sendBtn().disabled).toBe(true);
type('line one');
expect(sendBtn().disabled).toBe(false);
expect(enter({ shiftKey: true }).defaultPrevented).toBe(false);
expect(enter({ isComposing: true }).defaultPrevented).toBe(false);
expect(enter({}, 229).defaultPrevented).toBe(false);
expect(sent).not.toHaveBeenCalled();
type(' \n ');
expect(enter().defaultPrevented).toBe(true);
expect(sent).not.toHaveBeenCalled();
type('line one\nline two');
enter();
expect(sent).toHaveBeenCalledTimes(1);
expect(sent).toHaveBeenCalledWith(SID, 'line one\nline two');
expect(field().value).toBe('');
expect(win.document.activeElement).toBe(field());
// The Send button takes the same path.
type('again');
sendBtn().click();
expect(sent).toHaveBeenCalledTimes(2);
});
it('sends to the session the story shows, not the active tab', async () => {
boot(done());
stubSend();
app.sessions.set(OTHER, { name: 'other', workingDir: '/work/other' });
app.activeSessionId = OTHER;
app.openStoryMode(SID);
await settle();
type('look here');
enter();
expect(sent).toHaveBeenCalledWith(SID, 'look here');
});
it('a refused send (too long) keeps the text', async () => {
boot(done());
stubSend(false);
app.openStoryMode(SID);
await settle();
type('far too long');
enter();
expect(sent).toHaveBeenCalledTimes(1);
expect(field().value).toBe('far too long');
});
it('while Claude waits on a question or a permission, Enter never sends; the hint says why', async () => {
const ask = (answered: boolean) =>
storyOf([
prompt('pick'),
tool('q1', 'AskUserQuestion', {
questions: [{ question: 'Which name?', options: [{ label: 'slugify' }, { label: 'kebab' }] }],
}),
...(answered ? [result('q1', 'kebab', { toolUseResult: { answers: { 'Which name?': 'kebab' } } })] : []),
]);
boot(ask(false));
stubSend();
app.openStoryMode(SID);
await settle();
type('use kebab');
expect(app._story.compose.state).toBe('waiting');
expect(hint()).toMatch(/waiting for your answer in the terminal/);
expect(sendBtn().disabled).toBe(true);
// Swallowed (no newline added), not sent: it would pick an option in the dialog.
expect(enter().defaultPrevented).toBe(true);
sendBtn().click();
expect(sent).not.toHaveBeenCalled();
expect(field().value).toBe('use kebab');
expect(field().readOnly).toBe(false);
// Answered in the terminal: the turn works on, and a message may go out (Claude queues it).
responses = [{ status: 200, body: { success: true, data: ask(true) } }];
await app._storyRefresh();
await settle();
expect(app._story.compose.state).toBe('working');
expect(hint()).toMatch(/working/);
// A permission prompt never reaches the transcript: the hook says it.
app.pendingHooks = new Map([[SID, new Set(['permission_prompt'])]]);
enter();
expect(sent).not.toHaveBeenCalled();
expect(app._story.compose.state).toBe('waiting');
app.pendingHooks = new Map();
enter();
expect(sent).toHaveBeenCalledWith(SID, 'use kebab');
});
it('keeps a draft per session across switching and closing; a send clears only its own', async () => {
boot(done());
stubSend();
app.sessions.set(OTHER, { name: 'other', workingDir: '/work/other' });
app.openStoryMode(SID);
await settle();
type('draft for one');
app.openStoryMode(OTHER);
await settle();
expect(field().value).toBe('');
type('draft for two');
app.closeStoryMode();
app.openStoryMode(SID);
await settle();
expect(field().value).toBe('draft for one');
app.openStoryMode(OTHER);
await settle();
expect(field().value).toBe('draft for two');
enter();
expect(sent).toHaveBeenCalledWith(OTHER, 'draft for two');
app.closeStoryMode();
app.openStoryMode(OTHER);
await settle();
expect(field().value).toBe('');
app.openStoryMode(SID);
await settle();
expect(field().value).toBe('draft for one');
});
it('a reader parked on an older turn is taken to the newest after a send', async () => {
const two = [prompt('first job'), said('Done.'), end(2000), prompt('second job'), said('Done too.'), end(2000)];
boot(storyOf(two));
stubSend();
app.openStoryMode(SID);
await settle();
responses = [{ status: 200, body: { success: true, data: storyAt(two, 0) } }];
app._storyPickTurn(app._story.data.turns[0].id);
await settle();
expect(app._story.turnId).toBe(app._story.data.turns[0].id);
responses = [{ status: 200, body: { success: true, data: storyOf(two) } }];
calls.length = 0;
type('and now a third');
enter();
await settle();
expect(sent).toHaveBeenCalledWith(SID, 'and now a third');
expect(app._story.turnId).toBeNull();
expect(calls[0]).not.toContain('turn=');
expect(app._story.data.page.id).toBe(app._story.data.turns[1].id);
expect(win.document.activeElement).toBe(field());
});
it('a session that is gone gets no message; its draft stays readable', async () => {
boot(done());
stubSend();
app.openStoryMode(SID);
await settle();
type('one more thing');
app.sessions.delete(SID);
enter();
expect(sent).not.toHaveBeenCalled();
expect(app._story.compose.state).toBe('gone');
expect(field().readOnly).toBe(true);
expect(sendBtn().disabled).toBe(true);
expect(field().value).toBe('one more thing');
expect(hint()).toMatch(/gone/);
// The story says it too: a 404 from the route.
app.sessions.set(SID, { name: 'demo-shop', workingDir: '/work/demo-shop' });
responses = [{ status: 404, body: { success: false, error: 'Session not found' } }];
await app._storyRefresh();
expect(app._story.compose.state).toBe('gone');
});
it('a session Story Mode cannot draw gets no message: a shell would run it as a command', async () => {
boot({ supported: false, reason: 'Story Mode draws Claude Code sessions.' });
stubSend();
app.openStoryMode(SID);
await settle();
expect(app._story.compose.state).toBe('unsupported');
expect(sendBtn().disabled).toBe(true);
});
it('Escape in a field with text steps out of it first; an empty one closes as before', async () => {
boot(done());
app.openStoryMode(SID);
await settle();
type('half a thought');
// An IME cancelling its composition keeps everything as it is.
win.dispatchEvent(new win.KeyboardEvent('keydown', { key: 'Escape', isComposing: true, cancelable: true }));
expect($('#storyOverlay')!.hidden).toBe(false);
expect(win.document.activeElement).toBe(field());
press('Escape');
expect($('#storyOverlay')!.hidden).toBe(false);
expect(win.document.activeElement).toBe($('.story-close'));
expect(field().value).toBe('half a thought');
press('Escape');
expect($('#storyOverlay')!.hidden).toBe(true);
app.openStoryMode(SID);
await settle();
expect(field().value).toBe('half a thought');
type('');
press('Escape');
expect($('#storyOverlay')!.hidden).toBe(true);
});
it('the composer is not part of the page Save image draws', async () => {
boot(done());
app.openStoryMode(SID);
await settle();
expect($('.story-page')!.contains($('.story-compose'))).toBe(false);
expect($('.story-main')!.contains($('.story-compose'))).toBe(false);
});
});
describe('story-mode.js scenes', () => {
/** Every panel kind, in plain and awkward shapes, drawn across its whole animation. */
function pageWith(panels: StoryPanel[], extra: Partial<StoryPage> = {}): StoryPage {