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
231 lines
8.1 KiB
TypeScript
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;
|
|
}
|