From 88257fe0e2625f5e4a7dce0f2cf72ea95b0a0dd4 Mon Sep 17 00:00:00 2001 From: TyperBody Date: Fri, 25 Sep 2026 13:17:44 +0800 Subject: [PATCH] Merge origin/master into experiment/in-process-assistant Resolve conflicts in the workspace assistant integration: - DynamicFormItemComponent: keep HEAD's compact model selector and `disabled` field support while adopting master's `sortModelsByCatalog` ordering and `MODEL_SELECT_TRIGGER_CLASS`. - i18n (en-US, ja-JP, zh-Hans): keep both the `assistant` namespace from HEAD and master's `sidebarGuide` / `pipelineMigration` additions. Add 0030_merge_assistant to join the assistant conversations branch with the released chain so the migration graph converges on a single head. Rename it from 0030_merge_assistant_conversations to stay within the 32 character revision limit enforced by test_migrations. Also add assistant button docking (long-press drag, edge collapse to a short blue rail, hover restore) with pure helpers in assistant-dock.ts and unit coverage, plus auto-collapse for finished tool result cards. --- ...nversations.py => 0030_merge_assistant.py} | 8 +- .../home/components/AssistantToolResult.tsx | 53 ++- .../home/components/WorkspaceAssistant.tsx | 323 +++++++++++++++++- web/src/app/home/components/assistant-dock.ts | 80 +++++ web/tests/unit/assistant-dock-rail.test.mjs | 132 +++++++ 5 files changed, 589 insertions(+), 7 deletions(-) rename src/langbot/pkg/persistence/alembic/versions/{0030_merge_assistant_conversations.py => 0030_merge_assistant.py} (79%) create mode 100644 web/src/app/home/components/assistant-dock.ts create mode 100644 web/tests/unit/assistant-dock-rail.test.mjs diff --git a/src/langbot/pkg/persistence/alembic/versions/0030_merge_assistant_conversations.py b/src/langbot/pkg/persistence/alembic/versions/0030_merge_assistant.py similarity index 79% rename from src/langbot/pkg/persistence/alembic/versions/0030_merge_assistant_conversations.py rename to src/langbot/pkg/persistence/alembic/versions/0030_merge_assistant.py index b10f74996..95ca872fa 100644 --- a/src/langbot/pkg/persistence/alembic/versions/0030_merge_assistant_conversations.py +++ b/src/langbot/pkg/persistence/alembic/versions/0030_merge_assistant.py @@ -1,14 +1,16 @@ """Join the workspace assistant conversations branch with the released chain. -Revision ID: 0030_merge_assistant_conversations +Revision ID: 0030_merge_assistant Revises: 0029_merge_rag_identity, 0025_assistant_conversations The assistant conversation tables ship independently of the released schema chain. Both parents are already published, so this revision only joins the -graph; Alembic applies the missing branch before this no-op merge. +graph; Alembic applies the missing branch before this no-op merge. The +identifier is kept within the 32 character limit enforced by the migration +graph test. """ -revision = '0030_merge_assistant_conversations' +revision = '0030_merge_assistant' down_revision = ('0029_merge_rag_identity', '0025_assistant_conversations') branch_labels = None depends_on = None diff --git a/web/src/app/home/components/AssistantToolResult.tsx b/web/src/app/home/components/AssistantToolResult.tsx index 94895a627..8b7e489e3 100644 --- a/web/src/app/home/components/AssistantToolResult.tsx +++ b/web/src/app/home/components/AssistantToolResult.tsx @@ -1,4 +1,10 @@ -import { CheckCircle2, CircleAlert, MinusCircle } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { + CheckCircle2, + ChevronDown, + CircleAlert, + MinusCircle, +} from 'lucide-react'; import { useTranslation } from 'react-i18next'; export type AssistantTool = { @@ -10,11 +16,21 @@ export type AssistantTool = { export default function AssistantToolResult({ tool, content, + defaultCollapsed = false, }: { tool?: AssistantTool; content: string; + /** Turn finished: collapse to one line until the user expands it again. */ + defaultCollapsed?: boolean; }) { const { t } = useTranslation(); + // Track manual toggles so a completing turn cannot fight the user's choice. + const [collapsed, setCollapsed] = useState(defaultCollapsed); + const [manual, setManual] = useState(false); + + useEffect(() => { + if (!manual) setCollapsed(defaultCollapsed); + }, [defaultCollapsed, manual]); const result = tool?.result; const data = result && typeof result === 'object' && !Array.isArray(result) @@ -57,6 +73,29 @@ export default function AssistantToolResult({ ? tool.arguments.name : null; + if (collapsed) { + return ( + + ); + } + return (
@@ -67,6 +106,18 @@ export default function AssistantToolResult({ {tool && t(`assistant.${status}`)} +
{failed ? (

{t('assistant.operationFailed')}

diff --git a/web/src/app/home/components/WorkspaceAssistant.tsx b/web/src/app/home/components/WorkspaceAssistant.tsx index 22bf9e5be..4651f9f73 100644 --- a/web/src/app/home/components/WorkspaceAssistant.tsx +++ b/web/src/app/home/components/WorkspaceAssistant.tsx @@ -1,4 +1,10 @@ -import { useEffect, useRef, useState } from 'react'; +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'; @@ -13,6 +19,26 @@ import { PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; +import { + ASSISTANT_BUTTON_SIZE, + 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; @@ -55,6 +81,253 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { 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 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() { + if (hoverLocked.current) return; + if (shouldExpandRail({ dockedEdge, railExpanded, dragging })) + setRailExpanded(true); + } + + function handlePointerLeave() { + 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); + } + + 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); + + 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]); @@ -183,18 +456,61 @@ function AssistantPanel({ storageKey }: { storageKey: string }) { 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 && ( +