Skip to content

Commit 9a6450c

Browse files
jollyxenonshantur
andauthored
feat(ui): add resizable session composer (#439)
## Summary - add a top-edge resize handle so the session composer can be freely stretched upward while keeping the bottom edge anchored - switch the expand/shrink control to track the actual composer height, clamp expansion to the session toolbar boundary, and reset the composer back to its default height after each send - preserve the right-side three-column control layout in narrow and windowed panes so the five utility controls stay outside the input, the stop button rises with the composer, and the send button remains bottom-pinned ## Testing - npm run typecheck --workspace @codenomad/ui - npm run build --workspace @codenomad/ui ## Notes - the build still reports the existing `virtua` JSX warning from `../../node_modules/virtua/lib/solid/index.jsx`; this PR does not introduce that warning - local unstaged changes in `.opencode/package-lock.json`, `.sisyphus/`, and Electron dev-port files were intentionally excluded from this PR because they are unrelated to the composer height feature --------- Co-authored-by: Shantur Rathore <i@shantur.com>
1 parent 1295cfa commit 9a6450c

9 files changed

Lines changed: 151 additions & 2 deletions

File tree

packages/ui/src/components/prompt-input.tsx

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ import {
3131
} from "../stores/conversation-speech"
3232
const log = getLogger("actions")
3333
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
34+
const DEFAULT_PROMPT_FIELD_HEIGHT = 104
35+
const MAX_PROMPT_FIELD_HEIGHT_RATIO = 0.6
36+
37+
type ResizeDragState = {
38+
pointerId: number
39+
startY: number
40+
startHeight: number
41+
maxHeight: number
42+
}
3443

3544
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
3645
if (!text || attachments.length === 0) return []
@@ -65,11 +74,16 @@ export default function PromptInput(props: PromptInputProps) {
6574
const [, setIsFocused] = createSignal(false)
6675
const [mode, setMode] = createSignal<PromptMode>("normal")
6776
const [expandState, setExpandState] = createSignal<ExpandState>("normal")
77+
const [inputHeight, setInputHeight] = createSignal<number | null>(null)
78+
const [isResizing, setIsResizing] = createSignal(false)
6879
const [isFileBrowserOpen, setIsFileBrowserOpen] = createSignal(false)
6980
const SELECTION_INSERT_MAX_LENGTH = 2000
7081
const MAX_READABLE_PICKED_FILE_BYTES = 5 * 1024 * 1024
7182
let textareaRef: HTMLTextAreaElement | undefined
7283
let fileInputRef: HTMLInputElement | undefined
84+
let wrapperRef: HTMLDivElement | undefined
85+
let fieldContainerRef: HTMLDivElement | undefined
86+
let resizeDragState: ResizeDragState | undefined
7387

7488
const getPlaceholder = () => {
7589
if (mode() === "shell") {
@@ -216,6 +230,7 @@ export default function PromptInput(props: PromptInputProps) {
216230
draftLoadedNonce,
217231
() => {
218232
// Session switch resets (picker/counters/ignored positions) stay in the component.
233+
setInputHeight(null)
219234
setIgnoredAtPositions(new Set<number>())
220235
setShowPicker(false)
221236
setPickerMode("mention")
@@ -294,6 +309,61 @@ export default function PromptInput(props: PromptInputProps) {
294309
})
295310
})
296311

312+
function computeMaxFieldHeight(): number {
313+
if (typeof window === "undefined") return DEFAULT_PROMPT_FIELD_HEIGHT
314+
315+
const sessionCenter = wrapperRef?.closest("[data-session-center-width]")
316+
const availableHeight = sessionCenter?.getBoundingClientRect().height ?? window.innerHeight
317+
const maxHeight = Math.floor(availableHeight * MAX_PROMPT_FIELD_HEIGHT_RATIO)
318+
return Math.max(DEFAULT_PROMPT_FIELD_HEIGHT, maxHeight)
319+
}
320+
321+
function handleResizeStart(event: PointerEvent) {
322+
event.preventDefault()
323+
const target = event.currentTarget as HTMLElement
324+
325+
resizeDragState = {
326+
pointerId: event.pointerId,
327+
startY: event.clientY,
328+
startHeight: fieldContainerRef?.getBoundingClientRect().height ?? DEFAULT_PROMPT_FIELD_HEIGHT,
329+
maxHeight: computeMaxFieldHeight(),
330+
}
331+
332+
setIsResizing(true)
333+
334+
try {
335+
target.setPointerCapture(event.pointerId)
336+
} catch {
337+
resizeDragState = undefined
338+
setIsResizing(false)
339+
}
340+
}
341+
342+
function handleResizeMove(event: PointerEvent) {
343+
if (!resizeDragState || resizeDragState.pointerId !== event.pointerId) return
344+
345+
event.preventDefault()
346+
const deltaY = resizeDragState.startY - event.clientY
347+
const nextHeight = Math.max(
348+
DEFAULT_PROMPT_FIELD_HEIGHT,
349+
Math.min(resizeDragState.maxHeight, resizeDragState.startHeight + deltaY),
350+
)
351+
setInputHeight(nextHeight)
352+
}
353+
354+
function handleResizeEnd(event: PointerEvent) {
355+
if (!resizeDragState || resizeDragState.pointerId !== event.pointerId) return
356+
357+
event.preventDefault()
358+
resizeDragState = undefined
359+
setIsResizing(false)
360+
textareaRef?.focus()
361+
}
362+
363+
onCleanup(() => {
364+
resizeDragState = undefined
365+
})
366+
297367
async function handleSend() {
298368
const text = prompt().trim()
299369
const currentAttachments = attachments()
@@ -324,6 +394,7 @@ export default function PromptInput(props: PromptInputProps) {
324394
const refreshHistory = () => recordHistoryEntry(historyEntry)
325395

326396
setExpandState("normal")
397+
setInputHeight(null)
327398
clearPrompt()
328399
clearHistoryDraft()
329400
setMode("normal")
@@ -386,6 +457,7 @@ export default function PromptInput(props: PromptInputProps) {
386457
}
387458

388459
function handleExpandToggle(nextState: "normal" | "expanded") {
460+
setInputHeight(null)
389461
setExpandState(nextState)
390462
// Keep focus on textarea
391463
textareaRef?.focus()
@@ -603,6 +675,7 @@ export default function PromptInput(props: PromptInputProps) {
603675
return (
604676
<div class="prompt-input-container">
605677
<div
678+
ref={wrapperRef}
606679
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
607680
style={
608681
isDragging()
@@ -635,9 +708,26 @@ export default function PromptInput(props: PromptInputProps) {
635708
</Show>
636709

637710
<div class="prompt-input-main flex flex-1 flex-col">
638-
<div class={`prompt-input-field-container ${expandState() === "expanded" ? "is-expanded" : ""}`}>
711+
<div
712+
ref={fieldContainerRef}
713+
class={`prompt-input-field-container ${expandState() === "expanded" ? "is-expanded" : ""} ${inputHeight() !== null ? "is-resized" : ""}`}
714+
style={inputHeight() !== null ? { height: `${inputHeight()}px`, "min-height": `${inputHeight()}px` } : undefined}
715+
>
716+
<div
717+
class={`prompt-resize-handle ${isResizing() ? "is-resizing" : ""}`}
718+
onPointerDown={handleResizeStart}
719+
onPointerMove={handleResizeMove}
720+
onPointerUp={handleResizeEnd}
721+
onPointerCancel={handleResizeEnd}
722+
aria-hidden="true"
723+
role="presentation"
724+
title={t("promptInput.resizeHandle.title")}
725+
/>
639726

640-
<div class={`prompt-input-field ${expandState() === "expanded" ? "is-expanded" : ""}`}>
727+
<div
728+
class={`prompt-input-field ${expandState() === "expanded" ? "is-expanded" : ""}`}
729+
style={inputHeight() !== null ? { height: `${inputHeight()}px`, "min-height": `${inputHeight()}px` } : undefined}
730+
>
641731
<textarea
642732
ref={textareaRef}
643733
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
@@ -655,6 +745,7 @@ export default function PromptInput(props: PromptInputProps) {
655745
autocorrect="off"
656746
autoCapitalize="off"
657747
autocomplete="off"
748+
style={inputHeight() !== null ? { height: `${inputHeight()}px`, "min-height": `${inputHeight()}px`, "overflow-y": "auto" } : undefined}
658749
/>
659750
<button
660751
type="button"

packages/ui/src/lib/i18n/messages/en/messaging.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,5 @@ export const messagingMessages = {
182182
"promptInput.voiceInput.error.permissionDenied": "Microphone access was denied by macOS.",
183183
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
184184
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
185+
"promptInput.resizeHandle.title": "Drag to resize input height",
185186
} as const

packages/ui/src/lib/i18n/messages/es/messaging.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ export const messagingMessages = {
149149
"promptInput.hints.commands": "Comandos",
150150
"promptInput.history.previousAriaLabel": "Prompt anterior",
151151
"promptInput.history.nextAriaLabel": "Siguiente prompt",
152+
"promptInput.resizeHandle.title": "Arrastra para cambiar la altura del campo de entrada",
152153
"promptInput.overlay.newLine": "Nueva línea",
153154
"promptInput.overlay.send": "Enviar",
154155
"promptInput.overlay.filesAgents": "Archivos/agentes",

packages/ui/src/lib/i18n/messages/fr/messaging.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ export const messagingMessages = {
149149
"promptInput.hints.commands": "Commandes",
150150
"promptInput.history.previousAriaLabel": "Prompt précédent",
151151
"promptInput.history.nextAriaLabel": "Prompt suivant",
152+
"promptInput.resizeHandle.title": "Faites glisser pour redimensionner la hauteur de saisie",
152153
"promptInput.overlay.newLine": "Nouvelle ligne",
153154
"promptInput.overlay.send": "Envoyer",
154155
"promptInput.overlay.filesAgents": "Fichiers/agents",

packages/ui/src/lib/i18n/messages/he/messaging.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export const messagingMessages = {
147147
"promptInput.hints.commands": "פקודות",
148148
"promptInput.history.previousAriaLabel": "פקודה קודמת",
149149
"promptInput.history.nextAriaLabel": "פקודה הבאה",
150+
"promptInput.resizeHandle.title": "גרור כדי לשנות את גובה שדה הקלט",
150151
"promptInput.overlay.newLine": "שורה חדשה",
151152
"promptInput.overlay.send": "שלח",
152153
"promptInput.overlay.filesAgents": "קבצים/סוכנים",

packages/ui/src/lib/i18n/messages/ja/messaging.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ export const messagingMessages = {
149149
"promptInput.hints.commands": "コマンド",
150150
"promptInput.history.previousAriaLabel": "前のプロンプト",
151151
"promptInput.history.nextAriaLabel": "次のプロンプト",
152+
"promptInput.resizeHandle.title": "ドラッグして入力欄の高さを変更",
152153
"promptInput.overlay.newLine": "改行",
153154
"promptInput.overlay.send": "送信",
154155
"promptInput.overlay.filesAgents": "ファイル/エージェント",

packages/ui/src/lib/i18n/messages/ru/messaging.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ export const messagingMessages = {
149149
"promptInput.hints.commands": "Команды",
150150
"promptInput.history.previousAriaLabel": "Предыдущий prompt",
151151
"promptInput.history.nextAriaLabel": "Следующий prompt",
152+
"promptInput.resizeHandle.title": "Перетащите, чтобы изменить высоту поля ввода",
152153
"promptInput.overlay.newLine": "Новая строка",
153154
"promptInput.overlay.send": "Отправить",
154155
"promptInput.overlay.filesAgents": "Файлы/агенты",

packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,5 @@ export const messagingMessages = {
184184
"promptInput.voiceInput.error.permissionDenied": "macOS 已拒绝麦克风访问。",
185185
"promptInput.voiceInput.error.unsupported": "此浏览器不支持语音输入。",
186186
"promptInput.voiceInput.error.transcribe": "无法转写录制的音频。",
187+
"promptInput.resizeHandle.title": "拖动以调整输入框高度",
187188
} as const

packages/ui/src/styles/messaging/prompt-input.css

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,47 @@
44
background-color: var(--surface-base);
55
}
66

7+
.prompt-resize-handle {
8+
position: absolute;
9+
top: 0;
10+
left: 50%;
11+
width: 72px;
12+
height: 10px;
13+
transform: translateX(-50%);
14+
cursor: ns-resize;
15+
touch-action: none;
16+
z-index: 10;
17+
background: transparent;
18+
}
19+
20+
.prompt-resize-handle::after {
21+
content: "";
22+
position: absolute;
23+
top: 2px;
24+
left: 50%;
25+
transform: translateX(-50%);
26+
width: 40px;
27+
height: 2px;
28+
border-radius: 1px;
29+
background-color: var(--border-base);
30+
opacity: 0.5;
31+
transition:
32+
opacity 0.15s ease,
33+
background-color 0.15s ease;
34+
}
35+
36+
.prompt-resize-handle:hover::after,
37+
.prompt-resize-handle.is-resizing::after {
38+
opacity: 1;
39+
background-color: var(--accent-primary);
40+
}
41+
42+
@media (pointer: coarse) {
43+
.prompt-resize-handle {
44+
display: none;
45+
}
46+
}
47+
748
.prompt-input-wrapper {
849
--prompt-input-compact-height: 104px;
950
@apply grid items-stretch;
@@ -79,6 +120,16 @@
79120
overflow-y: auto;
80121
}
81122

123+
.prompt-input-field-container.is-resized .prompt-input-field {
124+
height: 100%;
125+
}
126+
127+
.prompt-input-field-container.is-resized .prompt-input {
128+
height: 100%;
129+
min-height: 100%;
130+
overflow-y: auto;
131+
}
132+
82133
.prompt-input-overlay {
83134
position: absolute;
84135
bottom: 1rem;

0 commit comments

Comments
 (0)