diff --git a/i18n/messages.json b/i18n/messages.json index f3547095e..742b23f62 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -9269,6 +9269,10 @@ "en": "Continue the conversation; Raven will receive your new instruction directly", "zh": "继续对话,Raven 将直接收到你的新指令" }, + "gui.composer.resize": { + "en": "Drag to resize the composer", + "zh": "拖动调整输入框高度" + }, "gui.at.point": { "en": "What this is about", "zh": "本次对话的范围" diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 7c7f124e2..d2f52936e 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -2728,6 +2728,7 @@ export const UI_TEXT: Record> = { 'gui.tasks.copied_answer': 'Answer copied', 'gui.composer.ph_session': 'Continue the conversation...', 'gui.composer.ph_busy': 'Continue the conversation; Raven will receive your new instruction directly', + 'gui.composer.resize': 'Drag to resize the composer', 'gui.at.point': 'What this is about', 'gui.at.folder': 'Folder', 'gui.at.bases': 'Knowledge bases', @@ -6088,6 +6089,7 @@ export const UI_TEXT: Record> = { 'gui.tasks.copied_answer': '已复制这次的回答', 'gui.composer.ph_session': '继续对话…', 'gui.composer.ph_busy': '继续对话,Raven 将直接收到你的新指令', + 'gui.composer.resize': '拖动调整输入框高度', 'gui.at.point': '本次对话的范围', 'gui.at.folder': '工作目录', 'gui.at.bases': '知识库', diff --git a/ui-web/scripts/__golden__/boot-live-noserver.txt b/ui-web/scripts/__golden__/boot-live-noserver.txt index 9c3924a30..97bb2d753 100644 --- a/ui-web/scripts/__golden__/boot-live-noserver.txt +++ b/ui-web/scripts/__golden__/boot-live-noserver.txt @@ -136,6 +136,7 @@ div.app[data-rail=on] div.dock div#sheetRack.sheets div.dock-in + div#composerGrip.chrome-composer-grip div#queued.queued div#atts.atts div.field diff --git a/ui-web/scripts/__golden__/boot-stub.txt b/ui-web/scripts/__golden__/boot-stub.txt index 9d5372908..5fb287c25 100644 --- a/ui-web/scripts/__golden__/boot-stub.txt +++ b/ui-web/scripts/__golden__/boot-stub.txt @@ -136,6 +136,7 @@ div.app[data-rail=on] div.dock div#sheetRack.sheets div.dock-in + div#composerGrip.chrome-composer-grip div#queued.queued div#atts.atts div.field diff --git a/ui-web/src/chrome/Dock.test.tsx b/ui-web/src/chrome/Dock.test.tsx index 2bd189dc6..565908283 100644 --- a/ui-web/src/chrome/Dock.test.tsx +++ b/ui-web/src/chrome/Dock.test.tsx @@ -146,6 +146,8 @@ describe('the dock', () => { expect(el('permChip').getAttribute('aria-haspopup')).toBe('true') expect(el('permChip').getAttribute('aria-expanded')).toBe('false') expect(el('modelChip').getAttribute('aria-haspopup')).toBe('true') + expect(el('composerGrip').getAttribute('role')).toBe('separator') + expect(el('composerGrip').getAttribute('aria-orientation')).toBe('horizontal') expect(ta().getAttribute('rows')).toBe('1') expect(ta().placeholder).not.toBe('') /* Four things the page is served hidden or disabled: the "+" waits for the @@ -311,7 +313,7 @@ describe('the dock', () => { const box = el('atts') expect(box.hidden).toBe(true) expect(Array.from(document.querySelector('.dock-in')!.children).map((c) => c.id || c.className)).toEqual([ - 'queued', 'atts', 'field', 'under', 'slashPop', + 'composerGrip', 'queued', 'atts', 'field', 'under', 'slashPop', ]) }) }) diff --git a/ui-web/src/chrome/Dock.tsx b/ui-web/src/chrome/Dock.tsx index 9249f1c93..0d7765586 100644 --- a/ui-web/src/chrome/Dock.tsx +++ b/ui-web/src/chrome/Dock.tsx @@ -76,6 +76,17 @@ function DockIn(): JSX.Element { useSyncExternalStore(lang.subscribe, lang.get) return (
+ {/* The height seam: dragged from the card's top edge between the field's + resting height and half the window (features/composer/store.ts). No + interior of its own. */} +
' + const ta = document.getElementById('ta') as HTMLTextAreaElement + ta.style.minHeight = `${FLOOR}px` + content(ta, FLOOR) + box(ta, 0, FLOOR) + return ta +} + +const grip = (): HTMLElement => document.getElementById('composerGrip')! + +function press(el: HTMLElement, y: number): void { + el.dispatchEvent(new PointerEvent('pointerdown', { clientY: y, bubbles: true, pointerId: 1 })) +} + +function moveTo(el: HTMLElement, y: number): void { + el.dispatchEvent(new PointerEvent('pointermove', { clientY: y, bubbles: true, pointerId: 1 })) +} + +function key(el: HTMLElement, name: string, shiftKey = false): void { + el.dispatchEvent(new KeyboardEvent('keydown', { key: name, shiftKey, bubbles: true, cancelable: true })) +} + +beforeEach(() => { + _resetForTests() + viewport(800) + document.body.style.userSelect = '' +}) + +describe('the composer height seam', () => { + it('grows the field by the distance the pointer travels up', () => { + const ta = mount() + gripDrag(grip()) + press(grip(), 500) + moveTo(grip(), 400) + expect(ta.style.height).toBe('152px') + }) + + it('stops at the resting height on the way down', () => { + const ta = mount() + gripDrag(grip()) + press(grip(), 500) + moveTo(grip(), 700) + expect(ta.style.height).toBe(`${FLOOR}px`) + }) + + it('stops at half the window on the way up', () => { + const ta = mount() + gripDrag(grip()) + press(grip(), 600) + moveTo(grip(), -400) + expect(ta.style.height).toBe('400px') + }) + + it('takes its floor from the field min-height rather than a second number', () => { + const ta = mount() + ta.style.minHeight = '60px' + resizeField(10) + expect(ta.style.height).toBe('60px') + }) + + it('publishes the new lift inside the move, before any paint', () => { + const ta = mount() + box(document.querySelector('.chat')!, 0, 600) + box(document.querySelector('.dock')!, 0, 600) + /* The card's own box follows the field's inline height, which is what a + real browser's layout would do between two pointer moves. */ + const card = document.querySelector('.dock-in') as HTMLElement + card.getBoundingClientRect = () => { + const height = parseFloat(ta.style.height) || FLOOR + return rect(600 - height, height) + } + gripDrag(grip()) + press(grip(), 500) + moveTo(grip(), 400) + const chat = document.querySelector('.chat') as HTMLElement + expect(chat.style.getPropertyValue('--lift')).toBe('152px') + }) +}) + +describe('a dragged height against the content fit', () => { + it('wins over the content, in both directions', () => { + const ta = mount() + resizeField(200) + content(ta, 60) + fitField() + expect(ta.style.height).toBe('200px') + content(ta, 900) + fitField() + expect(ta.style.height).toBe('200px') + }) + + it('is re-clamped under a window that shrank, and restored when it grows back', () => { + const ta = mount() + resizeField(350) + viewport(400) + fitField() + expect(ta.style.height).toBe('200px') + viewport(1200) + fitField() + expect(ta.style.height).toBe('350px') + }) + + it('still lets the content fit while no drag has happened', () => { + const ta = mount() + content(ta, 300) + fitField() + expect(ta.style.height).toBe('300px') + }) + + it('caps the content fit at half the window', () => { + const ta = mount() + content(ta, 900) + fitField() + expect(ta.style.height).toBe('400px') + }) +}) + +describe('the seam by keyboard', () => { + it('moves ten pixels a press and forty with Shift', () => { + const ta = mount() + gripDrag(grip()) + key(grip(), 'ArrowUp') + expect(ta.style.height).toBe('62px') + key(grip(), 'ArrowUp', true) + expect(ta.style.height).toBe('102px') + key(grip(), 'ArrowDown') + expect(ta.style.height).toBe('92px') + }) + + it('leaves every other key alone', () => { + const ta = mount() + gripDrag(grip()) + key(grip(), 'ArrowLeft') + expect(ta.style.height).toBe('') + }) +}) + +describe('resizing without a pointer', () => { + it('clamps to the resting height and to half the window', () => { + const ta = mount() + resizeField(10) + expect(ta.style.height).toBe(`${FLOOR}px`) + resizeField(9999) + expect(ta.style.height).toBe('400px') + }) +}) diff --git a/ui-web/src/features/composer/store.ts b/ui-web/src/features/composer/store.ts index 8d2553c5f..22bdb3e03 100644 --- a/ui-web/src/features/composer/store.ts +++ b/ui-web/src/features/composer/store.ts @@ -239,17 +239,100 @@ function placeholder(): string { return t(turn.busy() ? 'gui.composer.ph_busy' : 'gui.composer.ph_session') } -/* A textarea cannot size itself to its content, so the height is set here -- - but the ceiling stays in CSS and is read back, so there is one cap to tune - and it can be viewport-relative. */ +/* The field may take half the window, computed when asked rather than stored, + so a resized window moves the cap with it and there is no second number to + keep in step. The stylesheet gives it the same half as a backstop for the + frame between a resize and the fit below. */ +const fieldCeiling = (): number => Math.round(window.innerHeight * 0.5) + +/* The floor is today's resting height, which is the CSS min-height: read back + rather than repeated, so the two cannot drift. */ +const fieldFloor = (e: HTMLTextAreaElement): number => parseFloat(getComputedStyle(e).minHeight) || 52 + +/* The height a drag left the field at, or null while the field still follows + its content. Nothing renders from it -- the field carries the result -- so + it is module state rather than a store key. */ +let manualHeight: number | null = null + +function fieldBounds(e: HTMLTextAreaElement): { max: number; min: number } { + const min = fieldFloor(e) + return { max: Math.max(min, fieldCeiling()), min } +} + +/* A textarea cannot size itself to its content, so the height is set here. + Once the reader has dragged one, that height wins over the content and is + re-clamped on each pass, so a window that shrank pulls the field back under + the smaller ceiling instead of leaving it taller than half of one. */ export function fitField(): void { const e = field() if (!e) return + if (manualHeight !== null) { + const { max, min } = fieldBounds(e) + e.style.height = Math.round(Math.min(max, Math.max(min, manualHeight))) + 'px' + dockLift() + return + } e.style.height = 'auto' - e.style.height = Math.min(parseFloat(getComputedStyle(e).maxHeight) || 168, e.scrollHeight) + 'px' + e.style.height = Math.min(fieldCeiling(), e.scrollHeight) + 'px' + dockLift() +} + +/* A drag step: the height the pointer asked for, clamped and written at once. + The write and the lift below happen inside the move handler rather than on + the next paint, so the transcript's reserve and the sheets stacked over the + card travel with the pointer instead of a frame behind it. */ +export function resizeField(px: number): void { + const e = field() + if (!e) return + const { max, min } = fieldBounds(e) + manualHeight = Math.round(Math.max(min, Math.min(max, px))) + e.style.height = manualHeight + 'px' dockLift() } +/* The seam on the card's top edge: up grows the field, down shrinks it. The + pointer is captured on the handle and the move writes the height directly, + with no state and no re-render, for the reason `resizeField` gives. */ +export function gripDrag(el: HTMLElement): void { + el.tabIndex = 0 + el.addEventListener('pointerdown', (e) => { + const ta = field() + if (!ta) return + e.preventDefault() + e.stopPropagation() + const pointerId = e.pointerId + const startY = e.clientY + const startH = ta.getBoundingClientRect().height + el.dataset.drag = 'true' + el.setPointerCapture(pointerId) + document.body.style.userSelect = 'none' + const move = (ev: PointerEvent): void => { + if (ev.pointerId !== pointerId) return + resizeField(startH + startY - ev.clientY) + } + const up = (): void => { + el.removeEventListener('pointermove', move) + el.removeEventListener('pointerup', up) + el.removeEventListener('pointercancel', up) + delete el.dataset.drag + document.body.style.userSelect = '' + } + el.addEventListener('pointermove', move) + el.addEventListener('pointerup', up) + el.addEventListener('pointercancel', up) + }) + /* The handle is a separator, so arrows move it: ten pixels a press, forty + with Shift, the same step the pane grips take. */ + el.addEventListener('keydown', (e) => { + const ta = field() + if (!ta) return + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return + e.preventDefault() + const base = manualHeight ?? ta.getBoundingClientRect().height + resizeField(base + (e.key === 'ArrowUp' ? (e.shiftKey ? 40 : 10) : -(e.shiftKey ? 40 : 10))) + }) +} + /* Everything docked at the bottom moves: the composer grows with its content, queued rows and a clarify sheet stack above it. The pill hangs off .chat, a different positioned ancestor, so keeping them apart takes one measured @@ -888,6 +971,7 @@ export function _resetForTests(): void { liveT0 = 0 draftOwner = null draftTick = null + manualHeight = null jumpReset() trays.clear() store.set({ ...initial }) diff --git a/ui-web/src/styles/page.css b/ui-web/src/styles/page.css index 94dea649e..0dad14352 100644 --- a/ui-web/src/styles/page.css +++ b/ui-web/src/styles/page.css @@ -2412,6 +2412,25 @@ body:has(> .lightbox) .sbar:not([data-over="lightbox"]) { display: none; } the reader sees. */ .dock-in:has(.field.drop) { box-shadow: 0 0 20px var(--composer-sh), 0 0 0 1px var(--amber); } +/* The card's height seam, at its top edge: a short pill that stays quiet until + the pointer is on it, the restraint the pane grips show above. Laid over the + card's own top inset rather than given a row of its own, so the composer + still opens at exactly the height it did before the handle existed. + `touch-action` keeps a touch drag from scrolling the page instead. */ +.chrome-composer-grip { + position: absolute; top: 0; left: 0; right: 0; height: 12px; z-index: 2; + display: flex; align-items: center; justify-content: center; + cursor: ns-resize; touch-action: none; +} +.chrome-composer-grip::before { + content: ''; width: 32px; height: 3px; border-radius: 999px; + background: var(--composer-line); transition: background .12s ease; +} +.chrome-composer-grip:hover::before, .chrome-composer-grip[data-drag="true"]::before, +.chrome-composer-grip:focus-visible::before { background: var(--composer-ph); } +.chrome-composer-grip:focus-visible { outline: none; } +@media (prefers-reduced-motion: reduce) { .chrome-composer-grip::before { transition: none; } } + /* ── the work indicator ─────────────────────────────────────────────────── One glyph for "this is happening now", in every place that used to say it in its own words: the turn's own row, a sub-agent's row and its open transcript, @@ -2497,8 +2516,8 @@ body:has(> .lightbox) .sbar:not([data-over="lightbox"]) { display: none; } .field .c { font-family: var(--mono); color: var(--amber); padding-bottom: 3px; } /* The ceiling is viewport-relative on purpose: a fixed 168px eats a third of a short window, and what it eats is the tail of the transcript the reader is - writing about. This is the single source for the cap -- taFit() reads it back - rather than repeating the number. */ + writing about. The composer's own field takes the same viewport-relative + ceiling, set where its height is (features/composer/store.ts). */ /* Two lines at rest: an input that opens at one cramped line undersells itself as the main act of the page. */ textarea { @@ -2506,12 +2525,14 @@ textarea { min-height: 46px; max-height: min(200px, 24vh); padding: 2px 0; line-height: 1.6; } textarea::placeholder { color: var(--faint); } -/* The composer's own field: the reply's 15/26 body (--fs-body), two lines at rest and - about eleven before it scrolls -- still viewport-bounded, so a short window - keeps the transcript it is writing about. */ +/* The composer's own field: the reply's 15/26 body (--fs-body), two lines at + rest and up to half the window before it scrolls. Its height is written by + features/composer/store.ts, which owns both bounds; the min-height here is + the floor that file reads back, and the max-height only holds the field + between writes. */ .dock-in textarea { font-size: var(--fs-body); line-height: 26px; padding: 0; color: var(--composer-ink); - min-height: 52px; max-height: min(286px, 36vh); + min-height: 52px; max-height: 50vh; } .dock-in textarea::placeholder { color: var(--composer-ph); } /* WebKit paints its own focus ring on text controls; ours is the container diff --git a/ui-web/src/test/__golden__/region-app.txt b/ui-web/src/test/__golden__/region-app.txt index 5deb97d0e..e2fd71127 100644 --- a/ui-web/src/test/__golden__/region-app.txt +++ b/ui-web/src/test/__golden__/region-app.txt @@ -135,6 +135,7 @@ div.app div.dock div#sheetRack.sheets div.dock-in + div#composerGrip.chrome-composer-grip div#queued.queued div.field textarea#ta