From 9ffedc3bac097d6f02e9b679d0273e2cd3037cc4 Mon Sep 17 00:00:00 2001 From: draxxris <24984408+draxxris@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:06:07 -0400 Subject: [PATCH] Float the phone's composer controls over the input, one row each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a phone the ⋯ toggle opened the composer's controls as one no-wrap strip of 32px icon+label chips pinned to the row's bottom-right, over the attach button and the model picker. `nowrap` meant a strip wider than the space left of the toggle overflowed the screen instead of wrapping, and the ✕ was one more chip at the end of the strip. Make it a floating window over the composer instead: - components/ChatInput.tsx: the composer's fieldset (.chat-input-shell) becomes position: relative and the phone branch's controls container position: absolute at right: 4, bottom: 4. The panel's bottom edge lands on the composer's, so it covers the draft and the attach/model row and grows up over the transcript. All of it is the isMobile branch: the desktop chips keep their 32px row unchanged. - One 34px row per control, icon then label with the state at the right: Reasoning, Tools, Compact, Stop, Sound. The two rows that open a menu carry a small up-chevron, and their existing dropdowns are untouched and open above the panel, which is why the panel sets no overflow: an auto or hidden box would clip them. - Those two menus are sized for the screen inside the panel: right: 0 and maxWidth: calc(100vw - 16px), where they used to grow right from the row at left: 0 and put the tools menu's 288px past the edge; and maxHeight: calc(100dvh - 230px) with overflow-y: auto, the room above the panel's first row being the viewport less its own ~190px and that row's 34px, so a full reasoning list scrolls instead of running off the top of the screen. - A header row replaces the trailing ✕ chip: chat.moreControls in small mono capitals with the collapse button at its right, the container named #chat-controls-panel and role="group", aria-controls on the ⋯ toggle. Outside-click closing is unchanged: the panel stays inside the controlsMenuRef subtree, and so does the keyboard-open rule that hides it with the row. - No max height or scrolling on the panel itself: at most five rows are ever shown (idle has Reasoning, Tools, Compact, Sound; a run has Stop for the ones it drops), which is 184px measured. - i18n: five row labels, chat.controlsReasoning/Tools/Sound/On/Off, in en, zh-CN and zh-TW. - Docs: client-platform.md gains the phone controls panel note: the shell it anchors to, why it has no overflow, and how its two menus are kept on screen. --- components/ChatInput.tsx | 251 +++++++++++++++++++++------------ docs/agents/client-platform.md | 7 + lib/i18n/messages/en.ts | 5 + lib/i18n/messages/zh-CN.ts | 5 + lib/i18n/messages/zh-TW.ts | 5 + 5 files changed, 181 insertions(+), 92 deletions(-) diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index babcefe850..6aa5a49c09 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -360,6 +360,23 @@ export function stacksComposerLayout(compact: boolean, isMobile: boolean, isStre return compact || (isMobile && isStreaming); } +/** A row's trailing state in the phone controls panel (the level, preset, or on/off). */ +const CONTROLS_ROW_VALUE: React.CSSProperties = { + flexShrink: 0, + fontSize: 11.5, + color: "var(--text-dim)", + whiteSpace: "nowrap", +}; + +/** Marks a panel row that opens a menu of its own, pointing up at where it appears. */ +function ControlsRowChevron() { + return ( + + ); +} + const SLASH_SOURCES: SlashCommandSource[] = ["builtin", "extension", "prompt", "skill"]; const SLASH_SOURCE_GROUP_LABEL_KEYS: Record = { @@ -1732,6 +1749,10 @@ export const ChatInput = memo(forwardRef(function ChatIn style={{ flexShrink: 0, minWidth: 0, + // A phone's ⋯ controls panel is absolutely positioned against this + // shell, so it floats above the whole composer (draft, banners and the + // model/attach row) instead of pushing any of it around. + position: "relative", margin: 0, border: 0, background: "transparent", @@ -2484,7 +2505,9 @@ export const ChatInput = memo(forwardRef(function ChatIn display: "flex", alignItems: "center", justifyContent: "flex-end", - position: "relative", + // A phone's open panel is absolute: leave the shell as its + // containing block instead of this row. + position: isMobile ? "static" : "relative", marginLeft: isMobile ? 0 : "auto", }}> {isMobile && ( @@ -2492,6 +2515,7 @@ export const ChatInput = memo(forwardRef(function ChatIn type="button" title={controlsMenuOpen ? undefined : t("chat.moreControls")} aria-label={t("chat.moreControls")} + aria-controls={controlsMenuOpen ? "chat-controls-panel" : undefined} aria-expanded={controlsMenuOpen} aria-hidden={controlsMenuOpen || undefined} tabIndex={controlsMenuOpen ? -1 : undefined} @@ -2530,29 +2554,74 @@ export const ChatInput = memo(forwardRef(function ChatIn {t("chat.moreControls")} )} -
+ {isMobile && ( +
+ + {t("chat.moreControls")} + + +
+ )} {onThinkingLevelChange && ( -
+
{thinkingDropdownOpen && (
{THINKING_LEVELS.filter((lvl) => { if (!availableThinkingLevels) return true; @@ -2646,23 +2729,25 @@ export const ChatInput = memo(forwardRef(function ChatIn
)} {!isStreaming && onToolPresetChange && ( -
+
{toolDropdownOpen && (
{TOOL_PRESETS.map((lvl) => { const preset = TOOL_PRESET_MAP[lvl]; @@ -2731,20 +2826,22 @@ export const ChatInput = memo(forwardRef(function ChatIn )} {(!isStreaming || isCompacting) && onCompact && ( -
+
@@ -2775,15 +2872,16 @@ export const ChatInput = memo(forwardRef(function ChatIn onClick={onAbort} title={t("chat.stopAgent")} style={{ - display: "flex", alignItems: "center", gap: 6, - padding: "8px 14px", - height: 32, + display: "flex", alignItems: "center", justifyContent: isMobile ? "flex-start" : "center", gap: 6, + padding: isMobile ? "0 10px" : "8px 14px", + width: isMobile ? "100%" : undefined, + height: isMobile ? 34 : 32, background: "rgba(239,68,68,0.08)", border: "1px solid rgba(239,68,68,0.3)", - borderRadius: 9, + borderRadius: isMobile ? 8 : 9, color: "#ef4444", cursor: "pointer", - fontSize: 12, fontWeight: 600, + fontSize: isMobile ? 12.5 : 12, fontWeight: 600, whiteSpace: "nowrap", letterSpacing: "-0.01em", transition: "background 0.12s", }} @@ -2803,15 +2901,18 @@ export const ChatInput = memo(forwardRef(function ChatIn title={soundEnabled ? t("chat.disableSound") : t("chat.enableSound")} aria-label={soundEnabled ? t("chat.disableSound") : t("chat.enableSound")} style={{ - display: "flex", alignItems: "center", justifyContent: "center", gap: 5, - width: isMobile ? 32 : 32, - height: 32, - padding: 0, + display: "flex", alignItems: "center", justifyContent: isMobile ? "flex-start" : "center", gap: isMobile ? 8 : 5, + width: isMobile ? "100%" : 32, + height: isMobile ? 34 : 32, + padding: isMobile ? "0 10px" : 0, background: "none", border: "none", - borderRadius: 9, + borderRadius: isMobile ? 8 : 9, color: soundEnabled ? "var(--text-muted)" : "var(--text-dim)", cursor: "pointer", + fontSize: isMobile ? 12.5 : undefined, + textAlign: isMobile ? "left" : undefined, + minWidth: isMobile ? 0 : undefined, opacity: soundEnabled ? 1 : 0.55, transition: "background 0.12s, color 0.12s, opacity 0.12s", }} @@ -2839,46 +2940,12 @@ export const ChatInput = memo(forwardRef(function ChatIn )} - - )} - {isMobile && controlsMenuOpen && ( - )}
diff --git a/docs/agents/client-platform.md b/docs/agents/client-platform.md index 561ea6ef6f..b1150fc4c6 100644 --- a/docs/agents/client-platform.md +++ b/docs/agents/client-platform.md @@ -7,6 +7,13 @@ - `.chat-input-controls` stays while its model picker is open (`:not(:has(...))`, a rule of its own): the picker's panel lives in that row and its filter is the field the keyboard opened for, so hiding the row hid the picker and blurred the filter (#1271). See [models.md](models.md#model-selector-and-the-mobile-keyboard). - Anything fixed to the bottom of a phone screen sits inside `--app-viewport-height`, never at `bottom: 0`: iOS keeps the layout viewport, and with it `bottom: 0`, under the keyboard. The drawer and the right panel use `top: 0` with that height; the sidebar's bottom sheet (`.sidebar-sheet-layer` in `app/sidebar-menu.css`) is a layer of that height with the sheet at its bottom, and drops its safe-area padding under `html[data-keyboard-open]`. +## The composer's phone controls panel (`components/ChatInput.tsx`) +- The controls row's ⋯ toggle opens the reasoning, tools, Compact, Stop and sound controls as a floating window over the composer, one control per 34px row (icon, label, its state at the right). Before, they fanned out as one no-wrap strip of 24px icon+label chips `bottom: 0` over the same row: at 390px it was 231px wide, sat on the model selector and covered the whole left half once a control was added. +- It is absolutely positioned against the composer's fieldset (`.chat-input-shell`, now `position: relative`) at `right: 4; bottom: 4`, not against the row: the panel's bottom edge lands on the composer's, so it covers the draft and the attach/model row and grows up over the transcript. Never above the shell: a window that starts at the shell's top reads as another banner and leaves the composer showing under it. The row itself is `position: static` on a phone so the shell is the containing block. Desktop keeps its chips: the panel's mobile-only styles are the `isMobile` branch of the same nodes. +- No `overflow` on the panel: a row's own reasoning/tools menu opens above it, outside the panel's box, and `overflow: auto` would clip it. It holds at most five rows, so it never needs to scroll. +- Those two menus are sized for the screen inside the panel: `right: 0` and `maxWidth: calc(100vw - 16px)` (they used to grow right from the row at `left: 0`, which put the tools menu's 288px past the edge), and `maxHeight: calc(100dvh - 230px)` with `overflow-y: auto` — the room above the panel's first row is the viewport less its own ~190px and that row's 34px, so a full reasoning list scrolls instead of running off the top of the screen. +- It stays inside `.chat-input-controls`, so the keyboard-open rule hides it with the row; its header holds the title and the ✕ that collapses it (`chat.moreControls`, `chat.collapseControls`). The row labels are `chat.controls*` (`Reasoning`, `Tools`, `Sound`, `On`/`Off`), and outside-click closing is unchanged: the panel is inside the `controlsMenuRef` subtree. + ## Terminal extra keys (`components/TerminalExtraKeys.tsx`, `lib/terminal-extra-keys.ts`) - A touch device held in portrait gets Termux's two key rows under the terminal (`ESC / - HOME ↑ END PGUP`, `TAB CTRL ALT ← ↓ → PGDN`). Portrait comes from `screen.orientation` (then `window.orientation`), never from the viewport: the soft keyboard makes the viewport wider than tall, and the keys must stay while it is open. Left and right are Noto Sans Mono's `↓` and `↑` rotated 90deg: its latin subset has `↑`/`↓` but no `←`/`→`, which would fall back to a heavier system font. - The keys are the panel's last grid row, so xterm's `ResizeObserver` refits above them instead of being covered. Buttons call `preventDefault()` on pointer and mouse down so the hidden xterm textarea keeps focus and the keyboard stays up. diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index cd3d53ef39..67329ee920 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -586,6 +586,11 @@ export const enLocale: LocalePlugin = { "chat.defaultThinking": "Default reasoning level for new chats", "chat.moreControls": "More controls", "chat.collapseControls": "Collapse controls", + "chat.controlsReasoning": "Reasoning", + "chat.controlsTools": "Tools", + "chat.controlsSound": "Sound", + "chat.controlsOn": "On", + "chat.controlsOff": "Off", "chat.shell": "Shell", "terminal.title": "Terminal", "terminal.tabLabel": "Terminal: {name}", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index bb1c64868e..eaa51717fc 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -586,6 +586,11 @@ export const zhCNLocale: LocalePlugin = { "chat.defaultThinking": "新会话默认推理级别", "chat.moreControls": "更多控件", "chat.collapseControls": "收起控件", + "chat.controlsReasoning": "推理", + "chat.controlsTools": "工具", + "chat.controlsSound": "声音", + "chat.controlsOn": "开", + "chat.controlsOff": "关", "chat.shell": "Shell", "terminal.title": "终端", "terminal.tabLabel": "终端:{name}", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index fe29c7f3bc..dac17913c1 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -586,6 +586,11 @@ export const zhTWLocale: LocalePlugin = { "chat.defaultThinking": "新工作階段預設推理層級", "chat.moreControls": "更多控制項", "chat.collapseControls": "收起控制項", + "chat.controlsReasoning": "推理", + "chat.controlsTools": "工具", + "chat.controlsSound": "聲音", + "chat.controlsOn": "開", + "chat.controlsOff": "關", "chat.shell": "Shell", "terminal.title": "終端機", "terminal.tabLabel": "終端機:{name}",