Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@
}

.actionLabel {
flex: 0 0 auto;
min-inline-size: 0;
overflow: hidden;
flex: 0 1 auto;
color: var(--openbitfun-color-content-primary);
font-weight: var(--_tool-card-title-font-weight);
white-space: nowrap;
Expand Down Expand Up @@ -481,15 +483,16 @@
}

.ambientAction {
flex: 0 0 auto;
overflow: hidden;
flex: 0 1 auto;
color: inherit;
font-weight: var(--_tool-card-title-font-weight);
white-space: nowrap;
}

.ambientContent {
overflow: hidden;
flex: 1 1 auto;
flex: 1 1 0;
color: inherit;
font-weight: var(--_tool-card-content-font-weight);
white-space: nowrap;
Expand All @@ -513,9 +516,21 @@
}

.ambientExpandedShell .ambientSurface {
/* Once enclosed, use the same header geometry as prominent cards.
Collapsed traces retain their compact, flush transcript alignment. */
min-block-size: var(--openbitfun-control-height-sm);
gap: var(--openbitfun-space-2);
padding-block: var(--openbitfun-space-1);
padding-inline: var(--openbitfun-space-3) var(--openbitfun-space-1);
line-height: var(--openbitfun-type-heading-page-line-height);
color: var(--openbitfun-color-content-primary);
}

.ambientExpandedShell .ambientSurface .iconSlot {
--_tool-card-action-size: var(--openbitfun-space-6);
--_flow-chat-tool-card-icon-size: var(--openbitfun-font-size-xl);
}

