import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, } from 'react'; import { MessageCircle, Plus, Send, X, LoaderCircle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { backendClient, useCurrentWorkspace, userInfo } from '@/app/infra/http'; import { Button } from '@/components/ui/button'; import DynamicFormItemComponent from './dynamic-form/DynamicFormItemComponent'; import { DynamicFormItemType } from '@/app/infra/entities/form/dynamic'; import AssistantToolResult, { AssistantTool } from './AssistantToolResult'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { ASSISTANT_RAIL_WIDTH, clampAssistantPosition as clampInViewport, resolveAssistantEdge, shouldCollapseRail, shouldExpandRail, type AssistantDragPosition, type AssistantEdge, } from './assistant-dock'; const ASSISTANT_LONG_PRESS_MS = 260; function viewport(): { width: number; height: number } { return { width: window.innerWidth, height: window.innerHeight }; } function clampAssistantPosition(x: number, y: number): AssistantDragPosition { return clampInViewport(x, y, viewport()); } type Conversation = { uuid: string; revision: number; status: 'ready' | 'running' | 'approval' | 'failed'; messages: { role: string; content: string; tool?: AssistantTool }[]; pending: { name: string; arguments: Record }[]; error: string | null; model_name: string | null; model_uuid: string | null; }; export default function WorkspaceAssistant() { const workspace = useCurrentWorkspace(); if ( !workspace?.permissions.includes('runtime.operate') || !userInfo?.account_uuid ) return null; const identity = `${workspace.workspace.uuid}:${userInfo.account_uuid}`; return ( ); } function AssistantPanel({ storageKey }: { storageKey: string }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [conversation, setConversation] = useState(null); const [text, setText] = useState(''); const [sending, setBusy] = useState(false); const [loading, setLoading] = useState(false); const busy = sending || loading; const [error, setError] = useState(false); const [modelUuid, setModelUuid] = useState(''); const [pendingText, setPendingText] = useState(null); const controller = useRef(new AbortController()); const end = useRef(null); const [dragPosition, setDragPosition] = useState(null); const [dragging, setDragging] = useState(false); const [dockedEdge, setDockedEdge] = useState(null); // The rail collapses only while the pointer is away. Hovering any part of the // control restores the full button, and the two states never race because // every transition is derived from the same `dockedEdge` snapshot. const [railExpanded, setRailExpanded] = useState(false); const pointerOver = useRef(false); const containerRef = useRef(null); const hoverLocked = useRef(false); const dragState = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number; active: boolean; } | null>(null); const longPressTimer = useRef(null); const suppressClick = useRef(false); const buttonRef = useRef(null); function clearLongPressTimer() { if (longPressTimer.current !== null) { window.clearTimeout(longPressTimer.current); longPressTimer.current = null; } } useEffect(() => clearLongPressTimer, []); /* * Seed the resting position from the rendered default (bottom-right) so the * very first visit already docks and collapses. Without this the button would * only ever dock after a manual drag, which reads as "collapse is broken". */ useEffect(() => { if (window.localStorage.getItem(`${storageKey}:button-position`)) return; const rect = buttonRef.current?.getBoundingClientRect(); if (!rect) return; const position = clampAssistantPosition(rect.left, rect.top); setDragPosition(position); const edge = resolveAssistantEdge(position.x, viewport()); setDockedEdge(edge); if (edge) { try { window.localStorage.setItem(`${storageKey}:button-docked-edge`, edge); } catch { // Persisting the dock is best-effort only. } } }, [storageKey]); useEffect(() => { const stored = window.localStorage.getItem(`${storageKey}:button-position`); if (stored) { try { const parsed = JSON.parse(stored) as AssistantDragPosition; if (typeof parsed?.x === 'number' && typeof parsed?.y === 'number') { const position = clampAssistantPosition(parsed.x, parsed.y); setDragPosition(position); setDockedEdge(resolveAssistantEdge(position.x, viewport())); } } catch { window.localStorage.removeItem(`${storageKey}:button-position`); } } const storedEdge = window.localStorage.getItem( `${storageKey}:button-docked-edge`, ); if (storedEdge === 'left' || storedEdge === 'right') { setDockedEdge((current) => current ?? storedEdge); } }, [storageKey]); useEffect(() => { const onResize = () => { setDragPosition((prev) => { if (!prev) return prev; const next = clampAssistantPosition(prev.x, prev.y); setDockedEdge(resolveAssistantEdge(next.x, viewport())); return next; }); }; window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); function persistDragPosition(position: AssistantDragPosition) { try { window.localStorage.setItem( `${storageKey}:button-position`, JSON.stringify(position), ); } catch { // Ignore storage failures (private mode or quota); dragging still works. } } function applyRestingPosition(position: AssistantDragPosition) { const edge = resolveAssistantEdge(position.x, viewport()); setDragPosition(position); setDockedEdge(edge); // A fresh dock always collapses; the rail re-expands on the next hover. setRailExpanded(false); // Hold the collapse until the pointer leaves, otherwise the still-hovering // cursor would fight the new state. if (edge) lockHoverUntilPointerExit(); try { if (edge) { window.localStorage.setItem(`${storageKey}:button-docked-edge`, edge); } else { window.localStorage.removeItem(`${storageKey}:button-docked-edge`); } } catch { // Persisting the dock is best-effort only. } } function endDrag(commit: boolean, clientX?: number, clientY?: number) { const state = dragState.current; clearLongPressTimer(); dragState.current = null; if (!state?.active) return; setDragging(false); const next = clampAssistantPosition( state.originX + ((clientX ?? state.startX) - state.startX), state.originY + ((clientY ?? state.startY) - state.startY), ); if (commit) { applyRestingPosition(next); persistDragPosition(next); } else { setDragPosition(next); } } /* * Rail hover recovery. The subtle race: a drag usually ends with the pointer * still sitting on the button, so the browser fires no new `pointerenter` * once the button collapses. Re-expanding on `pointermove` would therefore * undo the collapse immediately. * * Instead the drop "locks" hover until the pointer physically leaves the * control. A window-level move listener watches for that exit (the element * can shift under a stationary cursor, so `pointerleave` alone is not * reliable) and clears the lock; only then does hovering reveal the button. */ function handlePointerEnter() { pointerOver.current = true; if (hoverLocked.current) return; if (shouldExpandRail({ dockedEdge, railExpanded, dragging })) setRailExpanded(true); } function handlePointerLeave() { pointerOver.current = false; hoverLocked.current = false; // Never collapse mid-drag; the drop handler owns the final state. if (dragState.current || dragging) return; if (shouldCollapseRail({ dockedEdge, railExpanded, dragging })) setRailExpanded(false); } /* * A click or an aborted swipe never arms the long press, so no drop handler * runs. Re-sync the rail from the real pointer position on release, otherwise * a button expanded by hover would stay expanded with the cursor gone. */ function settleRailAfterRelease() { if (pointerOver.current || dragState.current || dragging) return; if (shouldCollapseRail({ dockedEdge, railExpanded, dragging })) setRailExpanded(false); } function lockHoverUntilPointerExit() { hoverLocked.current = true; const releaseOnExit = (moveEvent: PointerEvent) => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; const outside = moveEvent.clientX < rect.left || moveEvent.clientX > rect.right || moveEvent.clientY < rect.top || moveEvent.clientY > rect.bottom; if (!outside) return; hoverLocked.current = false; window.removeEventListener('pointermove', releaseOnExit); }; window.addEventListener('pointermove', releaseOnExit); } function onButtonPointerDown(event: ReactPointerEvent) { if (event.button !== 0) return; suppressClick.current = false; const rect = buttonRef.current?.getBoundingClientRect(); if (!rect) return; const state = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: rect.left, originY: rect.top, active: false, }; dragState.current = state; clearLongPressTimer(); const onWindowMove = (moveEvent: PointerEvent) => { if (moveEvent.pointerId !== state.pointerId) return; const deltaX = moveEvent.clientX - state.startX; const deltaY = moveEvent.clientY - state.startY; if (!state.active) { // Cancel the long-press when the user is clearly scrolling or swiping. if (Math.hypot(deltaX, deltaY) > 8) clearLongPressTimer(); return; } moveEvent.preventDefault(); setDragPosition( clampAssistantPosition(state.originX + deltaX, state.originY + deltaY), ); }; const onWindowUp = (upEvent: PointerEvent) => { if (upEvent.pointerId !== state.pointerId) return; window.removeEventListener('pointermove', onWindowMove); window.removeEventListener('pointerup', onWindowUp); window.removeEventListener('pointercancel', onWindowUp); endDrag(true, upEvent.clientX, upEvent.clientY); }; window.addEventListener('pointermove', onWindowMove, { passive: false }); window.addEventListener('pointerup', onWindowUp); window.addEventListener('pointercancel', onWindowUp); // Release outside a drag still needs to reconcile the rail. window.addEventListener('pointerup', settleRailAfterRelease, { once: true, }); longPressTimer.current = window.setTimeout(() => { if (dragState.current !== state) return; state.active = true; setDragging(true); suppressClick.current = true; setDragPosition(clampAssistantPosition(state.originX, state.originY)); }, ASSISTANT_LONG_PRESS_MS); } function onButtonClick(event: ReactMouseEvent) { // After a drag the trailing click must not toggle the panel. Radix's // trigger skips its own toggle when the event default is prevented. if (suppressClick.current) { suppressClick.current = false; event.preventDefault(); event.stopPropagation(); } } useEffect(() => { if (conversation?.model_uuid) setModelUuid(conversation.model_uuid); }, [conversation?.model_uuid]); useEffect(() => { const abort = new AbortController(); controller.current = abort; return () => abort.abort(); }, []); useEffect(() => { if (!open || busy || (conversation && conversation.status !== 'running')) return; const id = localStorage.getItem(storageKey); if (!id) return; let active = true; setLoading(true); backendClient .request({ method: 'GET', url: `/api/v1/assistant/conversations/${encodeURIComponent(id)}`, signal: controller.current.signal, }) .then((value) => { if (active) setConversation(value); }) .catch(() => { if (active) { localStorage.removeItem(storageKey); setError(true); } }) .finally(() => { if (active) setLoading(false); }); return () => { active = false; setLoading(false); }; // Load only when opening; turn requests own subsequent state updates. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, storageKey]); // `open` is a dependency so a freshly opened panel jumps to the newest turn // instead of leaving the user at the oldest message. The rAF waits for the // popover to lay out before measuring the sentinel. useEffect(() => { if (!open) return; const frame = window.requestAnimationFrame(() => { end.current?.scrollIntoView({ behavior: 'auto', block: 'nearest' }); }); return () => window.cancelAnimationFrame(frame); }, [open, conversation, busy, pendingText]); async function submit(approved?: boolean) { if ( busy || (approved === undefined && (!text.trim() || (conversation && conversation.status !== 'ready'))) ) return; const sentText = approved === undefined ? text.trim() : null; const sentRevision = conversation?.revision ?? 0; if (sentText) { setPendingText(sentText); setText(''); } setBusy(true); setError(false); try { let current = conversation; if (!current) { current = await backendClient.request({ method: 'POST', url: '/api/v1/assistant/conversations', signal: controller.current.signal, }); localStorage.setItem(storageKey, current.uuid); setConversation(current); } const updated = await backendClient.request({ method: 'POST', url: `/api/v1/assistant/conversations/${current.uuid}/turn`, data: { revision: current.revision, ...(approved === undefined ? { text: sentText, ...(modelUuid ? { model_uuid: modelUuid } : {}), } : { approved }), }, timeout: 130000, signal: controller.current.signal, }); setConversation(updated); if (sentText) setPendingText(null); } catch { setError(true); // A lost response may already have executed a write. Refresh, never replay. const id = localStorage.getItem(storageKey); if (id && !controller.current.signal.aborted) { try { const latest = await backendClient.request({ method: 'GET', url: `/api/v1/assistant/conversations/${encodeURIComponent(id)}`, signal: controller.current.signal, }); setConversation(latest); if ( sentText && latest.revision > sentRevision && latest.messages.some( (message) => message.role === 'user' && message.content === sentText, ) ) setPendingText(null); } catch { /* Keep the error visible; do not retry a turn. */ } } } finally { setBusy(false); } } function reset() { localStorage.removeItem(storageKey); setConversation(null); setText(''); setError(false); setPendingText(null); } // Collapse only when docked, idle and not hovered. The container keeps its // resting box, so the hidden button and the visible strip share one anchor // and cannot drift apart; only the strip is painted while collapsed. const railCollapsed = !!dockedEdge && !railExpanded && !dragging; const inlinePosition = dragPosition ? { left: dragPosition.x, top: dragPosition.y } : undefined; return (
{railCollapsed && (