R3: Computer panel rebuilt to the reference 'computer' system

Panel chrome (CONSOLIDATED-computer-panel-handoff): the 448px docked rail
now hosts a 400×652 r32 'screen' card with the measured wallpaper stack —
135° slate/navy base gradient tinted by the agent accent, a #000/.40
vignette, and a frosted blur(40px) white/5 overlay with grain on top.
Header carries the rose-500 status dot (home) or a back chevron (drilled),
keeping the 'Computer home' / 'Close computer' accessible names stable.

Home screen: 56px tiles with 16px-radius icon containers (Chrome/Slack
brand marks on white squircles, coral-gradient lucide glyphs elsewhere),
11px labels, active:scale-[0.92] press; the frosted dock capsule uses the
exact blur(40px) saturate(1.5) bg-white/7 r24 + inset/drop shadow with
48px black tiles.

App window choreography: opening an app zooms it out of the tapped tile
(transform-origin + --app-origin-scale captured at click time → the
app-shell-zoom-in keyframe), ~0.28s ease-app.

All 8 apps restyled to the inventory spec via shared AppShell helpers
(SubViewHeader, PanelEmptyState = muted lucide glyph + 16/600 + 12 muted):
Files 45px rows w/ folder/file/chevron glyphs; Slack segmented tabs
(active white bg / aubergine text) + cream connect gate; Skills pinned
cream 'Add Skill'; Add Apps category pills + SearchPill + coral connect;
Settings grouped r12 cards + coral radios + cream Save + lucide delete;
Browser/Routines/ClawChat lucide empty states. Every test marker text and
aria-label preserved.

