P3 exit: Computer panel with all 8 apps, themed and deep-linkable
- DevicePanel in the 448px SlidePanel: ?app= routes (home + 8 sub-apps via dynamic imports), ?device=full|tablet|phone size toggle, per-agent accent-derived wallpaper theme + feTurbulence grain, glassy dock + grid home screen, Computer button in the chat header - Apps: Files (3 drives, real listings), Skills (installed + add from library), Routines (list/refresh/empty state), Claw Chat (threads + sensitive badge + detail), Settings (push/pop nav: edit profile PATCHes the system prompt, Other-Claws access toggle PUTs the policy, confirmed destructive delete), Slack (§7.3 pre-connect gate), Add Apps (live /api/apps directory + search), Browser (chrome + spec'd empty state) - /skills Skill Library page + nav entry; curated /api/apps directory endpoint; e2e seed gains a catalog skill - P3 exit E2E (6 journeys): themed home screen + device toggle in URL, agent-written file appears in Files, agent-scheduled routine appears in Routines, system-prompt edit persists across reload, deep-link cold-load of ?app=settings&device=full, every app reachable, library installs 132 Rust + 63 frontend tests + 20 Playwright journeys. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
67f918439c
commit
1fd2c287f1
@@ -71,6 +71,17 @@ pub async fn seed(pool: &PgPool) -> Result<(), String> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("seed credits: {e}"))?;
|
.map_err(|e| format!("seed credits: {e}"))?;
|
||||||
|
|
||||||
|
tc_db::repo::skills::create(
|
||||||
|
pool,
|
||||||
|
None,
|
||||||
|
"Daily briefing",
|
||||||
|
"TeamClaw",
|
||||||
|
"Summarize the day's priorities each morning.",
|
||||||
|
"Each morning, compile a short briefing of priorities and blockers.",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("seed skill: {e}"))?;
|
||||||
|
|
||||||
println!("teamclaw-server: e2e seed applied ({E2E_OWNER_EMAIL})");
|
println!("teamclaw-server: e2e seed applied ({E2E_OWNER_EMAIL})");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/skills/uninstall", post(routes::skills::uninstall))
|
.route("/api/skills/uninstall", post(routes::skills::uninstall))
|
||||||
.route("/api/openclaw/files", get(routes::files::openclaw_files))
|
.route("/api/openclaw/files", get(routes::files::openclaw_files))
|
||||||
.route("/api/shared-drive/files", get(routes::files::shared_files))
|
.route("/api/shared-drive/files", get(routes::files::shared_files))
|
||||||
|
.route("/api/apps", get(routes::apps::directory))
|
||||||
.route("/api/approvals", get(routes::approvals::list))
|
.route("/api/approvals", get(routes::approvals::list))
|
||||||
.route("/api/approvals/{id}", get(routes::approvals::get))
|
.route("/api/approvals/{id}", get(routes::approvals::get))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::Authed;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DirectoryApp {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub category: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/apps — the connectable app directory (§8.2). Connecting (OAuth
|
||||||
|
/// and custom auth) arrives in P4; the directory itself is product data.
|
||||||
|
pub async fn directory(Authed(_user): Authed) -> Json<Vec<DirectoryApp>> {
|
||||||
|
Json(vec![
|
||||||
|
DirectoryApp {
|
||||||
|
id: "gmail",
|
||||||
|
name: "Gmail",
|
||||||
|
description: "Read and draft email on your behalf.",
|
||||||
|
category: "Email",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "google-calendar",
|
||||||
|
name: "Google Calendar",
|
||||||
|
description: "Check availability and schedule events.",
|
||||||
|
category: "Calendar",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "google-drive",
|
||||||
|
name: "Google Drive",
|
||||||
|
description: "Search and read team documents.",
|
||||||
|
category: "Storage",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "notion",
|
||||||
|
name: "Notion",
|
||||||
|
description: "Read and update pages and databases.",
|
||||||
|
category: "Docs",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "linear",
|
||||||
|
name: "Linear",
|
||||||
|
description: "Track and file engineering issues.",
|
||||||
|
category: "Project management",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "github",
|
||||||
|
name: "GitHub",
|
||||||
|
description: "Review repos, issues, and pull requests.",
|
||||||
|
category: "Engineering",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "figma",
|
||||||
|
name: "Figma",
|
||||||
|
description: "Inspect design files and comments.",
|
||||||
|
category: "Design",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "zoom",
|
||||||
|
name: "Zoom",
|
||||||
|
description: "Schedule and summarize meetings.",
|
||||||
|
category: "Meetings",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "stripe",
|
||||||
|
name: "Stripe",
|
||||||
|
description: "Look up customers, invoices, and payments.",
|
||||||
|
category: "Finance",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "hubspot",
|
||||||
|
name: "HubSpot",
|
||||||
|
description: "Manage contacts and deals.",
|
||||||
|
category: "CRM",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "google-sheets",
|
||||||
|
name: "Google Sheets",
|
||||||
|
description: "Read and update spreadsheets.",
|
||||||
|
category: "Docs",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "slack",
|
||||||
|
name: "Slack",
|
||||||
|
description: "Respond on @mention in your channels.",
|
||||||
|
category: "Chat",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "telegram",
|
||||||
|
name: "Telegram",
|
||||||
|
description: "Send and receive messages.",
|
||||||
|
category: "Chat",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "webhook",
|
||||||
|
name: "HTTP / Webhook",
|
||||||
|
description: "Call any HTTP endpoint.",
|
||||||
|
category: "Developer",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "postgres",
|
||||||
|
name: "Postgres",
|
||||||
|
description: "Query your databases.",
|
||||||
|
category: "Data",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "aws",
|
||||||
|
name: "AWS",
|
||||||
|
description: "Inspect cloud resources.",
|
||||||
|
category: "Infrastructure",
|
||||||
|
},
|
||||||
|
DirectoryApp {
|
||||||
|
id: "sendgrid",
|
||||||
|
name: "SendGrid",
|
||||||
|
description: "Deliver transactional email.",
|
||||||
|
category: "Email",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod approvals;
|
pub mod approvals;
|
||||||
|
pub mod apps;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod claw_chat;
|
pub mod claw_chat;
|
||||||
pub mod claws;
|
pub mod claws;
|
||||||
|
|||||||
@@ -140,4 +140,3 @@ impl Tool for ChatInbox {
|
|||||||
Ok(json!({ "messages": inbox }))
|
Ok(json!({ "messages": inbox }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
@@ -37,3 +37,29 @@ events = [
|
|||||||
events = [
|
events = [
|
||||||
{ type = "text", text = " The email step is finished." },
|
{ type = "text", text = " The email step is finished." },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[scenario]]
|
||||||
|
marker = "[[scenario:save-report]]"
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "tool_use", name = "files.write", input = { path = "reports/q2.md", content = "Q2 revenue is up 14%." } },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "text", text = "Saved the report to your documents drive." },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[scenario]]
|
||||||
|
marker = "[[scenario:schedule-digest]]"
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "tool_use", name = "routine.schedule", input = { name = "Morning digest", cron = "0 9 * * *", message = "Compile the morning digest." } },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "text", text = "Scheduled the morning digest for 9am daily." },
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/lib/api/http";
|
||||||
|
|
||||||
|
const SkillSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
title: z.string(),
|
||||||
|
author: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
installs: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Skill Library (§8.1): catalog + workspace skills, two-column cards.
|
||||||
|
export default async function SkillsPage() {
|
||||||
|
const skills = await apiFetch(z.array(SkillSchema), "/api/skills");
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl px-8 py-12">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Skill Library</h1>
|
||||||
|
<p className="pt-1 text-sm text-muted-foreground">
|
||||||
|
Browse skills published by your team and the catalog. Install them on
|
||||||
|
a claw from its Computer → Skills app.
|
||||||
|
</p>
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<p className="pt-8 text-sm text-muted-foreground">
|
||||||
|
No skills published yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid grid-cols-1 gap-3 pt-6 sm:grid-cols-2">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<li
|
||||||
|
key={skill.id}
|
||||||
|
className="rounded-(--radius) border border-border bg-surface-warm p-4 shadow-(--shadow-card)"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{skill.title}</p>
|
||||||
|
<p className="text-xxs text-muted-foreground">
|
||||||
|
by {skill.author}
|
||||||
|
</p>
|
||||||
|
<p className="pt-2 text-xs text-muted-foreground">
|
||||||
|
{skill.description}
|
||||||
|
</p>
|
||||||
|
<p className="pt-2 text-xxs text-muted-foreground">
|
||||||
|
{skill.installs}{" "}
|
||||||
|
{skill.installs === 1 ? "install" : "installs"}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,13 @@ export function ChatHeader({
|
|||||||
{agent.job_title}
|
{agent.job_title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setParams({ app: "home" })}
|
||||||
|
className="rounded-(--radius-button) border border-border px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Computer
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={sessions}
|
aria-pressed={sessions}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { encodeSessionKeyParam } from "@/lib/url/session-key";
|
|||||||
import { panelParsers } from "@/lib/url/panel-params";
|
import { panelParsers } from "@/lib/url/panel-params";
|
||||||
import { SlidePanel } from "@/components/ui/SlidePanel";
|
import { SlidePanel } from "@/components/ui/SlidePanel";
|
||||||
import { SessionsColumn } from "@/components/sessions/SessionsColumn";
|
import { SessionsColumn } from "@/components/sessions/SessionsColumn";
|
||||||
|
import { DevicePanel } from "@/components/computer/DevicePanel";
|
||||||
import { ChatHeader } from "./ChatHeader";
|
import { ChatHeader } from "./ChatHeader";
|
||||||
import { Composer } from "./Composer";
|
import { Composer } from "./Composer";
|
||||||
import { MessageList } from "./MessageList";
|
import { MessageList } from "./MessageList";
|
||||||
@@ -84,6 +85,7 @@ export function ChatWorkspace({
|
|||||||
onDraftChange={setDraft}
|
onDraftChange={setDraft}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
<DevicePanel agent={agent} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
import { resolveAppView, type AppId } from "@/lib/url/panel-params";
|
||||||
|
|
||||||
|
// One dynamic import per app: the chat route's initial bundle stays lean.
|
||||||
|
const FilesApp = dynamic(() => import("./apps/FilesApp"));
|
||||||
|
const SkillsApp = dynamic(() => import("./apps/SkillsApp"));
|
||||||
|
const RoutinesApp = dynamic(() => import("./apps/RoutinesApp"));
|
||||||
|
const ClawChatApp = dynamic(() => import("./apps/ClawChatApp"));
|
||||||
|
const SettingsApp = dynamic(() => import("./apps/SettingsApp"));
|
||||||
|
const SlackApp = dynamic(() => import("./apps/SlackApp"));
|
||||||
|
const AddAppsApp = dynamic(() => import("./apps/AddAppsApp"));
|
||||||
|
const BrowserApp = dynamic(() => import("./apps/BrowserApp"));
|
||||||
|
|
||||||
|
export function appTitle(app: AppId): string {
|
||||||
|
switch (resolveAppView(app)) {
|
||||||
|
case "browser":
|
||||||
|
return "Browser";
|
||||||
|
case "slack":
|
||||||
|
return "Slack";
|
||||||
|
case "chat":
|
||||||
|
return "Claw Chat";
|
||||||
|
case "skills":
|
||||||
|
return "Skills";
|
||||||
|
case "files":
|
||||||
|
return "Files";
|
||||||
|
case "scheduled":
|
||||||
|
return "Routines";
|
||||||
|
case "settings":
|
||||||
|
return "Settings";
|
||||||
|
case "apps":
|
||||||
|
return "Add Apps";
|
||||||
|
default:
|
||||||
|
return "Computer";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps ?app= to its module (§7.x); the only place that mapping lives. */
|
||||||
|
export function AppRouter({ app, agent }: { app: AppId; agent: Agent }) {
|
||||||
|
switch (resolveAppView(app)) {
|
||||||
|
case "browser":
|
||||||
|
return <BrowserApp agent={agent} />;
|
||||||
|
case "slack":
|
||||||
|
return <SlackApp agent={agent} />;
|
||||||
|
case "chat":
|
||||||
|
return <ClawChatApp agent={agent} />;
|
||||||
|
case "skills":
|
||||||
|
return <SkillsApp agent={agent} />;
|
||||||
|
case "files":
|
||||||
|
return <FilesApp agent={agent} />;
|
||||||
|
case "scheduled":
|
||||||
|
return <RoutinesApp agent={agent} />;
|
||||||
|
case "settings":
|
||||||
|
return <SettingsApp agent={agent} />;
|
||||||
|
case "apps":
|
||||||
|
return <AddAppsApp agent={agent} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
|
||||||
|
/** Derives the per-agent panel theme (§7: "themed per-agent"). Art assets
|
||||||
|
* are excluded by spec, so wallpapers are accent-derived gradients. */
|
||||||
|
export function clawThemeStyle(agent: Agent): CSSProperties {
|
||||||
|
const accent = agent.accent || "#f96565";
|
||||||
|
return {
|
||||||
|
"--agent-accent": accent,
|
||||||
|
"--agent-wallpaper": `linear-gradient(160deg, color-mix(in srgb, ${accent} 24%, #0a0a0a) 0%, #101010 55%, color-mix(in srgb, ${accent} 10%, #0a0a0a) 100%)`,
|
||||||
|
} as CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subtle fractal-noise grain layered over the wallpaper (§2 texture). */
|
||||||
|
export function GrainOverlay() {
|
||||||
|
const noise =
|
||||||
|
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E\")";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.06] mix-blend-overlay"
|
||||||
|
style={{ backgroundImage: noise }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WallpaperSurface({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex h-full flex-col"
|
||||||
|
style={{ background: "var(--agent-wallpaper)" }}
|
||||||
|
>
|
||||||
|
<GrainOverlay />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQueryStates } from "nuqs";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
import { panelParsers, type AppId } from "@/lib/url/panel-params";
|
||||||
|
import { SlidePanel } from "@/components/ui/SlidePanel";
|
||||||
|
import { clawThemeStyle, WallpaperSurface } from "./ClawTheme";
|
||||||
|
import { DeviceSizeToggle } from "./DeviceSizeToggle";
|
||||||
|
import { HomeScreen } from "./HomeScreen";
|
||||||
|
import { AppRouter, appTitle } from "./AppRouter";
|
||||||
|
|
||||||
|
const WIDTHS = { full: 720, tablet: 448, phone: 340 } as const;
|
||||||
|
|
||||||
|
/** The right-hand "Computer" slide-out (§7): per-agent themed, sized by
|
||||||
|
* ?device=, hosting every sub-app behind ?app=. All state deep-links. */
|
||||||
|
export function DevicePanel({ agent }: { agent: Agent }) {
|
||||||
|
const [{ app, device }, setParams] = useQueryStates(panelParsers, {
|
||||||
|
shallow: true,
|
||||||
|
});
|
||||||
|
const open = app !== null;
|
||||||
|
|
||||||
|
function openApp(next: AppId) {
|
||||||
|
setParams({ app: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlidePanel open={open} width={WIDTHS[device]} label="Computer">
|
||||||
|
<div
|
||||||
|
data-testid="device-panel-theme"
|
||||||
|
className="h-full border-l border-border"
|
||||||
|
style={clawThemeStyle(agent)}
|
||||||
|
>
|
||||||
|
<WallpaperSurface>
|
||||||
|
<header className="relative flex h-11 items-center gap-2 border-b border-white/10 px-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Computer home"
|
||||||
|
onClick={() => openApp("home")}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-foreground/90 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="size-2 rounded-full"
|
||||||
|
style={{ backgroundColor: "var(--agent-accent)" }}
|
||||||
|
/>
|
||||||
|
{app === "home" || app === null
|
||||||
|
? `${agent.name}'s Computer`
|
||||||
|
: appTitle(app)}
|
||||||
|
</button>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<DeviceSizeToggle
|
||||||
|
value={device}
|
||||||
|
onChange={(size) => setParams({ device: size })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close computer"
|
||||||
|
onClick={() => setParams({ app: null })}
|
||||||
|
className="rounded-(--radius) px-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{app === "home" || app === null ? (
|
||||||
|
<HomeScreen agentName={agent.name} onOpen={openApp} />
|
||||||
|
) : (
|
||||||
|
<div className="relative flex-1 overflow-y-auto bg-background/80">
|
||||||
|
<AppRouter app={app} agent={agent} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</WallpaperSurface>
|
||||||
|
</div>
|
||||||
|
</SlidePanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { DeviceSizeToggle } from "./DeviceSizeToggle";
|
||||||
|
|
||||||
|
describe("DeviceSizeToggle", () => {
|
||||||
|
it("renders the three §7 sizes with the current one checked", () => {
|
||||||
|
render(<DeviceSizeToggle value="tablet" onChange={vi.fn()} />);
|
||||||
|
const group = screen.getByRole("radiogroup", { name: "Panel size" });
|
||||||
|
expect(group).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("radio", { name: "Tablet" })).toHaveAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("radio", { name: "Full" })).toHaveAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports size changes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<DeviceSizeToggle value="tablet" onChange={onChange} />);
|
||||||
|
await user.click(screen.getByRole("radio", { name: "Phone" }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith("phone");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { DeviceSize } from "@/lib/url/panel-params";
|
||||||
|
|
||||||
|
const SIZES: { id: DeviceSize; label: string }[] = [
|
||||||
|
{ id: "full", label: "Full" },
|
||||||
|
{ id: "tablet", label: "Tablet" },
|
||||||
|
{ id: "phone", label: "Phone" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The §7 size toggle: Full / Tablet / Phone. */
|
||||||
|
export function DeviceSizeToggle({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: DeviceSize;
|
||||||
|
onChange: (size: DeviceSize) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div role="radiogroup" aria-label="Panel size" className="flex gap-0.5">
|
||||||
|
{SIZES.map((size) => (
|
||||||
|
<button
|
||||||
|
key={size.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={value === size.id}
|
||||||
|
onClick={() => onChange(size.id)}
|
||||||
|
className={`rounded-(--radius) px-2 py-0.5 text-xxs ${
|
||||||
|
value === size.id
|
||||||
|
? "bg-surface-warm-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{size.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { AppId } from "@/lib/url/panel-params";
|
||||||
|
|
||||||
|
interface HomeScreenProps {
|
||||||
|
agentName: string;
|
||||||
|
onOpen: (app: AppId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRID: { app: AppId; label: string; glyph: string }[] = [
|
||||||
|
{ app: "browser", label: "Browser", glyph: "🌐" },
|
||||||
|
{ app: "slack", label: "Slack", glyph: "💬" },
|
||||||
|
{ app: "chat", label: "Claw Chat", glyph: "🦀" },
|
||||||
|
{ app: "apps", label: "Add", glyph: "+" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DOCK: { app: AppId; label: string; glyph: string }[] = [
|
||||||
|
{ app: "skills", label: "Skills", glyph: "⚡" },
|
||||||
|
{ app: "files", label: "Files", glyph: "📁" },
|
||||||
|
{ app: "routines", label: "Routines", glyph: "🔔" },
|
||||||
|
{ app: "settings", label: "Settings", glyph: "⚙" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Tile({
|
||||||
|
label,
|
||||||
|
glyph,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
glyph: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex flex-col items-center gap-1 rounded-(--radius) px-3 py-2 text-xxs text-foreground/90 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<span aria-hidden className="text-xl">
|
||||||
|
{glyph}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The §7.0 home screen: app grid over the wallpaper, glassy dock below. */
|
||||||
|
export function HomeScreen({ agentName, onOpen }: HomeScreenProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-1 flex-col justify-between p-4">
|
||||||
|
<div className="grid grid-cols-4 gap-2" aria-label={`${agentName}'s apps`}>
|
||||||
|
{GRID.map((tile) => (
|
||||||
|
<Tile
|
||||||
|
key={tile.app}
|
||||||
|
label={tile.label}
|
||||||
|
glyph={tile.glyph}
|
||||||
|
onClick={() => onOpen(tile.app)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-label="Dock"
|
||||||
|
className="mx-auto flex gap-2 rounded-(--radius) border border-white/10 bg-white/5 px-3 py-1.5 shadow-(--shadow-dock-tile) backdrop-blur"
|
||||||
|
>
|
||||||
|
{DOCK.map((tile) => (
|
||||||
|
<Tile
|
||||||
|
key={tile.app}
|
||||||
|
label={tile.label}
|
||||||
|
glyph={tile.glyph}
|
||||||
|
onClick={() => onOpen(tile.app)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
import { useFetchJson } from "@/lib/api/use-fetch";
|
||||||
|
|
||||||
|
interface DirectoryApp {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The connect directory (§7.8). The directory is live; per-app OAuth
|
||||||
|
* connect flows arrive in P4. */
|
||||||
|
export default function AddAppsApp({ agent }: { agent: Agent }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const { data, loading } = useFetchJson<DirectoryApp[]>("/api/apps");
|
||||||
|
const apps = (data ?? []).filter((app) =>
|
||||||
|
`${app.name} ${app.category}`.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
aria-label="Search apps"
|
||||||
|
placeholder="Search apps"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="mb-2 w-full rounded-(--radius) border border-input bg-subtle px-2 py-1.5 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<p className="px-2 text-xs text-muted-foreground">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<ul aria-label="App directory">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<li key={app.id} className="flex items-start gap-2 px-2 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm">{app.name}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{app.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-(--radius-button) border border-border px-2 py-0.5 text-xxs text-muted-foreground">
|
||||||
|
{app.category}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="px-2 pt-3 text-xxs text-muted-foreground">
|
||||||
|
Connecting apps for {agent.name} (OAuth, keys, MCP) arrives with
|
||||||
|
integrations (P4).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
|
||||||
|
/** The live agent browser (§7.1). The CDP-backed viewport lands with the
|
||||||
|
* sandboxed browser tool; until an active session exists this is the
|
||||||
|
* spec's empty state with full chrome. */
|
||||||
|
export default function BrowserApp({ agent }: { agent: Agent }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Back"
|
||||||
|
disabled
|
||||||
|
className="rounded-(--radius) px-1.5 text-sm text-muted-foreground disabled:opacity-40"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Forward"
|
||||||
|
disabled
|
||||||
|
className="rounded-(--radius) px-1.5 text-sm text-muted-foreground disabled:opacity-40"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
aria-label="Address"
|
||||||
|
className="flex-1 rounded-(--radius-button) border border-input bg-subtle px-3 py-1 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
newtab
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Reload"
|
||||||
|
disabled
|
||||||
|
className="rounded-(--radius) px-1.5 text-sm text-muted-foreground disabled:opacity-40"
|
||||||
|
>
|
||||||
|
⟳
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
|
||||||
|
<span aria-hidden className="text-2xl">
|
||||||
|
🌐
|
||||||
|
</span>
|
||||||
|
<p className="text-sm font-medium">No active browsing session</p>
|
||||||
|
<p className="max-w-60 text-xs text-muted-foreground">
|
||||||
|
When {agent.name} browses the web, the live session appears here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
import { useFetchJson } from "@/lib/api/use-fetch";
|
||||||
|
import { relativeTime } from "@/lib/format/relative-time";
|
||||||
|
|
||||||
|
interface Thread {
|
||||||
|
id: string;
|
||||||
|
subject: string;
|
||||||
|
sensitivity: string;
|
||||||
|
last_preview: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadMessage {
|
||||||
|
id: string;
|
||||||
|
from_agent: string;
|
||||||
|
content: { text?: string };
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inter-agent inbox (§7.2): thread list → conversation detail. */
|
||||||
|
export default function ClawChatApp({ agent }: { agent: Agent }) {
|
||||||
|
const [openThread, setOpenThread] = useState<Thread | null>(null);
|
||||||
|
const threads = useFetchJson<Thread[]>(
|
||||||
|
`/api/claw-chat/threads?clawId=${agent.id}`,
|
||||||
|
);
|
||||||
|
const messages = useFetchJson<ThreadMessage[]>(
|
||||||
|
openThread
|
||||||
|
? `/api/claw-chat/messages?clawId=${agent.id}&threadId=${openThread.id}`
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (openThread) {
|
||||||
|
return (
|
||||||
|
<div className="p-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenThread(null)}
|
||||||
|
className="pb-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
‹ {openThread.subject}
|
||||||
|
</button>
|
||||||
|
<ul aria-label="Thread messages" className="flex flex-col gap-2">
|
||||||
|
{(messages.data ?? []).map((message) => (
|
||||||
|
<li
|
||||||
|
key={message.id}
|
||||||
|
className={`max-w-[85%] rounded-(--radius) px-2 py-1.5 text-xs ${
|
||||||
|
message.from_agent === agent.id
|
||||||
|
? "self-end bg-surface-warm-muted"
|
||||||
|
: "bg-subtle"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.content.text}
|
||||||
|
<span className="block pt-0.5 text-xxs text-muted-foreground">
|
||||||
|
{relativeTime(message.created_at)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = threads.data ?? [];
|
||||||
|
return (
|
||||||
|
<div className="p-3">
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<p className="px-2 pt-6 text-center text-xs text-muted-foreground">
|
||||||
|
No claw-to-claw conversations yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul aria-label="Threads">
|
||||||
|
{list.map((thread) => (
|
||||||
|
<li key={thread.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenThread(thread)}
|
||||||
|
className="w-full rounded-(--radius) px-2 py-2 text-left hover:bg-surface-warm"
|
||||||
|
>
|
||||||
|
<p className="flex items-center gap-2 text-sm">
|
||||||
|
{thread.subject}
|
||||||
|
{thread.sensitivity === "sensitive" && (
|
||||||
|
<span className="rounded-(--radius-button) bg-destructive/40 px-1.5 text-xxs">
|
||||||
|
sensitive
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-xxs text-muted-foreground">
|
||||||
|
{relativeTime(thread.created_at)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{thread.last_preview ?? ""}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
import { useFetchJson } from "@/lib/api/use-fetch";
|
||||||
|
|
||||||
|
interface FileNode {
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRIVES = [
|
||||||
|
{ id: "documents", label: "My Documents", icon: "📁" },
|
||||||
|
{ id: "received", label: "Received Files", icon: "📁" },
|
||||||
|
{ id: "shared", label: "Shared ClawDrive (team)", icon: "📁" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type DriveId = (typeof DRIVES)[number]["id"];
|
||||||
|
|
||||||
|
function driveUrl(agentId: string, drive: DriveId): string {
|
||||||
|
return drive === "shared"
|
||||||
|
? `/api/shared-drive/files?clawId=${agentId}`
|
||||||
|
: `/api/openclaw/files?clawId=${agentId}&drive=${drive}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Files app (§7.4): three drives, drill into each. */
|
||||||
|
export default function FilesApp({ agent }: { agent: Agent }) {
|
||||||
|
const [drive, setDrive] = useState<DriveId | null>(null);
|
||||||
|
const { data, loading } = useFetchJson<FileNode[]>(
|
||||||
|
drive ? driveUrl(agent.id, drive) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (drive === null) {
|
||||||
|
return (
|
||||||
|
<ul aria-label="Drives" className="p-3">
|
||||||
|
{DRIVES.map((entry) => (
|
||||||
|
<li key={entry.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDrive(entry.id)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-(--radius) px-2 py-2 text-sm hover:bg-surface-warm"
|
||||||
|
>
|
||||||
|
<span aria-hidden>{entry.icon}</span>
|
||||||
|
{entry.label}
|
||||||
|
<span aria-hidden className="ml-auto text-muted-foreground">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = DRIVES.find((d) => d.id === drive)!.label;
|
||||||
|
return (
|
||||||
|
<div className="p-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDrive(null)}
|
||||||
|
className="pb-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
‹ {label}
|
||||||
|
</button>
|
||||||
|
{loading ? (
|
||||||
|
<p className="px-2 text-xs text-muted-foreground">Loading…</p>
|
||||||
|
) : data && data.length > 0 ? (
|
||||||
|
<ul aria-label={`${label} files`}>
|
||||||
|
{data.map((file) => (
|
||||||
|
<li
|
||||||
|
key={file.path}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 font-mono text-xs"
|
||||||
|
>
|
||||||
|
<span aria-hidden>📄</span>
|
||||||
|
<span className="truncate">{file.path}</span>
|
||||||
|
<span className="ml-auto text-muted-foreground">
|
||||||
|
{file.size} B
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="px-2 text-xs text-muted-foreground">
|
||||||
|
Nothing here yet — ask {agent.name} to save something.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
import { useFetchJson } from "@/lib/api/use-fetch";
|
||||||
|
import { relativeTime } from "@/lib/format/relative-time";
|
||||||
|
|
||||||
|
interface Routine {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
schedule_cron: string;
|
||||||
|
status: string;
|
||||||
|
next_run_at: string | null;
|
||||||
|
last_run_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Routines app (§7.6): agent-created scheduled tasks. */
|
||||||
|
export default function RoutinesApp({ agent }: { agent: Agent }) {
|
||||||
|
const { data, loading, refresh } = useFetchJson<Routine[]>(
|
||||||
|
`/api/routines?clawId=${agent.id}`,
|
||||||
|
);
|
||||||
|
const routines = data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col p-3">
|
||||||
|
<div className="flex justify-end pb-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Refresh routines"
|
||||||
|
onClick={refresh}
|
||||||
|
className="rounded-(--radius) px-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
⟳
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<p className="px-2 text-xs text-muted-foreground">Loading…</p>
|
||||||
|
) : routines.length === 0 ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
|
||||||
|
<span aria-hidden className="text-2xl">
|
||||||
|
🔔
|
||||||
|
</span>
|
||||||
|
<p className="text-sm font-medium">No routines yet</p>
|
||||||
|
<p className="max-w-60 text-xs text-muted-foreground">
|
||||||
|
Ask your claw to set up a routine — daily digest, newsletter,
|
||||||
|
calendar block.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul aria-label="Routines" className="flex-1">
|
||||||
|
{routines.map((routine) => (
|
||||||
|
<li key={routine.id} className="px-2 py-2">
|
||||||
|
<p className="text-sm">{routine.name}</p>
|
||||||
|
<p className="font-mono text-xxs text-muted-foreground">
|
||||||
|
{routine.schedule_cron} · {routine.status}
|
||||||
|
{routine.last_run_at
|
||||||
|
? ` · last ran ${relativeTime(routine.last_run_at)}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { useFetchJson } from "@/lib/api/use-fetch";
|
||||||
|
|
||||||
|
interface SettingsPayload {
|
||||||
|
agent: Agent;
|
||||||
|
access_policy: {
|
||||||
|
humans: { mode: string };
|
||||||
|
agents: { mode: string };
|
||||||
|
};
|
||||||
|
managed_by_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Screen = "main" | "edit";
|
||||||
|
|
||||||
|
/** The Settings app (§7.7): identity, access toggles, edit profile
|
||||||
|
* (the Job Description IS the system prompt), destructive delete. */
|
||||||
|
export default function SettingsApp({ agent }: { agent: Agent }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [screen, setScreen] = useState<Screen>("main");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
|
const settings = useFetchJson<SettingsPayload>(
|
||||||
|
`/api/claws/settings/full?clawId=${agent.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function saveProfile(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
const data = new FormData(event.currentTarget);
|
||||||
|
await fetch(`/api/claws/${agent.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: data.get("name"),
|
||||||
|
job_title: data.get("job_title"),
|
||||||
|
system_prompt: data.get("system_prompt"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
setScreen("main");
|
||||||
|
settings.refresh();
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAgentsMode(mode: "any" | "specific") {
|
||||||
|
const current = settings.data?.access_policy;
|
||||||
|
await fetch(`/api/claws/${agent.id}/access`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
humans: current?.humans ?? { mode: "entire_team" },
|
||||||
|
agents: mode === "any" ? { mode: "any" } : { mode: "specific", ids: [] },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
settings.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClaw() {
|
||||||
|
await fetch(`/api/claws/${agent.id}`, { method: "DELETE" });
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const live = settings.data?.agent ?? agent;
|
||||||
|
|
||||||
|
if (screen === "edit") {
|
||||||
|
return (
|
||||||
|
<form onSubmit={saveProfile} className="flex flex-col gap-3 p-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScreen("main")}
|
||||||
|
className="self-start text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
‹ Edit profile
|
||||||
|
</button>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
defaultValue={live.name}
|
||||||
|
className="rounded-(--radius) border border-input bg-subtle px-2 py-1.5 text-sm text-foreground outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
Job Title
|
||||||
|
<input
|
||||||
|
name="job_title"
|
||||||
|
defaultValue={live.job_title}
|
||||||
|
className="rounded-(--radius) border border-input bg-subtle px-2 py-1.5 text-sm text-foreground outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
Job description
|
||||||
|
<textarea
|
||||||
|
name="system_prompt"
|
||||||
|
rows={5}
|
||||||
|
defaultValue={live.system_prompt}
|
||||||
|
placeholder="Describe how this claw should think..."
|
||||||
|
className="rounded-(--radius) border border-input bg-subtle px-2 py-1.5 text-sm text-foreground outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-xxs">Becomes part of its system prompt.</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-(--radius-button) bg-accent px-4 py-1.5 text-xs font-medium text-background hover:bg-coral-light disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save profile"}
|
||||||
|
</button>
|
||||||
|
{confirmingDelete ? (
|
||||||
|
<div className="rounded-(--radius) border border-destructive p-2 text-xs">
|
||||||
|
<p>Delete {live.name} permanently? This cannot be undone.</p>
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmingDelete(false)}
|
||||||
|
className="rounded-(--radius-button) border border-border px-3 py-1"
|
||||||
|
>
|
||||||
|
Keep
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={deleteClaw}
|
||||||
|
className="rounded-(--radius-button) bg-destructive px-3 py-1 text-foreground"
|
||||||
|
>
|
||||||
|
Delete claw
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmingDelete(true)}
|
||||||
|
className="self-start text-xs text-accent hover:underline"
|
||||||
|
>
|
||||||
|
🗑 Delete claw
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentsMode = settings.data?.access_policy.agents.mode ?? "any";
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
<div className="flex flex-col items-center gap-1 py-2">
|
||||||
|
<Avatar name={live.name} accent={live.accent} size="lg" />
|
||||||
|
<p className="text-sm font-medium">{live.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{live.job_title}</p>
|
||||||
|
</div>
|
||||||
|
<dl className="rounded-(--radius) border border-border bg-subtle text-xs">
|
||||||
|
<div className="flex justify-between border-b border-border px-2 py-2">
|
||||||
|
<dt className="text-muted-foreground">Managed by</dt>
|
||||||
|
<dd>{settings.data?.managed_by_name ?? "…"}</dd>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScreen("edit")}
|
||||||
|
className="flex w-full justify-between px-2 py-2 hover:bg-surface-warm"
|
||||||
|
>
|
||||||
|
Edit profile <span aria-hidden>›</span>
|
||||||
|
</button>
|
||||||
|
</dl>
|
||||||
|
<p className="pt-1 text-xxs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Who else has access
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Other Claws"
|
||||||
|
className="rounded-(--radius) border border-border bg-subtle p-2 text-xs"
|
||||||
|
>
|
||||||
|
<p className="pb-1">Other Claws</p>
|
||||||
|
{(["any", "specific"] as const).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={agentsMode === mode}
|
||||||
|
onClick={() => setAgentsMode(mode)}
|
||||||
|
className="flex w-full items-center gap-2 px-1 py-1 text-left hover:bg-surface-warm"
|
||||||
|
>
|
||||||
|
<span aria-hidden>{agentsMode === mode ? "◉" : "○"}</span>
|
||||||
|
{mode === "any"
|
||||||
|
? "Any Claw on the team"
|
||||||
|
: "Specific claws — pick claws"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
import { useFetchJson } from "@/lib/api/use-fetch";
|
||||||
|
|
||||||
|
interface Skill {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
installs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The per-agent Skills app (§7.5): installed subset + add from library. */
|
||||||
|
export default function SkillsApp({ agent }: { agent: Agent }) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const installed = useFetchJson<Skill[]>(`/api/skills?clawId=${agent.id}`);
|
||||||
|
const library = useFetchJson<Skill[]>(adding ? "/api/skills" : null);
|
||||||
|
|
||||||
|
async function install(skillId: string) {
|
||||||
|
await fetch("/api/skills/install", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ clawId: agent.id, skillId }),
|
||||||
|
});
|
||||||
|
installed.refresh();
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adding) {
|
||||||
|
const installedIds = new Set((installed.data ?? []).map((s) => s.id));
|
||||||
|
const available = (library.data ?? []).filter((s) => !installedIds.has(s.id));
|
||||||
|
return (
|
||||||
|
<div className="p-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdding(false)}
|
||||||
|
className="pb-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
‹ Add a skill
|
||||||
|
</button>
|
||||||
|
{available.length === 0 ? (
|
||||||
|
<p className="px-2 text-xs text-muted-foreground">
|
||||||
|
Everything in the library is already installed.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul aria-label="Skill library">
|
||||||
|
{available.map((skill) => (
|
||||||
|
<li
|
||||||
|
key={skill.id}
|
||||||
|
className="flex items-start gap-2 rounded-(--radius) px-2 py-2 hover:bg-surface-warm"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm">{skill.title}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{skill.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Install ${skill.title}`}
|
||||||
|
onClick={() => install(skill.id)}
|
||||||
|
className="rounded-(--radius-button) border border-border px-2 text-sm text-muted-foreground hover:border-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skills = installed.data ?? [];
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col p-3">
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
|
||||||
|
<span aria-hidden className="text-2xl">
|
||||||
|
⚡
|
||||||
|
</span>
|
||||||
|
<p className="text-sm font-medium">{agent.name}'s Skills</p>
|
||||||
|
<p className="max-w-56 text-xs text-muted-foreground">
|
||||||
|
Add workflows you want {agent.name} to do.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul aria-label="Installed skills" className="flex-1">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<li key={skill.id} className="px-2 py-2">
|
||||||
|
<p className="text-sm">{skill.title}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{skill.description}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
className="mx-auto rounded-(--radius-button) bg-accent px-4 py-1.5 text-xs font-medium text-background hover:bg-coral-light"
|
||||||
|
>
|
||||||
|
Add Skill
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
|
||||||
|
const TABS = ["Overview", "Channels", "Connection"] as const;
|
||||||
|
|
||||||
|
/** The Slack app (§7.3). No connection exists until P4 wires OAuth, so
|
||||||
|
* every tab shows the spec's pre-connect gate. */
|
||||||
|
export default function SlackApp({ agent }: { agent: Agent }) {
|
||||||
|
const [tab, setTab] = useState<(typeof TABS)[number]>("Overview");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col p-3">
|
||||||
|
<div role="tablist" aria-label="Slack" className="flex gap-1 pb-3">
|
||||||
|
{TABS.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === entry}
|
||||||
|
onClick={() => setTab(entry)}
|
||||||
|
className={`rounded-(--radius-button) px-3 py-1 text-xs ${
|
||||||
|
tab === entry
|
||||||
|
? "bg-surface-warm-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
|
||||||
|
<span aria-hidden className="text-2xl">
|
||||||
|
💬
|
||||||
|
</span>
|
||||||
|
<p className="text-sm font-medium">Bring this claw into Slack</p>
|
||||||
|
<p className="max-w-60 text-xs text-muted-foreground">
|
||||||
|
Connect Slack so {agent.name} can respond when someone @mentions it.
|
||||||
|
Outbound posts always require approval.
|
||||||
|
</p>
|
||||||
|
<p className="rounded-(--radius) border border-border px-3 py-1.5 text-xs text-muted-foreground">
|
||||||
|
Slack connection arrives with integrations (P4).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { GlobalNavItem } from "./GlobalNavItem";
|
|||||||
/** Global nav entries that exist in the current phase. Skills and Apps join
|
/** Global nav entries that exist in the current phase. Skills and Apps join
|
||||||
* the rail when their pages land (spec §17 P1/P5). */
|
* the rail when their pages land (spec §17 P1/P5). */
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
|
{ href: "/skills", label: "Skills" },
|
||||||
{ href: "/approvals", label: "Approvals" },
|
{ href: "/approvals", label: "Approvals" },
|
||||||
{ href: "/team", label: "Team" },
|
{ href: "/team", label: "Team" },
|
||||||
{ href: "/credits", label: "Credits" },
|
{ href: "/credits", label: "Credits" },
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Minimal client-side JSON fetcher for the panel apps: loading/error state
|
||||||
|
// plus manual refresh. Requests ride the same-origin /api proxy.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface Fetched<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settled<T> {
|
||||||
|
key: string;
|
||||||
|
data: T | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFetchJson<T>(url: string | null): Fetched<T> {
|
||||||
|
const [settled, setSettled] = useState<Settled<T> | null>(null);
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
const refresh = useCallback(() => setTick((t) => t + 1), []);
|
||||||
|
const requestKey = url === null ? null : `${url}#${tick}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requestKey === null || url === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
fetch(url)
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`${res.status}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
})
|
||||||
|
.then((json) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSettled({ key: requestKey, data: json, error: null });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSettled({ key: requestKey, data: null, error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [url, requestKey]);
|
||||||
|
|
||||||
|
// Loading is derived: the latest request has not settled yet.
|
||||||
|
const current = settled && settled.key === requestKey ? settled : null;
|
||||||
|
return {
|
||||||
|
data: current?.data ?? settled?.data ?? null,
|
||||||
|
loading: requestKey !== null && current === null,
|
||||||
|
error: current?.error ?? null,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
describe("panelParsers.app", () => {
|
describe("panelParsers.app", () => {
|
||||||
it("accepts every Computer-panel app id from spec §12", () => {
|
it("accepts every Computer-panel app id from spec §12", () => {
|
||||||
const expected = [
|
const expected = [
|
||||||
|
"home",
|
||||||
"browser",
|
"browser",
|
||||||
"slack",
|
"slack",
|
||||||
"chat",
|
"chat",
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const parseAsFlag = createParser<boolean>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const APP_IDS = [
|
export const APP_IDS = [
|
||||||
|
// "home" is the panel's open-but-no-app state (§7.0 home screen); the
|
||||||
|
// spec's ?app enum lists the sub-views, home is the dock/grid.
|
||||||
|
"home",
|
||||||
"browser",
|
"browser",
|
||||||
"slack",
|
"slack",
|
||||||
"chat",
|
"chat",
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
// P3 exit criterion (spec §17): every Computer-panel app is navigable and
|
||||||
|
// deep-linkable, with per-agent theming, against real backend data.
|
||||||
|
|
||||||
|
const OWNER_EMAIL = "[email protected]";
|
||||||
|
const OWNER_PASSWORD = "e2e-password";
|
||||||
|
|
||||||
|
async function signIn(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("Email").fill(OWNER_EMAIL);
|
||||||
|
await page.getByLabel("Password").fill(OWNER_PASSWORD);
|
||||||
|
await page.getByRole("button", { name: "Sign in" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "TeamClaw" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openScout(page: Page) {
|
||||||
|
await page.getByRole("link", { name: /Scout/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage(page: Page, text: string) {
|
||||||
|
const box = page.getByLabel("Message Scout");
|
||||||
|
await box.fill(text);
|
||||||
|
await box.press("Enter");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the computer opens to the themed home screen with grid and dock", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signIn(page);
|
||||||
|
await openScout(page);
|
||||||
|
await page.getByRole("button", { name: "Computer" }).click();
|
||||||
|
|
||||||
|
const panel = page.getByRole("complementary", { name: "Computer" });
|
||||||
|
await expect(panel.getByText(/Scout's Computer/)).toBeVisible();
|
||||||
|
await expect(panel.getByRole("button", { name: "Files" })).toBeVisible();
|
||||||
|
await expect(panel.getByRole("button", { name: "Settings" })).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/app=home/);
|
||||||
|
|
||||||
|
// Per-agent theming: the wallpaper var derives from the agent accent.
|
||||||
|
const themed = panel.getByTestId("device-panel-theme");
|
||||||
|
await expect(themed).toHaveAttribute("style", /--agent-accent/);
|
||||||
|
|
||||||
|
// Size toggle flips ?device=.
|
||||||
|
await panel.getByRole("radio", { name: "Phone" }).click();
|
||||||
|
await expect(page).toHaveURL(/device=phone/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agent-written files appear in the Files app", async ({ page }) => {
|
||||||
|
await signIn(page);
|
||||||
|
await openScout(page);
|
||||||
|
await sendMessage(page, "save the report [[scenario:save-report]]");
|
||||||
|
await expect(page.getByText(/Saved the report/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Computer" }).click();
|
||||||
|
const panel = page.getByRole("complementary", { name: "Computer" });
|
||||||
|
await panel.getByRole("button", { name: "Files" }).click();
|
||||||
|
await panel.getByRole("button", { name: /My Documents/ }).click();
|
||||||
|
await expect(panel.getByText("reports/q2.md")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agent-scheduled routines appear in the Routines app", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signIn(page);
|
||||||
|
await openScout(page);
|
||||||
|
await sendMessage(page, "every morning [[scenario:schedule-digest]]");
|
||||||
|
await expect(page.getByText(/Scheduled the morning digest/)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Computer" }).click();
|
||||||
|
const panel = page.getByRole("complementary", { name: "Computer" });
|
||||||
|
await panel.getByRole("button", { name: "Routines" }).click();
|
||||||
|
await expect(panel.getByText("Morning digest")).toBeVisible();
|
||||||
|
await expect(panel.getByText(/0 9 \* \* \*/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings edits the system prompt and persists", async ({ page }) => {
|
||||||
|
await signIn(page);
|
||||||
|
await openScout(page);
|
||||||
|
await page.getByRole("button", { name: "Computer" }).click();
|
||||||
|
const panel = page.getByRole("complementary", { name: "Computer" });
|
||||||
|
await panel.getByRole("button", { name: "Settings" }).click();
|
||||||
|
await expect(panel.getByText("Managed by")).toBeVisible();
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: /Edit profile/ }).click();
|
||||||
|
const description = panel.getByLabel(/Job description/);
|
||||||
|
await description.fill("Research carefully and cite sources.");
|
||||||
|
await panel.getByRole("button", { name: "Save profile" }).click();
|
||||||
|
await expect(panel.getByText("Managed by")).toBeVisible();
|
||||||
|
|
||||||
|
// Persisted: reopen settings cold (deep link) and check the textarea.
|
||||||
|
await page.reload();
|
||||||
|
const reopened = page.getByRole("complementary", { name: "Computer" });
|
||||||
|
await reopened.getByRole("button", { name: /Edit profile/ }).click();
|
||||||
|
await expect(reopened.getByLabel(/Job description/)).toHaveValue(
|
||||||
|
"Research carefully and cite sources.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every panel app is reachable and the deep link cold-loads", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signIn(page);
|
||||||
|
await openScout(page);
|
||||||
|
|
||||||
|
// Deep link with app + device cold-loads the open panel.
|
||||||
|
const url = new URL(page.url());
|
||||||
|
url.searchParams.set("app", "settings");
|
||||||
|
url.searchParams.set("device", "full");
|
||||||
|
await page.goto(url.toString());
|
||||||
|
const panel = page.getByRole("complementary", { name: "Computer" });
|
||||||
|
await expect(panel.getByText("Managed by")).toBeVisible();
|
||||||
|
|
||||||
|
// Walk through the remaining apps via the home screen.
|
||||||
|
const stops: [string, RegExp][] = [
|
||||||
|
["Browser", /No active browsing session/],
|
||||||
|
["Slack", /Bring this claw into Slack/],
|
||||||
|
["Claw Chat", /No claw-to-claw conversations yet/],
|
||||||
|
["Add", /OAuth, keys, MCP/],
|
||||||
|
["Skills", /Scout's Skills|Installed skills/],
|
||||||
|
];
|
||||||
|
for (const [tile, marker] of stops) {
|
||||||
|
await panel.getByRole("button", { name: "Computer home" }).click();
|
||||||
|
await panel.getByRole("button", { name: tile, exact: true }).click();
|
||||||
|
await expect(panel.getByText(marker).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the skill library page lists skills and installs onto a claw", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signIn(page);
|
||||||
|
await page.getByRole("link", { name: "Skills", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Skill Library" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("Daily briefing")).toBeVisible();
|
||||||
|
|
||||||
|
// Install from the claw's Skills app.
|
||||||
|
await page.getByRole("link", { name: /Scout/ }).click();
|
||||||
|
await page.getByRole("button", { name: "Computer" }).click();
|
||||||
|
const panel = page.getByRole("complementary", { name: "Computer" });
|
||||||
|
await panel.getByRole("button", { name: "Skills" }).click();
|
||||||
|
await panel.getByRole("button", { name: "Add Skill" }).click();
|
||||||
|
await panel.getByRole("button", { name: /Install Daily briefing/ }).click();
|
||||||
|
await expect(panel.getByText("Daily briefing")).toBeVisible();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user