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
@@ -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>
);
}