Merge main into the legacy cleanup
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUTKejm92a8Z7bgSunJChV
This commit is contained in:
commit
3edce64bad
4
.github/workflows/publish-server-image.yml
vendored
4
.github/workflows/publish-server-image.yml
vendored
@ -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."
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
104
apps/web/e2e/adaptive-workspace.spec.ts
Normal file
104
apps/web/e2e/adaptive-workspace.spec.ts
Normal 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);
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
165
apps/web/src/components/ArtifactPane.tsx
Normal file
165
apps/web/src/components/ArtifactPane.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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)}
|
||||
>
|
||||
|
||||
38
apps/web/src/hooks/useArtifactBytes.ts
Normal file
38
apps/web/src/hooks/useArtifactBytes.ts
Normal 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;
|
||||
}
|
||||
@ -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();
|
||||
|
||||
16
apps/web/src/hooks/useWideLayout.ts
Normal file
16
apps/web/src/hooks/useWideLayout.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
31
apps/web/src/lib/csv-preview.test.ts
Normal file
31
apps/web/src/lib/csv-preview.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
43
apps/web/src/lib/csv-preview.ts
Normal file
43
apps/web/src/lib/csv-preview.ts
Normal 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 };
|
||||
}
|
||||
61
apps/web/src/lib/workspace-pane-pref.test.ts
Normal file
61
apps/web/src/lib/workspace-pane-pref.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
52
apps/web/src/lib/workspace-pane-pref.ts
Normal file
52
apps/web/src/lib/workspace-pane-pref.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
@ -155,6 +163,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";
|
||||
@ -177,6 +186,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";
|
||||
@ -245,6 +255,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";
|
||||
@ -323,6 +334,7 @@ type Panel =
|
||||
| "create-group"
|
||||
| "group-settings"
|
||||
| "browser"
|
||||
| "artifact"
|
||||
| null;
|
||||
|
||||
type PendingAttachment = {
|
||||
@ -347,6 +359,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)]);
|
||||
@ -433,12 +453,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);
|
||||
@ -453,6 +485,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);
|
||||
|
||||
@ -1453,6 +1494,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -2097,6 +2148,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(() => {
|
||||
@ -3105,8 +3233,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();
|
||||
@ -3223,6 +3365,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 =
|
||||
@ -4403,6 +4548,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.
|
||||
@ -4490,6 +4636,7 @@ export function ShellPage() {
|
||||
}
|
||||
scrollRef={messageScroll}
|
||||
artifactTarget={transcriptArtifactTarget}
|
||||
onOpenArtifact={wideLayout ? openArtifactBeside : undefined}
|
||||
messages={transcriptMessages}
|
||||
olderCursor={activeSnapshot?.olderCursor ?? null}
|
||||
loadingOlder={loadingOlder}
|
||||
@ -4592,6 +4739,7 @@ export function ShellPage() {
|
||||
<aside
|
||||
data-testid="side-panel"
|
||||
data-panel={panel ?? "closed"}
|
||||
data-auto={panel !== null && panel === autoPanel ? "" : undefined}
|
||||
style={{
|
||||
width:
|
||||
panel &&
|
||||
@ -4600,7 +4748,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 ${
|
||||
@ -4614,13 +4764,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]">
|
||||
@ -5620,6 +5779,7 @@ export function ShellPage() {
|
||||
const Transcript = memo(function Transcript({
|
||||
scrollRef,
|
||||
artifactTarget,
|
||||
onOpenArtifact,
|
||||
messages,
|
||||
olderCursor,
|
||||
loadingOlder,
|
||||
@ -5649,6 +5809,7 @@ const Transcript = memo(function Transcript({
|
||||
}: {
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
artifactTarget: ArtifactTarget;
|
||||
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
|
||||
messages: ThreadMessage[];
|
||||
olderCursor: number | null;
|
||||
loadingOlder: boolean;
|
||||
@ -5787,6 +5948,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) {
|
||||
@ -5953,6 +6151,7 @@ const Transcript = memo(function Transcript({
|
||||
)}
|
||||
<MessageView
|
||||
artifactTarget={artifactTarget}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
message={message}
|
||||
canAnswer={message.id === answerableAskMessageId}
|
||||
onOpenBot={onOpenBot}
|
||||
@ -6035,15 +6234,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>
|
||||
);
|
||||
});
|
||||
@ -7106,6 +7318,7 @@ function ComputerReleaseActions({
|
||||
|
||||
const MessageView = memo(function MessageView({
|
||||
artifactTarget,
|
||||
onOpenArtifact,
|
||||
canAnswer,
|
||||
message,
|
||||
onAnswer,
|
||||
@ -7127,6 +7340,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>;
|
||||
@ -7528,6 +7742,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>
|
||||
);
|
||||
|
||||
32
docs/ADAPTIVE-WORKSPACE.md
Normal file
32
docs/ADAPTIVE-WORKSPACE.md
Normal 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.
|
||||
@ -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" ]]');
|
||||
|
||||
@ -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"],
|
||||
},
|
||||
},
|
||||
|
||||
@ -2449,6 +2449,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
||||
filePath,
|
||||
bytes,
|
||||
operationId: executionId,
|
||||
present: args.present === true,
|
||||
},
|
||||
);
|
||||
await publishMessage(deps, run, "bot", [attached.block]);
|
||||
|
||||
@ -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`;
|
||||
|
||||
@ -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([
|
||||
{
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -47,3 +47,4 @@ export * from "./thread-subscription.js";
|
||||
export * from "./tool-activity.js";
|
||||
export * from "./voice-chat-command.js";
|
||||
export * from "./voice-labels.js";
|
||||
export * from "./workspace-focus.js";
|
||||
|
||||
305
packages/core/src/workspace-focus.test.ts
Normal file
305
packages/core/src/workspace-focus.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
230
packages/core/src/workspace-focus.ts
Normal file
230
packages/core/src/workspace-focus.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user