Skip to content

Commit b01fc61

Browse files
luoxuanzaoQoder-AI
andauthored
feat(chat): let the composer be dragged taller (#61)
Bring back the drag-to-resize composer from the reverted #56 in isolation: a grip above the message input sets the height (140px to 75% of the chat view), ArrowUp/ArrowDown step it by 24px, and double-click or Home returns to the automatic content-driven height. The grip is a focusable role=separator with aria value bounds and re-clamps on container resize. Co-authored-by: QoderAI (Qwen 3.8 Max) <qoder_ai@qoder.com>
1 parent 20948dc commit b01fc61

22 files changed

Lines changed: 364 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ version with its date and start a fresh empty `[Unreleased]` above it.
1111

1212
## [Unreleased]
1313

14+
### Added
15+
16+
- Draggable composer height: grab the thin grip above the message input to
17+
grow or shrink it between 140 px and three quarters of the chat view. The
18+
grip also answers ArrowUp/ArrowDown for keyboard users, and double-click
19+
or Home returns to the automatic content-driven height.
20+
1421
## [1.0.8] - 2026-09-12
1522

1623
### Fixed

src/features/chat/chat-view.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,9 @@ export class QoderianView extends ItemView {
359359
}
360360
if (this.historyButtonEl) setButtonTooltip(this.historyButtonEl, t('nav.chatHistory'));
361361
this.creditsUsageButton?.refreshLocale();
362+
for (const tab of this.tabManager?.getAllTabs() ?? []) {
363+
tab.ui.composerResize?.refreshLocale();
364+
}
362365
this.updateTabBar();
363366
}
364367

