The live screen now opens wide beside the chat while the bot works on it, 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. Focus is derived from signals clients already have (pointer events, takeover requests, a new `present` flag on attach_file, pending asks) by shared core rules, so web and mobile agree and the behaviour stays deterministic. The pane only moves on focus edges, never replaces a pane the user opened, and learns per bot whether the screen should open on its own. Pane widths persist per device. Mobile shows a pill that opens the computer route. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7VSRHHzyt2KwoCkUkZ2Gc
166 lines
4.8 KiB
TypeScript
166 lines
4.8 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import path from "node:path";
|
|
import type {
|
|
AdapterContext,
|
|
ArtifactStore,
|
|
ComputerRef,
|
|
SandboxProvider,
|
|
} from "@rakazo/adapter-kit";
|
|
import type { ComputerMode, MessageBlock } from "@rakazo/contracts";
|
|
import { ATTACHMENT_MAX_BYTES } from "@rakazo/contracts";
|
|
import {
|
|
attachmentExtensionForMimeType,
|
|
inferAttachmentMimeType,
|
|
messageBlockForArtifact,
|
|
validateAttachmentMimeType,
|
|
} from "@rakazo/core";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import { assertTeamWorkspacePathAllowed, resolveBotWorkspacePath } from "./computer-support.js";
|
|
|
|
export type MaterializedThreadFile = {
|
|
name: string;
|
|
mimeType: string;
|
|
size: number;
|
|
path: string;
|
|
};
|
|
|
|
export async function attachWorkspaceFileToThread(
|
|
deps: {
|
|
prisma: PrismaClient;
|
|
artifacts: ArtifactStore;
|
|
},
|
|
input: {
|
|
spaceId: string;
|
|
userId: string;
|
|
botId: string;
|
|
groupId?: string;
|
|
runId: string;
|
|
filePath: string;
|
|
bytes: Uint8Array;
|
|
operationId: string;
|
|
present?: boolean;
|
|
},
|
|
): Promise<{ artifactId: string; block: Extract<MessageBlock, { kind: "image" | "file" }> }> {
|
|
const name = path.basename(input.filePath) || input.filePath;
|
|
const mimeType = inferAttachmentMimeType(name);
|
|
if (!mimeType) {
|
|
throw new Error(`Unsupported attachment type for ${name}`);
|
|
}
|
|
validateAttachmentMimeType(mimeType);
|
|
if (input.bytes.byteLength > ATTACHMENT_MAX_BYTES) {
|
|
throw new Error("file exceeds the 10 MiB attachment limit");
|
|
}
|
|
|
|
const context = {
|
|
operationId: input.operationId,
|
|
traceId: input.operationId,
|
|
spaceId: input.spaceId,
|
|
userId: input.userId,
|
|
botId: input.botId,
|
|
signal: new AbortController().signal,
|
|
};
|
|
const stored = await deps.artifacts.put({ name, mimeType, bytes: input.bytes }, context);
|
|
const hash = createHash("sha256").update(input.bytes).digest("hex");
|
|
const row = await deps.prisma.artifact
|
|
.create({
|
|
data: {
|
|
spaceId: input.spaceId,
|
|
userId: input.userId,
|
|
botId: input.botId,
|
|
groupId: input.groupId,
|
|
runId: input.runId,
|
|
name,
|
|
mimeType,
|
|
size: input.bytes.byteLength,
|
|
hash,
|
|
storageKey: stored.id,
|
|
},
|
|
})
|
|
.catch(async (error) => {
|
|
await deps.artifacts.remove(stored.id, context).catch(() => undefined);
|
|
throw error;
|
|
});
|
|
const block = messageBlockForArtifact({
|
|
id: row.id,
|
|
name: row.name,
|
|
mimeType: row.mimeType,
|
|
size: row.size,
|
|
});
|
|
return { artifactId: row.id, block: input.present ? { ...block, present: true } : block };
|
|
}
|
|
|
|
export async function materializeCurrentTurnFiles(
|
|
deps: {
|
|
prisma: PrismaClient;
|
|
artifacts: ArtifactStore;
|
|
sandbox: SandboxProvider;
|
|
},
|
|
blocks: MessageBlock[] | undefined,
|
|
input: {
|
|
context: AdapterContext & { botId: string };
|
|
computer: ComputerRef;
|
|
computerMode: ComputerMode;
|
|
teamBotIds?: ReadonlySet<string>;
|
|
markWorkspaceDirty?: () => void;
|
|
},
|
|
): Promise<MaterializedThreadFile[]> {
|
|
const fileBlocks = blocks?.filter(
|
|
(block): block is Extract<MessageBlock, { kind: "file" }> => block.kind === "file",
|
|
);
|
|
if (!fileBlocks?.length) return [];
|
|
|
|
const rows = await deps.prisma.artifact.findMany({
|
|
where: {
|
|
id: { in: fileBlocks.map((block) => block.artifactId) },
|
|
spaceId: input.context.spaceId,
|
|
userId: input.context.userId,
|
|
},
|
|
});
|
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
const materialized: MaterializedThreadFile[] = [];
|
|
|
|
for (const block of fileBlocks) {
|
|
const row = byId.get(block.artifactId);
|
|
if (!row) throw new Error(`Attached file is unavailable: ${block.name}`);
|
|
const bytes = await deps.artifacts.get(row.storageKey, input.context);
|
|
if (bytes.byteLength > ATTACHMENT_MAX_BYTES) {
|
|
throw new Error(`Attached file exceeds the 10 MiB limit: ${row.name}`);
|
|
}
|
|
const relativePath = `attachments/${row.id}${attachmentExtensionForMimeType(row.mimeType)}`;
|
|
const storedPath = resolveBotWorkspacePath(
|
|
input.computerMode,
|
|
input.context.botId,
|
|
relativePath,
|
|
);
|
|
if (input.teamBotIds) assertTeamWorkspacePathAllowed(storedPath, input.teamBotIds);
|
|
input.markWorkspaceDirty?.();
|
|
await deps.sandbox.writeFile(
|
|
input.computer,
|
|
{
|
|
path: storedPath,
|
|
content: bytes,
|
|
},
|
|
input.context,
|
|
);
|
|
materialized.push({
|
|
name: row.name,
|
|
mimeType: row.mimeType,
|
|
size: row.size,
|
|
path: relativePath,
|
|
});
|
|
}
|
|
|
|
return materialized;
|
|
}
|
|
|
|
export function currentTurnFilesInstruction(files: readonly MaterializedThreadFile[]): string {
|
|
if (!files.length) return "";
|
|
return [
|
|
"Current-turn attached files are available on your computer. Inspect them before answering:",
|
|
...files.map(
|
|
(file) =>
|
|
`- ${JSON.stringify(file.name)} (${file.mimeType}, ${file.size} bytes): ${JSON.stringify(file.path)}`,
|
|
),
|
|
].join("\n");
|
|
}
|