83 unit + 29 functional E2E green; lucide icons throughout (emoji gone).

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 19:49:49 -05:00
co-authored by Claude Fable 5
parent 6e061515b8
commit 67b80da695
11 changed files with 295 additions and 159 deletions
@@ -1,6 +1,8 @@
"use client"; "use client";
import { ChevronLeft, X } from "lucide-react";
import { useQueryStates } from "nuqs"; import { useQueryStates } from "nuqs";
import { useRef, useState, type CSSProperties } from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { panelParsers, type AppId } from "@/lib/url/panel-params"; import { panelParsers, type AppId } from "@/lib/url/panel-params";
@@ -12,15 +14,29 @@ import { AppRouter, appTitle } from "./AppRouter";
const WIDTHS = { full: 720, tablet: 448, phone: 340 } as const; const WIDTHS = { full: 720, tablet: 448, phone: 340 } as const;
/** The right-hand "Computer" slide-out (§7): per-agent themed, sized by /** The right-hand "Computer" slide-out (§7): the 448px docked rail hosts a
* ?device=, hosting every sub-app behind ?app=. All state deep-links. */ * 400×652 r32 "screen" card with the wallpaper stack; apps zoom out of
* their home tile. All state deep-links via ?app=/?device=. */
export function DevicePanel({ agent }: { agent: Agent }) { export function DevicePanel({ agent }: { agent: Agent }) {
const [{ app, device }, setParams] = useQueryStates(panelParsers, { const [{ app, device }, setParams] = useQueryStates(panelParsers, {
shallow: true, shallow: true,
}); });
const cardRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState<CSSProperties>({});
const open = app !== null; const open = app !== null;
const onHome = app === "home" || app === null;
function openApp(next: AppId) { function openApp(next: AppId, tileRect?: DOMRect) {
// Capture the launch transform at click time (refs may not be read
// during render): the tapped tile's center, relative to the screen
// card, is the zoom origin; its size sets --app-origin-scale.
const card = cardRef.current?.getBoundingClientRect();
if (tileRect && card) {
setZoom({
transformOrigin: `${tileRect.left + tileRect.width / 2 - card.left}px ${tileRect.top + tileRect.height / 2 - card.top}px`,
["--app-origin-scale" as string]: `${tileRect.width / card.width}`,
});
}
setParams({ app: next }); setParams({ app: next });
} }
@@ -28,49 +44,59 @@ export function DevicePanel({ agent }: { agent: Agent }) {
<SlidePanel open={open} width={WIDTHS[device]} label="Computer"> <SlidePanel open={open} width={WIDTHS[device]} label="Computer">
<div <div
data-testid="device-panel-theme" data-testid="device-panel-theme"
className="h-full border-l border-border" className="flex h-full flex-col px-3 pt-[88px] pb-6"
style={clawThemeStyle(agent)} style={clawThemeStyle(agent)}
> >
<WallpaperSurface> <div
<header className="relative flex h-11 items-center gap-2 border-b border-white/10 px-3"> ref={cardRef}
<button className="relative mx-auto flex h-full w-full max-w-[400px] flex-col overflow-hidden rounded-[32px] shadow-screen"
type="button" >
aria-label="Computer home" <WallpaperSurface>
onClick={() => openApp("home")} <header className="relative flex h-11 shrink-0 items-center gap-2 px-3">
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 <button
type="button" type="button"
aria-label="Close computer" aria-label="Computer home"
onClick={() => setParams({ app: null })} onClick={() => openApp("home")}
className="rounded-(--radius) px-1.5 text-sm text-muted-foreground hover:text-foreground" className="flex items-center gap-1.5 text-xs font-medium text-white/90 transition-colors hover:text-white"
> >
× {onHome ? (
<span
aria-hidden
className="size-2 rounded-full bg-rose-500"
/>
) : (
<ChevronLeft aria-hidden size={16} />
)}
{onHome ? `${agent.name}'s Computer` : appTitle(app)}
</button> </button>
</div> <div className="ml-auto flex items-center gap-2">
</header> <DeviceSizeToggle
{app === "home" || app === null ? ( value={device}
<HomeScreen agentName={agent.name} onOpen={openApp} /> onChange={(size) => setParams({ device: size })}
) : ( />
<div className="relative flex-1 overflow-y-auto bg-background/80"> <button
<AppRouter app={app} agent={agent} /> type="button"
</div> aria-label="Close computer"
)} onClick={() => setParams({ app: null })}
</WallpaperSurface> className="inline-flex size-6 items-center justify-center rounded-full text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
<X aria-hidden size={15} />
</button>
</div>
</header>
{onHome ? (
<HomeScreen agentName={agent.name} onOpen={openApp} />
) : (
<div
key={app}
style={zoom}
className="app-shell-anim-launch relative flex-1 overflow-y-auto bg-background/85"
>
<AppRouter app={app} agent={agent} />
</div>
)}
</WallpaperSurface>
</div>
</div> </div>
</SlidePanel> </SlidePanel>
); );
@@ -5,7 +5,7 @@ import {
MessageCircle, MessageCircle,
Plus, Plus,
Settings, Settings,
Slack, Hash,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
@@ -17,7 +17,7 @@ import type { AppId } from "@/lib/url/panel-params";
* coral-gradient lucide glyph. */ * coral-gradient lucide glyph. */
export const APP_ICON: Record<string, LucideIcon> = { export const APP_ICON: Record<string, LucideIcon> = {
browser: Globe, browser: Globe,
slack: Slack, slack: Hash,
chat: MessageCircle, chat: MessageCircle,
apps: Plus, apps: Plus,
skills: Zap, skills: Zap,
@@ -1,9 +1,11 @@
"use client"; "use client";
import { Plus } from "lucide-react";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
import { SearchPill } from "@/components/ui/SearchPill";
interface DirectoryApp { interface DirectoryApp {
id: string; id: string;
@@ -13,10 +15,21 @@ interface DirectoryApp {
connected: boolean; connected: boolean;
} }
/** The connect directory (§7.8): inline API-key connects; OAuth/MCP run const CATEGORIES = [
* the authorization-code flow server-side. */ "All",
"Productivity",
"Communication",
"Dev tools",
"Design",
"Finance",
] as const;
/** The connect directory (§7.8): category pills + search, inline API-key
* connects; OAuth/MCP run the authorization-code flow server-side. */
export default function AddAppsApp({ agent }: { agent: Agent }) { export default function AddAppsApp({ agent }: { agent: Agent }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [category, setCategory] =
useState<(typeof CATEGORIES)[number]>("All");
const [connecting, setConnecting] = useState<string | null>(null); const [connecting, setConnecting] = useState<string | null>(null);
const { data, loading, refresh } = useFetchJson<DirectoryApp[]>( const { data, loading, refresh } = useFetchJson<DirectoryApp[]>(
`/api/apps?clawId=${agent.id}`, `/api/apps?clawId=${agent.id}`,
@@ -38,27 +51,55 @@ export default function AddAppsApp({ agent }: { agent: Agent }) {
setConnecting(null); setConnecting(null);
refresh(); refresh();
} }
const apps = (data ?? []).filter((app) =>
`${app.name} ${app.category}`.toLowerCase().includes(query.toLowerCase()), const apps = (data ?? []).filter((app) => {
); const matchesQuery = `${app.name} ${app.category}`
.toLowerCase()
.includes(query.toLowerCase());
const matchesCategory =
category === "All" ||
app.category.toLowerCase().includes(category.toLowerCase());
return matchesQuery && matchesCategory;
});
return ( return (
<div className="p-3"> <div className="p-3">
<input <div
type="search" role="tablist"
aria-label="Categories"
className="mb-2 flex gap-1.5 overflow-x-auto pb-1"
>
{CATEGORIES.map((entry) => (
<button
key={entry}
type="button"
role="tab"
aria-selected={category === entry}
onClick={() => setCategory(entry)}
className={`shrink-0 rounded-radius-button px-2.5 py-1 text-xs font-medium transition-colors duration-normal ease-app ${
category === entry
? "bg-white text-primary-foreground"
: "bg-white/10 text-white/80 hover:text-white"
}`}
>
{entry}
</button>
))}
</div>
<SearchPill
aria-label="Search apps" aria-label="Search apps"
placeholder="Search apps" placeholder="Search apps"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} 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" className="mb-2"
/> />
{loading ? ( {loading ? (
<p className="px-2 text-xs text-muted-foreground">Loading</p> <p className="px-2 text-xs text-muted-foreground">Loading</p>
) : ( ) : (
<ul aria-label="App directory"> <ul aria-label="App directory">
{apps.map((app) => ( {apps.map((app) => (
<li key={app.id} className="px-2 py-2"> <li key={app.id} className="px-1 py-1">
<div className="flex items-start gap-2"> <div className="flex items-center gap-2 rounded-xl px-2 py-2 transition-colors duration-normal ease-app hover:bg-neutral-800/40">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm">{app.name}</p> <p className="text-sm">{app.name}</p>
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
@@ -66,7 +107,7 @@ export default function AddAppsApp({ agent }: { agent: Agent }) {
</p> </p>
</div> </div>
{app.connected ? ( {app.connected ? (
<span className="rounded-(--radius-button) border border-border px-2 py-0.5 text-xxs text-muted-foreground"> <span className="rounded-radius-button border border-border px-2 py-0.5 text-xxs text-muted-foreground">
Connected Connected
</span> </span>
) : ( ) : (
@@ -76,16 +117,16 @@ export default function AddAppsApp({ agent }: { agent: Agent }) {
onClick={() => onClick={() =>
setConnecting(connecting === app.id ? null : app.id) setConnecting(connecting === app.id ? null : app.id)
} }
className="rounded-(--radius-button) border border-border px-2 text-sm text-muted-foreground hover:border-accent hover:text-foreground" className="inline-flex size-7 shrink-0 items-center justify-center rounded-radius-button border border-border text-muted-foreground transition-colors duration-normal ease-app hover:border-coral hover:text-foreground"
> >
+ <Plus aria-hidden size={14} />
</button> </button>
)} )}
</div> </div>
{connecting === app.id && ( {connecting === app.id && (
<form <form
onSubmit={(e) => connectKeys(e, app.id)} onSubmit={(e) => connectKeys(e, app.id)}
className="flex gap-2 pt-2" className="flex gap-2 px-2 pt-2"
> >
<input <input
name="secret" name="secret"
@@ -93,11 +134,11 @@ export default function AddAppsApp({ agent }: { agent: Agent }) {
required required
aria-label={`${app.name} API key`} aria-label={`${app.name} API key`}
placeholder="API key / token" placeholder="API key / token"
className="min-w-0 flex-1 rounded-(--radius) border border-input bg-subtle px-2 py-1 text-xs outline-none focus:border-accent" className="min-w-0 flex-1 rounded-xl border border-input bg-subtle px-3 py-1.5 text-xs outline-none transition-colors focus:border-coral"
/> />
<button <button
type="submit" type="submit"
className="rounded-(--radius-button) bg-accent px-3 py-1 text-xs font-medium text-background hover:bg-coral-light" className="rounded-radius-button bg-coral px-3 py-1.5 text-xs font-medium text-white shadow-cta transition-colors duration-normal ease-app hover:bg-coral-light"
> >
Connect Connect
</button> </button>
@@ -0,0 +1,56 @@
"use client";
import { ChevronLeft } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { GradientGlyph } from "@/components/ui/GradientGlyph";
/* Shared panel-app chrome (measured): drilled sub-views get a back-chevron
+ title (+ optional trailing action); top-level apps render their own
content directly since the panel header already shows the app name. */
export function SubViewHeader({
title,
onBack,
action,
}: {
title: string;
onBack: () => void;
action?: ReactNode;
}) {
return (
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronLeft aria-hidden size={14} />
{title}
</button>
{action != null && <span className="ml-auto">{action}</span>}
</div>
);
}
/* The shared empty / error state (measured): muted lucide glyph, 16px/600
title, 12px muted subtitle, centered. */
export function PanelEmptyState({
icon,
title,
subtitle,
}: {
icon: LucideIcon;
title: string;
subtitle: string;
}) {
return (
<div className="flex min-h-[55%] flex-col items-center justify-center gap-2 px-6 py-10 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-surface-warm-muted">
<GradientGlyph icon={icon} size={24} />
</span>
<p className="text-base font-semibold text-foreground">{title}</p>
<p className="max-w-64 text-xs text-muted-foreground">{subtitle}</p>
</div>
);
}
@@ -1,7 +1,10 @@
"use client"; "use client";
import { ChevronLeft, ChevronRight, Globe, RotateCw } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { PanelEmptyState } from "./AppShell";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
/** The agent browser (§7.1): shows the latest viewport captured by the /** The agent browser (§7.1): shows the latest viewport captured by the
@@ -38,22 +41,22 @@ export default function BrowserApp({ agent }: { agent: Agent }) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-1.5 border-b border-border px-2 py-1.5"> <div className="flex items-center gap-1.5 border-b border-white/10 px-2 py-1.5">
<button <button
type="button" type="button"
aria-label="Back" aria-label="Back"
disabled disabled
className="rounded-(--radius) px-1.5 text-sm text-muted-foreground disabled:opacity-40" className="inline-flex size-7 items-center justify-center rounded-radius-button text-muted-foreground disabled:opacity-40"
> >
<ChevronLeft aria-hidden size={16} />
</button> </button>
<button <button
type="button" type="button"
aria-label="Forward" aria-label="Forward"
disabled disabled
className="rounded-(--radius) px-1.5 text-sm text-muted-foreground disabled:opacity-40" className="inline-flex size-7 items-center justify-center rounded-radius-button text-muted-foreground disabled:opacity-40"
> >
<ChevronRight aria-hidden size={16} />
</button> </button>
<span <span
aria-label="Address" aria-label="Address"
@@ -67,7 +70,7 @@ export default function BrowserApp({ agent }: { agent: Agent }) {
disabled disabled
className="rounded-(--radius) px-1.5 text-sm text-muted-foreground disabled:opacity-40" className="rounded-(--radius) px-1.5 text-sm text-muted-foreground disabled:opacity-40"
> >
<RotateCw aria-hidden size={14} />
</button> </button>
</div> </div>
{viewport ? ( {viewport ? (
@@ -81,15 +84,11 @@ export default function BrowserApp({ agent }: { agent: Agent }) {
/> />
</div> </div>
) : ( ) : (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center"> <PanelEmptyState
<span aria-hidden className="text-2xl"> icon={Globe}
🌐 title="No active browsing session"
</span> subtitle={`When ${agent.name} browses the web, the live session appears here.`}
<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> </div>
); );
@@ -3,7 +3,10 @@
import { useState } from "react"; import { useState } from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { MessageCircle } from "lucide-react";
import { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
import { PanelEmptyState } from "./AppShell";
import { relativeTime } from "@/lib/format/relative-time"; import { relativeTime } from "@/lib/format/relative-time";
interface Thread { interface Thread {
@@ -68,9 +71,11 @@ export default function ClawChatApp({ agent }: { agent: Agent }) {
return ( return (
<div className="p-3"> <div className="p-3">
{list.length === 0 ? ( {list.length === 0 ? (
<p className="px-2 pt-6 text-center text-xs text-muted-foreground"> <PanelEmptyState
No claw-to-claw conversations yet. icon={MessageCircle}
</p> title="No conversations yet"
subtitle="No claw-to-claw conversations yet."
/>
) : ( ) : (
<ul aria-label="Threads"> <ul aria-label="Threads">
{list.map((thread) => ( {list.map((thread) => (
@@ -78,7 +83,7 @@ export default function ClawChatApp({ agent }: { agent: Agent }) {
<button <button
type="button" type="button"
onClick={() => setOpenThread(thread)} onClick={() => setOpenThread(thread)}
className="w-full rounded-(--radius) px-2 py-2 text-left hover:bg-surface-warm" className="w-full rounded-xl px-2 py-2 text-left transition-colors duration-normal ease-app hover:bg-neutral-800/40"
> >
<p className="flex items-center gap-2 text-sm"> <p className="flex items-center gap-2 text-sm">
{thread.subject} {thread.subject}
@@ -1,9 +1,12 @@
"use client"; "use client";
import { ChevronLeft, ChevronRight, File, Folder } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
import { GradientGlyph } from "@/components/ui/GradientGlyph";
import { PanelEmptyState } from "./AppShell";
interface FileNode { interface FileNode {
path: string; path: string;
@@ -11,9 +14,9 @@ interface FileNode {
} }
const DRIVES = [ const DRIVES = [
{ id: "documents", label: "My Documents", icon: "📁" }, { id: "documents", label: "My Documents" },
{ id: "received", label: "Received Files", icon: "📁" }, { id: "received", label: "Received Files" },
{ id: "shared", label: "Shared ClawDrive (team)", icon: "📁" }, { id: "shared", label: "Shared ClawDrive (team)" },
] as const; ] as const;
type DriveId = (typeof DRIVES)[number]["id"]; type DriveId = (typeof DRIVES)[number]["id"];
@@ -33,19 +36,21 @@ export default function FilesApp({ agent }: { agent: Agent }) {
if (drive === null) { if (drive === null) {
return ( return (
<ul aria-label="Drives" className="p-3"> <ul aria-label="Drives" className="p-2">
{DRIVES.map((entry) => ( {DRIVES.map((entry) => (
<li key={entry.id}> <li key={entry.id}>
<button <button
type="button" type="button"
onClick={() => setDrive(entry.id)} onClick={() => setDrive(entry.id)}
className="flex w-full items-center gap-2 rounded-(--radius) px-2 py-2 text-sm hover:bg-surface-warm" className="flex h-[45px] w-full items-center gap-3 rounded-xl px-3 text-base transition-colors duration-normal ease-app hover:bg-neutral-800/40"
> >
<span aria-hidden>{entry.icon}</span> <GradientGlyph icon={Folder} size={18} />
{entry.label} {entry.label}
<span aria-hidden className="ml-auto text-muted-foreground"> <ChevronRight
aria-hidden
</span> size={16}
className="ml-auto text-muted-foreground"
/>
</button> </button>
</li> </li>
))} ))}
@@ -59,9 +64,9 @@ export default function FilesApp({ agent }: { agent: Agent }) {
<button <button
type="button" type="button"
onClick={() => setDrive(null)} onClick={() => setDrive(null)}
className="pb-2 text-xs text-muted-foreground hover:text-foreground" className="flex items-center gap-1 pb-2 text-xs text-muted-foreground transition-colors hover:text-foreground"
> >
{label} <ChevronLeft aria-hidden size={14} /> {label}
</button> </button>
{loading ? ( {loading ? (
<p className="px-2 text-xs text-muted-foreground">Loading</p> <p className="px-2 text-xs text-muted-foreground">Loading</p>
@@ -72,7 +77,7 @@ export default function FilesApp({ agent }: { agent: Agent }) {
key={file.path} key={file.path}
className="flex items-center gap-2 px-2 py-1.5 font-mono text-xs" className="flex items-center gap-2 px-2 py-1.5 font-mono text-xs"
> >
<span aria-hidden>📄</span> <File aria-hidden size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate">{file.path}</span> <span className="truncate">{file.path}</span>
<span className="ml-auto text-muted-foreground"> <span className="ml-auto text-muted-foreground">
{file.size} B {file.size} B
@@ -81,9 +86,11 @@ export default function FilesApp({ agent }: { agent: Agent }) {
))} ))}
</ul> </ul>
) : ( ) : (
<p className="px-2 text-xs text-muted-foreground"> <PanelEmptyState
Nothing here yet ask {agent.name} to save something. icon={Folder}
</p> title="Nothing here yet"
subtitle={`Ask ${agent.name} to save something.`}
/>
)} )}
</div> </div>
); );
@@ -1,6 +1,9 @@
"use client"; "use client";
import { Bell, RefreshCw } from "lucide-react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { PanelEmptyState } from "./AppShell";
import { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
import { relativeTime } from "@/lib/format/relative-time"; import { relativeTime } from "@/lib/format/relative-time";
@@ -27,24 +30,19 @@ export default function RoutinesApp({ agent }: { agent: Agent }) {
type="button" type="button"
aria-label="Refresh routines" aria-label="Refresh routines"
onClick={refresh} onClick={refresh}
className="rounded-(--radius) px-1.5 text-sm text-muted-foreground hover:text-foreground" className="inline-flex size-7 items-center justify-center rounded-radius-button text-muted-foreground transition-colors duration-normal ease-app hover:bg-hover-bg hover:text-foreground"
> >
<RefreshCw aria-hidden size={14} />
</button> </button>
</div> </div>
{loading ? ( {loading ? (
<p className="px-2 text-xs text-muted-foreground">Loading</p> <p className="px-2 text-xs text-muted-foreground">Loading</p>
) : routines.length === 0 ? ( ) : routines.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center"> <PanelEmptyState
<span aria-hidden className="text-2xl"> icon={Bell}
🔔 title="No routines yet"
</span> subtitle="Ask your claw to set up a routine — daily digest, newsletter, calendar block."
<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"> <ul aria-label="Routines" className="flex-1">
{routines.map((routine) => ( {routines.map((routine) => (
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ChevronLeft, ChevronRight, Trash2 } from "lucide-react";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
@@ -75,16 +76,16 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
<button <button
type="button" type="button"
onClick={() => setScreen("main")} onClick={() => setScreen("main")}
className="self-start text-xs text-muted-foreground hover:text-foreground" className="flex items-center gap-1 self-start text-xs text-muted-foreground transition-colors hover:text-foreground"
> >
Edit profile <ChevronLeft aria-hidden size={14} /> Edit profile
</button> </button>
<label className="flex flex-col gap-1 text-xs text-muted-foreground"> <label className="flex flex-col gap-1 text-xs text-muted-foreground">
Name Name
<input <input
name="name" name="name"
defaultValue={live.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" className="rounded-xl border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-coral"
/> />
</label> </label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground"> <label className="flex flex-col gap-1 text-xs text-muted-foreground">
@@ -92,7 +93,7 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
<input <input
name="job_title" name="job_title"
defaultValue={live.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" className="rounded-xl border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-coral"
/> />
</label> </label>
<label className="flex flex-col gap-1 text-xs text-muted-foreground"> <label className="flex flex-col gap-1 text-xs text-muted-foreground">
@@ -102,32 +103,32 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
rows={5} rows={5}
defaultValue={live.system_prompt} defaultValue={live.system_prompt}
placeholder="Describe how this claw should think..." 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" className="rounded-xl border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-coral"
/> />
<span className="text-xxs">Becomes part of its system prompt.</span> <span className="text-xxs">Becomes part of its system prompt.</span>
</label> </label>
<button <button
type="submit" type="submit"
disabled={saving} 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" className="rounded-radius-button bg-cream px-4 py-2 text-sm font-semibold text-primary-foreground shadow-button transition-colors duration-normal ease-app hover:bg-white disabled:opacity-50"
> >
{saving ? "Saving…" : "Save profile"} {saving ? "Saving…" : "Save profile"}
</button> </button>
{confirmingDelete ? ( {confirmingDelete ? (
<div className="rounded-(--radius) border border-destructive p-2 text-xs"> <div className="rounded-xl border border-destructive p-3 text-xs">
<p>Delete {live.name} permanently? This cannot be undone.</p> <p>Delete {live.name} permanently? This cannot be undone.</p>
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<button <button
type="button" type="button"
onClick={() => setConfirmingDelete(false)} onClick={() => setConfirmingDelete(false)}
className="rounded-(--radius-button) border border-border px-3 py-1" className="rounded-radius-button border border-border px-3 py-1.5 transition-colors hover:bg-hover-bg"
> >
Keep Keep
</button> </button>
<button <button
type="button" type="button"
onClick={deleteClaw} onClick={deleteClaw}
className="rounded-(--radius-button) bg-destructive px-3 py-1 text-foreground" className="rounded-radius-button bg-destructive px-3 py-1.5 text-foreground"
> >
Delete claw Delete claw
</button> </button>
@@ -137,9 +138,9 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
<button <button
type="button" type="button"
onClick={() => setConfirmingDelete(true)} onClick={() => setConfirmingDelete(true)}
className="self-start text-xs text-accent hover:underline" className="flex items-center gap-1.5 self-start text-xs text-coral transition-colors hover:text-coral-light"
> >
🗑 Delete claw <Trash2 aria-hidden size={13} /> Delete claw
</button> </button>
)} )}
</form> </form>
@@ -150,32 +151,33 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
return ( return (
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
<div className="flex flex-col items-center gap-1 py-2"> <div className="flex flex-col items-center gap-1 py-2">
<Avatar name={live.name} accent={live.accent} size="lg" /> <Avatar name={live.name} accent={live.accent} size="lg" shape="squircle" />
<p className="text-sm font-medium">{live.name}</p> <p className="text-sm font-medium">{live.name}</p>
<p className="text-xs text-muted-foreground">{live.job_title}</p> <p className="text-xs text-muted-foreground">{live.job_title}</p>
</div> </div>
<div className="rounded-(--radius) border border-border bg-subtle text-xs"> <div className="divide-y divide-divider-subtle overflow-hidden rounded-xl bg-surface-warm text-xs">
<div className="flex justify-between border-b border-border px-2 py-2"> <div className="flex justify-between px-3 py-2.5">
<span className="text-muted-foreground">Managed by</span> <span className="text-muted-foreground">Managed by</span>
<span>{settings.data?.managed_by_name ?? "…"}</span> <span>{settings.data?.managed_by_name ?? "…"}</span>
</div> </div>
<button <button
type="button" type="button"
onClick={() => setScreen("edit")} onClick={() => setScreen("edit")}
className="flex w-full justify-between px-2 py-2 hover:bg-surface-warm" className="flex w-full items-center justify-between px-3 py-2.5 transition-colors hover:bg-neutral-800/40"
> >
Edit profile <span aria-hidden></span> Edit profile
<ChevronRight aria-hidden size={14} className="text-muted-foreground" />
</button> </button>
</div> </div>
<p className="pt-1 text-xxs uppercase tracking-wide text-muted-foreground"> <p className="pt-1 text-[11px] font-medium uppercase tracking-wider text-subtle-foreground">
Who else has access Who else has access
</p> </p>
<div <div
role="radiogroup" role="radiogroup"
aria-label="Other Claws" aria-label="Other Claws"
className="rounded-(--radius) border border-border bg-subtle p-2 text-xs" className="rounded-xl bg-surface-warm p-2 text-xs"
> >
<p className="pb-1">Other Claws</p> <p className="px-1 pb-1 text-muted-foreground">Other Claws</p>
{(["any", "specific"] as const).map((mode) => ( {(["any", "specific"] as const).map((mode) => (
<button <button
key={mode} key={mode}
@@ -183,9 +185,9 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
role="radio" role="radio"
aria-checked={agentsMode === mode} aria-checked={agentsMode === mode}
onClick={() => setAgentsMode(mode)} onClick={() => setAgentsMode(mode)}
className="flex w-full items-center gap-2 px-1 py-1 text-left hover:bg-surface-warm" className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-neutral-800/40"
> >
<span aria-hidden>{agentsMode === mode ? "◉" : "○"}</span> <span aria-hidden className={agentsMode === mode ? "text-coral" : "text-muted-foreground"}>{agentsMode === mode ? "◉" : "○"}</span>
{mode === "any" {mode === "any"
? "Any Claw on the team" ? "Any Claw on the team"
: "Specific claws — pick claws"} : "Specific claws — pick claws"}
@@ -1,7 +1,10 @@
"use client"; "use client";
import { Zap } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { PanelEmptyState } from "./AppShell";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
@@ -62,7 +65,7 @@ export default function SkillsApp({ agent }: { agent: Agent }) {
type="button" type="button"
aria-label={`Install ${skill.title}`} aria-label={`Install ${skill.title}`}
onClick={() => install(skill.id)} onClick={() => install(skill.id)}
className="rounded-(--radius-button) border border-border px-2 text-sm text-muted-foreground hover:border-accent hover:text-foreground" className="shrink-0 rounded-radius-button border border-border px-2 text-sm text-muted-foreground transition-colors duration-normal ease-app hover:border-coral hover:text-foreground"
> >
+ +
</button> </button>
@@ -78,15 +81,11 @@ export default function SkillsApp({ agent }: { agent: Agent }) {
return ( return (
<div className="flex h-full flex-col p-3"> <div className="flex h-full flex-col p-3">
{skills.length === 0 ? ( {skills.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center"> <PanelEmptyState
<span aria-hidden className="text-2xl"> icon={Zap}
title={`${agent.name}'s Skills`}
</span> subtitle={`Add workflows you want ${agent.name} to do.`}
<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"> <ul aria-label="Installed skills" className="flex-1">
{skills.map((skill) => ( {skills.map((skill) => (
@@ -102,7 +101,7 @@ export default function SkillsApp({ agent }: { agent: Agent }) {
<button <button
type="button" type="button"
onClick={() => setAdding(true)} 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" className="mx-auto w-[92%] rounded-radius-button bg-cream py-3 text-sm font-semibold text-primary-foreground shadow-button transition-colors duration-normal ease-app hover:bg-white"
> >
Add Skill Add Skill
</button> </button>
@@ -1,9 +1,11 @@
"use client"; "use client";
import { CheckCircle2, MessageCircle } from "lucide-react";
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
import { PanelEmptyState } from "./AppShell";
const TABS = ["Overview", "Channels", "Connection"] as const; const TABS = ["Overview", "Channels", "Connection"] as const;
@@ -50,7 +52,11 @@ export default function SlackApp({ agent }: { agent: Agent }) {
return ( return (
<div className="flex h-full flex-col p-3"> <div className="flex h-full flex-col p-3">
<div role="tablist" aria-label="Slack" className="flex gap-1 pb-3"> <div
role="tablist"
aria-label="Slack"
className="mb-3 inline-flex gap-1 self-start rounded-radius-button bg-white/10 p-1"
>
{TABS.map((entry) => ( {TABS.map((entry) => (
<button <button
key={entry} key={entry}
@@ -58,10 +64,10 @@ export default function SlackApp({ agent }: { agent: Agent }) {
role="tab" role="tab"
aria-selected={tab === entry} aria-selected={tab === entry}
onClick={() => setTab(entry)} onClick={() => setTab(entry)}
className={`rounded-(--radius-button) px-3 py-1 text-xs ${ className={`rounded-radius-button px-3 py-1 text-xs font-medium transition-colors duration-normal ease-app ${
tab === entry tab === entry
? "bg-surface-warm-muted text-foreground" ? "bg-white text-[#4a154b]"
: "text-muted-foreground hover:text-foreground" : "text-white/80 hover:text-white"
}`} }`}
> >
{entry} {entry}
@@ -78,7 +84,7 @@ export default function SlackApp({ agent }: { agent: Agent }) {
type="password" type="password"
required required
placeholder="xoxb-…" placeholder="xoxb-…"
className="rounded-(--radius) border border-input bg-subtle px-2 py-1.5 text-sm text-foreground outline-none focus:border-accent" className="rounded-xl border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-coral"
/> />
<span className="text-xxs"> <span className="text-xxs">
Stored encrypted by the secret broker never readable again, Stored encrypted by the secret broker never readable again,
@@ -92,42 +98,39 @@ export default function SlackApp({ agent }: { agent: Agent }) {
type="password" type="password"
required required
placeholder="Slack app signing secret" placeholder="Slack app signing secret"
className="rounded-(--radius) border border-input bg-subtle px-2 py-1.5 text-sm text-foreground outline-none focus:border-accent" className="rounded-xl border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-coral"
/> />
</label> </label>
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
className="self-start rounded-(--radius-button) bg-accent px-4 py-1.5 text-xs font-medium text-background hover:bg-coral-light disabled:opacity-50" className="self-start rounded-radius-button bg-coral px-4 py-2 text-xs font-medium text-white shadow-cta transition-colors duration-normal ease-app hover:bg-coral-light disabled:opacity-50"
> >
{saving ? "Connecting…" : "Connect Slack"} {saving ? "Connecting…" : "Connect Slack"}
</button> </button>
</form> </form>
) : connected ? ( ) : connected ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center"> <PanelEmptyState
<span aria-hidden className="text-2xl"> icon={CheckCircle2}
title="Slack is connected"
</span> subtitle={`${agent.name} can be asked to post — every outbound message still requires your approval first.`}
<p className="text-sm font-medium">Slack is connected</p> />
<p className="max-w-60 text-xs text-muted-foreground">
{agent.name} can be asked to post every outbound message still
requires your approval first.
</p>
</div>
) : ( ) : (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center"> <div className="flex min-h-[55%] flex-col items-center justify-center gap-3 px-6 py-10 text-center">
<span aria-hidden className="text-2xl"> <span className="flex size-14 items-center justify-center rounded-2xl bg-white shadow-dock-tile">
💬 <MessageCircle aria-hidden size={24} className="text-[#4a154b]" />
</span> </span>
<p className="text-sm font-medium">Bring this claw into Slack</p> <p className="text-base font-semibold text-foreground">
<p className="max-w-60 text-xs text-muted-foreground"> Bring this claw into Slack
</p>
<p className="max-w-64 text-xs text-muted-foreground">
Connect Slack so {agent.name} can post on your behalf. Outbound Connect Slack so {agent.name} can post on your behalf. Outbound
posts always require approval. posts always require approval.
</p> </p>
<button <button
type="button" type="button"
onClick={() => setTab("Connection")} onClick={() => setTab("Connection")}
className="rounded-(--radius-button) bg-accent px-4 py-1.5 text-xs font-medium text-background hover:bg-coral-light" className="rounded-radius-button bg-cream px-5 py-2.5 text-sm font-semibold text-primary-foreground shadow-button transition-colors duration-normal ease-app hover:bg-white"
> >
Connect Slack Connect Slack
</button> </button>