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
166 lines
5.3 KiB
TypeScript
166 lines
5.3 KiB
TypeScript
import { Trans, useLingui } from "@lingui/react/macro";
|
|
import { ChatMarkdown } from "@rakazo/chat-ui/web";
|
|
import { isAttachmentImageMimeType } from "@rakazo/contracts";
|
|
import type { WorkspaceArtifact } from "@rakazo/core";
|
|
import { Button } from "@rakazo/ui-web";
|
|
import { Download, X } from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useArtifactBytes } from "../hooks/useArtifactBytes";
|
|
import { type ArtifactTarget, downloadArtifactBytes } from "../lib/artifact-open";
|
|
import { parseCsvPreview } from "../lib/csv-preview";
|
|
|
|
/** A file the bot made, open beside the conversation so the user can read it while they talk. */
|
|
export function ArtifactPane({
|
|
target,
|
|
artifact,
|
|
onClose,
|
|
}: {
|
|
target: ArtifactTarget;
|
|
artifact: WorkspaceArtifact;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const loaded = useArtifactBytes(target, artifact.artifactId);
|
|
const { name, mimeType } = artifact;
|
|
|
|
return (
|
|
<section
|
|
data-testid="artifact-pane"
|
|
aria-label={name}
|
|
className="flex h-full min-h-0 w-full flex-col"
|
|
>
|
|
<header className="flex h-14 shrink-0 items-center gap-1 border-b border-border px-5">
|
|
<h2 className="min-w-0 flex-1 truncate text-ui-body font-medium text-foreground" dir="auto">
|
|
{name}
|
|
</h2>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-muted-foreground"
|
|
aria-label={t`Download ${name}`}
|
|
title={t`Download ${name}`}
|
|
disabled={loaded.status !== "ready"}
|
|
onClick={() => {
|
|
if (loaded.status === "ready") downloadArtifactBytes(name, mimeType, loaded.bytes);
|
|
}}
|
|
>
|
|
<Download strokeWidth={1.8} className="size-icon-md" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-muted-foreground"
|
|
aria-label={t`Close panel`}
|
|
onClick={onClose}
|
|
>
|
|
<X strokeWidth={1.8} className="size-icon-md" />
|
|
</Button>
|
|
</header>
|
|
<div className="rk-scroll min-h-0 flex-1 overflow-auto">
|
|
{loaded.status === "loading" ? (
|
|
<div className="px-6 py-8 text-ui-body text-muted-foreground">
|
|
<Trans>Loading preview…</Trans>
|
|
</div>
|
|
) : loaded.status === "error" ? (
|
|
<div
|
|
role="alert"
|
|
className="m-6 rounded-xl border border-destructive/40 bg-destructive/10 px-4 py-3 text-ui-body text-destructive"
|
|
>
|
|
{loaded.message}
|
|
</div>
|
|
) : isAttachmentImageMimeType(mimeType) ? (
|
|
<PaneImage bytes={loaded.bytes} mimeType={mimeType} name={name} />
|
|
) : (
|
|
<PaneText bytes={loaded.bytes} mimeType={mimeType} />
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function PaneImage({
|
|
bytes,
|
|
mimeType,
|
|
name,
|
|
}: {
|
|
bytes: Uint8Array;
|
|
mimeType: string;
|
|
name: string;
|
|
}) {
|
|
const [src, setSrc] = useState<string | null>(null);
|
|
useEffect(() => {
|
|
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: mimeType }));
|
|
setSrc(url);
|
|
return () => URL.revokeObjectURL(url);
|
|
}, [bytes, mimeType]);
|
|
return src ? (
|
|
<img src={src} alt={name} className="mx-auto block max-w-full p-6" draggable={false} />
|
|
) : null;
|
|
}
|
|
|
|
function PaneText({ bytes, mimeType }: { bytes: Uint8Array; mimeType: string }) {
|
|
const { t } = useLingui();
|
|
const text = useMemo(() => new TextDecoder("utf-8").decode(bytes), [bytes]);
|
|
if (mimeType === "text/markdown") {
|
|
return (
|
|
<article className="mx-auto w-full max-w-[760px] px-6 py-8 text-ui-body leading-7 text-foreground lg:px-10">
|
|
<ChatMarkdown>{text}</ChatMarkdown>
|
|
</article>
|
|
);
|
|
}
|
|
if (mimeType === "text/csv") {
|
|
const { rows, truncated } = parseCsvPreview(text);
|
|
const [head, ...body] = rows;
|
|
const shownRows = body.length;
|
|
return (
|
|
<div className="p-4">
|
|
<table className="w-full border-collapse text-ui-body-sm text-foreground">
|
|
{head ? (
|
|
<thead>
|
|
<tr>
|
|
{head.map((cell, index) => (
|
|
<th
|
|
key={index}
|
|
className="sticky top-0 border-b border-border bg-background px-3 py-2 text-start font-medium"
|
|
>
|
|
{cell}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
) : null}
|
|
<tbody>
|
|
{body.map((row, rowIndex) => (
|
|
<tr key={rowIndex} className="border-b border-border/60">
|
|
{row.map((cell, index) => (
|
|
<td key={index} className="px-3 py-1.5 align-top whitespace-pre-wrap">
|
|
{cell}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{truncated ? (
|
|
<p className="px-3 pt-3 text-ui-body-sm text-muted-foreground">
|
|
{t`First ${shownRows} rows`}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<pre className="whitespace-pre-wrap break-words px-6 py-6 font-mono text-ui-body-sm text-foreground">
|
|
{mimeType === "application/json" ? prettyJson(text) : text}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
function prettyJson(text: string): string {
|
|
try {
|
|
return JSON.stringify(JSON.parse(text), null, 2);
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|