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 { 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; }