mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -344,6 +344,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (issue #262, `keyboard-accessory.js`): a **shell**-mode session automatically swaps the mobile accessory bar for terminal controls (Ctrl, Esc, Tab, four arrows, paste, dismiss); every other mode keeps the agent bar. `setMode()` now records the user's `extendedKeyboardBar` preference as the **base** layout and `refreshForActiveSession()` (called from `selectSession`) resolves base-vs-shell, so a settings save during a shell session cannot yank the bar away and switching back restores the user's choice. ⚠️ **Ctrl is a ONE-SHOT modifier applied in `terminal.onData`, not in a keydown handler**: a virtual keyboard emits no usable key events, so the character only exists as onData text. The hook sits AFTER `shouldSuppressTerminalQueryResponse` (xterm answers DA/CPR through onData too, and one of those would silently spend the modifier) and BEFORE every send path, so the control byte follows the normal control-char route. ⚠️ **Not every onData chunk is a keystroke**, and the query filter is not enough on its own: xterm ALSO emits mouse and focus reports on its own initiative, so the hook skips them via `isTerminalFocusOrMouseReport()` (they still reach the PTY, they just don't count as the next key). The mouse half is live — a shell session keeps the NARROW strip, so mouse DECSETs reach the browser and one tap while vim/htop runs spent the armed modifier silently (measured). The focus half is defense in depth: `FOCUS_ESCAPE_FILTER` in `session.ts` strips `\x1b[?1004h` from every PTY read, so `sendFocusMode` never turns on today; if it ever did, the bar's own post-key refocus would emit `\x1b[I` and eat the modifier before the user typed. ⚠️ It must disarm on ALL of: use, second tap, any other accessory key, session switch, keyboard dismissal, and a layout swap; a modifier left armed turns the next innocent keystroke into a control byte. ⚠️ **onData is not the only input path** — with `cjkInputEnabled` on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends `terminal.focus()` there, which is where the bar refocuses after every key), so `_handleCjkInput()` applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke. Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. ⚠️ The armed style is `.accessory-btn.accessory-btn-ctrl.armed` (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at **(0,3,1)** (`:is()` inherits its most specific argument, and that list holds `.btn-toolbar.btn-shell`) — so that rule excludes the state by hand as `.accessory-btn:not(.armed)`. Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all.
|
||||
|
||||
**Mobile prompt composer** (PR #444, the first slice of #359, `keyboard-accessory.js`): the agent bars' Paste key is now **Compose**, a dialog with a native multiline textarea (autocorrect, autocapitalize, spellcheck) where Enter adds a line and only **Send** submits; the shell bar keeps the direct Paste dialog, since shell input is not an agent prompt. Opening it ADOPTS the whole editable terminal prompt (`_takePendingLocalEcho`): the local-echo overlay's pending text has never reached the PTY, but the flushed prefix has, so that prefix is erased with backspaces counted in CODE POINTS (`Array.from(text).length`; measured on Claude Code 2.1.278, `a` + emoji + `b` takes three, and the UTF-16 count sent four and ate the neighbour; `clearTerminalInput()` in terminal-ui.js moved with it). ⚠️ **Drafts are per-session and in memory only** (`_composerDrafts`, never persisted: prompts routinely carry secrets, and persisting them would need the 0600 treatment the intent store gets). Every non-Send exit (Cancel, backdrop, Escape, "Use terminal keyboard") leaves the taken text ONLY in the draft, with the dot on the key (`has-draft`, kept in step by `_syncComposerDraftIndicator`) as the signal that the terminal prompt is empty on purpose, and `_cleanupSessionData` discards the draft with the session. ⚠️ **Delivery is a hand-built bracketed-paste frame** (`\x1b[200~` + the text with newlines mapped to `\r` + `\x1b[201~`, byte-identical to what `terminal.paste()` would emit) through `_sendInputAsync` WITHOUT `{ useMux: true }`, plus a SEPARATE Enter 120 ms later WITH it (codex drops keys that share a PTY read with a bracketed paste). Not `terminal.paste()`, for two reasons: xterm's `bracketedPasteMode` mirror is false for every session after a tab switch or reload (`terminal.reset()` in the replay re-clones the DEC modes, the tmux capture carries no `?2004h`, and tmux never forwards the pane's DECSET to a client after attach), so a paste through xterm would go out unbracketed and the CLI would submit at the first `\r`; and xterm's onData is where the local-echo paste branch flushes pending overlay text AHEAD of the block, text the composer has already taken and erased from the PTY, so the frame goes straight to the wire with the composer as the prompt's only owner. The unconditional frame is safe for a CLI that never enabled DECSET 2004 because tmux does the gating (measured against a live pane: markers stripped for a `cat -v` pane, forwarded intact to a process that had emitted `?2004h`). ⚠️ The frame must never take the mux fallback: `TmuxManager.sendInput()` strips every `\r` and `\n`, which welds the lines together and submits them. ⚠️ The size guard sits on the `MAX_INPUT_LENGTH` boundary (64 KiB of UTF-16 code units): `ws-routes.ts` drops a longer frame WITHOUT an ACK, which would wedge the durable queue, so `_composerMaxLength` is derived from that limit minus both markers and an oversized prompt stays a draft with a toast. ⚠️ `.prompt-composer-overlay` is a `.paste-overlay` with a gutter of its own (a `padding` shorthand whose bottom is 12px plus the safe area), and the unconditional `.paste-overlay` fold rule at the end of styles.css is a later longhand at the same specificity, so it ERASED that gutter (measured at 393x852: `padding-bottom: 0px` flat, and the hinge strip REPLACING the gutter with the fold variables set): the composer has its own restatement after the fold rules, the dialog's `max-height` subtracts `--fold-block-end`, and `test/foldable-layout.test.ts` lists the composer in `ELEMENTS` by hand, because its derived overlay list keys on rules that declare `position: fixed; inset: 0` themselves. Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not under `test/mobile/**`).
|
||||
|
||||
**Dismissing the on-screen keyboard** (PRs #279/#280, `terminal-ui.js`): the terminal parks focus on a hidden textarea that nothing used to release, so TWO gestures now blur it, and they own different regions. **(1)** `_installMobileKeyboardDismiss()` — a document-level `touchend` that fires only while the terminal input actually holds focus, **never inside `#terminalContainer`** (tap classification owns that) and **never on a control** (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, matched with `closest()` so an icon inside a button counts). Session tabs are covered by the selector's `[tabindex]:not([tabindex="-1"])` arm, which is what stops a tab tap from blurring and then being re-focused by `selectSession()`. **(2)** In `_handleMobileTerminalTap`, a second tap on **inert `content`** (`startedWithTerminalFocus`) blurs instead of re-focusing. ⚠️ Scoped to `content` on purpose: the prompt row (`input`) keeps focus-then-position so a second tap still places the caret, and actionable rows blur earlier via `_isActionableMobileTerminalTap`. ⚠️ **A scroll ends in `touchend` too** — dismissing there closes the keyboard and drops the composer mid-read, so travel is tracked from `touchstart` and multi-touch is never a tap. Both classifiers MUST share one threshold: `initTerminal`'s `TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`, since a gesture the terminal calls a scroll and the dismiss handler calls a tap is exactly that bug. ⚠️ **The gate excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. (Not `npm test --`: the gate's config excludes that path, so a file filter pointing into it matches nothing and exits green having run zero tests.) That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks.
|
||||
|
||||
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
|
||||
|
||||
@@ -60,14 +60,20 @@ On by default; it can be turned off in settings.
|
||||
|
||||
A row of keys above the virtual keyboard, and what it contains depends on the session.
|
||||
|
||||
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a clipboard key, `Esc`,
|
||||
a path picker, an image key, and 🧠 when Read My Mind is on. Destructive commands need a
|
||||
double press, so you cannot fire `/clear` with a stray thumb. On Codex sessions the bar also
|
||||
shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to editing the last queued
|
||||
message and walking the prompt stack.
|
||||
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a Compose key, `Esc`,
|
||||
a path picker, and 🧠 when Read My Mind is on. Compose opens a multiline editor with
|
||||
autocorrect: Enter adds a new line, and only Send delivers the text, as one paste followed
|
||||
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
|
||||
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
|
||||
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
|
||||
is parked. The editor's Image button attaches photos and puts their paths into the draft.
|
||||
Destructive commands need a double press, so you cannot fire `/clear` with a stray thumb. On
|
||||
Codex sessions the bar also shows `⇧←` and `⇧→`, the Shift-modified arrows Codex binds to
|
||||
editing the last queued message and walking the prompt stack.
|
||||
|
||||
**Shell sessions** automatically swap it for terminal controls: `Ctrl`, `Esc`, `Tab`, four
|
||||
arrows, paste, and dismiss. Your normal preference is remembered and restored when you
|
||||
arrows, a direct Paste key (shell input is not an agent prompt, so there is no Compose
|
||||
there), and dismiss. Your normal preference is remembered and restored when you
|
||||
switch back to an agent session, so a settings change during a shell session cannot strip
|
||||
the bar away permanently.
|
||||
|
||||
|
||||
@@ -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': '当前会话',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -313,11 +313,17 @@ describe('fold reserved region: every centred overlay is covered', () => {
|
||||
/**
|
||||
* The elements whose padding cascade is simulated: every derived overlay as
|
||||
* a bare element, plus the open command palette, which is a `.modal` wearing
|
||||
* two more classes and the one overlay mobile.css pads with a shorthand.
|
||||
* two more classes and the one overlay mobile.css pads with a shorthand, plus
|
||||
* the mobile prompt composer, a `.paste-overlay` wearing a second class that
|
||||
* carries its own `padding` shorthand. The derived list cannot see the
|
||||
* composer (it inherits the centring declarations rather than declaring
|
||||
* them), and simulating `['paste-overlay']` alone stayed green while the
|
||||
* generic `.paste-overlay` fold rule erased the composer's bottom gutter.
|
||||
*/
|
||||
const ELEMENTS: { name: string; classes: string[] }[] = [
|
||||
...CENTRED_OVERLAYS.map((o) => ({ name: o.selector, classes: classCompound(o.selector)! })),
|
||||
{ name: '.modal.command-palette-modal.active', classes: ['modal', 'command-palette-modal', 'active'] },
|
||||
{ name: '.paste-overlay.prompt-composer-overlay', classes: ['paste-overlay', 'prompt-composer-overlay'] },
|
||||
];
|
||||
|
||||
it('simulates the cascade the browser measured', () => {
|
||||
@@ -330,7 +336,7 @@ describe('fold reserved region: every centred overlay is covered', () => {
|
||||
const picker = ['path-picker-overlay'];
|
||||
expect(cascadedPadding(picker, 'right', 393, false)).toBe('0');
|
||||
expect(cascadedPadding(picker, 'right', 626, false)).toBe('16px');
|
||||
const palette = ELEMENTS.at(-1)!.classes;
|
||||
const palette = ELEMENTS.find((e) => e.name === '.modal.command-palette-modal.active')!.classes;
|
||||
expect(cascadedPadding(palette, 'right', 393, false)).toBeNull();
|
||||
expect(cascadedPadding(palette, 'right', 626, false)).toBe('0.75rem');
|
||||
expect(cascadedPadding(palette, 'bottom', 626, false)).toBe('0');
|
||||
|
||||
@@ -299,6 +299,32 @@ describe('mobile prompt composer', () => {
|
||||
expect(bar._composerDrafts.get('session-1')).toHaveLength(65525);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only draft as empty instead of submitting blank lines', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
textarea(document).value = ' \n\n ';
|
||||
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('.prompt-composer-overlay')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('derives the prompt budget from the 64 KiB input frame minus both paste markers', () => {
|
||||
// ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH without an ACK,
|
||||
// so a prompt of exactly the budget must produce a frame of exactly 64 KiB.
|
||||
const { app, bar, document } = loadComposer();
|
||||
expect(bar._composerMaxLength).toBe(64 * 1024 - '\x1b[200~\x1b[201~'.length);
|
||||
bar.composePrompt();
|
||||
textarea(document).value = 'y'.repeat(bar._composerMaxLength);
|
||||
|
||||
(document.querySelector('.paste-send') as HTMLButtonElement).click();
|
||||
|
||||
expect(app._sendInputAsync).toHaveBeenCalledOnce();
|
||||
expect((app._sendInputAsync.mock.calls[0][1] as string).length).toBe(64 * 1024);
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the draft and focuses xterm when Use terminal keyboard is chosen', () => {
|
||||
const { app, bar, document, localEcho, runTimers } = loadComposer();
|
||||
const composeButton = mountComposeButton(bar, document);
|
||||
|
||||
Reference in New Issue
Block a user