diff --git a/apps/mesh/src/web/components/chat/agent-harness-trigger.test.tsx b/apps/mesh/src/web/components/chat/agent-harness-trigger.test.tsx new file mode 100644 index 0000000000..88d9a19828 --- /dev/null +++ b/apps/mesh/src/web/components/chat/agent-harness-trigger.test.tsx @@ -0,0 +1,106 @@ +import { setupComponentTest } from "../../../test/setup"; +setupComponentTest(); +import { describe, expect, test, mock } from "bun:test"; +import { render, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { AgentHarnessTriggerPure } from "./agent-harness-trigger"; +import { getAgentSections } from "./select-model/agent-models"; + +const ALL = getAgentSections({ + hasAnyKey: true, + link: { online: true, capabilities: ["claude-code", "codex"] }, +}); + +describe("AgentHarnessTriggerPure", () => { + test("closed pill shows the active section's title", () => { + const { getByRole } = render( + {}} + />, + ); + const pill = getByRole("button"); + expect(pill.textContent).toContain("Claude Code"); + }); + + test("local active agent renders the green dot on the pill", () => { + const { getByRole } = render( + {}} + />, + ); + const pill = getByRole("button"); + expect( + pill.querySelector("[data-testid=harness-local-indicator]"), + ).not.toBeNull(); + }); + + test("cloud active agent does not render the green dot on the pill", () => { + const { getByRole } = render( + {}} + />, + ); + const pill = getByRole("button"); + expect( + pill.querySelector("[data-testid=harness-local-indicator]"), + ).toBeNull(); + }); + + test("opens popover and renders one row per section", () => { + const { getByRole, getAllByRole } = render( + {}} + />, + ); + const pill = getByRole("button"); + fireEvent.click(pill); + const rows = getAllByRole("button").filter((b) => b !== pill); + expect(rows).toHaveLength(3); + expect(rows[0]?.textContent).toContain("Decopilot"); + expect(rows[1]?.textContent).toContain("Claude Code"); + expect(rows[2]?.textContent).toContain("Codex"); + }); + + test("clicking a row fires onSelect with that kind", () => { + const onSelect = mock((_k: "decopilot" | "claude-code" | "codex") => {}); + const { getByRole, getAllByRole } = render( + , + ); + const pill = getByRole("button"); + fireEvent.click(pill); + const codex = getAllByRole("button").find( + (b) => b !== pill && b.textContent?.includes("Codex"), + )!; + fireEvent.click(codex); + expect(onSelect).toHaveBeenCalledWith("codex"); + }); + + test("active row has aria-pressed=true and others have aria-pressed=false", () => { + const { getByRole, getAllByRole } = render( + {}} + />, + ); + const pill = getByRole("button"); + fireEvent.click(pill); + const rows = getAllByRole("button").filter((b) => b !== pill); + const claude = rows.find((b) => b.textContent?.includes("Claude Code"))!; + const decopilot = rows.find((b) => b.textContent?.includes("Decopilot"))!; + expect(claude.getAttribute("aria-pressed")).toBe("true"); + expect(decopilot.getAttribute("aria-pressed")).toBe("false"); + }); +}); diff --git a/apps/mesh/src/web/components/chat/agent-harness-trigger.tsx b/apps/mesh/src/web/components/chat/agent-harness-trigger.tsx new file mode 100644 index 0000000000..95a8c8a7a1 --- /dev/null +++ b/apps/mesh/src/web/components/chat/agent-harness-trigger.tsx @@ -0,0 +1,184 @@ +import { Button } from "@deco/ui/components/button.tsx"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@deco/ui/components/popover.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { useState } from "react"; +import type { HarnessId } from "@/harnesses"; +import type { SandboxProviderKind } from "@decocms/sandbox/provider"; +import type { ChatTier } from "@/tools/organization/schema"; +import { + SELF_MCP_ALIAS_ID, + useMCPClient, + useProjectContext, +} from "@decocms/mesh-sdk"; +import { track } from "@/web/lib/posthog-client"; +import { useAiProviderKeys } from "@/web/hooks/collections/use-ai-providers"; +import { useCurrentLink } from "@/web/hooks/use-current-link"; +import { useVmStart } from "@/web/components/vm/hooks/use-vm-start"; +import { useChatPrefs } from "./context"; +import { + type AgentKind, + type AgentSection as AgentSectionData, + getAgentSections, +} from "./select-model/agent-models"; +import { agentKindFromHarness, optionForAgent } from "./agent-model-trigger"; + +interface Props { + agent: HarnessId | null; + sandboxKind: SandboxProviderKind | null; + tier: ChatTier; + /** Set when the user is on a branch — needed for the eager VM-start + * when the user picks a CLI agent. `null` when no branch is selected + * (no eager start). */ + currentBranch: string | null; + virtualMcpId: string; +} + +/** + * Pill + popover that lets the user pick the active harness + * (Decopilot / Claude Code / Codex). Sits to the left of + * `AgentModelTrigger` in the chat input. Hidden entirely when the + * current agent is not clonable, or when fewer than two sections are + * eligible (no choice to make). + */ +export function AgentHarnessTrigger({ + agent, + sandboxKind, + tier, + currentBranch, + virtualMcpId, +}: Props) { + const keys = useAiProviderKeys(); + const link = useCurrentLink(); + const { setPendingAgentOption, isAgentClonable } = useChatPrefs(); + const { org } = useProjectContext(); + const mcpClient = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + orgSlug: org.slug, + }); + const startVm = useVmStart(mcpClient); + + const sections = getAgentSections({ + hasAnyKey: keys.length > 0, + link, + }); + + if (!isAgentClonable || sections.length <= 1) { + return null; + } + + const activeAgent = agentKindFromHarness(agent, sandboxKind); + + const handleSelect = (kind: AgentKind) => { + setPendingAgentOption(optionForAgent(kind)); + if (kind !== "decopilot" && currentBranch) { + startVm.mutate({ + virtualMcpId, + branch: currentBranch, + sandboxProviderKind: "remote-user" as const, + }); + } + track("agent_model_selected", { agent: kind, tier }); + }; + + return ( + + ); +} + +interface PureProps { + sections: AgentSectionData[]; + activeAgent: AgentKind | null; + onSelect: (kind: AgentKind) => void; +} + +/** + * Stateless variant for tests. Renders the closed pill + popover — + * does not touch hooks or chat prefs. + */ +export function AgentHarnessTriggerPure({ + sections, + activeAgent, + onSelect, +}: PureProps) { + const [open, setOpen] = useState(false); + + const section = + sections.find((s) => s.kind === activeAgent) ?? sections[0] ?? null; + + if (!section) return null; + + const isLocalActive = section.isLocal; + + const baseClasses = + "gap-0 @[496px]/chat-bottom:gap-1.5 text-muted-foreground hover:text-foreground"; + const localActiveClasses = isLocalActive + ? "text-success bg-success/10 hover:text-success" + : ""; + + return ( + + + + + +
+ {sections.map((s) => { + const isActive = s.kind === activeAgent; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/apps/mesh/src/web/components/chat/agent-model-popover.test.tsx b/apps/mesh/src/web/components/chat/agent-model-popover.test.tsx index c58fd2ce07..f2b9d5d8d8 100644 --- a/apps/mesh/src/web/components/chat/agent-model-popover.test.tsx +++ b/apps/mesh/src/web/components/chat/agent-model-popover.test.tsx @@ -11,38 +11,69 @@ const ALL = getAgentSections({ link: { online: true, capabilities: ["claude-code", "codex"] }, }); +const DECOPILOT_ONLY = getAgentSections({ + hasAnyKey: true, + link: { online: false, capabilities: [] }, +}); + describe("AgentModelPopover", () => { - test("renders one AgentSection per item", () => { - const { getAllByTestId } = render( + test("renders the active agent's three tier rows and no tab bar", () => { + const { getAllByRole, getAllByTestId } = render( {}} />, ); - expect(getAllByTestId("agent-section")).toHaveLength(3); + // Only one section is rendered. + expect(getAllByTestId("agent-section")).toHaveLength(1); + // Exactly 3 buttons (tier rows), no tab buttons. + const buttons = getAllByRole("button"); + expect(buttons).toHaveLength(3); + expect(buttons.every((b) => !b.hasAttribute("aria-pressed"))).toBe(true); }); - test("when lockedAgent is set, only the matching section is enabled", () => { - const { getAllByTestId } = render( + test("renders the section matching activeAgent", () => { + const { getByText, queryByText } = render( {}} />, ); - const sections = getAllByTestId("agent-section"); - const disabled = sections.filter( - (s) => s.getAttribute("aria-disabled") === "true", + expect(getByText("Haiku 4.5")).toBeInTheDocument(); + expect(queryByText("Fast")).toBeNull(); + }); + + test("falls back to the first section when activeAgent is null", () => { + const { getByText, queryByText } = render( + {}} + />, ); - expect(disabled).toHaveLength(2); + // Decopilot is first in ALL, so its tier labels are shown. + expect(getByText("Fast")).toBeInTheDocument(); + expect(queryByText("Haiku 4.5")).toBeNull(); }); - test("row click in a section calls onSelect with (kind, tier)", () => { + test("falls back to the first section when activeAgent is not in sections", () => { + const { getByText } = render( + {}} + />, + ); + expect(getByText("Fast")).toBeInTheDocument(); + }); + + test("clicking a tier row calls onSelect with (activeAgent, tier)", () => { const onSelect = mock( ( _k: "decopilot" | "claude-code" | "codex", @@ -52,29 +83,43 @@ describe("AgentModelPopover", () => { const { getByText } = render( , ); - getByText("Haiku").click(); - expect(onSelect).toHaveBeenCalledWith("claude-code", "fast"); + getByText("Opus 4.7").click(); + expect(onSelect).toHaveBeenCalledWith("claude-code", "thinking"); }); - test("locked non-active section does NOT call onSelect when its rows are clicked", () => { - const onSelect = mock(() => {}); + test("clicking a tier row in the fallback section reports that section's kind", () => { + const onSelect = mock( + ( + _k: "decopilot" | "claude-code" | "codex", + _t: "fast" | "smart" | "thinking", + ) => {}, + ); const { getByText } = render( , ); - // Fast row inside the locked Decopilot section getByText("Fast").click(); - expect(onSelect).not.toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalledWith("decopilot", "fast"); + }); + + test("renders an empty container when sections is empty", () => { + const { queryAllByTestId } = render( + {}} + />, + ); + expect(queryAllByTestId("agent-section")).toHaveLength(0); }); }); diff --git a/apps/mesh/src/web/components/chat/agent-model-popover.tsx b/apps/mesh/src/web/components/chat/agent-model-popover.tsx index b2b34ae51f..79476e6462 100644 --- a/apps/mesh/src/web/components/chat/agent-model-popover.tsx +++ b/apps/mesh/src/web/components/chat/agent-model-popover.tsx @@ -9,45 +9,39 @@ interface Props { sections: AgentSectionData[]; activeAgent: AgentKind | null; activeTier: ChatTier; - /** When non-null, only the section matching this kind is interactive; - * the others render opacity-40 + pointer-events-none. */ - lockedAgent: AgentKind | null; onSelect: (agent: AgentKind, tier: ChatTier) => void; } +/** + * Tier picker popover. Agent selection now lives outside the popover + * (see `AgentSegmentedControl`), so this component is purely a 3-row + * tier list for the currently active agent. Falls back to the first + * section when `activeAgent` is missing from `sections`. + */ export function AgentModelPopover({ sections, activeAgent, activeTier, - lockedAgent, onSelect, }: Props) { - const single = sections.length === 1; - // Always resolve a default selection so one tier is always highlighted — - // when activeAgent doesn't match a rendered section, fall back to the - // first section so the popover never opens with nothing "On". - const resolvedActiveAgent = - sections.find((s) => s.kind === activeAgent)?.kind ?? - sections[0]?.kind ?? - null; + const section = + sections.find((s) => s.kind === activeAgent) ?? sections[0] ?? null; + + if (!section) { + return
; + } + + const selectedTier = section.kind === activeAgent ? activeTier : null; return (
- {sections.map((section) => { - const disabled = lockedAgent !== null && lockedAgent !== section.kind; - const selectedTier = - resolvedActiveAgent === section.kind ? activeTier : null; - return ( - onSelect(section.kind, tier)} - /> - ); - })} + onSelect(section.kind, tier)} + />
); } diff --git a/apps/mesh/src/web/components/chat/agent-model-trigger.test.tsx b/apps/mesh/src/web/components/chat/agent-model-trigger.test.tsx index 81be728281..a5e43d5843 100644 --- a/apps/mesh/src/web/components/chat/agent-model-trigger.test.tsx +++ b/apps/mesh/src/web/components/chat/agent-model-trigger.test.tsx @@ -18,7 +18,6 @@ describe("AgentModelTriggerPure", () => { sections={ALL} activeAgent="decopilot" activeTier="smart" - lockedAgent={null} onSelect={() => {}} />, ); @@ -33,7 +32,6 @@ describe("AgentModelTriggerPure", () => { sections={ALL} activeAgent="claude-code" activeTier="thinking" - lockedAgent={null} onSelect={() => {}} />, ); @@ -48,7 +46,6 @@ describe("AgentModelTriggerPure", () => { sections={ALL} activeAgent="decopilot" activeTier="smart" - lockedAgent={null} onSelect={() => {}} />, ); @@ -57,17 +54,16 @@ describe("AgentModelTriggerPure", () => { expect(button?.className).toMatch(/@\[496px\]\/chat-bottom:gap-1\.5/); }); - test("label reflects the active CLI tier model label (Opus)", () => { + test("label reflects the active CLI tier model label (Opus 4.7)", () => { const { getByText } = render( {}} />, ); - expect(getByText("Opus")).toBeInTheDocument(); + expect(getByText("Opus 4.7")).toBeInTheDocument(); }); test("label reflects the active Decopilot tier label (Smart)", () => { @@ -76,7 +72,6 @@ describe("AgentModelTriggerPure", () => { sections={ALL} activeAgent="decopilot" activeTier="smart" - lockedAgent={null} onSelect={() => {}} />, ); diff --git a/apps/mesh/src/web/components/chat/agent-model-trigger.tsx b/apps/mesh/src/web/components/chat/agent-model-trigger.tsx index 68a1fb8e7c..1f03616096 100644 --- a/apps/mesh/src/web/components/chat/agent-model-trigger.tsx +++ b/apps/mesh/src/web/components/chat/agent-model-trigger.tsx @@ -42,7 +42,7 @@ interface Props { } /** Maps the popover's AgentKind back to the persisted AgentOption. */ -function optionForAgent(kind: AgentKind) { +export function optionForAgent(kind: AgentKind) { switch (kind) { case "decopilot": return "decopilot" as const; @@ -53,7 +53,7 @@ function optionForAgent(kind: AgentKind) { } } -function agentKindFromHarness( +export function agentKindFromHarness( agent: HarnessId | null, sandboxKind: SandboxProviderKind | null, ): AgentKind | null { @@ -117,7 +117,6 @@ export function AgentModelTrigger({ sections={sections} activeAgent={activeAgent} activeTier={tier} - lockedAgent={null /* lock comes from caller via ThreadPills later */} onSelect={handleSelect} /> ); @@ -127,7 +126,6 @@ interface PureProps { sections: AgentSection[]; activeAgent: AgentKind | null; activeTier: ChatTier; - lockedAgent: AgentKind | null; onSelect: (kind: AgentKind, tier: ChatTier) => void; } @@ -140,7 +138,6 @@ export function AgentModelTriggerPure({ sections, activeAgent, activeTier, - lockedAgent, onSelect, }: PureProps) { const [open, setOpen] = useState(false); @@ -192,12 +189,11 @@ export function AgentModelTriggerPure({ - + { onSelect(kind, t); setOpen(false); diff --git a/apps/mesh/src/web/components/chat/chat-context.tsx b/apps/mesh/src/web/components/chat/chat-context.tsx index 2b4660565b..5ba331a46f 100644 --- a/apps/mesh/src/web/components/chat/chat-context.tsx +++ b/apps/mesh/src/web/components/chat/chat-context.tsx @@ -200,6 +200,13 @@ export interface ChatPrefsContextValue { pendingHarnessId: HarnessId | null; /** Derived from `pendingAgentOption`. Read-only. */ pendingSandboxProviderKind: SandboxProviderKind | null; + /** + * True when the active agent has a clonable git source — only then can + * the chat route through a desktop CLI (Claude Code / Codex). Drives the + * model selector trigger: non-clonable agents lock the popover to + * Decopilot and hide the tab bar. + */ + isAgentClonable: boolean; } // ============================================================================ @@ -572,6 +579,7 @@ export function ChatPrefsProvider({ children }: PropsWithChildren) { pendingAgentOption: effectiveAgentOption, setPendingAgentOption, pendingHarnessId, + isAgentClonable: hasClonableSource, pendingSandboxProviderKind, }; diff --git a/apps/mesh/src/web/components/chat/input.tsx b/apps/mesh/src/web/components/chat/input.tsx index 74a9b028fe..65ebbfe4ed 100644 --- a/apps/mesh/src/web/components/chat/input.tsx +++ b/apps/mesh/src/web/components/chat/input.tsx @@ -33,6 +33,7 @@ import type { VirtualMCPInfo } from "./select-virtual-mcp"; import { ChatHighlight } from "./highlight"; import { getSupportedFileTypesLabel, modelSupportsFiles } from "./select-model"; import { AgentModelTrigger } from "./agent-model-trigger"; +import { AgentHarnessTrigger } from "./agent-harness-trigger"; import type { AiProviderModel } from "@/web/hooks/collections/use-ai-providers"; import { UnsupportedFileDialog, @@ -594,6 +595,13 @@ export function ChatInput({ {/* Right Actions (mic, model, send) */}
+ { expect(claude.tiers.fast.modelId).toBe("claude-code:haiku"); expect(claude.tiers.smart.modelId).toBe("claude-code:sonnet"); expect(claude.tiers.thinking.modelId).toBe("claude-code:opus"); - expect(claude.tiers.fast.label).toBe("Haiku"); - expect(claude.tiers.smart.label).toBe("Sonnet"); - expect(claude.tiers.thinking.label).toBe("Opus"); + expect(claude.tiers.fast.label).toBe("Haiku 4.5"); + expect(claude.tiers.smart.label).toBe("Sonnet 4.6"); + expect(claude.tiers.thinking.label).toBe("Opus 4.7"); }); test("codex section exposes the three Codex model labels", () => { diff --git a/apps/mesh/src/web/components/chat/select-model/agent-models.tsx b/apps/mesh/src/web/components/chat/select-model/agent-models.tsx index d932a390c2..ba70e6d0e1 100644 --- a/apps/mesh/src/web/components/chat/select-model/agent-models.tsx +++ b/apps/mesh/src/web/components/chat/select-model/agent-models.tsx @@ -69,19 +69,19 @@ const DECOPILOT_TIERS: AgentTierMap = { const CLAUDE_CODE_TIERS: AgentTierMap = { fast: { modelId: "claude-code:haiku", - label: "Haiku", + label: "Haiku 4.5", description: "Quicker responses", iconUrl: CLAUDE_CODE_LOGO, }, smart: { modelId: "claude-code:sonnet", - label: "Sonnet", + label: "Sonnet 4.6", description: "Balanced quality", iconUrl: CLAUDE_CODE_LOGO, }, thinking: { modelId: "claude-code:opus", - label: "Opus", + label: "Opus 4.7", description: "Deeper reasoning", iconUrl: CLAUDE_CODE_LOGO, }, diff --git a/apps/mesh/src/web/components/chat/select-model/agent-section.test.tsx b/apps/mesh/src/web/components/chat/select-model/agent-section.test.tsx index 6622adbb9f..1da75f65a8 100644 --- a/apps/mesh/src/web/components/chat/select-model/agent-section.test.tsx +++ b/apps/mesh/src/web/components/chat/select-model/agent-section.test.tsx @@ -15,7 +15,7 @@ const decopilot = SECTIONS.find((s) => s.kind === "decopilot")!; const claude = SECTIONS.find((s) => s.kind === "claude-code")!; describe("AgentSection", () => { - test("cloud section header has no success styling", () => { + test("cloud section header has no success styling (header path)", () => { const { container } = render( { expect(header?.className).not.toMatch(/text-success/); }); - test("local CLI section header uses text-success and · on desktop suffix", () => { + test("local CLI section header uses text-success and · on desktop suffix (header path)", () => { const { container, getByText } = render( { expect(getByText(/Claude Code · on desktop/)).toBeInTheDocument(); }); + test("hideHeader suppresses the header element entirely", () => { + const { container } = render( + {}} + />, + ); + expect( + container.querySelector("[data-testid=agent-section-header]"), + ).toBeNull(); + }); + test("disabled section sets aria-disabled and stops onSelect from firing", () => { const onSelect = mock(() => {}); const { container } = render( @@ -73,20 +88,27 @@ describe("AgentSection", () => { onSelect={onSelect} />, ); - getByText("Haiku").click(); + getByText("Haiku 4.5").click(); expect(onSelect).toHaveBeenCalledTimes(1); expect(onSelect).toHaveBeenCalledWith("fast"); }); - test("selected row marks itself with the On indicator", () => { + test("each row renders icon, label, and description inline", () => { const { getByText } = render( {}} />, ); - expect(getByText("On")).toBeInTheDocument(); + // Label and description both visible + expect(getByText("Haiku 4.5")).toBeInTheDocument(); + expect(getByText("Quicker responses")).toBeInTheDocument(); + expect(getByText("Sonnet 4.6")).toBeInTheDocument(); + expect(getByText("Balanced quality")).toBeInTheDocument(); + expect(getByText("Opus 4.7")).toBeInTheDocument(); + expect(getByText("Deeper reasoning")).toBeInTheDocument(); }); }); diff --git a/apps/mesh/src/web/components/chat/select-model/agent-section.tsx b/apps/mesh/src/web/components/chat/select-model/agent-section.tsx index d889677d21..26240ddf9e 100644 --- a/apps/mesh/src/web/components/chat/select-model/agent-section.tsx +++ b/apps/mesh/src/web/components/chat/select-model/agent-section.tsx @@ -20,7 +20,8 @@ export function AgentSection({ hideHeader, onSelect, }: Props) { - const localBand = section.isLocal && !disabled ? "bg-success/5" : ""; + const localBand = + !hideHeader && section.isLocal && !disabled ? "bg-success/5" : ""; return (
onSelect(tier)} className={cn( - "flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent", + "flex w-full items-center gap-2.5 rounded-md px-2 py-3.5 text-left text-sm hover:bg-accent", isSelected && "bg-accent", )} > {entry.iconNode ? ( - + {entry.iconNode} ) : entry.iconUrl ? ( ) : null} -
- - {entry.label} - - - {entry.description} - -
- {isSelected && ( - - On - - )} + + {entry.label} + + + {entry.description} + ); })} diff --git a/apps/mesh/src/web/views/settings/ai-providers/connect-provider-dialog.tsx b/apps/mesh/src/web/views/settings/ai-providers/connect-provider-dialog.tsx index 046e98f21d..c5d1886a76 100644 --- a/apps/mesh/src/web/views/settings/ai-providers/connect-provider-dialog.tsx +++ b/apps/mesh/src/web/views/settings/ai-providers/connect-provider-dialog.tsx @@ -11,6 +11,7 @@ import { } from "@deco/ui/components/dialog.tsx"; import { Button } from "@deco/ui/components/button.tsx"; import { Spinner } from "@deco/ui/components/spinner.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; import { SELF_MCP_ALIAS_ID, useMCPClient, @@ -309,9 +310,19 @@ export function ConnectProviderDialog({ } }; + const isNarrowState = + state.kind === "oauth-pending" || + state.kind === "provision-pending" || + state.kind === "provision-error"; + return ( !o && close()}> - +
{showBack && (