Merge main into the legacy cleanup
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUTKejm92a8Z7bgSunJChV
This commit is contained in:
commit
3edce64bad
4
.github/workflows/publish-server-image.yml
vendored
4
.github/workflows/publish-server-image.yml
vendored
@ -263,8 +263,8 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.owner.type')"
|
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.owner.type')"
|
||||||
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.private')"
|
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.private')"
|
||||||
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
|
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
|
||||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "Skipping registry attestation: unavailable for private repositories on personal accounts."
|
echo "Skipping registry attestation: unavailable for private repositories on personal accounts."
|
||||||
|
|||||||
@ -17,10 +17,12 @@ import {
|
|||||||
buildComposerMentionOptions,
|
buildComposerMentionOptions,
|
||||||
type ComposerMention,
|
type ComposerMention,
|
||||||
cloudAgentHttpsUrl,
|
cloudAgentHttpsUrl,
|
||||||
|
deriveWorkspaceFocus,
|
||||||
isApprovalAskBlock,
|
isApprovalAskBlock,
|
||||||
isRunTerminalEvent,
|
isRunTerminalEvent,
|
||||||
isSecretAskBlock,
|
isSecretAskBlock,
|
||||||
latestAnswerableAskMessageId,
|
latestAnswerableAskMessageId,
|
||||||
|
liveScreenRunId,
|
||||||
mentionChipKey,
|
mentionChipKey,
|
||||||
projectMessageReactions,
|
projectMessageReactions,
|
||||||
resolveComposerSendPlan,
|
resolveComposerSendPlan,
|
||||||
@ -288,6 +290,8 @@ function Thread() {
|
|||||||
? { botId }
|
? { botId }
|
||||||
: undefined;
|
: undefined;
|
||||||
const [snap, setSnap] = useState<MobileSnapshot | null>(null);
|
const [snap, setSnap] = useState<MobileSnapshot | null>(null);
|
||||||
|
/** Run that last pointed at the bot's screen, so the live screen is offered only during real screen work. */
|
||||||
|
const [screenRunId, setScreenRunId] = useState<string | null>(null);
|
||||||
const activeThreadId = useRef<string | undefined>(undefined);
|
const activeThreadId = useRef<string | undefined>(undefined);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||||
@ -860,6 +864,7 @@ function Thread() {
|
|||||||
}
|
}
|
||||||
expandedHistoryThread.current = null;
|
expandedHistoryThread.current = null;
|
||||||
historyEpoch.current += 1;
|
historyEpoch.current += 1;
|
||||||
|
setScreenRunId(null);
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
void (async () => {
|
void (async () => {
|
||||||
// Pending search jumps load the around-page separately; avoid replacing it with latest.
|
// Pending search jumps load the around-page separately; avoid replacing it with latest.
|
||||||
@ -885,6 +890,8 @@ function Thread() {
|
|||||||
(event) => {
|
(event) => {
|
||||||
cursor = Math.max(cursor, event.seq ?? -1);
|
cursor = Math.max(cursor, event.seq ?? -1);
|
||||||
retryMs = 250;
|
retryMs = 250;
|
||||||
|
const screenRun = groupId ? null : liveScreenRunId(event);
|
||||||
|
if (screenRun) setScreenRunId(screenRun);
|
||||||
if (
|
if (
|
||||||
event.type === "thread.progress" ||
|
event.type === "thread.progress" ||
|
||||||
event.type === "agent.tool.called" ||
|
event.type === "agent.tool.called" ||
|
||||||
@ -1271,6 +1278,23 @@ function Thread() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const answerableAskMessageId = latestAnswerableAskMessageId(snap);
|
const answerableAskMessageId = latestAnswerableAskMessageId(snap);
|
||||||
|
const screenFocus = useMemo(() => {
|
||||||
|
const computer = snap?.computer;
|
||||||
|
if (!snap || !computer || groupId) return null;
|
||||||
|
const focus = deriveWorkspaceFocus(
|
||||||
|
{
|
||||||
|
...snap,
|
||||||
|
computer: {
|
||||||
|
kind: computer.kind ?? "",
|
||||||
|
state: computer.state,
|
||||||
|
screenAvailable: computer.screenAvailable,
|
||||||
|
takeoverRequested: computer.takeoverRequested ?? false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ screenRunId },
|
||||||
|
);
|
||||||
|
return focus.pane === "screen" ? focus : null;
|
||||||
|
}, [groupId, screenRunId, snap]);
|
||||||
const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null;
|
const runError = snap?.run?.status === "failed" ? (snap.run.error ?? null) : null;
|
||||||
const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]);
|
const liveMessages = useMemo(() => [...visibleMessages].reverse(), [visibleMessages]);
|
||||||
const messagesById = useMemo(
|
const messagesById = useMemo(
|
||||||
@ -1668,6 +1692,43 @@ function Thread() {
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}>
|
<View style={{ paddingBottom: keyboardVisible ? 12 : Math.max(insets.bottom + 12, 24) }}>
|
||||||
|
{screenFocus && botId ? (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
testID="live-screen-pill"
|
||||||
|
onPress={() =>
|
||||||
|
router.push({
|
||||||
|
pathname: "/computer",
|
||||||
|
params: { botId, name: displayName ?? t("Bot") },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
alignSelf: "center",
|
||||||
|
marginTop: 10,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
borderRadius: 999,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: native.fillPressed,
|
||||||
|
backgroundColor: pressed ? native.fillPressed : native.fill,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: screenFocus.takeover ? tokens.warning : tokens.success,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: tokens.foreground, fontSize: 14, fontWeight: "500" }}>
|
||||||
|
{screenFocus.takeover ? t("Needs takeover") : t("Open computer")}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
{replyTarget ? (
|
{replyTarget ? (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -790,9 +790,11 @@ export type MobileSnapshot = {
|
|||||||
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
|
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
|
||||||
members?: MobileGroup["members"];
|
members?: MobileGroup["members"];
|
||||||
computer?: {
|
computer?: {
|
||||||
|
kind?: string;
|
||||||
state: string;
|
state: string;
|
||||||
controlHolder: string;
|
controlHolder: string;
|
||||||
screenAvailable: boolean;
|
screenAvailable: boolean;
|
||||||
|
takeoverRequested?: boolean;
|
||||||
mode: ComputerMode;
|
mode: ComputerMode;
|
||||||
busyBotName: string | null;
|
busyBotName: string | null;
|
||||||
};
|
};
|
||||||
@ -896,6 +898,7 @@ type ThreadEvent = {
|
|||||||
type: string;
|
type: string;
|
||||||
seq?: number;
|
seq?: number;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
|
createdAt?: string;
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
104
apps/web/e2e/adaptive-workspace.spec.ts
Normal file
104
apps/web/e2e/adaptive-workspace.spec.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
|
||||||
|
|
||||||
|
const SCREEN_URL = "https://screen.example/embed.html";
|
||||||
|
|
||||||
|
test("the bot's screen opens beside the chat while it works and steps back after", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await signup(page, `workspace-screen-${Date.now()}@rakazo.test`, "password12", "Screen Pane");
|
||||||
|
await completeOnboarding(page);
|
||||||
|
const botId = activeBotId(page);
|
||||||
|
await rpc(page, "computer/boot", { botId });
|
||||||
|
await page.route("https://screen.example/**", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "text/html",
|
||||||
|
body: `<!doctype html><title>Desktop</title><body style="margin:0;background:#1d1d20;display:grid;place-items:center;height:100vh">
|
||||||
|
<input style="width:40%;padding:8px;font:16px system-ui" placeholder="Search">`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route("**/rpc/computer/screenUrl", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ json: { url: SCREEN_URL } }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidePanel = page.getByTestId("side-panel");
|
||||||
|
await expect(sidePanel).toHaveAttribute("data-panel", "closed");
|
||||||
|
|
||||||
|
await rpc(page, "threads/send", { botId, text: "use your screen and type workspace-demo" });
|
||||||
|
|
||||||
|
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
|
||||||
|
await expect(sidePanel).toHaveAttribute("data-auto", "");
|
||||||
|
await expect(page.getByTestId("computer-preview").locator("iframe")).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
SCREEN_URL,
|
||||||
|
);
|
||||||
|
// The live screen gets more room than a settings panel.
|
||||||
|
expect((await sidePanel.boundingBox())!.width).toBeGreaterThan(400);
|
||||||
|
await captureScreenshot(page, testInfo, "workspace-screen-beside-chat");
|
||||||
|
|
||||||
|
// Once the work is done the conversation gets its full width back.
|
||||||
|
await expect(sidePanel).toHaveAttribute("data-panel", "closed", { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a report the bot presents opens beside the conversation", async ({ page }, testInfo) => {
|
||||||
|
await signup(page, `workspace-report-${Date.now()}@rakazo.test`, "password12", "Report Pane");
|
||||||
|
await completeOnboarding(page);
|
||||||
|
|
||||||
|
const composer = page.getByPlaceholder(/Message/);
|
||||||
|
await composer.fill("present the report");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
const pane = page.getByTestId("artifact-pane");
|
||||||
|
await expect(pane).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(pane.getByRole("heading", { name: "report.md" })).toBeVisible();
|
||||||
|
await expect(pane.getByRole("heading", { name: "Report", exact: true })).toBeVisible();
|
||||||
|
await expect(pane.getByRole("cell", { name: "Research" })).toBeVisible();
|
||||||
|
await expect(composer).toBeVisible();
|
||||||
|
await captureScreenshot(page, testInfo, "workspace-report-beside-chat");
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent("download");
|
||||||
|
await pane.getByRole("button", { name: "Download report.md" }).click();
|
||||||
|
expect((await downloadPromise).suggestedFilename()).toBe("report.md");
|
||||||
|
|
||||||
|
await pane.getByRole("button", { name: "Close panel" }).click();
|
||||||
|
await expect(pane).toHaveCount(0);
|
||||||
|
|
||||||
|
// The file card in the chat opens the same pane again.
|
||||||
|
await page.getByRole("button", { name: "Preview report.md" }).click();
|
||||||
|
await expect(pane.getByRole("heading", { name: "Report", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a question that scrolled away is one click back", async ({ page }, testInfo) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 520 });
|
||||||
|
await signup(page, `workspace-ask-${Date.now()}@rakazo.test`, "password12", "Ask Jump");
|
||||||
|
await completeOnboarding(page);
|
||||||
|
|
||||||
|
const composer = page.getByPlaceholder(/Message/);
|
||||||
|
// Earlier work gives the thread enough history to scroll back through.
|
||||||
|
for (const task of ["sort the inbox", "draft the agenda", "book the room"]) {
|
||||||
|
await composer.fill(task);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.getByText(`done. i handled: ${task}`, { exact: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await composer.fill("ask me which city to use");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
const sendIt = page.getByRole("button", { name: "Send it" });
|
||||||
|
await expect(sendIt).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const jump = page.getByTestId("needs-you-jump");
|
||||||
|
await expect(jump).toHaveCount(0);
|
||||||
|
await page.getByTestId("transcript").hover();
|
||||||
|
await page.mouse.wheel(0, -20_000);
|
||||||
|
await expect(jump).toBeVisible();
|
||||||
|
await captureScreenshot(page, testInfo, "workspace-needs-you-jump");
|
||||||
|
|
||||||
|
await jump.click();
|
||||||
|
await expect(sendIt).toBeInViewport();
|
||||||
|
await expect(sendIt).toBeFocused();
|
||||||
|
await expect(jump).toHaveCount(0);
|
||||||
|
});
|
||||||
@ -27,6 +27,8 @@ test("agent-attached files appear as downloadable cards", async ({ page }, testI
|
|||||||
test("agent-attached Markdown opens a rendered preview and can be downloaded", async ({
|
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,9 +69,10 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
|
|||||||
})
|
})
|
||||||
.toBe("waiting_takeover");
|
.toBe("waiting_takeover");
|
||||||
await captureScreenshot(page, testInfo, "08-protected-input-request");
|
await captureScreenshot(page, testInfo, "08-protected-input-request");
|
||||||
await page.getByTitle("Agent computer").click();
|
// A takeover request brings the bot's screen beside the chat on its own.
|
||||||
const sidePanel = page.getByTestId("side-panel");
|
const sidePanel = page.getByTestId("side-panel");
|
||||||
await expect(sidePanel).toHaveCSS("width", "384px");
|
await expect(sidePanel).toHaveAttribute("data-panel", "computer");
|
||||||
|
await expect(sidePanel).toHaveAttribute("data-auto", "");
|
||||||
const [mainBox, panelBox] = await Promise.all([
|
const [mainBox, panelBox] = await Promise.all([
|
||||||
page.locator("main").boundingBox(),
|
page.locator("main").boundingBox(),
|
||||||
sidePanel.boundingBox(),
|
sidePanel.boundingBox(),
|
||||||
|
|||||||
@ -303,9 +303,11 @@ test("create group from + and see two bots in one transcript", async ({ page },
|
|||||||
const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" });
|
const groupMarkdown = page.getByRole("button", { name: "Preview group-preview.md" });
|
||||||
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
|
await expect(groupMarkdown).toBeVisible({ timeout: 30_000 });
|
||||||
await groupMarkdown.click();
|
await groupMarkdown.click();
|
||||||
const markdownDialog = page.getByRole("dialog", { name: "group-preview.md" });
|
// Wide windows open the file beside the group conversation.
|
||||||
await expect(markdownDialog.getByRole("heading", { name: "Group artifact" })).toBeVisible();
|
const markdownPane = page.getByTestId("artifact-pane");
|
||||||
await markdownDialog.getByRole("button", { name: "Close preview" }).click();
|
await expect(markdownPane.getByRole("heading", { name: "Group artifact" })).toBeVisible();
|
||||||
|
await markdownPane.getByRole("button", { name: "Close panel" }).click();
|
||||||
|
await expect(markdownPane).toHaveCount(0);
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Open navigation" })).toBeVisible();
|
||||||
|
|||||||
@ -4,13 +4,9 @@ import { Trans, useLingui } from "@lingui/react/macro";
|
|||||||
import { ChatMarkdown } from "@rakazo/chat-ui/web";
|
import { ChatMarkdown } from "@rakazo/chat-ui/web";
|
||||||
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
|
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
|
||||||
import { Download, FileText, X } from "lucide-react";
|
import { Download, FileText, X } from "lucide-react";
|
||||||
import { type RefObject, useEffect, useRef, useState } from "react";
|
import { type RefObject, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import { useArtifactBytes } from "../hooks/useArtifactBytes";
|
||||||
type ArtifactTarget,
|
import { type ArtifactTarget, downloadArtifact, downloadArtifactBytes } from "../lib/artifact-open";
|
||||||
downloadArtifact,
|
|
||||||
downloadArtifactBytes,
|
|
||||||
fetchArtifactBytes,
|
|
||||||
} from "../lib/artifact-open";
|
|
||||||
|
|
||||||
type ArtifactFileCardProps = {
|
type ArtifactFileCardProps = {
|
||||||
target: ArtifactTarget;
|
target: ArtifactTarget;
|
||||||
@ -18,6 +14,8 @@ type ArtifactFileCardProps = {
|
|||||||
name: string;
|
name: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
size: number;
|
size: number;
|
||||||
|
/** Shows the preview beside the conversation instead of in a dialog. */
|
||||||
|
onOpenBeside?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ArtifactFileCard(props: ArtifactFileCardProps) {
|
export function ArtifactFileCard(props: ArtifactFileCardProps) {
|
||||||
@ -63,7 +61,7 @@ export function ArtifactFileCard(props: ArtifactFileCardProps) {
|
|||||||
ref={previewButton}
|
ref={previewButton}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t`Preview ${props.name}`}
|
aria-label={t`Preview ${props.name}`}
|
||||||
onClick={() => setPreviewOpen(true)}
|
onClick={() => (props.onOpenBeside ? props.onOpenBeside() : setPreviewOpen(true))}
|
||||||
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-left hover:bg-accent"
|
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-3 text-left hover:bg-accent"
|
||||||
>
|
>
|
||||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[10px] bg-muted text-foreground">
|
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[10px] bg-muted text-foreground">
|
||||||
@ -111,39 +109,16 @@ function MarkdownPreview({
|
|||||||
}: ArtifactFileCardProps & { closeButtonRef: RefObject<HTMLButtonElement | null> }) {
|
}: ArtifactFileCardProps & { closeButtonRef: RefObject<HTMLButtonElement | null> }) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||||
const [state, setState] = useState<
|
const loaded = useArtifactBytes(target, artifactId);
|
||||||
| { status: "loading" }
|
const state = useMemo(() => {
|
||||||
| { status: "ready"; bytes: Uint8Array; markdown: string }
|
if (loaded.status !== "ready") return loaded;
|
||||||
| { status: "error"; message: string }
|
|
||||||
>({ 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(bytes);
|
const markdown = new TextDecoder("utf-8", { fatal: true }).decode(loaded.bytes);
|
||||||
setState({ status: "ready", bytes, markdown });
|
return { status: "ready" as const, bytes: loaded.bytes, markdown };
|
||||||
} catch {
|
} catch {
|
||||||
setState({ status: "error", message: t`This file is not valid UTF-8 Markdown.` });
|
return { status: "error" as const, 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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
165
apps/web/src/components/ArtifactPane.tsx
Normal file
165
apps/web/src/components/ArtifactPane.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import { ChatMarkdown } from "@rakazo/chat-ui/web";
|
||||||
|
import { isAttachmentImageMimeType } from "@rakazo/contracts";
|
||||||
|
import type { WorkspaceArtifact } from "@rakazo/core";
|
||||||
|
import { Button } from "@rakazo/ui-web";
|
||||||
|
import { Download, X } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useArtifactBytes } from "../hooks/useArtifactBytes";
|
||||||
|
import { type ArtifactTarget, downloadArtifactBytes } from "../lib/artifact-open";
|
||||||
|
import { parseCsvPreview } from "../lib/csv-preview";
|
||||||
|
|
||||||
|
/** A file the bot made, open beside the conversation so the user can read it while they talk. */
|
||||||
|
export function ArtifactPane({
|
||||||
|
target,
|
||||||
|
artifact,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
target: ArtifactTarget;
|
||||||
|
artifact: WorkspaceArtifact;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const loaded = useArtifactBytes(target, artifact.artifactId);
|
||||||
|
const { name, mimeType } = artifact;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
data-testid="artifact-pane"
|
||||||
|
aria-label={name}
|
||||||
|
className="flex h-full min-h-0 w-full flex-col"
|
||||||
|
>
|
||||||
|
<header className="flex h-14 shrink-0 items-center gap-1 border-b border-border px-5">
|
||||||
|
<h2 className="min-w-0 flex-1 truncate text-ui-body font-medium text-foreground" dir="auto">
|
||||||
|
{name}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-label={t`Download ${name}`}
|
||||||
|
title={t`Download ${name}`}
|
||||||
|
disabled={loaded.status !== "ready"}
|
||||||
|
onClick={() => {
|
||||||
|
if (loaded.status === "ready") downloadArtifactBytes(name, mimeType, loaded.bytes);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download strokeWidth={1.8} className="size-icon-md" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-label={t`Close panel`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X strokeWidth={1.8} className="size-icon-md" />
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div className="rk-scroll min-h-0 flex-1 overflow-auto">
|
||||||
|
{loaded.status === "loading" ? (
|
||||||
|
<div className="px-6 py-8 text-ui-body text-muted-foreground">
|
||||||
|
<Trans>Loading preview…</Trans>
|
||||||
|
</div>
|
||||||
|
) : loaded.status === "error" ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="m-6 rounded-xl border border-destructive/40 bg-destructive/10 px-4 py-3 text-ui-body text-destructive"
|
||||||
|
>
|
||||||
|
{loaded.message}
|
||||||
|
</div>
|
||||||
|
) : isAttachmentImageMimeType(mimeType) ? (
|
||||||
|
<PaneImage bytes={loaded.bytes} mimeType={mimeType} name={name} />
|
||||||
|
) : (
|
||||||
|
<PaneText bytes={loaded.bytes} mimeType={mimeType} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaneImage({
|
||||||
|
bytes,
|
||||||
|
mimeType,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
mimeType: string;
|
||||||
|
name: string;
|
||||||
|
}) {
|
||||||
|
const [src, setSrc] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: mimeType }));
|
||||||
|
setSrc(url);
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}, [bytes, mimeType]);
|
||||||
|
return src ? (
|
||||||
|
<img src={src} alt={name} className="mx-auto block max-w-full p-6" draggable={false} />
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaneText({ bytes, mimeType }: { bytes: Uint8Array; mimeType: string }) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const text = useMemo(() => new TextDecoder("utf-8").decode(bytes), [bytes]);
|
||||||
|
if (mimeType === "text/markdown") {
|
||||||
|
return (
|
||||||
|
<article className="mx-auto w-full max-w-[760px] px-6 py-8 text-ui-body leading-7 text-foreground lg:px-10">
|
||||||
|
<ChatMarkdown>{text}</ChatMarkdown>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mimeType === "text/csv") {
|
||||||
|
const { rows, truncated } = parseCsvPreview(text);
|
||||||
|
const [head, ...body] = rows;
|
||||||
|
const shownRows = body.length;
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<table className="w-full border-collapse text-ui-body-sm text-foreground">
|
||||||
|
{head ? (
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{head.map((cell, index) => (
|
||||||
|
<th
|
||||||
|
key={index}
|
||||||
|
className="sticky top-0 border-b border-border bg-background px-3 py-2 text-start font-medium"
|
||||||
|
>
|
||||||
|
{cell}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
) : null}
|
||||||
|
<tbody>
|
||||||
|
{body.map((row, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className="border-b border-border/60">
|
||||||
|
{row.map((cell, index) => (
|
||||||
|
<td key={index} className="px-3 py-1.5 align-top whitespace-pre-wrap">
|
||||||
|
{cell}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{truncated ? (
|
||||||
|
<p className="px-3 pt-3 text-ui-body-sm text-muted-foreground">
|
||||||
|
{t`First ${shownRows} rows`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<pre className="whitespace-pre-wrap break-words px-6 py-6 font-mono text-ui-body-sm text-foreground">
|
||||||
|
{mimeType === "application/json" ? prettyJson(text) : text}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyJson(text: string): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(text), null, 2);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -87,6 +87,7 @@ export function AskCard({
|
|||||||
|
|
||||||
return (
|
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)}
|
||||||
>
|
>
|
||||||
|
|||||||
38
apps/web/src/hooks/useArtifactBytes.ts
Normal file
38
apps/web/src/hooks/useArtifactBytes.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { type ArtifactTarget, fetchArtifactBytes } from "../lib/artifact-open";
|
||||||
|
|
||||||
|
export type ArtifactBytesState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "ready"; bytes: Uint8Array }
|
||||||
|
| { status: "error"; message: string };
|
||||||
|
|
||||||
|
export function useArtifactBytes(target: ArtifactTarget, artifactId: string): ArtifactBytesState {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [state, setState] = useState<ArtifactBytesState>({ status: "loading" });
|
||||||
|
const targetBotId = "botId" in target ? target.botId : undefined;
|
||||||
|
const targetGroupId = "groupId" in target ? target.groupId : undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setState({ status: "loading" });
|
||||||
|
const artifactTarget: ArtifactTarget =
|
||||||
|
targetBotId !== undefined ? { botId: targetBotId } : { groupId: targetGroupId! };
|
||||||
|
void fetchArtifactBytes(artifactTarget, artifactId)
|
||||||
|
.then((bytes) => {
|
||||||
|
if (!cancelled) setState({ status: "ready", bytes });
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setState({
|
||||||
|
status: "error",
|
||||||
|
message: error instanceof Error ? error.message : t`Could not load this file.`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [artifactId, targetBotId, targetGroupId, t]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@ -1,13 +1,20 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { readPaneWidth, writePaneWidth } from "../lib/workspace-pane-pref";
|
||||||
|
|
||||||
export function useSidebarResize(
|
export function useSidebarResize(
|
||||||
initialWidth: number,
|
initialWidth: number,
|
||||||
side: "left" | "right",
|
side: "left" | "right",
|
||||||
minWidth: number,
|
minWidth: number,
|
||||||
maxWidth: number,
|
maxWidth: number,
|
||||||
|
/** Remembers the width across reloads on this device. */
|
||||||
|
storageKey?: string,
|
||||||
) {
|
) {
|
||||||
const [width, setWidth] = useState(initialWidth);
|
const [width, setWidth] = useState(() =>
|
||||||
|
storageKey ? readPaneWidth(storageKey, initialWidth, minWidth, maxWidth) : initialWidth,
|
||||||
|
);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const widthRef = useRef(width);
|
||||||
|
widthRef.current = width;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isResizing) return;
|
if (!isResizing) return;
|
||||||
@ -26,6 +33,7 @@ export function useSidebarResize(
|
|||||||
const handlePointerUp = () => {
|
const handlePointerUp = () => {
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
document.body.style.cursor = "default";
|
document.body.style.cursor = "default";
|
||||||
|
if (storageKey) writePaneWidth(storageKey, widthRef.current);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("pointermove", handlePointerMove);
|
window.addEventListener("pointermove", handlePointerMove);
|
||||||
@ -38,7 +46,7 @@ export function useSidebarResize(
|
|||||||
window.removeEventListener("pointerup", handlePointerUp);
|
window.removeEventListener("pointerup", handlePointerUp);
|
||||||
document.body.style.cursor = "default";
|
document.body.style.cursor = "default";
|
||||||
};
|
};
|
||||||
}, [isResizing, side, minWidth, maxWidth]);
|
}, [isResizing, side, minWidth, maxWidth, storageKey]);
|
||||||
|
|
||||||
const startResizing = useCallback((e: React.PointerEvent) => {
|
const startResizing = useCallback((e: React.PointerEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
16
apps/web/src/hooks/useWideLayout.ts
Normal file
16
apps/web/src/hooks/useWideLayout.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Wide enough to keep the conversation readable beside a working pane. */
|
||||||
|
const WIDE_LAYOUT_QUERY = "(min-width: 1024px)";
|
||||||
|
|
||||||
|
export function useWideLayout(): boolean {
|
||||||
|
const [wide, setWide] = useState(() => window.matchMedia(WIDE_LAYOUT_QUERY).matches);
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia(WIDE_LAYOUT_QUERY);
|
||||||
|
const sync = () => setWide(media.matches);
|
||||||
|
sync();
|
||||||
|
media.addEventListener("change", sync);
|
||||||
|
return () => media.removeEventListener("change", sync);
|
||||||
|
}, []);
|
||||||
|
return wide;
|
||||||
|
}
|
||||||
@ -3,12 +3,12 @@ import {
|
|||||||
ComputerPointerPayload,
|
ComputerPointerPayload,
|
||||||
type ComputerPointerStep,
|
type ComputerPointerStep,
|
||||||
} from "@rakazo/contracts";
|
} from "@rakazo/contracts";
|
||||||
|
import { POINTER_EVENT_MAX_AGE_MS } from "@rakazo/core";
|
||||||
|
|
||||||
export type PointerPoint = { x: number; y: number };
|
export type PointerPoint = { x: number; y: number };
|
||||||
export type PointerScreen = ComputerPointerPayload["screen"];
|
export type PointerScreen = ComputerPointerPayload["screen"];
|
||||||
|
|
||||||
/** Older announcements describe actions that already finished; replaying them would mislead. */
|
export { POINTER_EVENT_MAX_AGE_MS };
|
||||||
export const POINTER_EVENT_MAX_AGE_MS = 30_000;
|
|
||||||
export const POINTER_IDLE_MS = 3_500;
|
export const POINTER_IDLE_MS = 3_500;
|
||||||
|
|
||||||
type Listener = (payload: ComputerPointerPayload) => void;
|
type Listener = (payload: ComputerPointerPayload) => void;
|
||||||
|
|||||||
31
apps/web/src/lib/csv-preview.test.ts
Normal file
31
apps/web/src/lib/csv-preview.test.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseCsvPreview } from "./csv-preview";
|
||||||
|
|
||||||
|
describe("parseCsvPreview", () => {
|
||||||
|
it("reads quoted fields, doubled quotes and CRLF rows", () => {
|
||||||
|
expect(parseCsvPreview('name,note\r\n"Smith, Ann","said ""hi"""\r\nBo,\n')).toEqual({
|
||||||
|
rows: [
|
||||||
|
["name", "note"],
|
||||||
|
["Smith, Ann", 'said "hi"'],
|
||||||
|
["Bo", ""],
|
||||||
|
],
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a final row without a trailing newline and skips blank lines", () => {
|
||||||
|
expect(parseCsvPreview("a,b\n\n1,2").rows).toEqual([
|
||||||
|
["a", "b"],
|
||||||
|
["1", "2"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps newlines inside quoted fields", () => {
|
||||||
|
expect(parseCsvPreview('a\n"line 1\nline 2"').rows).toEqual([["a"], ["line 1\nline 2"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops after the row limit", () => {
|
||||||
|
const result = parseCsvPreview("1\n2\n3\n4\n", 2);
|
||||||
|
expect(result).toEqual({ rows: [["1"], ["2"]], truncated: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
43
apps/web/src/lib/csv-preview.ts
Normal file
43
apps/web/src/lib/csv-preview.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/** Parses enough of RFC 4180 to show a CSV as a table: quoted fields, doubled quotes, CRLF. */
|
||||||
|
export function parseCsvPreview(
|
||||||
|
text: string,
|
||||||
|
maxRows = 500,
|
||||||
|
): { rows: string[][]; truncated: boolean } {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let field = "";
|
||||||
|
let quoted = false;
|
||||||
|
let index = 0;
|
||||||
|
const endRow = () => {
|
||||||
|
row.push(field);
|
||||||
|
field = "";
|
||||||
|
if (row.length > 1 || row[0] !== "") rows.push(row);
|
||||||
|
row = [];
|
||||||
|
};
|
||||||
|
while (index < text.length) {
|
||||||
|
const char = text[index];
|
||||||
|
if (quoted) {
|
||||||
|
if (char === '"' && text[index + 1] === '"') {
|
||||||
|
field += '"';
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"') quoted = false;
|
||||||
|
else field += char;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"' && field === "") quoted = true;
|
||||||
|
else if (char === ",") {
|
||||||
|
row.push(field);
|
||||||
|
field = "";
|
||||||
|
} else if (char === "\n" || char === "\r") {
|
||||||
|
endRow();
|
||||||
|
if (char === "\r" && text[index + 1] === "\n") index += 1;
|
||||||
|
if (rows.length > maxRows) break;
|
||||||
|
} else field += char;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
if (rows.length <= maxRows && (field !== "" || row.length > 0)) endRow();
|
||||||
|
return { rows: rows.slice(0, maxRows), truncated: rows.length > maxRows };
|
||||||
|
}
|
||||||
61
apps/web/src/lib/workspace-pane-pref.test.ts
Normal file
61
apps/web/src/lib/workspace-pane-pref.test.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
readPaneWidth,
|
||||||
|
readScreenPaneHabit,
|
||||||
|
writePaneWidth,
|
||||||
|
writeScreenPaneHabit,
|
||||||
|
} from "./workspace-pane-pref";
|
||||||
|
|
||||||
|
function stubStorage() {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
vi.stubGlobal("window", {
|
||||||
|
localStorage: {
|
||||||
|
getItem: (key: string) => store.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
store.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
store.delete(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("workspace pane preferences", () => {
|
||||||
|
it("keeps the learned screen habit per user and bot", () => {
|
||||||
|
const store = stubStorage();
|
||||||
|
expect(readScreenPaneHabit("user-1", "bot-1")).toBe(0);
|
||||||
|
writeScreenPaneHabit("user-1", "bot-1", 2);
|
||||||
|
expect(readScreenPaneHabit("user-1", "bot-1")).toBe(2);
|
||||||
|
expect(readScreenPaneHabit("user-1", "bot-2")).toBe(0);
|
||||||
|
writeScreenPaneHabit("user-1", "bot-1", 0);
|
||||||
|
expect(store.size).toBe(0);
|
||||||
|
writeScreenPaneHabit(null, "bot-1", 3);
|
||||||
|
expect(store.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores a stored pane width within its bounds", () => {
|
||||||
|
stubStorage();
|
||||||
|
expect(readPaneWidth("width", 500, 320, 900)).toBe(500);
|
||||||
|
writePaneWidth("width", 640.4);
|
||||||
|
expect(readPaneWidth("width", 500, 320, 900)).toBe(640);
|
||||||
|
writePaneWidth("width", 2_000);
|
||||||
|
expect(readPaneWidth("width", 500, 320, 900)).toBe(900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when storage is unavailable", () => {
|
||||||
|
vi.stubGlobal("window", {
|
||||||
|
get localStorage(): Storage {
|
||||||
|
throw new Error("blocked");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(readScreenPaneHabit("user-1", "bot-1")).toBe(0);
|
||||||
|
expect(readPaneWidth("width", 500, 320, 900)).toBe(500);
|
||||||
|
expect(() => writePaneWidth("width", 600)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
52
apps/web/src/lib/workspace-pane-pref.ts
Normal file
52
apps/web/src/lib/workspace-pane-pref.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { parseScreenPaneHabit, type ScreenPaneHabit } from "@rakazo/core";
|
||||||
|
|
||||||
|
function screenHabitKey(userId: string | null | undefined, botId: string | null | undefined) {
|
||||||
|
if (!userId || !botId) return null;
|
||||||
|
return `rakazo:screen-pane:${userId}:${botId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readScreenPaneHabit(
|
||||||
|
userId: string | null | undefined,
|
||||||
|
botId: string | null | undefined,
|
||||||
|
): ScreenPaneHabit {
|
||||||
|
const key = screenHabitKey(userId, botId);
|
||||||
|
if (!key) return 0;
|
||||||
|
try {
|
||||||
|
return parseScreenPaneHabit(window.localStorage.getItem(key));
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeScreenPaneHabit(
|
||||||
|
userId: string | null | undefined,
|
||||||
|
botId: string | null | undefined,
|
||||||
|
habit: ScreenPaneHabit,
|
||||||
|
): void {
|
||||||
|
const key = screenHabitKey(userId, botId);
|
||||||
|
if (!key) return;
|
||||||
|
try {
|
||||||
|
if (habit === 0) window.localStorage.removeItem(key);
|
||||||
|
else window.localStorage.setItem(key, String(habit));
|
||||||
|
} catch {
|
||||||
|
// Private mode or a full quota only loses the learned default.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPaneWidth(storageKey: string, fallback: number, min: number, max: number) {
|
||||||
|
try {
|
||||||
|
const stored = Number(window.localStorage.getItem(storageKey));
|
||||||
|
if (Number.isFinite(stored) && stored > 0) return Math.max(min, Math.min(max, stored));
|
||||||
|
} catch {
|
||||||
|
// Fall through to the default width.
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writePaneWidth(storageKey: string, width: number): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, String(Math.round(width)));
|
||||||
|
} catch {
|
||||||
|
// The width still applies for this session.
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,11 +49,13 @@ import {
|
|||||||
normalizeCreateBotProfile,
|
normalizeCreateBotProfile,
|
||||||
} from "@rakazo/contracts";
|
} from "@rakazo/contracts";
|
||||||
import {
|
import {
|
||||||
|
applyWorkspaceFocus,
|
||||||
attachmentsForThread,
|
attachmentsForThread,
|
||||||
buildComposerMentionOptions,
|
buildComposerMentionOptions,
|
||||||
type ComposerMention,
|
type ComposerMention,
|
||||||
clampMentionHighlightIndex,
|
clampMentionHighlightIndex,
|
||||||
cronFromPreset,
|
cronFromPreset,
|
||||||
|
deriveWorkspaceFocus,
|
||||||
groupBotsForSidebar,
|
groupBotsForSidebar,
|
||||||
inferAttachmentMimeType,
|
inferAttachmentMimeType,
|
||||||
isActive,
|
isActive,
|
||||||
@ -61,21 +63,27 @@ import {
|
|||||||
isRunTerminalEvent,
|
isRunTerminalEvent,
|
||||||
isToolActivityBlock,
|
isToolActivityBlock,
|
||||||
latestAnswerableAskMessageId,
|
latestAnswerableAskMessageId,
|
||||||
|
liveScreenRunId,
|
||||||
matchVoiceByName,
|
matchVoiceByName,
|
||||||
mentionChipKey,
|
mentionChipKey,
|
||||||
|
type PaneState,
|
||||||
parseVoiceChatCommand,
|
parseVoiceChatCommand,
|
||||||
projectMessageReactions,
|
projectMessageReactions,
|
||||||
|
recordScreenPaneChoice,
|
||||||
reorderBotTo,
|
reorderBotTo,
|
||||||
resolveComposerSendPlan,
|
resolveComposerSendPlan,
|
||||||
resolveMentionPickerKey,
|
resolveMentionPickerKey,
|
||||||
runThreadSubscription,
|
runThreadSubscription,
|
||||||
SLASH_ACTIONS,
|
SLASH_ACTIONS,
|
||||||
type SlashActionId,
|
type SlashActionId,
|
||||||
|
screenPaneMode,
|
||||||
searchHitThreadTarget,
|
searchHitThreadTarget,
|
||||||
serializeComposerPrompt,
|
serializeComposerPrompt,
|
||||||
speechFromBlocks,
|
speechFromBlocks,
|
||||||
truncateSlashDescription,
|
truncateSlashDescription,
|
||||||
userVisibleMessages,
|
userVisibleMessages,
|
||||||
|
type WorkspaceArtifact,
|
||||||
|
type WorkspaceFocus,
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
import {
|
import {
|
||||||
AvatarStyleProvider,
|
AvatarStyleProvider,
|
||||||
@ -155,6 +163,7 @@ import {
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { ArtifactFileCard } from "../components/ArtifactFileCard";
|
import { ArtifactFileCard } from "../components/ArtifactFileCard";
|
||||||
|
import { ArtifactPane } from "../components/ArtifactPane";
|
||||||
import { AskCard } from "../components/AskCard";
|
import { AskCard } from "../components/AskCard";
|
||||||
import { ActiveBotGlyph, CollaborationMarker } from "../components/ai/CollaborationMarker";
|
import { ActiveBotGlyph, CollaborationMarker } from "../components/ai/CollaborationMarker";
|
||||||
import { DelegatedTaskCard, SentMessageCard } from "../components/ai/DelegatedTaskCard";
|
import { DelegatedTaskCard, SentMessageCard } from "../components/ai/DelegatedTaskCard";
|
||||||
@ -177,6 +186,7 @@ import { TeachComputerOverlayControl } from "../components/teach/TeachComputerOv
|
|||||||
import { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome";
|
import { TeachRecordingChrome, TeachStopButton } from "../components/teach/TeachRecordingChrome";
|
||||||
import { VoiceAuroraRim } from "../components/voice/VoiceAurora";
|
import { VoiceAuroraRim } from "../components/voice/VoiceAurora";
|
||||||
import { useSidebarResize } from "../hooks/useSidebarResize";
|
import { useSidebarResize } from "../hooks/useSidebarResize";
|
||||||
|
import { useWideLayout } from "../hooks/useWideLayout";
|
||||||
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
|
import { readActivityMode, writeActivityMode } from "../lib/activity-mode";
|
||||||
import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity";
|
import { needsYouChipLabel, rosterActivitySubtitle, rosterStatusDot } from "../lib/agent-activity";
|
||||||
import { agentPointerFeed } from "../lib/agent-pointer";
|
import { agentPointerFeed } from "../lib/agent-pointer";
|
||||||
@ -245,6 +255,7 @@ import {
|
|||||||
transcriptMovedDown,
|
transcriptMovedDown,
|
||||||
} from "../lib/transcript-scroll";
|
} from "../lib/transcript-scroll";
|
||||||
import { speaker } from "../lib/tts";
|
import { speaker } from "../lib/tts";
|
||||||
|
import { readScreenPaneHabit, writeScreenPaneHabit } from "../lib/workspace-pane-pref";
|
||||||
import { ActivityList } from "./ActivityList";
|
import { ActivityList } from "./ActivityList";
|
||||||
import type { ContextMenuPosition } from "./BotContextMenu";
|
import type { ContextMenuPosition } from "./BotContextMenu";
|
||||||
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
|
import { CreateGroupForm, GroupSettings, memberName } from "./GroupPanel";
|
||||||
@ -323,6 +334,7 @@ type Panel =
|
|||||||
| "create-group"
|
| "create-group"
|
||||||
| "group-settings"
|
| "group-settings"
|
||||||
| "browser"
|
| "browser"
|
||||||
|
| "artifact"
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
type PendingAttachment = {
|
type PendingAttachment = {
|
||||||
@ -347,6 +359,14 @@ const THREAD_SNAPSHOT_TIMEOUT_MS = 2_000;
|
|||||||
const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000;
|
const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000;
|
||||||
const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32;
|
const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32;
|
||||||
const MOBILE_SIDEBAR_SWIPE_DISTANCE_PX = 56;
|
const MOBILE_SIDEBAR_SWIPE_DISTANCE_PX = 56;
|
||||||
|
/** The screen lingers after the work so its final state and the bot's reply land first. */
|
||||||
|
const AUTO_SCREEN_CLOSE_DELAY_MS = 4_000;
|
||||||
|
/** How long one pointer announcement counts as the bot working on its screen. */
|
||||||
|
const SCREEN_ACTIVE_MS = 5_000;
|
||||||
|
|
||||||
|
function defaultWorkspacePaneWidth(): number {
|
||||||
|
return Math.round(Math.max(420, Math.min(760, window.innerWidth * 0.42)));
|
||||||
|
}
|
||||||
|
|
||||||
function threadSnapshotSignal(parent: AbortSignal): AbortSignal {
|
function threadSnapshotSignal(parent: AbortSignal): AbortSignal {
|
||||||
return AbortSignal.any([parent, AbortSignal.timeout(THREAD_SNAPSHOT_TIMEOUT_MS)]);
|
return AbortSignal.any([parent, AbortSignal.timeout(THREAD_SNAPSHOT_TIMEOUT_MS)]);
|
||||||
@ -433,12 +453,24 @@ export function ShellPage() {
|
|||||||
width: leftSidebarWidth,
|
width: leftSidebarWidth,
|
||||||
isResizing: isResizingLeft,
|
isResizing: isResizingLeft,
|
||||||
startResizing: startResizingLeft,
|
startResizing: startResizingLeft,
|
||||||
} = useSidebarResize(316, "left", 240, 450);
|
} = useSidebarResize(316, "left", 240, 450, "rakazo:left-sidebar-width");
|
||||||
const {
|
const {
|
||||||
width: rightSidebarWidth,
|
width: rightSidebarWidth,
|
||||||
isResizing: isResizingRight,
|
isResizing: isResizingRight,
|
||||||
startResizing: startResizingRight,
|
startResizing: startResizingRight,
|
||||||
} = useSidebarResize(384, "right", 280, 500);
|
} = useSidebarResize(384, "right", 280, 500, "rakazo:side-panel-width");
|
||||||
|
// The screen and presented files get a wider pane than settings: they are what the user watches.
|
||||||
|
const {
|
||||||
|
width: workspacePaneWidth,
|
||||||
|
isResizing: isResizingWorkspacePane,
|
||||||
|
startResizing: startResizingWorkspacePane,
|
||||||
|
} = useSidebarResize(
|
||||||
|
defaultWorkspacePaneWidth(),
|
||||||
|
"right",
|
||||||
|
360,
|
||||||
|
960,
|
||||||
|
"rakazo:workspace-pane-width",
|
||||||
|
);
|
||||||
|
|
||||||
const [heuteActionCount, setHeuteActionCount] = useState(0);
|
const [heuteActionCount, setHeuteActionCount] = useState(0);
|
||||||
const [archivedOpen, setArchivedOpen] = useState(false);
|
const [archivedOpen, setArchivedOpen] = useState(false);
|
||||||
@ -453,6 +485,15 @@ export function ShellPage() {
|
|||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
const [panel, setPanel] = useState<Panel>(null);
|
const [panel, setPanel] = useState<Panel>(null);
|
||||||
|
/** The pane the conversation's focus opened; cleared as soon as the user changes the pane. */
|
||||||
|
const [autoPanel, setAutoPanel] = useState<Panel>(null);
|
||||||
|
const [artifactPane, setArtifactPane] = useState<WorkspaceArtifact | null>(null);
|
||||||
|
/** Run that last pointed at the bot's screen; the live screen only follows real screen work. */
|
||||||
|
const [screenRunId, setScreenRunId] = useState<string | null>(null);
|
||||||
|
const [screenActive, setScreenActive] = useState(false);
|
||||||
|
const screenActiveTimer = useRef<number | undefined>(undefined);
|
||||||
|
const [screenHabit, setScreenHabit] = useState(0);
|
||||||
|
const wideLayout = useWideLayout();
|
||||||
|
|
||||||
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
|
const [showCreateBotModal, setShowCreateBotModal] = useState(false);
|
||||||
|
|
||||||
@ -1453,6 +1494,16 @@ export function ShellPage() {
|
|||||||
void refreshComputerScreen(active.id).catch(() => undefined);
|
void refreshComputerScreen(active.id).catch(() => undefined);
|
||||||
} else if (event.type === "thread.computer") {
|
} else if (event.type === "thread.computer") {
|
||||||
agentPointerFeed.publish(event.botId, event.payload, event.createdAt);
|
agentPointerFeed.publish(event.botId, event.payload, event.createdAt);
|
||||||
|
const screenRun = liveScreenRunId(event);
|
||||||
|
if (screenRun) {
|
||||||
|
setScreenRunId(screenRun);
|
||||||
|
setScreenActive(true);
|
||||||
|
window.clearTimeout(screenActiveTimer.current);
|
||||||
|
screenActiveTimer.current = window.setTimeout(
|
||||||
|
() => setScreenActive(false),
|
||||||
|
SCREEN_ACTIVE_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -2097,6 +2148,83 @@ export function ShellPage() {
|
|||||||
() => (inGroup ? { groupId: groupId ?? "" } : { botId: active?.id ?? "" }),
|
() => (inGroup ? { groupId: groupId ?? "" } : { botId: active?.id ?? "" }),
|
||||||
[active?.id, groupId, inGroup],
|
[active?.id, groupId, inGroup],
|
||||||
);
|
);
|
||||||
|
const workspaceFocus = useMemo(
|
||||||
|
() =>
|
||||||
|
deriveWorkspaceFocus(
|
||||||
|
activeSnapshot ? { ...activeSnapshot, computer: inGroup ? null : computer } : null,
|
||||||
|
{ screenRunId, screenActive, screenMode: screenPaneMode(screenHabit) },
|
||||||
|
),
|
||||||
|
[activeSnapshot, computer, inGroup, screenActive, screenHabit, screenRunId],
|
||||||
|
);
|
||||||
|
const workspaceFocusRef = useRef<{ threadId: string; focus: WorkspaceFocus } | null>(null);
|
||||||
|
const autoPanelRef = useRef(autoPanel);
|
||||||
|
autoPanelRef.current = autoPanel;
|
||||||
|
const autoCloseTimer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => () => window.clearTimeout(autoCloseTimer.current), []);
|
||||||
|
// The pane follows the bot's work on focus edges only, so it never jumps while nothing changed.
|
||||||
|
useEffect(() => {
|
||||||
|
const threadId = activeSnapshot?.threadId;
|
||||||
|
const sameThread = Boolean(threadId) && workspaceFocusRef.current?.threadId === threadId;
|
||||||
|
// New work or another conversation cancels a pending close; an unrelated edge does not.
|
||||||
|
if (!sameThread || workspaceFocus.pane !== null) window.clearTimeout(autoCloseTimer.current);
|
||||||
|
if (!threadId) {
|
||||||
|
workspaceFocusRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous = sameThread ? (workspaceFocusRef.current?.focus ?? null) : null;
|
||||||
|
workspaceFocusRef.current = { threadId, focus: workspaceFocus };
|
||||||
|
// Narrow windows keep the conversation full width; a pane would cover it.
|
||||||
|
if (!wideLayout) return;
|
||||||
|
const paneState: PaneState = {
|
||||||
|
open:
|
||||||
|
panel === "computer"
|
||||||
|
? "screen"
|
||||||
|
: panel === "artifact"
|
||||||
|
? "artifact"
|
||||||
|
: panel
|
||||||
|
? "other"
|
||||||
|
: null,
|
||||||
|
auto: panel !== null && panel === autoPanel,
|
||||||
|
};
|
||||||
|
const next = applyWorkspaceFocus(previous, workspaceFocus, paneState);
|
||||||
|
if (!next || next.open === "other") return;
|
||||||
|
if (next.open === null) {
|
||||||
|
const closing = autoPanelRef.current;
|
||||||
|
autoCloseTimer.current = window.setTimeout(() => {
|
||||||
|
if (closing === null || autoPanelRef.current !== closing) return;
|
||||||
|
setPanel(null);
|
||||||
|
setAutoPanel(null);
|
||||||
|
}, AUTO_SCREEN_CLOSE_DELAY_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next.open === "artifact") setArtifactPane(workspaceFocus.artifact);
|
||||||
|
const nextPanel: Panel = next.open === "screen" ? "computer" : next.open;
|
||||||
|
setPanel(nextPanel);
|
||||||
|
setAutoPanel(next.auto ? nextPanel : null);
|
||||||
|
}, [workspaceFocus.key, activeSnapshot?.threadId, wideLayout]);
|
||||||
|
const learnScreenPane = useCallback(
|
||||||
|
(choice: "opened" | "dismissed") => {
|
||||||
|
const botId = activeBotId.current;
|
||||||
|
if (!botId) return;
|
||||||
|
const next = recordScreenPaneChoice(screenHabit, choice);
|
||||||
|
setScreenHabit(next);
|
||||||
|
writeScreenPaneHabit(userId, botId, next);
|
||||||
|
},
|
||||||
|
[screenHabit, userId],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoPanel === null || panel === autoPanel) return;
|
||||||
|
// Closing the live screen while the bot still works on it teaches this bot's default.
|
||||||
|
const focus = workspaceFocusRef.current?.focus;
|
||||||
|
if (autoPanel === "computer" && panel === null && focus?.pane === "screen" && !focus.takeover) {
|
||||||
|
learnScreenPane("dismissed");
|
||||||
|
}
|
||||||
|
setAutoPanel(null);
|
||||||
|
}, [panel]);
|
||||||
|
const openArtifactBeside = useCallback((artifact: WorkspaceArtifact) => {
|
||||||
|
setArtifactPane(artifact);
|
||||||
|
setPanel("artifact");
|
||||||
|
}, []);
|
||||||
const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members;
|
const transcriptMembers = activeSnapshot?.members ?? activeGroup?.members;
|
||||||
// Bots of every space, so a group message from a bot that is not a member still has a speaker.
|
// Bots of every space, so a group message from a bot that is not a member still has a speaker.
|
||||||
const knownBots = useMemo(() => {
|
const knownBots = useMemo(() => {
|
||||||
@ -3105,8 +3233,22 @@ export function ShellPage() {
|
|||||||
setComputerOpen(false);
|
setComputerOpen(false);
|
||||||
setComputerError(null);
|
setComputerError(null);
|
||||||
setComputerErrorFromScreen(false);
|
setComputerErrorFromScreen(false);
|
||||||
|
setScreenRunId(null);
|
||||||
|
setScreenActive(false);
|
||||||
|
window.clearTimeout(screenActiveTimer.current);
|
||||||
}, [active?.id]);
|
}, [active?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setScreenHabit(readScreenPaneHabit(userId, active?.id));
|
||||||
|
}, [active?.id, userId]);
|
||||||
|
|
||||||
|
// A presented file belongs to its conversation, and a new conversation starts without auto panes.
|
||||||
|
useEffect(() => {
|
||||||
|
setPanel((current) => (current === "artifact" ? null : current));
|
||||||
|
setArtifactPane(null);
|
||||||
|
setAutoPanel(null);
|
||||||
|
}, [active?.id, groupId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusPromptBotIdRef.current && focusPromptBotIdRef.current !== active?.id) {
|
if (focusPromptBotIdRef.current && focusPromptBotIdRef.current !== active?.id) {
|
||||||
cancelFocusPrompt();
|
cancelFocusPrompt();
|
||||||
@ -3223,6 +3365,9 @@ export function ShellPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const embeddedScreenUrl = embeddableScreenUrl(screenUrl);
|
const embeddedScreenUrl = embeddableScreenUrl(screenUrl);
|
||||||
|
// Only what the work opened gets the wide pane; settings, routines and a hand-opened screen keep
|
||||||
|
// one width so switching between them never jumps.
|
||||||
|
const workspacePaneOpen = panel === "artifact" || (panel === "computer" && autoPanel === panel);
|
||||||
const hasControl = userHoldsComputerControl(computer, active?.id);
|
const hasControl = userHoldsComputerControl(computer, active?.id);
|
||||||
const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl);
|
const hideScreenLoadError = computerErrorFromScreen && Boolean(embeddedScreenUrl);
|
||||||
const computerScreenError =
|
const computerScreenError =
|
||||||
@ -4403,6 +4548,7 @@ export function ShellPage() {
|
|||||||
title={t`Agent computer`}
|
title={t`Agent computer`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = panel === "computer" ? null : "computer";
|
const next = panel === "computer" ? null : "computer";
|
||||||
|
if (next === "computer" && transcriptRunning) learnScreenPane("opened");
|
||||||
setPanel(next);
|
setPanel(next);
|
||||||
if (next === "computer" && active) {
|
if (next === "computer" && active) {
|
||||||
// Refresh run/computer so Take control isn't stuck on a stale busyBotName.
|
// Refresh run/computer so Take control isn't stuck on a stale busyBotName.
|
||||||
@ -4490,6 +4636,7 @@ export function ShellPage() {
|
|||||||
}
|
}
|
||||||
scrollRef={messageScroll}
|
scrollRef={messageScroll}
|
||||||
artifactTarget={transcriptArtifactTarget}
|
artifactTarget={transcriptArtifactTarget}
|
||||||
|
onOpenArtifact={wideLayout ? openArtifactBeside : undefined}
|
||||||
messages={transcriptMessages}
|
messages={transcriptMessages}
|
||||||
olderCursor={activeSnapshot?.olderCursor ?? null}
|
olderCursor={activeSnapshot?.olderCursor ?? null}
|
||||||
loadingOlder={loadingOlder}
|
loadingOlder={loadingOlder}
|
||||||
@ -4592,6 +4739,7 @@ export function ShellPage() {
|
|||||||
<aside
|
<aside
|
||||||
data-testid="side-panel"
|
data-testid="side-panel"
|
||||||
data-panel={panel ?? "closed"}
|
data-panel={panel ?? "closed"}
|
||||||
|
data-auto={panel !== null && panel === autoPanel ? "" : undefined}
|
||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
panel &&
|
panel &&
|
||||||
@ -4600,7 +4748,9 @@ export function ShellPage() {
|
|||||||
panel === "create" ||
|
panel === "create" ||
|
||||||
panel === "create-group" ||
|
panel === "create-group" ||
|
||||||
panel === "browser")
|
panel === "browser")
|
||||||
? rightSidebarWidth
|
? workspacePaneOpen
|
||||||
|
? workspacePaneWidth
|
||||||
|
: rightSidebarWidth
|
||||||
: 0,
|
: 0,
|
||||||
}}
|
}}
|
||||||
className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl md:relative ${
|
className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl md:relative ${
|
||||||
@ -4614,13 +4764,22 @@ export function ShellPage() {
|
|||||||
)
|
)
|
||||||
? "max-w-[384px] md:max-w-none opacity-100"
|
? "max-w-[384px] md:max-w-none opacity-100"
|
||||||
: "pointer-events-none w-0 opacity-0 md:opacity-0 md:w-0"
|
: "pointer-events-none w-0 opacity-0 md:opacity-0 md:w-0"
|
||||||
} ${!isResizingRight ? "transition-[width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" : ""}`}
|
} ${!(workspacePaneOpen ? isResizingWorkspacePane : isResizingRight) ? "transition-[width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none" : ""}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onPointerDown={startResizingRight}
|
onPointerDown={workspacePaneOpen ? startResizingWorkspacePane : startResizingRight}
|
||||||
className="absolute -left-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
|
className="absolute -left-2 top-0 bottom-0 w-4 cursor-col-resize z-50 hidden md:block"
|
||||||
/>
|
/>
|
||||||
{panel === "browser" ? (
|
{panel === "artifact" ? (
|
||||||
|
artifactPane ? (
|
||||||
|
<ArtifactPane
|
||||||
|
key={artifactPane.artifactId}
|
||||||
|
target={transcriptArtifactTarget}
|
||||||
|
artifact={artifactPane}
|
||||||
|
onClose={() => setPanel(null)}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
) : panel === "browser" ? (
|
||||||
<InAppBrowser onClose={() => setPanel(null)} />
|
<InAppBrowser onClose={() => setPanel(null)} />
|
||||||
) : panel && (active || activeGroup || panel === "create" || panel === "create-group") ? (
|
) : panel && (active || activeGroup || panel === "create" || panel === "create-group") ? (
|
||||||
<div className="rk-scroll h-full w-full overflow-y-auto px-5 lg:px-7 2xl:px-9 py-[17px]">
|
<div className="rk-scroll h-full w-full overflow-y-auto px-5 lg:px-7 2xl:px-9 py-[17px]">
|
||||||
@ -5620,6 +5779,7 @@ export function ShellPage() {
|
|||||||
const Transcript = memo(function Transcript({
|
const Transcript = memo(function Transcript({
|
||||||
scrollRef,
|
scrollRef,
|
||||||
artifactTarget,
|
artifactTarget,
|
||||||
|
onOpenArtifact,
|
||||||
messages,
|
messages,
|
||||||
olderCursor,
|
olderCursor,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
@ -5649,6 +5809,7 @@ const Transcript = memo(function Transcript({
|
|||||||
}: {
|
}: {
|
||||||
scrollRef: RefObject<HTMLDivElement | null>;
|
scrollRef: RefObject<HTMLDivElement | null>;
|
||||||
artifactTarget: ArtifactTarget;
|
artifactTarget: ArtifactTarget;
|
||||||
|
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
|
||||||
messages: ThreadMessage[];
|
messages: ThreadMessage[];
|
||||||
olderCursor: number | null;
|
olderCursor: number | null;
|
||||||
loadingOlder: boolean;
|
loadingOlder: boolean;
|
||||||
@ -5787,6 +5948,43 @@ const Transcript = memo(function Transcript({
|
|||||||
if (following.current) snapToEnd();
|
if (following.current) snapToEnd();
|
||||||
}, [messages, running, snapToEnd]);
|
}, [messages, running, snapToEnd]);
|
||||||
|
|
||||||
|
// A question the bot waits on can scroll out of view; offer a way back to it instead of the arrow.
|
||||||
|
const [askOffscreen, setAskOffscreen] = useState(false);
|
||||||
|
const findAsk = useCallback(
|
||||||
|
() =>
|
||||||
|
answerableAskMessageId
|
||||||
|
? (scrollRef.current?.querySelector<HTMLElement>(
|
||||||
|
`[data-message-id="${CSS.escape(answerableAskMessageId)}"]`,
|
||||||
|
) ?? null)
|
||||||
|
: null,
|
||||||
|
[answerableAskMessageId, scrollRef],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const root = scrollRef.current;
|
||||||
|
const element = findAsk();
|
||||||
|
if (!root || !element || typeof IntersectionObserver === "undefined") {
|
||||||
|
setAskOffscreen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => setAskOffscreen(!entries.some((entry) => entry.isIntersecting)),
|
||||||
|
{ root, threshold: 0.2 },
|
||||||
|
);
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [findAsk, messages, scrollRef]);
|
||||||
|
const jumpToAsk = useCallback(() => {
|
||||||
|
const element = findAsk();
|
||||||
|
if (!element) return;
|
||||||
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
following.current = false;
|
||||||
|
element.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "center" });
|
||||||
|
element
|
||||||
|
.querySelector<HTMLElement>("[data-ask-card] input, [data-ask-card] button")
|
||||||
|
?.focus({ preventScroll: true });
|
||||||
|
}, [findAsk]);
|
||||||
|
const showJumpToLatest = !atEnd && !askOffscreen;
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const button = jumpButtonRef.current;
|
const button = jumpButtonRef.current;
|
||||||
if (atEnd && button && document.activeElement === button) {
|
if (atEnd && button && document.activeElement === button) {
|
||||||
@ -5953,6 +6151,7 @@ const Transcript = memo(function Transcript({
|
|||||||
)}
|
)}
|
||||||
<MessageView
|
<MessageView
|
||||||
artifactTarget={artifactTarget}
|
artifactTarget={artifactTarget}
|
||||||
|
onOpenArtifact={onOpenArtifact}
|
||||||
message={message}
|
message={message}
|
||||||
canAnswer={message.id === answerableAskMessageId}
|
canAnswer={message.id === answerableAskMessageId}
|
||||||
onOpenBot={onOpenBot}
|
onOpenBot={onOpenBot}
|
||||||
@ -6035,15 +6234,28 @@ const Transcript = memo(function Transcript({
|
|||||||
ref={jumpButtonRef}
|
ref={jumpButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t`Jump to latest`}
|
aria-label={t`Jump to latest`}
|
||||||
aria-hidden={atEnd}
|
aria-hidden={!showJumpToLatest}
|
||||||
tabIndex={atEnd ? -1 : 0}
|
tabIndex={showJumpToLatest ? 0 : -1}
|
||||||
onClick={jumpToLatest}
|
onClick={jumpToLatest}
|
||||||
className={`absolute bottom-4 left-1/2 z-20 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-border bg-muted/95 text-foreground/75 shadow-md backdrop-blur transition-[opacity,transform,background-color] duration-200 ease-[cubic-bezier(.22,1,.36,1)] hover:bg-border motion-reduce:transition-none ${
|
className={`absolute bottom-4 left-1/2 z-20 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-border bg-muted/95 text-foreground/75 shadow-md backdrop-blur transition-[opacity,transform,background-color] duration-200 ease-[cubic-bezier(.22,1,.36,1)] hover:bg-border motion-reduce:transition-none ${
|
||||||
atEnd ? "pointer-events-none translate-y-2 opacity-0" : "translate-y-0 opacity-100"
|
showJumpToLatest
|
||||||
|
? "translate-y-0 opacity-100"
|
||||||
|
: "pointer-events-none translate-y-2 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ArrowDown size={17} strokeWidth={1.8} />
|
<ArrowDown size={17} strokeWidth={1.8} />
|
||||||
</button>
|
</button>
|
||||||
|
{askOffscreen ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="needs-you-jump"
|
||||||
|
onClick={jumpToAsk}
|
||||||
|
className="absolute bottom-4 left-1/2 z-20 inline-flex h-9 -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-muted/95 px-3.5 text-ui-body-sm font-medium text-foreground shadow-md backdrop-blur hover:bg-border"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="size-2 rounded-full bg-warning" />
|
||||||
|
{t`Needs you`}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -7106,6 +7318,7 @@ function ComputerReleaseActions({
|
|||||||
|
|
||||||
const MessageView = memo(function MessageView({
|
const MessageView = memo(function MessageView({
|
||||||
artifactTarget,
|
artifactTarget,
|
||||||
|
onOpenArtifact,
|
||||||
canAnswer,
|
canAnswer,
|
||||||
message,
|
message,
|
||||||
onAnswer,
|
onAnswer,
|
||||||
@ -7127,6 +7340,7 @@ const MessageView = memo(function MessageView({
|
|||||||
encounterMode = false,
|
encounterMode = false,
|
||||||
}: {
|
}: {
|
||||||
artifactTarget: ArtifactTarget;
|
artifactTarget: ArtifactTarget;
|
||||||
|
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
|
||||||
canAnswer: boolean;
|
canAnswer: boolean;
|
||||||
message: ThreadMessage;
|
message: ThreadMessage;
|
||||||
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
|
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
|
||||||
@ -7528,6 +7742,17 @@ const MessageView = memo(function MessageView({
|
|||||||
name={block.name}
|
name={block.name}
|
||||||
mimeType={block.mimeType}
|
mimeType={block.mimeType}
|
||||||
size={block.size}
|
size={block.size}
|
||||||
|
onOpenBeside={
|
||||||
|
onOpenArtifact
|
||||||
|
? () =>
|
||||||
|
onOpenArtifact({
|
||||||
|
messageId: message.id,
|
||||||
|
artifactId: block.artifactId,
|
||||||
|
name: block.name,
|
||||||
|
mimeType: block.mimeType,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
32
docs/ADAPTIVE-WORKSPACE.md
Normal file
32
docs/ADAPTIVE-WORKSPACE.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Adaptive workspace
|
||||||
|
|
||||||
|
The pane beside a conversation follows what the bot is actually doing, not a guess about the task.
|
||||||
|
No classifier or model decides the layout: `deriveWorkspaceFocus` in `@rakazo/core` reads the thread
|
||||||
|
state every client already has, so web and mobile agree and the rules stay testable offline.
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
| Focus | Signal | Web | Mobile |
|
||||||
|
|-------|--------|-----|--------|
|
||||||
|
| Screen | A fresh `thread.computer` pointer event from a working run, or a takeover request | Live screen opens beside the chat (wide pane), steps back a few seconds after the work ends | Pill above the composer opens the computer |
|
||||||
|
| Artifact | The latest run attached a file with `present: true` (`attach_file`) | File opens beside the chat until the user closes it or the bot presents another | Unchanged: file cards open the native preview |
|
||||||
|
| Needs you | An unanswered ask on a waiting run | "Needs you" pill replaces the jump arrow when the question scrolled out of view | Existing push and attention notifications |
|
||||||
|
|
||||||
|
Everything else stays a plain conversation.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- The pane moves only on focus edges (`applyWorkspaceFocus`). The first focus of a conversation is a
|
||||||
|
baseline, so opening a thread never jumps.
|
||||||
|
- A pane the user opened by hand is never replaced or closed automatically. Auto panes close only
|
||||||
|
when their own focus ends; a presented file stays.
|
||||||
|
- Windows narrower than 1024px keep the conversation full width; file cards fall back to the dialog.
|
||||||
|
- Per bot, the client learns from the user: opening the screen by hand while the bot works three
|
||||||
|
times makes it open on every run; closing an automatically opened screen twice stops it opening
|
||||||
|
on its own. A takeover request always shows the screen.
|
||||||
|
- Pane widths are remembered per device.
|
||||||
|
|
||||||
|
## Not in scope
|
||||||
|
|
||||||
|
Generated layouts, per-domain dashboards and prompt classification stay out: they guess, move the UI
|
||||||
|
under the user, and cannot be verified deterministically.
|
||||||
@ -43,6 +43,8 @@ describe("server image publish workflow", () => {
|
|||||||
expect(workflowText).toContain("actions/attest-build-provenance@");
|
expect(workflowText).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,10 +144,17 @@ export const builtinAgentTools: ConnectorTool[] = [
|
|||||||
{
|
{
|
||||||
name: "attach_file",
|
name: "attach_file",
|
||||||
description:
|
description:
|
||||||
"Attach a workspace file from this bot's home to the chat thread as an image or common file. The file stays in place; users can open it from the message.",
|
"Attach a workspace file from this bot's home to the chat thread as an image or common file. The file stays in place; users can open it from the message. Set present when the file is the result the user asked for, so it opens beside the conversation.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: { path: { type: "string" } },
|
properties: {
|
||||||
|
path: { type: "string" },
|
||||||
|
present: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"True only for the finished result of the request, not for supporting files.",
|
||||||
|
},
|
||||||
|
},
|
||||||
required: ["path"],
|
required: ["path"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2449,6 +2449,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
|
|||||||
filePath,
|
filePath,
|
||||||
bytes,
|
bytes,
|
||||||
operationId: executionId,
|
operationId: executionId,
|
||||||
|
present: args.present === true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await publishMessage(deps, run, "bot", [attached.block]);
|
await publishMessage(deps, run, "bot", [attached.block]);
|
||||||
|
|||||||
@ -454,6 +454,20 @@ export function inferScript(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (lower.includes("present") && lower.includes("report")) {
|
||||||
|
const filePath =
|
||||||
|
/(?:called|named|path|file)\s+([A-Za-z0-9._/-]+)/i.exec(prompt)?.[1] ?? "report.md";
|
||||||
|
const content =
|
||||||
|
"# Report\n\n| Item | Status |\n| --- | --- |\n| Research | Done |\n| Draft | Done |\n";
|
||||||
|
return [
|
||||||
|
{ assistant: "writing the report and putting it next to our chat." },
|
||||||
|
{ toolCalls: [{ name: "write_file", args: { path: filePath, content } }] },
|
||||||
|
{
|
||||||
|
toolCalls: [{ name: "attach_file", args: { path: filePath, present: true } }],
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
if (lower.includes("attach") && (lower.includes("thread") || lower.includes("into the thread"))) {
|
if (lower.includes("attach") && (lower.includes("thread") || lower.includes("into the thread"))) {
|
||||||
const said = /says?\s+(.+)$/i.exec(prompt)?.[1]?.replace(/[.]+$/, "") ?? prompt;
|
const said = /says?\s+(.+)$/i.exec(prompt)?.[1]?.replace(/[.]+$/, "") ?? prompt;
|
||||||
const content = `${said.trim()}\n`;
|
const content = `${said.trim()}\n`;
|
||||||
|
|||||||
@ -46,6 +46,39 @@ describe("current-turn thread files", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks a file the bot presents as its result", async () => {
|
||||||
|
const deps = {
|
||||||
|
prisma: {
|
||||||
|
artifact: {
|
||||||
|
create: vi.fn(
|
||||||
|
async ({ data }: { data: { name: string; mimeType: string; size: number } }) => ({
|
||||||
|
id: "artifact-1",
|
||||||
|
...data,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
} as unknown as PrismaClient,
|
||||||
|
artifacts: {
|
||||||
|
put: vi.fn().mockResolvedValue({ id: "stored-1", hash: "hash" }),
|
||||||
|
remove: vi.fn(),
|
||||||
|
} as unknown as ArtifactStore,
|
||||||
|
};
|
||||||
|
const input = {
|
||||||
|
spaceId: "workspace-1",
|
||||||
|
userId: "user-1",
|
||||||
|
botId: "bot-1",
|
||||||
|
runId: "run-1",
|
||||||
|
filePath: "out/report.md",
|
||||||
|
bytes: new TextEncoder().encode("# Report\n"),
|
||||||
|
operationId: "attach-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const presented = await attachWorkspaceFileToThread(deps, { ...input, present: true });
|
||||||
|
expect(presented.block).toMatchObject({ kind: "file", name: "report.md", present: true });
|
||||||
|
const attached = await attachWorkspaceFileToThread(deps, input);
|
||||||
|
expect(attached.block).not.toHaveProperty("present");
|
||||||
|
});
|
||||||
|
|
||||||
it("copies non-image attachments into the bot workspace and describes their paths", async () => {
|
it("copies non-image attachments into the bot workspace and describes their paths", async () => {
|
||||||
const findMany = vi.fn().mockResolvedValue([
|
const findMany = vi.fn().mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export async function attachWorkspaceFileToThread(
|
|||||||
filePath: string;
|
filePath: string;
|
||||||
bytes: Uint8Array;
|
bytes: Uint8Array;
|
||||||
operationId: string;
|
operationId: string;
|
||||||
|
present?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<{ artifactId: string; block: Extract<MessageBlock, { kind: "image" | "file" }> }> {
|
): Promise<{ artifactId: string; block: Extract<MessageBlock, { kind: "image" | "file" }> }> {
|
||||||
const name = path.basename(input.filePath) || input.filePath;
|
const name = path.basename(input.filePath) || input.filePath;
|
||||||
@ -79,15 +80,13 @@ export async function attachWorkspaceFileToThread(
|
|||||||
await deps.artifacts.remove(stored.id, context).catch(() => undefined);
|
await deps.artifacts.remove(stored.id, context).catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
return {
|
const block = messageBlockForArtifact({
|
||||||
artifactId: row.id,
|
|
||||||
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,6 +240,8 @@ export const MessageBlock = z.discriminatedUnion("kind", [
|
|||||||
artifactId: Id,
|
artifactId: Id,
|
||||||
mimeType: z.string(),
|
mimeType: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
/** The bot presented this as its result; clients may show it beside the conversation. */
|
||||||
|
present: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.literal("file"),
|
kind: z.literal("file"),
|
||||||
@ -247,6 +249,8 @@ export const MessageBlock = z.discriminatedUnion("kind", [
|
|||||||
mimeType: z.string(),
|
mimeType: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
size: z.number().int().nonnegative(),
|
size: z.number().int().nonnegative(),
|
||||||
|
/** The bot presented this as its result; clients may show it beside the conversation. */
|
||||||
|
present: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.literal("handoff"),
|
kind: z.literal("handoff"),
|
||||||
|
|||||||
@ -47,3 +47,4 @@ export * from "./thread-subscription.js";
|
|||||||
export * from "./tool-activity.js";
|
export * from "./tool-activity.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";
|
||||||
|
|||||||
305
packages/core/src/workspace-focus.test.ts
Normal file
305
packages/core/src/workspace-focus.test.ts
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
applyWorkspaceFocus,
|
||||||
|
deriveWorkspaceFocus,
|
||||||
|
isPaneArtifactMimeType,
|
||||||
|
liveScreenRunId,
|
||||||
|
parseScreenPaneHabit,
|
||||||
|
recordScreenPaneChoice,
|
||||||
|
screenPaneMode,
|
||||||
|
type WorkspaceFocusSnapshot,
|
||||||
|
} from "./workspace-focus.js";
|
||||||
|
|
||||||
|
const runningComputer = {
|
||||||
|
kind: "docker",
|
||||||
|
state: "running",
|
||||||
|
screenAvailable: true,
|
||||||
|
takeoverRequested: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function snapshot(overrides: Partial<WorkspaceFocusSnapshot> = {}): WorkspaceFocusSnapshot {
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
run: { id: "run-1", status: "running" },
|
||||||
|
computer: runningComputer,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
id: "msg-report",
|
||||||
|
role: "bot",
|
||||||
|
runId: "run-1",
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
kind: "file",
|
||||||
|
artifactId: "art-1",
|
||||||
|
name: "report.md",
|
||||||
|
mimeType: "text/markdown",
|
||||||
|
present: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("deriveWorkspaceFocus", () => {
|
||||||
|
it("keeps a plain conversation when the bot only chats", () => {
|
||||||
|
const focus = deriveWorkspaceFocus(
|
||||||
|
snapshot({ messages: [{ id: "m", runId: "run-1", blocks: [{ kind: "text" }] }] }),
|
||||||
|
);
|
||||||
|
expect(focus.pane).toBeNull();
|
||||||
|
expect(focus.key).toBe("none|ask:none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the screen once the working run acts on it", () => {
|
||||||
|
expect(deriveWorkspaceFocus(snapshot(), { screenRunId: "run-1" }).pane).toBe("screen");
|
||||||
|
expect(deriveWorkspaceFocus(snapshot(), { screenRunId: "run-old" }).pane).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps showing the screen while the bot pointed at it moments ago", () => {
|
||||||
|
const finished = snapshot({ run: { id: "run-1", status: "completed" } });
|
||||||
|
expect(deriveWorkspaceFocus(finished, { screenActive: true }).pane).toBe("screen");
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceFocus(finished, { screenActive: true, screenMode: "never" }).pane,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the conversation when the run finishes", () => {
|
||||||
|
const focus = deriveWorkspaceFocus(snapshot({ run: { id: "run-1", status: "completed" } }), {
|
||||||
|
screenRunId: "run-1",
|
||||||
|
});
|
||||||
|
expect(focus.pane).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never offers a screen it cannot embed", () => {
|
||||||
|
for (const computer of [
|
||||||
|
{ ...runningComputer, kind: "desktop" },
|
||||||
|
{ ...runningComputer, kind: "none" },
|
||||||
|
null,
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceFocus(snapshot({ computer }), { screenRunId: "run-1" }).pane,
|
||||||
|
).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trusts screen work over a computer status that has not caught up with the boot", () => {
|
||||||
|
const stale = { ...runningComputer, state: "stopped", screenAvailable: false };
|
||||||
|
expect(deriveWorkspaceFocus(snapshot({ computer: stale }), { screenRunId: "run-1" }).pane).toBe(
|
||||||
|
"screen",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceFocus(
|
||||||
|
snapshot({
|
||||||
|
run: { id: "run-1", status: "waiting_takeover" },
|
||||||
|
computer: { ...stale, takeoverRequested: true },
|
||||||
|
}),
|
||||||
|
).pane,
|
||||||
|
).toBe("screen");
|
||||||
|
// Without evidence of screen work, a learned default never boots a stopped computer.
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceFocus(snapshot({ computer: stale }), { screenMode: "always" }).pane,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the screen for a takeover request even when the user turned auto off", () => {
|
||||||
|
const focus = deriveWorkspaceFocus(
|
||||||
|
snapshot({
|
||||||
|
run: { id: "run-1", status: "waiting_takeover" },
|
||||||
|
computer: { ...runningComputer, takeoverRequested: true },
|
||||||
|
}),
|
||||||
|
{ screenMode: "never" },
|
||||||
|
);
|
||||||
|
expect(focus).toMatchObject({ pane: "screen", takeover: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the learned screen mode", () => {
|
||||||
|
expect(deriveWorkspaceFocus(snapshot(), { screenMode: "always" }).pane).toBe("screen");
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceFocus(snapshot(), { screenMode: "never", screenRunId: "run-1" }).pane,
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceFocus(snapshot({ run: { id: "run-1", status: "completed" } }), {
|
||||||
|
screenMode: "always",
|
||||||
|
}).pane,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the file the bot presented as its latest result", () => {
|
||||||
|
const focus = deriveWorkspaceFocus(
|
||||||
|
snapshot({ run: { id: "run-1", status: "completed" }, messages: [report] }),
|
||||||
|
);
|
||||||
|
expect(focus.pane).toBe("artifact");
|
||||||
|
expect(focus.artifact).toEqual({
|
||||||
|
messageId: "msg-report",
|
||||||
|
artifactId: "art-1",
|
||||||
|
name: "report.md",
|
||||||
|
mimeType: "text/markdown",
|
||||||
|
});
|
||||||
|
expect(focus.key).toBe("artifact:art-1|ask:none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores files that were only attached, presented before the user wrote again, or not previewable", () => {
|
||||||
|
const attached = {
|
||||||
|
...report,
|
||||||
|
blocks: [{ ...report.blocks[0]!, present: undefined }],
|
||||||
|
};
|
||||||
|
expect(deriveWorkspaceFocus(snapshot({ messages: [attached] })).pane).toBeNull();
|
||||||
|
const followUp = { id: "msg-user", role: "user", runId: "run-2", blocks: [{ kind: "text" }] };
|
||||||
|
expect(deriveWorkspaceFocus(snapshot({ messages: [report, followUp] })).pane).toBeNull();
|
||||||
|
// Another member's newer run in a group does not hide the result.
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceFocus(
|
||||||
|
snapshot({ run: { id: "run-9", status: "running" }, messages: [report] }),
|
||||||
|
).pane,
|
||||||
|
).toBe("artifact");
|
||||||
|
const pdf = {
|
||||||
|
...report,
|
||||||
|
blocks: [{ ...report.blocks[0]!, mimeType: "application/pdf", name: "report.pdf" }],
|
||||||
|
};
|
||||||
|
expect(deriveWorkspaceFocus(snapshot({ messages: [pdf] })).pane).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers live screen work over a presented file", () => {
|
||||||
|
expect(
|
||||||
|
deriveWorkspaceFocus(snapshot({ messages: [report] }), { screenRunId: "run-1" }).pane,
|
||||||
|
).toBe("screen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the ask the bot is waiting on", () => {
|
||||||
|
const focus = deriveWorkspaceFocus(
|
||||||
|
snapshot({
|
||||||
|
run: { id: "run-1", status: "waiting_input" },
|
||||||
|
messages: [{ id: "ask-1", runId: "run-1", blocks: [{ kind: "ask", status: "pending" }] }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(focus.needsYouMessageId).toBe("ask-1");
|
||||||
|
expect(focus.key).toBe("none|ask:ask-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyWorkspaceFocus", () => {
|
||||||
|
const plain = deriveWorkspaceFocus(snapshot());
|
||||||
|
const screen = deriveWorkspaceFocus(snapshot(), { screenRunId: "run-1" });
|
||||||
|
const artifact = deriveWorkspaceFocus(
|
||||||
|
snapshot({ run: { id: "run-1", status: "completed" }, messages: [report] }),
|
||||||
|
);
|
||||||
|
const closed = { open: null, auto: false } as const;
|
||||||
|
|
||||||
|
it("treats the first focus of a conversation as a baseline", () => {
|
||||||
|
expect(applyWorkspaceFocus(null, screen, closed)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the pane on an edge and closes it again when the work ends", () => {
|
||||||
|
const opened = applyWorkspaceFocus(plain, screen, closed);
|
||||||
|
expect(opened).toEqual({ open: "screen", auto: true });
|
||||||
|
expect(applyWorkspaceFocus(screen, plain, opened!)).toEqual(closed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves from the screen to the presented result", () => {
|
||||||
|
expect(applyWorkspaceFocus(screen, artifact, { open: "screen", auto: true })).toEqual({
|
||||||
|
open: "artifact",
|
||||||
|
auto: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a presented file open while the user follows up", () => {
|
||||||
|
const followUp = deriveWorkspaceFocus(
|
||||||
|
snapshot({
|
||||||
|
run: { id: "run-2", status: "running" },
|
||||||
|
messages: [report, { id: "msg-user", role: "user", blocks: [{ kind: "text" }] }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(followUp.pane).toBeNull();
|
||||||
|
expect(applyWorkspaceFocus(artifact, followUp, { open: "artifact", auto: true })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never replaces or closes what the user opened by hand", () => {
|
||||||
|
expect(applyWorkspaceFocus(plain, screen, { open: "other", auto: false })).toBeNull();
|
||||||
|
expect(applyWorkspaceFocus(plain, artifact, { open: "screen", auto: false })).toBeNull();
|
||||||
|
expect(applyWorkspaceFocus(screen, plain, { open: "screen", auto: false })).toBeNull();
|
||||||
|
expect(applyWorkspaceFocus(plain, screen, { open: "screen", auto: false })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swaps in a newer presented file", () => {
|
||||||
|
const newer = deriveWorkspaceFocus(
|
||||||
|
snapshot({
|
||||||
|
run: { id: "run-1", status: "completed" },
|
||||||
|
messages: [
|
||||||
|
report,
|
||||||
|
{
|
||||||
|
...report,
|
||||||
|
id: "msg-2",
|
||||||
|
blocks: [{ ...report.blocks[0]!, artifactId: "art-2", name: "v2.md" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(applyWorkspaceFocus(artifact, newer, { open: "artifact", auto: false })).toEqual({
|
||||||
|
open: "artifact",
|
||||||
|
auto: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores focus changes that only concern a new ask", () => {
|
||||||
|
const asking = { ...plain, needsYouMessageId: "ask-1", key: "none|ask:ask-1" };
|
||||||
|
expect(applyWorkspaceFocus(plain, asking, closed)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("screen pane habit", () => {
|
||||||
|
it("learns to always show the screen after repeated manual opens", () => {
|
||||||
|
let habit = 0;
|
||||||
|
for (let index = 0; index < 3; index += 1) habit = recordScreenPaneChoice(habit, "opened");
|
||||||
|
expect(screenPaneMode(habit)).toBe("always");
|
||||||
|
expect(recordScreenPaneChoice(habit, "opened")).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("learns to stop opening it after repeated dismissals, and recovers on a manual open", () => {
|
||||||
|
let habit = recordScreenPaneChoice(2, "dismissed");
|
||||||
|
expect(screenPaneMode(habit)).toBe("auto");
|
||||||
|
habit = recordScreenPaneChoice(habit, "dismissed");
|
||||||
|
expect(screenPaneMode(habit)).toBe("never");
|
||||||
|
expect(screenPaneMode(recordScreenPaneChoice(habit, "opened"))).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses stored habits defensively", () => {
|
||||||
|
expect(parseScreenPaneHabit(null)).toBe(0);
|
||||||
|
expect(parseScreenPaneHabit("2")).toBe(2);
|
||||||
|
expect(parseScreenPaneHabit("99")).toBe(3);
|
||||||
|
expect(parseScreenPaneHabit("-9")).toBe(-2);
|
||||||
|
expect(parseScreenPaneHabit("x")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isPaneArtifactMimeType", () => {
|
||||||
|
it("accepts text and images, not PDFs", () => {
|
||||||
|
expect(isPaneArtifactMimeType("text/csv")).toBe(true);
|
||||||
|
expect(isPaneArtifactMimeType("image/png")).toBe(true);
|
||||||
|
expect(isPaneArtifactMimeType("application/pdf")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("liveScreenRunId", () => {
|
||||||
|
const pointer = {
|
||||||
|
type: "thread.computer",
|
||||||
|
runId: "run-1",
|
||||||
|
createdAt: "2026-01-01T00:00:10.000Z",
|
||||||
|
payload: {
|
||||||
|
kind: "pointer",
|
||||||
|
screen: { width: 1280, height: 800 },
|
||||||
|
steps: [{ action: "click", x: 640, y: 400 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const now = Date.parse("2026-01-01T00:00:20.000Z");
|
||||||
|
|
||||||
|
it("names the run that is pointing at the screen right now", () => {
|
||||||
|
expect(liveScreenRunId(pointer, now)).toBe("run-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores stale, malformed and unrelated events", () => {
|
||||||
|
expect(liveScreenRunId(pointer, now + 60_000)).toBeNull();
|
||||||
|
expect(liveScreenRunId({ ...pointer, payload: { kind: "pointer" } }, now)).toBeNull();
|
||||||
|
expect(liveScreenRunId({ ...pointer, runId: undefined }, now)).toBeNull();
|
||||||
|
expect(liveScreenRunId({ ...pointer, type: "thread.progress" }, now)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
230
packages/core/src/workspace-focus.ts
Normal file
230
packages/core/src/workspace-focus.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
import { ComputerPointerPayload, isAttachmentImageMimeType } from "@rakazo/contracts";
|
||||||
|
import { latestAnswerableAskMessageId } from "./answerable-ask.js";
|
||||||
|
|
||||||
|
/** What the pane beside a conversation shows while a bot works. */
|
||||||
|
export type WorkspacePane = "screen" | "artifact";
|
||||||
|
|
||||||
|
/** Learned per bot: whether the live screen opens on its own while the bot works. */
|
||||||
|
export type ScreenPaneMode = "auto" | "always" | "never";
|
||||||
|
|
||||||
|
export type WorkspaceArtifact = {
|
||||||
|
messageId: string;
|
||||||
|
artifactId: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceFocus = {
|
||||||
|
pane: WorkspacePane | null;
|
||||||
|
artifact: WorkspaceArtifact | null;
|
||||||
|
/** The bot asked the user to take over its screen. */
|
||||||
|
takeover: boolean;
|
||||||
|
/** The ask message the bot is waiting on. */
|
||||||
|
needsYouMessageId: string | null;
|
||||||
|
/** Changes only when the focus target changes, so clients react to edges, not every event. */
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FocusBlock = {
|
||||||
|
kind: string;
|
||||||
|
artifactId?: string;
|
||||||
|
name?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
present?: boolean;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FocusRun = { id: string; status: string };
|
||||||
|
|
||||||
|
export type WorkspaceFocusSnapshot = {
|
||||||
|
messages: readonly {
|
||||||
|
id: string;
|
||||||
|
role?: string;
|
||||||
|
runId?: string | null;
|
||||||
|
blocks: readonly FocusBlock[];
|
||||||
|
}[];
|
||||||
|
run?: FocusRun | null;
|
||||||
|
activeRuns?: readonly FocusRun[];
|
||||||
|
computer?: {
|
||||||
|
kind: string;
|
||||||
|
state: string;
|
||||||
|
screenAvailable: boolean;
|
||||||
|
takeoverRequested: boolean;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceFocusLive = {
|
||||||
|
/** Run that last announced pointer activity on the bot's screen. */
|
||||||
|
screenRunId?: string | null;
|
||||||
|
/** The bot pointed at its screen moments ago, even if that run already finished. */
|
||||||
|
screenActive?: boolean;
|
||||||
|
screenMode?: ScreenPaneMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WORKING_RUN_STATUSES = new Set(["leased", "running", "waiting_takeover"]);
|
||||||
|
const PANE_TEXT_MIME_TYPES = new Set([
|
||||||
|
"text/markdown",
|
||||||
|
"text/plain",
|
||||||
|
"text/csv",
|
||||||
|
"application/json",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Older pointer announcements describe actions that already finished. */
|
||||||
|
export const POINTER_EVENT_MAX_AGE_MS = 30_000;
|
||||||
|
|
||||||
|
/** The run behind a fresh pointer announcement on a bot's screen, or null for any other event. */
|
||||||
|
export function liveScreenRunId(
|
||||||
|
event: { type: string; runId?: string | null; createdAt?: string; payload?: unknown },
|
||||||
|
now = Date.now(),
|
||||||
|
): string | null {
|
||||||
|
if (event.type !== "thread.computer" || !event.runId) return null;
|
||||||
|
if (!ComputerPointerPayload.safeParse(event.payload).success) return null;
|
||||||
|
const at = event.createdAt ? Date.parse(event.createdAt) : Number.NaN;
|
||||||
|
if (Number.isFinite(at) && now - at > POINTER_EVENT_MAX_AGE_MS) return null;
|
||||||
|
return event.runId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Files the side pane can render without a plugin; others stay download cards. */
|
||||||
|
export function isPaneArtifactMimeType(mimeType: string): boolean {
|
||||||
|
return PANE_TEXT_MIME_TYPES.has(mimeType) || isAttachmentImageMimeType(mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workingRunIds(snapshot: WorkspaceFocusSnapshot): Set<string> {
|
||||||
|
const runs = snapshot.activeRuns ?? (snapshot.run ? [snapshot.run] : []);
|
||||||
|
return new Set(runs.filter((run) => WORKING_RUN_STATUSES.has(run.status)).map((run) => run.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The newest file a bot presented since the user last wrote; a new request starts fresh. */
|
||||||
|
function presentedArtifact(snapshot: WorkspaceFocusSnapshot): WorkspaceArtifact | null {
|
||||||
|
for (let index = snapshot.messages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const message = snapshot.messages[index];
|
||||||
|
if (!message) continue;
|
||||||
|
if (message.role === "user") return null;
|
||||||
|
for (let blockIndex = message.blocks.length - 1; blockIndex >= 0; blockIndex -= 1) {
|
||||||
|
const block = message.blocks[blockIndex];
|
||||||
|
if (
|
||||||
|
(block?.kind === "file" || block?.kind === "image") &&
|
||||||
|
block.present &&
|
||||||
|
block.artifactId &&
|
||||||
|
block.mimeType &&
|
||||||
|
isPaneArtifactMimeType(block.mimeType)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
messageId: message.id,
|
||||||
|
artifactId: block.artifactId,
|
||||||
|
name: block.name ?? "",
|
||||||
|
mimeType: block.mimeType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the pane a conversation should show from what the bot is actually doing: a
|
||||||
|
* takeover request or live work on its screen shows the screen, a file the bot presented
|
||||||
|
* as its latest result shows that file, and everything else stays a plain conversation.
|
||||||
|
*/
|
||||||
|
export function deriveWorkspaceFocus(
|
||||||
|
snapshot: WorkspaceFocusSnapshot | null,
|
||||||
|
live: WorkspaceFocusLive = {},
|
||||||
|
): WorkspaceFocus {
|
||||||
|
if (!snapshot) {
|
||||||
|
return { pane: null, artifact: null, takeover: false, needsYouMessageId: null, key: "none" };
|
||||||
|
}
|
||||||
|
const needsYouMessageId = latestAnswerableAskMessageId({
|
||||||
|
messages: snapshot.messages,
|
||||||
|
run: snapshot.run,
|
||||||
|
activeRuns: snapshot.activeRuns,
|
||||||
|
});
|
||||||
|
const computer = snapshot.computer;
|
||||||
|
const working = workingRunIds(snapshot);
|
||||||
|
const mode = live.screenMode ?? "auto";
|
||||||
|
const pointerWork =
|
||||||
|
Boolean(live.screenActive) || (live.screenRunId != null && working.has(live.screenRunId));
|
||||||
|
// A desktop host or a bot without a computer has no screen to embed.
|
||||||
|
const embeddable = Boolean(computer) && computer?.kind !== "desktop" && computer?.kind !== "none";
|
||||||
|
// A bot that points at its screen or asks for a takeover has a running screen, even when the
|
||||||
|
// client's computer status has not caught up with a boot that happened inside the run.
|
||||||
|
const takeover = embeddable && Boolean(computer?.takeoverRequested);
|
||||||
|
const screenWork =
|
||||||
|
embeddable &&
|
||||||
|
mode !== "never" &&
|
||||||
|
(pointerWork || (mode === "always" && working.size > 0 && Boolean(computer?.screenAvailable)));
|
||||||
|
const artifact = presentedArtifact(snapshot);
|
||||||
|
const pane: WorkspacePane | null =
|
||||||
|
takeover || screenWork ? "screen" : artifact ? "artifact" : null;
|
||||||
|
const target =
|
||||||
|
pane === "screen"
|
||||||
|
? `screen:${takeover ? "takeover" : "work"}`
|
||||||
|
: pane === "artifact" && artifact
|
||||||
|
? `artifact:${artifact.artifactId}`
|
||||||
|
: "none";
|
||||||
|
return {
|
||||||
|
pane,
|
||||||
|
artifact,
|
||||||
|
takeover,
|
||||||
|
needsYouMessageId,
|
||||||
|
key: `${target}|ask:${needsYouMessageId ?? "none"}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What occupies the pane right now; "other" is anything the user opened that is not a focus pane. */
|
||||||
|
export type PaneState = { open: WorkspacePane | "other" | null; auto: boolean };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the pane only on focus edges. It never replaces something the user opened by hand,
|
||||||
|
* and it only closes a screen it opened itself; a presented file stays until the user closes it
|
||||||
|
* or the bot presents another.
|
||||||
|
*/
|
||||||
|
export function applyWorkspaceFocus(
|
||||||
|
previous: WorkspaceFocus | null,
|
||||||
|
next: WorkspaceFocus,
|
||||||
|
pane: PaneState,
|
||||||
|
): PaneState | null {
|
||||||
|
if (!previous) return null;
|
||||||
|
const sameTarget =
|
||||||
|
previous.pane === next.pane &&
|
||||||
|
(next.pane !== "artifact" || previous.artifact?.artifactId === next.artifact?.artifactId);
|
||||||
|
if (sameTarget) return null;
|
||||||
|
if (next.pane) {
|
||||||
|
// A newer presented file replaces the one on view; the screen is already showing.
|
||||||
|
if (pane.open === next.pane) return next.pane === "artifact" ? pane : null;
|
||||||
|
if (pane.open && !pane.auto) return null;
|
||||||
|
return { open: next.pane, auto: true };
|
||||||
|
}
|
||||||
|
if (pane.auto && pane.open === "screen" && previous.pane === "screen") {
|
||||||
|
return { open: null, auto: false };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed habit score: positive when the user keeps opening the screen, negative when they close it. */
|
||||||
|
export type ScreenPaneHabit = number;
|
||||||
|
|
||||||
|
const ALWAYS_AT = 3;
|
||||||
|
const NEVER_AT = -2;
|
||||||
|
|
||||||
|
export function screenPaneMode(habit: ScreenPaneHabit): ScreenPaneMode {
|
||||||
|
if (habit >= ALWAYS_AT) return "always";
|
||||||
|
if (habit <= NEVER_AT) return "never";
|
||||||
|
return "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Learns from what the user does while a bot works: opening the screen by hand moves toward
|
||||||
|
* showing it every time, closing an automatically opened screen moves toward never opening it.
|
||||||
|
*/
|
||||||
|
export function recordScreenPaneChoice(
|
||||||
|
habit: ScreenPaneHabit,
|
||||||
|
choice: "opened" | "dismissed",
|
||||||
|
): ScreenPaneHabit {
|
||||||
|
if (choice === "opened") return Math.min(ALWAYS_AT, Math.max(habit, 0) + 1);
|
||||||
|
return Math.max(NEVER_AT, Math.min(habit, 0) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseScreenPaneHabit(value: string | null | undefined): ScreenPaneHabit {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isInteger(parsed) ? Math.max(NEVER_AT, Math.min(ALWAYS_AT, parsed)) : 0;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user