+
+
+ {/* 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
+
+ ) : (
+ 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}
+
+
+ );
+}
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 (
+
+
+
+ );
+}
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.
+
+
+ >
+ );
+}
+
+/** 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.