evOo-local/apps/web/src/lib/agent-pointer.ts
cynarAI e28c3acf02
feat: let the pane beside a conversation follow the bot's work (#123)
The live screen opens wide beside the chat while the bot works on it or asks
for a takeover, a file the bot presents as its result opens next to the
conversation, and a question that scrolled away gets a "Needs you" pill back
to it. Shared core rules derive the focus from signals clients already have
(pointer events, takeover requests, a new `present` flag on attach_file,
pending asks), move the pane only on focus edges, never replace a pane the
user opened, and learn per bot whether the screen opens on its own. Pane
widths persist per device; mobile shows a pill that opens the computer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7VSRHHzyt2KwoCkUkZ2Gc
2026-10-03 22:23:46 +02:00

157 lines
4.6 KiB
TypeScript

import {
type ComputerPointerAction,
ComputerPointerPayload,
type ComputerPointerStep,
} from "@rakazo/contracts";
import { POINTER_EVENT_MAX_AGE_MS } from "@rakazo/core";
export type PointerPoint = { x: number; y: number };
export type PointerScreen = ComputerPointerPayload["screen"];
export { POINTER_EVENT_MAX_AGE_MS };
export const POINTER_IDLE_MS = 3_500;
type Listener = (payload: ComputerPointerPayload) => void;
/**
* Live pointer announcements per bot. The thread subscription publishes `thread.computer`
* pointer events here; screen viewers subscribe for the bot they show.
*/
class AgentPointerFeed {
private readonly listeners = new Map<string, Set<Listener>>();
publish(botId: string, payload: unknown, createdAt?: string, now = Date.now()): boolean {
const parsed = ComputerPointerPayload.safeParse(payload);
if (!parsed.success) return false;
const at = createdAt ? Date.parse(createdAt) : Number.NaN;
if (Number.isFinite(at) && now - at > POINTER_EVENT_MAX_AGE_MS) return false;
for (const listener of this.listeners.get(botId) ?? []) listener(parsed.data);
return true;
}
subscribe(botId: string, listener: Listener): () => void {
let set = this.listeners.get(botId);
if (!set) {
set = new Set();
this.listeners.set(botId, set);
}
set.add(listener);
return () => {
set.delete(listener);
if (set.size === 0) this.listeners.delete(botId);
};
}
}
export const agentPointerFeed = new AgentPointerFeed();
export type PointerSegment = {
from: PointerPoint;
to: PointerPoint;
/** Perpendicular bow in screen pixels so long moves read as a hand, not a teleport. */
bow: number;
start: number;
arrive: number;
end: number;
action: ComputerPointerAction;
};
const ACTION_DWELL_MS: Record<ComputerPointerAction, number> = {
move: 0,
click: 320,
double_click: 440,
right_click: 320,
down: 160,
up: 160,
type: 700,
key: 280,
scroll: 420,
};
export function moveDurationMs(distance: number): number {
return distance < 1 ? 0 : Math.round(Math.min(650, Math.max(180, 140 + distance * 0.45)));
}
/**
* Lays announced steps on a timeline that starts where the cursor currently is. Keyboard
* steps carry no coordinates and act in place.
*/
export function planPointerTimeline(
from: PointerPoint,
steps: readonly ComputerPointerStep[],
startAt: number,
): PointerSegment[] {
const segments: PointerSegment[] = [];
let position = from;
let time = startAt;
for (const step of steps) {
const to =
typeof step.x === "number" && typeof step.y === "number"
? { x: step.x, y: step.y }
: position;
const distance = Math.hypot(to.x - position.x, to.y - position.y);
const arrive = time + moveDurationMs(distance);
const end = arrive + ACTION_DWELL_MS[step.action];
segments.push({
from: position,
to,
bow: Math.min(48, distance * 0.12),
start: time,
arrive,
end,
action: step.action,
});
position = to;
time = end;
}
return segments;
}
export type PointerFrame = {
point: PointerPoint;
/** The action being performed at the target, with 0..1 progress; null while travelling. */
action: { kind: ComputerPointerAction; progress: number } | null;
done: boolean;
};
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
}
export function pointerFrameAt(
segments: readonly PointerSegment[],
now: number,
reducedMotion = false,
): PointerFrame | null {
if (segments.length === 0) return null;
const last = segments[segments.length - 1]!;
if (now >= last.end) return { point: last.to, action: null, done: true };
const segment = segments.find((candidate) => now < candidate.end) ?? last;
if (now < segment.arrive) {
if (reducedMotion) return { point: segment.to, action: null, done: false };
const span = segment.arrive - segment.start;
const t = easeInOutCubic(span > 0 ? Math.max(0, (now - segment.start) / span) : 1);
const dx = segment.to.x - segment.from.x;
const dy = segment.to.y - segment.from.y;
const length = Math.hypot(dx, dy) || 1;
const lift = Math.sin(Math.PI * t) * segment.bow;
return {
point: {
x: segment.from.x + dx * t - (dy / length) * lift,
y: segment.from.y + dy * t + (dx / length) * lift,
},
action: null,
done: false,
};
}
const dwell = segment.end - segment.arrive;
return {
point: segment.to,
action:
segment.action === "move"
? null
: { kind: segment.action, progress: dwell > 0 ? (now - segment.arrive) / dwell : 1 },
done: false,
};
}