Compare commits
No commits in common. "1dd4ff4ba123a6c6603daa6f7a87885ac0db0740" and "1704d1cec9903fa4f74388889d4f7258151d9559" have entirely different histories.
1dd4ff4ba1
...
1704d1cec9
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 }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.owner.type')"
|
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.owner.type')"
|
||||||
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.private')"
|
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.private')"
|
||||||
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
|
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
|
||||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "Skipping registry attestation: unavailable for private repositories on personal accounts."
|
echo "Skipping registry attestation: unavailable for private repositories on personal accounts."
|
||||||
|
|||||||
@ -17,12 +17,10 @@ import {
|
|||||||
buildComposerMentionOptions,
|
buildComposerMentionOptions,
|
||||||
type ComposerMention,
|
type ComposerMention,
|
||||||
cloudAgentHttpsUrl,
|
cloudAgentHttpsUrl,
|
||||||
deriveWorkspaceFocus,
|
|
||||||
isApprovalAskBlock,
|
isApprovalAskBlock,
|
||||||
isRunTerminalEvent,
|
isRunTerminalEvent,
|
||||||
isSecretAskBlock,
|
isSecretAskBlock,
|
||||||
latestAnswerableAskMessageId,
|
latestAnswerableAskMessageId,
|
||||||
liveScreenRunId,
|
|
||||||
mentionChipKey,
|
mentionChipKey,
|
||||||
projectMessageReactions,
|
projectMessageReactions,
|
||||||
resolveComposerSendPlan,
|
resolveComposerSendPlan,
|
||||||
@ -290,8 +288,6 @@ function Thread() {
|
|||||||
? { botId }
|
? { botId }
|
||||||
: undefined;
|
: undefined;
|
||||||
const [snap, setSnap] = useState<MobileSnapshot | null>(null);
|
const [snap, setSnap] = useState<MobileSnapshot | null>(null);
|
||||||
/** Run that last pointed at the bot's screen, so the live screen is offered only during real screen work. */
|
|
||||||
const [screenRunId, setScreenRunId] = useState<string | null>(null);
|
|
||||||
const activeThreadId = useRef<string | undefined>(undefined);
|
const activeThreadId = useRef<string | undefined>(undefined);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||||
@ -864,7 +860,6 @@ function Thread() {
|
|||||||
}
|
}
|
||||||
expandedHistoryThread.current = null;
|
expandedHistoryThread.current = null;
|
||||||
historyEpoch.current += 1;
|
historyEpoch.current += 1;
|
||||||
setScreenRunId(null);
|
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
void (async () => {
|
void (async () => {
|
||||||
// Pending search jumps load the around-page separately; avoid replacing it with latest.
|
// Pending search jumps load the around-page separately; avoid replacing it with latest.
|
||||||
@ -890,8 +885,6 @@ function Thread() {
|
|||||||
(event) => {
|
(event) => {
|
||||||
cursor = Math.max(cursor, event.seq ?? -1);
|
cursor = Math.max(cursor, event.seq ?? -1);
|
||||||
retryMs = 250;
|
retryMs = 250;
|
||||||
const screenRun = groupId ? null : liveScreenRunId(event);
|
|
||||||
if (screenRun) setScreenRunId(screenRun);
|
|
||||||
if (
|
if (
|
||||||
event.type === "thread.progress" ||
|
event.type === "thread.progress" ||
|
||||||
event.type === "agent.tool.called" ||
|
event.type === "agent.tool.called" ||
|
||||||
@ -1278,23 +1271,6 @@ function Thread() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const answerableAskMessageId = latestAnswerableAskMessageId(snap);
|
const answerableAskMessageId = latestAnswerableAskMessageId(snap);
|
||||||
const screenFocus = useMemo(() => {
|
|
||||||
const computer = snap?.computer;
|
|
||||||
if (!snap || !computer || groupId) return null;
|
|
||||||
const focus = deriveWorkspaceFocus(
|
|
||||||
{
|
|
||||||
...snap,
|
|
||||||
computer: {
|
|
||||||
kind: computer.kind ?? "",
|
|
||||||
state: computer.state,
|
|
||||||
screenAvailable: computer.screenAvailable,
|
|
||||||
takeoverRequested: computer.takeoverRequested ?? false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ screenRunId },
|
|
||||||
);
|
|
||||||
return focus.pane === "screen" ? focus : null;
|
|
||||||
}, [groupId, screenRunId, snap]);
|
|
||||||
const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null;
|
const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null;
|
||||||
const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]);
|
const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]);
|
||||||
const messagesById = useMemo(
|
const messagesById = useMemo(
|
||||||
@ -1692,43 +1668,6 @@ function Thread() {
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}>
|
<View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}>
|
||||||
{screenFocus && botId ? (
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
testID="live-screen-pill"
|
|
||||||
onPress={() =>
|
|
||||||
router.push({
|
|
||||||
pathname: "/computer",
|
|
||||||
params: { botId, name: displayName ?? t("Bot") },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
style={({ pressed }) => ({
|
|
||||||
alignSelf: "center",
|
|
||||||
marginTop: 10,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
borderRadius: 999,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: native.fillPressed,
|
|
||||||
backgroundColor: pressed ? native.fillPressed : native.fill,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 8,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: screenFocus.takeover ? tokens.warning : tokens.success,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text style={{ color: tokens.foreground, fontSize: 14, fontWeight: "500" }}>
|
|
||||||
{screenFocus.takeover ? t("Needs takeover") : t("Open computer")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
) : null}
|
|
||||||
{replyTarget ? (
|
{replyTarget ? (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -790,11 +790,9 @@ export type MobileSnapshot = {
|
|||||||
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
|
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
|
||||||
members?: MobileGroup["members"];
|
members?: MobileGroup["members"];
|
||||||
computer?: {
|
computer?: {
|
||||||
kind?: string;
|
|
||||||
state: string;
|
state: string;
|
||||||
controlHolder: string;
|
controlHolder: string;
|
||||||
screenAvailable: boolean;
|
screenAvailable: boolean;
|
||||||
takeoverRequested?: boolean;
|
|
||||||
mode: ComputerMode;
|
mode: ComputerMode;
|
||||||
busyBotName: string | null;
|
busyBotName: string | null;
|
||||||
};
|
};
|
||||||
@ -898,7 +896,6 @@ type ThreadEvent = {
|
|||||||
type: string;
|
type: string;
|
||||||
seq?: number;
|
seq?: number;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
createdAt?: string;
|
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
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,8 +27,6 @@ test("agent-attached files appear as downloadable cards", async ({ page }, testI
|
|||||||
test("agent-attached Markdown opens a rendered preview and can be downloaded", async ({
|
test("agent-attached Markdown opens a rendered preview and can be downloaded", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
// Narrow windows preview in a dialog; wide ones open the file beside the chat.
|
|
||||||
await page.setViewportSize({ width: 900, height: 800 });
|
|
||||||
const stamp = Date.now();
|
const stamp = Date.now();
|
||||||
await signup(page, `markdown-preview-${stamp}@rakazo.test`, "password12", "Markdown Preview");
|
await signup(page, `markdown-preview-${stamp}@rakazo.test`, "password12", "Markdown Preview");
|
||||||
await completeOnboarding(page);
|
await completeOnboarding(page);
|
||||||
|
|||||||
@ -69,10 +69,9 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
|
|||||||
})
|
})
|
||||||
.toBe("waiting_takeover");
|
.toBe("waiting_takeover");
|
||||||
await captureScreenshot(page, testInfo, "08-protected-input-request");
|
await captureScreenshot(page, testInfo, "08-protected-input-request");
|
||||||
// A takeover request brings the bot's screen beside the chat on its own.
|
await page.getByTitle("Agent computer").click();
|
||||||
const sidePanel = page.getByTestId("side-panel");
|
const sidePanel = page.getByTestId("side-panel");
|
||||||
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
|
await expect(sidePanel).toHaveCSS("width", "384px");
|
||||||
await expect(sidePanel).toHaveAttribute("data-auto", "");
|
|
||||||
const [mainBox, panelBox] = await Promise.all([
|
const [mainBox, panelBox] = await Promise.all([
|
||||||
page.locator("main").boundingBox(),
|
page.locator("main").boundingBox(),
|
||||||
sidePanel.boundingBox(),
|
sidePanel.boundingBox(),
|
||||||
|
|||||||
@ -303,11 +303,9 @@ test("create group from + and see two bots in one transcript", async ({ page },
|
|||||||
const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" });
|
const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" });
|
||||||
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
|
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
|
||||||
await groupMarkdown.click();
|
await groupMarkdown.click();
|
||||||
// Wide windows open the file beside the group conversation.
|
const markdownDialog = page.getByRole("dialog", { name: "group-preview.md" });
|
||||||
const markdownPane = page.getByTestId("artifact-pane");
|
await expect(markdownDialog.getByRole("heading", { name: "Group artifact" })).toBeVisible();
|
||||||
await expect(markdownPane.getByRole("heading", { name: "Group artifact" })).toBeVisible();
|
await markdownDialog.getByRole("button", { name: "Close preview" }).click();
|
||||||
await markdownPane.getByRole("button", { name: "Close panel" }).click();
|
|
||||||
await expect(markdownPane).toHaveCount(0);
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible();
|
||||||
|
|||||||
@ -75,11 +75,7 @@ test("local settings open and save integrations without an app session", async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("ordinary browsers cannot use local settings", async ({ page }) => {
|
test("ordinary browsers cannot use local settings", async ({ page }) => {
|
||||||
await expect(async () => {
|
await page.goto("/desktop-settings");
|
||||||
await page.goto("/desktop-settings", { waitUntil: "domcontentloaded" });
|
await expect(page.getByRole("alert")).toContainText("Could not open local settings");
|
||||||
await expect(page.getByRole("alert")).toContainText("Could not open local settings", {
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
}).toPass({ timeout: 45_000 });
|
|
||||||
await expect(page.getByRole("button", { name: "Models", exact: true })).toBeHidden();
|
await expect(page.getByRole("button", { name: "Models", exact: true })).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
showSessionUnavailable,
|
showSessionUnavailable,
|
||||||
} from "./lib/session-gate";
|
} from "./lib/session-gate";
|
||||||
import { IntegrationSetupPage } from "./pages/IntegrationSetup";
|
import { IntegrationSetupPage } from "./pages/IntegrationSetup";
|
||||||
|
import { LocalSettingsPage } from "./pages/LocalSettings";
|
||||||
import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback";
|
import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback";
|
||||||
import { ShellPage } from "./pages/Shell";
|
import { ShellPage } from "./pages/Shell";
|
||||||
|
|
||||||
@ -29,18 +30,9 @@ const OnboardingPage = lazyWithRecovery("pages/Onboarding", () =>
|
|||||||
const WelcomePage = lazyWithRecovery("pages/Welcome", () =>
|
const WelcomePage = lazyWithRecovery("pages/Welcome", () =>
|
||||||
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
|
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
|
||||||
);
|
);
|
||||||
const LocalSettingsPage = lazyWithRecovery("pages/LocalSettings", () =>
|
|
||||||
import("./pages/LocalSettings").then((module) => ({ default: module.LocalSettingsPage })),
|
|
||||||
);
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
if (window.location.pathname === LOCAL_SETTINGS_PAGE) {
|
if (window.location.pathname === LOCAL_SETTINGS_PAGE) return <LocalSettingsPage />;
|
||||||
return (
|
|
||||||
<Suspense fallback={<div className="h-full bg-background" data-rakazo-app-state="loading" />}>
|
|
||||||
<LocalSettingsPage />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <SessionApp />;
|
return <SessionApp />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,9 +4,13 @@ import { Trans, useLingui } from "@lingui/react/macro";
|
|||||||
import { ChatMarkdown } from "@rakazo/chat-ui/web";
|
import { ChatMarkdown } from "@rakazo/chat-ui/web";
|
||||||
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
|
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
|
||||||
import { Download, FileText, X } from "lucide-react";
|
import { Download, FileText, X } from "lucide-react";
|
||||||
import { type RefObject, useMemo, useRef, useState } from "react";
|
import { type RefObject, useEffect, useRef, useState } from "react";
|
||||||
import { useArtifactBytes } from "../hooks/useArtifactBytes";
|
import {
|
||||||
import { type ArtifactTarget, downloadArtifact, downloadArtifactBytes } from "../lib/artifact-open";
|
type ArtifactTarget,
|
||||||
|
downloadArtifact,
|
||||||
|
downloadArtifactBytes,
|
||||||
|
fetchArtifactBytes,
|
||||||
|
} from "../lib/artifact-open";
|
||||||
|
|
||||||
type ArtifactFileCardProps = {
|
type ArtifactFileCardProps = {
|
||||||
target: ArtifactTarget;
|
target: ArtifactTarget;
|
||||||
@ -14,8 +18,6 @@ type ArtifactFileCardProps = {
|
|||||||
name: string;
|
name: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
/** Shows the preview beside the conversation instead of in a dialog. */
|
|
||||||
onOpenBeside?: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ArtifactFileCard(props: ArtifactFileCardProps) {
|
export function ArtifactFileCard(props: ArtifactFileCardProps) {
|
||||||
@ -61,7 +63,7 @@ export function ArtifactFileCard(props: ArtifactFileCardProps) {
|
|||||||
ref={previewButton}
|
ref={previewButton}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t`Preview ${props.name}`}
|
aria-label={t`Preview ${props.name}`}
|
||||||
onClick={() => (props.onOpenBeside ? props.onOpenBeside() : setPreviewOpen(true))}
|
onClick={() => setPreviewOpen(true)}
|
||||||
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-left hover:bg-accent"
|
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-left hover:bg-accent"
|
||||||
>
|
>
|
||||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[10px] bg-muted text-foreground">
|
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[10px] bg-muted text-foreground">
|
||||||
@ -109,16 +111,39 @@ function MarkdownPreview({
|
|||||||
}: ArtifactFileCardProps & { closeButtonRef: RefObject<HTMLButtonElement | null> }) {
|
}: ArtifactFileCardProps & { closeButtonRef: RefObject<HTMLButtonElement | null> }) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||||
const loaded = useArtifactBytes(target, artifactId);
|
const [state, setState] = useState<
|
||||||
const state = useMemo(() => {
|
| { status: "loading" }
|
||||||
if (loaded.status !== "ready") return loaded;
|
| { 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 {
|
try {
|
||||||
const markdown = new TextDecoder("utf-8", { fatal: true }).decode(loaded.bytes);
|
const markdown = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||||
return { status: "ready" as const, bytes: loaded.bytes, markdown };
|
setState({ status: "ready", bytes, markdown });
|
||||||
} catch {
|
} catch {
|
||||||
return { status: "error" as const, message: t`This file is not valid UTF-8 Markdown.` };
|
setState({ status: "error", message: t`This file is not valid UTF-8 Markdown.` });
|
||||||
}
|
}
|
||||||
}, [loaded, t]);
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setState({
|
||||||
|
status: "error",
|
||||||
|
message: error instanceof Error ? error.message : t`Could not load this file.`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [artifactId, targetBotId, targetGroupId, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -1,165 +0,0 @@
|
|||||||
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,7 +87,6 @@ export function AskCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-ask-card
|
|
||||||
data-testid={secretInput ? "secret-ask-card" : undefined}
|
data-testid={secretInput ? "secret-ask-card" : undefined}
|
||||||
className={cn("max-w-[74%] rounded-2xl border border-border bg-card px-5 py-4", className)}
|
className={cn("max-w-[74%] rounded-2xl border border-border bg-card px-5 py-4", className)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,38 +0,0 @@
|
|||||||
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,20 +1,13 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { readPaneWidth, writePaneWidth } from "../lib/workspace-pane-pref";
|
|
||||||
|
|
||||||
export function useSidebarResize(
|
export function useSidebarResize(
|
||||||
initialWidth: number,
|
initialWidth: number,
|
||||||
side: "left" | "right",
|
side: "left" | "right",
|
||||||
minWidth: number,
|
minWidth: number,
|
||||||
maxWidth: number,
|
maxWidth: number,
|
||||||
/** Remembers the width across reloads on this device. */
|
|
||||||
storageKey?: string,
|
|
||||||
) {
|
) {
|
||||||
const [width, setWidth] = useState(() =>
|
const [width, setWidth] = useState(initialWidth);
|
||||||
storageKey ? readPaneWidth(storageKey, initialWidth, minWidth, maxWidth) : initialWidth,
|
|
||||||
);
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const widthRef = useRef(width);
|
|
||||||
widthRef.current = width;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isResizing) return;
|
if (!isResizing) return;
|
||||||
@ -33,7 +26,6 @@ export function useSidebarResize(
|
|||||||
const handlePointerUp = () => {
|
const handlePointerUp = () => {
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
document.body.style.cursor = "default";
|
document.body.style.cursor = "default";
|
||||||
if (storageKey) writePaneWidth(storageKey, widthRef.current);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("pointermove", handlePointerMove);
|
window.addEventListener("pointermove", handlePointerMove);
|
||||||
@ -46,7 +38,7 @@ export function useSidebarResize(
|
|||||||
window.removeEventListener("pointerup", handlePointerUp);
|
window.removeEventListener("pointerup", handlePointerUp);
|
||||||
document.body.style.cursor = "default";
|
document.body.style.cursor = "default";
|
||||||
};
|
};
|
||||||
}, [isResizing, side, minWidth, maxWidth, storageKey]);
|
}, [isResizing, side, minWidth, maxWidth]);
|
||||||
|
|
||||||
const startResizing = useCallback((e: React.PointerEvent) => {
|
const startResizing = useCallback((e: React.PointerEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@ -1,16 +0,0 @@
|
|||||||
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,
|
ComputerPointerPayload,
|
||||||
type ComputerPointerStep,
|
type ComputerPointerStep,
|
||||||
} from "@rakazo/contracts";
|
} from "@rakazo/contracts";
|
||||||
import { POINTER_EVENT_MAX_AGE_MS } from "@rakazo/core";
|
|
||||||
|
|
||||||
export type PointerPoint = { x: number; y: number };
|
export type PointerPoint = { x: number; y: number };
|
||||||
export type PointerScreen = ComputerPointerPayload["screen"];
|
export type PointerScreen = ComputerPointerPayload["screen"];
|
||||||
|
|
||||||
export { POINTER_EVENT_MAX_AGE_MS };
|
/** Older announcements describe actions that already finished; replaying them would mislead. */
|
||||||
|
export const POINTER_EVENT_MAX_AGE_MS = 30_000;
|
||||||
export const POINTER_IDLE_MS = 3_500;
|
export const POINTER_IDLE_MS = 3_500;
|
||||||
|
|
||||||
type Listener = (payload: ComputerPointerPayload) => void;
|
type Listener = (payload: ComputerPointerPayload) => void;
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
/** 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 };
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +1,20 @@
|
|||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { Button } from "@rakazo/ui-web";
|
import { Button } from "@rakazo/ui-web";
|
||||||
import { Suspense, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { desktopBridge } from "../lib/desktop";
|
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
|
||||||
import { lazyWithRecovery } from "../lib/lazy-recovery";
|
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
|
import { ModelSettingsOverlay } from "./ModelSettingsOverlay";
|
||||||
const IntegrationSetup = lazyWithRecovery("integrations/IntegrationSetup", () =>
|
|
||||||
import("../components/integrations/IntegrationSetup").then((module) => ({
|
|
||||||
default: module.IntegrationSetup,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const ModelSettingsOverlay = lazyWithRecovery("pages/ModelSettingsOverlay", () =>
|
|
||||||
import("./ModelSettingsOverlay").then((module) => ({ default: module.ModelSettingsOverlay })),
|
|
||||||
);
|
|
||||||
|
|
||||||
export function LocalSettingsPage() {
|
export function LocalSettingsPage() {
|
||||||
const [section, setSection] = useState<"models" | "integrations" | null>(null);
|
const [section, setSection] = useState<"models" | "integrations" | null>(null);
|
||||||
const hasLocalBridge = Boolean(desktopBridge()?.localSettings);
|
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [error, setError] = useState(!hasLocalBridge);
|
const [error, setError] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasLocalBridge) return;
|
|
||||||
void rpc.integrationSetup
|
void rpc.integrationSetup
|
||||||
.get()
|
.get()
|
||||||
.then(() => setReady(true))
|
.then(() => setReady(true))
|
||||||
.catch(() => setError(true));
|
.catch(() => setError(true));
|
||||||
}, [hasLocalBridge]);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<main className="h-full overflow-auto bg-background px-6 py-12">
|
<main className="h-full overflow-auto bg-background px-6 py-12">
|
||||||
<div className="mx-auto max-w-xl space-y-6">
|
<div className="mx-auto max-w-xl space-y-6">
|
||||||
@ -54,15 +43,9 @@ export function LocalSettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
{section === "models" ? (
|
{section === "models" ? (
|
||||||
<Suspense fallback={null}>
|
|
||||||
<ModelSettingsOverlay onClose={() => setSection(null)} localOwner />
|
<ModelSettingsOverlay onClose={() => setSection(null)} localOwner />
|
||||||
</Suspense>
|
|
||||||
) : null}
|
|
||||||
{section === "integrations" ? (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<IntegrationSetup serverSetup managedOnly />
|
|
||||||
</Suspense>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
{section === "integrations" ? <IntegrationSetup serverSetup managedOnly /> : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -49,13 +49,11 @@ import {
|
|||||||
normalizeCreateBotProfile,
|
normalizeCreateBotProfile,
|
||||||
} from "@rakazo/contracts";
|
} from "@rakazo/contracts";
|
||||||
import {
|
import {
|
||||||
applyWorkspaceFocus,
|
|
||||||
attachmentsForThread,
|
attachmentsForThread,
|
||||||
buildComposerMentionOptions,
|
buildComposerMentionOptions,
|
||||||
type ComposerMention,
|
type ComposerMention,
|
||||||
clampMentionHighlightIndex,
|
clampMentionHighlightIndex,
|
||||||
cronFromPreset,
|
cronFromPreset,
|
||||||
deriveWorkspaceFocus,
|
|
||||||
groupBotsForSidebar,
|
groupBotsForSidebar,
|
||||||
inferAttachmentMimeType,
|
inferAttachmentMimeType,
|
||||||
isActive,
|
isActive,
|
||||||
@ -63,27 +61,21 @@ import {
|
|||||||
isRunTerminalEvent,
|
isRunTerminalEvent,
|
||||||
isToolActivityBlock,
|
isToolActivityBlock,
|
||||||
latestAnswerableAskMessageId,
|
latestAnswerableAskMessageId,
|
||||||
liveScreenRunId,
|
|
||||||
matchVoiceByName,
|
matchVoiceByName,
|
||||||
mentionChipKey,
|
mentionChipKey,
|
||||||
type PaneState,
|
|
||||||
parseVoiceChatCommand,
|
parseVoiceChatCommand,
|
||||||
projectMessageReactions,
|
projectMessageReactions,
|
||||||
recordScreenPaneChoice,
|
|
||||||
reorderBotTo,
|
reorderBotTo,
|
||||||
resolveComposerSendPlan,
|
resolveComposerSendPlan,
|
||||||
resolveMentionPickerKey,
|
resolveMentionPickerKey,
|
||||||
runThreadSubscription,
|
runThreadSubscription,
|
||||||
SLASH_ACTIONS,
|
SLASH_ACTIONS,
|
||||||
type SlashActionId,
|
type SlashActionId,
|
||||||
screenPaneMode,
|
|
||||||
searchHitThreadTarget,
|
searchHitThreadTarget,
|
||||||
serializeComposerPrompt,
|
serializeComposerPrompt,
|
||||||
speechFromBlocks,
|
speechFromBlocks,
|
||||||
truncateSlashDescription,
|
truncateSlashDescription,
|
||||||
userVisibleMessages,
|
userVisibleMessages,
|
||||||
type WorkspaceArtifact,
|
|
||||||
type WorkspaceFocus,
|
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
import {
|
import {
|
||||||
AvatarStyleProvider,
|
AvatarStyleProvider,
|
||||||
@ -164,7 +156,6 @@ import {
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { ArtifactFileCard } from "../components/ArtifactFileCard";
|
import { ArtifactFileCard } from "../components/ArtifactFileCard";
|
||||||
import { ArtifactPane } from "../components/ArtifactPane";
|
|
||||||
import { AskCard } from "../components/AskCard";
|
import { AskCard } from "../components/AskCard";
|
||||||
import { ActiveBotGlyph, CollaborationMarker } from "../components/ai/CollaborationMarker";
|
import { ActiveBotGlyph, CollaborationMarker } from "../components/ai/CollaborationMarker";
|
||||||
import { DelegatedTaskCard, SentMessageCard } from "../components/ai/DelegatedTaskCard";
|
import { DelegatedTaskCard, SentMessageCard } from "../components/ai/DelegatedTaskCard";
|
||||||
@ -189,7 +180,6 @@ import { TeachComputerOverlayControl } from "../components/teach/TeachComputerOv
|
|||||||
import { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome";
|
import { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome";
|
||||||
import { VoiceAuroraRim } from "../components/voice/VoiceAurora";
|
import { VoiceAuroraRim } from "../components/voice/VoiceAurora";
|
||||||
import { useSidebarResize } from "../hooks/useSidebarResize";
|
import { useSidebarResize } from "../hooks/useSidebarResize";
|
||||||
import { useWideLayout } from "../hooks/useWideLayout";
|
|
||||||
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
|
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
|
||||||
import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity";
|
import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity";
|
||||||
import { agentPointerFeed } from "../lib/agent-pointer";
|
import { agentPointerFeed } from "../lib/agent-pointer";
|
||||||
@ -259,7 +249,6 @@ import {
|
|||||||
transcriptMovedDown,
|
transcriptMovedDown,
|
||||||
} from "../lib/transcript-scroll";
|
} from "../lib/transcript-scroll";
|
||||||
import { speaker } from "../lib/tts";
|
import { speaker } from "../lib/tts";
|
||||||
import { readScreenPaneHabit, writeScreenPaneHabit } from "../lib/workspace-pane-pref";
|
|
||||||
import { ActivityList } from "./ActivityList";
|
import { ActivityList } from "./ActivityList";
|
||||||
import type { ContextMenuPosition } from "./BotContextMenu";
|
import type { ContextMenuPosition } from "./BotContextMenu";
|
||||||
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
|
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
|
||||||
@ -338,7 +327,6 @@ type Panel =
|
|||||||
| "create-group"
|
| "create-group"
|
||||||
| "group-settings"
|
| "group-settings"
|
||||||
| "browser"
|
| "browser"
|
||||||
| "artifact"
|
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
type PendingAttachment = {
|
type PendingAttachment = {
|
||||||
@ -363,14 +351,6 @@ const THREAD_SNAPSHOT_TIMEOUT_MS = 2_000;
|
|||||||
const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000;
|
const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000;
|
||||||
const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32;
|
const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32;
|
||||||
const MOBILE_SIDEBAR_SWIPE_DISTANCE_PX = 56;
|
const MOBILE_SIDEBAR_SWIPE_DISTANCE_PX = 56;
|
||||||
/** The screen lingers after the work so its final state and the bot's reply land first. */
|
|
||||||
const AUTO_SCREEN_CLOSE_DELAY_MS = 4_000;
|
|
||||||
/** How long one pointer announcement counts as the bot working on its screen. */
|
|
||||||
const SCREEN_ACTIVE_MS = 5_000;
|
|
||||||
|
|
||||||
function defaultWorkspacePaneWidth(): number {
|
|
||||||
return Math.round(Math.max(420, Math.min(760, window.innerWidth * 0.42)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function threadSnapshotSignal(parent: AbortSignal): AbortSignal {
|
function threadSnapshotSignal(parent: AbortSignal): AbortSignal {
|
||||||
return AbortSignal.any([parent, AbortSignal.timeout(THREAD_SNAPSHOT_TIMEOUT_MS)]);
|
return AbortSignal.any([parent, AbortSignal.timeout(THREAD_SNAPSHOT_TIMEOUT_MS)]);
|
||||||
@ -458,24 +438,12 @@ export function ShellPage() {
|
|||||||
width: leftSidebarWidth,
|
width: leftSidebarWidth,
|
||||||
isResizing: isResizingLeft,
|
isResizing: isResizingLeft,
|
||||||
startResizing: startResizingLeft,
|
startResizing: startResizingLeft,
|
||||||
} = useSidebarResize(316, "left", 240, 450, "rakazo:left-sidebar-width");
|
} = useSidebarResize(316, "left", 240, 450);
|
||||||
const {
|
const {
|
||||||
width: rightSidebarWidth,
|
width: rightSidebarWidth,
|
||||||
isResizing: isResizingRight,
|
isResizing: isResizingRight,
|
||||||
startResizing: startResizingRight,
|
startResizing: startResizingRight,
|
||||||
} = useSidebarResize(384, "right", 280, 500, "rakazo:side-panel-width");
|
} = useSidebarResize(384, "right", 280, 500);
|
||||||
// The screen and presented files get a wider pane than settings: they are what the user watches.
|
|
||||||
const {
|
|
||||||
width: workspacePaneWidth,
|
|
||||||
isResizing: isResizingWorkspacePane,
|
|
||||||
startResizing: startResizingWorkspacePane,
|
|
||||||
} = useSidebarResize(
|
|
||||||
defaultWorkspacePaneWidth(),
|
|
||||||
"right",
|
|
||||||
360,
|
|
||||||
960,
|
|
||||||
"rakazo:workspace-pane-width",
|
|
||||||
);
|
|
||||||
|
|
||||||
const [heuteActionCount, setHeuteActionCount] = useState(0);
|
const [heuteActionCount, setHeuteActionCount] = useState(0);
|
||||||
const [archivedOpen, setArchivedOpen] = useState(false);
|
const [archivedOpen, setArchivedOpen] = useState(false);
|
||||||
@ -490,15 +458,6 @@ export function ShellPage() {
|
|||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
const [panel, setPanel] = useState<Panel>(null);
|
const [panel, setPanel] = useState<Panel>(null);
|
||||||
/** The pane the conversation's focus opened; cleared as soon as the user changes the pane. */
|
|
||||||
const [autoPanel, setAutoPanel] = useState<Panel>(null);
|
|
||||||
const [artifactPane, setArtifactPane] = useState<WorkspaceArtifact | null>(null);
|
|
||||||
/** Run that last pointed at the bot's screen; the live screen only follows real screen work. */
|
|
||||||
const [screenRunId, setScreenRunId] = useState<string | null>(null);
|
|
||||||
const [screenActive, setScreenActive] = useState(false);
|
|
||||||
const screenActiveTimer = useRef<number | undefined>(undefined);
|
|
||||||
const [screenHabit, setScreenHabit] = useState(0);
|
|
||||||
const wideLayout = useWideLayout();
|
|
||||||
|
|
||||||
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
|
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
|
||||||
|
|
||||||
@ -1499,16 +1458,6 @@ export function ShellPage() {
|
|||||||
void refreshComputerScreen(active.id).catch(() => undefined);
|
void refreshComputerScreen(active.id).catch(() => undefined);
|
||||||
} else if (event.type === "thread.computer") {
|
} else if (event.type === "thread.computer") {
|
||||||
agentPointerFeed.publish(event.botId, event.payload, event.createdAt);
|
agentPointerFeed.publish(event.botId, event.payload, event.createdAt);
|
||||||
const screenRun = liveScreenRunId(event);
|
|
||||||
if (screenRun) {
|
|
||||||
setScreenRunId(screenRun);
|
|
||||||
setScreenActive(true);
|
|
||||||
window.clearTimeout(screenActiveTimer.current);
|
|
||||||
screenActiveTimer.current = window.setTimeout(
|
|
||||||
() => setScreenActive(false),
|
|
||||||
SCREEN_ACTIVE_MS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -2153,83 +2102,6 @@ export function ShellPage() {
|
|||||||
() => (inGroup ? { groupId: groupId ?? "" } : { botId: active?.id ?? "" }),
|
() => (inGroup ? { groupId: groupId ?? "" } : { botId: active?.id ?? "" }),
|
||||||
[active?.id, groupId, inGroup],
|
[active?.id, groupId, inGroup],
|
||||||
);
|
);
|
||||||
const workspaceFocus = useMemo(
|
|
||||||
() =>
|
|
||||||
deriveWorkspaceFocus(
|
|
||||||
activeSnapshot ? { ...activeSnapshot, computer: inGroup ? null : computer } : null,
|
|
||||||
{ screenRunId, screenActive, screenMode: screenPaneMode(screenHabit) },
|
|
||||||
),
|
|
||||||
[activeSnapshot, computer, inGroup, screenActive, screenHabit, screenRunId],
|
|
||||||
);
|
|
||||||
const workspaceFocusRef = useRef<{ threadId: string; focus: WorkspaceFocus } | null>(null);
|
|
||||||
const autoPanelRef = useRef(autoPanel);
|
|
||||||
autoPanelRef.current = autoPanel;
|
|
||||||
const autoCloseTimer = useRef<number | undefined>(undefined);
|
|
||||||
useEffect(() => () => window.clearTimeout(autoCloseTimer.current), []);
|
|
||||||
// The pane follows the bot's work on focus edges only, so it never jumps while nothing changed.
|
|
||||||
useEffect(() => {
|
|
||||||
const threadId = activeSnapshot?.threadId;
|
|
||||||
const sameThread = Boolean(threadId) && workspaceFocusRef.current?.threadId === threadId;
|
|
||||||
// New work or another conversation cancels a pending close; an unrelated edge does not.
|
|
||||||
if (!sameThread || workspaceFocus.pane !== null) window.clearTimeout(autoCloseTimer.current);
|
|
||||||
if (!threadId) {
|
|
||||||
workspaceFocusRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const previous = sameThread ? (workspaceFocusRef.current?.focus ?? null) : null;
|
|
||||||
workspaceFocusRef.current = { threadId, focus: workspaceFocus };
|
|
||||||
// Narrow windows keep the conversation full width; a pane would cover it.
|
|
||||||
if (!wideLayout) return;
|
|
||||||
const paneState: PaneState = {
|
|
||||||
open:
|
|
||||||
panel === "computer"
|
|
||||||
? "screen"
|
|
||||||
: panel === "artifact"
|
|
||||||
? "artifact"
|
|
||||||
: panel
|
|
||||||
? "other"
|
|
||||||
: null,
|
|
||||||
auto: panel !== null && panel === autoPanel,
|
|
||||||
};
|
|
||||||
const next = applyWorkspaceFocus(previous, workspaceFocus, paneState);
|
|
||||||
if (!next || next.open === "other") return;
|
|
||||||
if (next.open === null) {
|
|
||||||
const closing = autoPanelRef.current;
|
|
||||||
autoCloseTimer.current = window.setTimeout(() => {
|
|
||||||
if (closing === null || autoPanelRef.current !== closing) return;
|
|
||||||
setPanel(null);
|
|
||||||
setAutoPanel(null);
|
|
||||||
}, AUTO_SCREEN_CLOSE_DELAY_MS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (next.open === "artifact") setArtifactPane(workspaceFocus.artifact);
|
|
||||||
const nextPanel: Panel = next.open === "screen" ? "computer" : next.open;
|
|
||||||
setPanel(nextPanel);
|
|
||||||
setAutoPanel(next.auto ? nextPanel : null);
|
|
||||||
}, [workspaceFocus.key, activeSnapshot?.threadId, wideLayout]);
|
|
||||||
const learnScreenPane = useCallback(
|
|
||||||
(choice: "opened" | "dismissed") => {
|
|
||||||
const botId = activeBotId.current;
|
|
||||||
if (!botId) return;
|
|
||||||
const next = recordScreenPaneChoice(screenHabit, choice);
|
|
||||||
setScreenHabit(next);
|
|
||||||
writeScreenPaneHabit(userId, botId, next);
|
|
||||||
},
|
|
||||||
[screenHabit, userId],
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoPanel === null || panel === autoPanel) return;
|
|
||||||
// Closing the live screen while the bot still works on it teaches this bot's default.
|
|
||||||
const focus = workspaceFocusRef.current?.focus;
|
|
||||||
if (autoPanel === "computer" && panel === null && focus?.pane === "screen" && !focus.takeover) {
|
|
||||||
learnScreenPane("dismissed");
|
|
||||||
}
|
|
||||||
setAutoPanel(null);
|
|
||||||
}, [panel]);
|
|
||||||
const openArtifactBeside = useCallback((artifact: WorkspaceArtifact) => {
|
|
||||||
setArtifactPane(artifact);
|
|
||||||
setPanel("artifact");
|
|
||||||
}, []);
|
|
||||||
const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members;
|
const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members;
|
||||||
// Bots of every space, so a group message from a bot that is not a member still has a speaker.
|
// Bots of every space, so a group message from a bot that is not a member still has a speaker.
|
||||||
const knownBots = useMemo(() => {
|
const knownBots = useMemo(() => {
|
||||||
@ -3238,22 +3110,8 @@ export function ShellPage() {
|
|||||||
setComputerOpen(false);
|
setComputerOpen(false);
|
||||||
setComputerError(null);
|
setComputerError(null);
|
||||||
setComputerErrorFromScreen(false);
|
setComputerErrorFromScreen(false);
|
||||||
setScreenRunId(null);
|
|
||||||
setScreenActive(false);
|
|
||||||
window.clearTimeout(screenActiveTimer.current);
|
|
||||||
}, [active?.id]);
|
}, [active?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setScreenHabit(readScreenPaneHabit(userId, active?.id));
|
|
||||||
}, [active?.id, userId]);
|
|
||||||
|
|
||||||
// A presented file belongs to its conversation, and a new conversation starts without auto panes.
|
|
||||||
useEffect(() => {
|
|
||||||
setPanel((current) => (current === "artifact" ? null : current));
|
|
||||||
setArtifactPane(null);
|
|
||||||
setAutoPanel(null);
|
|
||||||
}, [active?.id, groupId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusPromptBotIdRef.current && focusPromptBotIdRef.current !== active?.id) {
|
if (focusPromptBotIdRef.current && focusPromptBotIdRef.current !== active?.id) {
|
||||||
cancelFocusPrompt();
|
cancelFocusPrompt();
|
||||||
@ -3370,9 +3228,6 @@ export function ShellPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const embeddedScreenUrl = embeddableScreenUrl(screenUrl);
|
const embeddedScreenUrl = embeddableScreenUrl(screenUrl);
|
||||||
// Only what the work opened gets the wide pane; settings, routines and a hand-opened screen keep
|
|
||||||
// one width so switching between them never jumps.
|
|
||||||
const workspacePaneOpen = panel === "artifact" || (panel === "computer" && autoPanel === panel);
|
|
||||||
const hasControl = userHoldsComputerControl(computer, active?.id);
|
const hasControl = userHoldsComputerControl(computer, active?.id);
|
||||||
const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl);
|
const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl);
|
||||||
const computerScreenError =
|
const computerScreenError =
|
||||||
@ -4555,7 +4410,6 @@ export function ShellPage() {
|
|||||||
title={t`Agent computer`}
|
title={t`Agent computer`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = panel === "computer" ? null : "computer";
|
const next = panel === "computer" ? null : "computer";
|
||||||
if (next === "computer" && transcriptRunning) learnScreenPane("opened");
|
|
||||||
setPanel(next);
|
setPanel(next);
|
||||||
if (next === "computer" && active) {
|
if (next === "computer" && active) {
|
||||||
// Refresh run/computer so Take control isn't stuck on a stale busyBotName.
|
// Refresh run/computer so Take control isn't stuck on a stale busyBotName.
|
||||||
@ -4646,7 +4500,6 @@ export function ShellPage() {
|
|||||||
}}
|
}}
|
||||||
scrollRef={messageScroll}
|
scrollRef={messageScroll}
|
||||||
artifactTarget={transcriptArtifactTarget}
|
artifactTarget={transcriptArtifactTarget}
|
||||||
onOpenArtifact={wideLayout ? openArtifactBeside : undefined}
|
|
||||||
messages={transcriptMessages}
|
messages={transcriptMessages}
|
||||||
olderCursor={activeSnapshot?.olderCursor ?? null}
|
olderCursor={activeSnapshot?.olderCursor ?? null}
|
||||||
loadingOlder={loadingOlder}
|
loadingOlder={loadingOlder}
|
||||||
@ -4752,7 +4605,6 @@ export function ShellPage() {
|
|||||||
<aside
|
<aside
|
||||||
data-testid="side-panel"
|
data-testid="side-panel"
|
||||||
data-panel={panel ?? "closed"}
|
data-panel={panel ?? "closed"}
|
||||||
data-auto={panel !== null && panel === autoPanel ? "" : undefined}
|
|
||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
panel &&
|
panel &&
|
||||||
@ -4761,9 +4613,7 @@ export function ShellPage() {
|
|||||||
panel === "create" ||
|
panel === "create" ||
|
||||||
panel === "create-group" ||
|
panel === "create-group" ||
|
||||||
panel === "browser")
|
panel === "browser")
|
||||||
? workspacePaneOpen
|
? rightSidebarWidth
|
||||||
? workspacePaneWidth
|
|
||||||
: rightSidebarWidth
|
|
||||||
: 0,
|
: 0,
|
||||||
}}
|
}}
|
||||||
className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl md:relative ${
|
className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl md:relative ${
|
||||||
@ -4777,22 +4627,13 @@ export function ShellPage() {
|
|||||||
)
|
)
|
||||||
? "max-w-[384px] md:max-w-none opacity-100"
|
? "max-w-[384px] md:max-w-none opacity-100"
|
||||||
: "pointer-events-none w-0 opacity-0 md:opacity-0 md:w-0"
|
: "pointer-events-none w-0 opacity-0 md:opacity-0 md:w-0"
|
||||||
} ${!(workspacePaneOpen ? isResizingWorkspacePane : isResizingRight) ? "transition-[width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none" : ""}`}
|
} ${!isResizingRight ? "transition-[width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" : ""}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onPointerDown={workspacePaneOpen ? startResizingWorkspacePane : startResizingRight}
|
onPointerDown={startResizingRight}
|
||||||
className="absolute -left-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
|
className="absolute -left-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
|
||||||
/>
|
/>
|
||||||
{panel === "artifact" ? (
|
{panel === "browser" ? (
|
||||||
artifactPane ? (
|
|
||||||
<ArtifactPane
|
|
||||||
key={artifactPane.artifactId}
|
|
||||||
target={transcriptArtifactTarget}
|
|
||||||
artifact={artifactPane}
|
|
||||||
onClose={() => setPanel(null)}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
) : panel === "browser" ? (
|
|
||||||
<InAppBrowser onClose={() => setPanel(null)} />
|
<InAppBrowser onClose={() => setPanel(null)} />
|
||||||
) : panel && (active || activeGroup || panel === "create" || panel === "create-group") ? (
|
) : panel && (active || activeGroup || panel === "create" || panel === "create-group") ? (
|
||||||
<div className="rk-scroll h-full w-full overflow-y-auto px-5 lg:px-7 2xl:px-9 py-[17px]">
|
<div className="rk-scroll h-full w-full overflow-y-auto px-5 lg:px-7 2xl:px-9 py-[17px]">
|
||||||
@ -5793,7 +5634,6 @@ export function ShellPage() {
|
|||||||
const Transcript = memo(function Transcript({
|
const Transcript = memo(function Transcript({
|
||||||
scrollRef,
|
scrollRef,
|
||||||
artifactTarget,
|
artifactTarget,
|
||||||
onOpenArtifact,
|
|
||||||
messages,
|
messages,
|
||||||
olderCursor,
|
olderCursor,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
@ -5824,7 +5664,6 @@ const Transcript = memo(function Transcript({
|
|||||||
}: {
|
}: {
|
||||||
scrollRef: RefObject<HTMLDivElement | null>;
|
scrollRef: RefObject<HTMLDivElement | null>;
|
||||||
artifactTarget: ArtifactTarget;
|
artifactTarget: ArtifactTarget;
|
||||||
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
|
|
||||||
messages: ThreadMessage[];
|
messages: ThreadMessage[];
|
||||||
olderCursor: number | null;
|
olderCursor: number | null;
|
||||||
loadingOlder: boolean;
|
loadingOlder: boolean;
|
||||||
@ -5964,43 +5803,6 @@ const Transcript = memo(function Transcript({
|
|||||||
if (following.current) snapToEnd();
|
if (following.current) snapToEnd();
|
||||||
}, [messages, running, snapToEnd]);
|
}, [messages, running, snapToEnd]);
|
||||||
|
|
||||||
// A question the bot waits on can scroll out of view; offer a way back to it instead of the arrow.
|
|
||||||
const [askOffscreen, setAskOffscreen] = useState(false);
|
|
||||||
const findAsk = useCallback(
|
|
||||||
() =>
|
|
||||||
answerableAskMessageId
|
|
||||||
? (scrollRef.current?.querySelector<HTMLElement>(
|
|
||||||
`[data-message-id="${CSS.escape(answerableAskMessageId)}"]`,
|
|
||||||
) ?? null)
|
|
||||||
: null,
|
|
||||||
[answerableAskMessageId, scrollRef],
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
const root = scrollRef.current;
|
|
||||||
const element = findAsk();
|
|
||||||
if (!root || !element || typeof IntersectionObserver === "undefined") {
|
|
||||||
setAskOffscreen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => setAskOffscreen(!entries.some((entry) => entry.isIntersecting)),
|
|
||||||
{ root, threshold: 0.2 },
|
|
||||||
);
|
|
||||||
observer.observe(element);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [findAsk, messages, scrollRef]);
|
|
||||||
const jumpToAsk = useCallback(() => {
|
|
||||||
const element = findAsk();
|
|
||||||
if (!element) return;
|
|
||||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
||||||
following.current = false;
|
|
||||||
element.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "center" });
|
|
||||||
element
|
|
||||||
.querySelector<HTMLElement>("[data-ask-card] input, [data-ask-card] button")
|
|
||||||
?.focus({ preventScroll: true });
|
|
||||||
}, [findAsk]);
|
|
||||||
const showJumpToLatest = !atEnd && !askOffscreen;
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const button = jumpButtonRef.current;
|
const button = jumpButtonRef.current;
|
||||||
if (atEnd && button && document.activeElement === button) {
|
if (atEnd && button && document.activeElement === button) {
|
||||||
@ -6243,7 +6045,6 @@ const Transcript = memo(function Transcript({
|
|||||||
)}
|
)}
|
||||||
<MessageView
|
<MessageView
|
||||||
artifactTarget={artifactTarget}
|
artifactTarget={artifactTarget}
|
||||||
onOpenArtifact={onOpenArtifact}
|
|
||||||
message={message}
|
message={message}
|
||||||
canAnswer={message.id === answerableAskMessageId}
|
canAnswer={message.id === answerableAskMessageId}
|
||||||
onOpenBot={onOpenBot}
|
onOpenBot={onOpenBot}
|
||||||
@ -6326,28 +6127,15 @@ const Transcript = memo(function Transcript({
|
|||||||
ref={jumpButtonRef}
|
ref={jumpButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t`Jump to latest`}
|
aria-label={t`Jump to latest`}
|
||||||
aria-hidden={!showJumpToLatest}
|
aria-hidden={atEnd}
|
||||||
tabIndex={showJumpToLatest ? 0 : -1}
|
tabIndex={atEnd ? -1 : 0}
|
||||||
onClick={jumpToLatest}
|
onClick={jumpToLatest}
|
||||||
className={`absolute bottom-4 left-1/2 z-20 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-border bg-muted/95 text-foreground/75 shadow-md backdrop-blur transition-[opacity,transform,background-color] duration-200 ease-[cubic-bezier(.22,1,.36,1)] hover:bg-border motion-reduce:transition-none ${
|
className={`absolute bottom-4 left-1/2 z-20 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-border bg-muted/95 text-foreground/75 shadow-md backdrop-blur transition-[opacity,transform,background-color] duration-200 ease-[cubic-bezier(.22,1,.36,1)] hover:bg-border motion-reduce:transition-none ${
|
||||||
showJumpToLatest
|
atEnd ? "pointer-events-none translate-y-2 opacity-0" : "translate-y-0 opacity-100"
|
||||||
? "translate-y-0 opacity-100"
|
|
||||||
: "pointer-events-none translate-y-2 opacity-0"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ArrowDown size={17} strokeWidth={1.8} />
|
<ArrowDown size={17} strokeWidth={1.8} />
|
||||||
</button>
|
</button>
|
||||||
{askOffscreen ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="needs-you-jump"
|
|
||||||
onClick={jumpToAsk}
|
|
||||||
className="absolute bottom-4 left-1/2 z-20 inline-flex h-9 -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-muted/95 px-3.5 text-ui-body-sm font-medium text-foreground shadow-md backdrop-blur hover:bg-border"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className="size-2 rounded-full bg-warning" />
|
|
||||||
{t`Needs you`}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -7476,7 +7264,6 @@ function ComputerReleaseActions({
|
|||||||
|
|
||||||
const MessageView = memo(function MessageView({
|
const MessageView = memo(function MessageView({
|
||||||
artifactTarget,
|
artifactTarget,
|
||||||
onOpenArtifact,
|
|
||||||
canAnswer,
|
canAnswer,
|
||||||
message,
|
message,
|
||||||
onAnswer,
|
onAnswer,
|
||||||
@ -7498,7 +7285,6 @@ const MessageView = memo(function MessageView({
|
|||||||
encounterMode = false,
|
encounterMode = false,
|
||||||
}: {
|
}: {
|
||||||
artifactTarget: ArtifactTarget;
|
artifactTarget: ArtifactTarget;
|
||||||
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
|
|
||||||
canAnswer: boolean;
|
canAnswer: boolean;
|
||||||
message: ThreadMessage;
|
message: ThreadMessage;
|
||||||
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
|
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
|
||||||
@ -7900,17 +7686,6 @@ const MessageView = memo(function MessageView({
|
|||||||
name={block.name}
|
name={block.name}
|
||||||
mimeType={block.mimeType}
|
mimeType={block.mimeType}
|
||||||
size={block.size}
|
size={block.size}
|
||||||
onOpenBeside={
|
|
||||||
onOpenArtifact
|
|
||||||
? () =>
|
|
||||||
onOpenArtifact({
|
|
||||||
messageId: message.id,
|
|
||||||
artifactId: block.artifactId,
|
|
||||||
name: block.name,
|
|
||||||
mimeType: block.mimeType,
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -21,7 +21,7 @@ const workflow = parse(workflowText) as {
|
|||||||
describe("ci workflow merge gate", () => {
|
describe("ci workflow merge gate", () => {
|
||||||
it("aggregates only product jobs in Product CI", () => {
|
it("aggregates only product jobs in Product CI", () => {
|
||||||
const productGate = workflow.jobs["product-gate"];
|
const productGate = workflow.jobs["product-gate"];
|
||||||
expect(productGate?.needs).toEqual([
|
expect(productGate.needs).toEqual([
|
||||||
"lint",
|
"lint",
|
||||||
"check",
|
"check",
|
||||||
"build",
|
"build",
|
||||||
@ -29,12 +29,12 @@ describe("ci workflow merge gate", () => {
|
|||||||
"test-integration",
|
"test-integration",
|
||||||
"test-e2e",
|
"test-e2e",
|
||||||
]);
|
]);
|
||||||
expect(productGate?.if).toBe("always()");
|
expect(productGate.if).toBe("always()");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("soft-fails mobile OTA so missing Expo credentials do not fail the workflow", () => {
|
it("soft-fails mobile OTA so missing Expo credentials do not fail the workflow", () => {
|
||||||
const mobile = workflow.jobs["publish-mobile-update"];
|
const mobile = workflow.jobs["publish-mobile-update"];
|
||||||
expect(mobile?.["continue-on-error"]).toBe(true);
|
expect(mobile["continue-on-error"]).toBe(true);
|
||||||
expect(workflowText).toContain('echo "ready=false" >> "$GITHUB_OUTPUT"');
|
expect(workflowText).toContain('echo "ready=false" >> "$GITHUB_OUTPUT"');
|
||||||
expect(workflowText).not.toMatch(
|
expect(workflowText).not.toMatch(
|
||||||
/Check Expo automation token[\s\S]*?exit 1[\s\S]*?Require Expo/,
|
/Check Expo automation token[\s\S]*?exit 1[\s\S]*?Require Expo/,
|
||||||
@ -42,6 +42,8 @@ describe("ci workflow merge gate", () => {
|
|||||||
expect(workflowText).not.toContain(
|
expect(workflowText).not.toContain(
|
||||||
"Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project.",
|
"Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project.",
|
||||||
);
|
);
|
||||||
expect(workflowText).toContain("steps.expo_token.outputs.ready == 'true'");
|
expect(workflowText).toContain(
|
||||||
|
"steps.expo_token.outputs.ready == 'true'",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -43,8 +43,6 @@ describe("server image publish workflow", () => {
|
|||||||
expect(workflowText).toContain("actions/attest-build-provenance@");
|
expect(workflowText).toContain("actions/attest-build-provenance@");
|
||||||
expect(workflowText).not.toContain("github.repository_owner_type");
|
expect(workflowText).not.toContain("github.repository_owner_type");
|
||||||
expect(workflowText).toContain(`gh api "repos/\${GITHUB_REPOSITORY}"`);
|
expect(workflowText).toContain(`gh api "repos/\${GITHUB_REPOSITORY}"`);
|
||||||
// `gh api --jq` already prints raw strings; `--jq -r` takes `-r` as the filter and fails.
|
|
||||||
expect(workflowText).not.toContain("--jq -r");
|
|
||||||
expect(workflowText).toContain(".owner.type");
|
expect(workflowText).toContain(".owner.type");
|
||||||
expect(workflowText).toContain("if: steps.attestation.outputs.enabled == 'true'");
|
expect(workflowText).toContain("if: steps.attestation.outputs.enabled == 'true'");
|
||||||
expect(workflowText).toContain('[[ "$owner_type" == "User" && "$private" == "true" ]]');
|
expect(workflowText).toContain('[[ "$owner_type" == "User" && "$private" == "true" ]]');
|
||||||
|
|||||||
@ -144,17 +144,10 @@ export const builtinAgentTools: ConnectorTool[] = [
|
|||||||
{
|
{
|
||||||
name: "attach_file",
|
name: "attach_file",
|
||||||
description:
|
description:
|
||||||
"Attach a workspace file from this bot's home to the chat thread as an image or common file. The file stays in place; users can open it from the message. Set present when the file is the result the user asked for, so it opens beside the conversation.",
|
"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.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: { path: { type: "string" } },
|
||||||
path: { type: "string" },
|
|
||||||
present: {
|
|
||||||
type: "boolean",
|
|
||||||
description:
|
|
||||||
"True only for the finished result of the request, not for supporting files.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["path"],
|
required: ["path"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2449,7 +2449,6 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
filePath,
|
filePath,
|
||||||
bytes,
|
bytes,
|
||||||
operationId: executionId,
|
operationId: executionId,
|
||||||
present: args.present === true,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await publishMessage(deps, run, "bot", [attached.block]);
|
await publishMessage(deps, run, "bot", [attached.block]);
|
||||||
|
|||||||
@ -454,20 +454,6 @@ export function inferScript(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (lower.includes("present") && lower.includes("report")) {
|
|
||||||
const filePath =
|
|
||||||
/(?:called|named|path|file)\s+([A-Za-z0-9._/-]+)/i.exec(prompt)?.[1] ?? "report.md";
|
|
||||||
const content =
|
|
||||||
"# Report\n\n| Item | Status |\n| --- | --- |\n| Research | Done |\n| Draft | Done |\n";
|
|
||||||
return [
|
|
||||||
{ assistant: "writing the report and putting it next to our chat." },
|
|
||||||
{ toolCalls: [{ name: "write_file", args: { path: filePath, content } }] },
|
|
||||||
{
|
|
||||||
toolCalls: [{ name: "attach_file", args: { path: filePath, present: true } }],
|
|
||||||
complete: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (lower.includes("attach") && (lower.includes("thread") || lower.includes("into the thread"))) {
|
if (lower.includes("attach") && (lower.includes("thread") || lower.includes("into the thread"))) {
|
||||||
const said = /says?\s+(.+)$/i.exec(prompt)?.[1]?.replace(/[.]+$/, "") ?? prompt;
|
const said = /says?\s+(.+)$/i.exec(prompt)?.[1]?.replace(/[.]+$/, "") ?? prompt;
|
||||||
const content = `${said.trim()}\n`;
|
const content = `${said.trim()}\n`;
|
||||||
|
|||||||
@ -46,39 +46,6 @@ describe("current-turn thread files", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks a file the bot presents as its result", async () => {
|
|
||||||
const deps = {
|
|
||||||
prisma: {
|
|
||||||
artifact: {
|
|
||||||
create: vi.fn(
|
|
||||||
async ({ data }: { data: { name: string; mimeType: string; size: number } }) => ({
|
|
||||||
id: "artifact-1",
|
|
||||||
...data,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
} as unknown as PrismaClient,
|
|
||||||
artifacts: {
|
|
||||||
put: vi.fn().mockResolvedValue({ id: "stored-1", hash: "hash" }),
|
|
||||||
remove: vi.fn(),
|
|
||||||
} as unknown as ArtifactStore,
|
|
||||||
};
|
|
||||||
const input = {
|
|
||||||
spaceId: "workspace-1",
|
|
||||||
userId: "user-1",
|
|
||||||
botId: "bot-1",
|
|
||||||
runId: "run-1",
|
|
||||||
filePath: "out/report.md",
|
|
||||||
bytes: new TextEncoder().encode("# Report\n"),
|
|
||||||
operationId: "attach-1",
|
|
||||||
};
|
|
||||||
|
|
||||||
const presented = await attachWorkspaceFileToThread(deps, { ...input, present: true });
|
|
||||||
expect(presented.block).toMatchObject({ kind: "file", name: "report.md", present: true });
|
|
||||||
const attached = await attachWorkspaceFileToThread(deps, input);
|
|
||||||
expect(attached.block).not.toHaveProperty("present");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("copies non-image attachments into the bot workspace and describes their paths", async () => {
|
it("copies non-image attachments into the bot workspace and describes their paths", async () => {
|
||||||
const findMany = vi.fn().mockResolvedValue([
|
const findMany = vi.fn().mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
@ -38,7 +38,6 @@ export async function attachWorkspaceFileToThread(
|
|||||||
filePath: string;
|
filePath: string;
|
||||||
bytes: Uint8Array;
|
bytes: Uint8Array;
|
||||||
operationId: string;
|
operationId: string;
|
||||||
present?: boolean;
|
|
||||||
},
|
},
|
||||||
): Promise<{ artifactId: string; block: Extract<MessageBlock, { kind: "image" | "file" }> }> {
|
): Promise<{ artifactId: string; block: Extract<MessageBlock, { kind: "image" | "file" }> }> {
|
||||||
const name = path.basename(input.filePath) || input.filePath;
|
const name = path.basename(input.filePath) || input.filePath;
|
||||||
@ -80,13 +79,15 @@ export async function attachWorkspaceFileToThread(
|
|||||||
await deps.artifacts.remove(stored.id, context).catch(() => undefined);
|
await deps.artifacts.remove(stored.id, context).catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
const block = messageBlockForArtifact({
|
return {
|
||||||
|
artifactId: row.id,
|
||||||
|
block: messageBlockForArtifact({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
mimeType: row.mimeType,
|
mimeType: row.mimeType,
|
||||||
size: row.size,
|
size: row.size,
|
||||||
});
|
}),
|
||||||
return { artifactId: row.id, block: input.present ? { ...block, present: true } : block };
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function materializeCurrentTurnFiles(
|
export async function materializeCurrentTurnFiles(
|
||||||
|
|||||||
@ -240,8 +240,6 @@ export const MessageBlock = z.discriminatedUnion("kind", [
|
|||||||
artifactId: Id,
|
artifactId: Id,
|
||||||
mimeType: z.string(),
|
mimeType: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
/** The bot presented this as its result; clients may show it beside the conversation. */
|
|
||||||
present: z.boolean().optional(),
|
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.literal("file"),
|
kind: z.literal("file"),
|
||||||
@ -249,8 +247,6 @@ export const MessageBlock = z.discriminatedUnion("kind", [
|
|||||||
mimeType: z.string(),
|
mimeType: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
size: z.number().int().nonnegative(),
|
size: z.number().int().nonnegative(),
|
||||||
/** The bot presented this as its result; clients may show it beside the conversation. */
|
|
||||||
present: z.boolean().optional(),
|
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.literal("handoff"),
|
kind: z.literal("handoff"),
|
||||||
|
|||||||
@ -48,4 +48,3 @@ export * from "./tool-activity.js";
|
|||||||
export * from "./trading-bot-status.js";
|
export * from "./trading-bot-status.js";
|
||||||
export * from "./voice-chat-command.js";
|
export * from "./voice-chat-command.js";
|
||||||
export * from "./voice-labels.js";
|
export * from "./voice-labels.js";
|
||||||
export * from "./workspace-focus.js";
|
|
||||||
|
|||||||
@ -1,305 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,230 +0,0 @@
|
|||||||
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