Merge remote-tracking branch 'origin/main' into cursor/ci-mobile-ota-soft-fail-85b8

This commit is contained in:
Claude 2026-10-03 20:26:03 +00:00
commit 80c56eef36
No known key found for this signature in database
30 changed files with 1489 additions and 73 deletions

View File

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

View File

@ -17,10 +17,12 @@ import {
buildComposerMentionOptions,
type ComposerMention,
cloudAgentHttpsUrl,
deriveWorkspaceFocus,
isApprovalAskBlock,
isRunTerminalEvent,
isSecretAskBlock,
latestAnswerableAskMessageId,
liveScreenRunId,
mentionChipKey,
projectMessageReactions,
resolveComposerSendPlan,
@ -288,6 +290,8 @@ function Thread() {
? { botId }
: undefined;
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 [draft, setDraft] = useState("");
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
@ -860,6 +864,7 @@ function Thread() {
}
expandedHistoryThread.current = null;
historyEpoch.current += 1;
setScreenRunId(null);
const abort = new AbortController();
void (async () => {
// Pending search jumps load the around-page separately; avoid replacing it with latest.
@ -885,6 +890,8 @@ function Thread() {
(event) => {
cursor = Math.max(cursor, event.seq ?? -1);
retryMs = 250;
const screenRun = groupId ? null : liveScreenRunId(event);
if (screenRun) setScreenRunId(screenRun);
if (
event.type === "thread.progress" ||
event.type === "agent.tool.called" ||
@ -1271,6 +1278,23 @@ function Thread() {
}
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 liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]);
const messagesById = useMemo(
@ -1668,6 +1692,43 @@ function Thread() {
) : null}
</View>
<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 ? (
<View
style={{

View File

@ -790,9 +790,11 @@ export type MobileSnapshot = {
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
members?: MobileGroup["members"];
computer?: {
kind?: string;
state: string;
controlHolder: string;
screenAvailable: boolean;
takeoverRequested?: boolean;
mode: ComputerMode;
busyBotName: string | null;
};
@ -896,6 +898,7 @@ type ThreadEvent = {
type: string;
seq?: number;
runId?: string;
createdAt?: string;
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 ({
page,
}, 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();
await signup(page, `markdown-preview-${stamp}@rakazo.test`, "password12", "Markdown Preview");
await completeOnboarding(page);

View File

@ -69,9 +69,10 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
})
.toBe("waiting_takeover");
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");
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([
page.locator("main").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" });
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
await groupMarkdown.click();
const markdownDialog = page.getByRole("dialog", { name: "group-preview.md" });
await expect(markdownDialog.getByRole("heading", { name: "Group artifact" })).toBeVisible();
await markdownDialog.getByRole("button", { name: "Close preview" }).click();
// Wide windows open the file beside the group conversation.
const markdownPane = page.getByTestId("artifact-pane");
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 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 { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
import { Download, FileText, X } from "lucide-react";
import { type RefObject, useEffect, useRef, useState } from "react";
import {
type ArtifactTarget,
downloadArtifact,
downloadArtifactBytes,
fetchArtifactBytes,
} from "../lib/artifact-open";
import { type RefObject, useMemo, useRef, useState } from "react";
import { useArtifactBytes } from "../hooks/useArtifactBytes";
import { type ArtifactTarget, downloadArtifact, downloadArtifactBytes } from "../lib/artifact-open";
type ArtifactFileCardProps = {
target: ArtifactTarget;
@ -18,6 +14,8 @@ type ArtifactFileCardProps = {
name: string;
mimeType: string;
size: number;
/** Shows the preview beside the conversation instead of in a dialog. */
onOpenBeside?: () => void;
};
export function ArtifactFileCard(props: ArtifactFileCardProps) {
@ -63,7 +61,7 @@ export function ArtifactFileCard(props: ArtifactFileCardProps) {
ref={previewButton}
type="button"
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"
>
<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> }) {
const { t } = useLingui();
const [downloadError, setDownloadError] = useState<string | null>(null);
const [state, setState] = useState<
| { status: "loading" }
| { status: "ready"; bytes: Uint8Array; markdown: string }
| { status: "error"; message: string }
>({ status: "loading" });
const targetBotId = "botId" in target ? target.botId : undefined;
const targetGroupId = "groupId" in target ? target.groupId : undefined;
useEffect(() => {
let cancelled = false;
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]);
const loaded = useArtifactBytes(target, artifactId);
const state = useMemo(() => {
if (loaded.status !== "ready") return loaded;
try {
const markdown = new TextDecoder("utf-8", { fatal: true }).decode(loaded.bytes);
return { status: "ready" as const, bytes: loaded.bytes, markdown };
} catch {
return { status: "error" as const, message: t`This file is not valid UTF-8 Markdown.` };
}
}, [loaded, t]);
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 (
<div
data-ask-card
data-testid={secretInput ? "secret-ask-card" : undefined}
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(
initialWidth: number,
side: "left" | "right",
minWidth: 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 widthRef = useRef(width);
widthRef.current = width;
useEffect(() => {
if (!isResizing) return;
@ -26,6 +33,7 @@ export function useSidebarResize(
const handlePointerUp = () => {
setIsResizing(false);
document.body.style.cursor = "default";
if (storageKey) writePaneWidth(storageKey, widthRef.current);
};
window.addEventListener("pointermove", handlePointerMove);
@ -38,7 +46,7 @@ export function useSidebarResize(
window.removeEventListener("pointerup", handlePointerUp);
document.body.style.cursor = "default";
};
}, [isResizing, side, minWidth, maxWidth]);
}, [isResizing, side, minWidth, maxWidth, storageKey]);
const startResizing = useCallback((e: React.PointerEvent) => {
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,
type ComputerPointerStep,
} from "@rakazo/contracts";
import { POINTER_EVENT_MAX_AGE_MS } from "@rakazo/core";
export type PointerPoint = { x: number; y: number };
export type PointerScreen = ComputerPointerPayload["screen"];
/** Older announcements describe actions that already finished; replaying them would mislead. */
export const POINTER_EVENT_MAX_AGE_MS = 30_000;
export { POINTER_EVENT_MAX_AGE_MS };
export const POINTER_IDLE_MS = 3_500;
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

@ -49,11 +49,13 @@ import {
normalizeCreateBotProfile,
} from "@rakazo/contracts";
import {
applyWorkspaceFocus,
attachmentsForThread,
buildComposerMentionOptions,
type ComposerMention,
clampMentionHighlightIndex,
cronFromPreset,
deriveWorkspaceFocus,
groupBotsForSidebar,
inferAttachmentMimeType,
isActive,
@ -61,21 +63,27 @@ import {
isRunTerminalEvent,
isToolActivityBlock,
latestAnswerableAskMessageId,
liveScreenRunId,
matchVoiceByName,
mentionChipKey,
type PaneState,
parseVoiceChatCommand,
projectMessageReactions,
recordScreenPaneChoice,
reorderBotTo,
resolveComposerSendPlan,
resolveMentionPickerKey,
runThreadSubscription,
SLASH_ACTIONS,
type SlashActionId,
screenPaneMode,
searchHitThreadTarget,
serializeComposerPrompt,
speechFromBlocks,
truncateSlashDescription,
userVisibleMessages,
type WorkspaceArtifact,
type WorkspaceFocus,
} from "@rakazo/core";
import {
AvatarStyleProvider,
@ -156,6 +164,7 @@ import {
import { createPortal } from "react-dom";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ArtifactFileCard } from "../components/ArtifactFileCard";
import { ArtifactPane } from "../components/ArtifactPane";
import { AskCard } from "../components/AskCard";
import { ActiveBotGlyph, CollaborationMarker } from "../components/ai/CollaborationMarker";
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 { VoiceAuroraRim } from "../components/voice/VoiceAurora";
import { useSidebarResize } from "../hooks/useSidebarResize";
import { useWideLayout } from "../hooks/useWideLayout";
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity";
import { agentPointerFeed } from "../lib/agent-pointer";
@ -249,6 +259,7 @@ import {
transcriptMovedDown,
} from "../lib/transcript-scroll";
import { speaker } from "../lib/tts";
import { readScreenPaneHabit, writeScreenPaneHabit } from "../lib/workspace-pane-pref";
import { ActivityList } from "./ActivityList";
import type { ContextMenuPosition } from "./BotContextMenu";
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
@ -327,6 +338,7 @@ type Panel =
| "create-group"
| "group-settings"
| "browser"
| "artifact"
| null;
type PendingAttachment = {
@ -351,6 +363,14 @@ const THREAD_SNAPSHOT_TIMEOUT_MS = 2_000;
const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000;
const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32;
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 {
return AbortSignal.any([parent, AbortSignal.timeout(THREAD_SNAPSHOT_TIMEOUT_MS)]);
@ -438,12 +458,24 @@ export function ShellPage() {
width: leftSidebarWidth,
isResizing: isResizingLeft,
startResizing: startResizingLeft,
} = useSidebarResize(316, "left", 240, 450);
} = useSidebarResize(316, "left", 240, 450, "rakazo:left-sidebar-width");
const {
width: rightSidebarWidth,
isResizing: isResizingRight,
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 [archivedOpen, setArchivedOpen] = useState(false);
@ -458,6 +490,15 @@ export function ShellPage() {
}, [userId]);
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);
@ -1458,6 +1499,16 @@ export function ShellPage() {
void refreshComputerScreen(active.id).catch(() => undefined);
} else if (event.type === "thread.computer") {
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 ?? "" }),
[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;
// Bots of every space, so a group message from a bot that is not a member still has a speaker.
const knownBots = useMemo(() => {
@ -3110,8 +3238,22 @@ export function ShellPage() {
setComputerOpen(false);
setComputerError(null);
setComputerErrorFromScreen(false);
setScreenRunId(null);
setScreenActive(false);
window.clearTimeout(screenActiveTimer.current);
}, [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(() => {
if (focusPromptBotIdRef.current && focusPromptBotIdRef.current !== active?.id) {
cancelFocusPrompt();
@ -3228,6 +3370,9 @@ export function ShellPage() {
}
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 hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl);
const computerScreenError =
@ -4410,6 +4555,7 @@ export function ShellPage() {
title={t`Agent computer`}
onClick={() => {
const next = panel === "computer" ? null : "computer";
if (next === "computer" && transcriptRunning) learnScreenPane("opened");
setPanel(next);
if (next === "computer" && active) {
// Refresh run/computer so Take control isn't stuck on a stale busyBotName.
@ -4500,6 +4646,7 @@ export function ShellPage() {
}}
scrollRef={messageScroll}
artifactTarget={transcriptArtifactTarget}
onOpenArtifact={wideLayout ? openArtifactBeside : undefined}
messages={transcriptMessages}
olderCursor={activeSnapshot?.olderCursor ?? null}
loadingOlder={loadingOlder}
@ -4605,6 +4752,7 @@ export function ShellPage() {
<aside
data-testid="side-panel"
data-panel={panel ?? "closed"}
data-auto={panel !== null && panel === autoPanel ? "" : undefined}
style={{
width:
panel &&
@ -4613,7 +4761,9 @@ export function ShellPage() {
panel === "create" ||
panel === "create-group" ||
panel === "browser")
? rightSidebarWidth
? workspacePaneOpen
? workspacePaneWidth
: rightSidebarWidth
: 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 ${
@ -4627,13 +4777,22 @@ export function ShellPage() {
)
? "max-w-[384px] md:max-w-none opacity-100"
: "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
onPointerDown={startResizingRight}
onPointerDown={workspacePaneOpen ? startResizingWorkspacePane : startResizingRight}
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)} />
) : 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]">
@ -5634,6 +5793,7 @@ export function ShellPage() {
const Transcript = memo(function Transcript({
scrollRef,
artifactTarget,
onOpenArtifact,
messages,
olderCursor,
loadingOlder,
@ -5664,6 +5824,7 @@ const Transcript = memo(function Transcript({
}: {
scrollRef: RefObject<HTMLDivElement | null>;
artifactTarget: ArtifactTarget;
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
messages: ThreadMessage[];
olderCursor: number | null;
loadingOlder: boolean;
@ -5803,6 +5964,43 @@ const Transcript = memo(function Transcript({
if (following.current) 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(() => {
const button = jumpButtonRef.current;
if (atEnd && button && document.activeElement === button) {
@ -6045,6 +6243,7 @@ const Transcript = memo(function Transcript({
)}
<MessageView
artifactTarget={artifactTarget}
onOpenArtifact={onOpenArtifact}
message={message}
canAnswer={message.id === answerableAskMessageId}
onOpenBot={onOpenBot}
@ -6127,15 +6326,28 @@ const Transcript = memo(function Transcript({
ref={jumpButtonRef}
type="button"
aria-label={t`Jump to latest`}
aria-hidden={atEnd}
tabIndex={atEnd ? -1 : 0}
aria-hidden={!showJumpToLatest}
tabIndex={showJumpToLatest ? 0 : -1}
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 ${
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} />
</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>
);
});
@ -7264,6 +7476,7 @@ function ComputerReleaseActions({
const MessageView = memo(function MessageView({
artifactTarget,
onOpenArtifact,
canAnswer,
message,
onAnswer,
@ -7285,6 +7498,7 @@ const MessageView = memo(function MessageView({
encounterMode = false,
}: {
artifactTarget: ArtifactTarget;
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
canAnswer: boolean;
message: ThreadMessage;
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
@ -7686,6 +7900,17 @@ const MessageView = memo(function MessageView({
name={block.name}
mimeType={block.mimeType}
size={block.size}
onOpenBeside={
onOpenArtifact
? () =>
onOpenArtifact({
messageId: message.id,
artifactId: block.artifactId,
name: block.name,
mimeType: block.mimeType,
})
: undefined
}
/>
</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).not.toContain("github.repository_owner_type");
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("if: steps.attestation.outputs.enabled == 'true'");
expect(workflowText).toContain('[[ "$owner_type" == "User" && "$private" == "true" ]]');

View File

@ -144,10 +144,17 @@ export const builtinAgentTools: ConnectorTool[] = [
{
name: "attach_file",
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: {
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"],
},
},

View File

@ -2449,6 +2449,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
filePath,
bytes,
operationId: executionId,
present: args.present === true,
},
);
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"))) {
const said = /says?\s+(.+)$/i.exec(prompt)?.[1]?.replace(/[.]+$/, "") ?? prompt;
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 () => {
const findMany = vi.fn().mockResolvedValue([
{

View File

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

View File

@ -240,6 +240,8 @@ export const MessageBlock = z.discriminatedUnion("kind", [
artifactId: Id,
mimeType: 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({
kind: z.literal("file"),
@ -247,6 +249,8 @@ export const MessageBlock = z.discriminatedUnion("kind", [
mimeType: z.string(),
name: z.string(),
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({
kind: z.literal("handoff"),

View File

@ -48,3 +48,4 @@ export * from "./tool-activity.js";
export * from "./trading-bot-status.js";
export * from "./voice-chat-command.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;
}