diff --git a/docs/design/PRODUCTION-READINESS.md b/docs/design/PRODUCTION-READINESS.md index 2df66c662b..14a493e1b4 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/model-switch/debug · ⛔ 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 · ⛔ 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 e438b6ad7e..8156058295 100644 --- a/studio/src/app/workspace/_components/chat-input.tsx +++ b/studio/src/app/workspace/_components/chat-input.tsx @@ -12,6 +12,7 @@ import { Mic, Paperclip, Plus, + RotateCcw, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -19,6 +20,10 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -30,6 +35,7 @@ import { type EnterSendBehavior, useEnterSendBehavior, } from "@/lib/profile-preferences"; +import type { SessionPermissionMode } from "@/lib/protocol"; import { cn } from "@/lib/utils"; import { type ComposerMenuItem, @@ -49,6 +55,11 @@ interface ChatInputProps { while `isStreaming`; absent when the daemon lacks the steer capability (mid-run sends then queue and files stay attached). */ onSteer?: (content: string, files?: File[]) => void; + onModelChange?: (alias: string) => void; + /** Live daemon models for the picker; absent = the sentinel only. */ + models?: ComposerModelOption[]; + /** Label for the empty (daemon-picks) entry. */ + autoModelLabel?: string; /** Preview an attached file in the canvas panel. */ onPreviewAttachment?: (file: File) => void; disabled?: boolean; @@ -60,6 +71,19 @@ interface ChatInputProps { not quoted and replaces rather than appends. */ initialText?: string | null; onInitialTextConsumed?: () => void; + /** Display-only model label for live-harness sessions (see ModelSelector). */ + modelLockedLabel?: string; + /** Live-chat model switch: 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). */ + currentModelId?: string; + /** The session's current permission mode, shown by the Mode selector. */ + mode?: SessionPermissionMode; + /** Renders the Mode selector (first in the control bar) when provided. + Surfaces without a mode concept (the thread panel, the mock tour chat) + simply omit it. */ + onModeChange?: (mode: SessionPermissionMode) => void; } /** @@ -106,6 +130,302 @@ function FilesDropdown({ ); } +/** 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 = ""; +const autoModel = (label?: string) => ({ + id: AUTO_MODEL_ID, + label: label ?? "Default model", +}); + +export interface ComposerModelOption { + id: string; + label: string; + /** The daemon requires provider_id whenever model_id rides a create. */ + providerId?: string; +} + +const EFFORT_LEVELS = [ + { id: "light", label: "Light" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "extra-high", label: "Extra High" }, +] as const; + +type EffortId = (typeof EFFORT_LEVELS)[number]["id"]; + +const DEFAULT_EFFORT_ID: EffortId = "medium"; + +/** + * 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. + */ +function ModelEffortSelector({ + onModelChange, + lockedLabel, + onSwitchModel, + currentModelId, + models, + autoModelLabel, +}: { + onModelChange?: (id: string) => void; + lockedLabel?: string; + /** Live-chat switch: picking forks the chat onto the model. */ + onSwitchModel?: (option: ComposerModelOption | null) => void; + currentModelId?: string; + models?: ComposerModelOption[]; + autoModelLabel?: string; +}) { + const modelOptions = [autoModel(autoModelLabel), ...(models ?? [])]; + const [model, setModel] = useState(AUTO_MODEL_ID); + const [effort, setEffort] = useState(DEFAULT_EFFORT_ID); + const switchId = currentModelId ?? AUTO_MODEL_ID; + const selectedModel = onSwitchModel + ? (modelOptions.find((m) => m.id === switchId) ?? { + id: switchId, + label: switchId || autoModel(autoModelLabel).label, + }) + : (modelOptions.find((m) => m.id === model) ?? modelOptions[0]); + const selectedEffort = + EFFORT_LEVELS.find((e) => e.id === effort) ?? EFFORT_LEVELS[1]; + + // The toolbar is a CSS container (@container on the footer row): below + // ~28rem — a narrow side-panel composer, not just mobile viewports — the + // value labels collapse to the static word "Model", with the full selection + // kept on the title attribute; in between, truncation caps a long model id. + if (lockedLabel && !onSwitchModel) { + return ( + + ); + } + + return ( + + + + + e.preventDefault()} + align="start" + className="w-64" + > + + + Model + {selectedModel.label} + + + {onSwitchModel && ( +

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

+ )} + {modelOptions.map((m) => { + const isSelected = m.id === (onSwitchModel ? switchId : model); + return ( + { + if (onSwitchModel) { + if (m.id !== switchId) + onSwitchModel(m.id === AUTO_MODEL_ID ? null : m); + return; + } + setModel(m.id); + onModelChange?.(m.id); + }} + > + {m.label} + + + ); + })} +
+
+ {!onSwitchModel && ( + + + Effort + + {selectedEffort.label} + + + + {EFFORT_LEVELS.map((e) => { + const isSelected = e.id === effort; + return ( + setEffort(e.id)} + > + {e.label} + + + ); + })} + + + )} + {!onSwitchModel && ( + <> + + { + setModel(AUTO_MODEL_ID); + setEffort(DEFAULT_EFFORT_ID); + onModelChange?.(AUTO_MODEL_ID); + }} + > + + Reset to default + + + )} +
+
+ ); +} + +const PERMISSION_MODE_OPTIONS = [ + { + id: "default", + label: "Manual", + description: "Always ask before making changes", + }, + { + id: "acceptEdits", + label: "Accept edits", + description: "Automatically accept all file edits", + }, + { + id: "plan", + label: "Plan", + description: "Create a plan before making changes", + }, +] as const satisfies readonly { + id: SessionPermissionMode; + label: string; + description: string; +}[]; + +/** Display label for a session permission mode. */ +function permissionModeLabel(mode: SessionPermissionMode): string { + return ( + PERMISSION_MODE_OPTIONS.find((option) => option.id === mode)?.label ?? + "Manual" + ); +} + +/** + * The session permission-mode selector (Default / Plan / Accept edits), the + * first control in the composer bar. Same pill + container-collapse idiom as + * the model selector: the current mode wide, the bare word "Mode" narrow, + * always the full selection on the title. Disabled while a run streams — the + * daemon's session aggregate refuses a mid-turn mode change, so the control + * matches that reality instead of round-tripping a guaranteed refusal. + */ +function ModeSelector({ + mode, + onModeChange, + disabled, +}: { + mode: SessionPermissionMode; + onModeChange: (mode: SessionPermissionMode) => void; + disabled?: boolean; +}) { + const label = permissionModeLabel(mode); + return ( + + + + + e.preventDefault()} + align="start" + className="w-72" + > + {PERMISSION_MODE_OPTIONS.map((option) => ( + onModeChange(option.id)} + > + + + {option.label} + + {option.description} + + + + ))} + + + ); +} + /** * Controls whether the agent draws on (and writes to) its long-term memory for * this conversation. A pill matching the model selector, opening a small On/Off @@ -407,13 +727,21 @@ export function ChatInput({ onSend, onQueue, onSteer, + onModelChange, disabled = false, isStreaming = false, appendText, onAppendConsumed, initialText, onInitialTextConsumed, + modelLockedLabel, + onSwitchModel, + currentModelId, + models, + autoModelLabel, onPreviewAttachment, + mode, + onModeChange, }: ChatInputProps) { const placeholder = placeholderProp ?? DEFAULT_PLACEHOLDER; // Plain-text mirror of the editor, kept in sync via onUpdate. Used only for @@ -839,7 +1167,28 @@ export function ChatInput({ container queries when THIS row runs narrow (a ~400px side-panel composer), independent of the viewport width. */}
- {!compact && } + {!compact && ( + <> + {onModeChange && ( + + )} + { + onModelChange?.(id); + }} + /> + + + )}
); diff --git a/studio/src/app/workspace/chat/_components/chat-view.tsx b/studio/src/app/workspace/chat/_components/chat-view.tsx index e69750c0b3..c9083cf55c 100644 --- a/studio/src/app/workspace/chat/_components/chat-view.tsx +++ b/studio/src/app/workspace/chat/_components/chat-view.tsx @@ -55,6 +55,7 @@ import { useEnterSendBehavior, useShowToolCalls, } from "@/lib/profile-preferences"; +import type { SessionPermissionMode } from "@/lib/protocol"; import { useShortcut } from "@/lib/shortcuts/use-shortcuts"; import { composeThreadPrompt, @@ -69,7 +70,10 @@ import { useThreadMap, } from "@/lib/thread-map"; import { cn } from "@/lib/utils"; -import { ChatInput } from "../../_components/chat-input"; +import { + ChatInput, + type ComposerModelOption, +} from "../../_components/chat-input"; import { ApprovalPanel } from "./approval-panel"; import { ClarificationPanel } from "./clarification-panel"; import { ContextMeter } from "./context-meter"; @@ -536,6 +540,7 @@ function ThreadPanel({ disabled={!!pendingApproval} initialText={seedText} onInitialTextConsumed={() => setSeedText(null)} + onModelChange={() => {}} placeholder={isStreaming ? "Queue a reply…" : "Reply in thread…"} /> @@ -659,6 +664,7 @@ export function ChatView({ isStreaming, onSend, botName, + live = false, usage, error, onRetry, @@ -684,6 +690,11 @@ export function ChatView({ onCancelRun, onCompact, contextInfo, + mode, + onModeChange, + models, + autoModelLabel, + onSwitchModel, }: { session: AgentSession; messages: AgentMessage[]; @@ -735,6 +746,16 @@ 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; + /** 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 + omits it — a read-only demo has no permission posture to set). */ + onModeChange?: (mode: SessionPermissionMode) => void; + /** Live daemon models for the mid-chat switch picker. */ + models?: ComposerModelOption[]; + autoModelLabel?: string; + /** Picking a model forks this chat onto it (daemon fixes model at create). */ + onSwitchModel?: (option: ComposerModelOption | null) => void; }) { const messagesEndRef = useRef(null); // Whether the transcript is scrolled to (near) the bottom; when it isn't, @@ -1099,6 +1120,16 @@ export function ChatView({ onQueue={onQueueMessage} onSteer={onSteerMessage} onPreviewAttachment={handlePreviewFile} + modelLockedLabel={ + live ? session.model || "Auto-routed" : undefined + } + onModelChange={() => {}} + models={models} + autoModelLabel={autoModelLabel} + onSwitchModel={live ? onSwitchModel : undefined} + currentModelId={session.model ?? ""} + mode={mode} + onModeChange={onModeChange} isStreaming={isStreaming} disabled={!!pendingApproval} appendText={appendText} diff --git a/studio/src/app/workspace/chat/_components/chat-workspace.tsx b/studio/src/app/workspace/chat/_components/chat-workspace.tsx index fe7d491e2a..b3cae02c62 100644 --- a/studio/src/app/workspace/chat/_components/chat-workspace.tsx +++ b/studio/src/app/workspace/chat/_components/chat-workspace.tsx @@ -17,6 +17,8 @@ import { useAgentRoster, useAgentSessions, } from "@/features/agent"; +import { useHarnessRuntime } from "@/features/agent/hooks/use-harness-runtime"; +import { useSessionMode } from "@/features/agent/hooks/use-session-mode"; import { useRuntimeStatus } from "@/features/agent/runtime-status"; import { useConfirm } from "@/hooks/use-confirm"; import { useIsCompact } from "@/hooks/use-mobile"; @@ -26,18 +28,26 @@ import { usePrompt } from "@/hooks/use-prompt"; import { compactHarnessSession, fetchHarnessSessionDetail, + forkHarnessSessionToModel, type HarnessResolvedModel, + ThreadSourceBusyError, } from "@/lib/harness/client"; +import { createHarnessDebugSession } from "@/lib/harness/debug"; +import { useDisabledModels } from "@/lib/model-preferences"; import { type SessionListSide, useAgentDisplayName, useSessionListSide, } from "@/lib/profile-preferences"; +import type { SessionPermissionMode } from "@/lib/protocol"; import { useShortcut } from "@/lib/shortcuts/use-shortcuts"; import { useThreadSessionIds } from "@/lib/thread-map"; import { pageTitleClass } from "@/lib/typography"; import { cn } from "@/lib/utils"; -import { ChatInput } from "../../_components/chat-input"; +import { + ChatInput, + type ComposerModelOption, +} from "../../_components/chat-input"; import { ResizeHandle } from "../../_components/resize-handle"; import { ChatView } from "./chat-view"; import { @@ -181,6 +191,11 @@ function DraftView({ showSidebarButton, sidebarSide, onShowSidebar, + mode, + onModeChange, + models, + autoModelLabel, + onModelChange, }: { onSend: (content: string, files?: File[]) => void; seed: string | null; @@ -190,6 +205,13 @@ function DraftView({ showSidebarButton: boolean; sidebarSide: SessionListSide; onShowSidebar: () => void; + /** Pending permission mode, applied when the first send mints the session. */ + mode: SessionPermissionMode; + onModeChange: (mode: SessionPermissionMode) => void; + /** Live daemon models for the picker ("" = auto-routed). */ + models: ComposerModelOption[]; + autoModelLabel: string; + onModelChange: (id: string) => void; }) { return (
@@ -250,6 +272,11 @@ function DraftView({ initialText={seed} onInitialTextConsumed={onSeedConsumed} placeholder="Start a new chat..." + mode={mode} + onModeChange={onModeChange} + models={models} + autoModelLabel={autoModelLabel} + onModelChange={onModelChange} />
@@ -371,6 +398,49 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) { const hookSessionId = selectedId && 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 getCreateMode = useCallback(() => modeRef.current, [modeRef]); + + // The composer's model picker: live daemon models minus the Studio-side + // disabled set; "" = auto-routed. Like mode, the pick is pending local + // state read via ref when the first send mints the session. + const { models: liveModels, status: runtimeStatus } = useHarnessRuntime(); + const { disabled: disabledModels } = useDisabledModels(); + const modelOptions = useMemo( + () => + liveModels + .filter((m) => !disabledModels.has(m.id)) + .map((m) => ({ + id: m.id, + label: m.displayName || m.id, + // The daemon requires provider_id whenever model_id rides a create. + providerId: m.providerId, + })), + [liveModels, disabledModels], + ); + // "Auto-routed" is only an honest name for the empty pick while the model + // router is actually on; otherwise the daemon just uses its default model. + const routingEnabled = Boolean(runtimeStatus?.modelRouter?.enabled); + const draftModelRef = useRef(""); + const handleDraftModelChange = useCallback((id: string) => { + draftModelRef.current = id; + }, []); + const modelOptionsRef = useRef(modelOptions); + modelOptionsRef.current = modelOptions; + const getCreateModel = useCallback(() => { + const id = draftModelRef.current; + if (!id) return null; + const option = modelOptionsRef.current.find((m) => m.id === id); + // A pick that fell out of the inventory degrades to auto rather than + // sending a bare model_id the daemon would reject. + return option?.providerId + ? { modelId: id, providerId: option.providerId } + : null; + }, []); + const { messages, isStreaming, @@ -395,6 +465,8 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) { cancelChat, } = useAgentChat(hookSessionId, { onSessionCreated: handleSessionCreated, + createMode: getCreateMode, + createModel: getCreateModel, // The inventory poll's lifecycle state: running/awaiting attaches the // durable watch so an externally-driven run renders live (ADR 0250). sessionState: hookSessionId @@ -529,8 +601,40 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) { [selectedId, router], ); + // "Debug with AI" (F1, ADR 0254): creates a SEPARATE no-fs diagnostic + // session bound to the picked chat, after the mandated consent dialog — + // invoking the debugger sends the target's STORED transcript and event + // evidence (secrets included) to the model, even though the target itself + // can never be modified. Gated on the daemon's session_debug capability. + const debugSupported = serverCapabilities.session_debug === true; + const handleDebugSession = useCallback( + async (id: string) => { + const ok = await confirm({ + title: "Debug with AI", + description: + "This creates a separate diagnostic chat bound to this session. " + + "The session's stored transcript and event evidence — including " + + "anything sensitive it contains — will be sent to the model as " + + "debugging evidence. The session itself is read-only to the " + + "debugger and is never modified.", + confirmText: "Send evidence & debug", + }); + if (!ok) return; + try { + const debugId = await createHarnessDebugSession(id); + await refreshSessions(); + handleSelectSession(debugId); + toast.success("Debug session created"); + } catch (caught) { + toast.error(caught instanceof Error ? caught.message : String(caught)); + } + }, + [confirm, refreshSessions, handleSelectSession], + ); + const sessionActions: SessionActions = useMemo( () => ({ + onDebug: debugSupported ? handleDebugSession : undefined, onRename: async (id: string) => { const s = sessions.find((x) => x.id === id); const name = await prompt({ @@ -554,7 +658,16 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) { deselectIfActive(id); }, }), - [sessions, prompt, renameSession, confirm, deleteSession, deselectIfActive], + [ + sessions, + prompt, + renameSession, + confirm, + deleteSession, + deselectIfActive, + debugSupported, + handleDebugSession, + ], ); // Flattened, in-display-order chat ids for keyboard navigation. @@ -608,6 +721,40 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) { const turnError = status === "error" ? (chatError ?? "The last turn failed.") : null; + // Mid-chat model switch: the daemon fixes a session's model at create, so + // a pick FORKS the chat — a new session seeded from this one's history on + // the new model, carrying the title — and the UI moves there. The old chat + // stays in the list (nothing is destroyed); a mid-run source answers 412. + const handleSwitchModel = useCallback( + async (option: ComposerModelOption | null) => { + const source = selectedSession; + if (!source) return; + try { + const newId = await forkHarnessSessionToModel( + source.id, + option?.providerId + ? { modelId: option.id, providerId: option.providerId } + : null, + source.title || "", + ); + await refreshSessions(); + handleSelectSession(newId); + toast.success( + `Continuing on ${option?.label ?? "the auto-routed model"} in a copy of this chat`, + ); + } catch (caught) { + toast.error( + caught instanceof ThreadSourceBusyError + ? "Wait for the current response to finish, then switch models." + : caught instanceof Error + ? caught.message + : String(caught), + ); + } + }, + [selectedSession, refreshSessions, handleSelectSession], + ); + const chatView = (open: boolean, onToggle: () => void) => selectedSession ? ( ) : null; @@ -668,6 +820,9 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) { chatView(sidebarOpen, () => setSidebarOpen((o) => !o)) ) : ( setSidebarOpen(true)} + mode={mode} + onModeChange={changeMode} /> )} diff --git a/studio/src/app/workspace/chat/_components/session-sidebar.tsx b/studio/src/app/workspace/chat/_components/session-sidebar.tsx index 2f36257e68..5acfc141ac 100644 --- a/studio/src/app/workspace/chat/_components/session-sidebar.tsx +++ b/studio/src/app/workspace/chat/_components/session-sidebar.tsx @@ -2,6 +2,7 @@ import { Bot, + Bug, ChevronDown, ChevronUp, Ellipsis, @@ -9,6 +10,7 @@ import { Trash2, } from "lucide-react"; import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, @@ -22,6 +24,13 @@ import { cn } from "@/lib/utils"; export interface SessionActions { onRename: (id: string) => void; onDelete: (id: string) => void; + /** + * "Debug with AI" (ADR 0254): present only when the daemon reports + * `capabilities.session_debug` — the caller gates it, the menus render it. + * The handler owns the consent dialog; a row that already IS a debug + * session never offers it (no debugging the debugger). + */ + onDebug?: (id: string) => void; } export function SidebarGroup({ @@ -57,6 +66,8 @@ function SessionContextMenu({ // (disabled menu items swallow pointer events, so a tooltip can't open). const canRename = session.canRename === true; const canDelete = session.canDelete === true; + const offerDebug = + actions.onDebug !== undefined && !session.debugTargetSessionId; return ( @@ -67,6 +78,12 @@ function SessionContextMenu({ sideOffset={4} className="w-56" > + {offerDebug && ( + actions.onDebug?.(session.id)}> + + Debug with AI + + )} actions.onRename(session.id)} @@ -150,6 +167,17 @@ function SessionRow({ > {session.title || "Untitled"} + {/* An AI-debug session (ADR 0254) reads as an ordinary chat except + for this label — the relationship is the daemon's signal. */} + {session.debugTargetSessionId && ( + + Debug + + )}
diff --git a/studio/src/features/agent/hooks/use-session-mode.ts b/studio/src/features/agent/hooks/use-session-mode.ts new file mode 100644 index 0000000000..ed62202799 --- /dev/null +++ b/studio/src/features/agent/hooks/use-session-mode.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + fetchHarnessSessionMode, + setHarnessSessionMode, +} from "@/lib/harness/client"; +import type { SessionPermissionMode } from "@/lib/protocol"; +import { useRuntimeStatus } from "../runtime-status"; + +/** + * The permission mode for one chat's composer Mode selector. + * + * Two shapes, one seam: + * - A LIVE session (`sessionId` set): the mode is read from the session + * snapshot on open, and a change round-trips through POST /mode, adopting + * the daemon's echo (and rolling back when the daemon refuses — e.g. the + * aggregate rejects a mid-turn change). + * - A DRAFT (`sessionId` null): the selection is held as pending local state; + * the caller reads it via `modeRef` at session-creation time and passes it + * into the create body. + */ +export function useSessionMode(sessionId: string | null) { + const { connected } = useRuntimeStatus(); + const [mode, setMode] = useState("default"); + // Mirror for callers that need the value at an async boundary (the chat + // hook mints the draft's session mid-send, after this render's closure). + const modeRef = useRef(mode); + modeRef.current = mode; + + useEffect(() => { + // Opening a chat (or going back to the draft) resets, then adopts the + // snapshot's mode. A just-minted draft re-fetches its own creation mode — + // momentarily "default", then self-consistent. + setMode("default"); + if (!sessionId || !connected) return; + const controller = new AbortController(); + void (async () => { + try { + const fetched = await fetchHarnessSessionMode( + sessionId, + controller.signal, + ); + if (!controller.signal.aborted) setMode(fetched); + } catch { + // Unknown snapshot mode: keep the default label. Changing the mode + // still round-trips normally. + } + })(); + return () => controller.abort(); + }, [sessionId, connected]); + + const changeMode = useCallback( + (next: SessionPermissionMode) => { + if (!sessionId) { + // Draft: held pending; applied when the session is created. + setMode(next); + return; + } + const previous = modeRef.current; + setMode(next); // optimistic; the echo or the rollback settles it + void (async () => { + try { + const echoed = await setHarnessSessionMode(sessionId, next); + setMode(echoed); + } catch { + // The daemon refused (mid-turn, or the session vanished): the + // selector snaps back rather than lying about the posture. + setMode(previous); + } + })(); + }, + [sessionId], + ); + + return { mode, modeRef, changeMode }; +} diff --git a/studio/src/lib/server-proxy.ts b/studio/src/lib/server-proxy.ts index 73427d6694..f54daf2752 100644 --- a/studio/src/lib/server-proxy.ts +++ b/studio/src/lib/server-proxy.ts @@ -103,6 +103,19 @@ async function resolveWorkspace(external: string): Promise { } } +/** + * True for a create body the daemon REQUIRES to arrive workspace-free: the + * no-fs profile has no file tools to root anywhere ("no-fs requires an EMPTY + * workspace"), and a debug session (ADR 0254) is no-fs by contract — the + * daemon rejects either with a non-empty workspace. Injecting one here would + * turn every such create into a guaranteed 400, so injection skips them. + * (These bodies may still carry an explicit `workspace: ""`, which the + * `!body.workspace` check below would otherwise treat as "absent".) + */ +function requiresEmptyWorkspace(body: Record): boolean { + return body.profile === "no-fs" || Boolean(body.debug_target_session_id); +} + async function withWorkspace( request: Request, external: string, @@ -113,7 +126,7 @@ async function withWorkspace( if (typeof body !== "object" || body === null || Array.isArray(body)) { return raw; } - if (!body.workspace) { + if (!body.workspace && !requiresEmptyWorkspace(body)) { const workspace = await resolveWorkspace(external); if (workspace) body.workspace = workspace; } diff --git a/user-docs/building/what-you-get/studio.md b/user-docs/building/what-you-get/studio.md index f694069caf..1d431b737c 100644 --- a/user-docs/building/what-you-get/studio.md +++ b/user-docs/building/what-you-get/studio.md @@ -12,8 +12,8 @@ Studio reads and writes the daemon's state rather than keeping its own. :::note Landing in progress Studio is landing as a stacked series of pull requests. This page grows with -each one; right now everything except session modes/model-switch/debug, Labs, and the -mobile round is in the tree. +each one; right now everything except the Labs extras and the mobile round is in the +tree. ::: ## Starting it @@ -117,3 +117,12 @@ Open a driving session in a second tab (or refresh mid-run) and Studio re-attaches to the live run through the daemon's durable session watch. One limit to know: the tab that *started* the run still cancels it if that tab's prompt connection drops — re-attach covers every other tab. + +## Modes, model switching, debug sessions + +The composer's mode selector switches the session's permission mode live +(with rollback if the daemon refuses), and the model selector forks the +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.