mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge branch 'story/compose-input' into story/integrate
This commit is contained in:
commit
f13b59c7cd
3 files changed
+533
-5
No files matched your search
@@ -91,13 +91,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;
|
||||
@@ -480,10 +481,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 {
|
||||
@@ -942,7 +944,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 {
|
||||
@@ -1142,6 +1143,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,
|
||||
|
||||
@@ -1972,6 +1972,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
drawerBody,
|
||||
};
|
||||
this._story = st;
|
||||
this._storyComposeBuild(st);
|
||||
|
||||
closeBtn.addEventListener('click', () => this.closeStoryMode());
|
||||
saveBtn.addEventListener('click', () => this.saveStoryImage());
|
||||
@@ -2071,6 +2072,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) {
|
||||
@@ -2116,6 +2330,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.');
|
||||
@@ -2272,6 +2489,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) {
|
||||
@@ -2298,6 +2517,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.
|
||||
@@ -2452,6 +2674,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. */
|
||||
|
||||
@@ -1405,6 +1405,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 {
|
||||
|
||||
Reference in new issue
Block a user