diff --git a/design-system/packages/ui/src/flow-chat/tool-cards/FlowChatToolCard.module.css b/design-system/packages/ui/src/flow-chat/tool-cards/FlowChatToolCard.module.css
index 92e3407025..9787d1c458 100644
--- a/design-system/packages/ui/src/flow-chat/tool-cards/FlowChatToolCard.module.css
+++ b/design-system/packages/ui/src/flow-chat/tool-cards/FlowChatToolCard.module.css
@@ -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;
@@ -481,7 +483,8 @@
}
.ambientAction {
- flex: 0 0 auto;
+ overflow: hidden;
+ flex: 0 1 auto;
color: inherit;
font-weight: var(--_tool-card-title-font-weight);
white-space: nowrap;
@@ -489,7 +492,7 @@
.ambientContent {
overflow: hidden;
- flex: 1 1 auto;
+ flex: 1 1 0;
color: inherit;
font-weight: var(--_tool-card-content-font-weight);
white-space: nowrap;
@@ -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,
diff --git a/design-system/packages/ui/src/flow-chat/tool-cards/FlowChatToolCard.tsx b/design-system/packages/ui/src/flow-chat/tool-cards/FlowChatToolCard.tsx
index 0db70d155d..4e3fbce22e 100644
--- a/design-system/packages/ui/src/flow-chat/tool-cards/FlowChatToolCard.tsx
+++ b/design-system/packages/ui/src/flow-chat/tool-cards/FlowChatToolCard.tsx
@@ -699,7 +699,9 @@ export function ProminentToolCardSummary({
data-openbitfun-part="action"
data-testid={actionTestId}
>
- {action}
+ {typeof action === "string" || typeof action === "number"
+ ? {action}
+ : action}
)}
{content !== undefined && content !== null && content !== false && (
@@ -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"
+ ? {action}
+ : action}
)}
{content !== undefined && content !== null && content !== false && (
diff --git a/design-system/packages/ui/src/flow-chat/tool-cards/StandardAmbientToolCards.module.css b/design-system/packages/ui/src/flow-chat/tool-cards/StandardAmbientToolCards.module.css
index 663d561a85..d6832f96c8 100644
--- a/design-system/packages/ui/src/flow-chat/tool-cards/StandardAmbientToolCards.module.css
+++ b/design-system/packages/ui/src/flow-chat/tool-cards/StandardAmbientToolCards.module.css
@@ -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;
diff --git a/design-system/packages/ui/src/flow-chat/tool-cards/StandardAmbientToolCards.tsx b/design-system/packages/ui/src/flow-chat/tool-cards/StandardAmbientToolCards.tsx
index 46adf81397..36a228fd45 100644
--- a/design-system/packages/ui/src/flow-chat/tool-cards/StandardAmbientToolCards.tsx
+++ b/design-system/packages/ui/src/flow-chat/tool-cards/StandardAmbientToolCards.tsx
@@ -336,7 +336,7 @@ export function ViewImageToolCard({
) : undefined}
header={(
} />}
/>
)}
diff --git a/design-system/packages/ui/tests/flow-chat-tool-card.test.mjs b/design-system/packages/ui/tests/flow-chat-tool-card.test.mjs
index 981a34815f..8e78552fb6 100644
--- a/design-system/packages/ui/tests/flow-chat-tool-card.test.mjs
+++ b/design-system/packages/ui/tests/flow-chat-tool-card.test.mjs
@@ -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",
@@ -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 () => {
@@ -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">]*data-overflow="false"[^>]*>]*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"/);
diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss
index 5a7244de6e..e8622ff507 100644
--- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss
+++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss
@@ -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 {
@@ -172,6 +173,8 @@
place-items: center;
cursor: grab;
pointer-events: auto;
+ touch-action: none;
+ -webkit-user-drag: none;
&:active {
cursor: grabbing;
diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx
index 553ce81cbe..12c39bbe8f 100644
--- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx
+++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx
@@ -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()));
@@ -142,6 +144,8 @@ describe('AgentCompanionDesktopPet', () => {
element.dispatchEvent(new window.MouseEvent(type, {
bubbles: true,
cancelable: true,
+ screenX: at?.clientX,
+ screenY: at?.clientY,
...at,
}));
});
@@ -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> = [];
@@ -266,8 +272,8 @@ describe('AgentCompanionDesktopPet', () => {
controlledDragMock.mockReturnValue(stop);
try {
vi.resetModules();
- const { AgentCompanionDesktopPet: WindowsPet } = await import('./AgentCompanionDesktopPet');
- await act(async () => root.render());
+ const { AgentCompanionDesktopPet: PlatformPet } = await import('./AgentCompanionDesktopPet');
+ await act(async () => root.render());
const hitbox = query('.openbitfun-agent-companion-window__pet-hitbox')!;
dispatch(hitbox, 'pointerdown', { clientX: 100, clientY: 100 });
dispatch(hitbox, 'pointermove', { clientX: 120, clientY: 100 });
@@ -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');
@@ -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());
+ 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();
diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx
index 2f94d5bdaa..95fa70d791 100644
--- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx
+++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx
@@ -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');
@@ -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;
@@ -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(null);
+ useEffect(() => () => {
+ stopDragRef.current?.();
+ pointerDragRef.current?.cancel();
+ }, []);
const [petFrameSize, setPetFrameSize] = useState<{ width: number; height: number } | null>(null);
const [overlay, setOverlay] = useState(null);
const [menuAnchor, setMenuAnchor] = useState(null);
@@ -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;
@@ -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);
@@ -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') {
@@ -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 {
@@ -841,7 +868,11 @@ export const AgentCompanionDesktopPet: React.FC = () => {
const onPetPointerMove = (event: React.PointerEvent) => {
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;
@@ -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 => {
@@ -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) {
diff --git a/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.test.tsx
index 43a8e4cf10..d634133406 100644
--- a/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.test.tsx
+++ b/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.test.tsx
@@ -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();
diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx
index b82e729444..70edd3de42 100644
--- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx
+++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx
@@ -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"]');
diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionPointerDragService.test.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionPointerDragService.test.ts
new file mode 100644
index 0000000000..68a7a69331
--- /dev/null
+++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionPointerDragService.test.ts
@@ -0,0 +1,65 @@
+import { beforeEach, expect, it, vi } from 'vitest';
+import { prepareAgentCompanionPointerDrag } from './AgentCompanionPointerDragService';
+const mocks = vi.hoisted(() => ({ position: vi.fn(), scale: vi.fn(), move: vi.fn() }));
+vi.mock('@tauri-apps/api/window', () => ({
+ LogicalPosition: class { constructor(public x: number, public y: number) {} },
+ getCurrentWindow: () => ({ outerPosition: mocks.position, scaleFactor: mocks.scale, setPosition: mocks.move }),
+}));
+const settle = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); };
+beforeEach(() => {
+ mocks.position.mockReset().mockResolvedValue({ x: 7360, y: 1824 });
+ mocks.scale.mockReset().mockResolvedValue(2);
+ mocks.move.mockReset().mockResolvedValue(undefined);
+});
+it('uses Retina logical screen coordinates and follows both directions without a global cursor poll', async () => {
+ const direction = vi.fn();
+ const drag = prepareAgentCompanionPointerDrag({ x: 3760, y: 974 }, direction, vi.fn());
+ await settle();
+ drag.move({ x: 3690, y: 974 }); await settle();
+ expect(mocks.move).toHaveBeenLastCalledWith(expect.objectContaining({ x: 3610, y: 912 }));
+ expect(direction).toHaveBeenLastCalledWith('left');
+ drag.move({ x: 3790, y: 964 }); await settle();
+ expect(mocks.move).toHaveBeenLastCalledWith(expect.objectContaining({ x: 3710, y: 902 }));
+ expect(direction).toHaveBeenLastCalledWith('right');
+ drag.cancel();
+ drag.move({ x: 4000, y: 964 }); await settle();
+ expect(mocks.move).toHaveBeenCalledTimes(2);
+});
+it('applies the final move even when a quick release precedes origin acquisition', async () => {
+ let resolve!: (point: { x: number; y: number }) => void;
+ mocks.position.mockReturnValue(new Promise(done => { resolve = done; }));
+ const drag = prepareAgentCompanionPointerDrag({ x: 30, y: 40 }, vi.fn(), vi.fn());
+ drag.move({ x: 10, y: 60 }); drag.finish();
+ drag.move({ x: 500, y: 500 });
+ resolve({ x: -200, y: -80 }); await settle();
+ expect(mocks.move).toHaveBeenCalledOnce();
+ expect(mocks.move).toHaveBeenCalledWith(expect.objectContaining({ x: -120, y: -20 }));
+});
+it('coalesces pending movement and flushes the last position on release', async () => {
+ let resolve!: () => void;
+ mocks.move.mockReturnValueOnce(new Promise(done => { resolve = done; }));
+ const drag = prepareAgentCompanionPointerDrag({ x: 30, y: 40 }, vi.fn(), vi.fn());
+ await settle(); drag.move({ x: 50, y: 40 });
+ drag.move({ x: 60, y: 40 }); drag.move({ x: 70, y: 40 }); drag.finish();
+ expect(mocks.move).toHaveBeenCalledTimes(1);
+ resolve(); await settle();
+ expect(mocks.move).toHaveBeenCalledTimes(2);
+ expect(mocks.move).toHaveBeenLastCalledWith(expect.objectContaining({ x: 3720, y: 912 }));
+});
+it('cancels pending movement on lost capture or unmount', async () => {
+ let resolve!: (point: { x: number; y: number }) => void;
+ mocks.position.mockReturnValue(new Promise(done => { resolve = done; }));
+ const drag = prepareAgentCompanionPointerDrag({ x: 30, y: 40 }, vi.fn(), vi.fn());
+ drag.move({ x: 60, y: 40 }); drag.cancel();
+ resolve({ x: 200, y: 300 }); await settle();
+ expect(mocks.move).not.toHaveBeenCalled();
+});
+it('reports move failures and stops accepting subsequent movement', async () => {
+ mocks.move.mockRejectedValue(new Error('Window closed'));
+ const error = vi.fn();
+ const drag = prepareAgentCompanionPointerDrag({ x: 30, y: 40 }, vi.fn(), error);
+ await settle(); drag.move({ x: 50, y: 40 }); await settle();
+ drag.move({ x: 60, y: 40 }); await settle();
+ expect(error).toHaveBeenCalledOnce();
+ expect(mocks.move).toHaveBeenCalledOnce();
+});
diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionPointerDragService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionPointerDragService.ts
new file mode 100644
index 0000000000..f4f4b822f4
--- /dev/null
+++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionPointerDragService.ts
@@ -0,0 +1,75 @@
+import { getCurrentWindow, LogicalPosition } from '@tauri-apps/api/window';
+
+export interface CompanionPointerPosition { x: number; y: number }
+export interface CompanionPointerDrag {
+ move: (position: CompanionPointerPosition) => void;
+ finish: () => void;
+ cancel: () => void;
+}
+
+/**
+ * WebKit screen coordinates and macOS window positions share logical screen
+ * space. Capture the window origin at pointer-down, then coalesce pointer moves
+ * while an IPC is in flight. No global cursor polling or native drag handoff.
+ */
+export function prepareAgentCompanionPointerDrag(
+ grab: CompanionPointerPosition,
+ onDirection: (direction: 'left' | 'right') => void,
+ onError: (error: unknown) => void,
+): CompanionPointerDrag {
+ const petWindow = getCurrentWindow();
+ let origin: CompanionPointerPosition | null = null;
+ let latest: CompanionPointerPosition | null = null;
+ let previous = grab;
+ let cancelled = false;
+ let finished = false;
+ let moving = false;
+
+ const cancel = () => { cancelled = true; latest = null; };
+ const fail = (error: unknown) => {
+ if (cancelled) return;
+ cancel();
+ onError(error);
+ };
+ const flush = async () => {
+ if (cancelled || moving || !origin || !latest) return;
+ moving = true;
+ try {
+ while (!cancelled && latest) {
+ const pointer = latest;
+ latest = null;
+ await petWindow.setPosition(new LogicalPosition(
+ origin.x + pointer.x - grab.x,
+ origin.y + pointer.y - grab.y,
+ ));
+ }
+ } catch (error) {
+ fail(error);
+ } finally {
+ moving = false;
+ }
+ };
+
+ void Promise.all([petWindow.outerPosition(), petWindow.scaleFactor()])
+ .then(([position, scale]) => {
+ if (cancelled) return;
+ if (!Number.isFinite(scale) || scale <= 0) throw new Error('Invalid companion window scale factor');
+ origin = { x: position.x / scale, y: position.y / scale };
+ void flush();
+ }).catch(fail);
+
+ return {
+ move: pointer => {
+ if (cancelled || finished) return;
+ if (pointer.x === previous.x && pointer.y === previous.y) return;
+ if (pointer.x !== previous.x) onDirection(pointer.x > previous.x ? 'right' : 'left');
+ previous = pointer;
+ latest = pointer;
+ void flush();
+ },
+ // A quick release may precede origin acquisition or the last IPC. Keep the
+ // final requested position, but accept no further pointer events.
+ finish: () => { finished = true; void flush(); },
+ cancel,
+ };
+}