feat: implement platform-specific expand chat input with mobile optimizations

- Add platform detection (Electron vs Web) for expand behavior
  - Electron: 3-state (normal → 50% → 80%) with double-click
  - Web/Mobile: 2-state (normal → expanded) with instant single tap
- Implement fixed 15-line height for web/mobile (360px, capped)
- Add orientation-aware height calculation (landscape vs portrait)
- Remove tooltip on web/mobile, keep for Electron desktop
- Add responsive placeholder text to prevent overlap on mobile
  - Desktop: "Type your message, @file, @agent, or paste images and text..."
  - Mobile (≤640px): "Type message, @file, @agent..."
- Delete dev-docs/expand-chat-input.md per upstream feedback

Addresses PR feedback to simplify from 3-state to 2-state for web/mobile
while maintaining rich desktop experience in Electron app.
This commit is contained in:
bizzkoot
2026-01-12 20:40:19 +08:00
parent 296d07a0d6
commit 2e56a5e9f4
5 changed files with 214 additions and 660 deletions

View File

@@ -1,9 +1,10 @@
import { createSignal, Show } from "solid-js"
import { Maximize2, Minimize2 } from "lucide-solid"
import { isElectronHost } from "../lib/runtime-env"
interface ExpandButtonProps {
expandState: () => "normal" | "fifty" | "eighty"
onToggleExpand: (nextState: "normal" | "fifty" | "eighty") => void
expandState: () => "normal" | "fifty" | "eighty" | "expanded"
onToggleExpand: (nextState: "normal" | "fifty" | "eighty" | "expanded") => void
}
export default function ExpandButton(props: ExpandButtonProps) {
@@ -11,7 +12,23 @@ export default function ExpandButton(props: ExpandButtonProps) {
const [clickTimer, setClickTimer] = createSignal<number | null>(null)
const DOUBLE_CLICK_THRESHOLD = 300
// Check if we're in Electron (desktop app with 3-state support)
const isDesktopApp = isElectronHost()
function handleClick() {
const current = props.expandState()
if (!isDesktopApp) {
// Web/Mobile: Simple 2-state toggle (instant, no delay)
if (current === "normal") {
props.onToggleExpand("expanded")
} else {
props.onToggleExpand("normal")
}
return
}
// Electron: 3-state with double-click detection
const now = Date.now()
const lastClick = clickTime()
const isDoubleClick = now - lastClick < DOUBLE_CLICK_THRESHOLD
@@ -23,8 +40,6 @@ export default function ExpandButton(props: ExpandButtonProps) {
setClickTimer(null)
}
const current = props.expandState()
if (isDoubleClick) {
// Double click behavior - execute immediately
if (current === "normal") {
@@ -55,6 +70,11 @@ export default function ExpandButton(props: ExpandButtonProps) {
}
const getTooltip = () => {
// No tooltip for web/mobile - only Electron gets tooltips
if (!isDesktopApp) {
return undefined
}
const current = props.expandState()
if (current === "normal") {
return "Click to expand (50%) • Double-click to expand further (80%)"
@@ -65,17 +85,22 @@ export default function ExpandButton(props: ExpandButtonProps) {
}
}
const isExpanded = () => {
const state = props.expandState()
return state !== "normal"
}
return (
<button
type="button"
class="prompt-expand-button"
class={`prompt-expand-button ${isDesktopApp ? "desktop-mode" : "web-mode"}`}
onClick={handleClick}
disabled={false}
aria-label="Toggle chat input height"
data-tooltip={getTooltip()}
>
<Show
when={props.expandState() === "normal"}
when={!isExpanded()}
fallback={<Minimize2 class="h-5 w-5" aria-hidden="true" />}
>
<Maximize2 class="h-5 w-5" aria-hidden="true" />

View File

@@ -2,6 +2,7 @@ import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack,
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button"
import { isElectronHost } from "../lib/runtime-env"
import { addToHistory, getHistory } from "../stores/message-history"
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
@@ -47,40 +48,87 @@ export default function PromptInput(props: PromptInputProps) {
const [pasteCount, setPasteCount] = createSignal(0)
const [imageCount, setImageCount] = createSignal(0)
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
const [expandState, setExpandState] = createSignal<"normal" | "fifty" | "eighty">("normal")
const [expandState, setExpandState] = createSignal<"normal" | "fifty" | "eighty" | "expanded">("normal")
const SELECTION_INSERT_MAX_LENGTH = 2000
let textareaRef: HTMLTextAreaElement | undefined
let containerRef: HTMLDivElement | undefined
const calculateExpandedHeight = () => {
if (!containerRef) {
return 0
}
// Check if we're in Electron (desktop app with 3-state support)
const isDesktopApp = isElectronHost()
const root = containerRef.closest(".session-view")
if (!root) {
return 0
}
const rootRect = root.getBoundingClientRect()
// Fixed line height for web/mobile expanded state (15 lines as suggested)
const EXPANDED_LINES = 15
const LINE_HEIGHT = 24
const FIXED_EXPANDED_HEIGHT = EXPANDED_LINES * LINE_HEIGHT // 360px
// Reserve minimum space for message section (200px minimum)
const MIN_MESSAGE_SPACE = 200
const availableForInput = rootRect.height - MIN_MESSAGE_SPACE
const calculateExpandedHeight = () => {
if (!containerRef) {
return 0
}
return availableForInput
}
const root = containerRef.closest(".session-view")
if (!root) {
return 0
}
const rootRect = root.getBoundingClientRect()
const expandedHeight = createMemo(() => {
const state = expandState()
if (state === "normal") return "auto"
// Reserve minimum space for message section
// Use larger reserve for landscape orientation (less vertical space)
const isLandscape = typeof window !== "undefined" && window.innerWidth > window.innerHeight
const MIN_MESSAGE_SPACE = isLandscape ? 150 : 200
const availableForInput = rootRect.height - MIN_MESSAGE_SPACE
const availableHeight = calculateExpandedHeight()
return availableForInput
}
if (state === "fifty") {
return `${availableHeight * 0.5}px`
}
return `${availableHeight * 0.8}px`
})
const expandedHeight = createMemo(() => {
const state = expandState()
if (state === "normal") return "auto"
const availableHeight = calculateExpandedHeight()
if (isDesktopApp) {
// Electron: Use percentage-based heights (50% / 80%)
if (state === "fifty") {
return `${availableHeight * 0.5}px`
}
// state === "eighty"
return `${availableHeight * 0.8}px`
} else {
// Web/Mobile: Use fixed height, but cap at available space
// This prevents overflow in landscape or small screens
const maxHeight = Math.min(FIXED_EXPANDED_HEIGHT, availableHeight * 0.6)
return `${Math.max(maxHeight, 150)}px` // Minimum 150px to be useful
}
})
// Responsive placeholder text - shorter on mobile to avoid overlap with expand button
const [isMobileWidth, setIsMobileWidth] = createSignal(false)
const updateMobileWidth = () => {
if (typeof window !== "undefined") {
setIsMobileWidth(window.innerWidth <= 640)
}
}
onMount(() => {
updateMobileWidth()
window.addEventListener("resize", updateMobileWidth)
onCleanup(() => {
window.removeEventListener("resize", updateMobileWidth)
})
})
const getPlaceholder = () => {
if (mode() === "shell") {
return "Run a shell command (Esc to exit)..."
}
// Use shorter placeholder on mobile to prevent overlap with expand button
if (isMobileWidth()) {
return "Type message, @file, @agent..."
}
return "Type your message, @file, @agent, or paste images and text..."
}
@@ -647,7 +695,7 @@ export default function PromptInput(props: PromptInputProps) {
// Record attempted slash commands even if execution fails.
void refreshHistory()
}
try {
if (isShellMode) {
if (props.onRunShell) {
@@ -674,7 +722,7 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef?.focus()
}
}
function focusTextareaEnd() {
if (!textareaRef) return
setTimeout(() => {
@@ -684,7 +732,7 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef.focus()
}, 0)
}
function canUseHistory(force = false) {
if (force) return true
if (showPicker()) return false
@@ -692,29 +740,29 @@ export default function PromptInput(props: PromptInputProps) {
if (!textarea) return false
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
}
function selectPreviousHistory(force = false) {
const entries = history()
if (entries.length === 0) return false
if (!canUseHistory(force)) return false
if (historyIndex() === -1) {
setHistoryDraft(prompt())
}
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
setHistoryIndex(newIndex)
setPrompt(entries[newIndex])
focusTextareaEnd()
return true
}
function selectNextHistory(force = false) {
const entries = history()
if (entries.length === 0) return false
if (!canUseHistory(force)) return false
if (historyIndex() === -1) return false
const newIndex = historyIndex() - 1
if (newIndex >= 0) {
setHistoryIndex(newIndex)
@@ -728,18 +776,18 @@ export default function PromptInput(props: PromptInputProps) {
focusTextareaEnd()
return true
}
function handleAbort() {
if (!props.onAbortSession || !props.isSessionBusy) return
void props.onAbortSession()
}
function handleExpandToggle(nextState: "normal" | "fifty" | "eighty") {
setExpandState(nextState)
// Keep focus on textarea
textareaRef?.focus()
}
function handleExpandToggle(nextState: "normal" | "fifty" | "eighty" | "expanded") {
setExpandState(nextState)
// Keep focus on textarea
textareaRef?.focus()
}
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement
@@ -803,9 +851,9 @@ export default function PromptInput(props: PromptInputProps) {
item:
| { type: "agent"; agent: Agent }
| {
type: "file"
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
}
type: "file"
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
}
| { type: "command"; command: SDKCommand },
) {
if (item.type === "command") {
@@ -1056,18 +1104,18 @@ export default function PromptInput(props: PromptInputProps) {
}
const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession)
const hasHistory = () => history().length > 0
const canHistoryGoPrevious = () => hasHistory() && (historyIndex() === -1 || historyIndex() < history().length - 1)
const canHistoryGoNext = () => historyIndex() >= 0
const canSend = () => {
if (props.disabled) return false
const hasText = prompt().trim().length > 0
if (mode() === "shell") return hasText
return hasText || attachments().length > 0
}
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "Shell mode" })
const commandHint = () => ({ key: "/", text: "Commands" })
@@ -1199,7 +1247,7 @@ export default function PromptInput(props: PromptInputProps) {
</For>
</div>
</Show>
<div
<div
class="prompt-input-field-container"
style={{
"height": expandedHeight(),
@@ -1208,100 +1256,96 @@ export default function PromptInput(props: PromptInputProps) {
>
<div class="prompt-input-field">
<textarea
ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
placeholder={
mode() === "shell"
? "Run a shell command (Esc to exit)..."
: "Type your message, @file, @agent, or paste images and text..."
}
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={props.disabled}
rows={4}
style={{
"padding-top": attachments().length > 0 ? "8px" : "0",
"overflow-y": expandState() !== "normal" ? "auto" : "visible",
}}
spellcheck={false}
autocorrect="off"
autoCapitalize="off"
autocomplete="off"
/>
<Show when={hasHistory()}>
<div class="prompt-history-top">
<button
type="button"
class="prompt-history-button"
onClick={() => selectPreviousHistory(true)}
disabled={!canHistoryGoPrevious()}
aria-label="Previous prompt"
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
</div>
<div class="prompt-history-bottom">
<button
type="button"
class="prompt-history-button"
onClick={() => selectNextHistory(true)}
disabled={!canHistoryGoNext()}
aria-label="Next prompt"
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</div>
</Show>
<div class="prompt-expand-top">
<ExpandButton
expandState={expandState}
onToggleExpand={handleExpandToggle}
ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
placeholder={getPlaceholder()}
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={props.disabled}
rows={4}
style={{
"padding-top": attachments().length > 0 ? "8px" : "0",
"overflow-y": expandState() !== "normal" ? "auto" : "visible",
}}
spellcheck={false}
autocorrect="off"
autoCapitalize="off"
autocomplete="off"
/>
</div>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={
<>
<span class="prompt-overlay-text">
<Kbd>Enter</Kbd> New line <Kbd shortcut="cmd+enter" /> Send <Kbd>@</Kbd> Files/agents <Kbd></Kbd> History
</span>
<Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span>
</Show>
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() !== "shell"}>
<Show when={hasHistory()}>
<div class="prompt-history-top">
<button
type="button"
class="prompt-history-button"
onClick={() => selectPreviousHistory(true)}
disabled={!canHistoryGoPrevious()}
aria-label="Previous prompt"
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
</div>
<div class="prompt-history-bottom">
<button
type="button"
class="prompt-history-button"
onClick={() => selectNextHistory(true)}
disabled={!canHistoryGoNext()}
aria-label="Next prompt"
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</div>
</Show>
<div class="prompt-expand-top">
<ExpandButton
expandState={expandState}
onToggleExpand={handleExpandToggle}
/>
</div>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={
<>
<span class="prompt-overlay-text">
<Kbd>{commandHint().key}</Kbd> {commandHint().text}
<Kbd>Enter</Kbd> New line <Kbd shortcut="cmd+enter" /> Send <Kbd>@</Kbd> Files/agents <Kbd></Kbd> History
</span>
</Show>
<Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span>
</Show>
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() !== "shell"}>
<span class="prompt-overlay-text">
<Kbd>{commandHint().key}</Kbd> {commandHint().text}
</span>
</Show>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
}
>
<>
<span class="prompt-overlay-text prompt-overlay-warning">
Press <Kbd>Esc</Kbd> again to abort session
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
}
>
<>
<span class="prompt-overlay-text prompt-overlay-warning">
Press <Kbd>Esc</Kbd> again to abort session
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
</Show>
</div>
</Show>
</Show>
</div>
</Show>
</div>
</div>
</div>
</div>
<div class="prompt-input-actions">
<button