From ca6dfb9463b3d08439c3ca9e1af18edae5be3632 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 21 May 2026 21:07:33 -0300 Subject: [PATCH 01/12] feat(chat): tab the model selector and version Claude rows --- .../chat/agent-model-popover.test.tsx | 200 ++++++++++++++++-- .../components/chat/agent-model-popover.tsx | 129 ++++++++--- .../chat/agent-model-trigger.test.tsx | 4 +- .../chat/select-model/agent-models.test.ts | 6 +- .../chat/select-model/agent-models.tsx | 6 +- .../chat/select-model/agent-section.test.tsx | 43 +++- .../chat/select-model/agent-section.tsx | 23 +- 7 files changed, 343 insertions(+), 68 deletions(-) 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..fd0d740d50 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 @@ -1,7 +1,7 @@ import { setupComponentTest } from "../../../test/setup"; setupComponentTest(); import { describe, expect, test, mock } from "bun:test"; -import { render } from "@testing-library/react"; +import { act, render } from "@testing-library/react"; import "@testing-library/jest-dom"; import { AgentModelPopover } from "./agent-model-popover"; import { getAgentSections } from "./select-model/agent-models"; @@ -11,9 +11,19 @@ const ALL = getAgentSections({ link: { online: true, capabilities: ["claude-code", "codex"] }, }); +const DECOPILOT_ONLY = getAgentSections({ + hasAnyKey: true, + link: { online: false, capabilities: [] }, +}); + +const CLAUDE_ONLY = getAgentSections({ + hasAnyKey: false, + link: { online: true, capabilities: ["claude-code"] }, +}); + describe("AgentModelPopover", () => { - test("renders one AgentSection per item", () => { - const { getAllByTestId } = render( + test("renders one tab per eligible section", () => { + const { getAllByRole } = render( { onSelect={() => {}} />, ); - expect(getAllByTestId("agent-section")).toHaveLength(3); + const tabs = getAllByRole("tab"); + expect(tabs).toHaveLength(3); + expect(tabs[0]?.textContent).toContain("Decopilot"); + expect(tabs[1]?.textContent).toContain("Claude Code"); + expect(tabs[2]?.textContent).toContain("Codex"); }); - test("when lockedAgent is set, only the matching section is enabled", () => { - const { getAllByTestId } = render( + test("default active tab follows activeAgent", () => { + const { getAllByRole, getByText } = render( {}} />, ); - const sections = getAllByTestId("agent-section"); - const disabled = sections.filter( - (s) => s.getAttribute("aria-disabled") === "true", + const tabs = getAllByRole("tab"); + const claudeTab = tabs.find((t) => t.textContent?.includes("Claude Code")); + expect(claudeTab?.getAttribute("aria-selected")).toBe("true"); + // Claude Code body is rendered + expect(getByText("Haiku 4.5")).toBeInTheDocument(); + }); + + test("default active tab falls back to first section when activeAgent is not in sections", () => { + const { getAllByRole } = render( + {}} + />, + ); + const tabs = getAllByRole("tab"); + expect(tabs[0]?.getAttribute("aria-selected")).toBe("true"); + expect(tabs[0]?.textContent).toContain("Decopilot"); + }); + + test("clicking a tab switches the body content", () => { + const { getAllByRole, queryByText, getByText } = render( + {}} + />, ); - expect(disabled).toHaveLength(2); + // Body initially shows Decopilot tiers + expect(queryByText("Haiku 4.5")).toBeNull(); + expect(getByText("Fast")).toBeInTheDocument(); + + const tabs = getAllByRole("tab"); + const claudeTab = tabs.find((t) => t.textContent?.includes("Claude Code"))!; + act(() => { + claudeTab.click(); + }); + + // Body now shows Claude Code tiers + expect(getByText("Haiku 4.5")).toBeInTheDocument(); + expect(queryByText("Fast")).toBeNull(); }); - test("row click in a section calls onSelect with (kind, tier)", () => { + test("clicking a tier row calls onSelect with (kind, tier)", () => { const onSelect = mock( ( _k: "decopilot" | "claude-code" | "codex", @@ -52,18 +106,100 @@ describe("AgentModelPopover", () => { const { getByText } = render( , ); - getByText("Haiku").click(); + // Claude Code tab is active by default, so Haiku 4.5 is visible + getByText("Haiku 4.5").click(); expect(onSelect).toHaveBeenCalledWith("claude-code", "fast"); }); - test("locked non-active section does NOT call onSelect when its rows are clicked", () => { - const onSelect = mock(() => {}); + test("clicking a tier row after switching tabs uses the new agent", () => { + const onSelect = mock( + ( + _k: "decopilot" | "claude-code" | "codex", + _t: "fast" | "smart" | "thinking", + ) => {}, + ); + const { getAllByRole, getByText } = render( + , + ); + const tabs = getAllByRole("tab"); + act(() => { + tabs.find((t) => t.textContent?.includes("Codex"))!.click(); + }); + getByText("Quicker responses").click(); + expect(onSelect).toHaveBeenCalledWith("codex", "fast"); + }); + + test("when sections.length === 1, no tab bar is rendered", () => { + const { queryByRole, queryAllByRole } = render( + {}} + />, + ); + expect(queryByRole("tablist")).toBeNull(); + expect(queryAllByRole("tab")).toHaveLength(0); + }); + + test("single section still selects tiers via onSelect", () => { + const onSelect = mock( + ( + _k: "decopilot" | "claude-code" | "codex", + _t: "fast" | "smart" | "thinking", + ) => {}, + ); + const { getByText } = render( + , + ); + getByText("Opus 4.7").click(); + expect(onSelect).toHaveBeenCalledWith("claude-code", "thinking"); + }); + + test("when lockedAgent is set, only that section renders and no tab bar shows", () => { + const { queryByRole, queryByText, getByText, getAllByTestId } = render( + {}} + />, + ); + expect(queryByRole("tablist")).toBeNull(); + expect(getAllByTestId("agent-section")).toHaveLength(1); + // Claude tier labels visible + expect(getByText("Haiku 4.5")).toBeInTheDocument(); + // Decopilot tier labels NOT visible + expect(queryByText("Fast")).toBeNull(); + }); + + test("lockedAgent still fires onSelect for the locked section's rows", () => { + const onSelect = mock( + ( + _k: "decopilot" | "claude-code" | "codex", + _t: "fast" | "smart" | "thinking", + ) => {}, + ); const { getByText } = render( { onSelect={onSelect} />, ); - // Fast row inside the locked Decopilot section - getByText("Fast").click(); - expect(onSelect).not.toHaveBeenCalled(); + getByText("Haiku 4.5").click(); + expect(onSelect).toHaveBeenCalledWith("claude-code", "fast"); + }); + + test("laptop sections render a green dot in their tab label", () => { + const { getAllByRole } = render( + {}} + />, + ); + const tabs = getAllByRole("tab"); + const decopilotTab = tabs.find((t) => + t.textContent?.includes("Decopilot"), + )!; + const claudeTab = tabs.find((t) => t.textContent?.includes("Claude Code"))!; + const codexTab = tabs.find((t) => t.textContent?.includes("Codex"))!; + + // Decopilot (cloud) has no green dot + expect(decopilotTab.querySelector("span.bg-success")).toBeNull(); + // Claude Code (local) has a green dot + expect(claudeTab.querySelector("span.bg-success")).not.toBeNull(); + // Codex (local) has a green dot + expect(codexTab.querySelector("span.bg-success")).not.toBeNull(); }); }); 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..94a51d97d5 100644 --- a/apps/mesh/src/web/components/chat/agent-model-popover.tsx +++ b/apps/mesh/src/web/components/chat/agent-model-popover.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; +import { cn } from "@deco/ui/lib/utils.ts"; import type { ChatTier } from "@/tools/organization/schema"; import { AgentSection } from "./select-model/agent-section"; import type { @@ -9,12 +11,22 @@ 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. */ + /** When non-null, only this section is rendered (no tab bar). */ lockedAgent: AgentKind | null; onSelect: (agent: AgentKind, tier: ChatTier) => void; } +function resolveDefaultAgent( + sections: AgentSectionData[], + activeAgent: AgentKind | null, +): AgentKind | null { + return ( + sections.find((s) => s.kind === activeAgent)?.kind ?? + sections[0]?.kind ?? + null + ); +} + export function AgentModelPopover({ sections, activeAgent, @@ -22,32 +34,99 @@ export function AgentModelPopover({ 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 defaultAgent = resolveDefaultAgent(sections, activeAgent); + const [selectedTab, setSelectedTab] = useState( + defaultAgent, + ); + + // When lockedAgent is set, force-render only that section (no tabs). + if (lockedAgent !== null) { + const lockedSection = + sections.find((s) => s.kind === lockedAgent) ?? sections[0]; + if (!lockedSection) { + return
; + } + const selectedTier = lockedSection.kind === activeAgent ? activeTier : null; + return ( +
+ onSelect(lockedSection.kind, tier)} + /> +
+ ); + } + + if (sections.length === 0) { + return
; + } + + // Single-section: no tab bar, just the body. + if (sections.length === 1) { + const only = sections[0]!; + const selectedTier = only.kind === activeAgent ? activeTier : null; + return ( +
+ onSelect(only.kind, tier)} + /> +
+ ); + } + + // Make sure the controlled tab is still in `sections`. If sections changed + // out from under us (e.g. link went offline), fall back to the default. + const activeTab = + sections.find((s) => s.kind === selectedTab)?.kind ?? defaultAgent; + const activeSection = + sections.find((s) => s.kind === activeTab) ?? sections[0]!; + const selectedTier = activeSection.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)} - /> - ); - })} +
+ {sections.map((section) => { + const isActive = section.kind === activeSection.kind; + return ( + + ); + })} +
+ onSelect(activeSection.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..38a5d0bc68 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 @@ -57,7 +57,7 @@ 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( { onSelect={() => {}} />, ); - expect(getByText("Opus")).toBeInTheDocument(); + expect(getByText("Opus 4.7")).toBeInTheDocument(); }); test("label reflects the active Decopilot tier label (Smart)", () => { diff --git a/apps/mesh/src/web/components/chat/select-model/agent-models.test.ts b/apps/mesh/src/web/components/chat/select-model/agent-models.test.ts index e039b89eea..ece1051260 100644 --- a/apps/mesh/src/web/components/chat/select-model/agent-models.test.ts +++ b/apps/mesh/src/web/components/chat/select-model/agent-models.test.ts @@ -73,9 +73,9 @@ describe("getAgentSections", () => { 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..12bbff45c4 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,7 +88,7 @@ describe("AgentSection", () => { onSelect={onSelect} />, ); - getByText("Haiku").click(); + getByText("Haiku 4.5").click(); expect(onSelect).toHaveBeenCalledTimes(1); expect(onSelect).toHaveBeenCalledWith("fast"); }); @@ -89,4 +104,26 @@ describe("AgentSection", () => { ); expect(getByText("On")).toBeInTheDocument(); }); + + test("each row renders icon, label, and description inline", () => { + const { container, getByText } = render( + {}} + />, + ); + // 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(); + // Icon (img with claude logo) is rendered in each row + const imgs = container.querySelectorAll("button img"); + expect(imgs.length).toBe(3); + }); }); 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..65a3cad7fa 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 (
{entry.iconNode ? ( - + {entry.iconNode} ) : entry.iconUrl ? ( ) : null} -
- - {entry.label} - - - {entry.description} - -
+ + {entry.label} + + + {entry.description} + {isSelected && ( - + On )} From 4b11dc83193304a59840d2981b733b8cb1a39cad Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 21 May 2026 21:15:09 -0300 Subject: [PATCH 02/12] fix(chat): tighten model selector popover review feedback Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat/agent-model-popover.test.tsx | 79 +++++++++++-------- .../components/chat/agent-model-popover.tsx | 20 ++--- .../chat/select-model/agent-section.test.tsx | 5 +- 3 files changed, 59 insertions(+), 45 deletions(-) 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 fd0d740d50..a1cdaca09b 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 @@ -32,11 +32,12 @@ describe("AgentModelPopover", () => { onSelect={() => {}} />, ); - const tabs = getAllByRole("tab"); - expect(tabs).toHaveLength(3); - expect(tabs[0]?.textContent).toContain("Decopilot"); - expect(tabs[1]?.textContent).toContain("Claude Code"); - expect(tabs[2]?.textContent).toContain("Codex"); + const tabs = getAllByRole("button"); + expect(tabs).toHaveLength(3 + 3); // 3 tabs + 3 tier rows in active section + const tabLabels = tabs.slice(0, 3).map((t) => t.textContent ?? ""); + expect(tabLabels[0]).toContain("Decopilot"); + expect(tabLabels[1]).toContain("Claude Code"); + expect(tabLabels[2]).toContain("Codex"); }); test("default active tab follows activeAgent", () => { @@ -49,9 +50,11 @@ describe("AgentModelPopover", () => { onSelect={() => {}} />, ); - const tabs = getAllByRole("tab"); - const claudeTab = tabs.find((t) => t.textContent?.includes("Claude Code")); - expect(claudeTab?.getAttribute("aria-selected")).toBe("true"); + const buttons = getAllByRole("button"); + const claudeTab = buttons.find((t) => + t.textContent?.includes("Claude Code"), + ); + expect(claudeTab?.getAttribute("aria-pressed")).toBe("true"); // Claude Code body is rendered expect(getByText("Haiku 4.5")).toBeInTheDocument(); }); @@ -66,9 +69,11 @@ describe("AgentModelPopover", () => { onSelect={() => {}} />, ); - const tabs = getAllByRole("tab"); - expect(tabs[0]?.getAttribute("aria-selected")).toBe("true"); - expect(tabs[0]?.textContent).toContain("Decopilot"); + const buttons = getAllByRole("button"); + const decopilotTab = buttons.find((t) => + t.textContent?.includes("Decopilot"), + ); + expect(decopilotTab?.getAttribute("aria-pressed")).toBe("true"); }); test("clicking a tab switches the body content", () => { @@ -85,8 +90,10 @@ describe("AgentModelPopover", () => { expect(queryByText("Haiku 4.5")).toBeNull(); expect(getByText("Fast")).toBeInTheDocument(); - const tabs = getAllByRole("tab"); - const claudeTab = tabs.find((t) => t.textContent?.includes("Claude Code"))!; + const buttons = getAllByRole("button"); + const claudeTab = buttons.find((t) => + t.textContent?.includes("Claude Code"), + )!; act(() => { claudeTab.click(); }); @@ -133,16 +140,16 @@ describe("AgentModelPopover", () => { onSelect={onSelect} />, ); - const tabs = getAllByRole("tab"); + const buttons = getAllByRole("button"); act(() => { - tabs.find((t) => t.textContent?.includes("Codex"))!.click(); + buttons.find((t) => t.textContent?.includes("Codex"))!.click(); }); getByText("Quicker responses").click(); expect(onSelect).toHaveBeenCalledWith("codex", "fast"); }); test("when sections.length === 1, no tab bar is rendered", () => { - const { queryByRole, queryAllByRole } = render( + const { getAllByRole } = render( { onSelect={() => {}} />, ); - expect(queryByRole("tablist")).toBeNull(); - expect(queryAllByRole("tab")).toHaveLength(0); + // Only tier rows should be rendered, no tab buttons (one section -> no tabs). + const buttons = getAllByRole("button"); + // 3 tier rows only + expect(buttons).toHaveLength(3); + // None of the buttons should be aria-pressed (those would be tabs). + expect(buttons.every((b) => !b.hasAttribute("aria-pressed"))).toBe(true); }); test("single section still selects tiers via onSelect", () => { @@ -176,7 +187,7 @@ describe("AgentModelPopover", () => { }); test("when lockedAgent is set, only that section renders and no tab bar shows", () => { - const { queryByRole, queryByText, getByText, getAllByTestId } = render( + const { queryByText, getByText, getAllByTestId } = render( { onSelect={() => {}} />, ); - expect(queryByRole("tablist")).toBeNull(); expect(getAllByTestId("agent-section")).toHaveLength(1); // Claude tier labels visible expect(getByText("Haiku 4.5")).toBeInTheDocument(); @@ -213,7 +223,7 @@ describe("AgentModelPopover", () => { expect(onSelect).toHaveBeenCalledWith("claude-code", "fast"); }); - test("laptop sections render a green dot in their tab label", () => { + test("laptop sections render a local indicator in their tab label", () => { const { getAllByRole } = render( { onSelect={() => {}} />, ); - const tabs = getAllByRole("tab"); - const decopilotTab = tabs.find((t) => + const buttons = getAllByRole("button"); + const decopilotTab = buttons.find((t) => t.textContent?.includes("Decopilot"), )!; - const claudeTab = tabs.find((t) => t.textContent?.includes("Claude Code"))!; - const codexTab = tabs.find((t) => t.textContent?.includes("Codex"))!; - - // Decopilot (cloud) has no green dot - expect(decopilotTab.querySelector("span.bg-success")).toBeNull(); - // Claude Code (local) has a green dot - expect(claudeTab.querySelector("span.bg-success")).not.toBeNull(); - // Codex (local) has a green dot - expect(codexTab.querySelector("span.bg-success")).not.toBeNull(); + const claudeTab = buttons.find((t) => + t.textContent?.includes("Claude Code"), + )!; + const codexTab = buttons.find((t) => t.textContent?.includes("Codex"))!; + + const within = (el: HTMLElement) => + el.querySelector("[data-testid=local-indicator]"); + + // Decopilot (cloud) has no indicator + expect(within(decopilotTab)).toBeNull(); + // Claude Code (local) has an indicator + expect(within(claudeTab)).not.toBeNull(); + // Codex (local) has an indicator + expect(within(codexTab)).not.toBeNull(); }); }); 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 94a51d97d5..7295d91689 100644 --- a/apps/mesh/src/web/components/chat/agent-model-popover.tsx +++ b/apps/mesh/src/web/components/chat/agent-model-popover.tsx @@ -35,14 +35,16 @@ export function AgentModelPopover({ onSelect, }: Props) { const defaultAgent = resolveDefaultAgent(sections, activeAgent); + // Initial tab seeds from activeAgent on first mount only; subsequent user + // tab-switches are sticky for the lifetime of this popover. Each popover + // open creates a fresh mount, so close/reopen re-seeds from activeAgent. const [selectedTab, setSelectedTab] = useState( defaultAgent, ); // When lockedAgent is set, force-render only that section (no tabs). if (lockedAgent !== null) { - const lockedSection = - sections.find((s) => s.kind === lockedAgent) ?? sections[0]; + const lockedSection = sections.find((s) => s.kind === lockedAgent); if (!lockedSection) { return
; } @@ -91,18 +93,14 @@ export function AgentModelPopover({ return (
-
+
{sections.map((section) => { const isActive = section.kind === activeSection.kind; return ( ); })} 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 12bbff45c4..170c522fe9 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 @@ -106,7 +106,7 @@ describe("AgentSection", () => { }); test("each row renders icon, label, and description inline", () => { - const { container, getByText } = render( + const { getByText } = render( { expect(getByText("Balanced quality")).toBeInTheDocument(); expect(getByText("Opus 4.7")).toBeInTheDocument(); expect(getByText("Deeper reasoning")).toBeInTheDocument(); - // Icon (img with claude logo) is rendered in each row - const imgs = container.querySelectorAll("button img"); - expect(imgs.length).toBe(3); }); }); From c6c26db99cc639c3cb807adef66a9c8e58b045f1 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 21 May 2026 21:27:10 -0300 Subject: [PATCH 03/12] fix(ai-providers): narrow connect dialog for pending and error states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog used sm:max-w-3xl (768px) for every state. The grid view needs that width to fit provider cards, but the OAuth/provision spinner and the error state just show a one-line message — they looked oversized. Switch to sm:max-w-md (448px) for those states. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ai-providers/connect-provider-dialog.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 && ( From 29a2a9c8b586bf41462cb3adc30c170f4ac5f7f7 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 21 May 2026 21:33:28 -0300 Subject: [PATCH 04/12] feat(chat): lock model selector to Decopilot for non-clonable agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-clonable agents (no githubRepo metadata) can't route through a desktop CLI, so showing Claude Code / Codex tabs in the popover was misleading. Expose isAgentClonable in chat prefs and pass lockedAgent="decopilot" to the popover when false — that branch already renders a single section's body with no tab bar. Also tighten the row style: right-align the muted description (text-right) and bump py-1.5 to py-2.5 for breathing room. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mesh/src/web/components/chat/agent-model-trigger.tsx | 4 ++-- apps/mesh/src/web/components/chat/chat-context.tsx | 8 ++++++++ .../web/components/chat/select-model/agent-section.tsx | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) 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..bbdce056f4 100644 --- a/apps/mesh/src/web/components/chat/agent-model-trigger.tsx +++ b/apps/mesh/src/web/components/chat/agent-model-trigger.tsx @@ -82,7 +82,7 @@ export function AgentModelTrigger({ }: Props) { const keys = useAiProviderKeys(); const link = useCurrentLink(); - const { setPendingAgentOption } = useChatPrefs(); + const { setPendingAgentOption, isAgentClonable } = useChatPrefs(); const { org } = useProjectContext(); const mcpClient = useMCPClient({ connectionId: SELF_MCP_ALIAS_ID, @@ -117,7 +117,7 @@ export function AgentModelTrigger({ sections={sections} activeAgent={activeAgent} activeTier={tier} - lockedAgent={null /* lock comes from caller via ThreadPills later */} + lockedAgent={isAgentClonable ? null : "decopilot"} onSelect={handleSelect} /> ); 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/select-model/agent-section.tsx b/apps/mesh/src/web/components/chat/select-model/agent-section.tsx index 65a3cad7fa..f50aed6159 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 @@ -59,7 +59,7 @@ export function AgentSection({ disabled={disabled} onClick={() => 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-2.5 text-left text-sm hover:bg-accent", isSelected && "bg-accent", )} > @@ -77,7 +77,7 @@ export function AgentSection({ {entry.label} - + {entry.description} {isSelected && ( From a271b692744f7cf2da68276cb5d48fb914a20cd6 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 21 May 2026 21:35:35 -0300 Subject: [PATCH 05/12] feat(chat): drop the "On" pill from model selector rows The selected row's bg-accent highlight already conveys selection; the trailing "On" text was redundant alongside the new right-aligned description. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat/select-model/agent-section.test.tsx | 12 ------------ .../components/chat/select-model/agent-section.tsx | 5 ----- 2 files changed, 17 deletions(-) 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 170c522fe9..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 @@ -93,18 +93,6 @@ describe("AgentSection", () => { expect(onSelect).toHaveBeenCalledWith("fast"); }); - test("selected row marks itself with the On indicator", () => { - const { getByText } = render( - {}} - />, - ); - expect(getByText("On")).toBeInTheDocument(); - }); - test("each row renders icon, label, and description inline", () => { const { getByText } = render( {entry.description} - {isSelected && ( - - On - - )} ); })} From 3978127b80b26a398f0ccdb925928467e2919e0d Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 21 May 2026 21:36:15 -0300 Subject: [PATCH 06/12] style(chat): bump model selector row height to py-3.5 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mesh/src/web/components/chat/select-model/agent-section.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 38551310bf..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 @@ -59,7 +59,7 @@ export function AgentSection({ disabled={disabled} onClick={() => onSelect(tier)} className={cn( - "flex w-full items-center gap-2.5 rounded-md px-2 py-2.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", )} > From 95d566b2a0471ff29288ddd6fd94566c3c79b8f4 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 21 May 2026 21:36:37 -0300 Subject: [PATCH 07/12] style(chat): narrow model selector popover to w-64 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/web/components/chat/agent-model-popover.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 7295d91689..754c55248e 100644 --- a/apps/mesh/src/web/components/chat/agent-model-popover.tsx +++ b/apps/mesh/src/web/components/chat/agent-model-popover.tsx @@ -46,11 +46,11 @@ export function AgentModelPopover({ if (lockedAgent !== null) { const lockedSection = sections.find((s) => s.kind === lockedAgent); if (!lockedSection) { - return
; + return
; } const selectedTier = lockedSection.kind === activeAgent ? activeTier : null; return ( -
+
; + return
; } // Single-section: no tab bar, just the body. @@ -71,7 +71,7 @@ export function AgentModelPopover({ const only = sections[0]!; const selectedTier = only.kind === activeAgent ? activeTier : null; return ( -
+
+
{sections.map((section) => { const isActive = section.kind === activeSection.kind; From 3a8a407d9d8804a82bc0edc1d7b7165e3d4389d2 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 21 May 2026 21:39:51 -0300 Subject: [PATCH 08/12] style(chat): narrow model selector popover to w-56 The right-aligned description left an awkward gap inside w-64. Tighter container reduces the empty space between label and description. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/web/components/chat/agent-model-popover.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 754c55248e..973b9d242e 100644 --- a/apps/mesh/src/web/components/chat/agent-model-popover.tsx +++ b/apps/mesh/src/web/components/chat/agent-model-popover.tsx @@ -46,11 +46,11 @@ export function AgentModelPopover({ if (lockedAgent !== null) { const lockedSection = sections.find((s) => s.kind === lockedAgent); if (!lockedSection) { - return
; + return
; } const selectedTier = lockedSection.kind === activeAgent ? activeTier : null; return ( -
+
; + return
; } // Single-section: no tab bar, just the body. @@ -71,7 +71,7 @@ export function AgentModelPopover({ const only = sections[0]!; const selectedTier = only.kind === activeAgent ? activeTier : null; return ( -
+
+
{sections.map((section) => { const isActive = section.kind === activeSection.kind; From e9d81b1183ef63567186028217814c7930db2b34 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 21 May 2026 21:43:31 -0300 Subject: [PATCH 09/12] fix(chat): override PopoverContent default w-72 in model selector @deco/ui's PopoverContent bakes w-72 into its base className, so my inner w-56 container left a ~64px empty band of popover background on the right. Adding w-auto to PopoverContent lets the inner width own the sizing. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mesh/src/web/components/chat/agent-model-trigger.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 bbdce056f4..84b1fd221d 100644 --- a/apps/mesh/src/web/components/chat/agent-model-trigger.tsx +++ b/apps/mesh/src/web/components/chat/agent-model-trigger.tsx @@ -192,7 +192,7 @@ export function AgentModelTriggerPure({ - + Date: Thu, 21 May 2026 21:45:48 -0300 Subject: [PATCH 10/12] style(chat): widen model selector popover to w-60 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/web/components/chat/agent-model-popover.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 973b9d242e..5c2c8e8cfd 100644 --- a/apps/mesh/src/web/components/chat/agent-model-popover.tsx +++ b/apps/mesh/src/web/components/chat/agent-model-popover.tsx @@ -46,11 +46,11 @@ export function AgentModelPopover({ if (lockedAgent !== null) { const lockedSection = sections.find((s) => s.kind === lockedAgent); if (!lockedSection) { - return
; + return
; } const selectedTier = lockedSection.kind === activeAgent ? activeTier : null; return ( -
+
; + return
; } // Single-section: no tab bar, just the body. @@ -71,7 +71,7 @@ export function AgentModelPopover({ const only = sections[0]!; const selectedTier = only.kind === activeAgent ? activeTier : null; return ( -
+
+
{sections.map((section) => { const isActive = section.kind === activeSection.kind; From 7826a83afb7d664037cfa7cf179c804a08e80adc Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 21 May 2026 21:55:31 -0300 Subject: [PATCH 11/12] feat(chat): split agent selection into a segmented control next to the model pill The chat input now exposes agent selection (Decopilot / Claude Code / Codex) as an always-visible segmented control to the left of the existing model pill. The popover loses its tab bar entirely and becomes a clean tier picker for the active agent. - New AgentSegmentedControl + AgentSegmentedControlPure components, hidden when the agent is not clonable or fewer than two sections are eligible. - AgentModelPopover simplified: no tabs, no lockedAgent prop, always renders the active section's three tier rows. - AgentModelTrigger drops lockedAgent pass-through. - Tests updated; new tests cover the segmented control surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat/agent-model-popover.test.tsx | 194 +++--------------- .../components/chat/agent-model-popover.tsx | 111 ++-------- .../chat/agent-model-trigger.test.tsx | 5 - .../components/chat/agent-model-trigger.tsx | 10 +- .../chat/agent-segmented-control.test.tsx | 108 ++++++++++ .../chat/agent-segmented-control.tsx | 135 ++++++++++++ apps/mesh/src/web/components/chat/input.tsx | 8 + 7 files changed, 298 insertions(+), 273 deletions(-) create mode 100644 apps/mesh/src/web/components/chat/agent-segmented-control.test.tsx create mode 100644 apps/mesh/src/web/components/chat/agent-segmented-control.tsx 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 a1cdaca09b..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 @@ -1,7 +1,7 @@ import { setupComponentTest } from "../../../test/setup"; setupComponentTest(); import { describe, expect, test, mock } from "bun:test"; -import { act, render } from "@testing-library/react"; +import { render } from "@testing-library/react"; import "@testing-library/jest-dom"; import { AgentModelPopover } from "./agent-model-popover"; import { getAgentSections } from "./select-model/agent-models"; @@ -16,157 +16,64 @@ const DECOPILOT_ONLY = getAgentSections({ link: { online: false, capabilities: [] }, }); -const CLAUDE_ONLY = getAgentSections({ - hasAnyKey: false, - link: { online: true, capabilities: ["claude-code"] }, -}); - describe("AgentModelPopover", () => { - test("renders one tab per eligible section", () => { - const { getAllByRole } = render( + test("renders the active agent's three tier rows and no tab bar", () => { + const { getAllByRole, getAllByTestId } = render( {}} />, ); - const tabs = getAllByRole("button"); - expect(tabs).toHaveLength(3 + 3); // 3 tabs + 3 tier rows in active section - const tabLabels = tabs.slice(0, 3).map((t) => t.textContent ?? ""); - expect(tabLabels[0]).toContain("Decopilot"); - expect(tabLabels[1]).toContain("Claude Code"); - expect(tabLabels[2]).toContain("Codex"); + // 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("default active tab follows activeAgent", () => { - const { getAllByRole, getByText } = render( + test("renders the section matching activeAgent", () => { + const { getByText, queryByText } = render( {}} />, ); - const buttons = getAllByRole("button"); - const claudeTab = buttons.find((t) => - t.textContent?.includes("Claude Code"), - ); - expect(claudeTab?.getAttribute("aria-pressed")).toBe("true"); - // Claude Code body is rendered expect(getByText("Haiku 4.5")).toBeInTheDocument(); + expect(queryByText("Fast")).toBeNull(); }); - test("default active tab falls back to first section when activeAgent is not in sections", () => { - const { getAllByRole } = render( + test("falls back to the first section when activeAgent is null", () => { + const { getByText, queryByText } = render( {}} />, ); - const buttons = getAllByRole("button"); - const decopilotTab = buttons.find((t) => - t.textContent?.includes("Decopilot"), - ); - expect(decopilotTab?.getAttribute("aria-pressed")).toBe("true"); - }); - - test("clicking a tab switches the body content", () => { - const { getAllByRole, queryByText, getByText } = render( - {}} - />, - ); - // Body initially shows Decopilot tiers - expect(queryByText("Haiku 4.5")).toBeNull(); + // Decopilot is first in ALL, so its tier labels are shown. expect(getByText("Fast")).toBeInTheDocument(); - - const buttons = getAllByRole("button"); - const claudeTab = buttons.find((t) => - t.textContent?.includes("Claude Code"), - )!; - act(() => { - claudeTab.click(); - }); - - // Body now shows Claude Code tiers - expect(getByText("Haiku 4.5")).toBeInTheDocument(); - expect(queryByText("Fast")).toBeNull(); + expect(queryByText("Haiku 4.5")).toBeNull(); }); - test("clicking a tier row calls onSelect with (kind, tier)", () => { - const onSelect = mock( - ( - _k: "decopilot" | "claude-code" | "codex", - _t: "fast" | "smart" | "thinking", - ) => {}, - ); + test("falls back to the first section when activeAgent is not in sections", () => { const { getByText } = render( - , - ); - // Claude Code tab is active by default, so Haiku 4.5 is visible - getByText("Haiku 4.5").click(); - expect(onSelect).toHaveBeenCalledWith("claude-code", "fast"); - }); - - test("clicking a tier row after switching tabs uses the new agent", () => { - const onSelect = mock( - ( - _k: "decopilot" | "claude-code" | "codex", - _t: "fast" | "smart" | "thinking", - ) => {}, - ); - const { getAllByRole, getByText } = render( - , - ); - const buttons = getAllByRole("button"); - act(() => { - buttons.find((t) => t.textContent?.includes("Codex"))!.click(); - }); - getByText("Quicker responses").click(); - expect(onSelect).toHaveBeenCalledWith("codex", "fast"); - }); - - test("when sections.length === 1, no tab bar is rendered", () => { - const { getAllByRole } = render( {}} />, ); - // Only tier rows should be rendered, no tab buttons (one section -> no tabs). - const buttons = getAllByRole("button"); - // 3 tier rows only - expect(buttons).toHaveLength(3); - // None of the buttons should be aria-pressed (those would be tabs). - expect(buttons.every((b) => !b.hasAttribute("aria-pressed"))).toBe(true); + expect(getByText("Fast")).toBeInTheDocument(); }); - test("single section still selects tiers via onSelect", () => { + test("clicking a tier row calls onSelect with (activeAgent, tier)", () => { const onSelect = mock( ( _k: "decopilot" | "claude-code" | "codex", @@ -175,10 +82,9 @@ describe("AgentModelPopover", () => { ); const { getByText } = render( , ); @@ -186,24 +92,7 @@ describe("AgentModelPopover", () => { expect(onSelect).toHaveBeenCalledWith("claude-code", "thinking"); }); - test("when lockedAgent is set, only that section renders and no tab bar shows", () => { - const { queryByText, getByText, getAllByTestId } = render( - {}} - />, - ); - expect(getAllByTestId("agent-section")).toHaveLength(1); - // Claude tier labels visible - expect(getByText("Haiku 4.5")).toBeInTheDocument(); - // Decopilot tier labels NOT visible - expect(queryByText("Fast")).toBeNull(); - }); - - test("lockedAgent still fires onSelect for the locked section's rows", () => { + test("clicking a tier row in the fallback section reports that section's kind", () => { const onSelect = mock( ( _k: "decopilot" | "claude-code" | "codex", @@ -213,43 +102,24 @@ describe("AgentModelPopover", () => { const { getByText } = render( , ); - getByText("Haiku 4.5").click(); - expect(onSelect).toHaveBeenCalledWith("claude-code", "fast"); + getByText("Fast").click(); + expect(onSelect).toHaveBeenCalledWith("decopilot", "fast"); }); - test("laptop sections render a local indicator in their tab label", () => { - const { getAllByRole } = render( + test("renders an empty container when sections is empty", () => { + const { queryAllByTestId } = render( {}} />, ); - const buttons = getAllByRole("button"); - const decopilotTab = buttons.find((t) => - t.textContent?.includes("Decopilot"), - )!; - const claudeTab = buttons.find((t) => - t.textContent?.includes("Claude Code"), - )!; - const codexTab = buttons.find((t) => t.textContent?.includes("Codex"))!; - - const within = (el: HTMLElement) => - el.querySelector("[data-testid=local-indicator]"); - - // Decopilot (cloud) has no indicator - expect(within(decopilotTab)).toBeNull(); - // Claude Code (local) has an indicator - expect(within(claudeTab)).not.toBeNull(); - // Codex (local) has an indicator - expect(within(codexTab)).not.toBeNull(); + 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 5c2c8e8cfd..48a44e84f2 100644 --- a/apps/mesh/src/web/components/chat/agent-model-popover.tsx +++ b/apps/mesh/src/web/components/chat/agent-model-popover.tsx @@ -1,5 +1,3 @@ -import { useState } from "react"; -import { cn } from "@deco/ui/lib/utils.ts"; import type { ChatTier } from "@/tools/organization/schema"; import { AgentSection } from "./select-model/agent-section"; import type { @@ -11,123 +9,38 @@ interface Props { sections: AgentSectionData[]; activeAgent: AgentKind | null; activeTier: ChatTier; - /** When non-null, only this section is rendered (no tab bar). */ - lockedAgent: AgentKind | null; onSelect: (agent: AgentKind, tier: ChatTier) => void; } -function resolveDefaultAgent( - sections: AgentSectionData[], - activeAgent: AgentKind | null, -): AgentKind | null { - return ( - sections.find((s) => s.kind === activeAgent)?.kind ?? - sections[0]?.kind ?? - null - ); -} - +/** + * 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 defaultAgent = resolveDefaultAgent(sections, activeAgent); - // Initial tab seeds from activeAgent on first mount only; subsequent user - // tab-switches are sticky for the lifetime of this popover. Each popover - // open creates a fresh mount, so close/reopen re-seeds from activeAgent. - const [selectedTab, setSelectedTab] = useState( - defaultAgent, - ); + const section = + sections.find((s) => s.kind === activeAgent) ?? sections[0] ?? null; - // When lockedAgent is set, force-render only that section (no tabs). - if (lockedAgent !== null) { - const lockedSection = sections.find((s) => s.kind === lockedAgent); - if (!lockedSection) { - return
; - } - const selectedTier = lockedSection.kind === activeAgent ? activeTier : null; - return ( -
- onSelect(lockedSection.kind, tier)} - /> -
- ); - } - - if (sections.length === 0) { + if (!section) { return
; } - // Single-section: no tab bar, just the body. - if (sections.length === 1) { - const only = sections[0]!; - const selectedTier = only.kind === activeAgent ? activeTier : null; - return ( -
- onSelect(only.kind, tier)} - /> -
- ); - } - - // Make sure the controlled tab is still in `sections`. If sections changed - // out from under us (e.g. link went offline), fall back to the default. - const activeTab = - sections.find((s) => s.kind === selectedTab)?.kind ?? defaultAgent; - const activeSection = - sections.find((s) => s.kind === activeTab) ?? sections[0]!; - const selectedTier = activeSection.kind === activeAgent ? activeTier : null; + const selectedTier = section.kind === activeAgent ? activeTier : null; return (
-
- {sections.map((section) => { - const isActive = section.kind === activeSection.kind; - return ( - - ); - })} -
onSelect(activeSection.kind, tier)} + onSelect={(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 38a5d0bc68..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={() => {}} />, ); @@ -63,7 +60,6 @@ describe("AgentModelTriggerPure", () => { sections={ALL} activeAgent="claude-code" activeTier="thinking" - lockedAgent={null} onSelect={() => {}} />, ); @@ -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 84b1fd221d..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 { @@ -82,7 +82,7 @@ export function AgentModelTrigger({ }: Props) { const keys = useAiProviderKeys(); const link = useCurrentLink(); - const { setPendingAgentOption, isAgentClonable } = useChatPrefs(); + const { setPendingAgentOption } = useChatPrefs(); const { org } = useProjectContext(); const mcpClient = useMCPClient({ connectionId: SELF_MCP_ALIAS_ID, @@ -117,7 +117,6 @@ export function AgentModelTrigger({ sections={sections} activeAgent={activeAgent} activeTier={tier} - lockedAgent={isAgentClonable ? null : "decopilot"} 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); @@ -197,7 +194,6 @@ export function AgentModelTriggerPure({ sections={sections} activeAgent={activeAgent} activeTier={activeTier} - lockedAgent={lockedAgent} onSelect={(kind, t) => { onSelect(kind, t); setOpen(false); diff --git a/apps/mesh/src/web/components/chat/agent-segmented-control.test.tsx b/apps/mesh/src/web/components/chat/agent-segmented-control.test.tsx new file mode 100644 index 0000000000..5dcc9dad14 --- /dev/null +++ b/apps/mesh/src/web/components/chat/agent-segmented-control.test.tsx @@ -0,0 +1,108 @@ +import { setupComponentTest } from "../../../test/setup"; +setupComponentTest(); +import { describe, expect, test, mock } from "bun:test"; +import { render } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { AgentSegmentedControlPure } from "./agent-segmented-control"; +import { getAgentSections } from "./select-model/agent-models"; + +const ALL = getAgentSections({ + hasAnyKey: true, + link: { online: true, capabilities: ["claude-code", "codex"] }, +}); + +describe("AgentSegmentedControlPure", () => { + test("renders one button per section", () => { + const { getAllByRole } = render( + {}} + />, + ); + const buttons = getAllByRole("button"); + expect(buttons).toHaveLength(3); + expect(buttons[0]?.textContent).toContain("Decopilot"); + expect(buttons[1]?.textContent).toContain("Claude Code"); + expect(buttons[2]?.textContent).toContain("Codex"); + }); + + test("active segment has aria-pressed=true and others have aria-pressed=false", () => { + const { getAllByRole } = render( + {}} + />, + ); + const buttons = getAllByRole("button"); + const decopilot = buttons.find((b) => + b.textContent?.includes("Decopilot"), + )!; + const claude = buttons.find((b) => b.textContent?.includes("Claude Code"))!; + const codex = buttons.find((b) => b.textContent?.includes("Codex"))!; + expect(decopilot.getAttribute("aria-pressed")).toBe("false"); + expect(claude.getAttribute("aria-pressed")).toBe("true"); + expect(codex.getAttribute("aria-pressed")).toBe("false"); + }); + + test("clicking a segment fires onSelect with that kind", () => { + const onSelect = mock((_k: "decopilot" | "claude-code" | "codex") => {}); + const { getAllByRole } = render( + , + ); + const codex = getAllByRole("button").find((b) => + b.textContent?.includes("Codex"), + )!; + codex.click(); + expect(onSelect).toHaveBeenCalledWith("codex"); + }); + + test("laptop sections render the local indicator and sr-only 'on desktop' text", () => { + const { getAllByRole, getAllByTestId } = render( + {}} + />, + ); + const buttons = getAllByRole("button"); + const decopilot = buttons.find((b) => + b.textContent?.includes("Decopilot"), + )!; + const claude = buttons.find((b) => b.textContent?.includes("Claude Code"))!; + const codex = buttons.find((b) => b.textContent?.includes("Codex"))!; + + // Decopilot (cloud) — no local indicator. + expect(decopilot.querySelector("[data-testid=local-indicator]")).toBeNull(); + // Both CLI sections — local indicator present. + expect( + claude.querySelector("[data-testid=local-indicator]"), + ).not.toBeNull(); + expect(codex.querySelector("[data-testid=local-indicator]")).not.toBeNull(); + // sr-only "on desktop" mirrors the tabs. + expect(claude.textContent).toContain("on desktop"); + expect(codex.textContent).toContain("on desktop"); + + // And there are exactly two local indicators across the whole control. + expect(getAllByTestId("local-indicator")).toHaveLength(2); + }); + + test("no segment is active when activeAgent is null", () => { + const { getAllByRole } = render( + {}} + />, + ); + const buttons = getAllByRole("button"); + expect( + buttons.every((b) => b.getAttribute("aria-pressed") === "false"), + ).toBe(true); + }); +}); diff --git a/apps/mesh/src/web/components/chat/agent-segmented-control.tsx b/apps/mesh/src/web/components/chat/agent-segmented-control.tsx new file mode 100644 index 0000000000..5ba57860cb --- /dev/null +++ b/apps/mesh/src/web/components/chat/agent-segmented-control.tsx @@ -0,0 +1,135 @@ +import { cn } from "@deco/ui/lib/utils.ts"; +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; +} + +/** + * Always-visible segmented control that lets the user pick the active + * agent (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 AgentSegmentedControl({ + 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 segmented-control variant for tests. Renders the buttons + * inline; consumers wire up persistence + analytics in the wrapper. + */ +export function AgentSegmentedControlPure({ + sections, + activeAgent, + onSelect, +}: PureProps) { + return ( +
+ {sections.map((section) => { + const isActive = section.kind === activeAgent; + return ( + + ); + })} +
+ ); +} diff --git a/apps/mesh/src/web/components/chat/input.tsx b/apps/mesh/src/web/components/chat/input.tsx index 74a9b028fe..0ca6a3f66f 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 { AgentSegmentedControl } from "./agent-segmented-control"; import type { AiProviderModel } from "@/web/hooks/collections/use-ai-providers"; import { UnsupportedFileDialog, @@ -594,6 +595,13 @@ export function ChatInput({ {/* Right Actions (mic, model, send) */}
+ Date: Thu, 21 May 2026 22:07:10 -0300 Subject: [PATCH 12/12] feat(chat): replace harness segmented control with a select pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second pill next to the model pill makes the harness picker match the existing model picker UX — closed pill shows the active harness, click opens a popover with one row per eligible harness. Visibility rules are unchanged (hidden when the agent is not clonable or only one section is eligible). The model popover also widens from w-60 to w-72 to give the description more room. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat/agent-harness-trigger.test.tsx | 106 ++++++++++ .../components/chat/agent-harness-trigger.tsx | 184 ++++++++++++++++++ .../components/chat/agent-model-popover.tsx | 4 +- .../chat/agent-segmented-control.test.tsx | 108 ---------- .../chat/agent-segmented-control.tsx | 135 ------------- apps/mesh/src/web/components/chat/input.tsx | 4 +- 6 files changed, 294 insertions(+), 247 deletions(-) create mode 100644 apps/mesh/src/web/components/chat/agent-harness-trigger.test.tsx create mode 100644 apps/mesh/src/web/components/chat/agent-harness-trigger.tsx delete mode 100644 apps/mesh/src/web/components/chat/agent-segmented-control.test.tsx delete mode 100644 apps/mesh/src/web/components/chat/agent-segmented-control.tsx 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.tsx b/apps/mesh/src/web/components/chat/agent-model-popover.tsx index 48a44e84f2..79476e6462 100644 --- a/apps/mesh/src/web/components/chat/agent-model-popover.tsx +++ b/apps/mesh/src/web/components/chat/agent-model-popover.tsx @@ -28,13 +28,13 @@ export function AgentModelPopover({ sections.find((s) => s.kind === activeAgent) ?? sections[0] ?? null; if (!section) { - return
; + return
; } const selectedTier = section.kind === activeAgent ? activeTier : null; return ( -
+
{ - test("renders one button per section", () => { - const { getAllByRole } = render( - {}} - />, - ); - const buttons = getAllByRole("button"); - expect(buttons).toHaveLength(3); - expect(buttons[0]?.textContent).toContain("Decopilot"); - expect(buttons[1]?.textContent).toContain("Claude Code"); - expect(buttons[2]?.textContent).toContain("Codex"); - }); - - test("active segment has aria-pressed=true and others have aria-pressed=false", () => { - const { getAllByRole } = render( - {}} - />, - ); - const buttons = getAllByRole("button"); - const decopilot = buttons.find((b) => - b.textContent?.includes("Decopilot"), - )!; - const claude = buttons.find((b) => b.textContent?.includes("Claude Code"))!; - const codex = buttons.find((b) => b.textContent?.includes("Codex"))!; - expect(decopilot.getAttribute("aria-pressed")).toBe("false"); - expect(claude.getAttribute("aria-pressed")).toBe("true"); - expect(codex.getAttribute("aria-pressed")).toBe("false"); - }); - - test("clicking a segment fires onSelect with that kind", () => { - const onSelect = mock((_k: "decopilot" | "claude-code" | "codex") => {}); - const { getAllByRole } = render( - , - ); - const codex = getAllByRole("button").find((b) => - b.textContent?.includes("Codex"), - )!; - codex.click(); - expect(onSelect).toHaveBeenCalledWith("codex"); - }); - - test("laptop sections render the local indicator and sr-only 'on desktop' text", () => { - const { getAllByRole, getAllByTestId } = render( - {}} - />, - ); - const buttons = getAllByRole("button"); - const decopilot = buttons.find((b) => - b.textContent?.includes("Decopilot"), - )!; - const claude = buttons.find((b) => b.textContent?.includes("Claude Code"))!; - const codex = buttons.find((b) => b.textContent?.includes("Codex"))!; - - // Decopilot (cloud) — no local indicator. - expect(decopilot.querySelector("[data-testid=local-indicator]")).toBeNull(); - // Both CLI sections — local indicator present. - expect( - claude.querySelector("[data-testid=local-indicator]"), - ).not.toBeNull(); - expect(codex.querySelector("[data-testid=local-indicator]")).not.toBeNull(); - // sr-only "on desktop" mirrors the tabs. - expect(claude.textContent).toContain("on desktop"); - expect(codex.textContent).toContain("on desktop"); - - // And there are exactly two local indicators across the whole control. - expect(getAllByTestId("local-indicator")).toHaveLength(2); - }); - - test("no segment is active when activeAgent is null", () => { - const { getAllByRole } = render( - {}} - />, - ); - const buttons = getAllByRole("button"); - expect( - buttons.every((b) => b.getAttribute("aria-pressed") === "false"), - ).toBe(true); - }); -}); diff --git a/apps/mesh/src/web/components/chat/agent-segmented-control.tsx b/apps/mesh/src/web/components/chat/agent-segmented-control.tsx deleted file mode 100644 index 5ba57860cb..0000000000 --- a/apps/mesh/src/web/components/chat/agent-segmented-control.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { cn } from "@deco/ui/lib/utils.ts"; -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; -} - -/** - * Always-visible segmented control that lets the user pick the active - * agent (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 AgentSegmentedControl({ - 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 segmented-control variant for tests. Renders the buttons - * inline; consumers wire up persistence + analytics in the wrapper. - */ -export function AgentSegmentedControlPure({ - sections, - activeAgent, - onSelect, -}: PureProps) { - return ( -
- {sections.map((section) => { - const isActive = section.kind === activeAgent; - return ( - - ); - })} -
- ); -} diff --git a/apps/mesh/src/web/components/chat/input.tsx b/apps/mesh/src/web/components/chat/input.tsx index 0ca6a3f66f..65ebbfe4ed 100644 --- a/apps/mesh/src/web/components/chat/input.tsx +++ b/apps/mesh/src/web/components/chat/input.tsx @@ -33,7 +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 { AgentSegmentedControl } from "./agent-segmented-control"; +import { AgentHarnessTrigger } from "./agent-harness-trigger"; import type { AiProviderModel } from "@/web/hooks/collections/use-ai-providers"; import { UnsupportedFileDialog, @@ -595,7 +595,7 @@ export function ChatInput({ {/* Right Actions (mic, model, send) */}
-