.ambientRoot[data-openbitfun-state~="loading"] .ambientAction,
.ambientRoot[data-openbitfun-state~="loading"] .ambientContent,
.prominentRoot[data-openbitfun-state~="loading"] .actionLabel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,9 @@ export function ProminentToolCardSummary({
data-openbitfun-part="action"
data-testid={actionTestId}
>
{action}
{typeof action === "string" || typeof action === "number"
? <OverflowText>{action}</OverflowText>
: action}
</span>
)}
{content !== undefined && content !== null && content !== false && (
Expand Down Expand Up @@ -821,7 +823,9 @@ export function AmbientToolCardHeader({
data-openbitfun-component="flow-chat-tool-card"
data-openbitfun-part="action"
>
{action}
{typeof action === "string" || typeof action === "number"
? <OverflowText>{action}</OverflowText>
: action}
</span>
)}
{content !== undefined && content !== null && content !== false && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@
.imageButton {
display: inline-flex;
inline-size: 10rem;
max-inline-size: calc(100vi - 4.75rem);
max-inline-size: 100%;
aspect-ratio: 1;
align-items: center;
justify-content: center;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ export function ViewImageToolCard({
) : undefined}
header={(
<AmbientToolCardHeader
action={statusText}
content={statusText}
icon={<ToolCardStatusSlot size={14} status={status} toolIcon={<ImageIcon aria-hidden="true" />} />}
/>
)}
Expand Down
21 changes: 18 additions & 3 deletions design-system/packages/ui/tests/flow-chat-tool-card.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ test("FlowChat tool-card shells stay flat at rest and on hover", async () => {
assert.doesNotMatch(styles, /box-shadow\s+var\(--_tool-card-transition\)/);
});

test("ambient tool-card summary geometry stays stable while details expand", async () => {
test("expanded ambient headers match prominent cards while collapsed traces stay compact", async () => {
const styles = await readFile(
new URL("../src/flow-chat/tool-cards/FlowChatToolCard.module.css", import.meta.url),
"utf8",
Expand All @@ -302,15 +302,30 @@ test("ambient tool-card summary geometry stays stable while details expand", asy
const expandedAmbientSurfaceRule = styles.match(
/\.ambientExpandedShell \.ambientSurface\s*\{([^}]*)\}/s,
)?.[1];
const prominentSummaryRule = styles.match(/\.prominentSummary\s*\{([^}]*)\}/s)?.[1];
const expandedIconRule = styles.match(
/\.ambientExpandedShell \.ambientSurface \.iconSlot\s*\{([^}]*)\}/s,
)?.[1];

assert.ok(ambientSurfaceRule);
assert.ok(expandedAmbientSurfaceRule);
assert.ok(prominentSummaryRule);
assert.ok(expandedIconRule);
assert.match(
ambientSurfaceRule,
/min-block-size:\s*max\(1lh,\s*var\(--openbitfun-control-tool-card-ambient-row-min-block-size\)\)/,
);
assert.doesNotMatch(ambientSurfaceRule, /--openbitfun-control-height-sm/);
assert.doesNotMatch(expandedAmbientSurfaceRule, /min-block-size|padding/);
for (const property of ["min-block-size", "gap", "padding-block", "padding-inline", "line-height"]) {
const declaration = new RegExp(`${property}:\\s*([^;]+);`);
assert.equal(
expandedAmbientSurfaceRule.match(declaration)?.[1],
prominentSummaryRule.match(declaration)?.[1],
`expanded ambient ${property} must match the prominent header`,
);
}
assert.match(expandedIconRule, /--_tool-card-action-size:\s*var\(--openbitfun-space-6\)/);
assert.match(expandedIconRule, /--_flow-chat-tool-card-icon-size:\s*var\(--openbitfun-font-size-xl\)/);
});

test("ambient tool-card collapse has no delayed shell state or layout-changing shell chrome", async () => {
Expand Down Expand Up @@ -521,7 +536,7 @@ test("standard FlowChat tool views publish their concrete component contracts",
assert.match(commandMarkup, /57 tests passed/);
assert.match(deleteMarkup, /data-openbitfun-operation="delete"/);
assert.match(deleteMarkup, /data-openbitfun-attention="ambient"/);
assert.match(deleteMarkup, /data-openbitfun-part="action">Delete file<\/span>/);
assert.match(deleteMarkup, /data-openbitfun-part="action"><span[^>]*data-overflow="false"[^>]*><span[^>]*data-overflow-content="">Delete file<\/span><\/span><\/span>/);
assert.match(deleteMarkup, /data-openbitfun-part="content">/);
assert.match(editMarkup, /data-openbitfun-operation="edit"/);
assert.match(editMarkup, /data-openbitfun-attention="prominent"/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
position: relative;
background: transparent;
user-select: none;
-webkit-user-select: none;

/* Holds the dock anchored bottom-right, above the click-away backdrop. */
&__stack {
Expand Down Expand Up @@ -172,6 +173,8 @@
place-items: center;
cursor: grab;
pointer-events: auto;
touch-action: none;
-webkit-user-drag: none;

&:active {
cursor: grabbing;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ const invokeMock = vi.hoisted(() => vi.fn(() => Promise.resolve()));
const cursorPositionMock = vi.hoisted(() => vi.fn(() => Promise.resolve({ x: 0, y: 0 })));
const startDraggingMock = vi.hoisted(() => vi.fn(() => Promise.resolve()));
const controlledDragMock = vi.hoisted(() => vi.fn());
const pointerDrag = vi.hoisted(() => ({ prepare: vi.fn(), move: vi.fn(), finish: vi.fn(), cancel: vi.fn() }));
vi.mock('@/infrastructure/config/services/AgentCompanionDragService', () => ({ startAgentCompanionDrag: controlledDragMock }));
vi.mock('@/infrastructure/config/services/AgentCompanionPointerDragService', () => ({ prepareAgentCompanionPointerDrag: pointerDrag.prepare }));
/** Backs the Tauri IPC bridge, so window resize requests are observable. */
const hostInvokeMock = vi.hoisted(() => vi.fn(() => Promise.resolve()));

Expand Down Expand Up @@ -142,6 +144,8 @@ describe('AgentCompanionDesktopPet', () => {
element.dispatchEvent(new window.MouseEvent(type, {
bubbles: true,
cancelable: true,
screenX: at?.clientX,
screenY: at?.clientY,
...at,
}));
});
Expand All @@ -156,6 +160,8 @@ describe('AgentCompanionDesktopPet', () => {
startDraggingMock.mockReset();
startDraggingMock.mockResolvedValue(undefined);
controlledDragMock.mockReset();
pointerDrag.prepare.mockReset().mockReturnValue(pointerDrag);
pointerDrag.move.mockReset(); pointerDrag.finish.mockReset(); pointerDrag.cancel.mockReset();
listenMock.mockReset();

const activityListeners: Array<(event: { payload: AgentCompanionActivityPayload }) => void> = [];
Expand Down Expand Up @@ -266,8 +272,8 @@ describe('AgentCompanionDesktopPet', () => {
controlledDragMock.mockReturnValue(stop);
try {
vi.resetModules();
const { AgentCompanionDesktopPet: WindowsPet } = await import('./AgentCompanionDesktopPet');
await act(async () => root.render(<I18nextProvider i18n={i18n}><WindowsPet /></I18nextProvider>));
const { AgentCompanionDesktopPet: PlatformPet } = await import('./AgentCompanionDesktopPet');
await act(async () => root.render(<I18nextProvider i18n={i18n}><PlatformPet /></I18nextProvider>));
const hitbox = query('.openbitfun-agent-companion-window__pet-hitbox')!;
dispatch(hitbox, 'pointerdown', { clientX: 100, clientY: 100 });
dispatch(hitbox, 'pointermove', { clientX: 120, clientY: 100 });
Expand All @@ -277,6 +283,8 @@ describe('AgentCompanionDesktopPet', () => {
expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-mood')).toBe('dragging');
act(() => controlledDragMock.mock.calls[0][0]('left'));
expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-direction')).toBe('left');
act(() => controlledDragMock.mock.calls[0][0]('right'));
expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-direction')).toBe('right');
dispatch(hitbox, 'pointerup', { clientX: 120, clientY: 100 });
expect(stop).toHaveBeenCalledTimes(1);
expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-action')).toBe('waving');
Expand All @@ -285,11 +293,54 @@ describe('AgentCompanionDesktopPet', () => {
dispatch(hitbox, 'pointercancel');
expect(stop).toHaveBeenCalledTimes(2);
expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-action')).toBe('none');
expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-mood')).not.toBe('dragging');

dispatch(hitbox, 'pointerdown', { clientX: 100, clientY: 100 });
dispatch(hitbox, 'pointermove', { clientX: 80, clientY: 100 });
dispatch(hitbox, 'lostpointercapture');
expect(stop).toHaveBeenCalledTimes(3);
expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-mood')).not.toBe('dragging');
} finally {
userAgent.mockRestore();
}
});

it('uses captured screen coordinates on macOS and prevents native text selection at pointer-down', async () => {
const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Macintosh; Intel Mac OS X 10_15_7');
try {
vi.resetModules();
const { AgentCompanionDesktopPet: MacPet } = await import('./AgentCompanionDesktopPet');
await act(async () => root.render(<I18nextProvider i18n={i18n}><MacPet /></I18nextProvider>));
const hitbox = query('.openbitfun-agent-companion-window__pet-hitbox')!;
const down = new MouseEvent('pointerdown', {
bubbles: true, cancelable: true, clientX: 100, clientY: 100, screenX: 100, screenY: 100,
});
act(() => hitbox.dispatchEvent(down));
expect(down.defaultPrevented).toBe(true);
expect(pointerDrag.prepare).toHaveBeenCalledWith({ x: 100, y: 100 }, expect.any(Function), expect.any(Function));
dispatch(hitbox, 'pointermove', { clientX: 120, clientY: 100 });
await act(async () => Promise.resolve());
expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-mood')).toBe('dragging');
expect(pointerDrag.move).toHaveBeenLastCalledWith({ x: 120, y: 100 });
expect(startDraggingMock).not.toHaveBeenCalled();
expect(controlledDragMock).not.toHaveBeenCalled();
act(() => pointerDrag.prepare.mock.calls[0][1]('left'));
expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-direction')).toBe('left');
dispatch(hitbox, 'pointermove', { clientX: 80, clientY: 100 });
expect(pointerDrag.move).toHaveBeenLastCalledWith({ x: 80, y: 100 });
dispatch(hitbox, 'pointerup', { clientX: 70, clientY: 100 });
expect(pointerDrag.move).toHaveBeenLastCalledWith({ x: 70, y: 100 });
expect(pointerDrag.finish).toHaveBeenCalledTimes(1);
expect(pointerDrag.cancel).not.toHaveBeenCalled();
expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-action')).toBe('waving');
dispatch(hitbox, 'pointerdown', { clientX: 100, clientY: 100 });
dispatch(hitbox, 'pointermove', { clientX: 120, clientY: 100 });
dispatch(hitbox, 'lostpointercapture');
expect(pointerDrag.cancel).toHaveBeenCalledTimes(1);
expect(query('[data-testid="pixel-pet"]')?.getAttribute('data-mood')).not.toBe('dragging');
} finally { userAgent.mockRestore(); }
});

it('closes the desktop pet from the pet context menu', () => {
const hitbox = query('.openbitfun-agent-companion-window__pet-hitbox');
expect(hitbox).not.toBeNull();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime';
import { isReducedMotionPreferred } from '@/shared/utils/motionPreference';
import { getPetLookDirection } from '@/infrastructure/config/services/agentCompanionPetSprite';
import { startAgentCompanionDrag } from '@/infrastructure/config/services/AgentCompanionDragService';
import { prepareAgentCompanionPointerDrag, type CompanionPointerDrag } from '@/infrastructure/config/services/AgentCompanionPointerDragService';
import './AgentCompanionDesktopPet.scss';

const log = createLogger('AgentCompanionDesktopPet');
Expand All @@ -35,9 +36,13 @@ const BUBBLE_OUTPUT_TYPEWRITER_INTERVAL_MS = 28;
const WINDOW_EDGE_BUFFER = 4;
const POINTER_HOVER_POLL_INTERVAL_MS = 120;
const PET_LOOK_HOLD_MS = 960;
/** Clicks shorter/smaller than this use `show_main_window`; beyond it we start a native drag. */
/** Clicks shorter/smaller than this use `show_main_window`; beyond it we start dragging. */
const PET_DRAG_THRESHOLD_PX = 8;
const IS_WINDOWS_WEBVIEW = /\bWindows\b/i.test(window.navigator.userAgent);
const IS_MACOS_WEBVIEW = /\bMacintosh\b/i.test(window.navigator.userAgent);
// AppKit's native drag returns immediately and may consume mouse-up. Keep pointer
// capture on macOS too, so running direction and drag lifetime follow the pointer.
const USE_CONTROLLED_PET_DRAG = IS_WINDOWS_WEBVIEW || IS_MACOS_WEBVIEW;
const PET_COMMAND_EVENT = 'agent-companion://pet-command';
const MENU_EDGE_MARGIN = 4;

Expand Down Expand Up @@ -156,7 +161,11 @@ export const AgentCompanionDesktopPet: React.FC = () => {
const [isDraggingPet, setIsDraggingPet] = useState(false);
const [dragDirection, setDragDirection] = useState<'left' | 'right'>('right');
const stopDragRef = useRef<(() => void) | null>(null);
useEffect(() => () => stopDragRef.current?.(), []);
const pointerDragRef = useRef<CompanionPointerDrag | null>(null);
useEffect(() => () => {
stopDragRef.current?.();
pointerDragRef.current?.cancel();
}, []);
const [petFrameSize, setPetFrameSize] = useState<{ width: number; height: number } | null>(null);
const [overlay, setOverlay] = useState<PetOverlayState>(null);
const [menuAnchor, setMenuAnchor] = useState<MenuAnchor | null>(null);
Expand Down Expand Up @@ -493,7 +502,7 @@ export const AgentCompanionDesktopPet: React.FC = () => {
});

void tauriWindow.onMoved(event => {
if (!IS_WINDOWS_WEBVIEW && petPointerSessionRef.current?.dragStarted && windowPosition && event.payload.x !== windowPosition.x) {
if (!USE_CONTROLLED_PET_DRAG && petPointerSessionRef.current?.dragStarted && windowPosition && event.payload.x !== windowPosition.x) {
setDragDirection(event.payload.x > windowPosition.x ? 'right' : 'left');
}
windowPosition = event.payload;
Expand Down Expand Up @@ -807,6 +816,8 @@ export const AgentCompanionDesktopPet: React.FC = () => {
return;
}
petPointerSessionRef.current = null;
pointerDragRef.current?.cancel();
pointerDragRef.current = null;
stopDragRef.current?.();
stopDragRef.current = null;
setIsDraggingPet(false);
Expand All @@ -821,6 +832,9 @@ export const AgentCompanionDesktopPet: React.FC = () => {
if (event.button !== 0) {
return;
}
// WebKit can start a native text/image selection before our drag threshold
// is reached. Cancel that default at pointer-down, while retaining capture.
event.preventDefault();
// The bubble composer stays interactive while open (it lives inside a
// bubble), so touching the pet is what dismisses it.
if (overlay?.kind === 'composer') {
Expand All @@ -832,6 +846,19 @@ export const AgentCompanionDesktopPet: React.FC = () => {
startY: event.clientY,
dragStarted: false,
};
if (IS_MACOS_WEBVIEW) {
const target = event.currentTarget;
const session = petPointerSessionRef.current;
pointerDragRef.current?.cancel();
pointerDragRef.current = prepareAgentCompanionPointerDrag(
{ x: event.screenX, y: event.screenY },
setDragDirection,
error => {
log.warn('Failed to move Agent companion window', error);
if (petPointerSessionRef.current === session) clearPetPointerSession(target, session.pointerId);
},
);
}
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
Expand All @@ -841,7 +868,11 @@ export const AgentCompanionDesktopPet: React.FC = () => {

const onPetPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const session = petPointerSessionRef.current;
if (!session || event.pointerId !== session.pointerId || session.dragStarted) {
if (!session || event.pointerId !== session.pointerId) {
return;
}
if (session.dragStarted) {
pointerDragRef.current?.move({ x: event.screenX, y: event.screenY });
return;
}
const dx = event.clientX - session.startX;
Expand All @@ -854,6 +885,10 @@ export const AgentCompanionDesktopPet: React.FC = () => {
setDragDirection(dx < 0 ? 'left' : 'right');
setIsDraggingPet(true);
setReaction(null);
if (IS_MACOS_WEBVIEW) {
pointerDragRef.current?.move({ x: event.screenX, y: event.screenY });
return;
}
if (IS_WINDOWS_WEBVIEW) {
const target = event.currentTarget;
stopDragRef.current = startAgentCompanionDrag(setDragDirection, error => {
Expand Down Expand Up @@ -882,6 +917,11 @@ export const AgentCompanionDesktopPet: React.FC = () => {
return;
}
const shouldShowMain = !session.dragStarted;
if (session.dragStarted && pointerDragRef.current) {
pointerDragRef.current.move({ x: event.screenX, y: event.screenY });
pointerDragRef.current.finish();
pointerDragRef.current = null;
}
clearPetPointerSession(event.currentTarget, event.pointerId);
if (session.dragStarted) setReaction({ action: 'waving' });
if (shouldShowMain) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe('ContextCompressionDisplay', () => {
});

expect(container.querySelector('[data-openbitfun-part="action"]')?.textContent).toBe('Context compression:');
expect(container.querySelector('[data-openbitfun-part="content"]')?.textContent).toBe(
expect(container.querySelector('[data-openbitfun-component="flow-chat-tool-card"][data-openbitfun-part="content"]')?.textContent).toBe(
'Compressed context length 31,000 (compression ratio 75%)',
);
expect(container.querySelector('[data-openbitfun-part="tokenChange"]')).toBeNull();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -786,7 +786,7 @@ describe('FileOperationToolCard', () => {
);
});

const contentRegion = container.querySelector('[data-openbitfun-part="content"]');
const contentRegion = container.querySelector('[data-openbitfun-component="flow-chat-tool-card"][data-openbitfun-part="content"]');
const extraRegion = container.querySelector('[data-openbitfun-part="extra"]');
const changeSummary = extraRegion?.querySelector('[data-openbitfun-part="changeSummary"]');

Expand Down
Loading
Loading