From ceff73b5eefc56d617b48e26718f65814abb5443 Mon Sep 17 00:00:00 2001 From: liuxuezhuo Date: Mon, 14 Sep 2026 09:18:03 +0800 Subject: [PATCH] feat(chat): let the composer be dragged taller Long prompts kept clipping at the default box height. Bring back the drag-to-resize composer from the reverted #56 in isolation: grab the grip above the input to set a height, ArrowUp/ArrowDown to step it, and double-click or Home to return to the automatic height. --- CHANGELOG.md | 7 + src/features/chat/chat-view.ts | 3 + src/features/chat/tabs/tab-dom.ts | 2 + src/features/chat/tabs/tab-lifecycle.ts | 2 + src/features/chat/tabs/tab.ts | 8 + src/features/chat/tabs/types.ts | 3 + src/features/chat/ui/composer-resize.ts | 154 ++++++++++++++++++ src/i18n/locales/de.json | 3 +- src/i18n/locales/en.json | 3 +- src/i18n/locales/es.json | 3 +- src/i18n/locales/fr.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/ko.json | 3 +- src/i18n/locales/pt.json | 3 +- src/i18n/locales/ru.json | 3 +- src/i18n/locales/zh-CN.json | 3 +- src/i18n/locales/zh-TW.json | 3 +- src/i18n/types.ts | 1 + src/style/components/composer.css | 21 +++ src/style/components/input.css | 48 ++++++ .../features/chat/ui/composer-resize.test.ts | 94 +++++++++++ tests/unit/i18n/locales.test.ts | 1 + 22 files changed, 364 insertions(+), 10 deletions(-) create mode 100644 src/features/chat/ui/composer-resize.ts create mode 100644 tests/unit/features/chat/ui/composer-resize.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b893b4..559b93b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ version with its date and start a fresh empty `[Unreleased]` above it. ## [Unreleased] +### Added + +- Draggable composer height: grab the thin grip above the message input to + grow or shrink it between 140 px and three quarters of the chat view. The + grip also answers ArrowUp/ArrowDown for keyboard users, and double-click + or Home returns to the automatic content-driven height. + ## [1.0.8] - 2026-09-12 ### Fixed diff --git a/src/features/chat/chat-view.ts b/src/features/chat/chat-view.ts index c1a4309..2becba5 100644 --- a/src/features/chat/chat-view.ts +++ b/src/features/chat/chat-view.ts @@ -359,6 +359,9 @@ export class QoderianView extends ItemView { } if (this.historyButtonEl) setButtonTooltip(this.historyButtonEl, t('nav.chatHistory')); this.creditsUsageButton?.refreshLocale(); + for (const tab of this.tabManager?.getAllTabs() ?? []) { + tab.ui.composerResize?.refreshLocale(); + } this.updateTabBar(); } diff --git a/src/features/chat/tabs/tab-dom.ts b/src/features/chat/tabs/tab-dom.ts index bb410bb..6d70b73 100644 --- a/src/features/chat/tabs/tab-dom.ts +++ b/src/features/chat/tabs/tab-dom.ts @@ -11,6 +11,7 @@ export function buildTabDOM(contentEl: HTMLElement): TabDOMElements { const queueIndicatorEl = inputContainerEl.createDiv({ cls: 'qoderian-input-queue-row' }); const navRowEl = inputContainerEl.createDiv({ cls: 'qoderian-input-nav-row' }); const inputWrapper = inputContainerEl.createDiv({ cls: 'qoderian-input-wrapper' }); + const composerResizeHandleEl = inputWrapper.createDiv({ cls: 'qoderian-composer-resize-handle' }); const contextRowEl = inputWrapper.createDiv({ cls: 'qoderian-context-row' }); const inputEl = inputWrapper.createEl('textarea', { cls: 'qoderian-input', @@ -30,6 +31,7 @@ export function buildTabDOM(contentEl: HTMLElement): TabDOMElements { inputContainerEl, queueIndicatorEl, inputWrapper, + composerResizeHandleEl, inputEl, navRowEl, contextRowEl, diff --git a/src/features/chat/tabs/tab-lifecycle.ts b/src/features/chat/tabs/tab-lifecycle.ts index 1ee249a..ea36f74 100644 --- a/src/features/chat/tabs/tab-lifecycle.ts +++ b/src/features/chat/tabs/tab-lifecycle.ts @@ -40,6 +40,8 @@ export async function destroyTab(tab: TabData): Promise { tab.ui.fileContextManager?.destroy(); tab.ui.vaultDropController?.destroy(); tab.ui.vaultDropController = null; + tab.ui.composerResize?.destroy(); + tab.ui.composerResize = null; tab.ui.composerBridge?.destroy(); tab.ui.composerBridge = null; tab.ui.modelSelector?.destroy(); diff --git a/src/features/chat/tabs/tab.ts b/src/features/chat/tabs/tab.ts index 28c82cd..fb78bc9 100644 --- a/src/features/chat/tabs/tab.ts +++ b/src/features/chat/tabs/tab.ts @@ -32,6 +32,7 @@ import { ChatState } from '../state/chat-state'; import { BangBashModeManager as BangBashModeManagerClass } from '../ui/bang-bash-mode-manager'; import { ComposerBridge } from '../ui/composer/composer-bridge'; import { ComposerActionButton } from '../ui/composer-action-button'; +import { attachComposerResize } from '../ui/composer-resize'; import { FileContextManager } from '../ui/file-context/file-context-manager'; import { ImageContextManager } from '../ui/image-context'; import { createInputToolbar } from '../ui/input-toolbar'; @@ -151,6 +152,7 @@ export function createTab(options: TabCreateOptions): TabData { }, ui: { composerBridge: null, + composerResize: null, fileContextManager: null, imageContextManager: null, vaultDropController: null, @@ -583,6 +585,12 @@ export function initializeTabUI( ): void { const { dom, state } = tab; + tab.ui.composerResize = attachComposerResize( + dom.inputWrapper, + dom.composerResizeHandleEl, + () => tab.renderer?.scrollToBottomIfNeeded(), + ); + // Initialize context managers (file/image) initializeContextManagers(tab, plugin); diff --git a/src/features/chat/tabs/types.ts b/src/features/chat/tabs/types.ts index b79fec8..0e3b784 100644 --- a/src/features/chat/tabs/types.ts +++ b/src/features/chat/tabs/types.ts @@ -17,6 +17,7 @@ import type { ChatState } from '../state/chat-state'; import type { BangBashModeManager } from '../ui/bang-bash-mode-manager'; import type { ComposerBridge } from '../ui/composer/composer-bridge'; import type { ComposerActionButton } from '../ui/composer-action-button'; +import type { ComposerResizeController } from '../ui/composer-resize'; import type { FileContextManager } from '../ui/file-context/file-context-manager'; import type { ImageContextManager } from '../ui/image-context'; import type { @@ -118,6 +119,7 @@ export interface TabServices { export interface TabUIComponents { /** Bridges the legacy textarea with the CodeMirror live composer. */ composerBridge: ComposerBridge | null; + composerResize: ComposerResizeController | null; fileContextManager: FileContextManager | null; imageContextManager: ImageContextManager | null; vaultDropController: VaultDropController | null; @@ -150,6 +152,7 @@ export interface TabDOMElements { inputContainerEl: HTMLElement; queueIndicatorEl: HTMLElement; inputWrapper: HTMLElement; + composerResizeHandleEl: HTMLElement; inputEl: HTMLTextAreaElement; /** Nav row for tab badges and header icons (above input wrapper). */ diff --git a/src/features/chat/ui/composer-resize.ts b/src/features/chat/ui/composer-resize.ts new file mode 100644 index 0000000..0eda291 --- /dev/null +++ b/src/features/chat/ui/composer-resize.ts @@ -0,0 +1,154 @@ +import { t } from '../../../i18n/i18n'; +import { setButtonTooltip } from '../../../shared/dom/tooltip'; + +export const COMPOSER_MIN_HEIGHT = 140; +export const COMPOSER_MAX_HEIGHT_PERCENT = 0.75; +export const COMPOSER_KEYBOARD_RESIZE_STEP = 24; + +export interface ComposerResizeController { + destroy(): void; + refreshLocale(): void; + reset(): void; +} + +export function calculateComposerMaxHeight(viewHeight: number): number { + return Math.max(COMPOSER_MIN_HEIGHT, Math.floor(viewHeight * COMPOSER_MAX_HEIGHT_PERCENT)); +} + +export function clampComposerHeight(height: number, viewHeight: number): number { + return Math.min( + calculateComposerMaxHeight(viewHeight), + Math.max(COMPOSER_MIN_HEIGHT, Math.round(height)), + ); +} + +/** + * Makes the top edge of the composer draggable. The footer is anchored at the + * bottom of the chat view, so dragging upward increases the input area. + * Double-clicking the handle (or pressing Home) returns to content-driven size. + */ +export function attachComposerResize( + wrapperEl: HTMLElement, + handleEl: HTMLElement, + onResize?: () => void, +): ComposerResizeController { + const doc = handleEl.ownerDocument; + const viewWindow = doc.defaultView; + const containerEl = wrapperEl.closest('.qoderian-container'); + let dragging = false; + let startClientY = 0; + let startHeight = COMPOSER_MIN_HEIGHT; + + const getViewHeight = (): number => { + const containerHeight = containerEl?.clientHeight; + return containerHeight || doc.defaultView?.innerHeight || COMPOSER_MIN_HEIGHT; + }; + + const currentHeight = (): number => { + const measured = wrapperEl.getBoundingClientRect().height || wrapperEl.clientHeight; + return measured || COMPOSER_MIN_HEIGHT; + }; + + const updateAriaBounds = (height: number): void => { + handleEl.setAttribute('aria-valuemin', String(COMPOSER_MIN_HEIGHT)); + handleEl.setAttribute('aria-valuemax', String(calculateComposerMaxHeight(getViewHeight()))); + handleEl.setAttribute('aria-valuenow', String(Math.round(height))); + }; + + const applyHeight = (height: number): void => { + const nextHeight = clampComposerHeight(height, getViewHeight()); + wrapperEl.classList.add('qoderian-input-wrapper--resized'); + wrapperEl.style.setProperty('--qoderian-input-wrapper-height', `${nextHeight}px`); + updateAriaBounds(nextHeight); + onResize?.(); + }; + + const stopDragging = (): void => { + if (!dragging) return; + dragging = false; + doc.removeEventListener('pointermove', handlePointerMove); + doc.removeEventListener('pointerup', stopDragging); + doc.removeEventListener('pointercancel', stopDragging); + doc.body?.classList.remove('qoderian-composer-resizing'); + }; + + const handlePointerMove = (event: PointerEvent): void => { + if (!dragging) return; + applyHeight(startHeight + startClientY - event.clientY); + }; + + const handlePointerDown = (event: PointerEvent): void => { + if (event.button !== 0) return; + event.preventDefault(); + stopDragging(); + dragging = true; + startClientY = event.clientY; + startHeight = currentHeight(); + doc.addEventListener('pointermove', handlePointerMove); + doc.addEventListener('pointerup', stopDragging); + doc.addEventListener('pointercancel', stopDragging); + doc.body?.classList.add('qoderian-composer-resizing'); + }; + + const reset = (): void => { + stopDragging(); + wrapperEl.classList.remove('qoderian-input-wrapper--resized'); + wrapperEl.style.removeProperty('--qoderian-input-wrapper-height'); + updateAriaBounds(currentHeight()); + onResize?.(); + }; + + const handleKeydown = (event: KeyboardEvent): void => { + if (event.key === 'Home') { + event.preventDefault(); + reset(); + return; + } + + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return; + event.preventDefault(); + const delta = event.key === 'ArrowUp' + ? COMPOSER_KEYBOARD_RESIZE_STEP + : -COMPOSER_KEYBOARD_RESIZE_STEP; + applyHeight(currentHeight() + delta); + }; + + const refreshLocale = (): void => { + setButtonTooltip(handleEl, t('composer.resize')); + }; + + handleEl.setAttribute('role', 'separator'); + handleEl.setAttribute('aria-orientation', 'horizontal'); + handleEl.setAttribute('tabindex', '0'); + updateAriaBounds(currentHeight()); + refreshLocale(); + handleEl.addEventListener('pointerdown', handlePointerDown); + handleEl.addEventListener('dblclick', reset); + handleEl.addEventListener('keydown', handleKeydown); + viewWindow?.addEventListener('blur', stopDragging); + + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== 'undefined' && containerEl) { + resizeObserver = new ResizeObserver(() => { + if (wrapperEl.classList.contains('qoderian-input-wrapper--resized')) { + applyHeight(currentHeight()); + } else { + updateAriaBounds(currentHeight()); + } + }); + resizeObserver.observe(containerEl); + } + + return { + destroy: () => { + stopDragging(); + resizeObserver?.disconnect(); + handleEl.removeEventListener('pointerdown', handlePointerDown); + handleEl.removeEventListener('dblclick', reset); + handleEl.removeEventListener('keydown', handleKeydown); + viewWindow?.removeEventListener('blur', stopDragging); + }, + refreshLocale, + reset, + }; +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 54ab8f8..3b23317 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -23,7 +23,8 @@ }, "composer": { "send": "Nachricht senden", - "stop": "Generierung stoppen" + "stop": "Generierung stoppen", + "resize": "Ziehen, um die Eingabehöhe anzupassen; doppelklicken zum Zurücksetzen" }, "restore": { "failed": "Einige Tabs oder Unterhaltungen konnten nicht wiederhergestellt werden ({count} Problem(e)). Details in der Entwicklerkonsole." diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7380d97..46eb06e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -23,7 +23,8 @@ }, "composer": { "send": "Send message", - "stop": "Stop generation" + "stop": "Stop generation", + "resize": "Drag to resize the message input; double-click to reset" }, "restore": { "failed": "Some of your previous tabs or conversations could not be restored ({count} issue(s)). Details are in the developer console." diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 36d21b4..85c5794 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -23,7 +23,8 @@ }, "composer": { "send": "Enviar mensaje", - "stop": "Detener generación" + "stop": "Detener generación", + "resize": "Arrastra para ajustar la altura del campo; haz doble clic para restablecer" }, "restore": { "failed": "No se pudieron restaurar algunas pestañas o conversaciones ({count} problema(s)). Detalles en la consola de desarrollador." diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 51c72c1..555c537 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -23,7 +23,8 @@ }, "composer": { "send": "Envoyer le message", - "stop": "Arrêter la génération" + "stop": "Arrêter la génération", + "resize": "Faites glisser pour redimensionner la saisie ; double-cliquez pour réinitialiser" }, "restore": { "failed": "Certains onglets ou conversations n'ont pas pu être restaurés ({count} problème(s)). Détails dans la console développeur." diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 8b1db00..7ff3022 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -23,7 +23,8 @@ }, "composer": { "send": "メッセージを送信", - "stop": "生成を停止" + "stop": "生成を停止", + "resize": "ドラッグして入力欄の高さを調整。ダブルクリックで自動サイズに戻す" }, "restore": { "failed": "一部のタブまたは会話を復元できませんでした({count} 件の問題)。詳細は開発者コンソールを確認してください。" diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index c8a12da..7c8170a 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -23,7 +23,8 @@ }, "composer": { "send": "메시지 보내기", - "stop": "생성 중지" + "stop": "생성 중지", + "resize": "드래그하여 입력 높이 조절; 두 번 클릭하여 자동 높이로 재설정" }, "restore": { "failed": "이전 탭 또는 대화를 완전히 복원하지 못했습니다(문제 {count}건). 자세한 내용은 개발자 콘솔을 확인하세요." diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 5d50996..58b8df2 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -23,7 +23,8 @@ }, "composer": { "send": "Enviar mensagem", - "stop": "Parar geração" + "stop": "Parar geração", + "resize": "Arraste para ajustar a altura da entrada; clique duas vezes para redefinir" }, "restore": { "failed": "Algumas abas ou conversas não puderam ser restauradas ({count} problema(s)). Detalhes no console do desenvolvedor." diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e636969..19a86b8 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -23,7 +23,8 @@ }, "composer": { "send": "Отправить сообщение", - "stop": "Остановить генерацию" + "stop": "Остановить генерацию", + "resize": "Перетащите, чтобы изменить высоту поля; двойной щелчок — сброс" }, "restore": { "failed": "Не удалось восстановить часть вкладок или бесед (проблем: {count}). Подробности — в консоли разработчика." diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 079e432..0576e07 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -23,7 +23,8 @@ }, "composer": { "send": "发送消息", - "stop": "停止生成" + "stop": "停止生成", + "resize": "拖动调整输入框高度;双击恢复自动高度" }, "restore": { "failed": "部分标签或会话未能恢复({count} 个问题)。详情已输出到开发者控制台。" diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 2c246dc..11823f9 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -23,7 +23,8 @@ }, "composer": { "send": "傳送訊息", - "stop": "停止生成" + "stop": "停止生成", + "resize": "拖曳調整輸入框高度;按兩下恢復自動高度" }, "restore": { "failed": "部分分頁或工作階段未能恢復({count} 個問題)。詳情已輸出到開發者主控台。" diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 54df1b8..de9c61c 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -41,6 +41,7 @@ export type TranslationKey = // Composer - send/stop action button | 'composer.send' | 'composer.stop' + | 'composer.resize' | 'restore.failed' // Chat - Rewind diff --git a/src/style/components/composer.css b/src/style/components/composer.css index 6d34f31..e263f8e 100644 --- a/src/style/components/composer.css +++ b/src/style/components/composer.css @@ -36,6 +36,27 @@ font-family: inherit; line-height: 1.4; overflow-y: auto; + /* Reserve the scrollbar track before the content reaches its cap. This + avoids a wrap-width feedback loop that made long input flicker. */ + scrollbar-gutter: stable; +} + +/* Once the user chooses a height, flex the editor into that stable box and + scroll its content instead of continuing the content-driven growth path. */ +.qoderian-input-wrapper--resized .qoderian-live-composer-host { + min-height: 0; + overflow: hidden; +} + +.qoderian-input-wrapper--resized .qoderian-live-composer.cm-editor { + min-height: 0; + max-height: none; + height: 100%; +} + +.qoderian-input-wrapper--resized textarea.qoderian-input { + min-height: 0; + max-height: none; } .qoderian-input-wrapper .qoderian-live-composer .cm-content { diff --git a/src/style/components/input.css b/src/style/components/input.css index b4f0e2e..688d20f 100644 --- a/src/style/components/input.css +++ b/src/style/components/input.css @@ -29,6 +29,54 @@ box-shadow: var(--qoderian-input-wrapper-box-shadow); } +.qoderian-input-wrapper--resized { + height: var(--qoderian-input-wrapper-height); +} + +/* Dragging this top-edge grip grows the bottom-anchored composer upward. */ +.qoderian-composer-resize-handle { + position: absolute; + z-index: 4; + inset-block-start: -8px; + inset-inline: 0; + height: 16px; + cursor: ns-resize; + touch-action: none; +} + +.qoderian-composer-resize-handle::after { + content: ''; + position: absolute; + inset-block-start: 4px; + inset-inline-start: 50%; + width: clamp(64px, 20%, 192px); + height: 8px; + box-sizing: border-box; + border: 1px solid var(--background-modifier-border); + border-radius: 999px; + background: var(--background-primary); + transform: translateX(-50%); + opacity: 1; + transition: border-color 0.12s ease, background 0.12s ease; +} + +.qoderian-composer-resize-handle:hover::after, +.qoderian-composer-resize-handle:focus-visible::after, +.qoderian-composer-resizing .qoderian-composer-resize-handle::after { + border-color: var(--interactive-accent); + background: var(--background-modifier-hover); +} + +.qoderian-composer-resize-handle:focus-visible { + outline: none; +} + +body.qoderian-composer-resizing, +body.qoderian-composer-resizing * { + cursor: ns-resize; + user-select: none; +} + /* Context row (context chips: files, images, selections) - inside input wrapper at top */ /* Collapsed by default; expanded via .has-content class; textarea fills remaining space */ .qoderian-context-row { diff --git a/tests/unit/features/chat/ui/composer-resize.test.ts b/tests/unit/features/chat/ui/composer-resize.test.ts new file mode 100644 index 0000000..9bcdcd4 --- /dev/null +++ b/tests/unit/features/chat/ui/composer-resize.test.ts @@ -0,0 +1,94 @@ +/** + * @jest-environment jsdom + */ + +import { + attachComposerResize, + calculateComposerMaxHeight, + clampComposerHeight, + COMPOSER_KEYBOARD_RESIZE_STEP, + COMPOSER_MAX_HEIGHT_PERCENT, + COMPOSER_MIN_HEIGHT, +} from '@/features/chat/ui/composer-resize'; + +describe('composerResize', () => { + it('clamps manual height to the available view range', () => { + expect(calculateComposerMaxHeight(800)).toBe(800 * COMPOSER_MAX_HEIGHT_PERCENT); + expect(clampComposerHeight(80, 800)).toBe(COMPOSER_MIN_HEIGHT); + expect(clampComposerHeight(900, 800)).toBe(600); + }); + + it('grows upward while dragging and resets on double-click', () => { + const { container, wrapper, handle } = createHarness(800, 200); + const onResize = jest.fn(); + const controller = attachComposerResize(wrapper, handle, onResize); + + handle.dispatchEvent(new MouseEvent('pointerdown', { + bubbles: true, + button: 0, + clientY: 400, + })); + document.dispatchEvent(new MouseEvent('pointermove', { clientY: 300 })); + document.dispatchEvent(new MouseEvent('pointerup')); + + expect(wrapper.classList.contains('qoderian-input-wrapper--resized')).toBe(true); + expect(wrapper.style.getPropertyValue('--qoderian-input-wrapper-height')).toBe('300px'); + expect(handle.getAttribute('aria-valuenow')).toBe('300'); + expect(document.body.classList.contains('qoderian-composer-resizing')).toBe(false); + expect(onResize).toHaveBeenCalled(); + + handle.dispatchEvent(new MouseEvent('dblclick')); + + expect(wrapper.classList.contains('qoderian-input-wrapper--resized')).toBe(false); + expect(wrapper.style.getPropertyValue('--qoderian-input-wrapper-height')).toBe(''); + + controller.destroy(); + container.remove(); + }); + + it('supports keyboard resizing and Home to restore automatic height', () => { + const { container, wrapper, handle } = createHarness(800, 200); + const controller = attachComposerResize(wrapper, handle); + + handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })); + expect(wrapper.style.getPropertyValue('--qoderian-input-wrapper-height')) + .toBe(`${200 + COMPOSER_KEYBOARD_RESIZE_STEP}px`); + + handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' })); + expect(wrapper.classList.contains('qoderian-input-wrapper--resized')).toBe(false); + + controller.destroy(); + container.remove(); + }); +}); + +function createHarness(viewHeight: number, wrapperHeight: number): { + container: HTMLElement; + wrapper: HTMLElement; + handle: HTMLElement; +} { + const container = document.createElement('div'); + const wrapper = document.createElement('div'); + const handle = document.createElement('div'); + container.classList.add('qoderian-container'); + wrapper.classList.add('qoderian-input-wrapper'); + wrapper.appendChild(handle); + container.appendChild(wrapper); + document.body.appendChild(container); + + Object.defineProperty(container, 'clientHeight', { configurable: true, value: viewHeight }); + Object.defineProperty(wrapper, 'clientHeight', { configurable: true, value: wrapperHeight }); + wrapper.getBoundingClientRect = () => ({ + x: 0, + y: 0, + top: 0, + right: 0, + bottom: wrapperHeight, + left: 0, + width: 0, + height: wrapperHeight, + toJSON: () => {}, + }); + + return { container, wrapper, handle }; +} diff --git a/tests/unit/i18n/locales.test.ts b/tests/unit/i18n/locales.test.ts index acd60c5..dacee25 100644 --- a/tests/unit/i18n/locales.test.ts +++ b/tests/unit/i18n/locales.test.ts @@ -91,6 +91,7 @@ const localizedKeys = [ 'chat.permissionMode.plan.desc', 'chat.permissionMode.changeFailed', 'chat.slashCommand.requiresInteractiveTerminal', + 'composer.resize', ] as const; const staleBangBashDesc =