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.
This commit is contained in:
TyperBody
2026-09-25 13:17:44 +08:00
parent f316958619
commit 88257fe0e2
5 changed files with 589 additions and 7 deletions
@@ -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
@@ -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 (
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg border bg-background px-3 py-1.5 text-left text-xs hover:bg-muted"
aria-expanded={false}
onClick={() => {
setManual(true);
setCollapsed(false);
}}
>
<Icon
className={`size-3.5 shrink-0 ${failed ? 'text-destructive' : 'text-muted-foreground'}`}
/>
<span className="truncate font-medium">{label}</span>
<span className="ml-auto shrink-0 text-muted-foreground">
{tool && t(`assistant.${status}`)}
</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</button>
);
}
return (
<section className="space-y-2 rounded-xl border bg-background p-3 text-sm">
<div className="flex items-center gap-2">
@@ -67,6 +106,18 @@ export default function AssistantToolResult({
<span className="ml-auto text-xs text-muted-foreground">
{tool && t(`assistant.${status}`)}
</span>
<button
type="button"
className="shrink-0 text-muted-foreground hover:text-foreground"
aria-label={t('assistant.details')}
aria-expanded
onClick={() => {
setManual(true);
setCollapsed(true);
}}
>
<ChevronDown className="size-3.5 rotate-180" />
</button>
</div>
{failed ? (
<p className="text-destructive">{t('assistant.operationFailed')}</p>
@@ -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<HTMLDivElement>(null);
const [dragPosition, setDragPosition] = useState<AssistantDragPosition | null>(
null,
);
const [dragging, setDragging] = useState(false);
const [dockedEdge, setDockedEdge] = useState<AssistantEdge>(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<HTMLDivElement>(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<number | null>(null);
const suppressClick = useRef(false);
const buttonRef = useRef<HTMLButtonElement>(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<HTMLButtonElement>) {
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<HTMLButtonElement>) {
// 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 (
<div className="fixed bottom-20 right-5 z-50">
<div
ref={containerRef}
className={
dragPosition
? 'fixed z-50'
: 'fixed bottom-20 right-5 z-50'
}
style={inlinePosition}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={buttonRef}
size="icon"
className="size-12 rounded-full shadow-lg"
aria-label={t('assistant.title')}
aria-expanded={railCollapsed ? false : undefined}
className={`size-12 rounded-full shadow-lg ${
dragging
? 'cursor-grabbing scale-105'
: railCollapsed
? 'cursor-pointer'
: 'cursor-grab'
} transition-[transform,opacity] duration-200`}
style={{
touchAction: 'none',
opacity: railCollapsed ? 0 : 1,
}}
onClick={onButtonClick}
onPointerDown={onButtonPointerDown}
onContextMenu={(event) => event.preventDefault()}
>
<MessageCircle className="size-6" />
</Button>
</PopoverTrigger>
{railCollapsed && (
<span
role="presentation"
aria-hidden="true"
className={`pointer-events-none absolute top-1/2 h-6 -translate-y-1/2 rounded-full bg-[#3b82f6] shadow-md ${
dockedEdge === 'right' ? 'right-0' : 'left-0'
}`}
style={{ width: ASSISTANT_RAIL_WIDTH }}
/>
)}
<PopoverContent
align="end"
side="top"
@@ -287,6 +603,7 @@ function AssistantPanel({ storageKey }: { storageKey: string }) {
key={index}
tool={message.tool}
content={message.content}
defaultCollapsed={!busy && conversation.status === 'ready'}
/>
) : (
<div
@@ -0,0 +1,80 @@
/**
* Pure geometry + state helpers for the floating workspace assistant button.
*
* The component keeps these decisions here so the dock/rail transitions stay
* testable without a DOM. The key invariant is that the *same* edge value
* drives the collapsed strip and the expanded button, so they can never drift
* apart or fight each other (the race the UI is careful to avoid).
*/
export const ASSISTANT_BUTTON_SIZE = 48;
/** Distance to the viewport edge that snaps the button into the rail. */
export const ASSISTANT_SNAP_THRESHOLD = 24;
/** Rail strip that stays visible while collapsed, in pixels. */
export const ASSISTANT_RAIL_WIDTH = 6;
export type AssistantDragPosition = { x: number; y: number };
export type AssistantEdge = 'left' | 'right' | null;
/** Viewport box the button may occupy. Kept explicit so tests can pin a size. */
export type AssistantViewport = { width: number; height: number };
export function clampAssistantPosition(
x: number,
y: number,
viewport: AssistantViewport,
): AssistantDragPosition {
const maxX = Math.max(0, viewport.width - ASSISTANT_BUTTON_SIZE);
const maxY = Math.max(0, viewport.height - ASSISTANT_BUTTON_SIZE);
return {
x: Math.min(Math.max(0, x), maxX),
y: Math.min(Math.max(0, y), maxY),
};
}
/**
* Decide whether a resting position is docked against a vertical edge.
* The left edge wins ties so a centred drag always resolves deterministically.
*/
export function resolveAssistantEdge(
x: number,
viewport: AssistantViewport,
): AssistantEdge {
const maxX = Math.max(0, viewport.width - ASSISTANT_BUTTON_SIZE);
const leftGap = x;
const rightGap = maxX - x;
if (leftGap <= ASSISTANT_SNAP_THRESHOLD && leftGap <= rightGap) return 'left';
if (rightGap <= ASSISTANT_SNAP_THRESHOLD) return 'right';
return null;
}
export type AssistantHoverInput = {
dockedEdge: AssistantEdge;
railExpanded: boolean;
/** A long press has armed the drag, or the pointer is actively moving. */
dragging: boolean;
};
/** Hovering any part of the control must reveal the full button again. */
export function shouldExpandRail(input: AssistantHoverInput): boolean {
if (input.dragging) return false;
return !!input.dockedEdge && !input.railExpanded;
}
/**
* A docked button collapses to the strip only when it is idle and the pointer
* has left. The panel being open pins it, because the popover is anchored.
*/
export function shouldCollapseRail(input: AssistantHoverInput): boolean {
if (input.dragging) return false;
return !!input.dockedEdge && !input.railExpanded;
}
/**
* After a drop the button always starts collapsed, even though the pointer is
* still over it. The next pointermove re-expands it, which avoids the
* "drops under the cursor and never comes back" race.
*/
export function restingRailExpanded(): boolean {
return false;
}
+132
View File
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
// The floating assistant button docks against a viewport edge, collapses into a
// short blue rail, and re-expands on hover. The transition rules are pure so
// they can be pinned here: the race this guards against is a button that drops
// under the cursor and never becomes visible again.
const sourcePath = new URL(
'../../src/app/home/components/assistant-dock.ts',
import.meta.url,
);
assert.ok(fs.existsSync(sourcePath), 'Missing assistant dock module');
const compiled = ts.transpileModule(fs.readFileSync(sourcePath, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const loaded = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
() => {
throw new Error('assistant-dock must stay dependency free');
},
loaded,
loaded.exports,
);
const {
ASSISTANT_BUTTON_SIZE,
ASSISTANT_SNAP_THRESHOLD,
ASSISTANT_RAIL_WIDTH,
clampAssistantPosition,
resolveAssistantEdge,
shouldCollapseRail,
shouldExpandRail,
restingRailExpanded,
} = loaded.exports;
const viewport = { width: 1280, height: 800 };
const maxX = viewport.width - ASSISTANT_BUTTON_SIZE;
test('clamping keeps the button fully inside the viewport', () => {
assert.deepEqual(clampAssistantPosition(-50, -50, viewport), { x: 0, y: 0 });
assert.deepEqual(clampAssistantPosition(9999, 9999, viewport), {
x: maxX,
y: viewport.height - ASSISTANT_BUTTON_SIZE,
});
assert.deepEqual(clampAssistantPosition(400, 300, viewport), {
x: 400,
y: 300,
});
});
test('a narrow viewport never produces a negative travel range', () => {
assert.deepEqual(
clampAssistantPosition(10, 10, { width: 20, height: 20 }),
{ x: 0, y: 0 },
);
});
test('positions within the snap threshold dock to the nearest edge', () => {
assert.equal(resolveAssistantEdge(0, viewport), 'left');
assert.equal(resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD, viewport), 'left');
assert.equal(resolveAssistantEdge(maxX, viewport), 'right');
assert.equal(
resolveAssistantEdge(maxX - ASSISTANT_SNAP_THRESHOLD, viewport),
'right',
);
});
test('a centred position stays free floating', () => {
assert.equal(resolveAssistantEdge(Math.round(maxX / 2), viewport), null);
assert.equal(
resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD + 1, viewport),
null,
);
});
test('the left edge wins ties so a centred drop is deterministic', () => {
// A viewport sized so both gaps are inside the snap threshold at x = 0.
const tight = { width: ASSISTANT_BUTTON_SIZE, height: 400 };
assert.equal(resolveAssistantEdge(0, tight), 'left');
});
test('hovering a docked rail expands it, hovering a free button does not', () => {
assert.equal(
shouldExpandRail({ dockedEdge: 'right', railExpanded: false, dragging: false }),
true,
);
assert.equal(
shouldExpandRail({ dockedEdge: null, railExpanded: false, dragging: false }),
false,
);
// Already expanded: nothing to do, so no redundant state churn on every move.
assert.equal(
shouldExpandRail({ dockedEdge: 'left', railExpanded: true, dragging: false }),
false,
);
});
test('a drag in progress never expands or collapses the rail', () => {
const dragging = { dockedEdge: 'left', railExpanded: false, dragging: true };
assert.equal(shouldExpandRail(dragging), false);
assert.equal(shouldCollapseRail(dragging), false);
});
test('a docked, idle button is the only state that collapses', () => {
assert.equal(
shouldCollapseRail({ dockedEdge: 'left', railExpanded: false, dragging: false }),
true,
);
assert.equal(
shouldCollapseRail({ dockedEdge: null, railExpanded: false, dragging: false }),
false,
);
});
test('dropping always starts collapsed so the rail is never stuck open', () => {
// The pointer is still over the button on drop; the next pointermove expands.
assert.equal(restingRailExpanded(), false);
assert.equal(
shouldExpandRail({
dockedEdge: 'right',
railExpanded: restingRailExpanded(),
dragging: false,
}),
true,
);
});
test('the rail strip stays thinner than the button it replaces', () => {
assert.ok(ASSISTANT_RAIL_WIDTH > 0);
assert.ok(ASSISTANT_RAIL_WIDTH < ASSISTANT_BUTTON_SIZE);
});