Merge main into the i18n branch

Catalogs are extracted again; the artifact pane's row count is translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 20:39:38 +00:00
commit 82fad0c9ac
No known key found for this signature in database
39 changed files with 1561 additions and 82 deletions

View File

@ -263,8 +263,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.owner.type')" owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.owner.type')"
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.private')" private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.private')"
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
echo "enabled=false" >> "$GITHUB_OUTPUT" echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping registry attestation: unavailable for private repositories on personal accounts." echo "Skipping registry attestation: unavailable for private repositories on personal accounts."

View File

@ -17,10 +17,12 @@ import {
buildComposerMentionOptions, buildComposerMentionOptions,
type ComposerMention, type ComposerMention,
cloudAgentHttpsUrl, cloudAgentHttpsUrl,
deriveWorkspaceFocus,
isApprovalAskBlock, isApprovalAskBlock,
isRunTerminalEvent, isRunTerminalEvent,
isSecretAskBlock, isSecretAskBlock,
latestAnswerableAskMessageId, latestAnswerableAskMessageId,
liveScreenRunId,
mentionChipKey, mentionChipKey,
projectMessageReactions, projectMessageReactions,
resolveComposerSendPlan, resolveComposerSendPlan,
@ -288,6 +290,8 @@ function Thread() {
? { botId } ? { botId }
: undefined; : undefined;
const [snap, setSnap] = useState<MobileSnapshot | null>(null); const [snap, setSnap] = useState<MobileSnapshot | null>(null);
/** Run that last pointed at the bot's screen, so the live screen is offered only during real screen work. */
const [screenRunId, setScreenRunId] = useState<string | null>(null);
const activeThreadId = useRef<string | undefined>(undefined); const activeThreadId = useRef<string | undefined>(undefined);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [mentionQuery, setMentionQuery] = useState<string | null>(null); const [mentionQuery, setMentionQuery] = useState<string | null>(null);
@ -860,6 +864,7 @@ function Thread() {
} }
expandedHistoryThread.current = null; expandedHistoryThread.current = null;
historyEpoch.current += 1; historyEpoch.current += 1;
setScreenRunId(null);
const abort = new AbortController(); const abort = new AbortController();
void (async () => { void (async () => {
// Pending search jumps load the around-page separately; avoid replacing it with latest. // Pending search jumps load the around-page separately; avoid replacing it with latest.
@ -885,6 +890,8 @@ function Thread() {
(event) => { (event) => {
cursor = Math.max(cursor, event.seq ?? -1); cursor = Math.max(cursor, event.seq ?? -1);
retryMs = 250; retryMs = 250;
const screenRun = groupId ? null : liveScreenRunId(event);
if (screenRun) setScreenRunId(screenRun);
if ( if (
event.type === "thread.progress" || event.type === "thread.progress" ||
event.type === "agent.tool.called" || event.type === "agent.tool.called" ||
@ -1271,6 +1278,23 @@ function Thread() {
} }
const answerableAskMessageId = latestAnswerableAskMessageId(snap); const answerableAskMessageId = latestAnswerableAskMessageId(snap);
const screenFocus = useMemo(() => {
const computer = snap?.computer;
if (!snap || !computer || groupId) return null;
const focus = deriveWorkspaceFocus(
{
...snap,
computer: {
kind: computer.kind ?? "",
state: computer.state,
screenAvailable: computer.screenAvailable,
takeoverRequested: computer.takeoverRequested ?? false,
},
},
{ screenRunId },
);
return focus.pane === "screen" ? focus : null;
}, [groupId, screenRunId, snap]);
const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null; const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null;
const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]); const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]);
const messagesById = useMemo( const messagesById = useMemo(
@ -1668,6 +1692,43 @@ function Thread() {
) : null} ) : null}
</View> </View>
<View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}> <View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}>
{screenFocus && botId ? (
<Pressable
accessibilityRole="button"
testID="live-screen-pill"
onPress={() =>
router.push({
pathname: "/computer",
params: { botId, name: displayName ?? t("Bot") },
})
}
style={({ pressed }) => ({
alignSelf: "center",
marginTop: 10,
flexDirection: "row",
alignItems: "center",
gap: 8,
borderRadius: 999,
borderWidth: 1,
borderColor: native.fillPressed,
backgroundColor: pressed ? native.fillPressed : native.fill,
paddingHorizontal: 14,
paddingVertical: 8,
})}
>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: screenFocus.takeover ? tokens.warning : tokens.success,
}}
/>
<Text style={{ color: tokens.foreground, fontSize: 14, fontWeight: "500" }}>
{screenFocus.takeover ? t("Needs takeover") : t("Open computer")}
</Text>
</Pressable>
) : null}
{replyTarget ? ( {replyTarget ? (
<View <View
style={{ style={{

View File

@ -790,9 +790,11 @@ export type MobileSnapshot = {
activeRuns?: Array<{ id: string; botId?: string; status: string }>; activeRuns?: Array<{ id: string; botId?: string; status: string }>;
members?: MobileGroup["members"]; members?: MobileGroup["members"];
computer?: { computer?: {
kind?: string;
state: string; state: string;
controlHolder: string; controlHolder: string;
screenAvailable: boolean; screenAvailable: boolean;
takeoverRequested?: boolean;
mode: ComputerMode; mode: ComputerMode;
busyBotName: string | null; busyBotName: string | null;
}; };
@ -896,6 +898,7 @@ type ThreadEvent = {
type: string; type: string;
seq?: number; seq?: number;
runId?: string; runId?: string;
createdAt?: string;
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
}; };

View File

@ -0,0 +1,104 @@
import { expect, test } from "@playwright/test";
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
const SCREEN_URL = "https://screen.example/embed.html";
test("the bot's screen opens beside the chat while it works and steps back after", async ({
page,
}, testInfo) => {
await signup(page, `workspace-screen-${Date.now()}@rakazo.test`, "password12", "Screen Pane");
await completeOnboarding(page);
const botId = activeBotId(page);
await rpc(page, "computer/boot", { botId });
await page.route("https://screen.example/**", (route) =>
route.fulfill({
contentType: "text/html",
body: `<!doctype html><title>Desktop</title><body style="margin:0;background:#1d1d20;display:grid;place-items:center;height:100vh">
<input style="width:40%;padding:8px;font:16px system-ui" placeholder="Search">`,
}),
);
await page.route("**/rpc/computer/screenUrl", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ json: { url: SCREEN_URL } }),
}),
);
const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveAttribute("data-panel", "closed");
await rpc(page, "threads/send", { botId, text: "use your screen and type workspace-demo" });
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-auto", "");
await expect(page.getByTestId("computer-preview").locator("iframe")).toHaveAttribute(
"src",
SCREEN_URL,
);
// The live screen gets more room than a settings panel.
expect((await sidePanel.boundingBox())!.width).toBeGreaterThan(400);
await captureScreenshot(page, testInfo, "workspace-screen-beside-chat");
// Once the work is done the conversation gets its full width back.
await expect(sidePanel).toHaveAttribute("data-panel", "closed", { timeout: 20_000 });
});
test("a report the bot presents opens beside the conversation", async ({ page }, testInfo) => {
await signup(page, `workspace-report-${Date.now()}@rakazo.test`, "password12", "Report Pane");
await completeOnboarding(page);
const composer = page.getByPlaceholder(/Message/);
await composer.fill("present the report");
await page.keyboard.press("Enter");
const pane = page.getByTestId("artifact-pane");
await expect(pane).toBeVisible({ timeout: 30_000 });
await expect(pane.getByRole("heading", { name: "report.md" })).toBeVisible();
await expect(pane.getByRole("heading", { name: "Report", exact: true })).toBeVisible();
await expect(pane.getByRole("cell", { name: "Research" })).toBeVisible();
await expect(composer).toBeVisible();
await captureScreenshot(page, testInfo, "workspace-report-beside-chat");
const downloadPromise = page.waitForEvent("download");
await pane.getByRole("button", { name: "Download report.md" }).click();
expect((await downloadPromise).suggestedFilename()).toBe("report.md");
await pane.getByRole("button", { name: "Close panel" }).click();
await expect(pane).toHaveCount(0);
// The file card in the chat opens the same pane again.
await page.getByRole("button", { name: "Preview report.md" }).click();
await expect(pane.getByRole("heading", { name: "Report", exact: true })).toBeVisible();
});
test("a question that scrolled away is one click back", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 520 });
await signup(page, `workspace-ask-${Date.now()}@rakazo.test`, "password12", "Ask Jump");
await completeOnboarding(page);
const composer = page.getByPlaceholder(/Message/);
// Earlier work gives the thread enough history to scroll back through.
for (const task of ["sort the inbox", "draft the agenda", "book the room"]) {
await composer.fill(task);
await page.keyboard.press("Enter");
await expect(page.getByText(`done. i handled: ${task}`, { exact: true })).toBeVisible({
timeout: 30_000,
});
}
await composer.fill("ask me which city to use");
await page.keyboard.press("Enter");
const sendIt = page.getByRole("button", { name: "Send it" });
await expect(sendIt).toBeVisible({ timeout: 30_000 });
const jump = page.getByTestId("needs-you-jump");
await expect(jump).toHaveCount(0);
await page.getByTestId("transcript").hover();
await page.mouse.wheel(0, -20_000);
await expect(jump).toBeVisible();
await captureScreenshot(page, testInfo, "workspace-needs-you-jump");
await jump.click();
await expect(sendIt).toBeInViewport();
await expect(sendIt).toBeFocused();
await expect(jump).toHaveCount(0);
});

View File

@ -27,6 +27,8 @@ test("agent-attached files appear as downloadable cards", async ({ page }, testI
test("agent-attached Markdown opens a rendered preview and can be downloaded", async ({ test("agent-attached Markdown opens a rendered preview and can be downloaded", async ({
page, page,
}, testInfo) => { }, testInfo) => {
// Narrow windows preview in a dialog; wide ones open the file beside the chat.
await page.setViewportSize({ width: 900, height: 800 });
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `markdown-preview-${stamp}@rakazo.test`, "password12", "Markdown Preview"); await signup(page, `markdown-preview-${stamp}@rakazo.test`, "password12", "Markdown Preview");
await completeOnboarding(page); await completeOnboarding(page);

View File

@ -69,9 +69,10 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
}) })
.toBe("waiting_takeover"); .toBe("waiting_takeover");
await captureScreenshot(page, testInfo, "08-protected-input-request"); await captureScreenshot(page, testInfo, "08-protected-input-request");
await page.getByTitle("Agent computer").click(); // A takeover request brings the bot's screen beside the chat on its own.
const sidePanel = page.getByTestId("side-panel"); const sidePanel = page.getByTestId("side-panel");
await expect(sidePanel).toHaveCSS("width", "384px"); await expect(sidePanel).toHaveAttribute("data-panel", "computer");
await expect(sidePanel).toHaveAttribute("data-auto", "");
const [mainBox, panelBox] = await Promise.all([ const [mainBox, panelBox] = await Promise.all([
page.locator("main").boundingBox(), page.locator("main").boundingBox(),
sidePanel.boundingBox(), sidePanel.boundingBox(),

View File

@ -303,9 +303,11 @@ test("create group from + and see two bots in one transcript", async ({ page },
const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" }); const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" });
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 }); await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
await groupMarkdown.click(); await groupMarkdown.click();
const markdownDialog = page.getByRole("dialog", { name: "group-preview.md" }); // Wide windows open the file beside the group conversation.
await expect(markdownDialog.getByRole("heading", { name: "Group artifact" })).toBeVisible(); const markdownPane = page.getByTestId("artifact-pane");
await markdownDialog.getByRole("button", { name: "Close preview" }).click(); await expect(markdownPane.getByRole("heading", { name: "Group artifact" })).toBeVisible();
await markdownPane.getByRole("button", { name: "Close panel" }).click();
await expect(markdownPane).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible(); await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible();

View File

@ -4,13 +4,9 @@ import { Trans, useLingui } from "@lingui/react/macro";
import { ChatMarkdown } from "@rakazo/chat-ui/web"; import { ChatMarkdown } from "@rakazo/chat-ui/web";
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web"; import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
import { Download, FileText, X } from "lucide-react"; import { Download, FileText, X } from "lucide-react";
import { type RefObject, useEffect, useRef, useState } from "react"; import { type RefObject, useMemo, useRef, useState } from "react";
import { import { useArtifactBytes } from "../hooks/useArtifactBytes";
type ArtifactTarget, import { type ArtifactTarget, downloadArtifact, downloadArtifactBytes } from "../lib/artifact-open";
downloadArtifact,
downloadArtifactBytes,
fetchArtifactBytes,
} from "../lib/artifact-open";
type ArtifactFileCardProps = { type ArtifactFileCardProps = {
target: ArtifactTarget; target: ArtifactTarget;
@ -18,6 +14,8 @@ type ArtifactFileCardProps = {
name: string; name: string;
mimeType: string; mimeType: string;
size: number; size: number;
/** Shows the preview beside the conversation instead of in a dialog. */
onOpenBeside?: () => void;
}; };
export function ArtifactFileCard(props: ArtifactFileCardProps) { export function ArtifactFileCard(props: ArtifactFileCardProps) {
@ -63,7 +61,7 @@ export function ArtifactFileCard(props: ArtifactFileCardProps) {
ref={previewButton} ref={previewButton}
type="button" type="button"
aria-label={t`Preview ${props.name}`} aria-label={t`Preview ${props.name}`}
onClick={() => setPreviewOpen(true)} onClick={() => (props.onOpenBeside ? props.onOpenBeside() : setPreviewOpen(true))}
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-left hover:bg-accent" className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-left hover:bg-accent"
> >
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[10px] bg-muted text-foreground"> <span className="grid h-10 w-10 shrink-0 place-items-center rounded-[10px] bg-muted text-foreground">
@ -111,39 +109,16 @@ function MarkdownPreview({
}: ArtifactFileCardProps & { closeButtonRef: RefObject<HTMLButtonElement | null> }) { }: ArtifactFileCardProps & { closeButtonRef: RefObject<HTMLButtonElement | null> }) {
const { t } = useLingui(); const { t } = useLingui();
const [downloadError, setDownloadError] = useState<string | null>(null); const [downloadError, setDownloadError] = useState<string | null>(null);
const [state, setState] = useState< const loaded = useArtifactBytes(target, artifactId);
| { status: "loading" } const state = useMemo(() => {
| { status: "ready"; bytes: Uint8Array; markdown: string } if (loaded.status !== "ready") return loaded;
| { status: "error"; message: string } try {
>({ status: "loading" }); const markdown = new TextDecoder("utf-8", { fatal: true }).decode(loaded.bytes);
const targetBotId = "botId" in target ? target.botId : undefined; return { status: "ready" as const, bytes: loaded.bytes, markdown };
const targetGroupId = "groupId" in target ? target.groupId : undefined; } catch {
return { status: "error" as const, message: t`This file is not valid UTF-8 Markdown.` };
useEffect(() => { }
let cancelled = false; }, [loaded, t]);
const artifactTarget: ArtifactTarget =
targetBotId !== undefined ? { botId: targetBotId } : { groupId: targetGroupId! };
void fetchArtifactBytes(artifactTarget, artifactId)
.then((bytes) => {
if (cancelled) return;
try {
const markdown = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
setState({ status: "ready", bytes, markdown });
} catch {
setState({ status: "error", message: t`This file is not valid UTF-8 Markdown.` });
}
})
.catch((error) => {
if (cancelled) return;
setState({
status: "error",
message: error instanceof Error ? error.message : t`Could not load this file.`,
});
});
return () => {
cancelled = true;
};
}, [artifactId, targetBotId, targetGroupId, t]);
return ( return (
<> <>

View File

@ -0,0 +1,165 @@
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;
}
}

View File

@ -87,6 +87,7 @@ export function AskCard({
return ( return (
<div <div
data-ask-card
data-testid={secretInput ? "secret-ask-card" : undefined} data-testid={secretInput ? "secret-ask-card" : undefined}
className={cn("max-w-[74%] rounded-2xl border border-border bg-card px-5 py-4", className)} className={cn("max-w-[74%] rounded-2xl border border-border bg-card px-5 py-4", className)}
> >

View File

@ -0,0 +1,38 @@
import { useLingui } from "@lingui/react/macro";
import { useEffect, useState } from "react";
import { type ArtifactTarget, fetchArtifactBytes } from "../lib/artifact-open";
export type ArtifactBytesState =
| { status: "loading" }
| { status: "ready"; bytes: Uint8Array }
| { status: "error"; message: string };
export function useArtifactBytes(target: ArtifactTarget, artifactId: string): ArtifactBytesState {
const { t } = useLingui();
const [state, setState] = useState<ArtifactBytesState>({ status: "loading" });
const targetBotId = "botId" in target ? target.botId : undefined;
const targetGroupId = "groupId" in target ? target.groupId : undefined;
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
const artifactTarget: ArtifactTarget =
targetBotId !== undefined ? { botId: targetBotId } : { groupId: targetGroupId! };
void fetchArtifactBytes(artifactTarget, artifactId)
.then((bytes) => {
if (!cancelled) setState({ status: "ready", bytes });
})
.catch((error) => {
if (cancelled) return;
setState({
status: "error",
message: error instanceof Error ? error.message : t`Could not load this file.`,
});
});
return () => {
cancelled = true;
};
}, [artifactId, targetBotId, targetGroupId, t]);
return state;
}

View File

@ -1,13 +1,20 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { readPaneWidth, writePaneWidth } from "../lib/workspace-pane-pref";
export function useSidebarResize( export function useSidebarResize(
initialWidth: number, initialWidth: number,
side: "left" | "right", side: "left" | "right",
minWidth: number, minWidth: number,
maxWidth: number, maxWidth: number,
/** Remembers the width across reloads on this device. */
storageKey?: string,
) { ) {
const [width, setWidth] = useState(initialWidth); const [width, setWidth] = useState(() =>
storageKey ? readPaneWidth(storageKey, initialWidth, minWidth, maxWidth) : initialWidth,
);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const widthRef = useRef(width);
widthRef.current = width;
useEffect(() => { useEffect(() => {
if (!isResizing) return; if (!isResizing) return;
@ -26,6 +33,7 @@ export function useSidebarResize(
const handlePointerUp = () => { const handlePointerUp = () => {
setIsResizing(false); setIsResizing(false);
document.body.style.cursor = "default"; document.body.style.cursor = "default";
if (storageKey) writePaneWidth(storageKey, widthRef.current);
}; };
window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointermove", handlePointerMove);
@ -38,7 +46,7 @@ export function useSidebarResize(
window.removeEventListener("pointerup", handlePointerUp); window.removeEventListener("pointerup", handlePointerUp);
document.body.style.cursor = "default"; document.body.style.cursor = "default";
}; };
}, [isResizing, side, minWidth, maxWidth]); }, [isResizing, side, minWidth, maxWidth, storageKey]);
const startResizing = useCallback((e: React.PointerEvent) => { const startResizing = useCallback((e: React.PointerEvent) => {
e.preventDefault(); e.preventDefault();

View File

@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
/** Wide enough to keep the conversation readable beside a working pane. */
const WIDE_LAYOUT_QUERY = "(min-width: 1024px)";
export function useWideLayout(): boolean {
const [wide, setWide] = useState(() => window.matchMedia(WIDE_LAYOUT_QUERY).matches);
useEffect(() => {
const media = window.matchMedia(WIDE_LAYOUT_QUERY);
const sync = () => setWide(media.matches);
sync();
media.addEventListener("change", sync);
return () => media.removeEventListener("change", sync);
}, []);
return wide;
}

View File

@ -3,12 +3,12 @@ import {
ComputerPointerPayload, ComputerPointerPayload,
type ComputerPointerStep, type ComputerPointerStep,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { POINTER_EVENT_MAX_AGE_MS } from "@rakazo/core";
export type PointerPoint = { x: number; y: number }; export type PointerPoint = { x: number; y: number };
export type PointerScreen = ComputerPointerPayload["screen"]; export type PointerScreen = ComputerPointerPayload["screen"];
/** Older announcements describe actions that already finished; replaying them would mislead. */ export { POINTER_EVENT_MAX_AGE_MS };
export const POINTER_EVENT_MAX_AGE_MS = 30_000;
export const POINTER_IDLE_MS = 3_500; export const POINTER_IDLE_MS = 3_500;
type Listener = (payload: ComputerPointerPayload) => void; type Listener = (payload: ComputerPointerPayload) => void;

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { parseCsvPreview } from "./csv-preview";
describe("parseCsvPreview", () => {
it("reads quoted fields, doubled quotes and CRLF rows", () => {
expect(parseCsvPreview('name,note\r\n"Smith, Ann","said ""hi"""\r\nBo,\n')).toEqual({
rows: [
["name", "note"],
["Smith, Ann", 'said "hi"'],
["Bo", ""],
],
truncated: false,
});
});
it("keeps a final row without a trailing newline and skips blank lines", () => {
expect(parseCsvPreview("a,b\n\n1,2").rows).toEqual([
["a", "b"],
["1", "2"],
]);
});
it("keeps newlines inside quoted fields", () => {
expect(parseCsvPreview('a\n"line 1\nline 2"').rows).toEqual([["a"], ["line 1\nline 2"]]);
});
it("stops after the row limit", () => {
const result = parseCsvPreview("1\n2\n3\n4\n", 2);
expect(result).toEqual({ rows: [["1"], ["2"]], truncated: true });
});
});

View File

@ -0,0 +1,43 @@
/** Parses enough of RFC 4180 to show a CSV as a table: quoted fields, doubled quotes, CRLF. */
export function parseCsvPreview(
text: string,
maxRows = 500,
): { rows: string[][]; truncated: boolean } {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
let index = 0;
const endRow = () => {
row.push(field);
field = "";
if (row.length > 1 || row[0] !== "") rows.push(row);
row = [];
};
while (index < text.length) {
const char = text[index];
if (quoted) {
if (char === '"' && text[index + 1] === '"') {
field += '"';
index += 2;
continue;
}
if (char === '"') quoted = false;
else field += char;
index += 1;
continue;
}
if (char === '"' && field === "") quoted = true;
else if (char === ",") {
row.push(field);
field = "";
} else if (char === "\n" || char === "\r") {
endRow();
if (char === "\r" && text[index + 1] === "\n") index += 1;
if (rows.length > maxRows) break;
} else field += char;
index += 1;
}
if (rows.length <= maxRows && (field !== "" || row.length > 0)) endRow();
return { rows: rows.slice(0, maxRows), truncated: rows.length > maxRows };
}

View File

@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
readPaneWidth,
readScreenPaneHabit,
writePaneWidth,
writeScreenPaneHabit,
} from "./workspace-pane-pref";
function stubStorage() {
const store = new Map<string, string>();
vi.stubGlobal("window", {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
},
});
return store;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("workspace pane preferences", () => {
it("keeps the learned screen habit per user and bot", () => {
const store = stubStorage();
expect(readScreenPaneHabit("user-1", "bot-1")).toBe(0);
writeScreenPaneHabit("user-1", "bot-1", 2);
expect(readScreenPaneHabit("user-1", "bot-1")).toBe(2);
expect(readScreenPaneHabit("user-1", "bot-2")).toBe(0);
writeScreenPaneHabit("user-1", "bot-1", 0);
expect(store.size).toBe(0);
writeScreenPaneHabit(null, "bot-1", 3);
expect(store.size).toBe(0);
});
it("restores a stored pane width within its bounds", () => {
stubStorage();
expect(readPaneWidth("width", 500, 320, 900)).toBe(500);
writePaneWidth("width", 640.4);
expect(readPaneWidth("width", 500, 320, 900)).toBe(640);
writePaneWidth("width", 2_000);
expect(readPaneWidth("width", 500, 320, 900)).toBe(900);
});
it("falls back when storage is unavailable", () => {
vi.stubGlobal("window", {
get localStorage(): Storage {
throw new Error("blocked");
},
});
expect(readScreenPaneHabit("user-1", "bot-1")).toBe(0);
expect(readPaneWidth("width", 500, 320, 900)).toBe(500);
expect(() => writePaneWidth("width", 600)).not.toThrow();
});
});

View File

@ -0,0 +1,52 @@
import { parseScreenPaneHabit, type ScreenPaneHabit } from "@rakazo/core";
function screenHabitKey(userId: string | null | undefined, botId: string | null | undefined) {
if (!userId || !botId) return null;
return `rakazo:screen-pane:${userId}:${botId}`;
}
export function readScreenPaneHabit(
userId: string | null | undefined,
botId: string | null | undefined,
): ScreenPaneHabit {
const key = screenHabitKey(userId, botId);
if (!key) return 0;
try {
return parseScreenPaneHabit(window.localStorage.getItem(key));
} catch {
return 0;
}
}
export function writeScreenPaneHabit(
userId: string | null | undefined,
botId: string | null | undefined,
habit: ScreenPaneHabit,
): void {
const key = screenHabitKey(userId, botId);
if (!key) return;
try {
if (habit === 0) window.localStorage.removeItem(key);
else window.localStorage.setItem(key, String(habit));
} catch {
// Private mode or a full quota only loses the learned default.
}
}
export function readPaneWidth(storageKey: string, fallback: number, min: number, max: number) {
try {
const stored = Number(window.localStorage.getItem(storageKey));
if (Number.isFinite(stored) && stored > 0) return Math.max(min, Math.min(max, stored));
} catch {
// Fall through to the default width.
}
return fallback;
}
export function writePaneWidth(storageKey: string, width: number): void {
try {
window.localStorage.setItem(storageKey, String(Math.round(width)));
} catch {
// The width still applies for this session.
}
}

View File

@ -1104,6 +1104,7 @@ msgstr "Modelleinstellungen schließen"
msgid "Close navigation" msgid "Close navigation"
msgstr "Navigation schließen" msgstr "Navigation schließen"
#: src/components/ArtifactPane.tsx
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Close panel" msgid "Close panel"
@ -1533,7 +1534,7 @@ msgstr "Skill konnte nicht geladen werden"
msgid "Could not load this chat." msgid "Could not load this chat."
msgstr "Dieser Chat konnte nicht geladen werden." msgstr "Dieser Chat konnte nicht geladen werden."
#: src/components/ArtifactFileCard.tsx #: src/hooks/useArtifactBytes.ts
msgid "Could not load this file." msgid "Could not load this file."
msgstr "Datei konnte nicht geladen werden." msgstr "Datei konnte nicht geladen werden."
@ -2049,6 +2050,7 @@ msgid "Download {0}"
msgstr "{0} herunterladen" msgstr "{0} herunterladen"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Download {name}" msgid "Download {name}"
msgstr "{name} herunterladen" msgstr "{name} herunterladen"
@ -2278,6 +2280,10 @@ msgstr "Fertig."
msgid "Finishing MCP connection…" msgid "Finishing MCP connection…"
msgstr "MCP-Verbindung wird abgeschlossen…" msgstr "MCP-Verbindung wird abgeschlossen…"
#: src/components/ArtifactPane.tsx
msgid "First {shownRows} rows"
msgstr "Erste {shownRows} Zeilen"
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
msgid "Flag unexpected actions" msgid "Flag unexpected actions"
msgstr "Unerwartete Aktionen markieren" msgstr "Unerwartete Aktionen markieren"
@ -2724,6 +2730,7 @@ msgid "Loading model catalog…"
msgstr "Modellkatalog wird geladen…" msgstr "Modellkatalog wird geladen…"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Loading preview…" msgid "Loading preview…"
msgstr "Vorschau wird geladen…" msgstr "Vorschau wird geladen…"

View File

@ -1104,6 +1104,7 @@ msgstr "Close model settings"
msgid "Close navigation" msgid "Close navigation"
msgstr "Close navigation" msgstr "Close navigation"
#: src/components/ArtifactPane.tsx
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Close panel" msgid "Close panel"
@ -1533,7 +1534,7 @@ msgstr "Could not load skill"
msgid "Could not load this chat." msgid "Could not load this chat."
msgstr "Could not load this chat." msgstr "Could not load this chat."
#: src/components/ArtifactFileCard.tsx #: src/hooks/useArtifactBytes.ts
msgid "Could not load this file." msgid "Could not load this file."
msgstr "Could not load this file." msgstr "Could not load this file."
@ -2049,6 +2050,7 @@ msgid "Download {0}"
msgstr "Download {0}" msgstr "Download {0}"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Download {name}" msgid "Download {name}"
msgstr "Download {name}" msgstr "Download {name}"
@ -2278,6 +2280,10 @@ msgstr "Finished."
msgid "Finishing MCP connection…" msgid "Finishing MCP connection…"
msgstr "Finishing MCP connection…" msgstr "Finishing MCP connection…"
#: src/components/ArtifactPane.tsx
msgid "First {shownRows} rows"
msgstr "First {shownRows} rows"
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
msgid "Flag unexpected actions" msgid "Flag unexpected actions"
msgstr "Flag unexpected actions" msgstr "Flag unexpected actions"
@ -2724,6 +2730,7 @@ msgid "Loading model catalog…"
msgstr "Loading model catalog…" msgstr "Loading model catalog…"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Loading preview…" msgid "Loading preview…"
msgstr "Loading preview…" msgstr "Loading preview…"

View File

@ -1104,6 +1104,7 @@ msgstr "Cerrar configuración de modelos"
msgid "Close navigation" msgid "Close navigation"
msgstr "Cerrar navegación" msgstr "Cerrar navegación"
#: src/components/ArtifactPane.tsx
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Close panel" msgid "Close panel"
@ -1533,7 +1534,7 @@ msgstr "No se pudo cargar la habilidad"
msgid "Could not load this chat." msgid "Could not load this chat."
msgstr "No se pudo cargar este chat." msgstr "No se pudo cargar este chat."
#: src/components/ArtifactFileCard.tsx #: src/hooks/useArtifactBytes.ts
msgid "Could not load this file." msgid "Could not load this file."
msgstr "No se pudo cargar este archivo." msgstr "No se pudo cargar este archivo."
@ -2049,6 +2050,7 @@ msgid "Download {0}"
msgstr "Descargar {0}" msgstr "Descargar {0}"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Download {name}" msgid "Download {name}"
msgstr "Descargar {name}" msgstr "Descargar {name}"
@ -2278,6 +2280,10 @@ msgstr "Terminado."
msgid "Finishing MCP connection…" msgid "Finishing MCP connection…"
msgstr "Finalizando conexión MCP…" msgstr "Finalizando conexión MCP…"
#: src/components/ArtifactPane.tsx
msgid "First {shownRows} rows"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
msgid "Flag unexpected actions" msgid "Flag unexpected actions"
msgstr "Marcar acciones inesperadas" msgstr "Marcar acciones inesperadas"
@ -2724,6 +2730,7 @@ msgid "Loading model catalog…"
msgstr "Cargando catálogo de modelos…" msgstr "Cargando catálogo de modelos…"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Loading preview…" msgid "Loading preview…"
msgstr "Cargando vista previa…" msgstr "Cargando vista previa…"

View File

@ -1104,6 +1104,7 @@ msgstr "मॉडल सेटिंग्स बंद करें"
msgid "Close navigation" msgid "Close navigation"
msgstr "नेविगेशन बंद करें" msgstr "नेविगेशन बंद करें"
#: src/components/ArtifactPane.tsx
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Close panel" msgid "Close panel"
@ -1533,7 +1534,7 @@ msgstr "स्किल लोड नहीं हो सकी"
msgid "Could not load this chat." msgid "Could not load this chat."
msgstr "यह चैट लोड नहीं हो सकी." msgstr "यह चैट लोड नहीं हो सकी."
#: src/components/ArtifactFileCard.tsx #: src/hooks/useArtifactBytes.ts
msgid "Could not load this file." msgid "Could not load this file."
msgstr "यह फ़ाइल लोड नहीं हो सकी।" msgstr "यह फ़ाइल लोड नहीं हो सकी।"
@ -2049,6 +2050,7 @@ msgid "Download {0}"
msgstr "{0} डाउनलोड करें" msgstr "{0} डाउनलोड करें"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Download {name}" msgid "Download {name}"
msgstr "{name} डाउनलोड करें" msgstr "{name} डाउनलोड करें"
@ -2278,6 +2280,10 @@ msgstr "पूरा हुआ।"
msgid "Finishing MCP connection…" msgid "Finishing MCP connection…"
msgstr "MCP कनेक्शन पूरा हो रहा है…" msgstr "MCP कनेक्शन पूरा हो रहा है…"
#: src/components/ArtifactPane.tsx
msgid "First {shownRows} rows"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
msgid "Flag unexpected actions" msgid "Flag unexpected actions"
msgstr "" msgstr ""
@ -2724,6 +2730,7 @@ msgid "Loading model catalog…"
msgstr "मॉडल कैटलॉग लोड हो रहा है…" msgstr "मॉडल कैटलॉग लोड हो रहा है…"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Loading preview…" msgid "Loading preview…"
msgstr "प्रीव्यू लोड हो रहा है…" msgstr "प्रीव्यू लोड हो रहा है…"

View File

@ -1104,6 +1104,7 @@ msgstr "AI 모델 설정 닫기"
msgid "Close navigation" msgid "Close navigation"
msgstr "메뉴 닫기" msgstr "메뉴 닫기"
#: src/components/ArtifactPane.tsx
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Close panel" msgid "Close panel"
@ -1533,7 +1534,7 @@ msgstr "스킬을 불러오지 못했습니다"
msgid "Could not load this chat." msgid "Could not load this chat."
msgstr "이 채팅을 불러오지 못했습니다." msgstr "이 채팅을 불러오지 못했습니다."
#: src/components/ArtifactFileCard.tsx #: src/hooks/useArtifactBytes.ts
msgid "Could not load this file." msgid "Could not load this file."
msgstr "파일을 불러오지 못했습니다." msgstr "파일을 불러오지 못했습니다."
@ -2049,6 +2050,7 @@ msgid "Download {0}"
msgstr "{0} 다운로드" msgstr "{0} 다운로드"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Download {name}" msgid "Download {name}"
msgstr "{name} 다운로드" msgstr "{name} 다운로드"
@ -2278,6 +2280,10 @@ msgstr "완료되었습니다."
msgid "Finishing MCP connection…" msgid "Finishing MCP connection…"
msgstr "MCP 연결을 마무리하는 중…" msgstr "MCP 연결을 마무리하는 중…"
#: src/components/ArtifactPane.tsx
msgid "First {shownRows} rows"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
msgid "Flag unexpected actions" msgid "Flag unexpected actions"
msgstr "예상하지 못한 작업 표시" msgstr "예상하지 못한 작업 표시"
@ -2724,6 +2730,7 @@ msgid "Loading model catalog…"
msgstr "모델 목록을 불러오는 중…" msgstr "모델 목록을 불러오는 중…"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Loading preview…" msgid "Loading preview…"
msgstr "미리보기를 불러오는 중…" msgstr "미리보기를 불러오는 중…"

View File

@ -1104,6 +1104,7 @@ msgstr "Fechar configurações do modelo"
msgid "Close navigation" msgid "Close navigation"
msgstr "Fechar navegação" msgstr "Fechar navegação"
#: src/components/ArtifactPane.tsx
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Close panel" msgid "Close panel"
@ -1533,7 +1534,7 @@ msgstr "Não foi possível carregar a habilidade"
msgid "Could not load this chat." msgid "Could not load this chat."
msgstr "Não foi possível carregar este chat." msgstr "Não foi possível carregar este chat."
#: src/components/ArtifactFileCard.tsx #: src/hooks/useArtifactBytes.ts
msgid "Could not load this file." msgid "Could not load this file."
msgstr "Não foi possível carregar este arquivo." msgstr "Não foi possível carregar este arquivo."
@ -2049,6 +2050,7 @@ msgid "Download {0}"
msgstr "Baixar {0}" msgstr "Baixar {0}"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Download {name}" msgid "Download {name}"
msgstr "Baixar {name}" msgstr "Baixar {name}"
@ -2278,6 +2280,10 @@ msgstr "Concluído."
msgid "Finishing MCP connection…" msgid "Finishing MCP connection…"
msgstr "Finalizando conexão MCP…" msgstr "Finalizando conexão MCP…"
#: src/components/ArtifactPane.tsx
msgid "First {shownRows} rows"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
msgid "Flag unexpected actions" msgid "Flag unexpected actions"
msgstr "" msgstr ""
@ -2724,6 +2730,7 @@ msgid "Loading model catalog…"
msgstr "Carregando catálogo de modelos..." msgstr "Carregando catálogo de modelos..."
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Loading preview…" msgid "Loading preview…"
msgstr "Carregando Visualização…" msgstr "Carregando Visualização…"

View File

@ -1104,6 +1104,7 @@ msgstr "Закрыть настройки модели"
msgid "Close navigation" msgid "Close navigation"
msgstr "Закрыть навигацию" msgstr "Закрыть навигацию"
#: src/components/ArtifactPane.tsx
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Close panel" msgid "Close panel"
@ -1533,7 +1534,7 @@ msgstr "Не удалось загрузить навык"
msgid "Could not load this chat." msgid "Could not load this chat."
msgstr "Не удалось загрузить этот чат." msgstr "Не удалось загрузить этот чат."
#: src/components/ArtifactFileCard.tsx #: src/hooks/useArtifactBytes.ts
msgid "Could not load this file." msgid "Could not load this file."
msgstr "Не удалось загрузить этот файл." msgstr "Не удалось загрузить этот файл."
@ -2049,6 +2050,7 @@ msgid "Download {0}"
msgstr "Скачать {0}" msgstr "Скачать {0}"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Download {name}" msgid "Download {name}"
msgstr "Скачать {name}" msgstr "Скачать {name}"
@ -2278,6 +2280,10 @@ msgstr "Завершено."
msgid "Finishing MCP connection…" msgid "Finishing MCP connection…"
msgstr "Завершение подключения MCP…" msgstr "Завершение подключения MCP…"
#: src/components/ArtifactPane.tsx
msgid "First {shownRows} rows"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
msgid "Flag unexpected actions" msgid "Flag unexpected actions"
msgstr "Пометить неожиданные действия" msgstr "Пометить неожиданные действия"
@ -2724,6 +2730,7 @@ msgid "Loading model catalog…"
msgstr "Загрузка каталога моделей…" msgstr "Загрузка каталога моделей…"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Loading preview…" msgid "Loading preview…"
msgstr "Загрузка предварительного просмотра…" msgstr "Загрузка предварительного просмотра…"

View File

@ -1104,6 +1104,7 @@ msgstr "Model ayarlarını kapat"
msgid "Close navigation" msgid "Close navigation"
msgstr "Gezinmeyi kapat" msgstr "Gezinmeyi kapat"
#: src/components/ArtifactPane.tsx
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Close panel" msgid "Close panel"
@ -1533,7 +1534,7 @@ msgstr "Beceri yüklenemedi"
msgid "Could not load this chat." msgid "Could not load this chat."
msgstr "Bu sohbet yüklenemedi." msgstr "Bu sohbet yüklenemedi."
#: src/components/ArtifactFileCard.tsx #: src/hooks/useArtifactBytes.ts
msgid "Could not load this file." msgid "Could not load this file."
msgstr "Bu dosya yüklenemedi." msgstr "Bu dosya yüklenemedi."
@ -2049,6 +2050,7 @@ msgid "Download {0}"
msgstr "{0} indir" msgstr "{0} indir"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Download {name}" msgid "Download {name}"
msgstr "{name} indir" msgstr "{name} indir"
@ -2278,6 +2280,10 @@ msgstr "Tamamlandı."
msgid "Finishing MCP connection…" msgid "Finishing MCP connection…"
msgstr "MCP bağlantısı tamamlanıyor…" msgstr "MCP bağlantısı tamamlanıyor…"
#: src/components/ArtifactPane.tsx
msgid "First {shownRows} rows"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
msgid "Flag unexpected actions" msgid "Flag unexpected actions"
msgstr "" msgstr ""
@ -2724,6 +2730,7 @@ msgid "Loading model catalog…"
msgstr "Model kataloğu yükleniyor…" msgstr "Model kataloğu yükleniyor…"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Loading preview…" msgid "Loading preview…"
msgstr "Önizleme yükleniyor…" msgstr "Önizleme yükleniyor…"

View File

@ -1104,6 +1104,7 @@ msgstr "关闭模型设置"
msgid "Close navigation" msgid "Close navigation"
msgstr "关闭导航" msgstr "关闭导航"
#: src/components/ArtifactPane.tsx
#: src/components/browser/InAppBrowser.tsx #: src/components/browser/InAppBrowser.tsx
#: src/pages/Shell.tsx #: src/pages/Shell.tsx
msgid "Close panel" msgid "Close panel"
@ -1533,7 +1534,7 @@ msgstr "无法加载技能"
msgid "Could not load this chat." msgid "Could not load this chat."
msgstr "无法加载此对话。" msgstr "无法加载此对话。"
#: src/components/ArtifactFileCard.tsx #: src/hooks/useArtifactBytes.ts
msgid "Could not load this file." msgid "Could not load this file."
msgstr "无法加载此文件。" msgstr "无法加载此文件。"
@ -2049,6 +2050,7 @@ msgid "Download {0}"
msgstr "下载 {0}" msgstr "下载 {0}"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Download {name}" msgid "Download {name}"
msgstr "下载 {name}" msgstr "下载 {name}"
@ -2278,6 +2280,10 @@ msgstr "已完成。"
msgid "Finishing MCP connection…" msgid "Finishing MCP connection…"
msgstr "正在完成 MCP 连接…" msgstr "正在完成 MCP 连接…"
#: src/components/ArtifactPane.tsx
msgid "First {shownRows} rows"
msgstr ""
#: src/components/ApprovalRulesSettings.tsx #: src/components/ApprovalRulesSettings.tsx
msgid "Flag unexpected actions" msgid "Flag unexpected actions"
msgstr "标记异常操作" msgstr "标记异常操作"
@ -2724,6 +2730,7 @@ msgid "Loading model catalog…"
msgstr "正在加载模型目录…" msgstr "正在加载模型目录…"
#: src/components/ArtifactFileCard.tsx #: src/components/ArtifactFileCard.tsx
#: src/components/ArtifactPane.tsx
msgid "Loading preview…" msgid "Loading preview…"
msgstr "正在加载预览…" msgstr "正在加载预览…"

View File

@ -49,11 +49,13 @@ import {
normalizeCreateBotProfile, normalizeCreateBotProfile,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { import {
applyWorkspaceFocus,
attachmentsForThread, attachmentsForThread,
buildComposerMentionOptions, buildComposerMentionOptions,
type ComposerMention, type ComposerMention,
clampMentionHighlightIndex, clampMentionHighlightIndex,
cronFromPreset, cronFromPreset,
deriveWorkspaceFocus,
groupBotsForSidebar, groupBotsForSidebar,
inferAttachmentMimeType, inferAttachmentMimeType,
isActive, isActive,
@ -61,21 +63,27 @@ import {
isRunTerminalEvent, isRunTerminalEvent,
isToolActivityBlock, isToolActivityBlock,
latestAnswerableAskMessageId, latestAnswerableAskMessageId,
liveScreenRunId,
matchVoiceByName, matchVoiceByName,
mentionChipKey, mentionChipKey,
type PaneState,
parseVoiceChatCommand, parseVoiceChatCommand,
projectMessageReactions, projectMessageReactions,
recordScreenPaneChoice,
reorderBotTo, reorderBotTo,
resolveComposerSendPlan, resolveComposerSendPlan,
resolveMentionPickerKey, resolveMentionPickerKey,
runThreadSubscription, runThreadSubscription,
SLASH_ACTIONS, SLASH_ACTIONS,
type SlashActionId, type SlashActionId,
screenPaneMode,
searchHitThreadTarget, searchHitThreadTarget,
serializeComposerPrompt, serializeComposerPrompt,
speechFromBlocks, speechFromBlocks,
truncateSlashDescription, truncateSlashDescription,
userVisibleMessages, userVisibleMessages,
type WorkspaceArtifact,
type WorkspaceFocus,
} from "@rakazo/core"; } from "@rakazo/core";
import { import {
AvatarStyleProvider, AvatarStyleProvider,
@ -156,6 +164,7 @@ import {
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ArtifactFileCard } from "../components/ArtifactFileCard"; import { ArtifactFileCard } from "../components/ArtifactFileCard";
import { ArtifactPane } from "../components/ArtifactPane";
import { AskCard } from "../components/AskCard"; import { AskCard } from "../components/AskCard";
import { ActiveBotGlyph, CollaborationMarker } from "../components/ai/CollaborationMarker"; import { ActiveBotGlyph, CollaborationMarker } from "../components/ai/CollaborationMarker";
import { DelegatedTaskCard, SentMessageCard } from "../components/ai/DelegatedTaskCard"; import { DelegatedTaskCard, SentMessageCard } from "../components/ai/DelegatedTaskCard";
@ -180,6 +189,7 @@ import { TeachComputerOverlayControl } from "../components/teach/TeachComputerOv
import { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome"; import { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome";
import { VoiceAuroraRim } from "../components/voice/VoiceAurora"; import { VoiceAuroraRim } from "../components/voice/VoiceAurora";
import { useSidebarResize } from "../hooks/useSidebarResize"; import { useSidebarResize } from "../hooks/useSidebarResize";
import { useWideLayout } from "../hooks/useWideLayout";
import { readActivityMode, writeActivityMode } from "../lib/activity-mode"; import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity"; import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity";
import { agentPointerFeed } from "../lib/agent-pointer"; import { agentPointerFeed } from "../lib/agent-pointer";
@ -249,6 +259,7 @@ import {
transcriptMovedDown, transcriptMovedDown,
} from "../lib/transcript-scroll"; } from "../lib/transcript-scroll";
import { speaker } from "../lib/tts"; import { speaker } from "../lib/tts";
import { readScreenPaneHabit, writeScreenPaneHabit } from "../lib/workspace-pane-pref";
import { ActivityList } from "./ActivityList"; import { ActivityList } from "./ActivityList";
import type { ContextMenuPosition } from "./BotContextMenu"; import type { ContextMenuPosition } from "./BotContextMenu";
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel"; import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
@ -327,6 +338,7 @@ type Panel =
| "create-group" | "create-group"
| "group-settings" | "group-settings"
| "browser" | "browser"
| "artifact"
| null; | null;
type PendingAttachment = { type PendingAttachment = {
@ -351,6 +363,14 @@ const THREAD_SNAPSHOT_TIMEOUT_MS = 2_000;
const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000; const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000;
const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32; const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32;
const MOBILE_SIDEBAR_SWIPE_DISTANCE_PX = 56; const MOBILE_SIDEBAR_SWIPE_DISTANCE_PX = 56;
/** The screen lingers after the work so its final state and the bot's reply land first. */
const AUTO_SCREEN_CLOSE_DELAY_MS = 4_000;
/** How long one pointer announcement counts as the bot working on its screen. */
const SCREEN_ACTIVE_MS = 5_000;
function defaultWorkspacePaneWidth(): number {
return Math.round(Math.max(420, Math.min(760, window.innerWidth * 0.42)));
}
function threadSnapshotSignal(parent: AbortSignal): AbortSignal { function threadSnapshotSignal(parent: AbortSignal): AbortSignal {
return AbortSignal.any([parent, AbortSignal.timeout(THREAD_SNAPSHOT_TIMEOUT_MS)]); return AbortSignal.any([parent, AbortSignal.timeout(THREAD_SNAPSHOT_TIMEOUT_MS)]);
@ -438,12 +458,24 @@ export function ShellPage() {
width: leftSidebarWidth, width: leftSidebarWidth,
isResizing: isResizingLeft, isResizing: isResizingLeft,
startResizing: startResizingLeft, startResizing: startResizingLeft,
} = useSidebarResize(316, "left", 240, 450); } = useSidebarResize(316, "left", 240, 450, "rakazo:left-sidebar-width");
const { const {
width: rightSidebarWidth, width: rightSidebarWidth,
isResizing: isResizingRight, isResizing: isResizingRight,
startResizing: startResizingRight, startResizing: startResizingRight,
} = useSidebarResize(384, "right", 280, 500); } = useSidebarResize(384, "right", 280, 500, "rakazo:side-panel-width");
// The screen and presented files get a wider pane than settings: they are what the user watches.
const {
width: workspacePaneWidth,
isResizing: isResizingWorkspacePane,
startResizing: startResizingWorkspacePane,
} = useSidebarResize(
defaultWorkspacePaneWidth(),
"right",
360,
960,
"rakazo:workspace-pane-width",
);
const [heuteActionCount, setHeuteActionCount] = useState(0); const [heuteActionCount, setHeuteActionCount] = useState(0);
const [archivedOpen, setArchivedOpen] = useState(false); const [archivedOpen, setArchivedOpen] = useState(false);
@ -458,6 +490,15 @@ export function ShellPage() {
}, [userId]); }, [userId]);
const [panel, setPanel] = useState<Panel>(null); const [panel, setPanel] = useState<Panel>(null);
/** The pane the conversation's focus opened; cleared as soon as the user changes the pane. */
const [autoPanel, setAutoPanel] = useState<Panel>(null);
const [artifactPane, setArtifactPane] = useState<WorkspaceArtifact | null>(null);
/** Run that last pointed at the bot's screen; the live screen only follows real screen work. */
const [screenRunId, setScreenRunId] = useState<string | null>(null);
const [screenActive, setScreenActive] = useState(false);
const screenActiveTimer = useRef<number | undefined>(undefined);
const [screenHabit, setScreenHabit] = useState(0);
const wideLayout = useWideLayout();
const [showCreateBotModal, setShowCreateBotModal] = useState(false); const [showCreateBotModal, setShowCreateBotModal] = useState(false);
@ -1458,6 +1499,16 @@ export function ShellPage() {
void refreshComputerScreen(active.id).catch(() => undefined); void refreshComputerScreen(active.id).catch(() => undefined);
} else if (event.type === "thread.computer") { } else if (event.type === "thread.computer") {
agentPointerFeed.publish(event.botId, event.payload, event.createdAt); agentPointerFeed.publish(event.botId, event.payload, event.createdAt);
const screenRun = liveScreenRunId(event);
if (screenRun) {
setScreenRunId(screenRun);
setScreenActive(true);
window.clearTimeout(screenActiveTimer.current);
screenActiveTimer.current = window.setTimeout(
() => setScreenActive(false),
SCREEN_ACTIVE_MS,
);
}
} }
}, },
}); });
@ -2102,6 +2153,83 @@ export function ShellPage() {
() => (inGroup ? { groupId: groupId ?? "" } : { botId: active?.id ?? "" }), () => (inGroup ? { groupId: groupId ?? "" } : { botId: active?.id ?? "" }),
[active?.id, groupId, inGroup], [active?.id, groupId, inGroup],
); );
const workspaceFocus = useMemo(
() =>
deriveWorkspaceFocus(
activeSnapshot ? { ...activeSnapshot, computer: inGroup ? null : computer } : null,
{ screenRunId, screenActive, screenMode: screenPaneMode(screenHabit) },
),
[activeSnapshot, computer, inGroup, screenActive, screenHabit, screenRunId],
);
const workspaceFocusRef = useRef<{ threadId: string; focus: WorkspaceFocus } | null>(null);
const autoPanelRef = useRef(autoPanel);
autoPanelRef.current = autoPanel;
const autoCloseTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(autoCloseTimer.current), []);
// The pane follows the bot's work on focus edges only, so it never jumps while nothing changed.
useEffect(() => {
const threadId = activeSnapshot?.threadId;
const sameThread = Boolean(threadId) && workspaceFocusRef.current?.threadId === threadId;
// New work or another conversation cancels a pending close; an unrelated edge does not.
if (!sameThread || workspaceFocus.pane !== null) window.clearTimeout(autoCloseTimer.current);
if (!threadId) {
workspaceFocusRef.current = null;
return;
}
const previous = sameThread ? (workspaceFocusRef.current?.focus ?? null) : null;
workspaceFocusRef.current = { threadId, focus: workspaceFocus };
// Narrow windows keep the conversation full width; a pane would cover it.
if (!wideLayout) return;
const paneState: PaneState = {
open:
panel === "computer"
? "screen"
: panel === "artifact"
? "artifact"
: panel
? "other"
: null,
auto: panel !== null && panel === autoPanel,
};
const next = applyWorkspaceFocus(previous, workspaceFocus, paneState);
if (!next || next.open === "other") return;
if (next.open === null) {
const closing = autoPanelRef.current;
autoCloseTimer.current = window.setTimeout(() => {
if (closing === null || autoPanelRef.current !== closing) return;
setPanel(null);
setAutoPanel(null);
}, AUTO_SCREEN_CLOSE_DELAY_MS);
return;
}
if (next.open === "artifact") setArtifactPane(workspaceFocus.artifact);
const nextPanel: Panel = next.open === "screen" ? "computer" : next.open;
setPanel(nextPanel);
setAutoPanel(next.auto ? nextPanel : null);
}, [workspaceFocus.key, activeSnapshot?.threadId, wideLayout]);
const learnScreenPane = useCallback(
(choice: "opened" | "dismissed") => {
const botId = activeBotId.current;
if (!botId) return;
const next = recordScreenPaneChoice(screenHabit, choice);
setScreenHabit(next);
writeScreenPaneHabit(userId, botId, next);
},
[screenHabit, userId],
);
useEffect(() => {
if (autoPanel === null || panel === autoPanel) return;
// Closing the live screen while the bot still works on it teaches this bot's default.
const focus = workspaceFocusRef.current?.focus;
if (autoPanel === "computer" && panel === null && focus?.pane === "screen" && !focus.takeover) {
learnScreenPane("dismissed");
}
setAutoPanel(null);
}, [panel]);
const openArtifactBeside = useCallback((artifact: WorkspaceArtifact) => {
setArtifactPane(artifact);
setPanel("artifact");
}, []);
const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members; const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members;
// Bots of every space, so a group message from a bot that is not a member still has a speaker. // Bots of every space, so a group message from a bot that is not a member still has a speaker.
const knownBots = useMemo(() => { const knownBots = useMemo(() => {
@ -3110,8 +3238,22 @@ export function ShellPage() {
setComputerOpen(false); setComputerOpen(false);
setComputerError(null); setComputerError(null);
setComputerErrorFromScreen(false); setComputerErrorFromScreen(false);
setScreenRunId(null);
setScreenActive(false);
window.clearTimeout(screenActiveTimer.current);
}, [active?.id]); }, [active?.id]);
useEffect(() => {
setScreenHabit(readScreenPaneHabit(userId, active?.id));
}, [active?.id, userId]);
// A presented file belongs to its conversation, and a new conversation starts without auto panes.
useEffect(() => {
setPanel((current) => (current === "artifact" ? null : current));
setArtifactPane(null);
setAutoPanel(null);
}, [active?.id, groupId]);
useEffect(() => { useEffect(() => {
if (focusPromptBotIdRef.current && focusPromptBotIdRef.current !== active?.id) { if (focusPromptBotIdRef.current && focusPromptBotIdRef.current !== active?.id) {
cancelFocusPrompt(); cancelFocusPrompt();
@ -3228,6 +3370,9 @@ export function ShellPage() {
} }
const embeddedScreenUrl = embeddableScreenUrl(screenUrl); const embeddedScreenUrl = embeddableScreenUrl(screenUrl);
// Only what the work opened gets the wide pane; settings, routines and a hand-opened screen keep
// one width so switching between them never jumps.
const workspacePaneOpen = panel === "artifact" || (panel === "computer" && autoPanel === panel);
const hasControl = userHoldsComputerControl(computer, active?.id); const hasControl = userHoldsComputerControl(computer, active?.id);
const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl); const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl);
const computerScreenError = const computerScreenError =
@ -4408,6 +4553,7 @@ export function ShellPage() {
title={t`Agent computer`} title={t`Agent computer`}
onClick={() => { onClick={() => {
const next = panel === "computer" ? null : "computer"; const next = panel === "computer" ? null : "computer";
if (next === "computer" && transcriptRunning) learnScreenPane("opened");
setPanel(next); setPanel(next);
if (next === "computer" && active) { if (next === "computer" && active) {
// Refresh run/computer so Take control isn't stuck on a stale busyBotName. // Refresh run/computer so Take control isn't stuck on a stale busyBotName.
@ -4498,6 +4644,7 @@ export function ShellPage() {
}} }}
scrollRef={messageScroll} scrollRef={messageScroll}
artifactTarget={transcriptArtifactTarget} artifactTarget={transcriptArtifactTarget}
onOpenArtifact={wideLayout ? openArtifactBeside : undefined}
messages={transcriptMessages} messages={transcriptMessages}
olderCursor={activeSnapshot?.olderCursor ?? null} olderCursor={activeSnapshot?.olderCursor ?? null}
loadingOlder={loadingOlder} loadingOlder={loadingOlder}
@ -4603,6 +4750,7 @@ export function ShellPage() {
<aside <aside
data-testid="side-panel" data-testid="side-panel"
data-panel={panel ?? "closed"} data-panel={panel ?? "closed"}
data-auto={panel !== null && panel === autoPanel ? "" : undefined}
style={{ style={{
width: width:
panel && panel &&
@ -4611,7 +4759,9 @@ export function ShellPage() {
panel === "create" || panel === "create" ||
panel === "create-group" || panel === "create-group" ||
panel === "browser") panel === "browser")
? rightSidebarWidth ? workspacePaneOpen
? workspacePaneWidth
: rightSidebarWidth
: 0, : 0,
}} }}
className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl md:relative ${ className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl md:relative ${
@ -4625,13 +4775,22 @@ export function ShellPage() {
) )
? "max-w-[384px] md:max-w-none opacity-100" ? "max-w-[384px] md:max-w-none opacity-100"
: "pointer-events-none w-0 opacity-0 md:opacity-0 md:w-0" : "pointer-events-none w-0 opacity-0 md:opacity-0 md:w-0"
} ${!isResizingRight ? "transition-[width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" : ""}`} } ${!(workspacePaneOpen ? isResizingWorkspacePane : isResizingRight) ? "transition-[width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none" : ""}`}
> >
<div <div
onPointerDown={startResizingRight} onPointerDown={workspacePaneOpen ? startResizingWorkspacePane : startResizingRight}
className="absolute -left-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block" className="absolute -left-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
/> />
{panel === "browser" ? ( {panel === "artifact" ? (
artifactPane ? (
<ArtifactPane
key={artifactPane.artifactId}
target={transcriptArtifactTarget}
artifact={artifactPane}
onClose={() => setPanel(null)}
/>
) : null
) : panel === "browser" ? (
<InAppBrowser onClose={() => setPanel(null)} /> <InAppBrowser onClose={() => setPanel(null)} />
) : panel && (active || activeGroup || panel === "create" || panel === "create-group") ? ( ) : panel && (active || activeGroup || panel === "create" || panel === "create-group") ? (
<div className="rk-scroll h-full w-full overflow-y-auto px-5 lg:px-7 2xl:px-9 py-[17px]"> <div className="rk-scroll h-full w-full overflow-y-auto px-5 lg:px-7 2xl:px-9 py-[17px]">
@ -5632,6 +5791,7 @@ export function ShellPage() {
const Transcript = memo(function Transcript({ const Transcript = memo(function Transcript({
scrollRef, scrollRef,
artifactTarget, artifactTarget,
onOpenArtifact,
messages, messages,
olderCursor, olderCursor,
loadingOlder, loadingOlder,
@ -5662,6 +5822,7 @@ const Transcript = memo(function Transcript({
}: { }: {
scrollRef: RefObject<HTMLDivElement | null>; scrollRef: RefObject<HTMLDivElement | null>;
artifactTarget: ArtifactTarget; artifactTarget: ArtifactTarget;
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
messages: ThreadMessage[]; messages: ThreadMessage[];
olderCursor: number | null; olderCursor: number | null;
loadingOlder: boolean; loadingOlder: boolean;
@ -5801,6 +5962,43 @@ const Transcript = memo(function Transcript({
if (following.current) snapToEnd(); if (following.current) snapToEnd();
}, [messages, running, snapToEnd]); }, [messages, running, snapToEnd]);
// A question the bot waits on can scroll out of view; offer a way back to it instead of the arrow.
const [askOffscreen, setAskOffscreen] = useState(false);
const findAsk = useCallback(
() =>
answerableAskMessageId
? (scrollRef.current?.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(answerableAskMessageId)}"]`,
) ?? null)
: null,
[answerableAskMessageId, scrollRef],
);
useEffect(() => {
const root = scrollRef.current;
const element = findAsk();
if (!root || !element || typeof IntersectionObserver === "undefined") {
setAskOffscreen(false);
return;
}
const observer = new IntersectionObserver(
(entries) => setAskOffscreen(!entries.some((entry) => entry.isIntersecting)),
{ root, threshold: 0.2 },
);
observer.observe(element);
return () => observer.disconnect();
}, [findAsk, messages, scrollRef]);
const jumpToAsk = useCallback(() => {
const element = findAsk();
if (!element) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
following.current = false;
element.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "center" });
element
.querySelector<HTMLElement>("[data-ask-card] input, [data-ask-card] button")
?.focus({ preventScroll: true });
}, [findAsk]);
const showJumpToLatest = !atEnd && !askOffscreen;
useLayoutEffect(() => { useLayoutEffect(() => {
const button = jumpButtonRef.current; const button = jumpButtonRef.current;
if (atEnd && button && document.activeElement === button) { if (atEnd && button && document.activeElement === button) {
@ -6039,6 +6237,7 @@ const Transcript = memo(function Transcript({
)} )}
<MessageView <MessageView
artifactTarget={artifactTarget} artifactTarget={artifactTarget}
onOpenArtifact={onOpenArtifact}
message={message} message={message}
canAnswer={message.id === answerableAskMessageId} canAnswer={message.id === answerableAskMessageId}
onOpenBot={onOpenBot} onOpenBot={onOpenBot}
@ -6121,15 +6320,28 @@ const Transcript = memo(function Transcript({
ref={jumpButtonRef} ref={jumpButtonRef}
type="button" type="button"
aria-label={t`Jump to latest`} aria-label={t`Jump to latest`}
aria-hidden={atEnd} aria-hidden={!showJumpToLatest}
tabIndex={atEnd ? -1 : 0} tabIndex={showJumpToLatest ? 0 : -1}
onClick={jumpToLatest} onClick={jumpToLatest}
className={`absolute bottom-4 left-1/2 z-20 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-border bg-muted/95 text-foreground/75 shadow-md backdrop-blur transition-[opacity,transform,background-color] duration-200 ease-[cubic-bezier(.22,1,.36,1)] hover:bg-border motion-reduce:transition-none ${ className={`absolute bottom-4 left-1/2 z-20 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-border bg-muted/95 text-foreground/75 shadow-md backdrop-blur transition-[opacity,transform,background-color] duration-200 ease-[cubic-bezier(.22,1,.36,1)] hover:bg-border motion-reduce:transition-none ${
atEnd ? "pointer-events-none translate-y-2 opacity-0" : "translate-y-0 opacity-100" showJumpToLatest
? "translate-y-0 opacity-100"
: "pointer-events-none translate-y-2 opacity-0"
}`} }`}
> >
<ArrowDown size={17} strokeWidth={1.8} /> <ArrowDown size={17} strokeWidth={1.8} />
</button> </button>
{askOffscreen ? (
<button
type="button"
data-testid="needs-you-jump"
onClick={jumpToAsk}
className="absolute bottom-4 left-1/2 z-20 inline-flex h-9 -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-muted/95 px-3.5 text-ui-body-sm font-medium text-foreground shadow-md backdrop-blur hover:bg-border"
>
<span aria-hidden="true" className="size-2 rounded-full bg-warning" />
{t`Needs you`}
</button>
) : null}
</div> </div>
); );
}); });
@ -7258,6 +7470,7 @@ function ComputerReleaseActions({
const MessageView = memo(function MessageView({ const MessageView = memo(function MessageView({
artifactTarget, artifactTarget,
onOpenArtifact,
canAnswer, canAnswer,
message, message,
onAnswer, onAnswer,
@ -7279,6 +7492,7 @@ const MessageView = memo(function MessageView({
encounterMode = false, encounterMode = false,
}: { }: {
artifactTarget: ArtifactTarget; artifactTarget: ArtifactTarget;
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
canAnswer: boolean; canAnswer: boolean;
message: ThreadMessage; message: ThreadMessage;
onAnswer: (message: ThreadMessage, text: string) => Promise<void>; onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
@ -7680,6 +7894,17 @@ const MessageView = memo(function MessageView({
name={block.name} name={block.name}
mimeType={block.mimeType} mimeType={block.mimeType}
size={block.size} size={block.size}
onOpenBeside={
onOpenArtifact
? () =>
onOpenArtifact({
messageId: message.id,
artifactId: block.artifactId,
name: block.name,
mimeType: block.mimeType,
})
: undefined
}
/> />
</div> </div>
); );

View File

@ -0,0 +1,32 @@
# Adaptive workspace
The pane beside a conversation follows what the bot is actually doing, not a guess about the task.
No classifier or model decides the layout: `deriveWorkspaceFocus` in `@rakazo/core` reads the thread
state every client already has, so web and mobile agree and the rules stay testable offline.
## Signals
| Focus | Signal | Web | Mobile |
|-------|--------|-----|--------|
| Screen | A fresh `thread.computer` pointer event from a working run, or a takeover request | Live screen opens beside the chat (wide pane), steps back a few seconds after the work ends | Pill above the composer opens the computer |
| Artifact | The latest run attached a file with `present: true` (`attach_file`) | File opens beside the chat until the user closes it or the bot presents another | Unchanged: file cards open the native preview |
| Needs you | An unanswered ask on a waiting run | "Needs you" pill replaces the jump arrow when the question scrolled out of view | Existing push and attention notifications |
Everything else stays a plain conversation.
## Rules
- The pane moves only on focus edges (`applyWorkspaceFocus`). The first focus of a conversation is a
baseline, so opening a thread never jumps.
- A pane the user opened by hand is never replaced or closed automatically. Auto panes close only
when their own focus ends; a presented file stays.
- Windows narrower than 1024px keep the conversation full width; file cards fall back to the dialog.
- Per bot, the client learns from the user: opening the screen by hand while the bot works three
times makes it open on every run; closing an automatically opened screen twice stops it opening
on its own. A takeover request always shows the screen.
- Pane widths are remembered per device.
## Not in scope
Generated layouts, per-domain dashboards and prompt classification stay out: they guess, move the UI
under the user, and cannot be verified deterministically.

View File

@ -43,6 +43,8 @@ describe("server image publish workflow", () => {
expect(workflowText).toContain("actions/attest-build-provenance@"); expect(workflowText).toContain("actions/attest-build-provenance@");
expect(workflowText).not.toContain("github.repository_owner_type"); expect(workflowText).not.toContain("github.repository_owner_type");
expect(workflowText).toContain(`gh api "repos/\${GITHUB_REPOSITORY}"`); expect(workflowText).toContain(`gh api "repos/\${GITHUB_REPOSITORY}"`);
// `gh api --jq` already prints raw strings; `--jq -r` takes `-r` as the filter and fails.
expect(workflowText).not.toContain("--jq -r");
expect(workflowText).toContain(".owner.type"); expect(workflowText).toContain(".owner.type");
expect(workflowText).toContain("if: steps.attestation.outputs.enabled == 'true'"); expect(workflowText).toContain("if: steps.attestation.outputs.enabled == 'true'");
expect(workflowText).toContain('[[ "$owner_type" == "User" && "$private" == "true" ]]'); expect(workflowText).toContain('[[ "$owner_type" == "User" && "$private" == "true" ]]');

View File

@ -144,10 +144,17 @@ export const builtinAgentTools: ConnectorTool[] = [
{ {
name: "attach_file", name: "attach_file",
description: description:
"Attach a workspace file from this bot's home to the chat thread as an image or common file. The file stays in place; users can open it from the message.", "Attach a workspace file from this bot's home to the chat thread as an image or common file. The file stays in place; users can open it from the message. Set present when the file is the result the user asked for, so it opens beside the conversation.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { path: { type: "string" } }, properties: {
path: { type: "string" },
present: {
type: "boolean",
description:
"True only for the finished result of the request, not for supporting files.",
},
},
required: ["path"], required: ["path"],
}, },
}, },

View File

@ -2449,6 +2449,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
filePath, filePath,
bytes, bytes,
operationId: executionId, operationId: executionId,
present: args.present === true,
}, },
); );
await publishMessage(deps, run, "bot", [attached.block]); await publishMessage(deps, run, "bot", [attached.block]);

View File

@ -454,6 +454,20 @@ export function inferScript(
}, },
]; ];
} }
if (lower.includes("present") && lower.includes("report")) {
const filePath =
/(?:called|named|path|file)\s+([A-Za-z0-9._/-]+)/i.exec(prompt)?.[1] ?? "report.md";
const content =
"# Report\n\n| Item | Status |\n| --- | --- |\n| Research | Done |\n| Draft | Done |\n";
return [
{ assistant: "writing the report and putting it next to our chat." },
{ toolCalls: [{ name: "write_file", args: { path: filePath, content } }] },
{
toolCalls: [{ name: "attach_file", args: { path: filePath, present: true } }],
complete: true,
},
];
}
if (lower.includes("attach") && (lower.includes("thread") || lower.includes("into the thread"))) { if (lower.includes("attach") && (lower.includes("thread") || lower.includes("into the thread"))) {
const said = /says?\s+(.+)$/i.exec(prompt)?.[1]?.replace(/[.]+$/, "") ?? prompt; const said = /says?\s+(.+)$/i.exec(prompt)?.[1]?.replace(/[.]+$/, "") ?? prompt;
const content = `${said.trim()}\n`; const content = `${said.trim()}\n`;

View File

@ -46,6 +46,39 @@ describe("current-turn thread files", () => {
); );
}); });
it("marks a file the bot presents as its result", async () => {
const deps = {
prisma: {
artifact: {
create: vi.fn(
async ({ data }: { data: { name: string; mimeType: string; size: number } }) => ({
id: "artifact-1",
...data,
}),
),
},
} as unknown as PrismaClient,
artifacts: {
put: vi.fn().mockResolvedValue({ id: "stored-1", hash: "hash" }),
remove: vi.fn(),
} as unknown as ArtifactStore,
};
const input = {
spaceId: "workspace-1",
userId: "user-1",
botId: "bot-1",
runId: "run-1",
filePath: "out/report.md",
bytes: new TextEncoder().encode("# Report\n"),
operationId: "attach-1",
};
const presented = await attachWorkspaceFileToThread(deps, { ...input, present: true });
expect(presented.block).toMatchObject({ kind: "file", name: "report.md", present: true });
const attached = await attachWorkspaceFileToThread(deps, input);
expect(attached.block).not.toHaveProperty("present");
});
it("copies non-image attachments into the bot workspace and describes their paths", async () => { it("copies non-image attachments into the bot workspace and describes their paths", async () => {
const findMany = vi.fn().mockResolvedValue([ const findMany = vi.fn().mockResolvedValue([
{ {

View File

@ -38,6 +38,7 @@ export async function attachWorkspaceFileToThread(
filePath: string; filePath: string;
bytes: Uint8Array; bytes: Uint8Array;
operationId: string; operationId: string;
present?: boolean;
}, },
): Promise<{ artifactId: string; block: Extract<MessageBlock, { kind: "image" | "file" }> }> { ): Promise<{ artifactId: string; block: Extract<MessageBlock, { kind: "image" | "file" }> }> {
const name = path.basename(input.filePath) || input.filePath; const name = path.basename(input.filePath) || input.filePath;
@ -79,15 +80,13 @@ export async function attachWorkspaceFileToThread(
await deps.artifacts.remove(stored.id, context).catch(() => undefined); await deps.artifacts.remove(stored.id, context).catch(() => undefined);
throw error; throw error;
}); });
return { const block = messageBlockForArtifact({
artifactId: row.id, id: row.id,
block: messageBlockForArtifact({ name: row.name,
id: row.id, mimeType: row.mimeType,
name: row.name, size: row.size,
mimeType: row.mimeType, });
size: row.size, return { artifactId: row.id, block: input.present ? { ...block, present: true } : block };
}),
};
} }
export async function materializeCurrentTurnFiles( export async function materializeCurrentTurnFiles(

View File

@ -240,6 +240,8 @@ export const MessageBlock = z.discriminatedUnion("kind", [
artifactId: Id, artifactId: Id,
mimeType: z.string(), mimeType: z.string(),
name: z.string(), name: z.string(),
/** The bot presented this as its result; clients may show it beside the conversation. */
present: z.boolean().optional(),
}), }),
z.object({ z.object({
kind: z.literal("file"), kind: z.literal("file"),
@ -247,6 +249,8 @@ export const MessageBlock = z.discriminatedUnion("kind", [
mimeType: z.string(), mimeType: z.string(),
name: z.string(), name: z.string(),
size: z.number().int().nonnegative(), size: z.number().int().nonnegative(),
/** The bot presented this as its result; clients may show it beside the conversation. */
present: z.boolean().optional(),
}), }),
z.object({ z.object({
kind: z.literal("handoff"), kind: z.literal("handoff"),

View File

@ -48,3 +48,4 @@ export * from "./tool-activity.js";
export * from "./trading-bot-status.js"; export * from "./trading-bot-status.js";
export * from "./voice-chat-command.js"; export * from "./voice-chat-command.js";
export * from "./voice-labels.js"; export * from "./voice-labels.js";
export * from "./workspace-focus.js";

View File

@ -0,0 +1,305 @@
import { describe, expect, it } from "vitest";
import {
applyWorkspaceFocus,
deriveWorkspaceFocus,
isPaneArtifactMimeType,
liveScreenRunId,
parseScreenPaneHabit,
recordScreenPaneChoice,
screenPaneMode,
type WorkspaceFocusSnapshot,
} from "./workspace-focus.js";
const runningComputer = {
kind: "docker",
state: "running",
screenAvailable: true,
takeoverRequested: false,
};
function snapshot(overrides: Partial<WorkspaceFocusSnapshot> = {}): WorkspaceFocusSnapshot {
return {
messages: [],
run: { id: "run-1", status: "running" },
computer: runningComputer,
...overrides,
};
}
const report = {
id: "msg-report",
role: "bot",
runId: "run-1",
blocks: [
{
kind: "file",
artifactId: "art-1",
name: "report.md",
mimeType: "text/markdown",
present: true,
},
],
};
describe("deriveWorkspaceFocus", () => {
it("keeps a plain conversation when the bot only chats", () => {
const focus = deriveWorkspaceFocus(
snapshot({ messages: [{ id: "m", runId: "run-1", blocks: [{ kind: "text" }] }] }),
);
expect(focus.pane).toBeNull();
expect(focus.key).toBe("none|ask:none");
});
it("shows the screen once the working run acts on it", () => {
expect(deriveWorkspaceFocus(snapshot(), { screenRunId: "run-1" }).pane).toBe("screen");
expect(deriveWorkspaceFocus(snapshot(), { screenRunId: "run-old" }).pane).toBeNull();
});
it("keeps showing the screen while the bot pointed at it moments ago", () => {
const finished = snapshot({ run: { id: "run-1", status: "completed" } });
expect(deriveWorkspaceFocus(finished, { screenActive: true }).pane).toBe("screen");
expect(
deriveWorkspaceFocus(finished, { screenActive: true, screenMode: "never" }).pane,
).toBeNull();
});
it("returns to the conversation when the run finishes", () => {
const focus = deriveWorkspaceFocus(snapshot({ run: { id: "run-1", status: "completed" } }), {
screenRunId: "run-1",
});
expect(focus.pane).toBeNull();
});
it("never offers a screen it cannot embed", () => {
for (const computer of [
{ ...runningComputer, kind: "desktop" },
{ ...runningComputer, kind: "none" },
null,
]) {
expect(
deriveWorkspaceFocus(snapshot({ computer }), { screenRunId: "run-1" }).pane,
).toBeNull();
}
});
it("trusts screen work over a computer status that has not caught up with the boot", () => {
const stale = { ...runningComputer, state: "stopped", screenAvailable: false };
expect(deriveWorkspaceFocus(snapshot({ computer: stale }), { screenRunId: "run-1" }).pane).toBe(
"screen",
);
expect(
deriveWorkspaceFocus(
snapshot({
run: { id: "run-1", status: "waiting_takeover" },
computer: { ...stale, takeoverRequested: true },
}),
).pane,
).toBe("screen");
// Without evidence of screen work, a learned default never boots a stopped computer.
expect(
deriveWorkspaceFocus(snapshot({ computer: stale }), { screenMode: "always" }).pane,
).toBeNull();
});
it("shows the screen for a takeover request even when the user turned auto off", () => {
const focus = deriveWorkspaceFocus(
snapshot({
run: { id: "run-1", status: "waiting_takeover" },
computer: { ...runningComputer, takeoverRequested: true },
}),
{ screenMode: "never" },
);
expect(focus).toMatchObject({ pane: "screen", takeover: true });
});
it("follows the learned screen mode", () => {
expect(deriveWorkspaceFocus(snapshot(), { screenMode: "always" }).pane).toBe("screen");
expect(
deriveWorkspaceFocus(snapshot(), { screenMode: "never", screenRunId: "run-1" }).pane,
).toBeNull();
expect(
deriveWorkspaceFocus(snapshot({ run: { id: "run-1", status: "completed" } }), {
screenMode: "always",
}).pane,
).toBeNull();
});
it("shows the file the bot presented as its latest result", () => {
const focus = deriveWorkspaceFocus(
snapshot({ run: { id: "run-1", status: "completed" }, messages: [report] }),
);
expect(focus.pane).toBe("artifact");
expect(focus.artifact).toEqual({
messageId: "msg-report",
artifactId: "art-1",
name: "report.md",
mimeType: "text/markdown",
});
expect(focus.key).toBe("artifact:art-1|ask:none");
});
it("ignores files that were only attached, presented before the user wrote again, or not previewable", () => {
const attached = {
...report,
blocks: [{ ...report.blocks[0]!, present: undefined }],
};
expect(deriveWorkspaceFocus(snapshot({ messages: [attached] })).pane).toBeNull();
const followUp = { id: "msg-user", role: "user", runId: "run-2", blocks: [{ kind: "text" }] };
expect(deriveWorkspaceFocus(snapshot({ messages: [report, followUp] })).pane).toBeNull();
// Another member's newer run in a group does not hide the result.
expect(
deriveWorkspaceFocus(
snapshot({ run: { id: "run-9", status: "running" }, messages: [report] }),
).pane,
).toBe("artifact");
const pdf = {
...report,
blocks: [{ ...report.blocks[0]!, mimeType: "application/pdf", name: "report.pdf" }],
};
expect(deriveWorkspaceFocus(snapshot({ messages: [pdf] })).pane).toBeNull();
});
it("prefers live screen work over a presented file", () => {
expect(
deriveWorkspaceFocus(snapshot({ messages: [report] }), { screenRunId: "run-1" }).pane,
).toBe("screen");
});
it("names the ask the bot is waiting on", () => {
const focus = deriveWorkspaceFocus(
snapshot({
run: { id: "run-1", status: "waiting_input" },
messages: [{ id: "ask-1", runId: "run-1", blocks: [{ kind: "ask", status: "pending" }] }],
}),
);
expect(focus.needsYouMessageId).toBe("ask-1");
expect(focus.key).toBe("none|ask:ask-1");
});
});
describe("applyWorkspaceFocus", () => {
const plain = deriveWorkspaceFocus(snapshot());
const screen = deriveWorkspaceFocus(snapshot(), { screenRunId: "run-1" });
const artifact = deriveWorkspaceFocus(
snapshot({ run: { id: "run-1", status: "completed" }, messages: [report] }),
);
const closed = { open: null, auto: false } as const;
it("treats the first focus of a conversation as a baseline", () => {
expect(applyWorkspaceFocus(null, screen, closed)).toBeNull();
});
it("opens the pane on an edge and closes it again when the work ends", () => {
const opened = applyWorkspaceFocus(plain, screen, closed);
expect(opened).toEqual({ open: "screen", auto: true });
expect(applyWorkspaceFocus(screen, plain, opened!)).toEqual(closed);
});
it("moves from the screen to the presented result", () => {
expect(applyWorkspaceFocus(screen, artifact, { open: "screen", auto: true })).toEqual({
open: "artifact",
auto: true,
});
});
it("keeps a presented file open while the user follows up", () => {
const followUp = deriveWorkspaceFocus(
snapshot({
run: { id: "run-2", status: "running" },
messages: [report, { id: "msg-user", role: "user", blocks: [{ kind: "text" }] }],
}),
);
expect(followUp.pane).toBeNull();
expect(applyWorkspaceFocus(artifact, followUp, { open: "artifact", auto: true })).toBeNull();
});
it("never replaces or closes what the user opened by hand", () => {
expect(applyWorkspaceFocus(plain, screen, { open: "other", auto: false })).toBeNull();
expect(applyWorkspaceFocus(plain, artifact, { open: "screen", auto: false })).toBeNull();
expect(applyWorkspaceFocus(screen, plain, { open: "screen", auto: false })).toBeNull();
expect(applyWorkspaceFocus(plain, screen, { open: "screen", auto: false })).toBeNull();
});
it("swaps in a newer presented file", () => {
const newer = deriveWorkspaceFocus(
snapshot({
run: { id: "run-1", status: "completed" },
messages: [
report,
{
...report,
id: "msg-2",
blocks: [{ ...report.blocks[0]!, artifactId: "art-2", name: "v2.md" }],
},
],
}),
);
expect(applyWorkspaceFocus(artifact, newer, { open: "artifact", auto: false })).toEqual({
open: "artifact",
auto: false,
});
});
it("ignores focus changes that only concern a new ask", () => {
const asking = { ...plain, needsYouMessageId: "ask-1", key: "none|ask:ask-1" };
expect(applyWorkspaceFocus(plain, asking, closed)).toBeNull();
});
});
describe("screen pane habit", () => {
it("learns to always show the screen after repeated manual opens", () => {
let habit = 0;
for (let index = 0; index < 3; index += 1) habit = recordScreenPaneChoice(habit, "opened");
expect(screenPaneMode(habit)).toBe("always");
expect(recordScreenPaneChoice(habit, "opened")).toBe(3);
});
it("learns to stop opening it after repeated dismissals, and recovers on a manual open", () => {
let habit = recordScreenPaneChoice(2, "dismissed");
expect(screenPaneMode(habit)).toBe("auto");
habit = recordScreenPaneChoice(habit, "dismissed");
expect(screenPaneMode(habit)).toBe("never");
expect(screenPaneMode(recordScreenPaneChoice(habit, "opened"))).toBe("auto");
});
it("parses stored habits defensively", () => {
expect(parseScreenPaneHabit(null)).toBe(0);
expect(parseScreenPaneHabit("2")).toBe(2);
expect(parseScreenPaneHabit("99")).toBe(3);
expect(parseScreenPaneHabit("-9")).toBe(-2);
expect(parseScreenPaneHabit("x")).toBe(0);
});
});
describe("isPaneArtifactMimeType", () => {
it("accepts text and images, not PDFs", () => {
expect(isPaneArtifactMimeType("text/csv")).toBe(true);
expect(isPaneArtifactMimeType("image/png")).toBe(true);
expect(isPaneArtifactMimeType("application/pdf")).toBe(false);
});
});
describe("liveScreenRunId", () => {
const pointer = {
type: "thread.computer",
runId: "run-1",
createdAt: "2026-01-01T00:00:10.000Z",
payload: {
kind: "pointer",
screen: { width: 1280, height: 800 },
steps: [{ action: "click", x: 640, y: 400 }],
},
};
const now = Date.parse("2026-01-01T00:00:20.000Z");
it("names the run that is pointing at the screen right now", () => {
expect(liveScreenRunId(pointer, now)).toBe("run-1");
});
it("ignores stale, malformed and unrelated events", () => {
expect(liveScreenRunId(pointer, now + 60_000)).toBeNull();
expect(liveScreenRunId({ ...pointer, payload: { kind: "pointer" } }, now)).toBeNull();
expect(liveScreenRunId({ ...pointer, runId: undefined }, now)).toBeNull();
expect(liveScreenRunId({ ...pointer, type: "thread.progress" }, now)).toBeNull();
});
});

View File

@ -0,0 +1,230 @@
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;
}