+
+ );
+}
+
function TextSelectionToolbar({
containerRef,
onAddToChat,
@@ -690,6 +745,7 @@ export function ChatView({
onCancelRun,
onCompact,
contextInfo,
+ readOnlyPlaceholder,
mode,
onModeChange,
models,
@@ -746,6 +802,9 @@ export function ChatView({
/** The session's effective model + context window (B1.1): feeds the slim
approximate context meter near the composer. */
contextInfo?: { modelLabel: string; contextWindow: number } | null;
+ /** Disables the composer and shows this placeholder instead (the Labs
+ mock chat is read-only demo content). */
+ readOnlyPlaceholder?: string;
/** The session's current permission mode, for the composer's Mode selector. */
mode?: SessionPermissionMode;
/** Renders the composer's Mode selector when provided (the mock tour chat
@@ -1131,7 +1190,7 @@ export function ChatView({
mode={mode}
onModeChange={onModeChange}
isStreaming={isStreaming}
- disabled={!!pendingApproval}
+ disabled={!!pendingApproval || readOnlyPlaceholder != null}
appendText={appendText}
onAppendConsumed={handleAppendConsumed}
initialText={editSeed ?? initialDraft}
@@ -1140,13 +1199,14 @@ export function ChatView({
else onInitialDraftConsumed?.();
}}
placeholder={
- isStreaming
+ readOnlyPlaceholder ??
+ (isStreaming
? // "Steer" is only an honest promise while the daemon
// actually supports it (C1.2) — absent, sends queue.
enterBehavior === "steer" && onSteerMessage
? "Steer the agent..."
: "Queue a message..."
- : "Send a message..."
+ : "Send a message...")
}
/>
)}
@@ -1196,6 +1256,16 @@ function SidePanelForKind({
case "toolcall":
return ;
case "thread":
+ // Mock chat threads stay local: read-only replies, no daemon session.
+ if (isMockTourSession(parentSessionId)) {
+ return (
+
+ );
+ }
return (
void;
isLoading: boolean;
@@ -116,6 +130,8 @@ function SidebarContent({
selectedId: string;
onSelect: (id: string) => void;
actions: SessionActions;
+ /** Labs mock features: list the demo project-grouped chats. */
+ showMockProjects: boolean;
}) {
return (
<>
@@ -137,6 +153,23 @@ function SidebarContent({
New chat
+ {/* Part of the Labs mock Projects demo: presentational only — there
+ is no project system to create into, so the button is inert. */}
+ {showMockProjects && (
+
+
+
+
+ New project
+
+ )}
@@ -145,23 +178,47 @@ function SidebarContent({
{error}
)}
+ {/* The Labs mock Projects section leads the list: local demo
+ content, never daemon rows — same gate and labeling discipline
+ as the mock tour group. */}
+ {!isLoading && showMockProjects && (
+
+
+
+ )}
{isLoading ? (
) : groups.length > 0 ? (
-
- {groups.map((group) => (
-
+ showMockProjects ? (
+ // Mock mode flattens the date groups under one "Chat" header so
+ // the demo's Projects/Chat split reads like the reference UI.
+
0 || showMockProjects ? "pt-3" : undefined
+ }
+ >
@@ -305,6 +366,10 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
const router = useRouter();
const { name: agentName } = useAgentDisplayName();
const { side: sidebarSide } = useSessionListSide();
+ // Labs preference: list the mock feature tour. The mock id is treated as
+ // mock UNCONDITIONALLY below (never handed to the daemon) — the toggle
+ // only controls whether the row is offered.
+ const { enabled: mockFeatures } = useMockFeatures();
const isCompact = useIsCompact();
const { confirm, ConfirmDialog } = useConfirm();
const { prompt, PromptDialog } = usePrompt();
@@ -393,15 +458,23 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
[refreshSessions],
);
+ // The Labs mock chat never talks to the daemon: its transcript is a local
+ // constant and its id must never reach the chat hook.
+ const isMockSelected = isMockTourSession(selectedId);
+
// The draft keeps a null hook id even after its session is minted and the
// URL updates — the hook already streams against the minted id internally.
const hookSessionId =
- selectedId && selectedId !== draftMintedIdRef.current ? selectedId : null;
+ selectedId && !isMockSelected && selectedId !== draftMintedIdRef.current
+ ? selectedId
+ : null;
// The composer's permission mode. For an open chat this reads/writes the
// live session (POST /mode); for a draft it is pending local state, read
// via modeRef when the first send mints the daemon session below.
- const { mode, modeRef, changeMode } = useSessionMode(selectedId || null);
+ const { mode, modeRef, changeMode } = useSessionMode(
+ isMockSelected ? null : selectedId || null,
+ );
const getCreateMode = useCallback(() => modeRef.current, [modeRef]);
// The composer's model picker: live daemon models minus the Studio-side
@@ -489,7 +562,7 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
useState(null);
useEffect(() => {
setResolvedModel(null);
- if (!selectedId || !connected) return;
+ if (!selectedId || isMockTourSession(selectedId) || !connected) return;
const controller = new AbortController();
void fetchHarnessSessionDetail(selectedId, controller.signal)
.then((detail) => {
@@ -505,7 +578,7 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
const compactSupported = serverCapabilities.manual_compaction === true;
const handleCompact = useCallback(async () => {
const id = selectedIdRef.current;
- if (!id) return;
+ if (!id || isMockTourSession(id)) return;
try {
const compacted = await compactHarnessSession(id);
toast.success(
@@ -544,13 +617,18 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)),
[sessions, threadSessionIds],
);
- const groups = useMemo(
- () => groupSessionsByRecency(orderedSessions),
- [orderedSessions],
- );
+ const groups = useMemo(() => {
+ const recency = groupSessionsByRecency(orderedSessions);
+ // The Labs mock tour pins atop the list under its own clearly-labeled
+ // group — local demo content, never a daemon row.
+ return mockFeatures
+ ? [{ label: "Mock", sessions: [MOCK_TOUR_SESSION] }, ...recency]
+ : recency;
+ }, [orderedSessions, mockFeatures]);
const selectedSession = useMemo(() => {
if (!selectedId) return undefined;
+ if (isMockTourSession(selectedId)) return MOCK_TOUR_SESSION;
const found = sessions.find((s) => s.id === selectedId);
if (found) return found;
// A just-minted draft's row may not have landed in the polled list yet;
@@ -609,6 +687,8 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
const debugSupported = serverCapabilities.session_debug === true;
const handleDebugSession = useCallback(
async (id: string) => {
+ // The Labs mock row is local demo content — never a daemon target.
+ if (isMockTourSession(id)) return;
const ok = await confirm({
title: "Debug with AI",
description:
@@ -709,6 +789,7 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
selectedId,
onSelect: handleSelectSession,
actions: sessionActions,
+ showMockProjects: mockFeatures,
};
const dialogs = (
@@ -757,59 +838,80 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
const chatView = (open: boolean, onToggle: () => void) =>
selectedSession ? (
- 0
- ? {
- modelLabel: resolvedModel.modelId,
- contextWindow: resolvedModel.contextWindow,
- }
- : null
- }
- botName={agentName}
- sidebarOpen={open}
- sidebarSide={sidebarSide}
- onToggleSidebar={onToggle}
- pendingApproval={pendingApproval}
- onRespondApproval={respondToApproval}
- pendingClarification={pendingClarification}
- onRespondClarification={respondToClarification}
- onRename={
- selectedSession.canRename === true
- ? () => sessionActions.onRename(selectedSession.id)
- : undefined
- }
- onDelete={
- selectedSession.canDelete === true
- ? () => sessionActions.onDelete(selectedSession.id)
- : undefined
- }
- onSidePanelOpenChange={handleSidePanelOpenChange}
- mode={mode}
- onModeChange={changeMode}
- models={modelOptions}
- autoModelLabel={routingEnabled ? "Auto-routed" : "Default model"}
- onSwitchModel={handleSwitchModel}
- />
+ isMockSelected ? (
+ // The mock feature tour: a canned local transcript, a disabled
+ // composer, and zero daemon traffic (the hook id above is null).
+ {}}
+ readOnlyPlaceholder="Mock chat — read-only"
+ botName={agentName}
+ sidebarOpen={open}
+ sidebarSide={sidebarSide}
+ onToggleSidebar={onToggle}
+ pendingApproval={null}
+ onRespondApproval={() => {}}
+ pendingClarification={null}
+ onRespondClarification={() => {}}
+ onSidePanelOpenChange={handleSidePanelOpenChange}
+ />
+ ) : (
+ 0
+ ? {
+ modelLabel: resolvedModel.modelId,
+ contextWindow: resolvedModel.contextWindow,
+ }
+ : null
+ }
+ botName={agentName}
+ sidebarOpen={open}
+ sidebarSide={sidebarSide}
+ onToggleSidebar={onToggle}
+ pendingApproval={pendingApproval}
+ onRespondApproval={respondToApproval}
+ pendingClarification={pendingClarification}
+ onRespondClarification={respondToClarification}
+ onRename={
+ selectedSession.canRename === true
+ ? () => sessionActions.onRename(selectedSession.id)
+ : undefined
+ }
+ onDelete={
+ selectedSession.canDelete === true
+ ? () => sessionActions.onDelete(selectedSession.id)
+ : undefined
+ }
+ onSidePanelOpenChange={handleSidePanelOpenChange}
+ mode={mode}
+ onModeChange={changeMode}
+ models={modelOptions}
+ autoModelLabel={routingEnabled ? "Auto-routed" : "Default model"}
+ onSwitchModel={handleSwitchModel}
+ />
+ )
) : null;
return (
diff --git a/studio/src/app/workspace/chat/_components/session-sidebar.tsx b/studio/src/app/workspace/chat/_components/session-sidebar.tsx
index 5acfc141ac..bb0592c26a 100644
--- a/studio/src/app/workspace/chat/_components/session-sidebar.tsx
+++ b/studio/src/app/workspace/chat/_components/session-sidebar.tsx
@@ -6,6 +6,8 @@ import {
ChevronDown,
ChevronUp,
Ellipsis,
+ FolderClosed,
+ FolderOpen,
Pencil,
Trash2,
} from "lucide-react";
@@ -18,6 +20,13 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { AgentSession, RosterAgent } from "@/features/agent";
+import {
+ MOCK_PROJECTS,
+ MOCK_PROJECTS_DEFAULT_OPEN_ID,
+ type MockProject,
+ type MockProjectChat,
+} from "@/features/agent/mock-projects";
+import { isMockTourSession } from "@/features/agent/mock-tour";
import { formatRelativeTime } from "@/lib/formatters";
import { cn } from "@/lib/utils";
@@ -67,7 +76,9 @@ function SessionContextMenu({
const canRename = session.canRename === true;
const canDelete = session.canDelete === true;
const offerDebug =
- actions.onDebug !== undefined && !session.debugTargetSessionId;
+ actions.onDebug !== undefined &&
+ !session.debugTargetSessionId &&
+ !isMockTourSession(session.id);
return (
@@ -167,6 +178,14 @@ function SessionRow({
>
{session.title || "Untitled"}
+ {isMockTourSession(session.id) && (
+
+ Mock
+
+ )}
{/* An AI-debug session (ADR 0254) reads as an ordinary chat except
for this label — the relationship is the daemon's signal. */}
{session.debugTargetSessionId && (
@@ -366,3 +385,162 @@ export function AgentList({
);
}
+
+/**
+ * Remembers each mock project's expanded/collapsed state by id, module-wide.
+ * Selecting a chat can remount the workspace (the route re-keys), which would
+ * otherwise reset every item's local `open` state; seeding from — and writing
+ * back to — this store keeps folders as the user left them. Seeded with one
+ * project open so the section demonstrates the expanded look immediately.
+ */
+const mockProjectOpenStore = new Map([
+ [MOCK_PROJECTS_DEFAULT_OPEN_ID, true],
+]);
+
+/**
+ * One chat row inside an expanded mock project. Presentational only: the
+ * Labs mock machinery has exactly one canned transcript (the feature tour),
+ * and these rows deliberately do not mint a second mock-session system — so
+ * there is nothing to open and the row is inert except for its hover state.
+ */
+function MockProjectChatRow({ chat }: { chat: MockProjectChat }) {
+ const [menuOpen, setMenuOpen] = useState(false);
+ return (
+ // py-2 matches the real SessionRow height so the two lists read as one
+ // rhythm. Never styled selected: selection lives on real sessions only —
+ // these rows are inert, so a highlight here would always be a lie.
+
+
+ {chat.title}
+
+ {/* The real SessionRow's right-slot grammar: dot/age yields to the
+ hover "…" menu. The items decline with a reason, the same disabled
+ treatment a daemon row without the capability gets. */}
+
+ {chat.unread ? (
+
+ ) : (
+
+ {formatRelativeTime(chat.updatedAt)}
+
+ )}
+
+
+
+
+
+
+
+
+ Rename
+
+ Demo content — not a real chat
+
+
+
+
+
+
+ Delete
+
+ Demo content — not a real chat
+
+
+
+
+
+
+
+ );
+}
+
+/** A collapsible mock project folder; clicking the row toggles it open.
+ * The header itself never takes the green treatment — the highlight
+ * belongs to exactly one row, the SELECTED chat inside; openness is
+ * carried by the folder icon alone. */
+function MockProjectItem({ project }: { project: MockProject }) {
+ const [open, setOpenState] = useState(
+ () => mockProjectOpenStore.get(project.id) ?? false,
+ );
+ const toggle = () =>
+ setOpenState((prev) => {
+ const next = !prev;
+ mockProjectOpenStore.set(project.id, next);
+ return next;
+ });
+
+ return (
+
+
+ {open && (
+
+ {project.chats.map((chat) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+/**
+ * The Labs mock "Projects" section: project-grouped chats, presentation
+ * only. The caller gates it on the mock-features preference, exactly like
+ * the feature tour row — never rendered outside Settings → Labs.
+ */
+export function MockProjectList() {
+ return (
+
+ {MOCK_PROJECTS.map((project) => (
+
+ ))}
+
+ );
+}
diff --git a/studio/src/app/workspace/settings/_components/settings-sections.ts b/studio/src/app/workspace/settings/_components/settings-sections.ts
index 0ce1ed5462..e85f1f0cb7 100644
--- a/studio/src/app/workspace/settings/_components/settings-sections.ts
+++ b/studio/src/app/workspace/settings/_components/settings-sections.ts
@@ -1,6 +1,7 @@
import {
Bot,
Brain,
+ FlaskConical,
GraduationCap,
Network,
Palette,
@@ -76,6 +77,16 @@ export const SETTINGS_GROUPS: Array<{
},
],
},
+ {
+ label: "Experimental",
+ items: [
+ {
+ href: "/workspace/settings/labs",
+ label: "Labs",
+ icon: FlaskConical,
+ },
+ ],
+ },
];
/**
diff --git a/studio/src/app/workspace/settings/labs/page.tsx b/studio/src/app/workspace/settings/labs/page.tsx
new file mode 100644
index 0000000000..7041240795
--- /dev/null
+++ b/studio/src/app/workspace/settings/labs/page.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { useState } from "react";
+import { Switch } from "@/components/ui/switch";
+import { useRuntimeStatus } from "@/features/agent/runtime-status";
+import { useMockFeatures } from "@/lib/profile-preferences";
+import { SettingsCard, SettingsRow } from "../_components/settings-card";
+
+export default function LabsSettingsPage() {
+ const { enabled, setEnabled } = useMockFeatures();
+ const runtime = useRuntimeStatus();
+ const [switchingTo, setSwitchingTo] = useState(null);
+ const [switchError, setSwitchError] = useState(null);
+
+ // A user-requested convenience coupling, NOT a semantic link: the mock tour
+ // itself is browser-local and never touches the daemon. But switching the
+ // preview OFF while the daemon idles on the offline mock provider usually
+ // means "back to real work" — so if a real provider is configured (managed
+ // mode only), the daemon is switched back to it, which restarts it.
+ const onToggle = (next: boolean) => {
+ setEnabled(next);
+ if (next || switchingTo) return;
+ const target = runtime.configuredProviders[0];
+ if (runtime.mode !== "managed" || !runtime.isMock || !target) return;
+ setSwitchError(null);
+ setSwitchingTo(target);
+ void runtime
+ .switchProvider(target)
+ .catch((caught) =>
+ setSwitchError(
+ caught instanceof Error ? caught.message : String(caught),
+ ),
+ )
+ .finally(() => setSwitchingTo(null));
+ };
+
+ return (
+
+
+
+
+ {switchingTo && (
+
+ Switching the daemon back to {switchingTo}…
+
+ )}
+ {switchError && (
+
{switchError}
+ )}
+
+ );
+}
diff --git a/studio/src/features/agent/mock-projects.test.ts b/studio/src/features/agent/mock-projects.test.ts
new file mode 100644
index 0000000000..cc8f8e4960
--- /dev/null
+++ b/studio/src/features/agent/mock-projects.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+import { formatRelativeTime } from "@/lib/formatters";
+import { MOCK_PROJECTS, MOCK_PROJECTS_DEFAULT_OPEN_ID } from "./mock-projects";
+
+/**
+ * The Labs Projects section exists to demonstrate project-grouped chats, so
+ * the data must actually exercise the section's visuals: an expandable
+ * default-open project, indented chats, and both right-hand row variants
+ * (the unread dot and the muted age label).
+ */
+describe("mock projects", () => {
+ const chats = MOCK_PROJECTS.flatMap((p) => p.chats);
+
+ it("ids are unique and mock-prefixed, so they can never collide with daemon rows", () => {
+ const ids = [...MOCK_PROJECTS.map((p) => p.id), ...chats.map((c) => c.id)];
+ expect(new Set(ids).size).toBe(ids.length);
+ for (const id of ids) {
+ expect(id, `id ${id}`).toMatch(/^mock-/);
+ }
+ });
+
+ it("every project carries at least one chat to indent beneath it", () => {
+ expect(MOCK_PROJECTS.length).toBeGreaterThan(1);
+ for (const project of MOCK_PROJECTS) {
+ expect(project.chats.length, project.name).toBeGreaterThan(0);
+ }
+ });
+
+ it("the default-open project is one of the listed projects", () => {
+ expect(MOCK_PROJECTS.map((p) => p.id)).toContain(
+ MOCK_PROJECTS_DEFAULT_OPEN_ID,
+ );
+ });
+
+ it("demonstrates both row variants: unread dots and age labels", () => {
+ expect(chats.some((c) => c.unread)).toBe(true);
+ expect(chats.some((c) => !c.unread)).toBe(true);
+ });
+
+ it("every read chat's timestamp yields a compact age label", () => {
+ for (const chat of chats.filter((c) => !c.unread)) {
+ expect(formatRelativeTime(chat.updatedAt), chat.title).toMatch(
+ /^\d+[mhd]$/,
+ );
+ }
+ });
+});
diff --git a/studio/src/features/agent/mock-projects.ts b/studio/src/features/agent/mock-projects.ts
new file mode 100644
index 0000000000..0cb6f5186f
--- /dev/null
+++ b/studio/src/features/agent/mock-projects.ts
@@ -0,0 +1,97 @@
+/**
+ * The Labs "mock features" Projects section: a static, clearly-labeled
+ * demonstration of project-grouped chats in the sidebar. Everything in this
+ * module is browser-local demo content — the ids must NEVER reach the daemon
+ * (the rows are presentational; nothing here mints a session), and the
+ * section renders only while the Labs toggle is on. Like the feature tour
+ * (mock-tour.ts) this rides the one sanctioned mock-content exception to the
+ * daemon-only rule (CLAUDE.md rule 1): explicit, default-off, and labeled —
+ * never a fallback for an unreachable daemon.
+ */
+
+export interface MockProjectChat {
+ id: string;
+ title: string;
+ /** Renders the green unread dot instead of the age label. */
+ unread: boolean;
+ /** Epoch ms; rendered via formatRelativeTime ("9h", "3d"). */
+ updatedAt: number;
+}
+
+export interface MockProject {
+ id: string;
+ name: string;
+ chats: MockProjectChat[];
+}
+
+// Stable within a page load; the section only renders client-side (the Labs
+// preference hydrates after mount), so no SSR/hydration mismatch.
+const NOW = Date.now();
+const hoursAgo = (h: number) => NOW - h * 3_600_000;
+
+export const MOCK_PROJECTS: MockProject[] = [
+ {
+ id: "mock-project-platform-engineering",
+ name: "Platform Engineering",
+ chats: [
+ {
+ id: "mock-project-chat-auth-middleware",
+ title: "Refactor auth middleware",
+ unread: false,
+ updatedAt: hoursAgo(2),
+ },
+ {
+ id: "mock-project-chat-ci-pipeline",
+ title: "Debug CI pipeline failures",
+ unread: true,
+ updatedAt: hoursAgo(9),
+ },
+ {
+ id: "mock-project-chat-pr-847",
+ title: "Review PR #847 — rate limiter",
+ unread: false,
+ updatedAt: hoursAgo(3 * 24),
+ },
+ ],
+ },
+ {
+ id: "mock-project-notifications-service",
+ name: "Notifications Service",
+ chats: [
+ {
+ id: "mock-project-chat-webhook-retries",
+ title: "Webhook retry backoff tuning",
+ unread: true,
+ updatedAt: hoursAgo(5),
+ },
+ {
+ id: "mock-project-chat-digest-emails",
+ title: "Design the daily digest email",
+ unread: false,
+ updatedAt: hoursAgo(26),
+ },
+ ],
+ },
+ {
+ id: "mock-project-q2-security-audit",
+ name: "Q2 Security Audit",
+ chats: [
+ {
+ id: "mock-project-chat-cve-triage",
+ title: "Dependency CVE triage",
+ unread: false,
+ updatedAt: hoursAgo(6),
+ },
+ {
+ id: "mock-project-chat-key-rotation",
+ title: "Signing key rotation runbook",
+ unread: false,
+ updatedAt: hoursAgo(2 * 24),
+ },
+ ],
+ },
+];
+
+/** The project expanded on first render, so the section demonstrates the
+ * open state (accent bar, open folder, indented chats) without a click. */
+export const MOCK_PROJECTS_DEFAULT_OPEN_ID = MOCK_PROJECTS[0].id;
diff --git a/studio/src/features/agent/mock-tour.test.ts b/studio/src/features/agent/mock-tour.test.ts
new file mode 100644
index 0000000000..7d5ba94ed9
--- /dev/null
+++ b/studio/src/features/agent/mock-tour.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+import { previewKind } from "@/app/workspace/chat/_components/file-preview";
+import {
+ isMockTourSession,
+ MOCK_TOUR_MESSAGES,
+ MOCK_TOUR_SESSION,
+ MOCK_TOUR_SESSION_ID,
+} from "./mock-tour";
+
+/**
+ * The feature tour exists to demonstrate every preview surface, so every
+ * artifact and attachment it carries must resolve to a real renderer — the
+ * "No preview available" floor would defeat the tour's purpose.
+ */
+describe("mock feature tour", () => {
+ const artifacts = MOCK_TOUR_MESSAGES.flatMap((m) =>
+ m.artifact ? [m.artifact] : [],
+ );
+ const attachments = MOCK_TOUR_MESSAGES.flatMap((m) => m.attachments ?? []);
+
+ it("every artifact resolves to a renderer", () => {
+ expect(artifacts.length).toBeGreaterThan(0);
+ for (const artifact of artifacts) {
+ expect(
+ previewKind(artifact.name, artifact.content, artifact.url),
+ `artifact ${artifact.name}`,
+ ).not.toBe("none");
+ }
+ });
+
+ it("every attachment resolves to a renderer", () => {
+ expect(attachments.length).toBeGreaterThan(0);
+ for (const attachment of attachments) {
+ expect(
+ previewKind(attachment.name, attachment.content, attachment.url),
+ `attachment ${attachment.name}`,
+ ).not.toBe("none");
+ }
+ });
+
+ it("covers the code, markdown, image, and PDF renderers", () => {
+ const kinds = new Set(
+ [...artifacts, ...attachments].map((f) =>
+ previewKind(f.name, f.content, f.url),
+ ),
+ );
+ for (const kind of ["code", "markdown", "image", "pdf"]) {
+ expect(kinds, `tour must showcase ${kind}`).toContain(kind);
+ }
+ });
+
+ it("carries an image attachment with a chip-thumbnail source", () => {
+ const image = attachments.find(
+ (a) => previewKind(a.name, a.content, a.url) === "image",
+ );
+ expect(image).toBeDefined();
+ expect(image?.url ?? image?.content).toMatch(/^data:image\//);
+ });
+
+ it("carries no canned thread — threads are the real feature now", () => {
+ expect(
+ MOCK_TOUR_MESSAGES.every((m) => (m.replies?.length ?? 0) === 0),
+ ).toBe(true);
+ });
+
+ it("keeps the embedded PDF tiny", () => {
+ const pdf = artifacts.find((a) => a.type === "pdf");
+ expect(pdf?.url).toMatch(/^data:application\/pdf;base64,/);
+ // ~4KB budget on the decoded bytes (base64 is 4/3 the size).
+ const b64 = (pdf?.url ?? "").split(",", 2)[1] ?? "";
+ expect((b64.length * 3) / 4).toBeLessThan(4096);
+ });
+
+ it("self-gates the session row: no rename/delete capabilities", () => {
+ expect(MOCK_TOUR_SESSION.id).toBe(MOCK_TOUR_SESSION_ID);
+ expect(MOCK_TOUR_SESSION.canRename).toBeUndefined();
+ expect(MOCK_TOUR_SESSION.canDelete).toBeUndefined();
+ expect(isMockTourSession(MOCK_TOUR_SESSION_ID)).toBe(true);
+ expect(isMockTourSession("some-daemon-session")).toBe(false);
+ });
+});
diff --git a/studio/src/features/agent/mock-tour.ts b/studio/src/features/agent/mock-tour.ts
new file mode 100644
index 0000000000..1f528a1c2b
--- /dev/null
+++ b/studio/src/features/agent/mock-tour.ts
@@ -0,0 +1,323 @@
+import type { AgentMessage, AgentSession } from "./types";
+
+/**
+ * The Labs "mock features" tour: a single synthetic, clearly-labeled chat
+ * demonstrating file cards, canvas previews, and attachment chips.
+ * Everything in this module is browser-local demo content — the session id
+ * must NEVER reach the daemon (the chat workspace nulls the hook id for it),
+ * and the tour renders only while the Labs toggle is on. This is the one
+ * sanctioned mock-content exception to the daemon-only rule (CLAUDE.md rule
+ * 1): explicit, default-off, and labeled — never a fallback for an
+ * unreachable daemon.
+ */
+
+export const MOCK_TOUR_SESSION_ID = "mock-feature-tour";
+
+export function isMockTourSession(id: string): boolean {
+ return id === MOCK_TOUR_SESSION_ID;
+}
+
+// Stable within a page load; the tour only renders client-side (the Labs
+// preference hydrates after mount), so no SSR/hydration mismatch.
+const NOW = Date.now();
+const minutesAgo = (m: number) => NOW - m * 60_000;
+
+const LOGGER_TS = `import { inspect } from "node:util";
+
+export type LogLevel = "debug" | "info" | "warn" | "error";
+
+const LEVEL_WEIGHT: Record = {
+ debug: 10,
+ info: 20,
+ warn: 30,
+ error: 40,
+};
+
+export interface LoggerOptions {
+ /** Minimum level that gets written. Defaults to "info". */
+ level?: LogLevel;
+ /** Static fields stamped onto every line (service, version, ...). */
+ fields?: Record;
+ /** Sink for the rendered line; defaults to process.stdout. */
+ write?: (line: string) => void;
+}
+
+export class Logger {
+ private readonly threshold: number;
+ private readonly fields: Record;
+ private readonly write: (line: string) => void;
+
+ constructor(options: LoggerOptions = {}) {
+ this.threshold = LEVEL_WEIGHT[options.level ?? "info"];
+ this.fields = options.fields ?? {};
+ this.write =
+ options.write ?? ((line) => process.stdout.write(\`\${line}\\n\`));
+ }
+
+ /** A child logger with extra fields bound onto every line. */
+ child(fields: Record): Logger {
+ return new Logger({
+ fields: { ...this.fields, ...fields },
+ write: this.write,
+ });
+ }
+
+ log(level: LogLevel, message: string, extra?: Record) {
+ if (LEVEL_WEIGHT[level] < this.threshold) return;
+ const record = {
+ ts: new Date().toISOString(),
+ level,
+ message,
+ ...this.fields,
+ ...extra,
+ };
+ this.write(inspect(record, { breakLength: Infinity }));
+ }
+
+ debug = (msg: string, extra?: Record) =>
+ this.log("debug", msg, extra);
+ info = (msg: string, extra?: Record) =>
+ this.log("info", msg, extra);
+ warn = (msg: string, extra?: Record) =>
+ this.log("warn", msg, extra);
+ error = (msg: string, extra?: Record) =>
+ this.log("error", msg, extra);
+}
+`;
+
+const MIGRATION_NOTES_MD = `# Auth migration notes
+
+Moving token verification from \`jsonwebtoken\` to \`jose\`. This is the
+working plan — flag anything that looks off.
+
+## Why
+
+- \`jose\` is ESM-native and runs on the edge runtime.
+- First-class \`KeyLike\` handling removes the PEM string juggling.
+- Built-in JWKS caching (\`createRemoteJWKSet\`) replaces our hand-rolled one.
+
+## Steps
+
+1. Introduce \`verifyToken()\` behind the existing interface.
+2. Dual-run both verifiers in staging, log disagreements.
+3. Flip the default, keep the old path behind a flag for one release.
+4. Delete \`jsonwebtoken\` and the flag.
+
+## API mapping
+
+| jsonwebtoken | jose |
+| ----------------------- | --------------------------- |
+| \`jwt.verify(t, key)\` | \`jwtVerify(t, key, opts)\` |
+| \`jwt.sign(payload)\` | \`new SignJWT(payload)\` |
+| \`jwt.decode(t)\` | \`decodeJwt(t)\` |
+
+## Open questions
+
+- Do we still need HS256 anywhere, or is RS256 everywhere now?
+- Clock tolerance: keep the 5s skew allowance?
+`;
+
+const JOSE_GUIDE_MD = `# jose migration guide
+
+A condensed guide for swapping \`jsonwebtoken\` out for \`jose\`.
+
+## Verify
+
+\`\`\`ts
+import { jwtVerify, createRemoteJWKSet } from "jose";
+
+const jwks = createRemoteJWKSet(new URL("https://issuer/.well-known/jwks.json"));
+const { payload } = await jwtVerify(token, jwks, {
+ issuer: "https://issuer",
+ audience: "studio",
+});
+\`\`\`
+
+## Sign
+
+\`\`\`ts
+import { SignJWT } from "jose";
+
+const jwt = await new SignJWT({ sub: user.id })
+ .setProtectedHeader({ alg: "RS256" })
+ .setIssuedAt()
+ .setExpirationTime("2h")
+ .sign(privateKey);
+\`\`\`
+
+Errors are typed: catch \`errors.JWTExpired\` instead of string-matching.
+`;
+
+const AUTH_MIDDLEWARE_TS = `import type { NextFunction, Request, Response } from "express";
+import jwt from "jsonwebtoken";
+
+const PUBLIC_KEY = process.env.AUTH_PUBLIC_KEY ?? "";
+
+export interface AuthedRequest extends Request {
+ user?: { id: string; roles: string[] };
+}
+
+/** Rejects requests without a valid bearer token. */
+export function requireAuth(
+ req: AuthedRequest,
+ res: Response,
+ next: NextFunction,
+) {
+ const header = req.headers.authorization;
+ if (!header?.startsWith("Bearer ")) {
+ res.status(401).json({ error: "missing bearer token" });
+ return;
+ }
+ try {
+ const payload = jwt.verify(header.slice(7), PUBLIC_KEY, {
+ algorithms: ["RS256"],
+ clockTolerance: 5,
+ }) as { sub: string; roles?: string[] };
+ req.user = { id: payload.sub, roles: payload.roles ?? [] };
+ next();
+ } catch {
+ res.status(401).json({ error: "invalid token" });
+ }
+}
+`;
+
+/** A small bar chart as an inline SVG data URI (fixed palette — it's an image). */
+function chartDataUri(): string {
+ const bars = [
+ { label: "Apr", value: 62 },
+ { label: "May", value: 78 },
+ { label: "Jun", value: 71 },
+ { label: "Jul", value: 94 },
+ { label: "Aug", value: 118 },
+ ];
+ const max = 120;
+ const body = bars
+ .map((bar, i) => {
+ const h = Math.round((bar.value / max) * 170);
+ const x = 60 + i * 78;
+ return (
+ `` +
+ `${bar.label}` +
+ `${bar.value}k`
+ );
+ })
+ .join("");
+ const svg =
+ ``;
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
+}
+
+/** A tiny valid one-page PDF (738 bytes), embedded as a base64 data URI. */
+const PDF_DATA_URI =
+ "data:application/pdf;base64," +
+ "JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCAxOTMgPj4Kc3RyZWFtCkJUIC9GMSAyNCBUZiA3MiA3MDAgVGQgKE1vY2sgUERGKSBUaiBFVApCVCAvRjEgMTIgVGYgNzIgNjcwIFRkIChBIHRpbnkgb25lLXBhZ2UgUERGIHJlbmRlcmVkIGJ5IHRoZSBTdHVkaW8gZmVhdHVyZSB0b3VyLikgVGogRVQKQlQgL0YxIDEyIFRmIDcyIDY1MCBUZCAoTm90aGluZyBoZXJlIGNhbWUgZnJvbSB0aGUgZGFlbW9uLikgVGogRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDA0ODUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo1NTUKJSVFT0YK";
+
+/** The tour transcript. Read-only; the composer is disabled for this chat. */
+export const MOCK_TOUR_MESSAGES: AgentMessage[] = [
+ {
+ id: "mock-1",
+ role: "user",
+ content:
+ "Can you plan our auth migration off jsonwebtoken to jose? I've attached the migration guide, our current middleware, and the usage chart.",
+ timestamp: minutesAgo(42),
+ attachments: [
+ {
+ name: "jose-migration-guide.md",
+ type: "text/markdown",
+ content: JOSE_GUIDE_MD,
+ },
+ {
+ name: "auth-middleware.ts",
+ type: "text/typescript",
+ content: AUTH_MIDDLEWARE_TS,
+ },
+ {
+ name: "token-usage.svg",
+ type: "image/svg+xml",
+ url: chartDataUri(),
+ },
+ ],
+ },
+ {
+ id: "mock-2",
+ role: "assistant",
+ content:
+ "Happy to. First, a structured logger so we can trace both verifiers while they dual-run — every disagreement gets a correlated line:",
+ timestamp: minutesAgo(40),
+ artifact: {
+ name: "logger.ts",
+ type: "code",
+ content: LOGGER_TS,
+ },
+ },
+ {
+ id: "mock-3",
+ role: "assistant",
+ content:
+ "Here's the migration plan as a working doc — the API mapping table covers every call site I found:",
+ timestamp: minutesAgo(36),
+ artifact: {
+ name: "MIGRATION-NOTES.md",
+ type: "markdown",
+ content: MIGRATION_NOTES_MD,
+ },
+ },
+ {
+ id: "mock-4",
+ role: "assistant",
+ content:
+ "Verification volume is climbing, so the JWKS cache matters — here's the trend:",
+ timestamp: minutesAgo(30),
+ artifact: {
+ name: "token-verifications.svg",
+ type: "image",
+ url: chartDataUri(),
+ },
+ },
+ {
+ id: "mock-5",
+ role: "assistant",
+ content:
+ "And a one-page summary you can circulate — it renders right in the preview panel:",
+ timestamp: minutesAgo(24),
+ artifact: {
+ name: "migration-summary.pdf",
+ type: "pdf",
+ url: PDF_DATA_URI,
+ },
+ },
+ {
+ id: "mock-6",
+ role: "assistant",
+ content:
+ "That's the whole tour: file cards open in the canvas panel and attachment chips preview in place. Threads are the real thing — start one from any message's reply action.",
+ timestamp: minutesAgo(20),
+ },
+];
+
+/** The sidebar stand-in row for the tour. canRename/canDelete stay omitted —
+ * the row menus self-gate on capabilities, so mock rows offer no actions. */
+export const MOCK_TOUR_SESSION: AgentSession = {
+ id: MOCK_TOUR_SESSION_ID,
+ title: "Feature tour",
+ projectId: null,
+ model: "",
+ createdAt: minutesAgo(42),
+ updatedAt: minutesAgo(15),
+ pinned: true,
+ archived: false,
+ messageCount: MOCK_TOUR_MESSAGES.length,
+ isStreaming: false,
+ inputTokens: 0,
+ outputTokens: 0,
+ unread: false,
+ estimatedCost: null,
+ contextLength: null,
+ lastPromptTokens: null,
+ thresholdTokens: null,
+};
diff --git a/studio/src/lib/profile-preferences.ts b/studio/src/lib/profile-preferences.ts
index f7c3ed5801..e39b7fdaba 100644
--- a/studio/src/lib/profile-preferences.ts
+++ b/studio/src/lib/profile-preferences.ts
@@ -150,6 +150,30 @@ export function useSessionListSide() {
return { side, setSide };
}
+const MOCK_FEATURES_KEY = "mecatl-studio.mock-features";
+
+/**
+ * Labs preference: show the clearly-labeled mock feature-tour content (a
+ * synthetic chat demonstrating file cards, previews, and threads). Browser-
+ * local demo content only — nothing mock ever reaches the daemon. Default
+ * OFF; the key stores "1" only while enabled.
+ */
+export function useMockFeatures() {
+ const [enabled, setEnabledState] = useState(false);
+ useEffect(() => {
+ if (readLocalStorage(MOCK_FEATURES_KEY) === "1") {
+ setEnabledState(true);
+ }
+ }, []);
+
+ const setEnabled = useCallback((next: boolean) => {
+ setEnabledState(next);
+ writeLocalStorage(MOCK_FEATURES_KEY, next ? "1" : null);
+ }, []);
+
+ return { enabled, setEnabled };
+}
+
const SHOW_TOOL_CALLS_KEY = "mecatl-studio.show-tool-calls";
const showToolCallsListeners = new Set<() => void>();
diff --git a/user-docs/building/what-you-get/studio.md b/user-docs/building/what-you-get/studio.md
index 1d431b737c..ca2f9b31a1 100644
--- a/user-docs/building/what-you-get/studio.md
+++ b/user-docs/building/what-you-get/studio.md
@@ -12,8 +12,7 @@ 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 everything except the Labs extras and the mobile round is in the
-tree.
+each one; right now everything except the mobile round is in the tree.
:::
## Starting it
@@ -126,3 +125,10 @@ conversation onto another model mid-chat, keeping the transcript. Where the
daemon advertises session debugging, a chat's menu offers Debug with AI
behind an explicit consent dialog; debug sessions carry a badge in the
sidebar.
+
+## Labs
+
+Settings → Labs holds the clearly-labeled mock extras, default-off: a mock
+Projects section in the chat sidebar and a canned feature tour. These are
+browser-local demo content only — a mock id is hard-gated from ever reaching
+the daemon — and with the toggle off none of it exists at runtime.