Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 93 additions & 2 deletions packages/ui/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ import {
} from "../stores/conversation-speech"
const log = getLogger("actions")
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
const DEFAULT_PROMPT_FIELD_HEIGHT = 104
const MAX_PROMPT_FIELD_HEIGHT_RATIO = 0.6

type ResizeDragState = {
pointerId: number
startY: number
startHeight: number
maxHeight: number
}

function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
if (!text || attachments.length === 0) return []
Expand Down Expand Up @@ -65,11 +74,16 @@ export default function PromptInput(props: PromptInputProps) {
const [, setIsFocused] = createSignal(false)
const [mode, setMode] = createSignal<PromptMode>("normal")
const [expandState, setExpandState] = createSignal<ExpandState>("normal")
const [inputHeight, setInputHeight] = createSignal<number | null>(null)
const [isResizing, setIsResizing] = createSignal(false)
const [isFileBrowserOpen, setIsFileBrowserOpen] = createSignal(false)
const SELECTION_INSERT_MAX_LENGTH = 2000
const MAX_READABLE_PICKED_FILE_BYTES = 5 * 1024 * 1024
let textareaRef: HTMLTextAreaElement | undefined
let fileInputRef: HTMLInputElement | undefined
let wrapperRef: HTMLDivElement | undefined
let fieldContainerRef: HTMLDivElement | undefined
let resizeDragState: ResizeDragState | undefined

const getPlaceholder = () => {
if (mode() === "shell") {
Expand Down Expand Up @@ -216,6 +230,7 @@ export default function PromptInput(props: PromptInputProps) {
draftLoadedNonce,
() => {
// Session switch resets (picker/counters/ignored positions) stay in the component.
setInputHeight(null)
setIgnoredAtPositions(new Set<number>())
setShowPicker(false)
setPickerMode("mention")
Expand Down Expand Up @@ -294,6 +309,61 @@ export default function PromptInput(props: PromptInputProps) {
})
})

function computeMaxFieldHeight(): number {
if (typeof window === "undefined") return DEFAULT_PROMPT_FIELD_HEIGHT

const sessionCenter = wrapperRef?.closest("[data-session-center-width]")
const availableHeight = sessionCenter?.getBoundingClientRect().height ?? window.innerHeight
const maxHeight = Math.floor(availableHeight * MAX_PROMPT_FIELD_HEIGHT_RATIO)
return Math.max(DEFAULT_PROMPT_FIELD_HEIGHT, maxHeight)
}

function handleResizeStart(event: PointerEvent) {
event.preventDefault()
const target = event.currentTarget as HTMLElement

resizeDragState = {
pointerId: event.pointerId,
startY: event.clientY,
startHeight: fieldContainerRef?.getBoundingClientRect().height ?? DEFAULT_PROMPT_FIELD_HEIGHT,
maxHeight: computeMaxFieldHeight(),
}

setIsResizing(true)

try {
target.setPointerCapture(event.pointerId)
} catch {
resizeDragState = undefined
setIsResizing(false)
}
}

function handleResizeMove(event: PointerEvent) {
if (!resizeDragState || resizeDragState.pointerId !== event.pointerId) return

event.preventDefault()
const deltaY = resizeDragState.startY - event.clientY
const nextHeight = Math.max(
DEFAULT_PROMPT_FIELD_HEIGHT,
Math.min(resizeDragState.maxHeight, resizeDragState.startHeight + deltaY),
)
setInputHeight(nextHeight)
}

function handleResizeEnd(event: PointerEvent) {
if (!resizeDragState || resizeDragState.pointerId !== event.pointerId) return

event.preventDefault()
resizeDragState = undefined
setIsResizing(false)
textareaRef?.focus()
}

onCleanup(() => {
resizeDragState = undefined
})

async function handleSend() {
const text = prompt().trim()
const currentAttachments = attachments()
Expand Down Expand Up @@ -324,6 +394,7 @@ export default function PromptInput(props: PromptInputProps) {
const refreshHistory = () => recordHistoryEntry(historyEntry)

setExpandState("normal")
setInputHeight(null)
clearPrompt()
clearHistoryDraft()
setMode("normal")
Expand Down Expand Up @@ -382,6 +453,7 @@ export default function PromptInput(props: PromptInputProps) {
}

function handleExpandToggle(nextState: "normal" | "expanded") {
setInputHeight(null)
setExpandState(nextState)
// Keep focus on textarea
textareaRef?.focus()
Expand Down Expand Up @@ -599,6 +671,7 @@ export default function PromptInput(props: PromptInputProps) {
return (
<div class="prompt-input-container">
<div
ref={wrapperRef}
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
style={
isDragging()
Expand Down Expand Up @@ -631,9 +704,26 @@ export default function PromptInput(props: PromptInputProps) {
</Show>

<div class="prompt-input-main flex flex-1 flex-col">
<div class={`prompt-input-field-container ${expandState() === "expanded" ? "is-expanded" : ""}`}>
<div
ref={fieldContainerRef}
class={`prompt-input-field-container ${expandState() === "expanded" ? "is-expanded" : ""} ${inputHeight() !== null ? "is-resized" : ""}`}
style={inputHeight() !== null ? { height: `${inputHeight()}px`, "min-height": `${inputHeight()}px` } : undefined}
>
<div
class={`prompt-resize-handle ${isResizing() ? "is-resizing" : ""}`}
onPointerDown={handleResizeStart}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
onPointerCancel={handleResizeEnd}
aria-hidden="true"
role="presentation"
title={t("promptInput.resizeHandle.title")}
/>

<div class={`prompt-input-field ${expandState() === "expanded" ? "is-expanded" : ""}`}>
<div
class={`prompt-input-field ${expandState() === "expanded" ? "is-expanded" : ""}`}
style={inputHeight() !== null ? { height: `${inputHeight()}px`, "min-height": `${inputHeight()}px` } : undefined}
>
<textarea
ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
Expand All @@ -651,6 +741,7 @@ export default function PromptInput(props: PromptInputProps) {
autocorrect="off"
autoCapitalize="off"
autocomplete="off"
style={inputHeight() !== null ? { height: `${inputHeight()}px`, "min-height": `${inputHeight()}px`, "overflow-y": "auto" } : undefined}
/>
<button
type="button"
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/en/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,5 @@ export const messagingMessages = {
"promptInput.voiceInput.error.permissionDenied": "Microphone access was denied by macOS.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
"promptInput.resizeHandle.title": "Drag to resize input height",
} as const
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/es/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export const messagingMessages = {
"promptInput.hints.commands": "Comandos",
"promptInput.history.previousAriaLabel": "Prompt anterior",
"promptInput.history.nextAriaLabel": "Siguiente prompt",
"promptInput.resizeHandle.title": "Arrastra para cambiar la altura del campo de entrada",
"promptInput.overlay.newLine": "Nueva línea",
"promptInput.overlay.send": "Enviar",
"promptInput.overlay.filesAgents": "Archivos/agentes",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/fr/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export const messagingMessages = {
"promptInput.hints.commands": "Commandes",
"promptInput.history.previousAriaLabel": "Prompt précédent",
"promptInput.history.nextAriaLabel": "Prompt suivant",
"promptInput.resizeHandle.title": "Faites glisser pour redimensionner la hauteur de saisie",
"promptInput.overlay.newLine": "Nouvelle ligne",
"promptInput.overlay.send": "Envoyer",
"promptInput.overlay.filesAgents": "Fichiers/agents",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/he/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export const messagingMessages = {
"promptInput.hints.commands": "פקודות",
"promptInput.history.previousAriaLabel": "פקודה קודמת",
"promptInput.history.nextAriaLabel": "פקודה הבאה",
"promptInput.resizeHandle.title": "גרור כדי לשנות את גובה שדה הקלט",
"promptInput.overlay.newLine": "שורה חדשה",
"promptInput.overlay.send": "שלח",
"promptInput.overlay.filesAgents": "קבצים/סוכנים",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/ja/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export const messagingMessages = {
"promptInput.hints.commands": "コマンド",
"promptInput.history.previousAriaLabel": "前のプロンプト",
"promptInput.history.nextAriaLabel": "次のプロンプト",
"promptInput.resizeHandle.title": "ドラッグして入力欄の高さを変更",
"promptInput.overlay.newLine": "改行",
"promptInput.overlay.send": "送信",
"promptInput.overlay.filesAgents": "ファイル/エージェント",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/ru/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export const messagingMessages = {
"promptInput.hints.commands": "Команды",
"promptInput.history.previousAriaLabel": "Предыдущий prompt",
"promptInput.history.nextAriaLabel": "Следующий prompt",
"promptInput.resizeHandle.title": "Перетащите, чтобы изменить высоту поля ввода",
"promptInput.overlay.newLine": "Новая строка",
"promptInput.overlay.send": "Отправить",
"promptInput.overlay.filesAgents": "Файлы/агенты",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,5 @@ export const messagingMessages = {
"promptInput.voiceInput.error.permissionDenied": "macOS 已拒绝麦克风访问。",
"promptInput.voiceInput.error.unsupported": "此浏览器不支持语音输入。",
"promptInput.voiceInput.error.transcribe": "无法转写录制的音频。",
"promptInput.resizeHandle.title": "拖动以调整输入框高度",
} as const
51 changes: 51 additions & 0 deletions packages/ui/src/styles/messaging/prompt-input.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,47 @@
background-color: var(--surface-base);
}

.prompt-resize-handle {
position: absolute;
top: 0;
left: 50%;
width: 72px;
height: 10px;
transform: translateX(-50%);
cursor: ns-resize;
touch-action: none;
z-index: 10;
background: transparent;
}

.prompt-resize-handle::after {
content: "";
position: absolute;
top: 2px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 2px;
border-radius: 1px;
background-color: var(--border-base);
opacity: 0.5;
transition:
opacity 0.15s ease,
background-color 0.15s ease;
}

.prompt-resize-handle:hover::after,
.prompt-resize-handle.is-resizing::after {
opacity: 1;
background-color: var(--accent-primary);
}

@media (pointer: coarse) {
.prompt-resize-handle {
display: none;
}
}

.prompt-input-wrapper {
--prompt-input-compact-height: 104px;
@apply grid items-stretch;
Expand Down Expand Up @@ -79,6 +120,16 @@
overflow-y: auto;
}

.prompt-input-field-container.is-resized .prompt-input-field {
height: 100%;
}

.prompt-input-field-container.is-resized .prompt-input {
height: 100%;
min-height: 100%;
overflow-y: auto;
}

.prompt-input-overlay {
position: absolute;
bottom: 1rem;
Expand Down