fix(mobile): merge-time fixes for the prompt composer (#444)

- styles.css: restate the composer overlay's own bottom gutter after the fold rules
  (the generic .paste-overlay longhand erased it: 0px flat, hinge strip replacing it
  folded) and subtract the fold strip from the dialog's max-height
- test/foldable-layout.test.ts: simulate the cascade for
  .paste-overlay.prompt-composer-overlay (fails without the CSS fix); pin the palette
  anchor by name instead of ELEMENTS.at(-1)
- keyboard-accessory.js: guard the app global in refreshForActiveSession() like the
  rest of the file
- keyboard-accessory.js: a whitespace-only draft is empty (Send no longer submits
  blank lines); the text still goes out untrimmed
- keyboard-accessory.js: derive _composerMaxLength and the frame refusal from one
  64 KiB frame limit minus both bracketed-paste markers so they cannot drift
- keyboard-accessory.js: translate the textarea placeholder and label at build time,
  since the DOM translator skips <textarea> subtrees
- i18n.js: zh-CN entries for the composer dialog copy
- docs/wiki/Mobile-Guide.md: describe the Compose key instead of a clipboard key
- CLAUDE.md: a "Mobile prompt composer" paragraph after the accessory bar one
- test/mobile-prompt-composer.test.ts: pin the whitespace rule and the derived budget

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit f6725ba52da17b0bdbee8be3b5011e7cae514f69)
This commit is contained in:
Codeman maintainer
2026-09-21 04:39:28 +02:00
parent 0f955327b2
commit d8e85285c9
7 changed files with 102 additions and 17 deletions
+11
View File
@@ -451,6 +451,17 @@
'Show Shortcuts': '显示快捷键',
'Full shortcut reference': '完整快捷键参考',
// Mobile prompt composer (keyboard-accessory.js). The textarea's own
// placeholder and label are looked up by the module at build time, since
// the DOM translator skips <textarea> subtrees.
'Compose prompt': '撰写提示词',
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
'Enter adds a new line': '按 Enter 换行',
'Write your prompt…': '请输入提示词…',
'Use terminal keyboard': '使用终端键盘',
'Uploading…': '上传中…',
// Mobile overview (phone home screen)
'Needs you': '需要你',
'Current sessions': '当前会话',
+26 -8
View File
@@ -636,6 +636,16 @@ function applyOneShotCtrl(data) {
return { data, consumed: true };
}
// The composer's Send goes out as ONE bracketed-paste frame on the WebSocket
// input path, and ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH
// (config/terminal-limits.ts: 64 KiB, compared in UTF-16 code units) WITHOUT
// an ACK, which would wedge the durable input queue. So the prompt budget is
// that limit minus the two markers, derived here once so the refusal in
// _sendComposedPrompt() and the toast that names the maximum cannot drift.
const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024;
const COMPOSER_PASTE_START = '\x1b[200~';
const COMPOSER_PASTE_END = '\x1b[201~';
/**
* KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing.
*/
@@ -654,9 +664,8 @@ const KeyboardAccessoryBar = {
_composerDrafts: new Map(),
_composerUploads: new Map(),
_composerOverlay: null,
// Leave room for both six-character bracketed-paste markers under the
// WebSocket input frame's 64 KiB character limit.
_composerMaxLength: 65524,
// Longest prompt Send accepts: the input frame limit minus both markers.
_composerMaxLength: COMPOSER_INPUT_FRAME_LIMIT - COMPOSER_PASTE_START.length - COMPOSER_PASTE_END.length,
/** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */
_simpleButtons: `
@@ -833,7 +842,8 @@ const KeyboardAccessoryBar = {
* the next one. */
refreshForActiveSession() {
this.clearCtrl();
if (this._composerOverlay && this._composerOverlay.dataset.sessionId !== app.activeSessionId) {
const activeSessionId = typeof app !== 'undefined' ? app.activeSessionId : null;
if (this._composerOverlay && this._composerOverlay.dataset.sessionId !== activeSessionId) {
this._composerOverlay._closeComposer?.({ restoreFocus: false });
}
this._applyLayout(this._resolveMode());
@@ -1206,12 +1216,11 @@ const KeyboardAccessoryBar = {
// Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside
// the single-line input transport and is what terminal.paste() emitted.
const pasteText = text.replace(/\r?\n/g, '\r');
const payload = `\x1b[200~${pasteText}\x1b[201~`;
if (payload.length > 65536) {
if (pasteText.length > this._composerMaxLength) {
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
return false;
}
app._sendInputAsync(sessionId, payload);
app._sendInputAsync(sessionId, `${COMPOSER_PASTE_START}${pasteText}${COMPOSER_PASTE_END}`);
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
return true;
},
@@ -1266,6 +1275,13 @@ const KeyboardAccessoryBar = {
this._composerOverlay = overlay;
const textarea = overlay.querySelector('.prompt-composer-textarea');
// i18n.js skips <textarea> subtrees (what is typed there is user content),
// so the placeholder and label are translated here, when the dialog is built.
const i18n = typeof window !== 'undefined' ? window.CodemanI18n : undefined;
if (typeof i18n?.t === 'function') {
textarea.placeholder = i18n.t('Write your prompt…');
textarea.setAttribute('aria-label', i18n.t('Prompt'));
}
const fileInput = overlay.querySelector('.paste-file-input');
const imageButton = overlay.querySelector('.paste-image');
const sendButton = overlay.querySelector('.paste-send');
@@ -1292,8 +1308,10 @@ const KeyboardAccessoryBar = {
};
overlay._closeComposer = close;
const send = () => {
// Whitespace-only counts as empty (it would submit blank lines), but the
// text goes out untrimmed so deliberate leading or trailing lines survive.
const text = textarea.value;
if (!text || !this._sendComposedPrompt(sessionId, text)) return;
if (!text.trim() || !this._sendComposedPrompt(sessionId, text)) return;
app._echoPassthroughSessions?.delete(sessionId);
this._composerDrafts.delete(sessionId);
this._syncComposerDraftIndicator();
+17 -1
View File
@@ -13809,7 +13809,10 @@ body.touch-device.cjk-input-visible .main {
.prompt-composer-dialog {
max-width: 560px;
max-height: calc(100dvh - min(15dvh, 72px) - 12px - env(safe-area-inset-bottom));
/* The overlay's top inset and bottom gutter, plus the strip a folding device
reserves below the dialog (0px everywhere else; see the fold rules at the
end of this file). */
max-height: calc(100dvh - min(15dvh, 72px) - 12px - env(safe-area-inset-bottom) - var(--fold-block-end));
overflow-y: auto;
}
@@ -18683,6 +18686,19 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
padding-bottom: var(--fold-block-end);
}
/* The mobile prompt composer is a .paste-overlay with a gutter of its own: a
three-value `padding` shorthand whose bottom is 12px plus the safe area. The
generic rule above is a later longhand at the same specificity, so it ERASED
that gutter (measured at 393x852: padding-bottom 0 instead of 12px, and with
the fold variables set the hinge strip stood in for the gutter instead of
adding to it). Restate the composer's bottom gutter on top of the strip; the
side has no gutter of its own. The dialog's height cap subtracts the same
strip where it is declared (.prompt-composer-dialog). */
.prompt-composer-overlay {
padding-right: var(--fold-inline-end);
padding-bottom: calc(12px + env(safe-area-inset-bottom) + var(--fold-block-end));
}
/* The response viewer is a bottom sheet, so a vertical hinge running through it
is fine, since it is a wide surface like the terminal and inset dialogs are what
the fold guidance is about. A horizontal hinge is not: in tabletop pose the