evOo-local/packages/core/src/workspace-focus.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

231 lines
8.1 KiB
TypeScript

import { ComputerPointerPayload, isAttachmentImageMimeType } from "@rakazo/contracts";
import { latestAnswerableAskMessageId } from "./answerable-ask.js";
/** What the pane beside a conversation shows while a bot works. */
export type WorkspacePane = "screen" | "artifact";
/** Learned per bot: whether the live screen opens on its own while the bot works. */
export type ScreenPaneMode = "auto" | "always" | "never";
export type WorkspaceArtifact = {
messageId: string;
artifactId: string;
name: string;
mimeType: string;
};
export type WorkspaceFocus = {
pane: WorkspacePane | null;
artifact: WorkspaceArtifact | null;
/** The bot asked the user to take over its screen. */
takeover: boolean;
/** The ask message the bot is waiting on. */
needsYouMessageId: string | null;
/** Changes only when the focus target changes, so clients react to edges, not every event. */
key: string;
};
type FocusBlock = {
kind: string;
artifactId?: string;
name?: string;
mimeType?: string;
present?: boolean;
status?: string;
};
type FocusRun = { id: string; status: string };
export type WorkspaceFocusSnapshot = {
messages: readonly {
id: string;
role?: string;
runId?: string | null;
blocks: readonly FocusBlock[];
}[];
run?: FocusRun | null;
activeRuns?: readonly FocusRun[];
computer?: {
kind: string;
state: string;
screenAvailable: boolean;
takeoverRequested: boolean;
} | null;
};
export type WorkspaceFocusLive = {
/** Run that last announced pointer activity on the bot's screen. */
screenRunId?: string | null;
/** The bot pointed at its screen moments ago, even if that run already finished. */
screenActive?: boolean;
screenMode?: ScreenPaneMode;
};
const WORKING_RUN_STATUSES = new Set(["leased", "running", "waiting_takeover"]);
const PANE_TEXT_MIME_TYPES = new Set([
"text/markdown",
"text/plain",
"text/csv",
"application/json",
]);
/** Older pointer announcements describe actions that already finished. */
export const POINTER_EVENT_MAX_AGE_MS = 30_000;
/** The run behind a fresh pointer announcement on a bot's screen, or null for any other event. */
export function liveScreenRunId(
event: { type: string; runId?: string | null; createdAt?: string; payload?: unknown },
now = Date.now(),
): string | null {
if (event.type !== "thread.computer" || !event.runId) return null;
if (!ComputerPointerPayload.safeParse(event.payload).success) return null;
const at = event.createdAt ? Date.parse(event.createdAt) : Number.NaN;
if (Number.isFinite(at) && now - at > POINTER_EVENT_MAX_AGE_MS) return null;
return event.runId;
}
/** Files the side pane can render without a plugin; others stay download cards. */
export function isPaneArtifactMimeType(mimeType: string): boolean {
return PANE_TEXT_MIME_TYPES.has(mimeType) || isAttachmentImageMimeType(mimeType);
}
function workingRunIds(snapshot: WorkspaceFocusSnapshot): Set<string> {
const runs = snapshot.activeRuns ?? (snapshot.run ? [snapshot.run] : []);
return new Set(runs.filter((run) => WORKING_RUN_STATUSES.has(run.status)).map((run) => run.id));
}
/** The newest file a bot presented since the user last wrote; a new request starts fresh. */
function presentedArtifact(snapshot: WorkspaceFocusSnapshot): WorkspaceArtifact | null {
for (let index = snapshot.messages.length - 1; index >= 0; index -= 1) {
const message = snapshot.messages[index];
if (!message) continue;
if (message.role === "user") return null;
for (let blockIndex = message.blocks.length - 1; blockIndex >= 0; blockIndex -= 1) {
const block = message.blocks[blockIndex];
if (
(block?.kind === "file" || block?.kind === "image") &&
block.present &&
block.artifactId &&
block.mimeType &&
isPaneArtifactMimeType(block.mimeType)
) {
return {
messageId: message.id,
artifactId: block.artifactId,
name: block.name ?? "",
mimeType: block.mimeType,
};
}
}
}
return null;
}
/**
* Derives the pane a conversation should show from what the bot is actually doing: a
* takeover request or live work on its screen shows the screen, a file the bot presented
* as its latest result shows that file, and everything else stays a plain conversation.
*/
export function deriveWorkspaceFocus(
snapshot: WorkspaceFocusSnapshot | null,
live: WorkspaceFocusLive = {},
): WorkspaceFocus {
if (!snapshot) {
return { pane: null, artifact: null, takeover: false, needsYouMessageId: null, key: "none" };
}
const needsYouMessageId = latestAnswerableAskMessageId({
messages: snapshot.messages,
run: snapshot.run,
activeRuns: snapshot.activeRuns,
});
const computer = snapshot.computer;
const working = workingRunIds(snapshot);
const mode = live.screenMode ?? "auto";
const pointerWork =
Boolean(live.screenActive) || (live.screenRunId != null && working.has(live.screenRunId));
// A desktop host or a bot without a computer has no screen to embed.
const embeddable = Boolean(computer) && computer?.kind !== "desktop" && computer?.kind !== "none";
// A bot that points at its screen or asks for a takeover has a running screen, even when the
// client's computer status has not caught up with a boot that happened inside the run.
const takeover = embeddable && Boolean(computer?.takeoverRequested);
const screenWork =
embeddable &&
mode !== "never" &&
(pointerWork || (mode === "always" && working.size > 0 && Boolean(computer?.screenAvailable)));
const artifact = presentedArtifact(snapshot);
const pane: WorkspacePane | null =
takeover || screenWork ? "screen" : artifact ? "artifact" : null;
const target =
pane === "screen"
? `screen:${takeover ? "takeover" : "work"}`
: pane === "artifact" && artifact
? `artifact:${artifact.artifactId}`
: "none";
return {
pane,
artifact,
takeover,
needsYouMessageId,
key: `${target}|ask:${needsYouMessageId ?? "none"}`,
};
}
/** What occupies the pane right now; "other" is anything the user opened that is not a focus pane. */
export type PaneState = { open: WorkspacePane | "other" | null; auto: boolean };
/**
* Moves the pane only on focus edges. It never replaces something the user opened by hand,
* and it only closes a screen it opened itself; a presented file stays until the user closes it
* or the bot presents another.
*/
export function applyWorkspaceFocus(
previous: WorkspaceFocus | null,
next: WorkspaceFocus,
pane: PaneState,
): PaneState | null {
if (!previous) return null;
const sameTarget =
previous.pane === next.pane &&
(next.pane !== "artifact" || previous.artifact?.artifactId === next.artifact?.artifactId);
if (sameTarget) return null;
if (next.pane) {
// A newer presented file replaces the one on view; the screen is already showing.
if (pane.open === next.pane) return next.pane === "artifact" ? pane : null;
if (pane.open && !pane.auto) return null;
return { open: next.pane, auto: true };
}
if (pane.auto && pane.open === "screen" && previous.pane === "screen") {
return { open: null, auto: false };
}
return null;
}
/** Signed habit score: positive when the user keeps opening the screen, negative when they close it. */
export type ScreenPaneHabit = number;
const ALWAYS_AT = 3;
const NEVER_AT = -2;
export function screenPaneMode(habit: ScreenPaneHabit): ScreenPaneMode {
if (habit >= ALWAYS_AT) return "always";
if (habit <= NEVER_AT) return "never";
return "auto";
}
/**
* Learns from what the user does while a bot works: opening the screen by hand moves toward
* showing it every time, closing an automatically opened screen moves toward never opening it.
*/
export function recordScreenPaneChoice(
habit: ScreenPaneHabit,
choice: "opened" | "dismissed",
): ScreenPaneHabit {
if (choice === "opened") return Math.min(ALWAYS_AT, Math.max(habit, 0) + 1);
return Math.max(NEVER_AT, Math.min(habit, 0) - 1);
}
export function parseScreenPaneHabit(value: string | null | undefined): ScreenPaneHabit {
const parsed = Number(value);
return Number.isInteger(parsed) ? Math.max(NEVER_AT, Math.min(ALWAYS_AT, parsed)) : 0;
}