mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(mobile): add manual prompt composer
This commit is contained in:
@@ -6851,6 +6851,7 @@ class CodemanApp {
|
|||||||
|
|
||||||
this._flushedOffsets?.delete(sessionId);
|
this._flushedOffsets?.delete(sessionId);
|
||||||
this._flushedTexts?.delete(sessionId);
|
this._flushedTexts?.delete(sessionId);
|
||||||
|
if (typeof KeyboardAccessoryBar !== 'undefined') KeyboardAccessoryBar.discardComposerDraft?.(sessionId);
|
||||||
// Drop any durably-queued input for a session that's actually gone (deleted/
|
// Drop any durably-queued input for a session that's actually gone (deleted/
|
||||||
// exited). Not a lost prompt — the target no longer exists. Only reached on
|
// exited). Not a lost prompt — the target no longer exists. Only reached on
|
||||||
// real session removal, never on a tab switch.
|
// real session removal, never on a tab switch.
|
||||||
|
|||||||
@@ -124,12 +124,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// 20 photos don't crawl through serially.
|
// 20 photos don't crawl through serially.
|
||||||
_uploadConcurrency: 3,
|
_uploadConcurrency: 3,
|
||||||
|
|
||||||
async _uploadAndInsertImages(fileList) {
|
/** Upload a batch and normally insert its paths into the active terminal.
|
||||||
|
* The prompt composer passes `{ insert: false }` so it can put those paths
|
||||||
|
* into its textarea instead. Returns successful paths in selection order. */
|
||||||
|
async _uploadAndInsertImages(fileList, options = {}) {
|
||||||
const sessionId = this.activeSessionId;
|
const sessionId = this.activeSessionId;
|
||||||
if (!sessionId) return;
|
if (!sessionId) return [];
|
||||||
|
|
||||||
let files = Array.from(fileList || []);
|
let files = Array.from(fileList || []);
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return [];
|
||||||
|
|
||||||
// Cap the batch and tell the user what got dropped (no silent truncation).
|
// Cap the batch and tell the user what got dropped (no silent truncation).
|
||||||
let capped = false;
|
let capped = false;
|
||||||
@@ -175,7 +178,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
|
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
|
||||||
|
|
||||||
const paths = results.filter(Boolean);
|
const paths = results.filter(Boolean);
|
||||||
if (paths.length > 0) {
|
if (paths.length > 0 && options.insert !== false) {
|
||||||
// Insert all paths in one shot, space-separated, in selection order.
|
// Insert all paths in one shot, space-separated, in selection order.
|
||||||
await this.sendInput(paths.join(' '));
|
await this.sendInput(paths.join(' '));
|
||||||
}
|
}
|
||||||
@@ -187,6 +190,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
|
||||||
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
|
||||||
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
|
||||||
|
return paths;
|
||||||
},
|
},
|
||||||
|
|
||||||
async _uploadPasteImage(sessionId, file) {
|
async _uploadPasteImage(sessionId, file) {
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
* Defines three exports:
|
* Defines three exports:
|
||||||
*
|
*
|
||||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||||
* keyboard on mobile: arrow up/down, /init, Tab, paste, Esc, and dismiss (the extended
|
* keyboard on mobile: arrow up/down, /init, Tab, Compose, Esc, and dismiss (the extended
|
||||||
* bar adds /clear, /compact, Shift+Tab and more). Shift+Left/Right ship in both agent
|
* bar adds /clear, /compact, Shift+Tab and more). Shift+Left/Right ship in both agent
|
||||||
* layouts but are revealed only on Codex sessions (`codex-enabled` marker class on the
|
* layouts but are revealed only on Codex sessions (`codex-enabled` marker class on the
|
||||||
* bar, synced on every session switch), since they are Codex bindings. Tab flushes any locally-buffered
|
* bar, synced on every session switch), since they are Codex bindings. Tab flushes any locally-buffered
|
||||||
* prompt text to the PTY before sending \t, so completion applies to what was typed.
|
* prompt text to the PTY before sending \t, so completion applies to what was typed.
|
||||||
* The paste button opens a dialog that handles both text paste and image attach
|
* Agent bars expose a Compose dialog with an autocorrect-aware multiline textarea,
|
||||||
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
|
* per-session in-memory drafts and image attach. Shell bars keep the direct Paste
|
||||||
|
* dialog because shell input is not an agent prompt.
|
||||||
* Destructive actions (/clear, /compact, extended bar only) require double-tap confirmation (2s amber state).
|
* Destructive actions (/clear, /compact, extended bar only) require double-tap confirmation (2s amber state).
|
||||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||||
@@ -648,8 +649,13 @@ const KeyboardAccessoryBar = {
|
|||||||
_baseMode: 'simple',
|
_baseMode: 'simple',
|
||||||
// One-shot Ctrl modifier (shell bar only). See handleAction('ctrl').
|
// One-shot Ctrl modifier (shell bar only). See handleAction('ctrl').
|
||||||
_ctrlArmed: false,
|
_ctrlArmed: false,
|
||||||
|
// Prompt drafts intentionally stay in memory: prompts routinely contain secrets,
|
||||||
|
// so persistence would need the same treatment as the 0600 intent store.
|
||||||
|
_composerDrafts: new Map(),
|
||||||
|
_composerUploads: new Map(),
|
||||||
|
_composerOverlay: null,
|
||||||
|
|
||||||
/** HTML for simple mode: arrows, commands, paste, Esc, dismiss */
|
/** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */
|
||||||
_simpleButtons: `
|
_simpleButtons: `
|
||||||
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
<button class="accessory-btn accessory-btn-arrow" data-action="scroll-up" title="Arrow up">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
@@ -665,12 +671,7 @@ const KeyboardAccessoryBar = {
|
|||||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||||
<button class="accessory-btn accessory-btn-codex" data-action="shift-left" title="Shift+Left (Codex: edit queued message)" aria-label="Shift+Left (Codex: edit queued message)">⇧←</button>
|
<button class="accessory-btn accessory-btn-codex" data-action="shift-left" title="Shift+Left (Codex: edit queued message)" aria-label="Shift+Left (Codex: edit queued message)">⇧←</button>
|
||||||
<button class="accessory-btn accessory-btn-codex" data-action="shift-right" title="Shift+Right (Codex: prompt stack back)" aria-label="Shift+Right (Codex: prompt stack back)">⇧→</button>
|
<button class="accessory-btn accessory-btn-codex" data-action="shift-right" title="Shift+Right (Codex: prompt stack back)" aria-label="Shift+Right (Codex: prompt stack back)">⇧→</button>
|
||||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
<button class="accessory-btn accessory-btn-compose" data-action="compose" title="Compose prompt">Compose</button>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
|
||||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
|
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
|
||||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||||
@@ -740,12 +741,7 @@ const KeyboardAccessoryBar = {
|
|||||||
<path d="M9 5l7 7-7 7"/>
|
<path d="M9 5l7 7-7 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
<button class="accessory-btn accessory-btn-compose" data-action="compose" title="Compose prompt">Compose</button>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
|
||||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">📁 Path</button>
|
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">📁 Path</button>
|
||||||
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">⌫ All</button>
|
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">⌫ All</button>
|
||||||
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
|
<button class="accessory-btn accessory-btn-rmm" data-action="readmymind" title="Read My Mind: predict your next prompt">🧠</button>
|
||||||
@@ -823,6 +819,9 @@ const KeyboardAccessoryBar = {
|
|||||||
* the next one. */
|
* the next one. */
|
||||||
refreshForActiveSession() {
|
refreshForActiveSession() {
|
||||||
this.clearCtrl();
|
this.clearCtrl();
|
||||||
|
if (this._composerOverlay && this._composerOverlay.dataset.sessionId !== app.activeSessionId) {
|
||||||
|
this._composerOverlay._closeComposer?.({ restoreFocus: false });
|
||||||
|
}
|
||||||
this._applyLayout(this._resolveMode());
|
this._applyLayout(this._resolveMode());
|
||||||
this.syncCodexKeys();
|
this.syncCodexKeys();
|
||||||
},
|
},
|
||||||
@@ -977,6 +976,9 @@ const KeyboardAccessoryBar = {
|
|||||||
case 'paste':
|
case 'paste':
|
||||||
this.pasteFromClipboard();
|
this.pasteFromClipboard();
|
||||||
break;
|
break;
|
||||||
|
case 'compose':
|
||||||
|
this.composePrompt();
|
||||||
|
break;
|
||||||
case 'pick-path':
|
case 'pick-path':
|
||||||
this.pickPath();
|
this.pickPath();
|
||||||
break;
|
break;
|
||||||
@@ -1130,6 +1132,212 @@ const KeyboardAccessoryBar = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Move the whole editable terminal prompt into the composer. Pending text
|
||||||
|
* exists only in the overlay; flushed text already reached the PTY, so erase
|
||||||
|
* that prefix before making the textarea authoritative. */
|
||||||
|
_takePendingLocalEcho(sessionId) {
|
||||||
|
if (!app._localEchoEnabled || !app._localEchoOverlay) return '';
|
||||||
|
const pending = app._localEchoOverlay.pendingText || '';
|
||||||
|
const overlayFlushed = app._localEchoOverlay.getFlushed?.() || {};
|
||||||
|
const flushedText = overlayFlushed.text || app._flushedTexts?.get(sessionId) || '';
|
||||||
|
const flushedCount = overlayFlushed.count || app._flushedOffsets?.get(sessionId) || 0;
|
||||||
|
app._localEchoOverlay.clear();
|
||||||
|
app._localEchoOverlay.suppressBufferDetection?.();
|
||||||
|
app._flushedOffsets?.delete(sessionId);
|
||||||
|
app._flushedTexts?.delete(sessionId);
|
||||||
|
if (flushedCount > 0) {
|
||||||
|
app._sendInputAsync(sessionId, '\x7f'.repeat(flushedCount), { useMux: true });
|
||||||
|
}
|
||||||
|
return flushedText + pending;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Insert text at the textarea selection, adding one separating space when
|
||||||
|
* an attachment path would otherwise run into neighboring prompt text. */
|
||||||
|
_insertComposerText(textarea, text) {
|
||||||
|
if (!textarea || !text) return;
|
||||||
|
const start = Number.isInteger(textarea.selectionStart) ? textarea.selectionStart : textarea.value.length;
|
||||||
|
const end = Number.isInteger(textarea.selectionEnd) ? textarea.selectionEnd : start;
|
||||||
|
const before = textarea.value.slice(0, start);
|
||||||
|
const after = textarea.value.slice(end);
|
||||||
|
const prefix = before && !/\s$/.test(before) ? ' ' : '';
|
||||||
|
const suffix = after && !/^\s/.test(after) ? ' ' : '';
|
||||||
|
const inserted = `${prefix}${text}${suffix}`;
|
||||||
|
textarea.setRangeText(inserted, start, end, 'end');
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Deliver one complete prompt through xterm's paste path. terminal.paste()
|
||||||
|
* preserves bracketed-paste markers and multiline content; Enter is a
|
||||||
|
* separate delayed durable write because Codex drops keys sharing a PTY read
|
||||||
|
* with a bracketed paste. Capture the session so a fast tab switch cannot
|
||||||
|
* submit the prompt in a different pane. */
|
||||||
|
_sendComposedPrompt(sessionId, text) {
|
||||||
|
if (!sessionId || !text || !app.terminal?.paste) return false;
|
||||||
|
if (app.terminal.modes?.bracketedPasteMode !== true) {
|
||||||
|
app.showToast?.('Prompt composer is waiting for the agent input to become ready', 'info');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
app._predictiveEcho?.clearPredictions();
|
||||||
|
app.terminal.paste(text);
|
||||||
|
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Forget a draft when its target session no longer exists. */
|
||||||
|
discardComposerDraft(sessionId) {
|
||||||
|
this._composerDrafts.delete(sessionId);
|
||||||
|
this._composerUploads.delete(sessionId);
|
||||||
|
if (this._composerOverlay?.dataset.sessionId === sessionId) {
|
||||||
|
this._composerOverlay._closeComposer?.({ preserveDraft: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Open the manual agent prompt composer. Enter remains a newline; only the
|
||||||
|
* Send button submits. Cancel/backdrop/Escape preserve the per-session draft. */
|
||||||
|
composePrompt() {
|
||||||
|
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||||
|
if (this._isShellSession()) {
|
||||||
|
this.pasteFromClipboard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = app.activeSessionId;
|
||||||
|
const pending = this._takePendingLocalEcho(sessionId);
|
||||||
|
const saved = this._composerDrafts.get(sessionId) || '';
|
||||||
|
const initial = saved + pending;
|
||||||
|
|
||||||
|
this._composerOverlay?._closeComposer?.({ restoreFocus: false });
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'paste-overlay prompt-composer-overlay';
|
||||||
|
overlay.dataset.sessionId = sessionId;
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('aria-label', 'Compose prompt');
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="paste-dialog prompt-composer-dialog">
|
||||||
|
<div class="prompt-composer-header">
|
||||||
|
<strong>Compose prompt</strong>
|
||||||
|
<span>Enter adds a new line</span>
|
||||||
|
</div>
|
||||||
|
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
|
||||||
|
<div class="paste-actions prompt-composer-actions">
|
||||||
|
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
|
||||||
|
<button type="button" class="paste-image">🖼 Image</button>
|
||||||
|
<button type="button" class="paste-cancel">Cancel</button>
|
||||||
|
<button type="button" class="paste-send">Send</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this._composerOverlay = overlay;
|
||||||
|
const textarea = overlay.querySelector('.prompt-composer-textarea');
|
||||||
|
const fileInput = overlay.querySelector('.paste-file-input');
|
||||||
|
const imageButton = overlay.querySelector('.paste-image');
|
||||||
|
const sendButton = overlay.querySelector('.paste-send');
|
||||||
|
const focusTrap = new FocusTrap(overlay);
|
||||||
|
textarea.value = initial;
|
||||||
|
if (initial) this._composerDrafts.set(sessionId, initial);
|
||||||
|
const initialUploads = this._composerUploads.get(sessionId) || 0;
|
||||||
|
imageButton.disabled = initialUploads > 0;
|
||||||
|
sendButton.disabled = initialUploads > 0;
|
||||||
|
if (initialUploads > 0) imageButton.textContent = 'Uploading…';
|
||||||
|
|
||||||
|
const saveDraft = () => {
|
||||||
|
if (textarea.value) this._composerDrafts.set(sessionId, textarea.value);
|
||||||
|
else this._composerDrafts.delete(sessionId);
|
||||||
|
};
|
||||||
|
const close = ({ focusTerminal = false, preserveDraft = true, restoreFocus = true } = {}) => {
|
||||||
|
if (preserveDraft) saveDraft();
|
||||||
|
focusTrap.deactivate({ restoreFocus });
|
||||||
|
overlay.remove();
|
||||||
|
if (this._composerOverlay === overlay) this._composerOverlay = null;
|
||||||
|
if (focusTerminal) app.terminal?.focus();
|
||||||
|
};
|
||||||
|
overlay._closeComposer = close;
|
||||||
|
const send = () => {
|
||||||
|
const text = textarea.value;
|
||||||
|
if (!text || !this._sendComposedPrompt(sessionId, text)) return;
|
||||||
|
this._composerDrafts.delete(sessionId);
|
||||||
|
close({ preserveDraft: false });
|
||||||
|
};
|
||||||
|
const handleImages = async (files) => {
|
||||||
|
const images = Array.from(files || []).filter((file) => file.type.startsWith('image/'));
|
||||||
|
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
|
||||||
|
saveDraft();
|
||||||
|
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
|
||||||
|
const syncUploadUi = () => {
|
||||||
|
const currentOverlay =
|
||||||
|
this._composerOverlay?.isConnected && this._composerOverlay.dataset.sessionId === sessionId
|
||||||
|
? this._composerOverlay
|
||||||
|
: null;
|
||||||
|
const count = this._composerUploads.get(sessionId) || 0;
|
||||||
|
const currentImageButton = currentOverlay?.querySelector('.paste-image');
|
||||||
|
const currentSendButton = currentOverlay?.querySelector('.paste-send');
|
||||||
|
if (currentImageButton) {
|
||||||
|
currentImageButton.disabled = count > 0;
|
||||||
|
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
|
||||||
|
}
|
||||||
|
if (currentSendButton) currentSendButton.disabled = count > 0;
|
||||||
|
};
|
||||||
|
syncUploadUi();
|
||||||
|
try {
|
||||||
|
const paths = await app._uploadAndInsertImages(images, { insert: false });
|
||||||
|
if (paths?.length) {
|
||||||
|
const currentOverlay =
|
||||||
|
this._composerOverlay?.isConnected && this._composerOverlay.dataset.sessionId === sessionId
|
||||||
|
? this._composerOverlay
|
||||||
|
: null;
|
||||||
|
const currentTextarea = currentOverlay?.querySelector('.prompt-composer-textarea');
|
||||||
|
if (currentTextarea) this._insertComposerText(currentTextarea, paths.join(' '));
|
||||||
|
else if (app.sessions?.has(sessionId)) {
|
||||||
|
const draft = this._composerDrafts.get(sessionId) || '';
|
||||||
|
this._composerDrafts.set(sessionId, `${draft}${draft && !/\s$/.test(draft) ? ' ' : ''}${paths.join(' ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const remaining = Math.max(0, (this._composerUploads.get(sessionId) || 1) - 1);
|
||||||
|
if (remaining > 0) this._composerUploads.set(sessionId, remaining);
|
||||||
|
else this._composerUploads.delete(sessionId);
|
||||||
|
syncUploadUi();
|
||||||
|
if (overlay.isConnected) fileInput.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
textarea.addEventListener('input', saveDraft);
|
||||||
|
textarea.addEventListener('paste', (event) => {
|
||||||
|
const items = event.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
const images = Array.from(items)
|
||||||
|
.filter((item) => item.type.startsWith('image/'))
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (images.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleImages(images);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
overlay.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
imageButton.addEventListener('click', () => fileInput.click());
|
||||||
|
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
|
||||||
|
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => close({ focusTerminal: true }));
|
||||||
|
overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
|
||||||
|
sendButton.addEventListener('click', send);
|
||||||
|
overlay.addEventListener('click', (event) => {
|
||||||
|
if (event.target === overlay) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
focusTrap.activate();
|
||||||
|
textarea.focus();
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = textarea.value.length;
|
||||||
|
},
|
||||||
|
|
||||||
/** Show a paste overlay for iOS compatibility.
|
/** Show a paste overlay for iOS compatibility.
|
||||||
* Handles three input paths from one dialog:
|
* Handles three input paths from one dialog:
|
||||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||||
@@ -1259,9 +1467,9 @@ class FocusTrap {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deactivate() {
|
deactivate({ restoreFocus = true } = {}) {
|
||||||
this.element.removeEventListener('keydown', this.boundHandleKeydown);
|
this.element.removeEventListener('keydown', this.boundHandleKeydown);
|
||||||
if (this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
|
if (restoreFocus && this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') {
|
||||||
this.previouslyFocused.focus();
|
this.previouslyFocused.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13733,6 +13733,61 @@ body.touch-device.cjk-input-visible .main {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-composer-overlay {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: min(15dvh, 72px) 0 calc(12px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-composer-dialog {
|
||||||
|
max-width: 560px;
|
||||||
|
max-height: calc(100dvh - min(15dvh, 72px) - 12px - env(safe-area-inset-bottom));
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-composer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 2px 2px 10px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-composer-header span {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-composer-textarea {
|
||||||
|
min-height: min(34dvh, 240px);
|
||||||
|
max-height: 50dvh;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-composer-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-composer-actions button {
|
||||||
|
min-height: var(--touch-target-min);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-composer-terminal {
|
||||||
|
flex: 1 0 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-composer-terminal:active {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-input);
|
||||||
|
}
|
||||||
|
|
||||||
/* Shared lazy filesystem path picker (case linking + mobile input). */
|
/* Shared lazy filesystem path picker (case linking + mobile input). */
|
||||||
.path-input-group {
|
.path-input-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const imageInputSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
|
||||||
|
|
||||||
interface TrapListener {
|
interface TrapListener {
|
||||||
(e: Record<string, unknown>): void;
|
(e: Record<string, unknown>): void;
|
||||||
@@ -91,8 +93,7 @@ function loadPasteHarness(): Harness {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vm.runInContext('class CodemanApp {}', context);
|
vm.runInContext('class CodemanApp {}', context);
|
||||||
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
|
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
|
||||||
vm.runInContext(src, context, { filename: 'image-input.js' });
|
|
||||||
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
|
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
|
||||||
|
|
||||||
const pastedText: string[] = [];
|
const pastedText: string[] = [];
|
||||||
@@ -133,6 +134,20 @@ function loadPasteHarness(): Harness {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadImageInputApp() {
|
||||||
|
const context = vm.createContext({ console, window: {}, document: {} });
|
||||||
|
vm.runInContext('class CodemanApp {}', context);
|
||||||
|
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
|
||||||
|
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
|
||||||
|
const app = new CodemanApp();
|
||||||
|
app.activeSessionId = 'session-1';
|
||||||
|
app.showToast = vi.fn();
|
||||||
|
app.sendInput = vi.fn(async () => {});
|
||||||
|
app._normalizeImageForUpload = vi.fn(async (file) => file);
|
||||||
|
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
|
||||||
|
return app as Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
describe('Ctrl+V paste trap', () => {
|
describe('Ctrl+V paste trap', () => {
|
||||||
it('sends clipboard text to the terminal once for a single paste event', () => {
|
it('sends clipboard text to the terminal once for a single paste event', () => {
|
||||||
const h = loadPasteHarness();
|
const h = loadPasteHarness();
|
||||||
@@ -174,3 +189,24 @@ describe('Ctrl+V paste trap', () => {
|
|||||||
expect(h.attachedTraps()).toBe(0);
|
expect(h.attachedTraps()).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('image upload insertion policy', () => {
|
||||||
|
it('returns ordered paths without terminal insertion when requested by the composer', async () => {
|
||||||
|
const app = loadImageInputApp();
|
||||||
|
const files = [{ path: '/tmp/first.png' }, { path: '/tmp/second.png' }];
|
||||||
|
|
||||||
|
const paths = await app._uploadAndInsertImages(files, { insert: false });
|
||||||
|
|
||||||
|
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
|
||||||
|
expect(app.sendInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves terminal insertion by default', async () => {
|
||||||
|
const app = loadImageInputApp();
|
||||||
|
|
||||||
|
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
|
||||||
|
|
||||||
|
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
|
||||||
|
expect(app.sendInput).toHaveBeenCalledWith('/tmp/legacy.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview CI-visible coverage for the manual mobile prompt composer.
|
||||||
|
*
|
||||||
|
* The Playwright mobile suite is excluded from the CI gate, so the behaviors
|
||||||
|
* most likely to regress live here against the real browser module: native
|
||||||
|
* textarea replacement, local-echo adoption, per-session drafts, bracketed
|
||||||
|
* multiline delivery and image-path insertion.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const accessorySource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
|
||||||
|
const appSource = readFileSync(resolve('src/web/public/app.js'), 'utf8');
|
||||||
|
|
||||||
|
type Timer = { callback: () => void; delay: number };
|
||||||
|
|
||||||
|
function loadComposer(sessionId = 'session-1') {
|
||||||
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
|
||||||
|
const window = dom.window;
|
||||||
|
const timers: Timer[] = [];
|
||||||
|
const localEcho = {
|
||||||
|
pendingText: '',
|
||||||
|
clear: vi.fn(() => {
|
||||||
|
localEcho.pendingText = '';
|
||||||
|
}),
|
||||||
|
suppressBufferDetection: vi.fn(),
|
||||||
|
};
|
||||||
|
const app = {
|
||||||
|
activeSessionId: sessionId,
|
||||||
|
sessions: new Map([
|
||||||
|
['session-1', { mode: 'claude' }],
|
||||||
|
['session-2', { mode: 'claude' }],
|
||||||
|
]),
|
||||||
|
terminal: { paste: vi.fn(), focus: vi.fn(), modes: { bracketedPasteMode: true } },
|
||||||
|
_localEchoEnabled: true,
|
||||||
|
_localEchoOverlay: localEcho,
|
||||||
|
_flushedOffsets: new Map<string, number>(),
|
||||||
|
_flushedTexts: new Map<string, string>(),
|
||||||
|
_predictiveEcho: { clearPredictions: vi.fn() },
|
||||||
|
_sendInputAsync: vi.fn(),
|
||||||
|
_uploadAndInsertImages: vi.fn(async () => ['/tmp/image-one.png']),
|
||||||
|
showToast: vi.fn(),
|
||||||
|
};
|
||||||
|
const schedule = (callback: () => void, delay = 0) => {
|
||||||
|
timers.push({ callback, delay });
|
||||||
|
return timers.length;
|
||||||
|
};
|
||||||
|
const factory = new Function(
|
||||||
|
'window',
|
||||||
|
'document',
|
||||||
|
'Event',
|
||||||
|
'app',
|
||||||
|
'MobileDetection',
|
||||||
|
'URLSearchParams',
|
||||||
|
'fetch',
|
||||||
|
'setTimeout',
|
||||||
|
'clearTimeout',
|
||||||
|
'requestAnimationFrame',
|
||||||
|
`${accessorySource}\nreturn KeyboardAccessoryBar;`
|
||||||
|
);
|
||||||
|
const bar = factory(
|
||||||
|
window,
|
||||||
|
window.document,
|
||||||
|
window.Event,
|
||||||
|
app,
|
||||||
|
{ isTouchDevice: () => true },
|
||||||
|
window.URLSearchParams,
|
||||||
|
vi.fn(),
|
||||||
|
schedule,
|
||||||
|
vi.fn(),
|
||||||
|
(callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
bar,
|
||||||
|
document: window.document,
|
||||||
|
localEcho,
|
||||||
|
timers,
|
||||||
|
runTimers() {
|
||||||
|
for (const timer of timers.splice(0)) timer.callback();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function textarea(document: Document): HTMLTextAreaElement {
|
||||||
|
return document.querySelector('.prompt-composer-textarea') as HTMLTextAreaElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mobile prompt composer', () => {
|
||||||
|
beforeEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it('replaces Paste with Compose on agent bars while shell keeps direct Paste', () => {
|
||||||
|
const template = (name: string) => accessorySource.match(new RegExp(name + '\\s*:\\s*`([\\s\\S]*?)`'))?.[1] ?? '';
|
||||||
|
|
||||||
|
expect(template('_simpleButtons')).toContain('data-action="compose"');
|
||||||
|
expect(template('_simpleButtons')).not.toContain('data-action="paste"');
|
||||||
|
expect(template('_extendedButtons')).toContain('data-action="compose"');
|
||||||
|
expect(template('_extendedButtons')).not.toContain('data-action="paste"');
|
||||||
|
expect(template('_shellButtons')).toContain('data-action="paste"');
|
||||||
|
expect(template('_shellButtons')).not.toContain('data-action="compose"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a native autocorrect-aware textarea and stores replacement text exactly once', () => {
|
||||||
|
const { app, bar, document } = loadComposer();
|
||||||
|
bar.composePrompt();
|
||||||
|
const input = textarea(document);
|
||||||
|
|
||||||
|
expect(input.getAttribute('autocorrect')).toBe('on');
|
||||||
|
expect(input.getAttribute('autocapitalize')).toBe('sentences');
|
||||||
|
expect(input.getAttribute('spellcheck')).toBe('true');
|
||||||
|
|
||||||
|
input.value = 'Please fix teh bug';
|
||||||
|
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||||
|
input.value = 'Please fix the bug';
|
||||||
|
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||||
|
(document.querySelector('.paste-cancel') as HTMLButtonElement).click();
|
||||||
|
|
||||||
|
bar.composePrompt();
|
||||||
|
expect(textarea(document).value).toBe('Please fix the bug');
|
||||||
|
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||||
|
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts and clears locally-buffered terminal input on open', () => {
|
||||||
|
const { app, bar, document, localEcho } = loadComposer();
|
||||||
|
localEcho.pendingText = '-written prompt';
|
||||||
|
app._flushedOffsets.set('session-1', 4);
|
||||||
|
app._flushedTexts.set('session-1', 'half');
|
||||||
|
|
||||||
|
bar.composePrompt();
|
||||||
|
|
||||||
|
expect(textarea(document).value).toBe('half-written prompt');
|
||||||
|
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x7f'.repeat(4), { useMux: true });
|
||||||
|
expect(localEcho.clear).toHaveBeenCalledOnce();
|
||||||
|
expect(localEcho.suppressBufferDetection).toHaveBeenCalledOnce();
|
||||||
|
expect(app._flushedOffsets.has('session-1')).toBe(false);
|
||||||
|
expect(app._flushedTexts.has('session-1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on tab switch and keeps drafts isolated by session', () => {
|
||||||
|
const { app, bar, document } = loadComposer();
|
||||||
|
bar.composePrompt();
|
||||||
|
textarea(document).value = 'first session draft';
|
||||||
|
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
app.activeSessionId = 'session-2';
|
||||||
|
bar.refreshForActiveSession();
|
||||||
|
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||||
|
expect(() => bar.refreshForActiveSession()).not.toThrow();
|
||||||
|
bar.composePrompt();
|
||||||
|
expect(textarea(document).value).toBe('');
|
||||||
|
textarea(document).value = 'second session draft';
|
||||||
|
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||||
|
(document.querySelector('.paste-cancel') as HTMLButtonElement).click();
|
||||||
|
|
||||||
|
app.activeSessionId = 'session-1';
|
||||||
|
bar.refreshForActiveSession();
|
||||||
|
bar.composePrompt();
|
||||||
|
expect(textarea(document).value).toBe('first session draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a draft and closes its composer when the session is deleted', () => {
|
||||||
|
const { bar, document } = loadComposer();
|
||||||
|
bar.composePrompt();
|
||||||
|
textarea(document).value = 'temporary secret';
|
||||||
|
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
bar.discardComposerDraft('session-1');
|
||||||
|
|
||||||
|
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||||
|
bar.composePrompt();
|
||||||
|
expect(textarea(document).value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wires session cleanup to composer draft cleanup', () => {
|
||||||
|
const cleanupStart = appSource.indexOf(' _cleanupSessionData(sessionId) {');
|
||||||
|
const cleanup = appSource.slice(cleanupStart, cleanupStart + 1200);
|
||||||
|
|
||||||
|
expect(cleanup).toContain('KeyboardAccessoryBar.discardComposerDraft?.(sessionId)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Enter as a newline and sends multiline text once via bracketed paste plus delayed Enter', () => {
|
||||||
|
const { app, bar, document, timers, runTimers } = loadComposer();
|
||||||
|
bar.composePrompt();
|
||||||
|
const input = textarea(document);
|
||||||
|
input.value = 'first line\nsecond line';
|
||||||
|
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||||
|
input.dispatchEvent(new document.defaultView!.KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
|
||||||
|
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||||
|
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||||
|
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||||
|
|
||||||
|
expect(app.terminal.paste).toHaveBeenCalledOnce();
|
||||||
|
expect(app.terminal.paste).toHaveBeenCalledWith('first line\nsecond line');
|
||||||
|
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||||
|
expect(timers).toContainEqual(expect.objectContaining({ delay: 120 }));
|
||||||
|
runTimers();
|
||||||
|
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\r', { useMux: true });
|
||||||
|
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||||
|
|
||||||
|
bar.composePrompt();
|
||||||
|
expect(textarea(document).value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the draft open when the agent has not enabled bracketed paste', () => {
|
||||||
|
const { app, bar, document } = loadComposer();
|
||||||
|
app.terminal.modes.bracketedPasteMode = false;
|
||||||
|
bar.composePrompt();
|
||||||
|
textarea(document).value = 'do not lose this';
|
||||||
|
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||||
|
|
||||||
|
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||||
|
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||||
|
expect(textarea(document).value).toBe('do not lose this');
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith(
|
||||||
|
'Prompt composer is waiting for the agent input to become ready',
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the draft and focuses xterm when Use terminal keyboard is chosen', () => {
|
||||||
|
const { app, bar, document, localEcho, runTimers } = loadComposer();
|
||||||
|
bar.composePrompt();
|
||||||
|
textarea(document).value = 'keep this';
|
||||||
|
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||||
|
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
|
||||||
|
|
||||||
|
expect(app.terminal.focus).toHaveBeenCalledOnce();
|
||||||
|
runTimers();
|
||||||
|
localEcho.pendingText = '; then continue';
|
||||||
|
bar.composePrompt();
|
||||||
|
expect(textarea(document).value).toBe('keep this; then continue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads images without writing into the PTY and inserts their paths into the draft', async () => {
|
||||||
|
const { app, bar, document } = loadComposer();
|
||||||
|
let finishUpload!: (paths: string[]) => void;
|
||||||
|
app._uploadAndInsertImages.mockImplementation(
|
||||||
|
() => new Promise<string[]>((resolveUpload) => (finishUpload = resolveUpload))
|
||||||
|
);
|
||||||
|
bar.composePrompt();
|
||||||
|
const input = textarea(document);
|
||||||
|
input.value = 'review';
|
||||||
|
input.selectionStart = input.selectionEnd = input.value.length;
|
||||||
|
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||||
|
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
|
||||||
|
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
|
||||||
|
Object.defineProperty(fileInput, 'files', { configurable: true, value: [image] });
|
||||||
|
|
||||||
|
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||||
|
expect((document.querySelector('.paste-image') as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
finishUpload(['/tmp/image-one.png']);
|
||||||
|
await vi.waitFor(() => expect(input.value).toBe('review /tmp/image-one.png'));
|
||||||
|
|
||||||
|
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([image], { insert: false });
|
||||||
|
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||||
|
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finishes an upload into a reopened composer without restoring a deleted session draft', async () => {
|
||||||
|
const { app, bar, document } = loadComposer();
|
||||||
|
let finishUpload!: (paths: string[]) => void;
|
||||||
|
app._uploadAndInsertImages.mockImplementation(
|
||||||
|
() => new Promise<string[]>((resolveUpload) => (finishUpload = resolveUpload))
|
||||||
|
);
|
||||||
|
bar.composePrompt();
|
||||||
|
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
|
||||||
|
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
|
||||||
|
Object.defineProperty(fileInput, 'files', { configurable: true, value: [image] });
|
||||||
|
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||||
|
(document.querySelector('.paste-cancel') as HTMLButtonElement).click();
|
||||||
|
bar.composePrompt();
|
||||||
|
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
|
||||||
|
finishUpload(['/tmp/late-image.png']);
|
||||||
|
await vi.waitFor(() => expect(textarea(document).value).toBe('/tmp/late-image.png'));
|
||||||
|
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
|
||||||
|
let finishDeletedUpload!: (paths: string[]) => void;
|
||||||
|
app._uploadAndInsertImages.mockImplementation(
|
||||||
|
() => new Promise<string[]>((resolveUpload) => (finishDeletedUpload = resolveUpload))
|
||||||
|
);
|
||||||
|
const reopenedInput = document.querySelector('.paste-file-input') as HTMLInputElement;
|
||||||
|
Object.defineProperty(reopenedInput, 'files', { configurable: true, value: [image] });
|
||||||
|
reopenedInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||||
|
app.sessions.delete('session-1');
|
||||||
|
bar.discardComposerDraft('session-1');
|
||||||
|
finishDeletedUpload(['/tmp/deleted-session.png']);
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(bar._composerDrafts.has('session-1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Send disabled until every concurrent image upload finishes', async () => {
|
||||||
|
const { app, bar, document } = loadComposer();
|
||||||
|
const finishUploads: Array<(paths: string[]) => void> = [];
|
||||||
|
app._uploadAndInsertImages.mockImplementation(
|
||||||
|
() => new Promise<string[]>((resolveUpload) => finishUploads.push(resolveUpload))
|
||||||
|
);
|
||||||
|
bar.composePrompt();
|
||||||
|
const input = textarea(document);
|
||||||
|
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
|
||||||
|
const pasteImage = () => {
|
||||||
|
const item = { type: 'image/png', getAsFile: () => image };
|
||||||
|
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(event, 'clipboardData', { value: { items: [item] } });
|
||||||
|
input.dispatchEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
pasteImage();
|
||||||
|
pasteImage();
|
||||||
|
expect(finishUploads).toHaveLength(2);
|
||||||
|
finishUploads[0](['/tmp/first.png']);
|
||||||
|
await vi.waitFor(() => expect(input.value).toBe('/tmp/first.png'));
|
||||||
|
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
|
||||||
|
finishUploads[1](['/tmp/second.png']);
|
||||||
|
await vi.waitFor(() => expect(input.value).toBe('/tmp/first.png /tmp/second.png'));
|
||||||
|
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -478,7 +478,7 @@ describe('Virtual Keyboard', () => {
|
|||||||
'tab',
|
'tab',
|
||||||
'shift-left',
|
'shift-left',
|
||||||
'shift-right',
|
'shift-right',
|
||||||
'paste',
|
'compose',
|
||||||
'readmymind',
|
'readmymind',
|
||||||
'esc',
|
'esc',
|
||||||
'dismiss',
|
'dismiss',
|
||||||
|
|||||||
Reference in New Issue
Block a user