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
157 lines
4.6 KiB
TypeScript
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,
|
|
};
|
|
}
|