Merge pull request #62 from bizzkoot/feat/expand-chat-input
feat: Implement expandable chat input with double-click detection and gradient tooltip
This commit is contained in:
@@ -3,6 +3,6 @@
|
|||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.1.10"
|
"@opencode-ai/plugin": "1.1.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
packages/ui/src/components/expand-button.tsx
Normal file
30
packages/ui/src/components/expand-button.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Show } from "solid-js"
|
||||||
|
import { Maximize2, Minimize2 } from "lucide-solid"
|
||||||
|
|
||||||
|
interface ExpandButtonProps {
|
||||||
|
expandState: () => "normal" | "expanded"
|
||||||
|
onToggleExpand: (nextState: "normal" | "expanded") => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExpandButton(props: ExpandButtonProps) {
|
||||||
|
function handleClick() {
|
||||||
|
const current = props.expandState()
|
||||||
|
props.onToggleExpand(current === "normal" ? "expanded" : "normal")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="prompt-expand-button"
|
||||||
|
onClick={handleClick}
|
||||||
|
aria-label="Toggle chat input height"
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.expandState() === "normal"}
|
||||||
|
fallback={<Minimize2 class="h-4 w-4" aria-hidden="true" />}
|
||||||
|
>
|
||||||
|
<Maximize2 class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js"
|
import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack, createMemo } from "solid-js"
|
||||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
||||||
import UnifiedPicker from "./unified-picker"
|
import UnifiedPicker from "./unified-picker"
|
||||||
|
import ExpandButton from "./expand-button"
|
||||||
import { addToHistory, getHistory } from "../stores/message-history"
|
import { addToHistory, getHistory } from "../stores/message-history"
|
||||||
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
|
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
|
||||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||||
@@ -46,10 +47,56 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const [pasteCount, setPasteCount] = createSignal(0)
|
const [pasteCount, setPasteCount] = createSignal(0)
|
||||||
const [imageCount, setImageCount] = createSignal(0)
|
const [imageCount, setImageCount] = createSignal(0)
|
||||||
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
|
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
|
||||||
|
const [expandState, setExpandState] = createSignal<"normal" | "expanded">("normal")
|
||||||
const SELECTION_INSERT_MAX_LENGTH = 2000
|
const SELECTION_INSERT_MAX_LENGTH = 2000
|
||||||
let textareaRef: HTMLTextAreaElement | undefined
|
let textareaRef: HTMLTextAreaElement | undefined
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
// Fixed line height for expanded state (15 lines as suggested by dev)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
const calculateExpandedHeight = () => {
|
||||||
|
if (!containerRef) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = containerRef.closest(".session-view")
|
||||||
|
if (!root) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const rootRect = root.getBoundingClientRect()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
return availableForInput
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedHeight = createMemo(() => {
|
||||||
|
const state = expandState()
|
||||||
|
if (state === "normal") return "auto"
|
||||||
|
|
||||||
|
// Use fixed height, but cap at available space
|
||||||
|
// This prevents overflow in landscape or small screens
|
||||||
|
const availableHeight = calculateExpandedHeight()
|
||||||
|
const maxHeight = Math.min(FIXED_EXPANDED_HEIGHT, availableHeight * 0.6)
|
||||||
|
return `${Math.max(maxHeight, 150)}px` // Minimum 150px to be useful
|
||||||
|
})
|
||||||
|
|
||||||
|
const getPlaceholder = () => {
|
||||||
|
if (mode() === "shell") {
|
||||||
|
return "Run a shell command (Esc to exit)..."
|
||||||
|
}
|
||||||
|
return "Type your message, @file, @agent, or paste images and text..."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -702,6 +749,12 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
void props.onAbortSession()
|
void props.onAbortSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleExpandToggle(nextState: "normal" | "expanded") {
|
||||||
|
setExpandState(nextState)
|
||||||
|
// Keep focus on textarea
|
||||||
|
textareaRef?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
function handleInput(e: Event) {
|
function handleInput(e: Event) {
|
||||||
|
|
||||||
const target = e.target as HTMLTextAreaElement
|
const target = e.target as HTMLTextAreaElement
|
||||||
@@ -765,9 +818,9 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
item:
|
item:
|
||||||
| { type: "agent"; agent: Agent }
|
| { type: "agent"; agent: Agent }
|
||||||
| {
|
| {
|
||||||
type: "file"
|
type: "file"
|
||||||
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
|
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
|
||||||
}
|
}
|
||||||
| { type: "command"; command: SDKCommand },
|
| { type: "command"; command: SDKCommand },
|
||||||
) {
|
) {
|
||||||
if (item.type === "command") {
|
if (item.type === "command") {
|
||||||
@@ -1161,94 +1214,101 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="prompt-input-field-container">
|
<div
|
||||||
|
class="prompt-input-field-container"
|
||||||
|
style={{
|
||||||
|
"height": expandedHeight(),
|
||||||
|
"transition": "height 0.25s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="prompt-input-field">
|
<div class="prompt-input-field">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||||
placeholder={
|
placeholder={getPlaceholder()}
|
||||||
mode() === "shell"
|
value={prompt()}
|
||||||
? "Run a shell command (Esc to exit)..."
|
onInput={handleInput}
|
||||||
: "Type your message, @file, @agent, or paste images and text..."
|
onKeyDown={handleKeyDown}
|
||||||
}
|
onPaste={handlePaste}
|
||||||
value={prompt()}
|
onFocus={() => setIsFocused(true)}
|
||||||
onInput={handleInput}
|
onBlur={() => setIsFocused(false)}
|
||||||
onKeyDown={handleKeyDown}
|
disabled={props.disabled}
|
||||||
onPaste={handlePaste}
|
rows={4}
|
||||||
onFocus={() => setIsFocused(true)}
|
style={{
|
||||||
onBlur={() => setIsFocused(false)}
|
"padding-top": attachments().length > 0 ? "8px" : "0",
|
||||||
disabled={props.disabled}
|
"overflow-y": expandState() !== "normal" ? "auto" : "visible",
|
||||||
rows={4}
|
}}
|
||||||
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
spellcheck={false}
|
||||||
spellcheck={false}
|
autocorrect="off"
|
||||||
autocorrect="off"
|
autoCapitalize="off"
|
||||||
autoCapitalize="off"
|
autocomplete="off"
|
||||||
autocomplete="off"
|
/>
|
||||||
/>
|
<div class="prompt-nav-buttons">
|
||||||
<Show when={hasHistory()}>
|
<ExpandButton
|
||||||
<div class="prompt-history-top">
|
expandState={expandState}
|
||||||
<button
|
onToggleExpand={handleExpandToggle}
|
||||||
type="button"
|
/>
|
||||||
class="prompt-history-button"
|
<Show when={hasHistory()}>
|
||||||
onClick={() => selectPreviousHistory(true)}
|
<button
|
||||||
disabled={!canHistoryGoPrevious()}
|
type="button"
|
||||||
aria-label="Previous prompt"
|
class="prompt-history-button"
|
||||||
>
|
onClick={() => selectPreviousHistory(true)}
|
||||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
disabled={!canHistoryGoPrevious()}
|
||||||
</button>
|
aria-label="Previous prompt"
|
||||||
|
>
|
||||||
|
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="prompt-history-bottom">
|
<Show when={shouldShowOverlay()}>
|
||||||
<button
|
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||||
type="button"
|
<Show
|
||||||
class="prompt-history-button"
|
when={props.escapeInDebounce}
|
||||||
onClick={() => selectNextHistory(true)}
|
fallback={
|
||||||
disabled={!canHistoryGoNext()}
|
<>
|
||||||
aria-label="Next prompt"
|
|
||||||
>
|
|
||||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<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"}>
|
|
||||||
<span class="prompt-overlay-text">
|
<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>
|
</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"}>
|
<Show when={mode() === "shell"}>
|
||||||
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
||||||
</Show>
|
</Show>
|
||||||
</>
|
</>
|
||||||
}
|
</Show>
|
||||||
>
|
</div>
|
||||||
<>
|
</Show>
|
||||||
<span class="prompt-overlay-text prompt-overlay-warning">
|
</div>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="prompt-input-actions">
|
<div class="prompt-input-actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-actions {
|
.prompt-input-actions {
|
||||||
@apply flex flex-col items-center justify-between;
|
@apply flex flex-col items-center;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0.5rem 0.25rem;
|
padding: 0.5rem 0.25rem;
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
transition: height 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-field {
|
.prompt-input-field {
|
||||||
@@ -36,18 +37,18 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input {
|
.prompt-input {
|
||||||
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors;
|
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border-color: var(--border-base);
|
border-color: var(--border-base);
|
||||||
line-height: var(--line-height-normal);
|
line-height: var(--line-height-normal);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.prompt-input-overlay {
|
.prompt-input-overlay {
|
||||||
@@ -69,37 +70,42 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-history-top,
|
/* Navigation buttons container (expand, prev, next) */
|
||||||
.prompt-history-bottom {
|
.prompt-nav-buttons {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.35rem;
|
top: 0.25rem;
|
||||||
|
right: 0.25rem;
|
||||||
|
bottom: 0.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
|
gap: 0.125rem;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-history-top {
|
.prompt-expand-button,
|
||||||
top: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt-history-bottom {
|
|
||||||
bottom: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt-history-button {
|
.prompt-history-button {
|
||||||
@apply w-9 h-9 flex items-center justify-center rounded-md;
|
@apply w-7 h-7 flex items-center justify-center rounded-md;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background-color: rgba(15, 23, 42, 0.04);
|
background-color: rgba(15, 23, 42, 0.04);
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-expand-button:hover:not(:disabled),
|
||||||
.prompt-history-button:hover:not(:disabled) {
|
.prompt-history-button:hover:not(:disabled) {
|
||||||
background-color: var(--surface-secondary);
|
background-color: var(--surface-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-expand-button:active:not(:disabled) {
|
||||||
|
background-color: var(--accent-primary);
|
||||||
|
color: var(--text-inverted);
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-expand-button:disabled,
|
||||||
.prompt-history-button:disabled {
|
.prompt-history-button:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -176,6 +182,7 @@
|
|||||||
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--accent-primary);
|
||||||
color: var(--text-inverted);
|
color: var(--text-inverted);
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-button.shell-mode {
|
.send-button.shell-mode {
|
||||||
|
|||||||
Reference in New Issue
Block a user