mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
fix(mobile): preserve composer delivery after replay
This commit is contained in:
@@ -777,6 +777,7 @@ const KeyboardAccessoryBar = {
|
|||||||
// The ⇧←/⇧→ keys are Codex bindings: same shape, gated on the active
|
// The ⇧←/⇧→ keys are Codex bindings: same shape, gated on the active
|
||||||
// session's mode instead of a setting.
|
// session's mode instead of a setting.
|
||||||
this.syncCodexKeys();
|
this.syncCodexKeys();
|
||||||
|
this._syncComposerDraftIndicator();
|
||||||
|
|
||||||
// Add click handlers — preventDefault stops event from reaching terminal
|
// Add click handlers — preventDefault stops event from reaching terminal
|
||||||
this.element.addEventListener('click', (e) => {
|
this.element.addEventListener('click', (e) => {
|
||||||
@@ -824,6 +825,7 @@ const KeyboardAccessoryBar = {
|
|||||||
}
|
}
|
||||||
this._applyLayout(this._resolveMode());
|
this._applyLayout(this._resolveMode());
|
||||||
this.syncCodexKeys();
|
this.syncCodexKeys();
|
||||||
|
this._syncComposerDraftIndicator();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Which layout the current state calls for. */
|
/** Which layout the current state calls for. */
|
||||||
@@ -851,6 +853,20 @@ const KeyboardAccessoryBar = {
|
|||||||
this.clearCtrl();
|
this.clearCtrl();
|
||||||
this.element.innerHTML =
|
this.element.innerHTML =
|
||||||
mode === 'shell' ? this._shellButtons : mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
mode === 'shell' ? this._shellButtons : mode === 'extended' ? this._extendedButtons : this._simpleButtons;
|
||||||
|
this._syncComposerDraftIndicator();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Show when the active session has a prompt parked in memory. The marker
|
||||||
|
* keeps non-Send closes visible without copying the draft back into the PTY
|
||||||
|
* and creating a second source of truth. */
|
||||||
|
_syncComposerDraftIndicator() {
|
||||||
|
const button = this.element?.querySelector('[data-action="compose"]');
|
||||||
|
if (!button) return;
|
||||||
|
const sessionId = typeof app !== 'undefined' ? app.activeSessionId : null;
|
||||||
|
const hasDraft = !!(sessionId && this._composerDrafts.get(sessionId));
|
||||||
|
button.classList.toggle('has-draft', hasDraft);
|
||||||
|
button.title = hasDraft ? 'Resume saved prompt draft' : 'Compose prompt';
|
||||||
|
button.setAttribute('aria-label', hasDraft ? 'Compose prompt, draft saved' : 'Compose prompt');
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── One-shot Ctrl modifier (shell bar) ──────────────────────────────────
|
// ── One-shot Ctrl modifier (shell bar) ──────────────────────────────────
|
||||||
@@ -1166,19 +1182,18 @@ const KeyboardAccessoryBar = {
|
|||||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Deliver one complete prompt through xterm's paste path. terminal.paste()
|
/** Deliver one complete prompt as an explicit bracketed paste. xterm loses
|
||||||
* preserves bracketed-paste markers and multiline content; Enter is a
|
* its DECSET 2004 mirror after terminal replay, even though the CLI still
|
||||||
* separate delayed durable write because Codex drops keys sharing a PTY read
|
* expects bracketed input, so build the byte-identical sequence directly on
|
||||||
* with a bracketed paste. Capture the session so a fast tab switch cannot
|
* the durable session-bound path. Enter stays a separate delayed write
|
||||||
* submit the prompt in a different pane. */
|
* because Codex drops keys sharing a PTY read with a bracketed paste. */
|
||||||
_sendComposedPrompt(sessionId, text) {
|
_sendComposedPrompt(sessionId, text) {
|
||||||
if (!sessionId || !text || !app.terminal?.paste) return false;
|
if (!sessionId || !text || typeof app._sendInputAsync !== 'function') 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._predictiveEcho?.clearPredictions();
|
||||||
app.terminal.paste(text);
|
// 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');
|
||||||
|
app._sendInputAsync(sessionId, `\x1b[200~${pasteText}\x1b[201~`, { useMux: true });
|
||||||
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
|
setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -1187,6 +1202,7 @@ const KeyboardAccessoryBar = {
|
|||||||
discardComposerDraft(sessionId) {
|
discardComposerDraft(sessionId) {
|
||||||
this._composerDrafts.delete(sessionId);
|
this._composerDrafts.delete(sessionId);
|
||||||
this._composerUploads.delete(sessionId);
|
this._composerUploads.delete(sessionId);
|
||||||
|
this._syncComposerDraftIndicator();
|
||||||
if (this._composerOverlay?.dataset.sessionId === sessionId) {
|
if (this._composerOverlay?.dataset.sessionId === sessionId) {
|
||||||
this._composerOverlay._closeComposer?.({ preserveDraft: false });
|
this._composerOverlay._closeComposer?.({ preserveDraft: false });
|
||||||
}
|
}
|
||||||
@@ -1238,6 +1254,7 @@ const KeyboardAccessoryBar = {
|
|||||||
const focusTrap = new FocusTrap(overlay);
|
const focusTrap = new FocusTrap(overlay);
|
||||||
textarea.value = initial;
|
textarea.value = initial;
|
||||||
if (initial) this._composerDrafts.set(sessionId, initial);
|
if (initial) this._composerDrafts.set(sessionId, initial);
|
||||||
|
this._syncComposerDraftIndicator();
|
||||||
const initialUploads = this._composerUploads.get(sessionId) || 0;
|
const initialUploads = this._composerUploads.get(sessionId) || 0;
|
||||||
imageButton.disabled = initialUploads > 0;
|
imageButton.disabled = initialUploads > 0;
|
||||||
sendButton.disabled = initialUploads > 0;
|
sendButton.disabled = initialUploads > 0;
|
||||||
@@ -1246,6 +1263,7 @@ const KeyboardAccessoryBar = {
|
|||||||
const saveDraft = () => {
|
const saveDraft = () => {
|
||||||
if (textarea.value) this._composerDrafts.set(sessionId, textarea.value);
|
if (textarea.value) this._composerDrafts.set(sessionId, textarea.value);
|
||||||
else this._composerDrafts.delete(sessionId);
|
else this._composerDrafts.delete(sessionId);
|
||||||
|
this._syncComposerDraftIndicator();
|
||||||
};
|
};
|
||||||
const close = ({ focusTerminal = false, preserveDraft = true, restoreFocus = true } = {}) => {
|
const close = ({ focusTerminal = false, preserveDraft = true, restoreFocus = true } = {}) => {
|
||||||
if (preserveDraft) saveDraft();
|
if (preserveDraft) saveDraft();
|
||||||
@@ -1259,6 +1277,7 @@ const KeyboardAccessoryBar = {
|
|||||||
const text = textarea.value;
|
const text = textarea.value;
|
||||||
if (!text || !this._sendComposedPrompt(sessionId, text)) return;
|
if (!text || !this._sendComposedPrompt(sessionId, text)) return;
|
||||||
this._composerDrafts.delete(sessionId);
|
this._composerDrafts.delete(sessionId);
|
||||||
|
this._syncComposerDraftIndicator();
|
||||||
close({ preserveDraft: false });
|
close({ preserveDraft: false });
|
||||||
};
|
};
|
||||||
const handleImages = async (files) => {
|
const handleImages = async (files) => {
|
||||||
@@ -1293,6 +1312,7 @@ const KeyboardAccessoryBar = {
|
|||||||
else if (app.sessions?.has(sessionId)) {
|
else if (app.sessions?.has(sessionId)) {
|
||||||
const draft = this._composerDrafts.get(sessionId) || '';
|
const draft = this._composerDrafts.get(sessionId) || '';
|
||||||
this._composerDrafts.set(sessionId, `${draft}${draft && !/\s$/.test(draft) ? ' ' : ''}${paths.join(' ')}`);
|
this._composerDrafts.set(sessionId, `${draft}${draft && !/\s$/.test(draft) ? ' ' : ''}${paths.join(' ')}`);
|
||||||
|
this._syncComposerDraftIndicator();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -13613,6 +13613,14 @@ body.touch-device.cjk-input-visible .main {
|
|||||||
background: var(--control-bg-hover);
|
background: var(--control-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.accessory-btn-compose.has-draft::after {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--yellow);
|
||||||
|
border-radius: 50%;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
.accessory-btn svg {
|
.accessory-btn svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ function textarea(document: Document): HTMLTextAreaElement {
|
|||||||
return document.querySelector('.prompt-composer-textarea') as HTMLTextAreaElement;
|
return document.querySelector('.prompt-composer-textarea') as HTMLTextAreaElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mountComposeButton(bar: any, document: Document): HTMLButtonElement {
|
||||||
|
bar.element = document.createElement('div');
|
||||||
|
bar.element.innerHTML = bar._simpleButtons;
|
||||||
|
document.body.appendChild(bar.element);
|
||||||
|
bar._syncComposerDraftIndicator();
|
||||||
|
return bar.element.querySelector('[data-action="compose"]') as HTMLButtonElement;
|
||||||
|
}
|
||||||
|
|
||||||
describe('mobile prompt composer', () => {
|
describe('mobile prompt composer', () => {
|
||||||
beforeEach(() => vi.restoreAllMocks());
|
beforeEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
@@ -145,13 +153,16 @@ describe('mobile prompt composer', () => {
|
|||||||
|
|
||||||
it('closes on tab switch and keeps drafts isolated by session', () => {
|
it('closes on tab switch and keeps drafts isolated by session', () => {
|
||||||
const { app, bar, document } = loadComposer();
|
const { app, bar, document } = loadComposer();
|
||||||
|
const composeButton = mountComposeButton(bar, document);
|
||||||
bar.composePrompt();
|
bar.composePrompt();
|
||||||
textarea(document).value = 'first session draft';
|
textarea(document).value = 'first session draft';
|
||||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||||
|
expect(composeButton.classList.contains('has-draft')).toBe(true);
|
||||||
|
|
||||||
app.activeSessionId = 'session-2';
|
app.activeSessionId = 'session-2';
|
||||||
bar.refreshForActiveSession();
|
bar.refreshForActiveSession();
|
||||||
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||||
|
expect(composeButton.classList.contains('has-draft')).toBe(false);
|
||||||
expect(() => bar.refreshForActiveSession()).not.toThrow();
|
expect(() => bar.refreshForActiveSession()).not.toThrow();
|
||||||
bar.composePrompt();
|
bar.composePrompt();
|
||||||
expect(textarea(document).value).toBe('');
|
expect(textarea(document).value).toBe('');
|
||||||
@@ -161,19 +172,24 @@ describe('mobile prompt composer', () => {
|
|||||||
|
|
||||||
app.activeSessionId = 'session-1';
|
app.activeSessionId = 'session-1';
|
||||||
bar.refreshForActiveSession();
|
bar.refreshForActiveSession();
|
||||||
|
expect(composeButton.classList.contains('has-draft')).toBe(true);
|
||||||
|
expect(composeButton.getAttribute('aria-label')).toBe('Compose prompt, draft saved');
|
||||||
bar.composePrompt();
|
bar.composePrompt();
|
||||||
expect(textarea(document).value).toBe('first session draft');
|
expect(textarea(document).value).toBe('first session draft');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops a draft and closes its composer when the session is deleted', () => {
|
it('drops a draft and closes its composer when the session is deleted', () => {
|
||||||
const { bar, document } = loadComposer();
|
const { bar, document } = loadComposer();
|
||||||
|
const composeButton = mountComposeButton(bar, document);
|
||||||
bar.composePrompt();
|
bar.composePrompt();
|
||||||
textarea(document).value = 'temporary secret';
|
textarea(document).value = 'temporary secret';
|
||||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||||
|
expect(composeButton.classList.contains('has-draft')).toBe(true);
|
||||||
|
|
||||||
bar.discardComposerDraft('session-1');
|
bar.discardComposerDraft('session-1');
|
||||||
|
|
||||||
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||||
|
expect(composeButton.classList.contains('has-draft')).toBe(false);
|
||||||
bar.composePrompt();
|
bar.composePrompt();
|
||||||
expect(textarea(document).value).toBe('');
|
expect(textarea(document).value).toBe('');
|
||||||
});
|
});
|
||||||
@@ -187,6 +203,7 @@ describe('mobile prompt composer', () => {
|
|||||||
|
|
||||||
it('keeps Enter as a newline and sends multiline text once via bracketed paste plus delayed Enter', () => {
|
it('keeps Enter as a newline and sends multiline text once via bracketed paste plus delayed Enter', () => {
|
||||||
const { app, bar, document, timers, runTimers } = loadComposer();
|
const { app, bar, document, timers, runTimers } = loadComposer();
|
||||||
|
const composeButton = mountComposeButton(bar, document);
|
||||||
bar.composePrompt();
|
bar.composePrompt();
|
||||||
const input = textarea(document);
|
const input = textarea(document);
|
||||||
input.value = 'first line\nsecond line';
|
input.value = 'first line\nsecond line';
|
||||||
@@ -197,44 +214,51 @@ describe('mobile prompt composer', () => {
|
|||||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||||
|
|
||||||
expect(app.terminal.paste).toHaveBeenCalledOnce();
|
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||||
expect(app.terminal.paste).toHaveBeenCalledWith('first line\nsecond line');
|
expect(app._sendInputAsync).toHaveBeenCalledOnce();
|
||||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
expect(app._sendInputAsync).toHaveBeenNthCalledWith(1, 'session-1', '\x1b[200~first line\rsecond line\x1b[201~', {
|
||||||
|
useMux: true,
|
||||||
|
});
|
||||||
|
expect(composeButton.classList.contains('has-draft')).toBe(false);
|
||||||
expect(timers).toContainEqual(expect.objectContaining({ delay: 120 }));
|
expect(timers).toContainEqual(expect.objectContaining({ delay: 120 }));
|
||||||
runTimers();
|
runTimers();
|
||||||
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\r', { useMux: true });
|
expect(app._sendInputAsync).toHaveBeenNthCalledWith(2, 'session-1', '\r', { useMux: true });
|
||||||
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||||
|
|
||||||
bar.composePrompt();
|
bar.composePrompt();
|
||||||
expect(textarea(document).value).toBe('');
|
expect(textarea(document).value).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the draft open when the agent has not enabled bracketed paste', () => {
|
it('sends after replay resets xterm’s mirrored bracketed-paste mode', () => {
|
||||||
const { app, bar, document } = loadComposer();
|
const { app, bar, document, runTimers } = loadComposer();
|
||||||
app.terminal.modes.bracketedPasteMode = false;
|
app.terminal.modes.bracketedPasteMode = false;
|
||||||
bar.composePrompt();
|
bar.composePrompt();
|
||||||
textarea(document).value = 'do not lose this';
|
textarea(document).value = 'still\nmultiline';
|
||||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||||
|
|
||||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||||
|
|
||||||
expect(app.terminal.paste).not.toHaveBeenCalled();
|
expect(app.terminal.paste).not.toHaveBeenCalled();
|
||||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~still\rmultiline\x1b[201~', {
|
||||||
expect(textarea(document).value).toBe('do not lose this');
|
useMux: true,
|
||||||
expect(app.showToast).toHaveBeenCalledWith(
|
});
|
||||||
'Prompt composer is waiting for the agent input to become ready',
|
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
|
||||||
'info'
|
expect(app.showToast).not.toHaveBeenCalled();
|
||||||
);
|
runTimers();
|
||||||
|
expect(app._sendInputAsync).toHaveBeenLastCalledWith('session-1', '\r', { useMux: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves the draft and focuses xterm when Use terminal keyboard is chosen', () => {
|
it('preserves the draft and focuses xterm when Use terminal keyboard is chosen', () => {
|
||||||
const { app, bar, document, localEcho, runTimers } = loadComposer();
|
const { app, bar, document, localEcho, runTimers } = loadComposer();
|
||||||
|
const composeButton = mountComposeButton(bar, document);
|
||||||
bar.composePrompt();
|
bar.composePrompt();
|
||||||
textarea(document).value = 'keep this';
|
textarea(document).value = 'keep this';
|
||||||
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
textarea(document).dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
|
||||||
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
|
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
|
||||||
|
|
||||||
expect(app.terminal.focus).toHaveBeenCalledOnce();
|
expect(app.terminal.focus).toHaveBeenCalledOnce();
|
||||||
|
expect(composeButton.classList.contains('has-draft')).toBe(true);
|
||||||
|
expect(composeButton.title).toBe('Resume saved prompt draft');
|
||||||
runTimers();
|
runTimers();
|
||||||
localEcho.pendingText = '; then continue';
|
localEcho.pendingText = '; then continue';
|
||||||
bar.composePrompt();
|
bar.composePrompt();
|
||||||
|
|||||||
Reference in New Issue
Block a user