Skip to content
Open
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
4 changes: 4 additions & 0 deletions i18n/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "本次对话的范围"
Expand Down
2 changes: 2 additions & 0 deletions ui-tui/src/i18n/messages.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2728,6 +2728,7 @@ export const UI_TEXT: Record<Locale, Record<string, string>> = {
'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',
Expand Down Expand Up @@ -6088,6 +6089,7 @@ export const UI_TEXT: Record<Locale, Record<string, string>> = {
'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': '知识库',
Expand Down
1 change: 1 addition & 0 deletions ui-web/scripts/__golden__/boot-live-noserver.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions ui-web/scripts/__golden__/boot-stub.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion ui-web/src/chrome/Dock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
])
})
})
Expand Down
11 changes: 11 additions & 0 deletions ui-web/src/chrome/Dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ function DockIn(): JSX.Element {
useSyncExternalStore(lang.subscribe, lang.get)
return (
<div className="dock-in">
{/* 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. */}
<div
className="chrome-composer-grip"
id="composerGrip"
role="separator"
aria-orientation="horizontal"
title={lang.attr('gui.composer.resize')}
aria-label={lang.attr('gui.composer.resize')}
/>
<div className="queued" id="queued" />
<div className="field">
<textarea id="ta" rows={1} placeholder={t('gui.composer_ph')} />
Expand Down
2 changes: 2 additions & 0 deletions ui-web/src/features/composer/mount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ export function install(): void {
store.goClick()
}
}
const grip = document.getElementById('composerGrip')
if (grip) store.gripDrag(grip)
/* The drop target exists only where a dropped file has somewhere to go: the
demo canvas offers no upload, so it must not light up the field either. */
const fieldBox = ta.closest('.field')
Expand Down
185 changes: 185 additions & 0 deletions ui-web/src/features/composer/store.grip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// @vitest-environment happy-dom
/* The composer's height seam: a drag from the card's top edge sets the field's
* height between its resting size and half the window, the dragged height takes
* precedence over the content fit, and the lift the transcript reserves is
* published inside the move rather than after the paint that follows it.
*
* happy-dom does no layout and loads no stylesheet, so the field's min-height
* is stated inline (the value page.css gives it) and its scroll height and
* rects are stubbed; the numbers are what is being asserted.
*/
import { beforeEach, describe, expect, it } from 'vitest'

import { _resetForTests, fitField, gripDrag, resizeField } from './store'

const FLOOR = 52

function viewport(px: number): void {
Object.defineProperty(window, 'innerHeight', { value: px, configurable: true })
}

function rect(top: number, height: number): DOMRect {
return {
top, bottom: top + height, height, left: 0, right: 300, width: 300, x: 0, y: top, toJSON: () => ({}),
} as DOMRect
}

function box(el: Element, top: number, height: number): void {
el.getBoundingClientRect = () => rect(top, height)
}

function content(ta: HTMLTextAreaElement, height: number): void {
Object.defineProperty(ta, 'scrollHeight', { value: height, configurable: true })
}

function mount(): HTMLTextAreaElement {
document.body.innerHTML =
'<div class="chat"><div class="dock"><div class="dock-in">'
+ '<div class="cp-grip" id="composerGrip"></div>'
+ '<div class="field"><textarea id="ta" rows="1"></textarea></div>'
+ '</div></div></div>'
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')
})
})
92 changes: 88 additions & 4 deletions ui-web/src/features/composer/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -888,6 +971,7 @@ export function _resetForTests(): void {
liveT0 = 0
draftOwner = null
draftTick = null
manualHeight = null
jumpReset()
trays.clear()
store.set({ ...initial })
Expand Down
Loading
Loading