From 6a13206616dbf75918e9b44a1d594d7b8d2947a9 Mon Sep 17 00:00:00 2001 From: jtenniswood Date: Wed, 2 Sep 2026 11:41:59 +0100 Subject: [PATCH] =?UTF-8?q?feat(studio):=20Labs=20mock=20features=20?= =?UTF-8?q?=E2=80=94=20Projects=20and=20the=20feature=20tour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one sanctioned daemon-never mock exception, isolated and default-off. mock-projects and mock-tour carry the canned content with tests pinning that a mock id never reaches the daemon; Settings gains the Labs page (the sections registry takes its last row) with the mock-features toggle and the convenience mock-provider switch. The sidebar grows MockProjectList, the chat workspace its showMockProjects/isMockTourSession guards, the chat view the read-only MockThreadPanel, and the composer its Projects dropdown. With the preference unset (the default) every guard branch is dead at runtime — zero behavior change for non-Labs users. profile-preferences and the settings registry reach their final states. Co-Authored-By: Claude Fable 5 --- docs/design/PRODUCTION-READINESS.md | 2 +- .../app/workspace/_components/chat-input.tsx | 101 ++++++ .../workspace/chat/_components/chat-view.tsx | 76 ++++- .../chat/_components/chat-workspace.tsx | 240 +++++++++---- .../chat/_components/session-sidebar.tsx | 180 +++++++++- .../settings/_components/settings-sections.ts | 11 + .../src/app/workspace/settings/labs/page.tsx | 61 ++++ .../src/features/agent/mock-projects.test.ts | 47 +++ studio/src/features/agent/mock-projects.ts | 97 ++++++ studio/src/features/agent/mock-tour.test.ts | 81 +++++ studio/src/features/agent/mock-tour.ts | 323 ++++++++++++++++++ studio/src/lib/profile-preferences.ts | 24 ++ user-docs/building/what-you-get/studio.md | 10 +- 13 files changed, 1177 insertions(+), 76 deletions(-) create mode 100644 studio/src/app/workspace/settings/labs/page.tsx create mode 100644 studio/src/features/agent/mock-projects.test.ts create mode 100644 studio/src/features/agent/mock-projects.ts create mode 100644 studio/src/features/agent/mock-tour.test.ts create mode 100644 studio/src/features/agent/mock-tour.ts diff --git a/docs/design/PRODUCTION-READINESS.md b/docs/design/PRODUCTION-READINESS.md index 14a493e1b4..7080585bfc 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 (read-only table + detail + consolidate, honest disabled/empty states) · ✅ Settings core (Personalize, agent identity, learning review) · ✅ provider management + model router + MCP gateway (controller-mediated; server-side auth.yaml ownership, no key material on the wire) · ✅ external-mode OIDC sign-in (PKCE, server-held tokens) · ✅ advanced chat: attachments/previews, tool drill-down + context meter + typed retry + live delegation cards, queue + steer, threads + canvas, live re-attach via the session watch (residual: the driving tab's prompt stream still cancels on disconnect) · ✅ session modes + fork-based mid-chat model switch + consented debug-with-AI (ADR 0254) · ⛔ Labs · ⛔ mobile round · ⛔ 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 management + model router + MCP gateway (controller-mediated; server-side auth.yaml ownership, no key material on the wire) · ✅ external-mode OIDC sign-in (PKCE, server-held tokens) · ✅ advanced chat: attachments/previews, tool drill-down + context meter + typed retry + live delegation cards, queue + steer, threads + canvas, live re-attach via the session watch (residual: the driving tab's prompt stream still cancels on disconnect) · ✅ session modes + fork-based mid-chat model switch + consented debug-with-AI (ADR 0254) · ✅ Labs mock Projects + feature tour (default-off; mock ids hard-gated from the daemon) · ⛔ mobile round · ⛔ 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/src/app/workspace/_components/chat-input.tsx b/studio/src/app/workspace/_components/chat-input.tsx index 8156058295..e7a01a0654 100644 --- a/studio/src/app/workspace/_components/chat-input.tsx +++ b/studio/src/app/workspace/_components/chat-input.tsx @@ -9,6 +9,8 @@ import { Bot, Check, ChevronDown, + FolderClosed, + FolderPlus, Mic, Paperclip, Plus, @@ -19,7 +21,9 @@ import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, @@ -30,6 +34,7 @@ import { getAgentMentions, getSlashCommands, } from "@/features/agent/composer-capabilities"; +import { usePrompt } from "@/hooks/use-prompt"; import { fileKindMeta } from "@/lib/file-meta"; import { type EnterSendBehavior, @@ -44,9 +49,19 @@ import { setComposerText, } from "./composer-mentions"; +interface ProjectItem { + id: string; + name: string; + color: string; +} + interface ChatInputProps { placeholder?: string; rows?: number; + projects?: ProjectItem[]; + selectedProjectId?: string | null; + onSelectProject?: (id: string | null) => void; + onCreateProject?: (name: string) => void; compact?: boolean; onSend?: (content: string, files?: File[]) => void; onQueue?: (content: string) => void; @@ -473,6 +488,80 @@ function MemoryToggle() { ); } +function ProjectsDropdown({ + projects, + selectedProjectId, + onSelect, + onCreate, +}: { + projects?: ProjectItem[]; + selectedProjectId?: string | null; + onSelect?: (id: string | null) => void; + onCreate?: (name: string) => void; +}) { + const { prompt, PromptDialog } = usePrompt(); + const selected = projects?.find((p) => p.id === selectedProjectId); + + return ( + <> + {PromptDialog} + + + + + e.preventDefault()} + align="start" + className="w-56" + > + Projects + + + onSelect?.(null)}> + + Chats + + + {projects?.map((p) => ( + onSelect?.(p.id)}> + + + {p.name} + + + ))} + + + { + const name = await prompt({ + title: "New project", + description: + "Give your project a name to organize related conversations.", + placeholder: "Project name", + confirmText: "Create project", + }); + if (name) onCreate?.(name); + }} + > + + New project + + + + + ); +} + function useVoiceInput(onTranscript: (text: string) => void) { const [isListening, setIsListening] = useState(false); const [isSupported, setIsSupported] = useState(false); @@ -723,6 +812,10 @@ function handleMenuNavKey( export function ChatInput({ placeholder: placeholderProp, + projects, + selectedProjectId, + onSelectProject, + onCreateProject, compact = false, onSend, onQueue, @@ -1167,6 +1260,14 @@ export function ChatInput({ container queries when THIS row runs narrow (a ~400px side-panel composer), independent of the viewport width. */}
+ {projects && ( + + )} {!compact && ( <> {onModeChange && ( diff --git a/studio/src/app/workspace/chat/_components/chat-view.tsx b/studio/src/app/workspace/chat/_components/chat-view.tsx index c9083cf55c..bae77443c0 100644 --- a/studio/src/app/workspace/chat/_components/chat-view.tsx +++ b/studio/src/app/workspace/chat/_components/chat-view.tsx @@ -45,6 +45,7 @@ import { useAgentChat, } from "@/features/agent"; import type { QueuedMessage } from "@/features/agent/hooks/use-agent-chat"; +import { isMockTourSession } from "@/features/agent/mock-tour"; import { formatTokens } from "@/lib/formatters"; import { createThreadHarnessSession, @@ -368,6 +369,9 @@ function ThreadPanel({ const handleSend = useCallback( async (content: string, files?: File[]) => { + // Defense in depth: a mock session id must never mint a daemon thread + // session (the panel router already sends mock threads elsewhere). + if (isMockTourSession(parentSessionId)) return; setSourceBusy(false); setCreateError(null); if (threadIdRef.current) { @@ -550,6 +554,57 @@ function ThreadPanel({ ); } +/** + * The thread panel for the Labs mock chat: a read-only view of the root + * message's canned replies. Entirely local — a mock session id must never + * mint a daemon thread session, so this replaces ThreadPanel outright. + */ +function MockThreadPanel({ + rootMessage, + botName, + onClose, + maximized, + onToggleMaximize, +}: { + rootMessage: AgentMessage; + botName: string; + onClose: () => void; + maximized: boolean; + onToggleMaximize: () => void; +}) { + const replies = rootMessage.replies ?? []; + return ( + +
+
+ +
+ {replies.length > 0 && ( +
+ + {replies.length} {replies.length === 1 ? "reply" : "replies"} + +
+ )} + {replies.map((reply) => ( + + ))} +

+ Mock thread — read-only demo content. +

+
+
+ ); +} + 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. +
+ group.sessions)} selectedId={selectedId} onSelect={onSelect} actions={actions} + cap={7} /> - ))} -
+
+ ) : ( +
+ {groups.map((group) => ( + + + + ))} +
+ ) ) : ( !error && (

@@ -170,7 +227,11 @@ function SidebarContent({ ) )} {!isLoading && agents.length > 0 && ( -

0 ? "pt-3" : undefined}> +
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 = + `` + + `` + + `Token verifications / month` + + `${body}` + + `` + + ``; + 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.