diff --git a/docs/design/PRODUCTION-READINESS.md b/docs/design/PRODUCTION-READINESS.md index 3e3f903703..4efb97b875 100644 --- a/docs/design/PRODUCTION-READINESS.md +++ b/docs/design/PRODUCTION-READINESS.md @@ -46,7 +46,7 @@ record; current behaviour is in the linked [architecture](../architecture.md) do | mecak8s (storage-free k8s-native agent) | ✅ shipped (MVP) · ✅ OPT-IN `/metrics` loopback scrape + OTLP push (ADR 0098) · ✅ verified external Redis TLS/ACL with transactional projected-file reload + last-valid generations (ADR 0240) · ✅ Helm 0.3.0 secure real-provider in-pod TLS+OIDC or edge-terminated TLS+OIDC (ClusterIP h2c), nullable spend ceilings, and pod scheduling controls · ⛔ CRD/Operator · ⛔ HPA (custom-metrics on active-runs) · ⛔ managed Redis provisioning (ElastiCache/MemoryStore — endpoint only) · ⛔ fix `mecated`'s unbounded `GracefulStop` (pre-existing, follow-up) | [mecak8s.md](../adr/0048-mecak8s.md) · [0098](../adr/0098-headless-telemetry.md) · [0240](../adr/0240-mecak8s-credential-reload-and-chart-security.md) · [0278](../adr/0278-mecak8s-edge-terminated-tls.md) · [MECAK8S-PLAN.md](./MECAK8S-PLAN.md) | [overview](../architecture.md) | | ACP adapter (editor stdio surface) | ✅ Phase 1+2 + bounded Phase 3 + multimodal shipped · ⛔ Phase 3 long-tail (rule persistence, grep-over-buffers, fs/* on resume) | [0001-acp-adapter.md](../adr/0001-acp-adapter.md) | [api surface](../architecture/api-surface.md) | | Conversation fork (peer session from a history snapshot) | ✅ shipped · ✅ effort override (mid-conversation effort switch, keeps the transcript — [0068](../adr/0068-effort-change-via-fork.md)) · ⛔ cross-provider/model fork (v2: replay-blob stripping) · ⛔ workspace-branching fork · ⛔ fork-from-event-log-at-arbitrary-point · ⛔ fork lineage (`forked_from` label) | [0065-conversation-fork.md](../adr/0065-conversation-fork.md) | [overview](../architecture.md) | -| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ✅ Skills (browse/create/upload/enable-disable, controller-mediated; learned-skills panel) · ⛔ Memory · Settings surfaces · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) | +| Studio (web client) | 🚧 landing as a stacked PR series: ✅ module foundation (vendored Atrium UI kit, toolchain, CI gates) · ✅ server tier (trusted proxy + managed-mode controller core, hermetic suite) · ✅ protocol seam + harness transport · ✅ workspace shell + runtime status · ✅ Chats core + hermetic browser e2e (fixture daemon) · ✅ Scheduled (authoring, carried-spec edit, fire log + per-fire transcripts) · ✅ Skills (browse/create/upload/enable-disable, controller-mediated; learned-skills panel) · ✅ Memory (read-only table + detail + consolidate, honest disabled/empty states) · ✅ Settings core (Personalize, agent identity, learning review) · ⛔ provider/model-router/gateway settings · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) | | _Historical / retired_ | — | [ARCHITECTURE.md](../adr/0004-v1-architecture.md) · [STEP-CHAIN.md](../adr/0006-v1-step-chain.md) · [TWELVE-PATTERNS-AUDIT.md](../adr/0007-twelve-patterns-audit.md) · [REPOMAP-TREE-SITTER.md](../adr/0029-repomap-tree-sitter.md) | — | ## Security diff --git a/studio/knip.ts b/studio/knip.ts index 485fceaacb..d14632a788 100644 --- a/studio/knip.ts +++ b/studio/knip.ts @@ -18,7 +18,6 @@ const config: KnipConfig = { "src/features/**", // Reached only through the UI kit until later PRs in the stacked series // land their first app-level consumers; each line leaves with that PR. - "src/hooks/use-mobile.ts", ], ignoreDependencies: [ // Tailwind v4 is imported via CSS (@import "tailwindcss"), not JS diff --git a/studio/src/app/workspace/memory/[memoryId]/page.tsx b/studio/src/app/workspace/memory/[memoryId]/page.tsx new file mode 100644 index 0000000000..295deeb475 --- /dev/null +++ b/studio/src/app/workspace/memory/[memoryId]/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { notFound, useParams, useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { useAgentMemory } from "@/features/agent"; +import { pageTitleClass } from "@/lib/typography"; + +/** + * A remembered fact as its own full page — deliberately OUTSIDE the settings + * layout (no settings nav), the same dedicated-detail treatment skills and + * schedules get. The list it backs out to stays under Settings → Memory. + */ +export default function MemoryDetailPage() { + const router = useRouter(); + const params = useParams<{ memoryId: string }>(); + const memory = useAgentMemory(); + // The route segment arrives URL-encoded; entry ids are the raw store keys. + const key = decodeURIComponent(params.memoryId); + const entry = memory.entries.find((e) => e.id === key); + + if (!entry) { + if (memory.isLoading) { + return ( +
+ Loading… +
+ ); + } + if (!memory.isSupported) { + return ( +
+

+ Memory is disabled on this daemon +

+ {memory.disabledReason && ( +

+ {memory.disabledReason} +

+ )} +
+ ); + } + return notFound(); + } + + return ( +
+
+ + + {/* The schedules/skills detail grammar: serif title, pill row, the + content leading unlabelled as its own card, then grouped facts. */} +

+ {entry.title} +

+ +
+

+ {entry.content || "No description recorded."} +

+ +
+

+ Details +

+
+
+ Key + + {entry.id} + +
+
+ Source + + Learned in conversation + +
+
+
+
+
+
+ ); +} diff --git a/studio/src/app/workspace/memory/page.tsx b/studio/src/app/workspace/memory/page.tsx new file mode 100644 index 0000000000..ebbeb48264 --- /dev/null +++ b/studio/src/app/workspace/memory/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +/** Memory moved under Settings; keep old bookmarks working. */ +export default function LegacyMemoryPage() { + redirect("/workspace/settings/memory"); +} diff --git a/studio/src/app/workspace/settings/_components/avatar-picker.tsx b/studio/src/app/workspace/settings/_components/avatar-picker.tsx new file mode 100644 index 0000000000..9e48e51afa --- /dev/null +++ b/studio/src/app/workspace/settings/_components/avatar-picker.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { X } from "lucide-react"; +import { useRef } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; + +/** Longest edge of a stored avatar; they render at 56px, so this is ample. */ +const AVATAR_MAX_DIM = 512; + +/** + * Downscale a picked image in the browser so any size of upload fits + * comfortably in local storage: longest edge capped at AVATAR_MAX_DIM, + * re-encoded as JPEG (composited over white — JPEG has no alpha). + */ +async function downscaleAvatar(file: File): Promise { + const url = URL.createObjectURL(file); + try { + const img = await new Promise((resolve, reject) => { + const el = new Image(); + el.onload = () => resolve(el); + el.onerror = () => reject(new Error("undecodable image")); + el.src = url; + }); + const scale = Math.min( + 1, + AVATAR_MAX_DIM / Math.max(img.naturalWidth, img.naturalHeight), + ); + const width = Math.max(1, Math.round(img.naturalWidth * scale)); + const height = Math.max(1, Math.round(img.naturalHeight * scale)); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("canvas unavailable"); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + ctx.drawImage(img, 0, 0, width, height); + return canvas.toDataURL("image/jpeg", 0.85); + } finally { + URL.revokeObjectURL(url); + } +} + +/** + * A stored-picture control: circular preview (or the given fallback), + * upload/change via the file picker with in-browser downscaling, and remove. + * Backed by any of the browser-local avatar preferences. + */ +export function AvatarPicker({ + avatarUrl, + onChange, + alt, + fallback, +}: { + avatarUrl: string | null; + onChange: (next: string | null) => void; + alt: string; + /** Rendered inside the circle when no picture is stored. */ + fallback: React.ReactNode; +}) { + const fileInputRef = useRef(null); + + function handleFileChange(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + if (!file.type.startsWith("image/")) { + toast.error("Choose an image file."); + return; + } + downscaleAvatar(file) + .then(onChange) + .catch(() => toast.error("Could not read that image.")); + } + + return ( +
+
+
+ {avatarUrl ? ( + // biome-ignore lint/performance/noImgElement: a locally stored data URL, not a remote image + {alt} + ) : ( + fallback + )} +
+ {/* Remove lives on the picture itself: hover (or keyboard focus) + reveals a small ×; without a picture there is nothing to remove. */} + {avatarUrl && ( + + )} +
+
+ + +
+
+ ); +} diff --git a/studio/src/app/workspace/settings/_components/option-field.tsx b/studio/src/app/workspace/settings/_components/option-field.tsx new file mode 100644 index 0000000000..a07fdf4953 --- /dev/null +++ b/studio/src/app/workspace/settings/_components/option-field.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { Check, ChevronDown } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { cn } from "@/lib/utils"; + +export interface OptionItem { + value: string; + label: string; + icon?: React.ComponentType<{ className?: string }>; +} + +/** + * A select-style settings control: a trigger button showing the current + * choice that opens a dropdown on desktop and a bottom sheet on mobile + * (the app's picker convention — pill groups don't scale on small screens). + */ +export function OptionField({ + label, + value, + options, + onChange, +}: { + /** Accessible name for the control (the visible label lives beside it). */ + label: string; + value: string; + options: readonly OptionItem[]; + onChange: (value: string) => void; +}) { + const isMobile = useIsMobile(); + const [sheetOpen, setSheetOpen] = useState(false); + const current = options.find((option) => option.value === value); + const CurrentIcon = current?.icon; + + const trigger = ( + + ); + + if (isMobile) { + return ( + <> + {trigger} + + + {label} +
+ {options.map((option) => { + const Icon = option.icon; + return ( + + ); + })} +
+
+
+ + ); + } + + return ( + + {trigger} + + {options.map((option) => { + const Icon = option.icon; + return ( + onChange(option.value)} + > + + {Icon && } + {option.label} + + ); + })} + + + ); +} diff --git a/studio/src/app/workspace/settings/_components/profile-section.tsx b/studio/src/app/workspace/settings/_components/profile-section.tsx new file mode 100644 index 0000000000..d16d27726b --- /dev/null +++ b/studio/src/app/workspace/settings/_components/profile-section.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { User } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { useUserAvatar, useUserDisplayName } from "@/lib/profile-preferences"; +import { AvatarPicker } from "./avatar-picker"; +import { SettingsCard, SettingsRow } from "./settings-card"; + +/** + * The user's own identity preferences — browser-local only: there is no + * daemon concept of a user profile to write back to. The agent's identity + * lives on the separate Agent page. + */ +export function ProfileSection() { + const { avatarUrl, setAvatarUrl } = useUserAvatar(); + const { name, setName } = useUserDisplayName(); + + return ( + +
+ + setName(event.target.value)} + maxLength={40} + className="w-44 min-[500px]:w-60" + /> + + + } + /> + +
+
+ ); +} diff --git a/studio/src/app/workspace/settings/_components/settings-card.tsx b/studio/src/app/workspace/settings/_components/settings-card.tsx new file mode 100644 index 0000000000..e2b96e4749 --- /dev/null +++ b/studio/src/app/workspace/settings/_components/settings-card.tsx @@ -0,0 +1,103 @@ +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; + +/** Shared card shell for Settings sections, so runtime and preference cards + * read as one page. Follows the app's sectioned-settings grammar (see the + * keyboard-shortcuts page): `rounded-xl border bg-card p-5` with an uppercase + * eyebrow title. */ +export function SettingsCard({ + title, + description, + children, +}: { + title: string; + description?: string; + children: React.ReactNode; +}) { + return ( + // On mobile the card chrome is redundant — the back bar already names + // the section — so the box and header dissolve into the page. +
+
+

+ {title} +

+ {description ? ( +

{description}

+ ) : null} +
+ {children} +
+ ); +} + +/** + * The shared settings row idiom: label (and optional description) on the + * left, the control on the right. Wrap consecutive rows in + * `
` for hairline separators — + * the first/last padding collapses so a lone row sits flush in its card. + */ +export function SettingsRow({ + label, + description, + htmlFor, + className, + children, +}: { + label: React.ReactNode; + description?: React.ReactNode; + /** Ties the label to a form control (e.g. a Switch or Input id). */ + htmlFor?: string; + className?: string; + children: React.ReactNode; +}) { + return ( +
+
+ {htmlFor ? ( + + ) : ( +

{label}

+ )} + {description ? ( +

+ {description} +

+ ) : null} +
+
{children}
+
+ ); +} + +export function Note({ children }: { children: React.ReactNode }) { + return

{children}

; +} + +/** Shown in place of a form when the configuration is owned elsewhere: the + * controller answers 409 for every write in external mode, so offering the + * form would only manufacture errors. */ +function ExternalManagedNote() { + return ( + + Managed by the external mecated deployment. Configuration writes are not + available from this UI — change the deployment’s own settings + instead. + + ); +} + +function OfflineNote() { + return ( + + The runtime is offline — its configuration cannot be read right now. + + ); +} diff --git a/studio/src/app/workspace/settings/_components/settings-header.tsx b/studio/src/app/workspace/settings/_components/settings-header.tsx new file mode 100644 index 0000000000..2f963d042e --- /dev/null +++ b/studio/src/app/workspace/settings/_components/settings-header.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { pageTitleClass } from "@/lib/typography"; +import { cn } from "@/lib/utils"; +import { settingsSectionFor } from "./settings-sections"; + +/** + * Mobile-only subpage header, styled like the chat header: a full-bleed + * h-14 bar with a back arrow and the section title. On a section's deeper + * pages (e.g. a memory entry) the arrow goes up to the section; on the + * section page itself it goes back to the settings index. Renders nothing + * on the index or from 500px up. + */ +export function SettingsMobileBar() { + const pathname = usePathname() ?? ""; + const section = settingsSectionFor(pathname); + if (!section) return null; + + const backHref = + pathname === section.href ? "/workspace/settings" : section.href; + + return ( +
+ +

+ {section.label} +

+
+ ); +} + +/** + * The big serif page title. On a mobile subpage the SettingsMobileBar + * replaces it; everywhere else (desktop, and the mobile index) it renders. + */ +export function SettingsTitle() { + const pathname = usePathname() ?? ""; + const section = settingsSectionFor(pathname); + + return ( +

+ Settings +

+ ); +} diff --git a/studio/src/app/workspace/settings/_components/settings-nav.tsx b/studio/src/app/workspace/settings/_components/settings-nav.tsx new file mode 100644 index 0000000000..2c3fc97680 --- /dev/null +++ b/studio/src/app/workspace/settings/_components/settings-nav.tsx @@ -0,0 +1,54 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { SETTINGS_GROUPS } from "./settings-sections"; + +/** + * The settings sections as a left secondary menu; one subpage per section. + * Hidden on mobile, where the settings index renders the same sections as a + * drill-down list instead (see `settings/page.tsx`). + */ +export function SettingsNav() { + const pathname = usePathname(); + return ( + + ); +} diff --git a/studio/src/app/workspace/settings/_components/settings-sections.ts b/studio/src/app/workspace/settings/_components/settings-sections.ts new file mode 100644 index 0000000000..19a5982c40 --- /dev/null +++ b/studio/src/app/workspace/settings/_components/settings-sections.ts @@ -0,0 +1,72 @@ +import { Bot, Brain, GraduationCap, Palette, UserRound } from "lucide-react"; + +export interface SettingsSection { + href: string; + label: string; + icon: React.ComponentType<{ className?: string }>; +} + +/** + * The settings information architecture, shared by the desktop secondary nav, + * the mobile drill-down list on the settings index, and the mobile subpage + * back-header. One entry per subpage. + */ +export const SETTINGS_GROUPS: Array<{ + label: string; + items: SettingsSection[]; +}> = [ + { + label: "Preferences", + items: [ + { + href: "/workspace/settings/profile", + label: "You", + icon: UserRound, + }, + { + href: "/workspace/settings/appearance", + label: "Personalize", + icon: Palette, + }, + // After Appearance: identity, then how the app looks, then how the chat + // behaves, then when it interrupts you. + ], + }, + { + label: "Agent runtime", + items: [ + { + href: "/workspace/settings/agent", + label: "Agent", + icon: Bot, + }, + { + href: "/workspace/settings/memory", + label: "Memory", + icon: Brain, + }, + { + href: "/workspace/settings/learning", + label: "Learning", + icon: GraduationCap, + }, + ], + }, +]; + +/** + * The section a pathname belongs to, for the mobile back-header and the + * desktop nav active state. Prefix-aware so a section's deeper pages + * (e.g. a memory entry) still resolve to their section. + */ +export function settingsSectionFor( + pathname: string, +): SettingsSection | undefined { + for (const group of SETTINGS_GROUPS) { + const hit = group.items.find( + (item) => item.href === pathname || pathname.startsWith(`${item.href}/`), + ); + if (hit) return hit; + } + return undefined; +} diff --git a/studio/src/app/workspace/settings/agent/page.tsx b/studio/src/app/workspace/settings/agent/page.tsx new file mode 100644 index 0000000000..1a1a6f5b97 --- /dev/null +++ b/studio/src/app/workspace/settings/agent/page.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Bot } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { useAgentAvatar, useAgentDisplayName } from "@/lib/profile-preferences"; +import { AvatarPicker } from "../_components/avatar-picker"; +import { SettingsCard, SettingsRow } from "../_components/settings-card"; + +/** + * The agent's cosmetic identity — display name and picture, browser-local + * (no daemon concept of either). The picture replaces the default bot mark + * in chat. + */ +export default function AgentSettingsPage() { + const { name, setName, defaultName } = useAgentDisplayName(); + const { avatarUrl, setAvatarUrl } = useAgentAvatar(); + + return ( + +
+ + setName(event.target.value)} + maxLength={40} + className="w-44 min-[500px]:w-60" + /> + + + } + /> + +
+
+ ); +} diff --git a/studio/src/app/workspace/settings/appearance/page.tsx b/studio/src/app/workspace/settings/appearance/page.tsx new file mode 100644 index 0000000000..7efe7f02a1 --- /dev/null +++ b/studio/src/app/workspace/settings/appearance/page.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { + Bell, + BellRing, + CornerDownRight, + ListEnd, + Minus, + Monitor, + Moon, + PanelLeft, + PanelRight, + Plus, + Sun, +} from "lucide-react"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + UI_SCALE_MAX, + UI_SCALE_MIN, + useSessionListSide, + useUiScale, +} from "@/lib/profile-preferences"; +import { OptionField } from "../_components/option-field"; +import { SettingsCard, SettingsRow } from "../_components/settings-card"; + +const THEME_OPTIONS = [ + { value: "light", label: "Light", icon: Sun }, + { value: "dark", label: "Dark", icon: Moon }, + { value: "system", label: "System", icon: Monitor }, +] as const; + +const SIDE_OPTIONS = [ + { value: "left", label: "Left", icon: PanelLeft }, + { value: "right", label: "Right", icon: PanelRight }, +] as const; + +export default function AppearanceSettingsPage() { + const { theme: activeTheme, setTheme } = useTheme(); + const { side, setSide } = useSessionListSide(); + const { scale, setScale } = useUiScale(); + + // Browser notifications: permission mirrored into state so the row reflects + // granted / denied / not-yet-asked; "unsupported" hides the row's actions. + const [notifyPermission, setNotifyPermission] = useState< + NotificationPermission | "unsupported" + >("default"); + useEffect(() => { + if (typeof window !== "undefined" && "Notification" in window) { + setNotifyPermission(Notification.permission); + } else { + setNotifyPermission("unsupported"); + } + }, []); + + async function enableNotifications() { + if (typeof Notification === "undefined") return; + const result = await Notification.requestPermission(); + setNotifyPermission(result); + if (result === "granted") { + toast.success("Browser notifications enabled"); + } else if (result === "denied") { + toast.error("Notifications are blocked — enable them in your browser."); + } + } + + function sendTestNotification() { + if ( + typeof Notification === "undefined" || + Notification.permission !== "granted" + ) { + return; + } + new Notification("Scheduled task finished", { + body: "Daily dependency audit completed — 0 critical vulnerabilities found.", + tag: "atrium-example", + icon: "/favicon.ico", + }); + toast.success("Test notification sent"); + } + + // next-themes resolves only on the client; gate the current value on mount + // so the trigger shows the real choice instead of a flash of "system". + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + return ( + +
+ + + + + + {/* Same footprint as the OptionField triggers so the control + column lines up. */} +
+ + + {Math.round(scale * 100)}% + + +
+
+ + {/* Meaningless on mobile — the session list is full-screen there. */} + + setSide(next as "left" | "right")} + /> + + + {notifyPermission !== "unsupported" && ( + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/studio/src/app/workspace/settings/chat/page.tsx b/studio/src/app/workspace/settings/chat/page.tsx new file mode 100644 index 0000000000..8c718de438 --- /dev/null +++ b/studio/src/app/workspace/settings/chat/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +/** The Enter-behavior setting lives on Personalize now; keep old links. */ +export default function LegacyChatSettingsPage() { + redirect("/workspace/settings/appearance"); +} diff --git a/studio/src/app/workspace/settings/layout.tsx b/studio/src/app/workspace/settings/layout.tsx new file mode 100644 index 0000000000..3d4851f6b1 --- /dev/null +++ b/studio/src/app/workspace/settings/layout.tsx @@ -0,0 +1,30 @@ +import { + SettingsMobileBar, + SettingsTitle, +} from "./_components/settings-header"; +import { SettingsNav } from "./_components/settings-nav"; + +export default function SettingsLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+ {/* Mobile subpages get a chat-style back header, full-bleed so its + border spans the card; the scroll area below keeps the padding. */} + +
+
+ +
+ + {/* Capped at the reading width the shortcuts page established, so + forms don't stretch across very wide viewports. */} +
{children}
+
+
+
+
+ ); +} diff --git a/studio/src/app/workspace/settings/learning/page.tsx b/studio/src/app/workspace/settings/learning/page.tsx new file mode 100644 index 0000000000..0d8362d240 --- /dev/null +++ b/studio/src/app/workspace/settings/learning/page.tsx @@ -0,0 +1,455 @@ +"use client"; + +import { GraduationCap } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useRuntimeStatus } from "@/features/agent/runtime-status"; +import { formatRelativeTime } from "@/lib/formatters"; +import { fetchAllSessions } from "@/lib/harness/client"; +import { + decideLearningProposal, + isProposalConflict, + type LearningProposal, + listLearningProposals, + type ReflectionReceipt, + reflectHarnessSession, + undoLearningPromotion, +} from "@/lib/harness/learning"; +import { cn } from "@/lib/utils"; +import { Note, SettingsCard } from "../_components/settings-card"; + +/** + * Settings → Learning: the human half of the daemon's reflection loop + * (ADR 0109). Pending proposals are reviewed here — approve promotes the + * daemon-curated digest into memory, reject retires it, undo reverts a + * promotion. Studio never composes memory content; it only decides on what + * the daemon staged (the same posture as the read-only Memory panel). + */ + +/** Status pills over the daemon's proposal vocabulary ("staged" = pending). */ +const PROPOSAL_FILTERS = [ + { value: "staged", label: "Pending" }, + { value: "promoted", label: "Promoted" }, + { value: "rejected", label: "Rejected" }, +] as const; +type ProposalFilterValue = (typeof PROPOSAL_FILTERS)[number]["value"]; + +export default function LearningSettingsPage() { + const runtime = useRuntimeStatus(); + const proposalsSupported = + runtime.serverCapabilities.learning_proposals === true; + const reflectionSupported = runtime.serverCapabilities.reflection === true; + + if (!proposalsSupported && !reflectionSupported) { + return ( +
+
+
+ +
+
+

+ Learning is not supported by this daemon +

+

+ This daemon reports neither learning proposals nor reflection in + its capabilities. Enable learning in the daemon’s settings + (learning.mode) to review what the agent wants to remember. +

+
+
+
+ ); + } + + return ( + <> + {proposalsSupported ? ( + + ) : ( + + Learning proposals are not enabled on this daemon. + + )} + {reflectionSupported && } + + ); +} + +/** Approve/undo eligibility is the daemon's call, threaded per proposal. */ +function proposalActionTitle(proposal: LearningProposal): string | undefined { + if (proposal.promotionAvailable) return undefined; + return ( + proposal.promotionUnavailableReason || + "This partition has no trusted memory target." + ); +} + +function ProposalQueueCard({ connected }: { connected: boolean }) { + const [filter, setFilter] = useState("staged"); + const [proposals, setProposals] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [busyId, setBusyId] = useState(null); + + const load = useCallback( + async (signal?: AbortSignal) => { + try { + const page = await listLearningProposals({ status: filter }, signal); + if (signal?.aborted) return; + setProposals(page.proposals); + setError(null); + } catch (caught) { + if (signal?.aborted) return; + setProposals([]); + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + if (!signal?.aborted) setIsLoading(false); + } + }, + [filter], + ); + + useEffect(() => { + if (!connected) return; + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [connected, load]); + + /** + * Runs one decision/undo. A 409 proposal_conflict means the proposal + * changed underneath the review — the honest move is refresh-and-re-review, + * never a blind retry with the new version. + */ + const act = async ( + proposal: LearningProposal, + run: () => Promise, + done: string, + ) => { + setBusyId(proposal.id); + setNotice(null); + try { + await run(); + toast.success(done); + await load(); + } catch (caught) { + if (isProposalConflict(caught)) { + setNotice( + "That proposal changed since it was loaded — the queue was refreshed. Review it again before deciding.", + ); + await load(); + } else { + setError(caught instanceof Error ? caught.message : String(caught)); + } + } finally { + setBusyId(null); + } + }; + + return ( + +
+
+ {PROPOSAL_FILTERS.map((f) => ( + + ))} +
+ + {notice && ( +

+ {notice} +

+ )} + {error &&

{error}

} + + {isLoading && connected ? ( +

+ Loading proposals… +

+ ) : proposals.length === 0 ? ( +

+ {filter === "staged" + ? "Nothing waiting for review." + : `No ${filter} proposals.`} +

+ ) : ( +
    + {proposals.map((proposal) => ( + + act( + proposal, + () => + decideLearningProposal( + proposal.id, + "approve", + proposal.version, + ), + "Proposal approved", + ) + } + onReject={() => + act( + proposal, + () => + decideLearningProposal( + proposal.id, + "reject", + proposal.version, + ), + "Proposal rejected", + ) + } + onUndo={() => + act( + proposal, + () => undoLearningPromotion(proposal.id, proposal.version), + "Promotion undone", + ) + } + /> + ))} +
+ )} +
+
+ ); +} + +/** One proposal: the bounded digest detail plus the status-appropriate actions. */ +function ProposalRow({ + proposal, + busy, + onApprove, + onReject, + onUndo, +}: { + proposal: LearningProposal; + busy: boolean; + onApprove: () => void; + onReject: () => void; + onUndo: () => void; +}) { + const updated = formatRelativeTime(proposal.updatedAtUnix * 1000); + const digest = proposal.value || proposal.body; + const title = proposal.title || proposal.key || proposal.id; + + return ( +
  • +
    + + {title} + + {proposal.kind && {proposal.kind}} + {proposal.projectScoped && project} + {updated && ( + {updated} ago + )} +
    + {proposal.key && proposal.key !== title && ( +

    + {proposal.key} +

    + )} + {proposal.description && ( +

    {proposal.description}

    + )} + {digest && ( +
    +          {digest}
    +        
    + )} +
    + {proposal.evidenceCount > 0 && ( + + {proposal.evidenceCount} evidence ref + {proposal.evidenceCount === 1 ? "" : "s"} + + )} + {proposal.triggers.slice(0, 4).map((trigger) => ( + + {trigger} + + ))} +
    +
    + {proposal.status === "staged" && ( + <> + + + + )} + {proposal.status === "promoted" && ( + + )} + {proposal.status !== "staged" && proposal.status !== "promoted" && ( + {proposal.status.replaceAll("_", " ")} + )} +
    +
  • + ); +} + +/** + * Explicit reflection over one completed chat: the daemon re-reads the + * session and stages proposals from it (which then land in the queue above). + * Synchronous and model-driven — it can take a minute. + */ +function ReflectionCard({ connected }: { connected: boolean }) { + const [sessions, setSessions] = useState<{ id: string; title: string }[]>([]); + const [selected, setSelected] = useState(""); + const [isReflecting, setIsReflecting] = useState(false); + const [receipt, setReceipt] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!connected) return; + const controller = new AbortController(); + // One inventory page is plenty for a picker; rows arrive newest-first. + fetchAllSessions(controller.signal, 1) + .then(({ sessions: rows }) => { + if (controller.signal.aborted) return; + setSessions( + rows + .filter((row) => row.isChat && row.state === "completed") + .slice(0, 20) + .map((row) => ({ + id: row.sessionId, + title: row.title || row.sessionId, + })), + ); + }) + .catch(() => { + if (!controller.signal.aborted) setSessions([]); + }); + return () => controller.abort(); + }, [connected]); + + const reflect = async () => { + if (!selected) return; + setIsReflecting(true); + setError(null); + setReceipt(null); + try { + setReceipt(await reflectHarnessSession(selected)); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setIsReflecting(false); + } + }; + + const summary = useMemo(() => { + if (!receipt) return null; + if (receipt.abstained) { + return "The daemon abstained — nothing in that session was worth remembering."; + } + const parts = [ + `${receipt.staged} staged`, + `${receipt.promoted} promoted`, + `${receipt.conflicted} conflicted`, + ]; + if (receipt.queued > 0) parts.push(`${receipt.queued} queued`); + return parts.join(" · "); + }, [receipt]); + + return ( + +
    +
    + + +
    + {sessions.length === 0 && ( + No completed chats to reflect on yet. + )} + {isReflecting && ( +

    + Reflection is model-driven and can take a minute — leave this page + open. +

    + )} + {summary && ( +

    + Reflection {receipt?.disposition || "finished"}: {summary} +

    + )} + {error &&

    {error}

    } +
    +
    + ); +} diff --git a/studio/src/app/workspace/settings/memory/[memoryId]/page.tsx b/studio/src/app/workspace/settings/memory/[memoryId]/page.tsx new file mode 100644 index 0000000000..4af310db33 --- /dev/null +++ b/studio/src/app/workspace/settings/memory/[memoryId]/page.tsx @@ -0,0 +1,14 @@ +import { redirect } from "next/navigation"; + +/** + * Memory details are a dedicated page outside the settings nav now; keep the + * old nested URL working for bookmarks. + */ +export default async function LegacySettingsMemoryDetailPage({ + params, +}: { + params: Promise<{ memoryId: string }>; +}) { + const { memoryId } = await params; + redirect(`/workspace/memory/${memoryId}`); +} diff --git a/studio/src/app/workspace/settings/memory/_components/consolidate-memory.tsx b/studio/src/app/workspace/settings/memory/_components/consolidate-memory.tsx new file mode 100644 index 0000000000..22225057a9 --- /dev/null +++ b/studio/src/app/workspace/settings/memory/_components/consolidate-memory.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useRuntimeStatus } from "@/features/agent/runtime-status"; +import { + type DreamPlan, + type DreamReceipt, + type DreamTarget, + decideDreamPlan, + dreamTargetCapability, + generateDreamPlan, + isStaleDreamPlan, +} from "@/lib/harness/dream"; +import { SettingsCard } from "../../_components/settings-card"; + +/** + * Manual memory consolidation (ADR 0227): the daemon curates a bounded merge + * plan over its own memory store; the human applies or dismisses the WHOLE + * plan. This stays inside memory rule 8 — Studio never composes memory + * content, it only decides on what the daemon proposed. + * + * Plan ids are process-local: a daemon restart answers dream_not_found, which + * renders as "regenerate", never as a retry. + */ + +const TARGET_LABELS: Record = { + user_model: "User model (facts about you)", + project_memory: "Project memory", +}; + +export function ConsolidateMemoryCard() { + const { connected, serverCapabilities } = useRuntimeStatus(); + const manualDream = serverCapabilities.manual_dream; + + const targets = useMemo( + () => + (Object.keys(TARGET_LABELS) as DreamTarget[]).filter( + (target) => dreamTargetCapability(manualDream, target).generate, + ), + [manualDream], + ); + + const [target, setTarget] = useState("user_model"); + const [plan, setPlan] = useState(null); + const [receipt, setReceipt] = useState(null); + const [isGenerating, setIsGenerating] = useState(false); + const [isDeciding, setIsDeciding] = useState(false); + const [notice, setNotice] = useState(null); + const [error, setError] = useState(null); + const [confirmApply, setConfirmApply] = useState(false); + + // Absent capability → the surface hides entirely (older/leaner daemon). + if (targets.length === 0) return null; + + const effectiveTarget = targets.includes(target) ? target : targets[0]; + const canDecide = dreamTargetCapability(manualDream, effectiveTarget).decide; + + const generate = async () => { + setIsGenerating(true); + setError(null); + setNotice(null); + setReceipt(null); + setPlan(null); + try { + setPlan(await generateDreamPlan(effectiveTarget)); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setIsGenerating(false); + } + }; + + const decide = async (decision: "apply" | "dismiss") => { + if (!plan) return; + setIsDeciding(true); + setError(null); + try { + setReceipt(await decideDreamPlan(plan.id, decision)); + setPlan(null); + } catch (caught) { + if (isStaleDreamPlan(caught)) { + // Process-local id: the daemon restarted or the plan aged out. + setPlan(null); + setNotice( + "That plan is no longer valid (the daemon restarted or the plan expired) — generate a new one.", + ); + } else { + setError(caught instanceof Error ? caught.message : String(caught)); + } + } finally { + setIsDeciding(false); + } + }; + + return ( + +
    +
    + {targets.length > 1 ? ( + + ) : ( + + {TARGET_LABELS[effectiveTarget]} + + )} + +
    + {isGenerating && ( +

    + The daemon is reviewing its memories — this can take a minute. +

    + )} + {notice && ( +

    + {notice} +

    + )} + {error &&

    {error}

    } + + {plan && plan.operations.length === 0 && ( +
    +

    + Nothing to consolidate — this memory is already tidy. +

    + +
    + )} + + {plan && plan.operations.length > 0 && ( +
    +

    + {plan.plannedOperationCount} operation + {plan.plannedOperationCount === 1 ? "" : "s"} over{" "} + {plan.plannedSourceCount} memor + {plan.plannedSourceCount === 1 ? "y" : "ies"}. Review, then apply + or dismiss the whole plan. +

    +
      + {plan.operations.map((operation) => ( +
    • source.key) + .join(",")}`} + className="space-y-2 rounded-lg border p-3" + > +
      + + {operation.kind.replaceAll("_", " ") || "merge"} + + {operation.reason && ( + + {operation.reason} + + )} +
      +
      +

      + Keeps{" "} + + {operation.survivor.key} + + {operation.sources.length > 0 && ( + <> + {" "} + — absorbs{" "} + {operation.sources.map((source, i) => ( + + {i > 0 && ", "} + {source.key} + + ))} + + )} +

      + {operation.replacement.value && ( +
      +                        {operation.replacement.value}
      +                      
      + )} +
      +
    • + ))} +
    +
    + + +
    +
    + )} + + {receipt && ( +

    + {receipt.disposition === "apply" + ? `Applied: ${receipt.applied} merged, ${receipt.conflicted} conflicted, ${receipt.skipped} skipped, ${receipt.failed} failed.` + : "Plan dismissed — nothing changed."} +

    + )} +
    + + + + + Apply the consolidation plan? + + The daemon merges the listed memories exactly as shown. Absorbed + entries are replaced by their survivor; a memory that changed + since planning is skipped as conflicted, never overwritten. + + + + Cancel + { + setConfirmApply(false); + void decide("apply"); + }} + > + Apply + + + + +
    + ); +} diff --git a/studio/src/app/workspace/settings/memory/page.tsx b/studio/src/app/workspace/settings/memory/page.tsx new file mode 100644 index 0000000000..6bcf910e6e --- /dev/null +++ b/studio/src/app/workspace/settings/memory/page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { Brain } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo } from "react"; +import { + directed, + SortableHead, + useTableSort, +} from "@/components/sortable-head"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { type MemoryEntry, useAgentMemory } from "@/features/agent"; +import { ConsolidateMemoryCard } from "./_components/consolidate-memory"; + +/** + * The agent's remembered facts, read-only — a settings subpage. The one + * mutation offered is the daemon-curated consolidation flow below the table + * (ADR 0227), which never accepts free-text content (memory rule 8). + */ +export default function MemorySettingsPage() { + const memory = useAgentMemory(); + const sort = useTableSort<"name" | "remembers">("name"); + + const entries = useMemo(() => { + const byName = (a: MemoryEntry, b: MemoryEntry) => + a.title.localeCompare(b.title); + const primary = (a: MemoryEntry, b: MemoryEntry) => + sort.key === "remembers" + ? (a.content || "").localeCompare(b.content || "") + : byName(a, b); + return [...memory.entries].sort( + (a, b) => directed(sort.dir, primary(a, b)) || byName(a, b), + ); + }, [memory.entries, sort.key, sort.dir]); + + // The consolidation card gates itself on capabilities.manual_dream, and the + // project-memory target can be consolidatable even when the user model is + // disabled — so it renders as a sibling of every state below. + if (!memory.isSupported) { + return ( + <> +
    +
    +
    + +
    +
    +

    + Memory is disabled on this daemon +

    +

    + The daemon is running without a user model (e.g. started with + --no-user-model), so there are no remembered facts to show. +

    + {memory.disabledReason && ( +

    + {memory.disabledReason} +

    + )} +
    +
    +
    + + + ); + } + + if (memory.isLoading) { + return ( +
    + Loading memory… +
    + ); + } + + if (entries.length === 0) { + return ( + <> +
    + The agent hasn't stored any facts yet. +
    + + + ); + } + + return ( + <> +
    + + + + + + + + + {entries.map((entry) => ( + + ))} + +
    +
    + + + ); +} + +/** One fact per row; the whole row opens the dedicated detail page. */ +function MemoryRow({ entry }: { entry: MemoryEntry }) { + const router = useRouter(); + const href = `/workspace/memory/${encodeURIComponent(entry.id)}`; + + return ( + router.push(href)}> + + e.stopPropagation()} + className="block truncate text-sm font-medium hover:underline" + > + {entry.title} + + {/* Mobile collapses to a single stacked cell, like the other tables. */} +

    + {entry.content || "No description recorded."} +

    +
    + +

    + {entry.content || "No description recorded."} +

    +
    +
    + ); +} diff --git a/studio/src/app/workspace/settings/notifications/page.tsx b/studio/src/app/workspace/settings/notifications/page.tsx new file mode 100644 index 0000000000..ba4711c5b9 --- /dev/null +++ b/studio/src/app/workspace/settings/notifications/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +/** Notification settings live on Personalize now; keep old links. */ +export default function LegacyNotificationSettingsPage() { + redirect("/workspace/settings/appearance"); +} diff --git a/studio/src/app/workspace/settings/page.tsx b/studio/src/app/workspace/settings/page.tsx new file mode 100644 index 0000000000..41c602f788 --- /dev/null +++ b/studio/src/app/workspace/settings/page.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { cn } from "@/lib/utils"; +import { SETTINGS_GROUPS } from "./_components/settings-sections"; + +/** + * The settings index. On mobile it is the first level of a native-style + * drill-down, following the inset-grouped-list convention: filled cards + * (no border), a neutral icon square, and hairline dividers inset to the + * text edge. Desktop keeps the old + * behaviour — land on the first section, with the left secondary nav for + * switching — via a client redirect (the split is a viewport question, + * so the server cannot decide it). + */ +export default function SettingsIndexPage() { + const router = useRouter(); + + useEffect(() => { + if (window.matchMedia("(min-width: 500px)").matches) { + router.replace("/workspace/settings/profile"); + } + }, [router]); + + return ( +
    + {SETTINGS_GROUPS.map((group) => ( +
    +

    + {group.label} +

    +
    + {group.items.map((item, index) => { + const Icon = item.icon; + return ( + + {/* Bare glyph, sized explicitly so the global mobile + size-4 bump doesn't inflate it. */} + + {/* The divider hangs off the row body so it stays inset + to the text edge, native-list style. */} + 0 && "border-t border-border/60", + )} + > + + {item.label} + + + + + ); + })} +
    +
    + ))} +
    + ); +} diff --git a/studio/src/app/workspace/settings/profile/page.tsx b/studio/src/app/workspace/settings/profile/page.tsx new file mode 100644 index 0000000000..6aa2ca1951 --- /dev/null +++ b/studio/src/app/workspace/settings/profile/page.tsx @@ -0,0 +1,5 @@ +import { ProfileSection } from "../_components/profile-section"; + +export default function ProfileSettingsPage() { + return ; +} diff --git a/studio/src/app/workspace/setup/page.tsx b/studio/src/app/workspace/setup/page.tsx new file mode 100644 index 0000000000..8b5d649fc2 --- /dev/null +++ b/studio/src/app/workspace/setup/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +/** + * Workspace-scoped agent settings were merged into the personal Settings page, + * so this route now redirects there; bookmarks and old links still land on the + * right place. + */ +export default function WorkspaceSettingsPage() { + redirect("/workspace/settings"); +} diff --git a/studio/src/lib/profile-preferences.ts b/studio/src/lib/profile-preferences.ts index ca00ed804b..2176f73dd2 100644 --- a/studio/src/lib/profile-preferences.ts +++ b/studio/src/lib/profile-preferences.ts @@ -79,8 +79,8 @@ export function useAgentAvatar() { return useStoredAvatar(AGENT_AVATAR_KEY); } -const UI_SCALE_MIN = 0.85; -const UI_SCALE_MAX = 1.3; +export const UI_SCALE_MIN = 0.85; +export const UI_SCALE_MAX = 1.3; const UI_SCALE_KEY = "mecatl-studio.ui-scale"; diff --git a/studio/tests/e2e/workspace.spec.ts b/studio/tests/e2e/workspace.spec.ts index e1ee3346e2..37e4c322fb 100644 --- a/studio/tests/e2e/workspace.spec.ts +++ b/studio/tests/e2e/workspace.spec.ts @@ -41,3 +41,12 @@ test("skills render the resolved inventory", async ({ page }) => { .filter({ visible: true }), ).toBeVisible(); }); + +test("memory renders the user model, read-only", async ({ page }) => { + await page.goto("/workspace/settings/memory"); + await expect( + page + .getByText("prefers tabs over spaces", { exact: false }) + .filter({ visible: true }), + ).toBeVisible(); +}); diff --git a/user-docs/building/what-you-get/studio.md b/user-docs/building/what-you-get/studio.md index 4036f40334..4fffddae5f 100644 --- a/user-docs/building/what-you-get/studio.md +++ b/user-docs/building/what-you-get/studio.md @@ -12,10 +12,8 @@ Studio reads and writes the daemon's state rather than keeping its own. :::note Landing in progress Studio is landing as a stacked series of pull requests. This page grows with -each one; right now the module foundation, the server tier (proxy + managed-mode -controller), the typed protocol seam, the workspace shell, and the Chats, -Scheduled, and Skills surfaces are in the tree; the remaining surfaces -arrive next. +each one; right now everything except provider/model-router/gateway settings, +external-mode sign-in, and the advanced chat tiers is in the tree. ::: ## Starting it @@ -81,3 +79,13 @@ folder, enable/disable (a disabled skill moves to a holding area rather than being deleted), or delete. Skill names pass one shared validator on both the browser and the controller. In external mode the list is read-only: skill management belongs to the deployment. + +## Memory and Settings + +Memory is read-only by design: the daemon has no memory write API (a +hand-typed value would enter turn-0 context without injection scanning), so +Studio shows the memory table with honest disabled/empty states, per-entry +detail, the store footprint, and the consolidate action. Settings carries +Personalize (text size, interface scale, session-list side, notifications), +the agent identity card (name and avatar are browser-local cosmetics — the +agent learns your name in conversation), and the learning review page.