src/features/chat/tabs/tab-dom.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export function buildTabDOM(contentEl: HTMLElement): TabDOMElements {
1111
const queueIndicatorEl = inputContainerEl.createDiv({ cls: 'qoderian-input-queue-row' });
1212
const navRowEl = inputContainerEl.createDiv({ cls: 'qoderian-input-nav-row' });
1313
const inputWrapper = inputContainerEl.createDiv({ cls: 'qoderian-input-wrapper' });
14+
const composerResizeHandleEl = inputWrapper.createDiv({ cls: 'qoderian-composer-resize-handle' });
1415
const contextRowEl = inputWrapper.createDiv({ cls: 'qoderian-context-row' });
1516
const inputEl = inputWrapper.createEl('textarea', {
1617
cls: 'qoderian-input',
@@ -30,6 +31,7 @@ export function buildTabDOM(contentEl: HTMLElement): TabDOMElements {
3031
inputContainerEl,
3132
queueIndicatorEl,
3233
inputWrapper,
34+
composerResizeHandleEl,
3335
inputEl,
3436
navRowEl,
3537
contextRowEl,

src/features/chat/tabs/tab-lifecycle.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export async function destroyTab(tab: TabData): Promise<void> {
4040
tab.ui.fileContextManager?.destroy();
4141
tab.ui.vaultDropController?.destroy();
4242
tab.ui.vaultDropController = null;
43+
tab.ui.composerResize?.destroy();
44+
tab.ui.composerResize = null;
4345
tab.ui.composerBridge?.destroy();
4446
tab.ui.composerBridge = null;
4547
tab.ui.modelSelector?.destroy();

src/features/chat/tabs/tab.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { ChatState } from '../state/chat-state';
3232
import { BangBashModeManager as BangBashModeManagerClass } from '../ui/bang-bash-mode-manager';
3333
import { ComposerBridge } from '../ui/composer/composer-bridge';
3434
import { ComposerActionButton } from '../ui/composer-action-button';
35+
import { attachComposerResize } from '../ui/composer-resize';
3536
import { FileContextManager } from '../ui/file-context/file-context-manager';
3637
import { ImageContextManager } from '../ui/image-context';
3738
import { createInputToolbar } from '../ui/input-toolbar';
@@ -151,6 +152,7 @@ export function createTab(options: TabCreateOptions): TabData {
151152
},
152153
ui: {
153154
composerBridge: null,
155+
composerResize: null,
154156
fileContextManager: null,
155157
imageContextManager: null,
156158
vaultDropController: null,
@@ -583,6 +585,12 @@ export function initializeTabUI(
583585
): void {
584586
const { dom, state } = tab;
585587

588+
tab.ui.composerResize = attachComposerResize(
589+
dom.inputWrapper,
590+
dom.composerResizeHandleEl,
591+
() => tab.renderer?.scrollToBottomIfNeeded(),
592+
);
593+
586594
// Initialize context managers (file/image)
587595
initializeContextManagers(tab, plugin);
588596

src/features/chat/tabs/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { ChatState } from '../state/chat-state';
1717
import type { BangBashModeManager } from '../ui/bang-bash-mode-manager';
1818
import type { ComposerBridge } from '../ui/composer/composer-bridge';
1919
import type { ComposerActionButton } from '../ui/composer-action-button';
20+
import type { ComposerResizeController } from '../ui/composer-resize';
2021
import type { FileContextManager } from '../ui/file-context/file-context-manager';
2122
import type { ImageContextManager } from '../ui/image-context';
2223
import type {
@@ -118,6 +119,7 @@ export interface TabServices {
118119
export interface TabUIComponents {
119120
/** Bridges the legacy textarea with the CodeMirror live composer. */
120121
composerBridge: ComposerBridge | null;
122+
composerResize: ComposerResizeController | null;
121123
fileContextManager: FileContextManager | null;
122124
imageContextManager: ImageContextManager | null;
123125
vaultDropController: VaultDropController | null;
@@ -150,6 +152,7 @@ export interface TabDOMElements {
150152
inputContainerEl: HTMLElement;
151153
queueIndicatorEl: HTMLElement;
152154
inputWrapper: HTMLElement;
155+
composerResizeHandleEl: HTMLElement;
153156
inputEl: HTMLTextAreaElement;
154157

155158
/** Nav row for tab badges and header icons (above input wrapper). */
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { t } from '../../../i18n/i18n';
2+
import { setButtonTooltip } from '../../../shared/dom/tooltip';
3+
4+
export const COMPOSER_MIN_HEIGHT = 140;
5+
export const COMPOSER_MAX_HEIGHT_PERCENT = 0.75;
6+
export const COMPOSER_KEYBOARD_RESIZE_STEP = 24;
7+
8+
export interface ComposerResizeController {
9+
destroy(): void;
10+
refreshLocale(): void;
11+
reset(): void;
12+
}
13+
14+
export function calculateComposerMaxHeight(viewHeight: number): number {
15+
return Math.max(COMPOSER_MIN_HEIGHT, Math.floor(viewHeight * COMPOSER_MAX_HEIGHT_PERCENT));
16+
}
17+
18+
export function clampComposerHeight(height: number, viewHeight: number): number {
19+
return Math.min(
20+
calculateComposerMaxHeight(viewHeight),
21+
Math.max(COMPOSER_MIN_HEIGHT, Math.round(height)),
22+
);
23+
}
24+
25+
/**
26+
* Makes the top edge of the composer draggable. The footer is anchored at the
27+
* bottom of the chat view, so dragging upward increases the input area.
28+
* Double-clicking the handle (or pressing Home) returns to content-driven size.
29+
*/
30+
export function attachComposerResize(
31+
wrapperEl: HTMLElement,
32+
handleEl: HTMLElement,
33+
onResize?: () => void,
34+
): ComposerResizeController {
35+
const doc = handleEl.ownerDocument;
36+
const viewWindow = doc.defaultView;
37+
const containerEl = wrapperEl.closest<HTMLElement>('.qoderian-container');
38+
let dragging = false;
39+
let startClientY = 0;
40+
let startHeight = COMPOSER_MIN_HEIGHT;
41+
42+
const getViewHeight = (): number => {
43+
const containerHeight = containerEl?.clientHeight;
44+
return containerHeight || doc.defaultView?.innerHeight || COMPOSER_MIN_HEIGHT;
45+
};
46+
47+
const currentHeight = (): number => {
48+
const measured = wrapperEl.getBoundingClientRect().height || wrapperEl.clientHeight;
49+
return measured || COMPOSER_MIN_HEIGHT;
50+
};
51+
52+
const updateAriaBounds = (height: number): void => {
53+
handleEl.setAttribute('aria-valuemin', String(COMPOSER_MIN_HEIGHT));
54+
handleEl.setAttribute('aria-valuemax', String(calculateComposerMaxHeight(getViewHeight())));
55+
handleEl.setAttribute('aria-valuenow', String(Math.round(height)));
56+
};
57+
58+
const applyHeight = (height: number): void => {
59+
const nextHeight = clampComposerHeight(height, getViewHeight());
60+
wrapperEl.classList.add('qoderian-input-wrapper--resized');
61+
wrapperEl.style.setProperty('--qoderian-input-wrapper-height', `${nextHeight}px`);
62+
updateAriaBounds(nextHeight);
63+
onResize?.();
64+
};
65+
66+
const stopDragging = (): void => {
67+
if (!dragging) return;
68+
dragging = false;
69+
doc.removeEventListener('pointermove', handlePointerMove);
70+
doc.removeEventListener('pointerup', stopDragging);
71+
doc.removeEventListener('pointercancel', stopDragging);
72+
doc.body?.classList.remove('qoderian-composer-resizing');
73+
};
74+
75+
const handlePointerMove = (event: PointerEvent): void => {
76+
if (!dragging) return;
77+
applyHeight(startHeight + startClientY - event.clientY);
78+
};
79+
80+
const handlePointerDown = (event: PointerEvent): void => {
81+
if (event.button !== 0) return;
82+
event.preventDefault();
83+
stopDragging();
84+
dragging = true;
85+
startClientY = event.clientY;
86+
startHeight = currentHeight();
87+
doc.addEventListener('pointermove', handlePointerMove);
88+
doc.addEventListener('pointerup', stopDragging);
89+
doc.addEventListener('pointercancel', stopDragging);
90+
doc.body?.classList.add('qoderian-composer-resizing');
91+
};
92+
93+
const reset = (): void => {
94+
stopDragging();
95+
wrapperEl.classList.remove('qoderian-input-wrapper--resized');
96+
wrapperEl.style.removeProperty('--qoderian-input-wrapper-height');
97+
updateAriaBounds(currentHeight());
98+
onResize?.();
99+
};
100+
101+
const handleKeydown = (event: KeyboardEvent): void => {
102+
if (event.key === 'Home') {
103+
event.preventDefault();
104+
reset();
105+
return;
106+
}
107+
108+
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
109+
event.preventDefault();
110+
const delta = event.key === 'ArrowUp'
111+
? COMPOSER_KEYBOARD_RESIZE_STEP
112+
: -COMPOSER_KEYBOARD_RESIZE_STEP;
113+
applyHeight(currentHeight() + delta);
114+
};
115+
116+
const refreshLocale = (): void => {
117+
setButtonTooltip(handleEl, t('composer.resize'));
118+
};
119+
120+
handleEl.setAttribute('role', 'separator');
121+
handleEl.setAttribute('aria-orientation', 'horizontal');
122+
handleEl.setAttribute('tabindex', '0');
123+
updateAriaBounds(currentHeight());
124+
refreshLocale();
125+
handleEl.addEventListener('pointerdown', handlePointerDown);
126+
handleEl.addEventListener('dblclick', reset);
127+
handleEl.addEventListener('keydown', handleKeydown);
128+
viewWindow?.addEventListener('blur', stopDragging);
129+
130+
let resizeObserver: ResizeObserver | null = null;
131+
if (typeof ResizeObserver !== 'undefined' && containerEl) {
132+
resizeObserver = new ResizeObserver(() => {
133+
if (wrapperEl.classList.contains('qoderian-input-wrapper--resized')) {
134+
applyHeight(currentHeight());
135+
} else {
136+
updateAriaBounds(currentHeight());
137+
}
138+
});
139+
resizeObserver.observe(containerEl);
140+
}
141+
142+
return {
143+
destroy: () => {
144+
stopDragging();
145+
resizeObserver?.disconnect();
146+
handleEl.removeEventListener('pointerdown', handlePointerDown);
147+
handleEl.removeEventListener('dblclick', reset);
148+
handleEl.removeEventListener('keydown', handleKeydown);
149+
viewWindow?.removeEventListener('blur', stopDragging);
150+
},
151+
refreshLocale,
152+
reset,
153+
};
154+
}

src/i18n/locales/de.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
},
2424
"composer": {
2525
"send": "Nachricht senden",
26-
"stop": "Generierung stoppen"
26+
"stop": "Generierung stoppen",
27+
"resize": "Ziehen, um die Eingabehöhe anzupassen; doppelklicken zum Zurücksetzen"
2728
},
2829
"restore": {
2930
"failed": "Einige Tabs oder Unterhaltungen konnten nicht wiederhergestellt werden ({count} Problem(e)). Details in der Entwicklerkonsole."

src/i18n/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
},
2424
"composer": {
2525
"send": "Send message",
26-
"stop": "Stop generation"
26+
"stop": "Stop generation",
27+
"resize": "Drag to resize the message input; double-click to reset"
2728
},
2829
"restore": {
2930
"failed": "Some of your previous tabs or conversations could not be restored ({count} issue(s)). Details are in the developer console."

src/i18n/locales/es.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
},
2424
"composer": {
2525
"send": "Enviar mensaje",
26-
"stop": "Detener generación"
26+
"stop": "Detener generación",
27+
"resize": "Arrastra para ajustar la altura del campo; haz doble clic para restablecer"
2728
},
2829
"restore": {
2930
"failed": "No se pudieron restaurar algunas pestañas o conversaciones ({count} problema(s)). Detalles en la consola de desarrollador."

0 commit comments

Comments
 (0)