diff --git a/docs/design/PRODUCTION-READINESS.md b/docs/design/PRODUCTION-READINESS.md
index 7080585bfc..fe4237b4f9 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 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) |
+| 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 + PWA interaction round (sheet panels, docked composer, safe areas; desktop rendering untouched) · ⛔ advanced chat tiers (attachments, steer/queue, threads, re-attach, modes, mobile) | [0288](../adr/0288-studio-atrium-module.md) · [0289](../adr/0289-studio-server-backed-chats.md) | [overview](../architecture.md) |
| _Historical / retired_ | — | [ARCHITECTURE.md](../adr/0004-v1-architecture.md) · [STEP-CHAIN.md](../adr/0006-v1-step-chain.md) · [TWELVE-PATTERNS-AUDIT.md](../adr/0007-twelve-patterns-audit.md) · [REPOMAP-TREE-SITTER.md](../adr/0029-repomap-tree-sitter.md) | — |
## Security
diff --git a/studio/knip.ts b/studio/knip.ts
index d14632a788..6b5cbbbf9b 100644
--- a/studio/knip.ts
+++ b/studio/knip.ts
@@ -16,8 +16,6 @@ const config: KnipConfig = {
"src/components/ui/**",
// Feature modules: internal exports consumed within the feature
"src/features/**",
- // Reached only through the UI kit until later PRs in the stacked series
- // land their first app-level consumers; each line leaves with that PR.
],
ignoreDependencies: [
// Tailwind v4 is imported via CSS (@import "tailwindcss"), not JS
@@ -25,10 +23,6 @@ const config: KnipConfig = {
// Used by shadcn/ui Form and Label components (in src/components/ui/ which knip ignores)
"react-hook-form",
// Used by shadcn/ui components (in src/components/ui/ which knip ignores)
- // (these leave the list as their first reached consumers land later in
- // the stacked series: lucide-react/cmdk/tooltip with the workspace shell,
- // select/switch/tabs with the surface pages)
- "@radix-ui/react-switch",
"@radix-ui/react-avatar",
"@radix-ui/react-checkbox",
"@radix-ui/react-popover",
diff --git a/studio/src/app/workspace/_components/chat-input.tsx b/studio/src/app/workspace/_components/chat-input.tsx
index e7a01a0654..863e38e045 100644
--- a/studio/src/app/workspace/_components/chat-input.tsx
+++ b/studio/src/app/workspace/_components/chat-input.tsx
@@ -7,14 +7,18 @@ import type { SuggestionProps } from "@tiptap/suggestion";
import {
ArrowUp,
Bot,
+ Brain,
Check,
ChevronDown,
+ ChevronRight,
FolderClosed,
FolderPlus,
Mic,
Paperclip,
Plus,
RotateCcw,
+ Shield,
+ SlidersHorizontal,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -30,6 +34,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import {
getAgentMentions,
getSlashCommands,
@@ -63,6 +68,11 @@ interface ChatInputProps {
onSelectProject?: (id: string | null) => void;
onCreateProject?: (name: string) => void;
compact?: boolean;
+ /** Docked at a screen edge on mobile: full-bleed single row with only a
+ hairline top border, instead of the floating rounded box. */
+ mobileDocked?: boolean;
+ /** Refocuses the field when this changes (e.g. the open chat's id). */
+ focusKey?: string;
onSend?: (content: string, files?: File[]) => void;
onQueue?: (content: string) => void;
/** Injects the text — plus any staged image attachments (ADR 0251) — into
@@ -145,6 +155,17 @@ function FilesDropdown({
);
}
+/**
+ * On mobile the composer must NOT grab focus when a chat opens — the keyboard
+ * would pop over the transcript the user came to read. Read the breakpoint
+ * synchronously (module-stable, so focus effects need not depend on it): the
+ * useIsMobile hook reports undefined for a render, which would race the
+ * mount-time autofocus.
+ */
+const mobileViewport = () =>
+ typeof window !== "undefined" &&
+ window.matchMedia("(max-width: 499px)").matches;
+
/** The daemon-picks sentinel (model_id omitted at create). Its label is
* supplied by the caller: "Auto-routed" only while the router is really on. */
const AUTO_MODEL_ID = "";
@@ -171,11 +192,60 @@ type EffortId = (typeof EFFORT_LEVELS)[number]["id"];
const DEFAULT_EFFORT_ID: EffortId = "medium";
+/** One tappable choice row inside a mobile picker sheet (MobileChatMenu's
+ * row idiom plus a trailing checkmark). */
+function SheetOptionRow({
+ label,
+ description,
+ selected,
+ onSelect,
+}: {
+ label: string;
+ description?: string;
+ selected: boolean;
+ onSelect: () => void;
+}) {
+ return (
+
+ );
+}
+
+/** Muted section heading inside a mobile picker sheet. */
+function SheetSectionLabel({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
/**
* Combined model + effort picker in a single menu: the trigger reads
* "{model} {effort}", and the menu drills into a Model submenu and an Effort
* submenu, with a reset. When `lockedLabel` is set (a live harness routes the
- * model server-side) the trigger is display-only.
+ * model server-side) the trigger is display-only. On mobile the menu is a
+ * bottom sheet instead (the MobileChatMenu convention) with the submenus
+ * flattened into sections — it stays open across taps so model and effort
+ * can be set in one visit.
*/
function ModelEffortSelector({
onModelChange,
@@ -488,6 +558,273 @@ function MemoryToggle() {
);
}
+/**
+ * The mobile composer's left-hand options button: a + that opens a bottom
+ * sheet with the composer's secondary actions — Add a file, Model, Memory —
+ * replacing the desktop toolbar row (hidden on mobile). Model and Memory
+ * drill into their own sheets; a routed model renders display-only.
+ */
+function MobileComposerMenu({
+ onFilesSelected,
+ onModelChange,
+ modelLockedLabel,
+ onSwitchModel,
+ currentModelId,
+ mode,
+ onModeChange,
+ modeDisabled,
+ models,
+ autoModelLabel,
+}: {
+ onFilesSelected: (files: File[]) => void;
+ onModelChange?: (id: string) => void;
+ modelLockedLabel?: string;
+ /** Present in a live chat: picking forks the chat onto the model (the
+ * daemon fixes a session's model at create). null = auto-routed. */
+ onSwitchModel?: (option: ComposerModelOption | null) => void;
+ /** The live session's current model id ("" = auto), for the checkmark. */
+ currentModelId?: string;
+ mode?: SessionPermissionMode;
+ onModeChange?: (mode: SessionPermissionMode) => void;
+ modeDisabled?: boolean;
+ models?: ComposerModelOption[];
+ autoModelLabel?: string;
+}) {
+ const [menuOpen, setMenuOpen] = useState(false);
+ const [sub, setSub] = useState<"mode" | "model" | "memory" | null>(null);
+ const [model, setModel] = useState(AUTO_MODEL_ID);
+ const [effort, setEffort] = useState(DEFAULT_EFFORT_ID);
+ const [memoryOn, setMemoryOn] = useState(true);
+ const fileInputRef = useRef(null);
+ const modelOptions = [autoModel(autoModelLabel), ...(models ?? [])];
+ const selectedModel =
+ modelOptions.find((m) => m.id === model) ?? modelOptions[0];
+ // Switch mode ignores the local pick state — the session's model is the
+ // truth, and a stale/disabled id still labels honestly as itself.
+ const switchId = currentModelId ?? AUTO_MODEL_ID;
+ const switchSelected = modelOptions.find((m) => m.id === switchId) ?? {
+ id: switchId,
+ label: switchId || autoModel(autoModelLabel).label,
+ };
+ const selectedEffort =
+ EFFORT_LEVELS.find((e) => e.id === effort) ?? EFFORT_LEVELS[1];
+
+ const menuRow =
+ "flex w-full items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-muted/50 disabled:opacity-50";
+
+ return (
+ <>
+ {
+ const files = Array.from(event.target.files ?? []);
+ event.target.value = "";
+ if (files.length > 0) onFilesSelected(files);
+ }}
+ />
+
+
+
+
+ Composer options
+
+
+
+ >
+ );
+}
+
function ProjectsDropdown({
projects,
selectedProjectId,
@@ -817,6 +1154,8 @@ export function ChatInput({
onSelectProject,
onCreateProject,
compact = false,
+ mobileDocked = false,
+ focusKey,
onSend,
onQueue,
onSteer,
@@ -897,7 +1236,7 @@ export function ChatInput({
const editor = useEditor({
immediatelyRender: false,
- autofocus: "end",
+ autofocus: mobileViewport() ? false : "end",
extensions: [
// A deliberately plain field: keep the editing primitives (undo, hard
// break, drop/gap cursors) but drop every rich-text mark and block so
@@ -923,6 +1262,14 @@ export function ChatInput({
onUpdate: ({ editor }) => setText(editor.getText({ blockSeparator: "\n" })),
});
+ // Entering a chat or thread puts the caret in the field; autofocus only
+ // covers the first mount, so a change of target refocuses explicitly.
+ // Desktop only — see mobileViewport above.
+ useEffect(() => {
+ if (focusKey !== undefined && !mobileViewport())
+ editor?.commands.focus("end");
+ }, [focusKey, editor]);
+
useEffect(() => {
editor?.setEditable(!disabled);
}, [editor, disabled]);
@@ -1114,7 +1461,10 @@ export function ChatInput({
setAttachedFiles((prev) => [...prev, ...droppedFiles]);
}
}}
- className="relative rounded-2xl bg-zinc-50 dark:bg-zinc-900"
+ className={cn(
+ "relative rounded-2xl bg-zinc-50 dark:bg-zinc-900",
+ mobileDocked && "max-[499px]:rounded-none max-[499px]:bg-transparent",
+ )}
>
{/* Autocomplete popover (agent @-mentions or slash commands), floating
above the input box. Driven by TipTap's suggestion lifecycle. */}
@@ -1164,6 +1514,13 @@ export function ChatInput({
)}
-
+ {/* Mobile consolidates to ONE line — + on the left, the field, and a
+ single right slot that is the mic until there is text, then the
+ send button (native messaging convention). Desktop keeps the
+ two-row layout below. */}
+ {/* min-h-14 mirrors the chat header bar, so the docked composer and
+ the title bar read as symmetric top/bottom bands. */}
+
{/* TipTap composer: resolved @agent / /skill mentions are atomic
green chips (Backspace removes a whole chip); Enter sends and
Shift+Enter inserts a newline (handled in the editor keymap). */}
- {/* Bottom row: attach (+), mic on the left; send right */}
-
+ {/* Desktop-only bottom row: attach (+), mic on the left; send right */}
+
setAttachedFiles((prev) => [...prev, ...newFiles])
@@ -1256,10 +1665,11 @@ export function ChatInput({
{/* Toolbar sits BEHIND the input box: negative top margin pulls it up
so its top edge overlaps the input box's bottom rounded corners,
making the two boxes appear to share a single outline. */}
+ {/* Hidden on mobile: Model and Memory live in the + options sheet. */}
{/* @container: the Model/Memory pills collapse their value labels via
container queries when THIS row runs narrow (a ~400px side-panel
composer), independent of the viewport width. */}
-
+
{projects && (
void;
+ onRename?: () => void;
+ onDelete?: () => void;
+ /** Manual compaction (B1.2): present only when the daemon supports it. */
+ onCompact?: () => void;
+ /** True while a run streams — the daemon 412s a mid-run compact. */
+ compactDisabled?: boolean;
+ usage?: { inputTokens: number; outputTokens: number } | null;
+}) {
+ const [open, setOpen] = useState(false);
+
+ return (
+ <>
+
+
+
+ Chat options
+
@@ -841,6 +948,7 @@ export function ChatView({
// When maximized, the panel fills the pane and the conversation column is
// hidden. Always reset when the panel is closed.
const [panelMaximized, setPanelMaximized] = useState(false);
+ const isMobile = useIsMobile();
const handleAppendConsumed = useCallback(() => setAppendText(null), []);
// Threads branched off this chat's messages (browser-local), for the
// Slack-style reply indicators under their root messages.
@@ -930,7 +1038,9 @@ export function ChatView({
// autocomplete — consume their own Escape before the dispatcher sees it
// (`defaultPrevented`), so by the time this fires nothing transient is
// open. Close the side panel if one is up; otherwise interrupt a streaming
- // run (the Claude Code convention: Esc cancels).
+ // run (the Claude Code convention: Esc cancels). On mobile the panel lives
+ // in a Radix Sheet that owns its own Escape, so only the cancel arm fires
+ // there.
useShortcut("close.esc", () => {
if (panel !== null) {
closeSidePanel();
@@ -939,9 +1049,9 @@ export function ChatView({
if (isStreaming) onCancelRun?.();
});
- // Let the parent collapse the chat list while the side panel is open so
- // both panels fit side by side.
- const sidePanelOpen = panel !== null;
+ // The right-hand panel only renders on non-mobile layouts; let the parent
+ // collapse the chat list while it's open so both panels fit side by side.
+ const sidePanelOpen = !isMobile && panel !== null;
useEffect(() => {
onSidePanelOpenChange?.(sidePanelOpen);
}, [sidePanelOpen, onSidePanelOpenChange]);
@@ -951,8 +1061,9 @@ export function ChatView({
// With the list docked right, an open side panel occupies its slot — the
// toggle then means "give me the list back": close the panel, and the
// workspace restores the sidebar to its pre-panel state.
- const panelHoldsSidebarSlot = sidebarSide === "right" && activePanel !== null;
- const sidebarToggle = (
+ const panelHoldsSidebarSlot =
+ sidebarSide === "right" && activePanel !== null && !isMobile;
+ const sidebarToggle = !isMobile && (
- {activePanel !== null && (
+ {!isMobile && activePanel !== null && (
)}
+ {/* On mobile the same panels render as a full-height bottom sheet: the
+ grab handle owns dismissal (no window controls), and dvh keeps the
+ thread composer above the on-screen keyboard. */}
+ {isMobile && activePanel !== null && (
+ {
+ if (!open) closeSidePanel();
+ }}
+ >
+
+
+ {activePanel.kind === "thread"
+ ? "Thread"
+ : activePanel.kind === "attachment"
+ ? activePanel.attachment.name
+ : activePanel.kind === "toolcall"
+ ? activePanel.call.name
+ : activePanel.artifact.name}
+
+
+ {}}
+ windowControls={false}
+ />
+
+
+
+ )}
);
}
@@ -1238,6 +1412,7 @@ function SidePanelForKind({
onConvertToChat,
maximized,
onToggleMaximize,
+ windowControls,
}: {
panel: ActivePanel;
parentSessionId: string;
@@ -1246,8 +1421,9 @@ function SidePanelForKind({
onConvertToChat?: (threadSessionId: string) => void;
maximized: boolean;
onToggleMaximize: () => void;
+ windowControls?: boolean;
}) {
- const shared = { onClose, maximized, onToggleMaximize };
+ const shared = { onClose, maximized, onToggleMaximize, windowControls };
switch (panel.kind) {
case "artifact":
return ;
diff --git a/studio/src/app/workspace/chat/_components/chat-workspace.tsx b/studio/src/app/workspace/chat/_components/chat-workspace.tsx
index 1be7e25c18..968f178fe7 100644
--- a/studio/src/app/workspace/chat/_components/chat-workspace.tsx
+++ b/studio/src/app/workspace/chat/_components/chat-workspace.tsx
@@ -32,7 +32,7 @@ import {
} from "@/features/agent/mock-tour";
import { useRuntimeStatus } from "@/features/agent/runtime-status";
import { useConfirm } from "@/hooks/use-confirm";
-import { useIsCompact } from "@/hooks/use-mobile";
+import { useIsCompact, useIsMobile } from "@/hooks/use-mobile";
import { useNavReopenSidebar } from "@/hooks/use-nav-reopen-sidebar";
import { usePanelWidth } from "@/hooks/use-panel-width";
import { usePrompt } from "@/hooks/use-prompt";
@@ -333,6 +333,7 @@ function DraftView({
initialText={seed}
onInitialTextConsumed={onSeedConsumed}
placeholder="Start a new chat..."
+ mobileDocked
mode={mode}
onModeChange={onModeChange}
models={models}
@@ -370,6 +371,7 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
// mock UNCONDITIONALLY below (never handed to the daemon) — the toggle
// only controls whether the row is offered.
const { enabled: mockFeatures } = useMockFeatures();
+ const isMobile = useIsMobile();
const isCompact = useIsCompact();
const { confirm, ConfirmDialog } = useConfirm();
const { prompt, PromptDialog } = usePrompt();
@@ -406,7 +408,14 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
window.history.pushState(null, "", chatHref(id));
}, []);
- const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
+ const [sidebarOpen, setSidebarOpen] = useState(!isMobile && !isCompact);
+ // The init above runs before the viewport is measured (useIsMobile is
+ // undefined on the first render), so a phone mounts with the list open even
+ // over a deep-linked chat. Once mobile is a measured fact, the selection
+ // wins; a list the user opens later is untouched.
+ useEffect(() => {
+ if (isMobile && selectedIdRef.current) setSidebarOpen(false);
+ }, [isMobile]);
const wasCompactRef = useRef(isCompact);
// Mirrors `sidebarOpen` for reads inside the stable side-panel handler.
const sidebarOpenRef = useRef(sidebarOpen);
@@ -657,17 +666,17 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
const handleSelectSession = useCallback(
(id: string) => {
selectId(id);
- if (isCompact) setSidebarOpen(false);
+ if (isMobile || isCompact) setSidebarOpen(false);
},
- [selectId, isCompact],
+ [selectId, isMobile, isCompact],
);
/** "New chat" opens the draft route; the daemon session is minted on send. */
const handleNewChat = useCallback(() => {
setSelectedIdState("");
router.push(chatHref());
- if (isCompact) setSidebarOpen(false);
- }, [router, isCompact]);
+ if (isMobile || isCompact) setSidebarOpen(false);
+ }, [router, isMobile, isCompact]);
const deselectIfActive = useCallback(
(id: string) => {
@@ -855,7 +864,9 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
onRespondApproval={() => {}}
pendingClarification={null}
onRespondClarification={() => {}}
- onSidePanelOpenChange={handleSidePanelOpenChange}
+ onSidePanelOpenChange={
+ isMobile ? undefined : handleSidePanelOpenChange
+ }
/>
) : (
sessionActions.onDelete(selectedSession.id)
: undefined
}
- onSidePanelOpenChange={handleSidePanelOpenChange}
+ onSidePanelOpenChange={
+ isMobile ? undefined : handleSidePanelOpenChange
+ }
mode={mode}
onModeChange={changeMode}
models={modelOptions}
@@ -914,6 +927,37 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
)
) : null;
+ if (isMobile) {
+ return (
+
);
}
diff --git a/user-docs/building/what-you-get/studio.md b/user-docs/building/what-you-get/studio.md
index ca2f9b31a1..3a083dd941 100644
--- a/user-docs/building/what-you-get/studio.md
+++ b/user-docs/building/what-you-get/studio.md
@@ -11,8 +11,9 @@ surfaces on one rail — **Chats**, **Scheduled**, **Skills**, **Memory**, and
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 mobile round is in the tree.
+Studio is landing as a stacked series of pull requests; the full workspace,
+including the mobile round, is now in the tree, with a closing documentation
+pass to follow.
:::
## Starting it
@@ -132,3 +133,10 @@ 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.
+
+## On a phone
+
+Studio installs as a PWA and adapts below tablet width: bottom tab
+navigation, sheet-based side panels, a docked composer that plays well with
+the on-screen keyboard, long-press row actions, and swipe-to-dismiss.
+Desktop rendering is unchanged.