diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index 93e7d208f2..845cea436a 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -9,10 +9,10 @@ import { createHash } from "node:crypto"; import type { StudioContext } from "@/core/studio-context"; import { TierUnavailableError, + resolveCliTier, resolveTier, tryResolveTier, } from "@/core/resolve-tier"; -import { resolveAgentTier } from "@/ai-providers/agent-tiers"; import type { ChatTier, SimpleModeTier } from "@/tools/organization/schema"; import { posthog } from "@/posthog"; import { consumeStream, createUIMessageStreamResponse } from "ai"; @@ -226,14 +226,9 @@ async function resolvePerRequestModels( tier === "fast" || tier === "smart" || tier === "thinking" ? tier : "smart"; - const entry = resolveAgentTier(harnessId, chatTier); - if (!entry) { - // Should be unreachable — resolveAgentTier returns non-null for - // both supported CLI harnesses and every ChatTier value. - throw new Error( - `No model mapping for harness "${harnessId}" tier "${chatTier}"`, - ); - } + // Prefer the org's `simple_mode.cli` override; falls back to the hardcoded + // default when unset. + const entry = await resolveCliTier(ctx, harnessId, chatTier); return { credentialId: `desktop:${harnessId}`, thinking: { diff --git a/apps/mesh/src/core/resolve-tier.test.ts b/apps/mesh/src/core/resolve-tier.test.ts index 242b93dd15..e2c25914ea 100644 --- a/apps/mesh/src/core/resolve-tier.test.ts +++ b/apps/mesh/src/core/resolve-tier.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, mock } from "bun:test"; import type { StudioContext } from "@/core/studio-context"; -import { resolveTier, TierUnavailableError } from "./resolve-tier"; +import type { SimpleModeConfig } from "@/storage/types"; +import { + resolveCliTier, + resolveTier, + TierUnavailableError, +} from "./resolve-tier"; interface ProviderKeyFixture { id: string; @@ -177,3 +182,95 @@ describe("resolveTier", () => { expect(result.modelId).toBe("claude-sonnet-4-6"); }); }); + +function makeCliCtx(opts: { + simpleMode?: SimpleModeConfig | null; + orgId?: string | null; + getThrows?: boolean; +}): StudioContext { + return { + organization: + opts.orgId === null ? undefined : { id: opts.orgId ?? "org_1" }, + storage: { + organizationSettings: { + get: mock(() => + opts.getThrows + ? Promise.reject(new Error("boom")) + : Promise.resolve( + opts.simpleMode ? { simple_mode: opts.simpleMode } : null, + ), + ), + }, + }, + } as unknown as StudioContext; +} + +describe("resolveCliTier", () => { + it("returns the hardcoded default when no override is set", async () => { + const ctx = makeCliCtx({ simpleMode: null }); + const entry = await resolveCliTier(ctx, "claude-code", "smart"); + expect(entry.modelId).toBe("claude-code:sonnet"); + expect(entry.label).toBe("Sonnet 5"); + }); + + it("prefers the org override when present", async () => { + const ctx = makeCliCtx({ + simpleMode: { + tiers: { + fast: null, + smart: null, + thinking: null, + image: null, + web_search: null, + deep_research: null, + }, + cli: { + "claude-code": { + fast: null, + smart: { modelId: "claude-code:opus-1m", title: "Opus 4.8 1M" }, + thinking: null, + }, + }, + }, + }); + const entry = await resolveCliTier(ctx, "claude-code", "smart"); + expect(entry.modelId).toBe("claude-code:opus-1m"); + expect(entry.label).toBe("Opus 4.8 1M"); + }); + + it("falls back to default for a tier the override does not set", async () => { + const ctx = makeCliCtx({ + simpleMode: { + tiers: { + fast: null, + smart: null, + thinking: null, + image: null, + web_search: null, + deep_research: null, + }, + cli: { + codex: { + fast: { modelId: "codex:gpt-5.5", title: "GPT-5.5" }, + smart: null, + thinking: null, + }, + }, + }, + }); + const entry = await resolveCliTier(ctx, "codex", "smart"); + expect(entry.modelId).toBe("codex:gpt-5.4"); + }); + + it("returns the default when there is no organization in context", async () => { + const ctx = makeCliCtx({ orgId: null }); + const entry = await resolveCliTier(ctx, "codex", "fast"); + expect(entry.modelId).toBe("codex:gpt-5.4-mini"); + }); + + it("returns the default when the settings read fails", async () => { + const ctx = makeCliCtx({ getThrows: true }); + const entry = await resolveCliTier(ctx, "claude-code", "thinking"); + expect(entry.modelId).toBe("claude-code:opus-1m"); + }); +}); diff --git a/apps/mesh/src/core/resolve-tier.ts b/apps/mesh/src/core/resolve-tier.ts index a314e583db..2601fa3ca2 100644 --- a/apps/mesh/src/core/resolve-tier.ts +++ b/apps/mesh/src/core/resolve-tier.ts @@ -1,5 +1,10 @@ import type { StudioContext } from "@/core/studio-context"; -import type { SimpleModeTier } from "@/tools/organization/schema"; +import type { ChatTier, SimpleModeTier } from "@/tools/organization/schema"; +import { + resolveAgentTier, + type AgentTierEntry, +} from "@/ai-providers/agent-tiers"; +import type { CliHarnessId } from "@/storage/types"; import { pickSimpleModeDefaults, type AiProviderKey, @@ -180,6 +185,43 @@ export async function resolveSpecificModel( }; } +/** + * Resolve the model a local CLI harness (Claude Code / Codex) should run for a + * chat tier. Prefers the org's `simple_mode.cli` override, falling back to the + * hardcoded default in `resolveAgentTier`. Reads settings best-effort — a + * missing org, missing override, or a read failure all fall through to the + * default, so dispatch never fails on the CLI path. + */ +export async function resolveCliTier( + ctx: StudioContext, + harnessId: CliHarnessId, + tier: ChatTier, +): Promise { + const fallback = resolveAgentTier(harnessId, tier); + if (!fallback) { + // Unreachable — resolveAgentTier is total over CLI harness × ChatTier. + throw new Error( + `No model mapping for harness "${harnessId}" tier "${tier}"`, + ); + } + + const orgId = ctx.organization?.id; + if (!orgId) return fallback; + + try { + const settings = await ctx.storage.organizationSettings.get(orgId); + const slot = settings?.simple_mode?.cli?.[harnessId]?.[tier] ?? null; + if (!slot) return fallback; + return { modelId: slot.modelId, label: slot.title ?? slot.modelId }; + } catch (err) { + console.warn( + `[resolveCliTier] override lookup failed for "${harnessId}"/"${tier}":`, + err, + ); + return fallback; + } +} + export async function tryResolveTier( ctx: StudioContext, tier: SimpleModeTier, diff --git a/apps/mesh/src/storage/types.ts b/apps/mesh/src/storage/types.ts index 56b93b1685..959ef38b74 100644 --- a/apps/mesh/src/storage/types.ts +++ b/apps/mesh/src/storage/types.ts @@ -145,8 +145,23 @@ export type SimpleModeTier = | "web_search" | "deep_research"; +/** Keyless slot for local CLI harnesses — the credential is desktop-side. */ +export interface CliModelSlot { + modelId: string; + title?: string; +} + +export type CliHarnessId = "claude-code" | "codex"; + +export interface CliTierConfig { + fast: CliModelSlot | null; + smart: CliModelSlot | null; + thinking: CliModelSlot | null; +} + export interface SimpleModeConfig { tiers: Record; + cli?: Partial>; } export interface DefaultHomeAgentsConfig { diff --git a/apps/mesh/src/tools/organization/schema.ts b/apps/mesh/src/tools/organization/schema.ts index 34d65b5c57..c3bc0426b4 100644 --- a/apps/mesh/src/tools/organization/schema.ts +++ b/apps/mesh/src/tools/organization/schema.ts @@ -65,6 +65,27 @@ export const ChatTierSchema = z.enum(["fast", "smart", "thinking"]); export type ChatTier = z.infer; +/** + * Keyless model slot for local CLI harnesses (Claude Code / Codex). Unlike the + * cloud `ModelSlotSchema`, there is no `keyId` — the credential lives on the + * user's desktop link, not in an `ai_provider_keys` row. + */ +const CliModelSlotSchema = z + .object({ + modelId: z.string(), + title: z.string().optional(), + }) + .nullable(); + +/** Per-harness fast/smart/thinking override for a local CLI runtime. */ +export const CliTierConfigSchema = z.object({ + fast: CliModelSlotSchema, + smart: CliModelSlotSchema, + thinking: CliModelSlotSchema, +}); + +export type CliTierConfig = z.infer; + export const SimpleModeConfigSchema = z.object({ tiers: z.object({ fast: ModelSlotSchema, @@ -74,6 +95,19 @@ export const SimpleModeConfigSchema = z.object({ web_search: ModelSlotSchema, deep_research: ModelSlotSchema, }), + /** + * Optional per-harness tier→model overrides for local CLI runtimes. Absent + * (or a missing harness/tier) falls back to the hardcoded default in + * `resolveAgentTier`. Lives in the same JSON blob as `tiers` so no migration + * is needed. + */ + cli: z + .object({ + "claude-code": CliTierConfigSchema, + codex: CliTierConfigSchema, + }) + .partial() + .optional(), }); export type SimpleModeConfig = z.infer; diff --git a/apps/mesh/src/web/components/chat/chat-context.tsx b/apps/mesh/src/web/components/chat/chat-context.tsx index eb49e5ea71..883d767fb4 100644 --- a/apps/mesh/src/web/components/chat/chat-context.tsx +++ b/apps/mesh/src/web/components/chat/chat-context.tsx @@ -48,6 +48,8 @@ import { agentOptionFor, type AgentOption, } from "./pills/agent-options"; +import { useCurrentLink } from "../../hooks/use-current-link"; +import { defaultAgentOption } from "../../lib/agent-capabilities"; import { resolveSubmitSettings } from "./resolve-submit-settings"; import { isDeepResearchModel, @@ -602,10 +604,15 @@ export function ChatPrefsProvider({ children }: PropsWithChildren) { ) : null; - // Preserve the user's selected runtime exactly. Presence/capability probes are - // advisory only; dispatch should surface the real backend error if the choice - // cannot run. - const selectedAgentOption = pendingAgentOption; + // Preserve the user's explicit selection exactly. Presence/capability probes + // are advisory only; dispatch should surface the real backend error if the + // choice cannot run. When the user hasn't picked (`null`), resolve a smart + // default: with no cloud provider keys, a cloud Decopilot default can't run, + // so fall back to a linked local CLI harness (Claude Code / Codex) when one + // is available. SaaS (keys present) keeps the cloud default. + const link = useCurrentLink(); + const selectedAgentOption = + pendingAgentOption ?? defaultAgentOption(keys.length > 0, link); // When the thread is locked, the agent option is dictated by the persisted // (harness, sandbox) pair — period. Otherwise, fall through to the user's diff --git a/apps/mesh/src/web/components/chat/pills/chat-mode-row.test.tsx b/apps/mesh/src/web/components/chat/pills/chat-mode-row.test.tsx index 48800c4b59..7a275d9084 100644 --- a/apps/mesh/src/web/components/chat/pills/chat-mode-row.test.tsx +++ b/apps/mesh/src/web/components/chat/pills/chat-mode-row.test.tsx @@ -13,54 +13,20 @@ mock.module("../../thread/github/branch-picker", () => ({ import { ChatModeRowPure } from "./chat-mode-row"; import { BranchPill } from "./branch-pill"; -import { ModePickerPure } from "./mode-picker"; describe("ChatModeRowPure", () => { - it("returns null when both pills are null", () => { - const { container } = render( - , - ); + it("returns null when branchPill is null", () => { + const { container } = render(); expect(container.firstChild).toBeNull(); }); - it("renders only the BranchPill when ModePicker is null", () => { - const { getByTestId, queryByTestId } = render( - branch} - modePicker={null} - />, - ); - expect(getByTestId("branch-pill")).toBeInTheDocument(); - expect(queryByTestId("mode-picker")).toBeNull(); - }); - - it("renders only the ModePicker when BranchPill is null", () => { - const { getByTestId, queryByTestId } = render( - mode} - />, - ); - expect(getByTestId("mode-picker")).toBeInTheDocument(); - expect(queryByTestId("branch-pill")).toBeNull(); - }); - - it("renders both pills, ModePicker before BranchPill", () => { + it("renders the BranchPill when provided", () => { const { getByTestId } = render( branch} - modePicker={mode} />, ); - const branch = getByTestId("branch-pill"); - const mode = getByTestId("mode-picker"); - expect(branch).toBeInTheDocument(); - expect(mode).toBeInTheDocument(); - // Harness (ModePicker) reads first so the user sees: - // "using [Cloud] on branch [main]" left-to-right. - expect( - mode.compareDocumentPosition(branch) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); + expect(getByTestId("branch-pill")).toBeInTheDocument(); }); }); @@ -93,38 +59,3 @@ describe("BranchPill", () => { expect(getByRole("button")).toBeInTheDocument(); }); }); - -describe("ChatModeRow integration", () => { - it("renders both pills as non-interactive when locked=true", () => { - const { getByTestId, queryAllByRole } = render( - - } - modePicker={ - {})} - /> - } - />, - ); - - // Both locked elements are present - expect(getByTestId("branch-picker-locked")).toBeInTheDocument(); - expect(getByTestId("mode-picker-locked")).toBeInTheDocument(); - - // No interactive (enabled) buttons should be present - const buttons = queryAllByRole("button"); - for (const btn of buttons) { - expect(btn).toBeDisabled(); - } - }); -}); diff --git a/apps/mesh/src/web/components/chat/pills/chat-mode-row.tsx b/apps/mesh/src/web/components/chat/pills/chat-mode-row.tsx index ab60edfc16..b792711ef2 100644 --- a/apps/mesh/src/web/components/chat/pills/chat-mode-row.tsx +++ b/apps/mesh/src/web/components/chat/pills/chat-mode-row.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from "react"; import type { VirtualMCPEntity } from "@decocms/mesh-sdk/types"; import { useOptionalChatStream, useOptionalChatTask } from "../context"; -import { ModePicker } from "./mode-picker"; import { BranchPill } from "./branch-pill"; import { getActiveGithubRepo } from "@/web/lib/github-repo"; import { useProjectContext } from "@decocms/mesh-sdk"; @@ -9,24 +8,11 @@ import { authClient } from "@/web/lib/auth-client"; interface PureProps { branchPill: ReactNode; - modePicker: ReactNode; } -/** - * Pure layout — used by tests. Each slot renders independently; the - * component returns null only when BOTH are null. - * - * Renders as a fragment (no wrapping div) so the pills sit in the - * parent flex flow with the same gap as their siblings. - */ -export function ChatModeRowPure({ branchPill, modePicker }: PureProps) { - if (!branchPill && !modePicker) return null; - return ( - <> - {modePicker} - {branchPill} - - ); +export function ChatModeRowPure({ branchPill }: PureProps) { + if (!branchPill) return null; + return <>{branchPill}; } interface SmartProps { @@ -34,21 +20,6 @@ interface SmartProps { currentBranch: string | null; } -/** - * Smart wrapper. Composes BranchPill + ModePicker. Each pill is gated - * by its own capability check: - * - * - BranchPill: agent was imported from GitHub — `metadata.githubRepo` - * exists AND has an attached `connectionId` (authenticated user - * repo, not a public-template clone). Start Website agents - * populate `metadata.githubRepo.url` for the template but leave - * `connectionId` unset; branches aren't meaningful there. - * - ModePicker: runtime availability is decided inside the picker from - * server runtime config plus the user's linked desktop capabilities. - * - * Locked flag is derived once here from - * `useOptionalChatStream().messages.length > 0` and passed to both. - */ export function ChatModeRow({ virtualMcp, currentBranch }: SmartProps) { const stream = useOptionalChatStream(); const taskCtx = useOptionalChatTask(); @@ -85,13 +56,5 @@ export function ChatModeRow({ virtualMcp, currentBranch }: SmartProps) { /> ) : null; - const modePicker = ( - - ); - - return ; + return ; } diff --git a/apps/mesh/src/web/components/chat/side-panel-chat.tsx b/apps/mesh/src/web/components/chat/side-panel-chat.tsx index 4df62bca54..efad38db1d 100644 --- a/apps/mesh/src/web/components/chat/side-panel-chat.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-chat.tsx @@ -10,10 +10,9 @@ import { AgentHome } from "./agent-home"; import { ThreadFilesPanel } from "./thread-files-panel"; import { wasCreditsEmptyDismissed } from "./credits-empty-state"; -import { hasLocalCliHarness } from "@/web/lib/agent-capabilities"; import { useAiProviderKeys } from "@/web/hooks/collections/use-ai-providers"; -import { useCurrentLink } from "@/web/hooks/use-current-link"; import { useDecoCredits } from "@/web/hooks/use-deco-credits"; +import { agentModeNeedsCloudProvider, useAgentMode } from "./use-agent-mode"; // ---------- Panel content ---------- @@ -23,12 +22,13 @@ function ChatPanelContent() { const { isChatEmpty } = useChatStream(); const [activePanel, setActivePanel] = useState<"chat" | "context">("chat"); const deco = useDecoCredits(); - const link = useCurrentLink(); + const agentMode = useAgentMode(); - // No cloud provider key needed when an online desktop CLI harness - // (Claude Code / Codex) can back the chat instead. + // Only Decopilot needs a cloud provider key; Claude Code / Codex bring their + // own inference. Show the provider picker when the selected runtime is + // Decopilot and the org has no keys — otherwise it'd fail with "no model". const showProviderEmptyState = - allKeys.length === 0 && !hasLocalCliHarness(link); + allKeys.length === 0 && agentModeNeedsCloudProvider(agentMode); if (showProviderEmptyState) { return ( diff --git a/apps/mesh/src/web/components/chat/tier-trigger.tsx b/apps/mesh/src/web/components/chat/tier-trigger.tsx index cb3fdaed80..82c5960a5e 100644 --- a/apps/mesh/src/web/components/chat/tier-trigger.tsx +++ b/apps/mesh/src/web/components/chat/tier-trigger.tsx @@ -20,11 +20,13 @@ import { } from "@untitledui/icons"; import type { ChatTier } from "@/tools/organization/schema"; import { + cliHarnessForMode, resolveTierSubtitle, useAgentMode, useChatTier, useSetChatTier, } from "./use-agent-mode"; +import { useSimpleMode } from "@/web/hooks/use-organization-settings"; const TIER_ORDER: ChatTier[] = ["fast", "smart", "thinking"]; const TIER_LABELS: Record = { @@ -160,9 +162,15 @@ export function TierTrigger() { const tier = useChatTier(); const setTier = useSetChatTier(); const mode = useAgentMode(); + const simpleMode = useSimpleMode(); - const subtitleFor = (t: ChatTier): string | null => - resolveTierSubtitle(mode, t); + const harness = cliHarnessForMode(mode); + const subtitleFor = (t: ChatTier): string | null => { + const overrideTitle = harness + ? (simpleMode.cli?.[harness]?.[t]?.title ?? null) + : null; + return resolveTierSubtitle(mode, t, overrideTitle); + }; return ( = { export function resolveTierSubtitle( mode: AgentMode, tier: ChatTier, + /** Org `simple_mode.cli` override title for this harness/tier, when set. */ + overrideTitle?: string | null, ): string | null { if (mode === "local-claude-code") { - return resolveAgentTier("claude-code", tier)?.label ?? null; + return ( + overrideTitle ?? resolveAgentTier("claude-code", tier)?.label ?? null + ); } if (mode === "local-codex") { - return resolveAgentTier("codex", tier)?.label ?? null; + return overrideTitle ?? resolveAgentTier("codex", tier)?.label ?? null; } return DECOPILOT_TIER_DESCRIPTIONS[tier]; } + +/** Maps a chat mode to the CLI harness whose tier overrides apply, or null. */ +export function cliHarnessForMode( + mode: AgentMode, +): "claude-code" | "codex" | null { + if (mode === "local-claude-code") return "claude-code"; + if (mode === "local-codex") return "codex"; + return null; +} diff --git a/apps/mesh/src/web/components/sidebar/footer/inbox.tsx b/apps/mesh/src/web/components/sidebar/footer/inbox.tsx index 8c59858144..33f440d3d2 100644 --- a/apps/mesh/src/web/components/sidebar/footer/inbox.tsx +++ b/apps/mesh/src/web/components/sidebar/footer/inbox.tsx @@ -23,7 +23,6 @@ import { useProjectContext } from "@decocms/mesh-sdk"; import { useNavigate } from "@tanstack/react-router"; import { AddConnectionDialog } from "@/web/views/virtual-mcp/add-connection-dialog"; import { ToolbarIconButton } from "@/web/components/toolbar-icon-button"; -import { LinkedDesktopIndicator } from "@/web/components/header/linked-desktop-indicator"; import { track } from "@/web/lib/posthog-client"; import { InvitationItem } from "@/web/components/sidebar/footer/invitation-item"; import { JoinRequestItem } from "@/web/components/sidebar/footer/join-request-item"; @@ -291,11 +290,6 @@ export function SidebarInboxFooter() { - -
- -
-
@@ -325,7 +319,6 @@ export function SidebarInboxFooter() { - diff --git a/apps/mesh/src/web/components/sidebar/harness-picker.tsx b/apps/mesh/src/web/components/sidebar/harness-picker.tsx new file mode 100644 index 0000000000..4fad2d1f63 --- /dev/null +++ b/apps/mesh/src/web/components/sidebar/harness-picker.tsx @@ -0,0 +1,219 @@ +import { useState } from "react"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { Check } from "@untitledui/icons"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@deco/ui/components/popover.tsx"; +import { getWellKnownDecopilotVirtualMCP } from "@decocms/mesh-sdk"; +import { AgentAvatar } from "@/web/components/agent-icon"; +import { ClaudeCodeIcon, CodexIcon } from "@/web/components/chat/agent-icons"; +import { useAgentOptionAvailability } from "@/web/components/chat/use-agent-availability"; +import { + type AgentMode, + useAgentMode, + useSetAgentMode, +} from "@/web/components/chat/use-agent-mode"; + +const DECOPILOT_ICON = getWellKnownDecopilotVirtualMCP("").icon; + +function activeHarnessKey( + mode: AgentMode, +): "decopilot" | "claude-code" | "codex" { + if (mode === "local-claude-code") return "claude-code"; + if (mode === "local-codex") return "codex"; + return "decopilot"; +} + +interface CircleDef { + key: "decopilot" | "claude-code" | "codex"; + node: React.ReactNode; +} + +const ALL_CIRCLES: CircleDef[] = [ + { + key: "decopilot", + node: ( + + ), + }, + { + key: "claude-code", + node: ( + + + + ), + }, + { + key: "codex", + node: ( + + + + ), + }, +]; + +// Horizontal distance between adjacent stacked circles (px). Circle is 16px +// (size-4); a 10px step leaves a 6px overlap, matching the old `-ml-1.5`. +const STACK_STEP_PX = 10; + +function StackedIcons({ mode }: { mode: AgentMode }) { + const activeKey = activeHarnessKey(mode); + // Slot order: inactive circles first (in their stable order), the active one + // last so it sits rightmost and frontmost. DOM order stays fixed + // (ALL_CIRCLES) — only each circle's translateX/z-index changes — so a CSS + // transition animates the rearrange without a layout library. + const slotOrder = [ + ...ALL_CIRCLES.filter((c) => c.key !== activeKey).map((c) => c.key), + activeKey, + ]; + + return ( + + {ALL_CIRCLES.map((circle) => { + const slot = slotOrder.indexOf(circle.key); + return ( + + {circle.node} + + ); + })} + + ); +} + +function modeLabel(mode: AgentMode): string { + return mode === "cloud-decopilot" ? "Cloud" : "Local"; +} + +interface RowDef { + mode: AgentMode; + label: string; + description: string; + icon: React.ReactNode; + isAvailable: (a: ReturnType) => boolean; +} + +const ROWS: RowDef[] = [ + { + mode: "cloud-decopilot", + label: "Decopilot", + description: "Runs in an agent sandbox", + icon: ( + + ), + isAvailable: (a) => a.agentSandbox, + }, + { + mode: "local-decopilot", + label: "Decopilot", + description: "Runs on your desktop", + icon: ( + + ), + isAvailable: (a) => a.userDesktop, + }, + { + mode: "local-claude-code", + label: "Claude Code", + description: "Runs via Claude Code CLI", + icon: , + isAvailable: (a) => a.userDesktop && a.claudeCode, + }, + { + mode: "local-codex", + label: "Codex", + description: "Runs via Codex CLI", + icon: , + isAvailable: (a) => a.userDesktop && a.codex, + }, +]; + +export function HarnessPicker({ className }: { className?: string }) { + const [open, setOpen] = useState(false); + const mode = useAgentMode(); + const setAgentMode = useSetAgentMode(); + const availability = useAgentOptionAvailability(); + + return ( + + + + + +
+ {ROWS.map((row) => { + const available = row.isAvailable(availability); + return ( + + ); + })} +
+
+
+ ); +} diff --git a/apps/mesh/src/web/components/sidebar/sidebar-logo-header.tsx b/apps/mesh/src/web/components/sidebar/sidebar-logo-header.tsx index 31091fa4ca..b147501171 100644 --- a/apps/mesh/src/web/components/sidebar/sidebar-logo-header.tsx +++ b/apps/mesh/src/web/components/sidebar/sidebar-logo-header.tsx @@ -3,6 +3,7 @@ import { SidebarHeader } from "@deco/ui/components/sidebar.tsx"; import { cn } from "@deco/ui/lib/utils.ts"; import { LayoutLeft } from "@untitledui/icons"; import { ToolbarIconButton } from "@/web/components/toolbar-icon-button"; +import { HarnessPicker } from "./harness-picker"; interface SidebarLogoHeaderProps { /** Collapse / close the sidebar. */ @@ -62,6 +63,7 @@ export function SidebarLogoHeader({ + ); } diff --git a/apps/mesh/src/web/hooks/use-organization-settings.ts b/apps/mesh/src/web/hooks/use-organization-settings.ts index 14537ca3ac..e9e39e4d11 100644 --- a/apps/mesh/src/web/hooks/use-organization-settings.ts +++ b/apps/mesh/src/web/hooks/use-organization-settings.ts @@ -21,8 +21,23 @@ export interface ModelSlot { title?: string; } +/** Keyless slot for local CLI harnesses (credential lives on the desktop). */ +export interface CliModelSlot { + modelId: string; + title?: string; +} + +export type CliHarnessId = "claude-code" | "codex"; + +export interface CliTierConfig { + fast: CliModelSlot | null; + smart: CliModelSlot | null; + thinking: CliModelSlot | null; +} + export interface SimpleModeConfig { tiers: Record; + cli?: Partial>; } export interface RegistryConfig { @@ -195,6 +210,7 @@ function normalizeSimpleMode(cfg: SimpleModeConfig | null): SimpleModeConfig { web_search: cfg.tiers.web_search ?? null, deep_research: cfg.tiers.deep_research ?? null, }, + ...(cfg.cli ? { cli: cfg.cli } : {}), }; } diff --git a/apps/mesh/src/web/layouts/agent-shell-layout/toolbar.tsx b/apps/mesh/src/web/layouts/agent-shell-layout/toolbar.tsx index ebe52e8601..7855a78f92 100644 --- a/apps/mesh/src/web/layouts/agent-shell-layout/toolbar.tsx +++ b/apps/mesh/src/web/layouts/agent-shell-layout/toolbar.tsx @@ -26,6 +26,7 @@ import { createPortal } from "react-dom"; import { Link, useParams } from "@tanstack/react-router"; import { cn } from "@deco/ui/lib/utils.ts"; import { DEFAULT_LOGO, usePublicConfig } from "@/web/hooks/use-public-config"; +import { HarnessPicker } from "@/web/components/sidebar/harness-picker"; type ToolbarCtx = { togglesEl: HTMLDivElement | null; @@ -227,12 +228,23 @@ function ToolbarRight({ children }: { children: ReactNode }) { return createPortal(children, rightEl); } +function ToolbarHarnessPicker() { + return ( + }> + + + + + ); +} + Toolbar.Provider = ToolbarProviderImpl; Toolbar.Header = ToolbarHeader; Toolbar.LeftColumn = ToolbarLeftColumn; Toolbar.RightColumn = ToolbarRightColumn; Toolbar.Logo = ToolbarLogo; Toolbar.LogoLink = ToolbarLogoLink; +Toolbar.HarnessPicker = ToolbarHarnessPicker; Toolbar.CenterSlot = ToolbarCenterSlot; Toolbar.Center = ToolbarCenter; Toolbar.TabsSlot = ToolbarTabsSlot; diff --git a/apps/mesh/src/web/layouts/home-page/index.tsx b/apps/mesh/src/web/layouts/home-page/index.tsx index e34b2812ba..cc785a2563 100644 --- a/apps/mesh/src/web/layouts/home-page/index.tsx +++ b/apps/mesh/src/web/layouts/home-page/index.tsx @@ -3,7 +3,6 @@ import { SELF_MCP_ALIAS_ID, useMCPClient, useProjectContext, - useVirtualMCP, virtualMcpItemQueryOptions, } from "@decocms/mesh-sdk"; import { useQuery, useSuspenseQueries } from "@tanstack/react-query"; @@ -25,14 +24,13 @@ import { aiProviderKeysQueryOptions, useAiProviderKeys, } from "@/web/hooks/collections/use-ai-providers"; -import { useCurrentLink } from "@/web/hooks/use-current-link"; +import { + agentModeNeedsCloudProvider, + useAgentMode, +} from "@/web/components/chat/use-agent-mode"; import { useDecoCredits } from "@/web/hooks/use-deco-credits"; import { homeNextActionsQueryOptions } from "@/web/hooks/use-home-next-actions"; import { organizationSettingsQueryOptions } from "@/web/hooks/use-organization-settings"; -import { - agentHasClonableSource, - hasLocalCliHarness, -} from "@/web/lib/agent-capabilities"; import { authClient } from "@/web/lib/auth-client"; import { Toolbar } from "@/web/layouts/agent-shell-layout/toolbar"; import { HomeBackground } from "./background"; @@ -41,7 +39,7 @@ export function HomePage() { const { data: session } = authClient.useSession(); const { org } = useProjectContext(); const isMobile = useIsMobile(); - const link = useCurrentLink(); + const agentMode = useAgentMode(); const { selectedVirtualMcp } = useChatPrefs(); const defaultAgent = getWellKnownDecopilotVirtualMCP(org.id); const displayAgent = selectedVirtualMcp ?? defaultAgent; @@ -69,7 +67,6 @@ export function HomePage() { }); const allKeys = useAiProviderKeys(); - const fullVm = useVirtualMCP(displayAgent.id); const { hasDecoKey, isZeroBalance, @@ -79,9 +76,13 @@ export function HomePage() { } = useDecoCredits(); const { hasVisibleTiles } = useHomeGridStats(org.slug); - const isClonableAgent = agentHasClonableSource(fullVm?.metadata); + // Show the no-provider empty state only when the SELECTED runtime actually + // needs a cloud provider key and none is configured. Decopilot (cloud/local) + // calls models through the org's keys, so with zero keys it'd fail with a + // "no model for tier" error — surface the provider picker instead. Claude + // Code / Codex bring their own inference, so they start chatting immediately. const showProviderEmptyState = - allKeys.length === 0 && !(isClonableAgent && hasLocalCliHarness(link)); + allKeys.length === 0 && agentModeNeedsCloudProvider(agentMode); if (showProviderEmptyState) { return ( diff --git a/apps/mesh/src/web/layouts/org-shell-layout/index.tsx b/apps/mesh/src/web/layouts/org-shell-layout/index.tsx index c6b2fbfb51..5883043f96 100644 --- a/apps/mesh/src/web/layouts/org-shell-layout/index.tsx +++ b/apps/mesh/src/web/layouts/org-shell-layout/index.tsx @@ -29,7 +29,6 @@ import { useSidebarResize } from "@/web/hooks/use-sidebar-resize"; import { StudioSidebar, StudioSidebarMobile } from "@/web/components/sidebar"; import { ChatPrefsProvider } from "@/web/components/chat/context"; import { ThreadManagerProvider } from "@/web/components/chat/store/hooks"; -import { LinkedDesktopIndicator } from "@/web/components/header/linked-desktop-indicator"; import { Toolbar } from "@/web/layouts/agent-shell-layout/toolbar"; import { MobileSidebarSheet, @@ -63,7 +62,7 @@ export default function OrgShellLayout() {
- +
@@ -76,6 +75,7 @@ export default function OrgShellLayout() { + diff --git a/apps/mesh/src/web/lib/agent-capabilities.test.ts b/apps/mesh/src/web/lib/agent-capabilities.test.ts index 2f908e0b51..bba21e730f 100644 --- a/apps/mesh/src/web/lib/agent-capabilities.test.ts +++ b/apps/mesh/src/web/lib/agent-capabilities.test.ts @@ -4,7 +4,7 @@ import { agentHasClonableSource, agentHasConnectedGithub, agentShowsGithubHeaderActions, - hasLocalCliHarness, + defaultAgentOption, } from "./agent-capabilities"; describe("agentHasClonableSource", () => { @@ -165,7 +165,7 @@ describe("agentShowsGithubHeaderActions", () => { }); }); -describe("hasLocalCliHarness", () => { +describe("defaultAgentOption", () => { const link = (overrides: Partial = {}): CurrentLink => ({ online: false, capabilities: [], @@ -173,35 +173,52 @@ describe("hasLocalCliHarness", () => { ...overrides, }); - it("returns false when the link is offline", () => { - expect(hasLocalCliHarness(link({ online: false }))).toBe(false); + it("returns null when cloud provider keys exist (SaaS default → cloud)", () => { expect( - hasLocalCliHarness( + defaultAgentOption( + true, + link({ online: true, capabilities: ["claude-code"] }), + ), + ).toBeNull(); + }); + + it("returns null with no keys when the link is offline", () => { + expect( + defaultAgentOption( + false, link({ online: false, capabilities: ["claude-code"] }), ), - ).toBe(false); + ).toBeNull(); }); - it("returns false when online but no CLI harness is reported", () => { - expect(hasLocalCliHarness(link({ online: true }))).toBe(false); + it("returns null with no keys when online but no CLI harness is reported", () => { + expect(defaultAgentOption(false, link({ online: true }))).toBeNull(); expect( - hasLocalCliHarness( + defaultAgentOption( + false, link({ online: true, capabilities: ["decopilot-sandbox"] }), ), - ).toBe(false); + ).toBeNull(); }); - it("returns true when online with claude-code or codex", () => { + it("falls back to a local CLI (Claude Code first) with no keys", () => { expect( - hasLocalCliHarness(link({ online: true, capabilities: ["claude-code"] })), - ).toBe(true); + defaultAgentOption( + false, + link({ online: true, capabilities: ["claude-code"] }), + ), + ).toBe("claude-code-desktop"); expect( - hasLocalCliHarness(link({ online: true, capabilities: ["codex"] })), - ).toBe(true); + defaultAgentOption( + false, + link({ online: true, capabilities: ["codex"] }), + ), + ).toBe("codex-desktop"); expect( - hasLocalCliHarness( - link({ online: true, capabilities: ["claude-code", "codex"] }), + defaultAgentOption( + false, + link({ online: true, capabilities: ["codex", "claude-code"] }), ), - ).toBe(true); + ).toBe("claude-code-desktop"); }); }); diff --git a/apps/mesh/src/web/lib/agent-capabilities.ts b/apps/mesh/src/web/lib/agent-capabilities.ts index 034e24fae8..6d6cf67ee8 100644 --- a/apps/mesh/src/web/lib/agent-capabilities.ts +++ b/apps/mesh/src/web/lib/agent-capabilities.ts @@ -1,5 +1,6 @@ import type { VirtualMCPEntity } from "@decocms/mesh-sdk/types"; import type { CurrentLink } from "@/web/hooks/use-current-link"; +import type { AgentOption } from "@/web/components/chat/pills/agent-options"; import { resolveGithubAttachment } from "./github-repo"; /** @@ -113,15 +114,24 @@ export function findDevPartner( } /** - * True when the user's link daemon is online AND exposes at least one - * CLI harness (Claude Code or Codex) that a clonable agent's chat can - * route through. Lets the chat skip the no-provider empty state when - * the user has a local CLI to fall back on. + * Runtime the chat defaults to when the user hasn't explicitly picked one + * (`pendingAgentOption === null`). + * + * SaaS: a cloud provider key is present → `null`, which the picker renders as + * cloud Decopilot and the server resolves with the org's default model. + * + * Zero provider keys: a cloud Decopilot default can't run (no model to call), + * so fall back to a linked local CLI harness — Claude Code first, then Codex — + * which brings its own inference. Returns `null` when neither is available (the + * no-provider empty state covers that case). */ -export function hasLocalCliHarness(link: CurrentLink): boolean { - if (!link.online) return false; - return ( - link.capabilities.includes("claude-code") || - link.capabilities.includes("codex") - ); +export function defaultAgentOption( + hasCloudProviderKeys: boolean, + link: CurrentLink, +): AgentOption | null { + if (hasCloudProviderKeys) return null; + if (!link.online) return null; + if (link.capabilities.includes("claude-code")) return "claude-code-desktop"; + if (link.capabilities.includes("codex")) return "codex-desktop"; + return null; } diff --git a/apps/mesh/src/web/views/settings/ai-providers/index.tsx b/apps/mesh/src/web/views/settings/ai-providers/index.tsx index 935debecbc..3484d4787f 100644 --- a/apps/mesh/src/web/views/settings/ai-providers/index.tsx +++ b/apps/mesh/src/web/views/settings/ai-providers/index.tsx @@ -9,6 +9,7 @@ import { useAiProviders, } from "@/web/hooks/collections/use-ai-providers"; import { SimpleModeSection } from "./simple-mode-section"; +import { LocalModelsSection } from "./local-models-section"; import { DecoCreditsHero } from "./deco-credits-hero"; import { DecoNudgeCard } from "./deco-nudge-card"; import { ConnectedProvidersSection } from "./connected-providers-section"; @@ -45,6 +46,9 @@ function OrgAiProvidersContent() { onSelect={setPendingProvider} onShowAll={() => setConnectOpen(true)} /> + {/* Local CLI runtimes work without a cloud provider, so their tier + config is available even on the empty provider state. */} + { @@ -64,6 +68,7 @@ function OrgAiProvidersContent() { }> + {hasDeco ? : } setConnectOpen(true)} /> diff --git a/apps/mesh/src/web/views/settings/ai-providers/local-models-section.tsx b/apps/mesh/src/web/views/settings/ai-providers/local-models-section.tsx new file mode 100644 index 0000000000..1c6261f9c7 --- /dev/null +++ b/apps/mesh/src/web/views/settings/ai-providers/local-models-section.tsx @@ -0,0 +1,169 @@ +import { toast } from "sonner"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@deco/ui/components/select.tsx"; +import { + SettingsCard, + SettingsCardItem, + SettingsSection, +} from "@/web/components/settings/settings-section"; +import type { HarnessId } from "@/harnesses"; +import { getAgentModelSet } from "@/web/components/chat/select-model/agent-models"; +import { useAgentOptionAvailability } from "@/web/components/chat/use-agent-availability"; +import { + useSimpleMode, + useUpdateSimpleMode, + type CliHarnessId, + type CliTierConfig, + type SimpleModeConfig, +} from "@/web/hooks/use-organization-settings"; +import { ClaudeCodeIcon, CodexIcon } from "@/web/components/chat/agent-icons"; + +const TIER_ROWS = [ + { key: "fast" as const, label: "Fast", description: "Quicker responses" }, + { key: "smart" as const, label: "Smart", description: "Balanced quality" }, + { + key: "thinking" as const, + label: "Thinking", + description: "Deeper reasoning", + }, +] as const; + +const EMPTY_TIERS: CliTierConfig = { fast: null, smart: null, thinking: null }; + +interface HarnessMeta { + id: CliHarnessId; + label: string; + icon: React.ReactNode; + available: boolean; +} + +function CliTierRow({ + harnessId, + tier, + defaultModelId, +}: { + harnessId: CliHarnessId; + tier: "fast" | "smart" | "thinking"; + defaultModelId: string; +}) { + const simpleMode = useSimpleMode(); + const { mutate: updateSimpleMode } = useUpdateSimpleMode(); + const set = getAgentModelSet(harnessId as HarnessId); + const models = set?.models ?? []; + + // Effective selection: the org override if set, otherwise the built-in + // default for this harness/tier. + const selected = + simpleMode.cli?.[harnessId]?.[tier]?.modelId ?? defaultModelId; + + const handleChange = (modelId: string) => { + const model = models.find((m) => m.modelId === modelId); + if (!model) return; + const harnessTiers = simpleMode.cli?.[harnessId] ?? EMPTY_TIERS; + const next: SimpleModeConfig = { + ...simpleMode, + cli: { + ...simpleMode.cli, + [harnessId]: { + ...harnessTiers, + [tier]: { modelId: model.modelId, title: model.title }, + }, + }, + }; + updateSimpleMode(next, { + onError: (err) => toast.error(`Failed to save: ${err.message}`), + }); + }; + + return ( + + ); +} + +function HarnessCard({ harness }: { harness: HarnessMeta }) { + const set = getAgentModelSet(harness.id as HarnessId); + if (!set) return null; + + return ( + + {harness.icon} + {harness.label} + + } + headerClassName="pl-0" + > + + {TIER_ROWS.map((row) => ( + + } + /> + ))} + + + ); +} + +/** + * Per-harness fast/smart/thinking model overrides for local CLI runtimes + * (Claude Code / Codex). Mirrors the cloud "Default models" section but for + * desktop harnesses — unset tiers fall back to the built-in default. Only + * renders harnesses the linked desktop currently exposes. + */ +export function LocalModelsSection() { + const availability = useAgentOptionAvailability(); + const harnesses: HarnessMeta[] = [ + { + id: "claude-code", + label: "Claude Code", + icon: , + available: availability.claudeCode, + }, + { + id: "codex", + label: "Codex", + icon: , + available: availability.codex, + }, + ]; + const visible = harnesses.filter((h) => h.available); + if (visible.length === 0) return null; + + return ( +
+

+ Pick which model each tier runs on your desktop runtimes. Unset tiers + use the built-in default. +

+ {visible.map((h) => ( + + ))} +
+ ); +}