From c3b474b1e0681757a01b1a36e5e935c5681f4c75 Mon Sep 17 00:00:00 2001 From: GCWing Date: Sun, 18 Oct 2026 10:18:32 +0800 Subject: [PATCH 1/4] feat(web-ui): add elastic navigation divider feedback Keep the grip on the boundary at rest, separate it progressively in the pull direction, and spring back on release. Use a fixed 3px stroke and neutral design-system colors. Add pointer capture, cancellation cleanup, keyboard resizing, and focused interaction coverage while keeping live width updates outside React renders. Validation: 22 focused tests, check:web, motion:audit, Sass compilation, and git diff --check passed. Remote scenarios were not exercised. --- src/web-ui/AGENTS-CN.md | 8 + src/web-ui/AGENTS.md | 9 + src/web-ui/src/app/layout/WorkspaceBody.scss | 54 +++- src/web-ui/src/app/layout/WorkspaceBody.tsx | 115 ++----- .../src/app/layout/useNavResize.test.tsx | 300 ++++++++++++++++++ src/web-ui/src/app/layout/useNavResize.ts | 246 ++++++++++++++ 6 files changed, 647 insertions(+), 85 deletions(-) create mode 100644 src/web-ui/src/app/layout/useNavResize.test.tsx create mode 100644 src/web-ui/src/app/layout/useNavResize.ts diff --git a/src/web-ui/AGENTS-CN.md b/src/web-ui/AGENTS-CN.md index d9b3be5a63..e2960af273 100644 --- a/src/web-ui/AGENTS-CN.md +++ b/src/web-ui/AGENTS-CN.md @@ -79,3 +79,11 @@ pnpm run check:web 以上依次用于 locale 资源、locale contract/shared terms、i18n runtime/namespace loading 和普通 Web UI 代码。 `check:web` 会执行类型检查,以及 CI 使用的 Appearance contract、主题颜色和主题视觉治理门禁,确保渲染 DOM 或样式回归能在本地发现。完整 lint、build 与大范围测试由 CI 兜底,除非本地改动确实需要复现。 + +导航分割线拖拽、弹性反馈、取消操作与键盘调宽使用以下聚焦检查: + +```bash +pnpm --dir src/web-ui run test:run src/app/layout/useNavResize.test.tsx src/app/layout/WorkspaceBody.presentation.test.ts +``` + +这些本地 DOM 测试覆盖手势状态和弹簧收敛,不代表原生 WebView 渲染或真实远程场景验证。 diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index 8d8c77eec6..455d1957e8 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -108,6 +108,15 @@ deliberate layout transitions or animate virtualized content. Rely on CI for full lint, build, and broad test coverage unless the local change specifically needs it. +For navigation-divider dragging, elastic feedback, cancellation, and keyboard resizing: + +```bash +pnpm --dir src/web-ui run test:run src/app/layout/useNavResize.test.tsx src/app/layout/WorkspaceBody.presentation.test.ts +``` + +These local DOM fixtures cover gesture state and spring settling, not native +WebView rendering or live remote scenarios. + For Appearance descriptor changes, also exercise production registration; its ID and property validation goes beyond the static DOM audit: diff --git a/src/web-ui/src/app/layout/WorkspaceBody.scss b/src/web-ui/src/app/layout/WorkspaceBody.scss index 92dafa5210..7ca4649816 100644 --- a/src/web-ui/src/app/layout/WorkspaceBody.scss +++ b/src/web-ui/src/app/layout/WorkspaceBody.scss @@ -8,7 +8,7 @@ $_nav-width: 300px; $_nav-collapsed-width: 76px; $_nav-bar-height: var(--openbitfun-layout-toolbar-md-height); -$_resize-target-width: 6px; +$_resize-target-width: 12px; .openbitfun-workspace-body { --nav-width: #{$_nav-width}; @@ -112,12 +112,55 @@ $_resize-target-width: 6px; top: 0; bottom: 0; // Center the resize hit target on the navigation / scene boundary. The - // scene surface owns the visual feedback so the hit target stays invisible. + // scene surface owns the edge; the short grip supplies elastic feedback. left: calc(var(--nav-width) - #{$_resize-target-width * 0.5}); z-index: 10; width: $_resize-target-width; cursor: ew-resize; background: transparent; + touch-action: none; + outline: none; + + // Once pulled away, keep the grip reachable on either side during the drag + // and release. At rest the normal hit target stays on the boundary. + &[data-resize-feedback]::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 48px; + height: 80px; + transform: translate(-50%, -50%); + } + + &[data-resize-feedback], + &:focus-visible { + .openbitfun-workspace-body__nav-grip { opacity: 1; } + } + + &:focus-visible .openbitfun-workspace-body__nav-grip { + color: var(--openbitfun-color-focus-ring); + } +} + +.openbitfun-workspace-body__nav-grip { + position: absolute; + top: 50%; + left: 50%; + width: 48px; + height: 120px; + overflow: visible; + transform: translate(-50%, -50%); + color: var(--openbitfun-color-border-strong); + opacity: 0; + pointer-events: none; + transition: + opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), + color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); +} + +.openbitfun-is-resizing-nav .openbitfun-workspace-body__nav-grip { + opacity: 1; } .openbitfun-workspace-body__scene-area { @@ -173,6 +216,10 @@ $_resize-target-width: 6px; } @media (hover: hover) and (pointer: fine) { + .openbitfun-workspace-body__nav-divider:hover:not(:focus-visible) .openbitfun-workspace-body__nav-grip { + opacity: 1; + } + .openbitfun-workspace-body__nav-divider:hover ~ .openbitfun-workspace-body__scene-area { .openbitfun-workspace-body__scene-surface::after { border-color: var(--openbitfun-color-border-subtle); @@ -248,7 +295,8 @@ $_resize-target-width: 6px; transition: none; } - .openbitfun-workspace-body__scene-surface::after { + .openbitfun-workspace-body__scene-surface::after, + .openbitfun-workspace-body__nav-grip { transition: none; } } diff --git a/src/web-ui/src/app/layout/WorkspaceBody.tsx b/src/web-ui/src/app/layout/WorkspaceBody.tsx index a2c2b4a72f..fef6093f6f 100644 --- a/src/web-ui/src/app/layout/WorkspaceBody.tsx +++ b/src/web-ui/src/app/layout/WorkspaceBody.tsx @@ -10,7 +10,7 @@ * SceneViewport (flex:1 — active scene content) */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useId, useRef, useState } from 'react'; import { useCurrentWorkspace } from '../../infrastructure/contexts/WorkspaceContext'; import { NavBar } from '../components/NavBar'; import NavPanel from '../components/NavPanel/NavPanel'; @@ -20,6 +20,8 @@ import { SceneViewport } from '../scenes'; import TerminalActionBridge from '../scenes/terminal/TerminalActionBridge'; import { useApp } from '../hooks/useApp'; import { selectActiveSceneId, useSceneStore } from '../stores/sceneStore'; +import { useI18n } from '@/infrastructure/i18n'; +import { useNavResize } from './useNavResize'; import './WorkspaceBody.scss'; const NAV_DEFAULT_WIDTH = 300; @@ -48,6 +50,8 @@ const WorkspaceBody: React.FC = ({ isMaximized = false, sceneOverlay, }) => { + const { t } = useI18n('components'); + const navId = useId(); const { workspace: currentWorkspace } = useCurrentWorkspace(); const { state, toggleLeftPanel } = useApp(); const activeSceneId = useSceneStore(selectActiveSceneId); @@ -55,84 +59,19 @@ const WorkspaceBody: React.FC = ({ const [navWidth, setNavWidth] = useState(NAV_DEFAULT_WIDTH); const navAreaRef = useRef(null); const navDividerRef = useRef(null); - // Active drag cleanup, so window listeners never leak if we unmount mid-drag. - const dragCleanupRef = useRef<(() => void) | null>(null); - - useEffect(() => { - return () => { - dragCleanupRef.current?.(); - }; - }, []); - - const handleNavCollapseDragStart = useCallback((event: React.MouseEvent) => { - if (event.button !== 0 || isNavCollapsed) return; - event.preventDefault(); - - const startX = event.clientX; - const startWidth = navWidth; - let latestWidth = startWidth; - let hasCollapsed = false; - let frameId: number | null = null; - - document.body.classList.add('openbitfun-is-dragging-nav-collapse'); - document.body.classList.add('openbitfun-is-resizing-nav'); - - // During the drag we bypass React entirely: write the --nav-width CSS - // variable straight to the two elements that consume it (rAF-merged). - // React state is committed once on mouseup. - const applyWidth = () => { - frameId = null; - const value = `${latestWidth}px`; - navAreaRef.current?.style.setProperty('--nav-width', value); - navDividerRef.current?.style.setProperty('--nav-width', value); - }; - - const handleMouseMove = (moveEvent: MouseEvent) => { - if (hasCollapsed) return; - const deltaX = moveEvent.clientX - startX; - const rawWidth = startWidth + deltaX; - - // Collapse only after the width hits minimum AND continues left by COLLAPSE_THRESHOLD - if (rawWidth <= NAV_MIN_WIDTH - COLLAPSE_THRESHOLD) { - hasCollapsed = true; - toggleLeftPanel(); - cleanup(); - return; - } - latestWidth = Math.min(NAV_MAX_WIDTH, Math.max(NAV_MIN_WIDTH, rawWidth)); - if (frameId === null) { - frameId = requestAnimationFrame(applyWidth); - } - }; - - const handleMouseUp = () => cleanup(); - - function cleanup() { - if (frameId !== null) { - cancelAnimationFrame(frameId); - frameId = null; - } - // Flush the final width synchronously, while `openbitfun-is-resizing-nav` - // still suppresses the width transition. Without this, a drop that lands - // between two animation frames leaves the DOM at the last painted width: - // React only rewrites the inline var when `navWidth` actually changes, so - // ending a drag back on the previous width would strand the DOM out of - // sync with state, and any other drop would glide to its final width - // ($motion-base) while the divider's `left` jumps instantly. - applyWidth(); - document.body.classList.remove('openbitfun-is-dragging-nav-collapse'); - document.body.classList.remove('openbitfun-is-resizing-nav'); - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - dragCleanupRef.current = null; - // Commit the final width through React once. - setNavWidth(latestWidth); - } - - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - dragCleanupRef.current = cleanup; - }, [isNavCollapsed, navWidth, toggleLeftPanel]); + const navGripRef = useRef(null); + const navResize = useNavResize({ + areaRef: navAreaRef, + dividerRef: navDividerRef, + gripRef: navGripRef, + width: navWidth, + minWidth: NAV_MIN_WIDTH, + maxWidth: NAV_MAX_WIDTH, + collapseThreshold: COLLAPSE_THRESHOLD, + collapsed: isNavCollapsed, + onWidthChange: setNavWidth, + onCollapse: toggleLeftPanel, + }); return (
= ({ {/* Left: nav history bar + navigation sidebar — always rendered for slide animation */}
= ({ ref={navDividerRef} className="openbitfun-workspace-body__nav-divider" style={{ '--nav-width': `${navWidth}px` } as React.CSSProperties} - onMouseDown={handleNavCollapseDragStart} + {...navResize} role="separator" - aria-hidden="true" + tabIndex={0} + aria-label={t('canvas.resizePanes')} + aria-orientation="vertical" + aria-controls={navId} + aria-valuemin={NAV_MIN_WIDTH} + aria-valuemax={NAV_MAX_WIDTH} + aria-valuenow={navWidth} data-openbitfun-scene="workbench" data-openbitfun-part="navDivider" - /> + data-openbitfun-theme-scope="chrome" + > + +
)} {/* Right: visual scene surface + any shell-level overlay */} diff --git a/src/web-ui/src/app/layout/useNavResize.test.tsx b/src/web-ui/src/app/layout/useNavResize.test.tsx new file mode 100644 index 0000000000..39d2268e9d --- /dev/null +++ b/src/web-ui/src/app/layout/useNavResize.test.tsx @@ -0,0 +1,300 @@ +// @vitest-environment jsdom +import React, { act, useRef, useState } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useNavResize } from './useNavResize'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('navigation resize interaction', () => { + let root: Root; + let container: HTMLDivElement; + let frames: Map; + let nextFrame: number; + let now: number; + let reducedMotion: boolean; + let preferenceChanged: () => void; + let committed: ReturnType; + let collapse: ReturnType; + let captured: Set; + + function Harness({ collapsed = false }: { collapsed?: boolean }) { + const [width, setWidth] = useState(300); + const areaRef = useRef(null); + const dividerRef = useRef(null); + const gripRef = useRef(null); + const handlers = useNavResize({ + areaRef, dividerRef, gripRef, width, minWidth: 216, maxWidth: 480, + collapseThreshold: 64, collapsed, + onWidthChange: value => { committed(value); setWidth(value); }, onCollapse: collapse, + }); + return <> +
+ {!collapsed &&
+ +
} + ; + } + + const area = () => container.querySelector('[data-testid="area"]')!; + const divider = () => container.querySelector('[data-testid="divider"]')!; + const grip = () => container.querySelector('path')!; + const geometry = () => grip().getAttribute('d')!.match(/-?\d+(?:\.\d+)?/g)!.map(Number); + const shape = () => { + const points = geometry(); + return { + centerX: points.length === 4 ? (points[0] + points[2]) / 2 + : (points[0] + 3 * points[2] + 3 * points[4] + points[6]) / 8, + endX: points[0], + height: points.at(-1)! - points[1], + thickness: Number(grip().getAttribute('stroke-width')), + }; + }; + const render = (collapsed = false) => act(() => root.render()); + function pointer(type: string, x: number, pointerId = 1, button = 0) { + act(() => { + const event = new MouseEvent(type, { clientX: x, button, bubbles: true, cancelable: true }); + Object.defineProperty(event, 'pointerId', { value: pointerId }); + (type === 'pointerdown' || type === 'lostpointercapture' ? divider() : window).dispatchEvent(event); + }); + } + const key = (value: string, shiftKey = false) => act(() => divider().dispatchEvent( + new KeyboardEvent('keydown', { key: value, shiftKey, bubbles: true, cancelable: true }), + )); + const paint = (count = 1, interval = 16) => act(() => { + for (let i = 0; i < count; i++) { + now += interval; + const pending = [...frames.values()]; + frames.clear(); + pending.forEach(callback => callback(now)); + } + }); + + beforeEach(() => { + frames = new Map(); nextFrame = 0; now = 0; reducedMotion = false; + committed = vi.fn(); collapse = vi.fn(); captured = new Set(); + vi.spyOn(performance, 'now').mockImplementation(() => now); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); return nextFrame; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); + vi.stubGlobal('matchMedia', () => ({ + get matches() { return reducedMotion; }, + addEventListener: (_event: string, callback: () => void) => { preferenceChanged = callback; }, + removeEventListener: vi.fn(), + })); + container = document.createElement('div'); document.body.appendChild(container); + root = createRoot(container); + render(); + Object.assign(divider(), { + setPointerCapture: (id: number) => captured.add(id), + hasPointerCapture: (id: number) => captured.has(id), + releasePointerCapture: (id: number) => captured.delete(id), + }); + }); + + afterEach(() => { + act(() => root.unmount()); container.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + document.body.style.userSelect = ''; + }); + + it('keeps width under the pointer while the grip bends, then springs back and stops scheduling frames', () => { + pointer('pointerdown', 300); pointer('pointermove', 370); paint(); + expect(area().style.getPropertyValue('--nav-width')).toBe('370px'); + expect(divider().style.getPropertyValue('--nav-width')).toBe('370px'); + expect(shape().centerX).toBeGreaterThan(24); + expect(committed).not.toHaveBeenCalled(); + pointer('pointerup', 382); + expect(committed).toHaveBeenCalledExactlyOnceWith(382); + expect(divider().getAttribute('data-resize-feedback')).toBe('settling'); + paint(100); + expect(shape().centerX).toBe(24); + expect(shape().height).toBe(32); + expect(shape().thickness).toBe(3); + expect(divider().hasAttribute('data-resize-feedback')).toBe(false); + expect(frames.size).toBe(0); + expect(captured.size).toBe(0); + }); + + it.each([-1, 1])('starts on the boundary and separates progressively toward a %s pull, including reversals', direction => { + expect(shape().centerX).toBe(24); + expect(shape().endX).toBe(24); + pointer('pointerdown', 300); + paint(6); + expect(shape().centerX).toBe(24); + expect(shape().endX).toBe(24); + let firstSeparation = 0; + for (let i = 1; i <= 6; i++) { + now += 16; + pointer('pointermove', 300 + direction * i * 10); + paint(); + if (i === 1) firstSeparation = (shape().endX - 24) * direction; + } + expect(firstSeparation).toBeGreaterThan(0); + expect((shape().endX - 24) * direction).toBeGreaterThan(firstSeparation); + expect((shape().endX - 24) * direction).toBeGreaterThan(1); + expect((shape().centerX - 24) * direction).toBeGreaterThan(1.5); + expect(Math.abs(shape().centerX - 24)).toBeLessThan(8); + for (let i = 1; i <= 6; i++) { + now += 16; + pointer('pointermove', 300 + direction * (60 - i * 10)); + paint(); + } + expect((shape().centerX - 24) * direction).toBeLessThan(-1.5); + expect(committed).not.toHaveBeenCalled(); + pointer('pointerup', 300); paint(100); + expect(shape().endX).toBe(24); + expect(shape().centerX).toBe(24); + expect(frames.size).toBe(0); + }); + + it('gives a visible stretch and a small release recoil, with a constant stroke width', () => { + pointer('pointerdown', 300); + for (let i = 1; i <= 10; i++) { + now += 16; + pointer('pointermove', 300 + i * 12); + paint(); + expect(shape().thickness).toBe(3); + } + expect(shape().height).toBeGreaterThan(50); + expect(shape().height).toBeLessThanOrEqual(74); + paint(90); + expect(shape().centerX).toBe(24); + expect(shape().height).toBe(32); + expect(shape().thickness).toBe(3); + + // Releasing a fresh pull gives a small recoil, capped at two pixels. + pointer('pointermove', 460); paint(5); + const pulledHeight = shape().height; + pointer('pointerup', 460); + const heights: number[] = []; + for (let i = 0; i < 100; i++) { + paint(); heights.push(shape().height); + expect(shape().thickness).toBe(3); + } + expect(pulledHeight).toBeGreaterThan(40); + expect(Math.min(...heights)).toBeGreaterThanOrEqual(30); + expect(Math.min(...heights)).toBeLessThan(31.8); + expect(Math.max(...heights)).toBeLessThanOrEqual(74); + expect(shape().height).toBe(32); + expect(shape().thickness).toBe(3); + expect(frames.size).toBe(0); + }); + + it('gives the same pull feedback when pointer events are coalesced or delivered more frequently', () => { + const drag = (samplesPerFrame: number) => { + pointer('pointerdown', 300); + for (let frame = 0; frame < 10; frame++) { + for (let sample = 1; sample <= samplesPerFrame; sample++) { + now += 16 / samplesPerFrame; + pointer('pointermove', 300 + (frame + sample / samplesPerFrame) * 8); + } + paint(1, 0); + } + const result = shape(); + pointer('pointercancel', 380); + return result; + }; + const coalesced = drag(1); + const frequent = drag(4); + expect(frequent.centerX).toBeCloseTo(coalesced.centerX, 5); + expect(frequent.height).toBeCloseTo(coalesced.height, 5); + expect(frequent.thickness).toBeCloseTo(coalesced.thickness, 5); + }); + + it('flushes the final release and a return to the old width even between animation frames', () => { + pointer('pointerdown', 300); pointer('pointermove', 360); paint(); + pointer('pointerup', 300); + expect(area().style.getPropertyValue('--nav-width')).toBe('300px'); + expect(divider().style.getPropertyValue('--nav-width')).toBe('300px'); + expect(committed).toHaveBeenCalledExactlyOnceWith(300); + pointer('pointerdown', 300); pointer('pointerup', 410); + expect(committed).toHaveBeenLastCalledWith(410); + expect(area().style.getPropertyValue('--nav-width')).toBe('410px'); + }); + + it('adds bounded elastic resistance at both limits and preserves drag-to-collapse', () => { + pointer('pointerdown', 300); pointer('pointermove', 10000); paint(100); + expect(area().style.getPropertyValue('--nav-width')).toBe('480px'); + expect(shape().centerX).toBeGreaterThan(24); + expect(shape().centerX).toBeLessThan(44.4); + pointer('pointermove', 180); paint(100); + expect(area().style.getPropertyValue('--nav-width')).toBe('216px'); + expect(shape().centerX).toBeLessThan(24); + expect(collapse).not.toHaveBeenCalled(); + pointer('pointermove', 152); pointer('pointerup', 100); + expect(collapse).toHaveBeenCalledOnce(); + expect(committed).toHaveBeenCalledExactlyOnceWith(216); + expect(frames.size).toBe(0); + }); + + it.each(['pointercancel', 'lostpointercapture', 'Escape', 'blur', 'resize', 'collapse', 'unmount'])( + 'restores geometry, selection and capture without committing when interrupted by %s', reason => { + document.body.style.userSelect = 'text'; + pointer('pointerdown', 300); pointer('pointermove', 380); paint(); + if (reason === 'Escape') key('Escape'); + else if (reason === 'blur' || reason === 'resize') act(() => window.dispatchEvent(new Event(reason))); + else if (reason === 'collapse') render(true); + else if (reason === 'unmount') act(() => root.render(null)); + else pointer(reason, 380); + if (reason !== 'unmount') expect(area().style.getPropertyValue('--nav-width')).toBe('300px'); + expect(committed).not.toHaveBeenCalled(); + expect(collapse).not.toHaveBeenCalled(); + expect(document.body.style.userSelect).toBe('text'); + expect(document.body.classList.contains('openbitfun-is-resizing-nav')).toBe(false); + expect(document.body.classList.contains('openbitfun-is-dragging-nav-collapse')).toBe(false); + expect(captured.size).toBe(0); + expect(frames.size).toBe(0); + pointer('pointerup', 400); + expect(committed).not.toHaveBeenCalled(); + }, + ); + + it('ignores secondary buttons and other pointers throughout an active drag', () => { + pointer('pointerdown', 300, 1, 2); + expect(captured.size).toBe(0); + pointer('pointerdown', 300); + pointer('pointerdown', 200, 2); pointer('pointermove', 100, 2); + pointer('pointercancel', 100, 2); pointer('pointerup', 100, 2); + expect(committed).not.toHaveBeenCalled(); + expect(captured.has(1)).toBe(true); + pointer('pointerup', 340); + expect(committed).toHaveBeenCalledExactlyOnceWith(340); + }); + + it('can reverse rapidly and re-grab during the release spring without stale commits', () => { + pointer('pointerdown', 300); + for (const x of [440, 220, 460, 240, 400]) { + pointer('pointermove', x); paint(); + expect(shape().centerX).toBeGreaterThanOrEqual(3.6); + expect(shape().centerX).toBeLessThanOrEqual(44.4); + } + pointer('pointerup', 400); paint(2); + pointer('pointerdown', 400); pointer('pointerup', 360); paint(100); + expect(committed.mock.calls).toEqual([[400], [360]]); + expect(area().style.getPropertyValue('--nav-width')).toBe('360px'); + expect(frames.size).toBe(0); + }); + + it('keeps resizing available with reduced motion and settles immediately when the preference changes', () => { + pointer('pointerdown', 300); pointer('pointermove', 360); paint(); + reducedMotion = true; act(() => preferenceChanged()); + expect(frames.size).toBe(0); + expect(shape().centerX).toBe(24); + pointer('pointermove', 390); pointer('pointerup', 390); + expect(committed).toHaveBeenCalledExactlyOnceWith(390); + expect(area().style.getPropertyValue('--nav-width')).toBe('390px'); + expect(frames.size).toBe(0); + }); + + it('supports keyboard width changes without decorative motion', () => { + key('ArrowLeft'); expect(committed).toHaveBeenLastCalledWith(290); + key('ArrowRight', true); expect(committed).toHaveBeenLastCalledWith(330); + key('Home'); expect(committed).toHaveBeenLastCalledWith(216); + key('ArrowLeft'); expect(committed).toHaveBeenLastCalledWith(216); + key('End'); expect(committed).toHaveBeenLastCalledWith(480); + expect(frames.size).toBe(0); + }); +}); diff --git a/src/web-ui/src/app/layout/useNavResize.ts b/src/web-ui/src/app/layout/useNavResize.ts new file mode 100644 index 0000000000..64741a7575 --- /dev/null +++ b/src/web-ui/src/app/layout/useNavResize.ts @@ -0,0 +1,246 @@ +import { useCallback, useLayoutEffect, useRef, type KeyboardEvent, type PointerEvent, type RefObject } from 'react'; +import { isReducedMotionPreferred } from '@/shared/utils/motionPreference'; + +interface NavResizeOptions { + areaRef: RefObject; + dividerRef: RefObject; + gripRef: RefObject; + width: number; + minWidth: number; + maxWidth: number; + collapseThreshold: number; + collapsed: boolean; + onWidthChange: (width: number) => void; + onCollapse: () => void; +} + +const REST_LENGTH = 32; +const MAX_BEND = 14; +const MAX_STRETCH = 42; +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); +// Resistance approaches a limit smoothly, so pulling harder never makes the +// decorative grip escape its hit target or distort the navigation contents. +const rubberBand = (distance: number) => MAX_BEND * distance / (36 + Math.abs(distance)); + +interface Spring { position: number; velocity: number } +function advance(spring: Spring, target: number, dt: number, active: boolean) { + // Track the pull firmly, then allow one small recoil before settling. + const stiffness = active ? 690 : 430; + const damping = active ? 47 : 29; + spring.velocity += ((target - spring.position) * stiffness - spring.velocity * damping) * dt; + spring.position += spring.velocity * dt; + if (Math.abs(spring.position - target) < 0.02 && Math.abs(spring.velocity) < 0.1) { + spring.position = target; + spring.velocity = 0; + } +} + +/** The pane tracks directly; the grip stretches and bows in the pull direction. */ +export function useNavResize({ + areaRef, dividerRef, gripRef, width, minWidth, maxWidth, collapseThreshold, + collapsed, onWidthChange, onCollapse, +}: NavResizeOptions) { + const dragRef = useRef<((commit: boolean) => void) | null>(null); + const animationRef = useRef(null); + const bend = useRef({ position: 0, velocity: 0 }); + const length = useRef({ position: REST_LENGTH, velocity: 0 }); + const pressure = useRef(0); + const pullVelocity = useRef(0); + const lastTime = useRef(null); + + const paintGrip = useCallback(() => { + const size = clamp(length.current.position, REST_LENGTH - 2, REST_LENGTH + MAX_STRETCH); + const half = size / 2; + // Separation grows with the signed pull, starting at zero on the edge. + // Sharing the bow's spring also brings the entire grip home on release. + const lift = bend.current.position * 0.45; + const endX = 24 + lift + bend.current.position * 0.3; + const controlX = 24 + lift + bend.current.position * 1.2333; + gripRef.current?.setAttribute('d', [ + `M ${endX} ${60 - half}`, + `C ${controlX} ${60 - half * 0.44} ${controlX} ${60 + half * 0.44} ${endX} ${60 + half}`, + ].join(' ')); + }, [gripRef]); + + const resetFeedback = useCallback(() => { + if (animationRef.current !== null) cancelAnimationFrame(animationRef.current); + animationRef.current = null; + lastTime.current = null; + pressure.current = 0; + pullVelocity.current = 0; + bend.current = { position: 0, velocity: 0 }; + length.current = { position: REST_LENGTH, velocity: 0 }; + dividerRef.current?.removeAttribute('data-resize-feedback'); + paintGrip(); + }, [dividerRef, paintGrip]); + + const animateFeedback = useCallback(function schedule() { + if (isReducedMotionPreferred()) { resetFeedback(); return; } + if (animationRef.current !== null) return; + animationRef.current = requestAnimationFrame(time => { + animationRef.current = null; + // Substeps keep the spring stable across low frame rates and wakeups. + let remaining = Math.min((time - (lastTime.current ?? time - 16)) / 1000, 0.04); + lastTime.current = time; + const active = dragRef.current !== null; + while (remaining > 0) { + const dt = Math.min(remaining, 1 / 120); + // Velocity comes from elapsed pointer time, not a kick per event. + // When the hand pauses, the stretched grip relaxes back into a pill. + pullVelocity.current *= Math.exp(-dt / 0.075); + if (Math.abs(pullVelocity.current) < 0.5) pullVelocity.current = 0; + const pull = active ? pullVelocity.current * 0.052 + pressure.current : 0; + const targetBend = rubberBand(pull); + const tension = active ? 1 - Math.exp(-(Math.abs(pullVelocity.current) / 280 + Math.abs(pressure.current) / 50)) : 0; + const targetLength = REST_LENGTH + tension * MAX_STRETCH; + advance(bend.current, targetBend, dt, active); + advance(length.current, targetLength, dt, active); + remaining -= dt; + } + // A rapid reversal must stay inside the SVG even before it settles. + bend.current.position = clamp(bend.current.position, -MAX_BEND, MAX_BEND); + paintGrip(); + const targetBend = active ? rubberBand(pressure.current) : 0; + const targetLength = REST_LENGTH + (active ? (1 - Math.exp(-Math.abs(pressure.current) / 50)) * MAX_STRETCH : 0); + if (Math.abs(pullVelocity.current) > 0.5 || bend.current.position !== targetBend || bend.current.velocity !== 0 + || length.current.position !== targetLength || length.current.velocity !== 0) { + schedule(); + } else { + lastTime.current = null; + if (!active) dividerRef.current?.removeAttribute('data-resize-feedback'); + } + }); + }, [dividerRef, paintGrip, resetFeedback]); + + useLayoutEffect(() => { + if (collapsed) { dragRef.current?.(false); resetFeedback(); } + }, [collapsed, resetFeedback]); + + useLayoutEffect(() => () => { + dragRef.current?.(false); + resetFeedback(); + }, [resetFeedback]); + + useLayoutEffect(() => { + const preference = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)'); + const changed = () => { if (preference?.matches) resetFeedback(); }; + preference?.addEventListener('change', changed); + return () => preference?.removeEventListener('change', changed); + }, [resetFeedback]); + + const onPointerDown = useCallback((event: PointerEvent) => { + if (event.button !== 0 || collapsed || dragRef.current) return; + event.preventDefault(); + const divider = event.currentTarget; + const startX = event.clientX; + let previousX = startX; + let previousTime = performance.now(); + const pointerId = event.pointerId; + let latestWidth = width; + let widthFrame: number | null = null; + const previousUserSelect = document.body.style.userSelect; + + const applyWidth = () => { + widthFrame = null; + const value = `${latestWidth}px`; + areaRef.current?.style.setProperty('--nav-width', value); + divider.style.setProperty('--nav-width', value); + divider.setAttribute('aria-valuenow', String(Math.round(latestWidth))); + }; + const finish = (commit: boolean) => { + if (dragRef.current !== finish) return; + dragRef.current = null; + if (widthFrame !== null) cancelAnimationFrame(widthFrame); + if (!commit) latestWidth = width; + // Flush even when the final pointer event arrives between paint frames, + // including a drag that returns to the previously committed width. + applyWidth(); + window.removeEventListener('pointermove', move, true); + window.removeEventListener('pointerup', up, true); + window.removeEventListener('pointercancel', cancel, true); + window.removeEventListener('keydown', key, true); + window.removeEventListener('blur', cancel); + window.removeEventListener('resize', cancel); + divider.removeEventListener('lostpointercapture', lostCapture); + if (divider.hasPointerCapture?.(pointerId)) divider.releasePointerCapture(pointerId); + document.body.style.userSelect = previousUserSelect; + document.body.classList.remove('openbitfun-is-dragging-nav-collapse', 'openbitfun-is-resizing-nav'); + if (commit) { + onWidthChange(latestWidth); + pullVelocity.current = 0; + divider.setAttribute('data-resize-feedback', 'settling'); + animateFeedback(); + } else resetFeedback(); + }; + const update = (clientX: number) => { + const rawWidth = width + clientX - startX; + const nextWidth = clamp(rawWidth, minWidth, maxWidth); + const time = performance.now(); + const delta = clientX - previousX; + if (delta !== 0) { + // Bow toward the hand, including at a width limit. Reversals replace + // the old pull immediately instead of accumulating opposite inertia. + pullVelocity.current = clamp(delta / clamp((time - previousTime) / 1000, 0.001, 0.05), -2400, 2400); + } + previousX = clientX; + previousTime = time; + pressure.current = rawWidth - nextWidth; + latestWidth = nextWidth; + if (rawWidth <= minWidth - collapseThreshold) { + finish(true); + resetFeedback(); + onCollapse(); + return false; + } + animateFeedback(); + return true; + }; + function move(event: globalThis.PointerEvent) { + if (event.pointerId !== pointerId) return; + event.preventDefault(); + if (update(event.clientX) && widthFrame === null) widthFrame = requestAnimationFrame(applyWidth); + } + function up(event: globalThis.PointerEvent) { + if (event.pointerId === pointerId && update(event.clientX)) finish(true); + } + function cancel(event?: Event) { + if (event?.type === 'pointercancel' && (event as globalThis.PointerEvent).pointerId !== pointerId) return; + finish(false); + } + function lostCapture(event: globalThis.PointerEvent) { if (event.pointerId === pointerId) cancel(); } + function key(event: globalThis.KeyboardEvent) { + if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); cancel(); } + } + dragRef.current = finish; + divider.setPointerCapture?.(pointerId); + divider.setAttribute('data-resize-feedback', 'dragging'); + document.body.style.userSelect = 'none'; + document.body.classList.add('openbitfun-is-dragging-nav-collapse', 'openbitfun-is-resizing-nav'); + window.addEventListener('pointermove', move, { capture: true, passive: false }); + window.addEventListener('pointerup', up, true); + window.addEventListener('pointercancel', cancel, true); + window.addEventListener('keydown', key, true); + window.addEventListener('blur', cancel); + window.addEventListener('resize', cancel); + divider.addEventListener('lostpointercapture', lostCapture); + animateFeedback(); + }, [animateFeedback, areaRef, collapsed, collapseThreshold, maxWidth, minWidth, onCollapse, onWidthChange, resetFeedback, width]); + + const onKeyDown = useCallback((event: KeyboardEvent) => { + if (dragRef.current || collapsed) return; + const step = event.shiftKey ? 40 : 10; + let nextWidth: number; + switch (event.key) { + case 'ArrowLeft': nextWidth = width - step; break; + case 'ArrowRight': nextWidth = width + step; break; + case 'Home': nextWidth = minWidth; break; + case 'End': nextWidth = maxWidth; break; + default: return; + } + event.preventDefault(); + resetFeedback(); + onWidthChange(clamp(nextWidth, minWidth, maxWidth)); + }, [collapsed, maxWidth, minWidth, onWidthChange, resetFeedback, width]); + + return { onPointerDown, onKeyDown }; +} From d7f69e1bfdcf249ee566b07fefabe496ab5cc4d5 Mon Sep 17 00:00:00 2001 From: GCWing Date: Sun, 18 Oct 2026 11:15:07 +0800 Subject: [PATCH 2/4] feat(web-ui): add guided agent creation and immersive Markdown editing Introduce a three-step split-pane creation dialog with concise guidance, validation, and tool loading states. Reuse the application Markdown editor with persistent empty placeholders and automatic rendering of pasted Markdown source. --- .../components/CreateAgentDialog.test.tsx | 295 ++++++ .../agents/components/CreateAgentDialog.tsx | 14 +- .../components/CreateAgentPage.appearance.ts | 1 + .../CreateAgentPage.presentation.test.ts | 10 +- .../agents/components/CreateAgentPage.scss | 189 +++- .../agents/components/CreateAgentPage.tsx | 876 +++++++++++------- .../src/locales/en-US/scenes/agents.json | 20 +- .../src/locales/zh-CN/scenes/agents.json | 20 +- .../src/locales/zh-TW/scenes/agents.json | 20 +- src/web-ui/src/tools/editor/AGENTS.md | 7 + .../editor/components/MarkdownEditor.test.tsx | 9 +- .../editor/components/MarkdownEditor.tsx | 4 + .../meditor/components/MEditor.test.tsx | 191 ++++ .../editor/meditor/components/MEditor.tsx | 7 +- .../meditor/components/TiptapEditor.tsx | 37 +- .../src/tools/editor/meditor/types/index.ts | 2 + .../editor/meditor/utils/markdownClipboard.ts | 79 ++ 17 files changed, 1402 insertions(+), 379 deletions(-) create mode 100644 src/web-ui/src/app/scenes/agents/components/CreateAgentDialog.test.tsx create mode 100644 src/web-ui/src/tools/editor/meditor/utils/markdownClipboard.ts diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentDialog.test.tsx b/src/web-ui/src/app/scenes/agents/components/CreateAgentDialog.test.tsx new file mode 100644 index 0000000000..4717830b59 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentDialog.test.tsx @@ -0,0 +1,295 @@ +/** @vitest-environment jsdom */ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '@tiptap/core'; +import CreateAgentDialog from './CreateAgentDialog'; +import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; + +const mocks = vi.hoisted(() => ({ + create: vi.fn(), + tools: vi.fn(), + error: vi.fn(), + success: vi.fn(), + workspace: { id: 'workspace-1' } as { id: string } | null, +})); +vi.mock('@/infrastructure/i18n/hooks/useI18n', () => ({ + useI18n: () => ({ t: (key: string) => key, formatNumber: String }), +})); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ + useCurrentWorkspace: () => ({ workspace: mocks.workspace, hasWorkspace: Boolean(mocks.workspace) }), +})); +vi.mock('@/infrastructure/api/service-api/CustomAgentAPI', () => ({ + CustomAgentAPI: { createCustomAgent: mocks.create }, +})); +vi.mock('@/infrastructure/api/service-api/ToolAPI', () => ({ + toolAPI: { getAllToolsInfo: mocks.tools }, +})); +vi.mock('@/shared/notification-system', () => ({ + useNotification: () => ({ error: mocks.error, success: mocks.success }), +})); +vi.mock('../agentsStore', () => ({ + useAgentsStore: () => ({ openHome: vi.fn(), agentEditorMode: 'create', editingAgentId: null }), +})); +vi.mock('./useUserToolGroups', () => ({ + useUserToolGroups: () => ({ groups: [], saveGroups: vi.fn() }), +})); +vi.mock('@/infrastructure/confirm-dialog', () => ({ confirmDanger: vi.fn() })); +vi.mock('@/infrastructure/markdown', () => ({ + MarkdownRenderer: ({ content }: { content: string }) =>
{content}
, +})); + +const tools = [ + { name: 'Read', description: 'Read files', is_readonly: true }, + { name: 'Write', description: 'Write files', is_readonly: false }, + { name: 'WebFetch', description: 'Fetch pages', is_readonly: true }, +]; +let container: HTMLDivElement; +let root: Root; +let onCreated: ReturnType; +let onClose: ReturnType; +const copy = (key: string) => `agentsOverview.form.${key}`; +const dialog = () => document.querySelector('[role="dialog"]')!; +const field = (key: string) => dialog().querySelector(`[id$="-custom-agent-${key}"]`)!; +const promptGroup = () => dialog().querySelector('[id$="-custom-agent-prompt"]')!; +const promptEditor = () => (promptGroup().querySelector('.ProseMirror') as HTMLElement & { editor: Editor }).editor; +const button = (label: string) => [...dialog().querySelectorAll('button')].find(control => control.textContent === label)!; +const click = async (label: string) => { await act(async () => button(label).click()); }; +const submit = async () => { + await act(async () => dialog().querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))); +}; +const fill = (key: string, value: string) => { + if (key === 'prompt') { + act(() => promptEditor().commands.setContent({ + type: 'doc', + content: [{ type: 'paragraph', content: [{ type: 'text', text: value }] }], + })); + return; + } + const control = field(key); + const prototype = control instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + act(() => { + Object.getOwnPropertyDescriptor(prototype, 'value')!.set!.call(control, value); + control.dispatchEvent(new Event('input', { bubbles: true })); + }); +}; +const render = async () => { + await act(async () => root.render()); +}; +const completeBasics = async () => { + fill('id', 'research-helper'); + fill('name', 'Research helper'); + fill('description', 'Research a topic'); + await click(copy('wizard.next')); + await act(async () => { await vi.dynamicImportSettled(); }); +}; +const reachTools = async () => { + await completeBasics(); + fill('prompt', 'Return a summary with sources.'); + await click(copy('wizard.next')); +}; + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + Range.prototype.getClientRects = () => [] as unknown as DOMRectList; + Range.prototype.getBoundingClientRect = () => new DOMRect(); + vi.clearAllMocks(); + mocks.workspace = { id: 'workspace-1' }; + mocks.tools.mockReset().mockResolvedValue(tools); + mocks.create.mockReset().mockResolvedValue(undefined); + activateSurface('local'); + onCreated = vi.fn(); + onClose = vi.fn(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); + activateSurface('local'); +}); + +describe('guided agent creation', () => { + it('validates each stage and submits only on the tools stage with all drafts intact', async () => { + await render(); + expect(dialog().querySelectorAll('[aria-current="step"]')).toHaveLength(1); + expect(dialog().querySelector('textarea')).toBeNull(); + await submit(); + expect(field('id').getAttribute('aria-invalid')).toBe('true'); + expect(field('description').getAttribute('aria-invalid')).toBe('true'); + expect(document.activeElement).toBe(field('id')); + fill('id', '9-invalid'); + fill('description', 'Research a topic'); + await submit(); + expect(field('id').getAttribute('aria-invalid')).toBe('true'); + expect(mocks.create).not.toHaveBeenCalled(); + + await completeBasics(); + expect(promptGroup().querySelector('p[data-placeholder]')?.getAttribute('data-placeholder')).toBe(copy('promptPlaceholder')); + act(() => promptEditor().view.dom.focus()); + expect(promptGroup().querySelector('p[data-placeholder]')?.getAttribute('data-placeholder')).toBe(copy('promptPlaceholder')); + expect(dialog().querySelector('[id$="-custom-agent-id"]')).toBeNull(); + await submit(); + expect(promptGroup().getAttribute('aria-invalid')).toBe('true'); + expect(document.activeElement).toBe(promptEditor().view.dom); + fill('prompt', 'Return a summary with sources.'); + await submit(); + expect(mocks.create).not.toHaveBeenCalled(); + expect(dialog().querySelector('[data-testid="custom-agent-tool-groups"]')).not.toBeNull(); + const write = dialog().querySelector('input[aria-label^="Write"]')!; + expect(write.checked).toBe(true); + act(() => write.click()); + await click(copy('wizard.back')); + expect(promptEditor().getText()).toBe('Return a summary with sources.'); + await click(copy('wizard.back')); + expect(field('id').value).toBe('research-helper'); + expect(field('name').value).toBe('Research helper'); + expect(field('description').value).toBe('Research a topic'); + await submit(); + await submit(); + expect(dialog().querySelector('input[aria-label^="Write"]')!.checked).toBe(false); + await submit(); + expect(mocks.create).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + id: 'research-helper', name: 'Research helper', kind: 'mode', level: 'user', + description: 'Research a topic', prompt: 'Return a summary with sources.', + tools: ['Read', 'WebFetch'], userContextPolicy: ['workspace_context', 'workspace_instructions'], + })); + expect(onCreated).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it.each(['text', 'source-html', 'markdown-editor'])('renders pasted Markdown from %s and keeps its structure across steps', async (format) => { + await render(); + await completeBasics(); + expect(promptGroup().querySelector('textarea')).toBeNull(); + const markdown = '## Role\n\n{PERSONA}\n\n- Return evidence.'; + const sourceHtml = document.createElement('div'); + for (const line of markdown.split('\n')) { + sourceHtml.appendChild(document.createElement('div')).textContent = line || '\u00a0'; + } + const paste = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(paste, 'clipboardData', { value: { + getData: (type: string) => { + if (type === 'text/plain') return markdown; + if (type === 'text/html' && format !== 'text') return sourceHtml.outerHTML; + if (type === 'vscode-editor-data' && format === 'markdown-editor') return '{"version":1,"mode":"markdown"}'; + return ''; + }, + files: [], + types: format === 'text' ? ['text/plain'] : ['text/plain', 'text/html'], + } }); + act(() => { promptEditor().view.dom.dispatchEvent(paste); }); + expect(promptGroup().querySelector('h2')?.textContent).toBe('Role'); + expect(promptGroup().getAttribute('aria-invalid')).toBeNull(); + await click(copy('wizard.next')); + await click(copy('wizard.back')); + await act(async () => { await vi.dynamicImportSettled(); }); + expect(promptGroup().querySelector('h2')?.textContent).toBe('Role'); + expect(promptGroup().querySelector('li')?.textContent).toBe('Return evidence.'); + await click(copy('wizard.next')); + await submit(); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ + prompt: '## Role\n\n{PERSONA}\n\n- Return evidence.', + })); + }); + + it('keeps project subagent selection and enforces readonly tools for review', async () => { + await render(); + await click('filters.subagent'); + await click('agentsOverview.filterProject'); + await reachTools(); + const review = dialog().querySelector(`input[aria-label="${copy('review')}"]`)!; + act(() => review.click()); + const readonly = dialog().querySelector(`input[aria-label="${copy('readonly')}"]`)!; + expect(readonly.checked).toBe(true); + expect(readonly.disabled).toBe(true); + expect(dialog().querySelector('input[aria-label^="Write"]')).toBeNull(); + await submit(); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ + kind: 'subagent', level: 'project', workspaceId: 'workspace-1', review: true, readonly: true, tools: ['Read'], + })); + }); + + it('shows tool load errors and allows retry without losing the completed fields', async () => { + mocks.tools.mockRejectedValueOnce(new Error('Peer offline')); + await render(); + await reachTools(); + expect(dialog().querySelector('[role="alert"]')?.textContent).toBe(copy('wizard.toolsLoadFailed')); + expect(button(copy('submit')).disabled).toBe(true); + await submit(); + expect(mocks.create).not.toHaveBeenCalled(); + await click(copy('wizard.retry')); + expect(button(copy('submit')).disabled).toBe(false); + await submit(); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ id: 'research-helper', prompt: 'Return a summary with sources.' })); + }); + + it('blocks creation while tools load and preserves a failed submission for retry', async () => { + let resolveTools!: (value: typeof tools) => void; + mocks.tools.mockReturnValueOnce(new Promise(resolve => { resolveTools = resolve; })); + await render(); + await reachTools(); + expect(dialog().querySelector('[role="status"]')?.textContent).toBe(copy('wizard.loadingTools')); + expect(button(copy('submit')).disabled).toBe(true); + await submit(); + expect(mocks.create).not.toHaveBeenCalled(); + await act(async () => resolveTools(tools)); + mocks.create.mockRejectedValueOnce(new Error('Connection lost')); + await submit(); + expect(onCreated).not.toHaveBeenCalled(); + expect(mocks.error).toHaveBeenCalled(); + expect(button(copy('submit')).disabled).toBe(false); + await submit(); + expect(mocks.create).toHaveBeenCalledTimes(2); + expect(onCreated).toHaveBeenCalledOnce(); + }); + + it('preserves project scope and context choices when tools finish loading later', async () => { + let resolveTools!: (value: typeof tools) => void; + mocks.tools.mockReturnValueOnce(new Promise(resolve => { resolveTools = resolve; })); + await render(); + await click('filters.subagent'); + await click('agentsOverview.filterProject'); + await completeBasics(); + await click(copy('contextProjectLayout')); + fill('prompt', 'Review project changes.'); + await submit(); + const readonly = dialog().querySelector(`input[aria-label="${copy('readonly')}"]`)!; + act(() => readonly.click()); + await act(async () => resolveTools(tools)); + await submit(); + expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ + kind: 'subagent', level: 'project', workspaceId: 'workspace-1', readonly: false, + userContextPolicy: ['workspace_context', 'workspace_instructions', 'project_layout'], + })); + }); + + it('ignores completion from the previous peer after switching surfaces', async () => { + let finishCreate!: () => void; + mocks.create.mockReturnValueOnce(new Promise(resolve => { finishCreate = resolve; })); + await render(); + await reachTools(); + await submit(); + await act(async () => { activateSurface('peer-device'); }); + await act(async () => finishCreate()); + expect(onCreated).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(mocks.success).not.toHaveBeenCalled(); + expect(field('id').value).toBe(''); + }); + + it('resets the draft when the workspace or peer surface changes', async () => { + await render(); + await completeBasics(); + mocks.workspace = { id: 'workspace-2' }; + await render(); + expect(field('id').value).toBe(''); + expect(dialog().querySelector('textarea')).toBeNull(); + await completeBasics(); + await act(async () => { activateSurface('peer-device'); }); + expect(field('id').value).toBe(''); + expect(dialog().querySelector('textarea')).toBeNull(); + expect(mocks.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentDialog.tsx b/src/web-ui/src/app/scenes/agents/components/CreateAgentDialog.tsx index eb54ff7859..117f2c147b 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateAgentDialog.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentDialog.tsx @@ -1,4 +1,4 @@ -import { Dialog, DialogClose, DialogHeader, DialogHeading, DialogTitle } from '@openbitfun/ui'; +import { Dialog, DialogClose, DialogHeader, DialogHeaderActions, DialogHeading, DialogTitle } from '@openbitfun/ui'; import { useSyncExternalStore } from 'react'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; @@ -14,10 +14,14 @@ export default function CreateAgentDialog({ onClose, onCreated }: { const { workspace } = useCurrentWorkspace(); return ( - - - {t('agentsOverview.form.title')} - + + + + {t('agentsOverview.form.title')} + + + + { expect(source).toContain('data-openbitfun-part="contextPreview"'); expect(source).toContain('onClick={() => toggleContextSection(section)}'); - expect(source).toContain("aria-describedby={fieldId('runtime-context-preview')}"); + expect(source).toContain("aria-describedby={`${fieldId('runtime-context-preview')}"); expect(source).toContain('value={prompt}'); }); - it('uses the home-page content frame and a single column on both surfaces', () => { + it('keeps the page content frame and gives the dialog a responsive two-column layout', () => { const stylesheet = readLocalFile('./CreateAgentPage.scss'); expect(stylesheet).toContain( @@ -64,5 +64,11 @@ describe('CreateAgentPage presentation contract', () => { expect(stylesheet).toMatch( /@media \(max-width: 480px\)[\s\S]*\.th-create-panel__identity-fields[\s\S]*grid-template-columns: minmax\(0, 1fr\);/, ); + expect(stylesheet).toMatch( + /\.th-create-page--dialog \{[\s\S]*grid-template-columns: minmax\(240px, 0\.9fr\) minmax\(0, 1\.3fr\);/, + ); + expect(stylesheet).toMatch( + /@media \(max-width: 640px\)[\s\S]*\.th-create-page--dialog \{\s*grid-template-columns: minmax\(0, 1fr\);/, + ); }); }); diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss index 11228b60f5..0bcd2a4c82 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss @@ -5,8 +5,129 @@ background: var(--openbitfun-color-surface-scene); } +.th-create-dialog__header { + display: grid; + grid-template-columns: minmax(240px, 0.9fr) minmax(0, 1.3fr); + align-items: stretch; + gap: 0; + padding: 0; + background: var(--openbitfun-color-surface-scene); +} + +.th-create-dialog__heading { + justify-content: center; + padding: var(--openbitfun-space-6) var(--openbitfun-space-8); + background: var(--openbitfun-color-surface-chrome); +} + +.th-create-dialog__window-actions { + justify-content: flex-end; + padding: var(--openbitfun-space-6) var(--openbitfun-space-8); +} + .th-create-page--dialog { - height: min(760px, 70vh); + display: grid; + height: min(680px, 72dvh); + grid-template-columns: minmax(240px, 0.9fr) minmax(0, 1.3fr); + grid-template-rows: minmax(0, 1fr) auto; + + .th-create-page__body { + grid-column: 2; + grid-row: 1; + } + + .th-create-page__inner { + width: 100%; + min-height: 100%; + justify-content: center; + padding: var(--openbitfun-space-8); + } + + .th-create-page__columns, + .th-create-page__panel { + gap: var(--openbitfun-space-6); + } + + .th-create-panel__identity-fields { + grid-template-columns: minmax(0, 1fr); + gap: var(--openbitfun-space-5); + } + +} + +// The Markdown editor owns scrolling on this step. Keep the form's flex chain +// bounded so editing cannot grow a second scrollbar on the surrounding panel. +.th-create-page--prompt-step { + .th-create-page__body { + overflow: hidden; + scrollbar-gutter: auto; + } + + .th-create-page__inner { + height: 100%; + min-height: 0; + justify-content: flex-start; + } + + .th-create-page__form, + .th-create-page__columns, + .th-create-page__panel--capabilities, + .th-create-panel__field--prompt { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; + } + + .th-create-page__prompt-heading { + flex-shrink: 0; + } + + .th-create-panel__prompt-control { + height: auto; + } +} + +.th-create-page__guide { + display: flex; + min-height: 0; + grid-column: 1; + grid-row: 1 / -1; + flex-direction: column; + justify-content: space-between; + gap: var(--openbitfun-space-8); + overflow-y: auto; + padding: var(--openbitfun-space-8); + background: var(--openbitfun-color-surface-chrome); +} + +.th-create-page__guide-copy { + margin-block: auto; +} + +.th-create-page__steps { + display: flex; + align-items: center; + gap: var(--openbitfun-space-3); + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-label-sm-font-size); + line-height: var(--openbitfun-type-label-sm-line-height); +} + +.th-create-page__step { + color: var(--openbitfun-color-content-primary); + font-weight: var(--openbitfun-type-label-selected-font-weight); +} + +.th-create-page__wizard-footer { + grid-column: 2; + grid-row: 2; + padding: var(--openbitfun-space-4) var(--openbitfun-space-8); + + .th-create-page__actions { + width: 100%; + } } .th__list-body { @@ -213,7 +334,28 @@ .th-create-panel__prompt-control { display: flex; flex: 1; - min-height: 320px; + min-width: 0; + min-height: 0; + height: 420px; + overflow: hidden; +} + +.th-create-panel__markdown-editor { + flex: 1; + min-width: 0; + min-height: 0; + border: 0; + background: var(--openbitfun-color-surface-scene); + + .m-editor-content, + .m-editor-ir-panel { + min-width: 0; + min-height: 0; + } + + .m-editor-tiptap { + padding: 0; + } } .th-create-panel__identity-fields { @@ -311,6 +453,49 @@ } @media (max-width: 640px) { + .th-create-dialog__header { + grid-template-columns: minmax(0, 1fr) auto; + } + + .th-create-dialog__heading, + .th-create-dialog__window-actions { + padding: var(--openbitfun-space-4); + } + + .th-create-dialog__window-actions { + background: var(--openbitfun-color-surface-chrome); + } + + .th-create-page--dialog { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) auto; + height: min(740px, 76dvh); + + .th-create-page__body { + grid-column: 1; + grid-row: 2; + } + + .th-create-page__inner { + justify-content: flex-start; + padding: var(--openbitfun-space-5); + } + } + + .th-create-page__guide { + grid-column: 1; + grid-row: 1; + gap: var(--openbitfun-space-4); + overflow: visible; + padding: var(--openbitfun-space-4) var(--openbitfun-space-5); + } + + .th-create-page__wizard-footer { + grid-column: 1; + grid-row: 3; + padding-inline: var(--openbitfun-space-5); + } + .th-create-page__actions { width: 100%; diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx index bdb47ba861..2d7f6737a1 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx @@ -1,4 +1,4 @@ -import { Button, DialogBody, DialogFooter, Icon, Input, PageHeader, ScrollArea, Switch, Textarea, Tooltip, type IconSource } from '@openbitfun/ui'; +import { Button, DialogBody, DialogFooter, Icon, Input, PageHeader, ScrollArea, Switch, Tooltip, type IconSource } from '@openbitfun/ui'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FileText, @@ -27,6 +27,8 @@ import { useUserToolGroups } from './useUserToolGroups'; import '../AgentsView.scss'; import './CreateAgentPage.scss'; +const MEditor = React.lazy(() => import('@/tools/editor/meditor/components/MEditor').then((module) => ({ default: module.MEditor }))); + const ID_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const DEFAULT_MODE_POLICY: UserContextSection[] = [ 'workspace_context', @@ -100,7 +102,7 @@ interface CreateAgentPageProps { } const CreateAgentPage: React.FC = ({ standalone = false, onCancel, onCreated }) => { - const { t } = useI18n('scenes/agents'); + const { t, formatNumber } = useI18n('scenes/agents'); const notification = useNotification(); const { hasWorkspace, workspace } = useCurrentWorkspace(); const workspaceId = workspace?.id; @@ -109,6 +111,37 @@ const CreateAgentPage: React.FC = ({ standalone = false, o const formInstanceId = React.useId(); const formId = `${formInstanceId}-custom-agent-form`; const fieldId = (field: string) => `${formInstanceId}-custom-agent-${field}`; + const formRef = useRef(null); + const bodyRef = useRef(null); + const promptControlRef = useRef(null); + const [step, setStep] = useState(0); + const steps = [ + { + label: t('agentsOverview.form.basicInformation'), + title: t('agentsOverview.form.wizard.basicsTitle'), + description: t('agentsOverview.form.wizard.basicsDescription'), + }, + { + label: t('agentsOverview.form.prompt'), + title: t('agentsOverview.form.wizard.promptTitle'), + description: t('agentsOverview.form.wizard.promptDescription'), + }, + { + label: t('agentsOverview.form.tools'), + title: t('agentsOverview.form.wizard.toolsTitle'), + description: t('agentsOverview.form.wizard.toolsDescription'), + }, + ]; + + useEffect(() => { + if (!standalone) return; + if (bodyRef.current) bodyRef.current.scrollTop = 0; + // The Markdown editor owns focus once its lazy-loaded editing surface mounts. + if (step === 1) return; + const firstControl = formRef.current?.querySelector('input:not(:disabled), textarea') + ?? formRef.current?.querySelector('button:not(:disabled)'); + firstControl?.focus(); + }, [standalone, step]); const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; @@ -127,10 +160,15 @@ const CreateAgentPage: React.FC = ({ standalone = false, o const [agentIdError, setAgentIdError] = useState(null); const [name, setName] = useState(''); const [description, setDescription] = useState(''); + const [descriptionError, setDescriptionError] = useState(false); const [prompt, setPrompt] = useState(''); + const [promptError, setPromptError] = useState(false); const [readonly, setReadonly] = useState(defaultReadonlyForKind('mode')); const [review, setReview] = useState(false); const [toolInfos, setToolInfos] = useState([]); + const [toolsLoading, setToolsLoading] = useState(true); + const [toolsLoadError, setToolsLoadError] = useState(false); + const [toolsAttempt, setToolsAttempt] = useState(0); const [selectedTools, setSelectedTools] = useState>(new Set()); const [toolsEditing, setToolsEditing] = useState(false); const [pendingTools, setPendingTools] = useState | null>(null); @@ -142,9 +180,13 @@ const CreateAgentPage: React.FC = ({ standalone = false, o const [detailError, setDetailError] = useState(null); useEffect(() => { + let cancelled = false; + setToolsLoading(true); + setToolsLoadError(false); toolAPI .getAllToolsInfo() .then((tools) => { + if (cancelled) return; const normalizedTools = tools .map((tool): SubagentEditorToolInfo | null => { const toolName = typeof tool?.name === 'string' ? tool.name : ''; @@ -162,8 +204,16 @@ const CreateAgentPage: React.FC = ({ standalone = false, o .filter((tool): tool is SubagentEditorToolInfo => Boolean(tool)); setToolInfos(normalizedTools); }) - .catch(() => setToolInfos([])); - }, []); + .catch(() => { + if (cancelled) return; + setToolInfos([]); + setToolsLoadError(true); + }) + .finally(() => { + if (!cancelled) setToolsLoading(false); + }); + return () => { cancelled = true; }; + }, [toolsAttempt]); useEffect(() => { if (!hasWorkspace && level === 'project') { @@ -179,10 +229,10 @@ const CreateAgentPage: React.FC = ({ standalone = false, o setReadonly(defaultReadonlyForKind(kind)); setReview(false); setUserContextPolicy(new Set(defaultPolicyForKind(kind))); - setSelectedTools(defaultSelectedTools(toolInfos, kind, false)); + setSelectedTools(new Set()); setToolsEditing(false); setPendingTools(null); - }, [isEdit, kind, toolInfos]); + }, [isEdit, kind]); useEffect(() => { if (isEdit || toolInfos.length === 0) { @@ -329,6 +379,35 @@ const CreateAgentPage: React.FC = ({ standalone = false, o project_layout: t('agentsOverview.form.contextProjectLayoutTooltip'), }; + const focusField = (field: string) => { + if (field === 'prompt') { + promptControlRef.current?.querySelector('[contenteditable="true"], textarea')?.focus({ preventScroll: true }); + return; + } + const control = formRef.current?.elements.namedItem(fieldId(field)); + if (control instanceof HTMLElement) control.focus(); + }; + + const handleNext = () => { + if (step === 0) { + const idError = validateAgentId(agentId); + setAgentIdError(idError); + setDescriptionError(!description.trim()); + if (idError || !description.trim()) { + focusField(idError ? 'id' : 'description'); + return; + } + } + if (step === 1) { + setPromptError(!prompt.trim()); + if (!prompt.trim()) { + focusField('prompt'); + return; + } + } + setStep(step + 1); + }; + const handleSubmit = useCallback(async () => { const nextAgentIdError = validateAgentId(agentId); setAgentIdError(nextAgentIdError); @@ -481,6 +560,371 @@ const CreateAgentPage: React.FC = ({ standalone = false, o ); } + const permissionControls = ( +
+
+ + {t('agentsOverview.form.readonly')} + + handleReadonlyChange(event.target.checked)} + aria-label={t('agentsOverview.form.readonly')} + /> +
+ {kind === 'subagent' ? ( +
+ + {t('agentsOverview.form.review')} + + handleReviewChange(event.target.checked)} + aria-label={t('agentsOverview.form.review')} + /> +
+ ) : null} +
+ ); + const basicInformation = ( +
+
+

{t('agentsOverview.form.basicInformation')}

+
+ +
+ {t('agentsOverview.form.kind')} +
+ {(['mode', 'subagent'] as CustomAgentKind[]).map((candidateKind) => ( + + + + ))} +
+
+ +
+
+ + { + setAgentId(event.target.value); + setAgentIdError(validateAgentId(event.target.value)); + }} + onBlur={() => setAgentIdError(validateAgentId(agentId))} + placeholder={t('agentsOverview.form.idPlaceholder')} + invalid={!!agentIdError} + aria-describedby={agentIdError ? fieldId('id-error') : undefined} + disabled={isEdit} + size="sm" + /> + {agentIdError ? ( + {agentIdError} + ) : null} +
+ +
+ + setName(event.target.value)} + placeholder={t('agentsOverview.form.namePlaceholder')} + size="sm" + /> +
+
+ +
+ + { + setDescription(event.target.value); + setDescriptionError(false); + }} + invalid={descriptionError} + aria-describedby={descriptionError ? fieldId('description-error') : undefined} + placeholder={t('agentsOverview.form.descPlaceholder')} + size="sm" + /> + {descriptionError && ( + + {t('agentsOverview.form.descRequired')} + + )} +
+ + {kind === 'subagent' ? ( +
+ {t('agentsOverview.form.level')} +
+ {(['user', 'project'] as CustomAgentLevel[]).map((candidateLevel) => { + const disabled = + (candidateLevel === 'project' && !hasWorkspace) || isEdit; + return ( + + ); + })} +
+
+ ) : null} + {!standalone && permissionControls} +
+ ); + const toolSelection = selectableTools.length > 0 ? ( +
+
+
+

{t('agentsOverview.form.tools')}

+ + {kind === 'subagent' && review + ? t('agentsOverview.form.reviewToolsHint') + : t('agentsOverview.form.toolsHint', { + optionalLabel: t('agentsOverview.form.toolsOptional'), + })} + +
+ {standalone ? null : toolsEditing ? ( +
+ + +
+ ) : ( + + )} +
+ {standalone ? ( + setSelectedTools(new Set(toolNames))} + onSaveUserGroups={saveUserToolGroups} + disabled={submitting} + testId="custom-agent-tool-groups" + /> + ) : toolsEditing ? ( + + setPendingTools(new Set(toolNames))} + onSaveUserGroups={saveUserToolGroups} + disabled={submitting} + testId="custom-agent-tool-groups" + /> + + ) : ( + + + + )} +
+ ) : null; + const promptEditor = ( +
+

+ + {t('agentsOverview.form.prompt')} + +

+
+
+ + {t('agentsOverview.form.contextPreviewLabel')} + +
+ {VISIBLE_CONTEXT_SECTIONS.map((section) => { + const label = contextSectionLabels[section]; + const isSelected = userContextPolicy.has(section); + return ( + + + + + + ); + })} +
+
+
+ {t('agentsOverview.form.loadingDetail')}

}> + { + setPrompt(value); + setPromptError(false); + }} + placeholder={t('agentsOverview.form.promptPlaceholder')} + emptyDocumentPlaceholder={t('agentsOverview.form.promptPlaceholder')} + mode="ir" + toolbar={false} + height="100%" + readonly={submitting} + autofocus={standalone} + className="th-create-panel__markdown-editor" + /> +
+
+
+ {promptError && ( + + {t('agentsOverview.form.promptRequired')} + + )} +
+ ); + const formActions = ( <> + {standalone && step > 0 && ( + + )} ); const page = ( -
- +
+ {standalone && ( + + )} +
{!standalone && (
)} -
{ event.preventDefault(); - if (submitting || toolsEditing) { + if (submitting || toolsEditing) return; + if (standalone && step < 2) { + handleNext(); return; } + if (standalone && (toolsLoading || toolsLoadError)) return; void handleSubmit(); }} >
-
-
-
-

{t('agentsOverview.form.basicInformation')}

-
- -
- {t('agentsOverview.form.kind')} -
- {(['mode', 'subagent'] as CustomAgentKind[]).map((candidateKind) => ( - - - - ))} -
-
- -
-
- - { - setAgentId(event.target.value); - setAgentIdError(validateAgentId(event.target.value)); - }} - onBlur={() => setAgentIdError(validateAgentId(agentId))} - placeholder={t('agentsOverview.form.idPlaceholder')} - invalid={!!agentIdError} - disabled={isEdit} - size="sm" - /> - {agentIdError ? ( - {agentIdError} - ) : null} -
- -
- - setName(event.target.value)} - placeholder={t('agentsOverview.form.namePlaceholder')} - size="sm" - /> -
-
- -
- - setDescription(event.target.value)} - placeholder={t('agentsOverview.form.descPlaceholder')} - size="sm" - /> -
- - {kind === 'subagent' ? ( -
- {t('agentsOverview.form.level')} -
- {(['user', 'project'] as CustomAgentLevel[]).map((candidateLevel) => { - const disabled = - (candidateLevel === 'project' && !hasWorkspace) || isEdit; - return ( - - ); - })} -
-
- ) : null} - -
-
- - {t('agentsOverview.form.readonly')} - - handleReadonlyChange(event.target.checked)} - aria-label={t('agentsOverview.form.readonly')} - /> -
- {kind === 'subagent' ? ( -
- - {t('agentsOverview.form.review')} - - handleReviewChange(event.target.checked)} - aria-label={t('agentsOverview.form.review')} - /> -
- ) : null} -
-
- - {selectableTools.length > 0 ? ( - <> -
-
-
-

{t('agentsOverview.form.tools')}

- - {kind === 'subagent' && review - ? t('agentsOverview.form.reviewToolsHint') - : t('agentsOverview.form.toolsHint', { - optionalLabel: t('agentsOverview.form.toolsOptional'), - })} - -
- {toolsEditing ? ( -
- - -
- ) : ( - - )} -
- {toolsEditing ? ( - - setPendingTools(new Set(toolNames))} - onSaveUserGroups={saveUserToolGroups} - disabled={submitting} - testId="custom-agent-tool-groups" - /> - +
) : ( - - - + <> + {toolSelection} + {standalone && selectableTools.length === 0 && ( +

{t('agentsOverview.form.wizard.noTools')}

+ )} + )} -
- - ) : null} - - -
-
-

- -

-
-
- - {t('agentsOverview.form.contextPreviewLabel')} - -
- {VISIBLE_CONTEXT_SECTIONS.map((section) => { - const label = contextSectionLabels[section]; - const isSelected = userContextPolicy.has(section); - return ( - - - - - - ); - })} -
-
-
-