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:
Omar Sobh
2026-06-10 05:37:18 -05:00
co-authored by Claude Fable 5
parent 67f918439c
commit 1fd2c287f1
30 changed files with 1488 additions and 1 deletions
+11
View File
@@ -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(())
} }
+1
View File
@@ -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(
+121
View File
@@ -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
View File
@@ -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;
-1
View File
@@ -140,4 +140,3 @@ impl Tool for ChatInbox {
Ok(json!({ "messages": inbox })) Ok(json!({ "messages": inbox }))
} }
} }
+26
View File
@@ -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}&apos;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" },
+62
View File
@@ -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",
+3
View File
@@ -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",
+148
View File
@@ -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();
});