Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/features/chat/chat-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
2 changes: 2 additions & 0 deletions src/features/chat/tabs/tab-dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -30,6 +31,7 @@ export function buildTabDOM(contentEl: HTMLElement): TabDOMElements {
inputContainerEl,
queueIndicatorEl,
inputWrapper,
composerResizeHandleEl,
inputEl,
navRowEl,
contextRowEl,
Expand Down
2 changes: 2 additions & 0 deletions src/features/chat/tabs/tab-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export async function destroyTab(tab: TabData): Promise<void> {
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();
Expand Down
8 changes: 8 additions & 0 deletions src/features/chat/tabs/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -151,6 +152,7 @@ export function createTab(options: TabCreateOptions): TabData {
},
ui: {
composerBridge: null,
composerResize: null,
fileContextManager: null,
imageContextManager: null,
vaultDropController: null,
Expand Down Expand Up @@ -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);

Expand Down
3 changes: 3 additions & 0 deletions src/features/chat/tabs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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). */
Expand Down
154 changes: 154 additions & 0 deletions src/features/chat/ui/composer-resize.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('.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,
};
}
3 changes: 2 additions & 1 deletion src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
},
"composer": {
"send": "メッセージを送信",
"stop": "生成を停止"
"stop": "生成を停止",
"resize": "ドラッグして入力欄の高さを調整。ダブルクリックで自動サイズに戻す"
},
"restore": {
"failed": "一部のタブまたは会話を復元できませんでした({count} 件の問題)。詳細は開発者コンソールを確認してください。"
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
},
"composer": {
"send": "메시지 보내기",
"stop": "생성 중지"
"stop": "생성 중지",
"resize": "드래그하여 입력 높이 조절; 두 번 클릭하여 자동 높이로 재설정"
},
"restore": {
"failed": "이전 탭 또는 대화를 완전히 복원하지 못했습니다(문제 {count}건). 자세한 내용은 개발자 콘솔을 확인하세요."
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
},
"composer": {
"send": "Отправить сообщение",
"stop": "Остановить генерацию"
"stop": "Остановить генерацию",
"resize": "Перетащите, чтобы изменить высоту поля; двойной щелчок — сброс"
},
"restore": {
"failed": "Не удалось восстановить часть вкладок или бесед (проблем: {count}). Подробности — в консоли разработчика."
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
},
"composer": {
"send": "发送消息",
"stop": "停止生成"
"stop": "停止生成",
"resize": "拖动调整输入框高度;双击恢复自动高度"
},
"restore": {
"failed": "部分标签或会话未能恢复({count} 个问题)。详情已输出到开发者控制台。"
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
},
"composer": {
"send": "傳送訊息",
"stop": "停止生成"
"stop": "停止生成",
"resize": "拖曳調整輸入框高度;按兩下恢復自動高度"
},
"restore": {
"failed": "部分分頁或工作階段未能恢復({count} 個問題)。詳情已輸出到開發者主控台。"
Expand Down
1 change: 1 addition & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type TranslationKey =
// Composer - send/stop action button
| 'composer.send'
| 'composer.stop'
| 'composer.resize'
| 'restore.failed'

// Chat - Rewind
Expand Down
21 changes: 21 additions & 0 deletions src/style/components/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading