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 +
+ + {onModeChange && ( + + )} + + +
+
+
+ + { + if (!open) setSub(null); + }} + > + + Permission mode +
+ {PERMISSION_MODE_OPTIONS.map((option) => ( + { + onModeChange?.(option.id); + setSub(null); + }} + /> + ))} +
+
+
+ + { + if (!open) setSub(null); + }} + > + + Model and effort +
+ Model + {onSwitchModel && ( +

+ Picking a model continues this chat in a copy on it. +

+ )} + {modelOptions.map((m) => ( + { + if (onSwitchModel) { + setSub(null); + if (m.id !== switchId) + onSwitchModel(m.id === AUTO_MODEL_ID ? null : m); + return; + } + setModel(m.id); + onModelChange?.(m.id); + }} + /> + ))} + {!onSwitchModel && Effort} + {!onSwitchModel && + EFFORT_LEVELS.map((e) => ( + setEffort(e.id)} + /> + ))} +
+ +
+ + + + { + if (!open) setSub(null); + }} + > + + Memory +
+ {[true, false].map((value) => ( + { + setMemoryOn(value); + setSub(null); + }} + /> + ))} +
+
+
+ + ); +} + 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. */} +
+
+ + setAttachedFiles((prev) => [...prev, ...newFiles]) + } + onModelChange={onModelChange} + modelLockedLabel={modelLockedLabel} + onSwitchModel={onSwitchModel} + currentModelId={currentModelId} + mode={mode} + onModeChange={onModeChange} + modeDisabled={disabled || isStreaming} + /> +
{/* 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). */}
+
+ {!hasText && voice.isSupported ? ( + + ) : ( + + )} +
- {/* 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 +
+ + + {onCompact && ( + + )} + {onRename && ( + + )} + {onDelete && ( + + )} +
+
+
+ + ); +} + function AttachmentPanel({ attachment, onClose, maximized, onToggleMaximize, + windowControls, }: { attachment: Attachment; onClose: () => void; maximized: boolean; onToggleMaximize: () => void; + windowControls?: boolean; }) { return (
void; maximized: boolean; onToggleMaximize: () => void; + windowControls?: boolean; }) { // The global Show Tools preference — shared with the chat's ··· menu. const { showToolCalls: showTools, setShowToolCalls } = useShowToolCalls(); @@ -423,6 +525,7 @@ function ThreadPanel({ onToggleMaximize={onToggleMaximize} onClose={onClose} minWidth={340} + windowControls={windowControls} headerExtra={ @@ -546,6 +649,7 @@ function ThreadPanel({ onInitialTextConsumed={() => setSeedText(null)} onModelChange={() => {}} placeholder={isStreaming ? "Queue a reply…" : "Reply in thread…"} + mobileDocked />
@@ -565,12 +669,14 @@ function MockThreadPanel({ onClose, maximized, onToggleMaximize, + windowControls, }: { rootMessage: AgentMessage; botName: string; onClose: () => void; maximized: boolean; onToggleMaximize: () => void; + windowControls?: boolean; }) { const replies = rootMessage.replies ?? []; return ( @@ -582,6 +688,7 @@ function MockThreadPanel({ onToggleMaximize={onToggleMaximize} onClose={onClose} minWidth={340} + windowControls={windowControls} >
@@ -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 && ( + )} {sidebarSide === "left" && sidebarToggle} {isStreaming && ( {sidebarSide === "right" && sidebarToggle} - - - - - - {/* Token usage as the daemon reported it (was a header pill; - it lives in the menu now). Hidden until any lands. */} - - setShowToolCalls(!showActivity)}> - - {showActivity ? "Hide Tools" : "Show Tools"} - - {onCompact && ( - - - Compact conversation - - )} - {onRename && ( - - - Rename - - )} - {onDelete && ( - - - Delete + {!isMobile && ( + + + + + + {/* Token usage as the daemon reported it (was a header pill; + it lives in the menu now). Hidden until any lands. */} + + setShowToolCalls(!showActivity)} + > + + {showActivity ? "Hide Tools" : "Show Tools"} - )} - - + {onCompact && ( + + + Compact conversation + + )} + {onRename && ( + + + Rename + + )} + {onDelete && ( + + + Delete + + )} + + + )} + {isMobile && ( + setShowToolCalls(!showActivity)} + onRename={onRename} + onDelete={onDelete} + onCompact={onCompact} + compactDisabled={isStreaming} + usage={usage} + /> + )}
@@ -1179,6 +1316,8 @@ export function ChatView({ onQueue={onQueueMessage} onSteer={onSteerMessage} onPreviewAttachment={handlePreviewFile} + focusKey={session.id} + mobileDocked modelLockedLabel={ live ? session.model || "Auto-routed" : undefined } @@ -1214,7 +1353,7 @@ export function ChatView({
- {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 ( +
+ {dialogs} + {sidebarOpen ? ( +
+ +
+ ) : selectedSession ? ( + chatView(false, () => setSidebarOpen(true)) + ) : ( + setSidebarOpen(true)} + mode={mode} + onModeChange={changeMode} + /> + )} +
+ ); + } + return (
{dialogs} diff --git a/studio/src/app/workspace/chat/_components/message-bubble.tsx b/studio/src/app/workspace/chat/_components/message-bubble.tsx index 4e5d1d4e26..5081a92852 100644 --- a/studio/src/app/workspace/chat/_components/message-bubble.tsx +++ b/studio/src/app/workspace/chat/_components/message-bubble.tsx @@ -13,6 +13,7 @@ import { MessageSquareText, User, } from "lucide-react"; +import { useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Badge } from "@/components/ui/badge"; @@ -461,7 +462,27 @@ export function MessageBubble({ const isUser = message.role === "user"; const { name: userName } = useUserDisplayName(); + // Touch has no hover: a horizontal-dominant left swipe reveals the action + // row instead (right swipe or a new swipe elsewhere hides it again). + // Hooks stay above the tool-message early return (rules of hooks). + const [swipeRevealed, setSwipeRevealed] = useState(false); + const touchStart = useRef<{ x: number; y: number } | null>(null); + if (message.role === "tool") return null; + const onTouchStart = (event: React.TouchEvent) => { + const touch = event.touches[0]; + touchStart.current = { x: touch.clientX, y: touch.clientY }; + }; + const onTouchMove = (event: React.TouchEvent) => { + const start = touchStart.current; + if (!start) return; + const touch = event.touches[0]; + const dx = touch.clientX - start.x; + const dy = touch.clientY - start.y; + if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy) * 1.5) return; + setSwipeRevealed(dx < 0); + touchStart.current = null; + }; const hasToolCalls = message.toolCalls && message.toolCalls.length > 0; // Files this turn produced (Write calls), rendered as attachment chips — @@ -494,7 +515,14 @@ export function MessageBubble({ return null; return ( -
+
{isUser ? : }
diff --git a/studio/src/app/workspace/chat/_components/session-sidebar.tsx b/studio/src/app/workspace/chat/_components/session-sidebar.tsx index bb0592c26a..89549ff9a0 100644 --- a/studio/src/app/workspace/chat/_components/session-sidebar.tsx +++ b/studio/src/app/workspace/chat/_components/session-sidebar.tsx @@ -11,7 +11,7 @@ import { Pencil, Trash2, } from "lucide-react"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { DropdownMenu, @@ -19,6 +19,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import type { AgentSession, RosterAgent } from "@/features/agent"; import { MOCK_PROJECTS, @@ -130,6 +131,155 @@ function SessionContextMenu({ ); } +/** + * Touch long-press detection for a row. Mouse pointers are ignored (desktop + * has the hover "…" menu); a hold of ~450ms without moving past the slop + * fires, and the click that follows the release is swallowed by the caller + * via `firedRef`. Android's native long-press contextmenu is suppressed for + * touch so the sheet is the one menu. + */ +function useLongPress(onLongPress: () => void) { + const firedRef = useRef(false); + const state = useRef<{ + timer: ReturnType | null; + startX: number; + startY: number; + touch: boolean; + }>({ timer: null, startX: 0, startY: 0, touch: false }); + + const clear = () => { + if (state.current.timer) { + clearTimeout(state.current.timer); + state.current.timer = null; + } + }; + + const handlers = { + onPointerDown: (event: React.PointerEvent) => { + state.current.touch = event.pointerType !== "mouse"; + if (!state.current.touch) return; + firedRef.current = false; + state.current.startX = event.clientX; + state.current.startY = event.clientY; + clear(); + state.current.timer = setTimeout(() => { + state.current.timer = null; + firedRef.current = true; + onLongPress(); + }, 450); + }, + onPointerMove: (event: React.PointerEvent) => { + if ( + state.current.timer && + Math.hypot( + event.clientX - state.current.startX, + event.clientY - state.current.startY, + ) > 10 + ) { + clear(); + } + }, + onPointerUp: clear, + onPointerCancel: clear, + onPointerLeave: clear, + onContextMenu: (event: React.MouseEvent) => { + if (state.current.touch) event.preventDefault(); + }, + }; + + return { firedRef, handlers }; +} + +/** The long-press bottom sheet: the same rename/delete actions as the + * hover "…" menu, honoring the daemon row's capabilities and reasons. */ +function SessionActionsSheet({ + session, + actions, + onClose, +}: { + session: AgentSession; + actions: SessionActions; + onClose: () => void; +}) { + const canRename = session.canRename === true; + const canDelete = session.canDelete === true; + const offerDebug = + actions.onDebug !== undefined && + !session.debugTargetSessionId && + !isMockTourSession(session.id); + const row = + "flex w-full items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-muted/50 disabled:opacity-50"; + + return ( + { + if (!open) onClose(); + }} + > + + Chat options +
+

+ {session.title || "Untitled"} +

+ {offerDebug && ( + + )} + + +
+
+
+ ); +} + function SessionRow({ session, isSelected, @@ -142,7 +292,9 @@ function SessionRow({ actions: SessionActions; }) { const [menuOpen, setMenuOpen] = useState(false); + const [sheetOpen, setSheetOpen] = useState(false); const isRunning = session.isStreaming || session.state === "running"; + const longPress = useLongPress(() => setSheetOpen(true)); return (
+ {sheetOpen && ( + setSheetOpen(false)} + /> + )}
); } 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.