diff --git a/apps/web/src/components/header/shell-breadcrumb.tsx b/apps/web/src/components/header/shell-breadcrumb.tsx
index 26986002bd..7440d0fa83 100644
--- a/apps/web/src/components/header/shell-breadcrumb.tsx
+++ b/apps/web/src/components/header/shell-breadcrumb.tsx
@@ -1,193 +1,24 @@
/**
- * Org + agent navigation crumbs, used standalone so each can be placed
- * independently:
- *
- * switcher (Slack-style). Opens the org switcher popover. Lives in the desktop
- * sidebar header and the mobile sidebar sheet.
- * - **{@link AgentSwitcherCrumb}** — the active agent (the org's Super Agent by
- * default). The avatar opens the agent's home; the label opens the agent
- * picker. Lives in the desktop topbar (when the sidebar is collapsed) and the
- * mobile top header + sheet.
+ * The new-chat crumb, which is what is left of this file: the agent crumb and
+ * its picker lived here too, until the mobile sheet — their only caller —
+ * stopped carrying an agent selector beside the org one.
*/
-import { Suspense } from "react";
-import { useRouteThreadId, useRouteVirtualMcpId } from "@/layouts/thread-route";
-import { ChevronDown, Edit05 } from "@untitledui/icons";
+import { Edit05 } from "@untitledui/icons";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@decocms/ui/components/tooltip.tsx";
-import {
- getWellKnownDecopilotVirtualMCP,
- useProjectContext,
- useVirtualMCP,
- useVirtualMCPs,
-} from "@/sdk";
-import { getActiveGithubRepo } from "@/lib/github-repo";
-import {
- draftsModeEnabled,
- useBaseBranch,
-} from "@/components/thread/github/use-version-gate";
-import {
- branchUserLabel,
- generateBranchName,
-} from "@decocms/shared/branch-name";
-import type { VirtualMCPEntity } from "@decocms/shared/sdk/types";
-import { AgentAvatar } from "@/components/agent-icon";
-import { AgentScopePicker } from "@/components/sidebar/agents-section";
+import { useRouteThreadId, useRouteVirtualMcpId } from "@/layouts/thread-route";
import { useThreads } from "@/components/chat/store/hooks";
import { usePanelActions } from "@/layouts/shell-layout";
-import { findAgentEntryThread } from "@/lib/reusable-new-chat";
-import { useProjectDefaultRuntime } from "@/sdk/project-default-runtime";
-import { authClient } from "@/lib/auth-client.ts";
import { useT } from "@/i18n/use-t.ts";
-const crumbBtnClass =
- "inline-flex items-center gap-1.5 min-w-0 rounded-md pl-1 pr-2 py-1.5 text-sm text-foreground hover:bg-accent/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
-
-/**
- * Agent crumb — resolves the active agent (thread's agent when inside a thread,
- * else the org's Super Agent). The avatar navigates to the agent's home (its
- * empty "New chat", or a fresh one); the label + chevron open the agent picker.
- * Suspends on the vMCP fetch, so it's wrapped in its own boundary and never
- * blocks the toolbar.
- *
- * `fallback` supplies the icon/title for a synthesized agent (the Super Agent
- * is never persisted, so `useVirtualMCP` returns null for it) — without it the
- * avatar would render a hash-based placeholder instead of the real icon.
- */
-function AgentCrumb({
- agentId,
- fallback,
- onOpenHome,
- onPick,
-}: {
- agentId: string;
- fallback?: VirtualMCPEntity;
- onOpenHome: () => void;
- onPick: (id: string | null) => void;
-}) {
- const t = useT();
- const entity = useVirtualMCP(agentId) ?? fallback ?? null;
- const title =
- entity?.title ?? t("header.shellBreadcrumb.superAgentDefaultName");
- return (
-
-
-
- {title}
-
-
- }
- />
-
- );
-}
-
-/**
- * Agent switcher crumb — just the agent avatar + name + picker, standalone.
- * Used in the topbar (desktop) and the mobile sidebar sheet header; the org
- * icon sits beside it. `onNavigate` fires after a pick so the mobile sheet can
- * close itself once an agent is chosen.
- */
-export function AgentSwitcherCrumb({
- onNavigate,
-}: {
- onNavigate?: () => void;
-} = {}) {
- const { org } = useProjectContext();
- const { threads } = useThreads();
- const allAgents = useVirtualMCPs();
- const { data: session } = authClient.useSession();
- const { setTaskId, createNewTask } = usePanelActions();
- const projectDefaultRuntime = useProjectDefaultRuntime();
- /** Cold-entry base ("main"): no current branch to resolve a PR base from. */
- const baseBranch = useBaseBranch(undefined, null);
-
- const decopilot = getWellKnownDecopilotVirtualMCP(org.id);
- const decopilotId = decopilot.id;
- /** Route-aware: the `{-$project}` segment on a destination, `?virtualmcpid=` on the legacy route. */
- const activeAgentId = useRouteVirtualMcpId();
-
- // Open the picked agent directly. The Super Agent (Decopilot) is opened by
- // its well-known id like any other agent rather than by navigating to
- // `/$org`: the org landing may resolve to a configured "main agent", so
- // routing the Super Agent through `/$org` would redirect away from it and
- // leave it unreachable. `null` (the picker's "Super Agent" row) maps to the
- // Decopilot id.
- const handlePickAgent = (id: string | null) => {
- const targetId = id ?? decopilotId;
- const target = (allAgents ?? []).find((a) => a.id === targetId);
- const isDraftsMode = draftsModeEnabled(target);
- const existing = findAgentEntryThread(
- threads,
- targetId,
- session?.user?.id,
- projectDefaultRuntime(targetId),
- !!(target && getActiveGithubRepo(target)),
- {
- knownBranches: new Set([
- baseBranch,
- ...(target?.metadata?.releases ?? []).map((r) => r.branch),
- ]),
- draftsMode: isDraftsMode,
- baseBranch,
- },
- );
- if (existing) {
- setTaskId(existing.id, targetId);
- } else {
- // Drafts mode mints a fresh thread on an editable draft, never production.
- void createNewTask(
- targetId,
- isDraftsMode
- ? generateBranchName(branchUserLabel(session?.user))
- : undefined,
- );
- }
- onNavigate?.();
- };
-
- return (
- }
- >
- handlePickAgent(activeAgentId)}
- onPick={handlePickAgent}
- />
-
- );
-}
-
/**
* New-chat button — starts a fresh chat with the active agent (reusing an
* existing empty "New chat" for it when there is one, so empties don't pile
- * up). Sibling of {@link AgentSwitcherCrumb}: shown in the same places (the
- * collapsed-sidebar topbar / chat header) so "new chat" stays reachable when
- * the sidebar's own new-chat action is tucked away.
+ * up). Lives in the chat panel header, so "new chat" stays reachable when the
+ * sidebar's own new-chat action is tucked away.
*/
export function NewChatCrumb() {
const t = useT();
diff --git a/apps/web/src/components/org-home/connect-pill.tsx b/apps/web/src/components/org-home/connect-pill.tsx
index 56e9b89b58..24129675d4 100644
--- a/apps/web/src/components/org-home/connect-pill.tsx
+++ b/apps/web/src/components/org-home/connect-pill.tsx
@@ -6,6 +6,8 @@
* Claude, Cursor and Codex; the pill teases all three as logos. The marks are
* decorative — the sentence beside them already names the action, so the row
* is `aria-hidden` and the link's accessible name stays the sentence.
+ * Desktop only: it offers to install a CLI on the machine you are sitting at,
+ * which is not the phone you are holding.
*/
import { Link } from "@tanstack/react-router";
@@ -43,7 +45,7 @@ export function ConnectPill() {
to="/$org/settings/connect"
params={{ org: org.slug }}
onClick={() => track("connect_clients_opened", { source: "org_home" })}
- className="group inline-flex max-w-full flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-full border border-border bg-card py-1.5 pl-4 pr-2.5 text-sm transition-colors hover:bg-accent/60"
+ className="group hidden max-w-full md:inline-flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-full border border-border bg-card py-1.5 pl-4 pr-2.5 text-sm transition-colors hover:bg-accent/60"
>
{t("home.orgHome.connectPill")}
diff --git a/apps/web/src/components/sidebar/agents-section.tsx b/apps/web/src/components/sidebar/agents-section.tsx
deleted file mode 100644
index 699612d374..0000000000
--- a/apps/web/src/components/sidebar/agents-section.tsx
+++ /dev/null
@@ -1,643 +0,0 @@
-import {
- Suspense,
- cloneElement,
- useDeferredValue,
- useState,
- type MouseEvent,
- type ReactElement,
- type ReactNode,
-} from "react";
-import { ToolbarIconButton } from "@/components/toolbar-icon-button";
-import { cn } from "@decocms/ui/lib/utils.ts";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@decocms/ui/components/tooltip.tsx";
-import { Link, useNavigate } from "@tanstack/react-router";
-import { useRouteAgentId, useRouteThreadId } from "@/layouts/thread-route";
-import {
- SidebarMenuButton,
- SidebarMenuItem,
- useSidebar,
-} from "@decocms/ui/components/sidebar.tsx";
-import { Skeleton } from "@decocms/ui/components/skeleton.tsx";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@decocms/ui/components/popover.tsx";
-import {
- Drawer,
- DrawerContent,
- DrawerTitle,
-} from "@decocms/ui/components/drawer.tsx";
-import { useIsMobile } from "@decocms/ui/hooks/use-mobile.ts";
-import { CollectionSearch } from "@decocms/ui/components/collection-search.tsx";
-import { Check, Plus } from "@untitledui/icons";
-import {
- getWellKnownDecopilotVirtualMCP,
- isDecopilot,
- isStudioPackAgent,
- useProjectContext,
- useVirtualMCPs,
-} from "@/sdk";
-import type { VirtualMCPEntity } from "@decocms/shared/sdk/types";
-import { track } from "@/lib/posthog-client";
-import { AgentAvatar } from "@/components/agent-icon";
-import { GitHubIcon } from "@/components/icons/github-icon";
-import { GitHubRepoPicker } from "@/components/github-repo-picker";
-import { useThreadActions } from "@/components/chat/store/hooks";
-import { readCachedTaskBranch } from "@/lib/read-cached-task-branch";
-import {
- agentHasClonableSource,
- getDevAgentIds,
-} from "@/lib/agent-capabilities";
-import { useSidebarAgentGroupsEmpty } from "./sidebar-agent-groups-context";
-import { useT } from "@/i18n/use-t.ts";
-
-function CollectionSearchWrapper({
- value,
- onChange,
-}: {
- value: string;
- onChange: (value: string) => void;
-}) {
- const t = useT();
- return (
-
- );
-}
-
-function NoAgentsFound() {
- const t = useT();
- return (
-
- );
-}
-
-function SectionLabelAgents() {
- const t = useT();
- return {t("sidebar.agentsSection.agents")};
-}
-
-function SectionLabelCodeAgents({
- onImportFromGithub,
-}: {
- onImportFromGithub: () => void;
-}) {
- const t = useT();
- return (
-
-
- {t("sidebar.agentsSection.import")}
-
- }
- >
- {t("sidebar.agentsSection.codeAgents")}
-
- );
-}
-
-function MobileCompactButton({
- setOpen,
- emptyCtaClass,
-}: {
- setOpen: (open: boolean) => void;
- emptyCtaClass: string | undefined;
-}) {
- const t = useT();
- return (
- {
- track("agent_browser_opened", { surface: "mobile_drawer" });
- setOpen(true);
- }}
- >
-
-
- );
-}
-
-function MobileFullButton({
- setOpen,
- highlightEmpty,
- emptyCtaClass,
-}: {
- setOpen: (open: boolean) => void;
- highlightEmpty: boolean;
- emptyCtaClass: string | undefined;
-}) {
- const t = useT();
- return (
-
- {
- track("agent_browser_opened", { surface: "mobile_drawer" });
- setOpen(true);
- }}
- >
-
- {t("sidebar.agentsSection.newAgent")}
-
-
- );
-}
-
-function DrawerTitleWrapper() {
- const t = useT();
- return (
-
- {t("sidebar.agentsSection.browseAgents")}
-
- );
-}
-
-function DesktopCompactButton({
- emptyCtaClass,
-}: {
- emptyCtaClass: string | undefined;
-}) {
- const t = useT();
- return (
-
-
-
- );
-}
-
-function DesktopFullButton({
- wrapEmptyHint,
- highlightEmpty,
- emptyCtaClass,
-}: {
- wrapEmptyHint: (trigger: ReactElement) => ReactElement;
- highlightEmpty: boolean;
- emptyCtaClass: string | undefined;
-}) {
- const t = useT();
- return (
-
- {wrapEmptyHint(
-
-
-
- {t("sidebar.agentsSection.newAgent")}
-
- ,
- )}
-
- );
-}
-
-function BrowseAgentsEmptyHint({ children }: { children: ReactElement }) {
- const t = useT();
- return (
-
- {children}
-
- {t("sidebar.agentsSection.selectAnExistingAgent")}
-
-
- );
-}
-
-/**
- * Hook for "spawn task on this vMCP" buttons (used by the browse-agents
- * popover). When the user clicks the vMCP the route already names, the active
- * thread's branch is carried into the new thread so the new task lands on the
- * same warm sandbox. When the clicked vMCP differs, no branch is passed and the
- * server picks the most-recently-touched sandboxMap entry for that vMCP.
- *
- * Both reads go through the route resolvers rather than the raw URL: the agent
- * is the `{-$project}` segment (`?virtualmcpid=` is legacy-only), and the
- * thread is `?thread=` everywhere except the legacy route's path param.
- */
-function useNavigateToNewTaskWithBranchCarry(orgSlug: string) {
- const navigate = useNavigate();
- const { create } = useThreadActions();
- const { locator } = useProjectContext();
- const routeThreadId = useRouteThreadId();
- const routeAgentId = useRouteAgentId();
-
- return async (clickedVirtualMcpId: string) => {
- const taskId = crypto.randomUUID();
- const carryBranch =
- clickedVirtualMcpId === routeAgentId
- ? readCachedTaskBranch(orgSlug, locator, routeThreadId ?? "")
- : null;
- try {
- await create({
- id: taskId,
- virtual_mcp_id: clickedVirtualMcpId,
- ...(carryBranch ? { branch: carryBranch } : {}),
- });
- } catch {
- // Toast already fired; navigate anyway so the route loader's
- // ensure-fallback can retry.
- }
- navigate({
- to: "/$org/$taskId",
- params: { org: orgSlug, taskId },
- search: { virtualmcpid: clickedVirtualMcpId },
- });
- };
-}
-
-function AgentRow({
- agent,
- selected,
- onClick,
-}: {
- agent: VirtualMCPEntity;
- selected?: boolean;
- onClick: () => void;
-}) {
- return (
-
- );
-}
-
-/** A group label ("Agents" / "Code Agents") with an optional trailing action
- * (the "Import from GitHub" button lives on the Code Agents header). */
-function SectionLabel({
- children,
- action,
-}: {
- children: ReactNode;
- action?: ReactElement;
-}) {
- return (
-
-
- {children}
-
- {action}
-
- );
-}
-
-function PinAgentPopoverContent({
- onClose,
- onSelectAgent,
- selectedAgentId,
- onImportFromGithub,
-}: {
- onClose: () => void;
- /** When provided (breadcrumb scope picker), selecting an agent sets the
- * sidebar scope instead of opening a new task; `null` = all agents. The list
- * then leads with a Decopilot row ("all threads") and marks the active one. */
- onSelectAgent?: (id: string | null) => void;
- /** The currently-scoped agent, for the check mark (picker mode). */
- selectedAgentId?: string | null;
- /** Close the popover and open the GitHub import dialog (owned by the parent
- * so the dialog survives the popover unmounting). */
- onImportFromGithub: () => void;
-}) {
- const [search, setSearch] = useState("");
- /**
- * The query runs on the DEFERRED term while the input keeps the immediate
- * one, so typing never suspends the field out from under the cursor.
- *
- * The term goes to the server rather than filtering the returned array: the
- * list is capped at 100 rows with no cursor, so a client-side filter could
- * only ever search the first hundred and silently miss the rest.
- *
- * Every predicate below reads this term, never `search`.
- */
- const deferredSearch = useDeferredValue(search);
- const allAgents = useVirtualMCPs({ searchTerm: deferredSearch || undefined });
- const agents = allAgents ?? [];
- const { org } = useProjectContext();
-
- const navigateToNewTask = useNavigateToNewTaskWithBranchCarry(org.slug);
-
- // Dev agents are reached via the Develop/Live toggle on their live
- // counterpart, not as standalone browse entries.
- const devAgentIds = getDevAgentIds(allAgents);
- const lowerSearch = deferredSearch.toLowerCase();
- /** The term already went to the server, which matches description too — a
- * title filter here would drop those hits. */
- const userAgents = agents
- .filter((s) => !isDecopilot(s.id))
- .filter((s) => !devAgentIds.has(s.id))
- // Studio Pack default agents live only on the agents page, not this browse
- // list.
- .filter((s) => !isStudioPackAgent(s.id));
-
- // "Code agents" are agents backed by a GitHub repo (imported from GitHub or
- // cloned from a template); plain agents have no clonable source. They render
- // as two labelled groups so the repo-backed ones — and the Import button that
- // creates more — are easy to find.
- const codeAgents = userAgents.filter((s) =>
- agentHasClonableSource(s.metadata),
- );
- const plainAgents = userAgents.filter(
- (s) => !agentHasClonableSource(s.metadata),
- );
-
- // Decopilot — the "all threads / every agent" option in scope-picker mode.
- // The well-known agent isn't in the collection list, so build it directly.
- const decopilotAgent = getWellKnownDecopilotVirtualMCP(org.id);
- const showDecopilot =
- !deferredSearch || decopilotAgent.title.toLowerCase().includes(lowerSearch);
- // Decopilot only renders in scope-picker mode; when it's shown the list is
- // never truly empty, so the "No agents yet" hint would be misleading.
- const decopilotRowShown = Boolean(onSelectAgent && showDecopilot);
-
- const selectAll = () => {
- onSelectAgent?.(null);
- onClose();
- setSearch("");
- };
-
- const handleSelect = (agent: VirtualMCPEntity) => {
- // Scope-picker mode (breadcrumb): set the sidebar filter, don't open a task.
- if (onSelectAgent) {
- onSelectAgent(agent.id);
- onClose();
- setSearch("");
- return;
- }
- onClose();
- setSearch("");
- navigateToNewTask(agent.id);
- };
-
- return (
-
- {/* Scope-picker mode: Decopilot = all threads, every agent. */}
- {onSelectAgent && showDecopilot && decopilotAgent && (
-
- )}
-
- {/* Code Agents — repo-backed. The Import button is always available
- when not filtering, so a repo can be imported even with none yet. */}
- {(codeAgents.length > 0 || !deferredSearch) && (
-
- )}
- {codeAgents.map((agent) => (
- handleSelect(agent)}
- />
- ))}
-
- {/* Agents */}
- {plainAgents.length > 0 && }
- {plainAgents.map((agent) => (
- handleSelect(agent)}
- />
- ))}
-
- {userAgents.length === 0 && !decopilotRowShown && deferredSearch && (
-
- )}
-
-
- {/* Footer */}
-
-
- );
-}
-
-function PinAgentPopover({
- compact = false,
- trigger,
- onSelectAgent,
- selectedAgentId,
- side = "right",
- align = "start",
-}: {
- compact?: boolean;
- /** Custom trigger (e.g. the breadcrumb agent crumb); defaults to the "+" btn. */
- trigger?: ReactElement<{ onClick?: (event: MouseEvent) => void }>;
- /** Scope-picker mode: set the sidebar agent filter instead of opening a task. */
- onSelectAgent?: (id: string | null) => void;
- selectedAgentId?: string | null;
- side?: "top" | "right" | "bottom" | "left";
- align?: "start" | "center" | "end";
-} = {}) {
- const [open, setOpen] = useState(false);
- const [pickerOpen, setPickerOpen] = useState(false);
- const isMobile = useIsMobile();
- const { setOpenMobile } = useSidebar();
- const highlightEmpty = useSidebarAgentGroupsEmpty();
- const emptyCtaClass = highlightEmpty ? "border border-border" : undefined;
-
- const wrapEmptyHint = (trigger: ReactElement) =>
- highlightEmpty ? (
- {trigger}
- ) : (
- trigger
- );
-
- const handleClose = () => {
- setOpen(false);
- if (isMobile) setOpenMobile(false);
- };
-
- // Close the popover first, then open the import dialog — the dialog is
- // rendered as a sibling below (not inside the popover content) so it isn't
- // torn down when the popover unmounts.
- const handleImportFromGithub = () => {
- handleClose();
- setPickerOpen(true);
- };
-
- const popoverContent = open && (
-
-
-
- }
- >
-
-
- );
-
- return (
- <>
- {isMobile ? (
- <>
- {trigger ? (
- // trigger is always a
);
+ /** The drawer already decided how tall it is, so the Command just fills it
+ * and `min-h-0` lets the list inside do the scrolling. The popover has to
+ * cap itself, and its third term is what Radix measured between the trigger
+ * and the viewport edge — a picker opened low on a short screen ends where
+ * the screen does instead of running off it. */
+ const heightClass = isMobile
+ ? "min-h-0 flex-1"
+ : "max-h-[min(560px,70dvh,var(--radix-popover-content-available-height,70dvh))]";
+
+ const content = (
+ {
+ setOpen(false);
+ onNavigate?.();
+ }}
+ onCreateOrg={() => {
+ setOpen(false);
+ setCreatingOrg(true);
+ }}
+ className={heightClass}
+ />
+ );
+
return (
<>
-
- {trigger}
-
- setOpen(false)}
- onCreateOrg={() => {
- setOpen(false);
- setCreatingOrg(true);
- }}
- />
-
-
- {/* Sibling of the Popover, never inside its content: a dialog mounted in
+ {/* A drawer on mobile, a popover on the desktop it was drawn for. A
+ popover anchored to a control in the sidebar SHEET is the wrong shape
+ on a phone twice over: it hangs off a trigger a thumb is covering, and
+ being portalled outside the sheet it lands outside that dialog's
+ scroll lock, which cancels every touchmove over it — the list clipped
+ at its height and would not move under a finger. The drawer answers
+ both: it opens from the bottom edge where the thumb already is, and it
+ is its own modal layer, so the list scrolls. Same content either way —
+ only the surface changes. */}
+ {isMobile ? (
+
+ {trigger}
+
+
+ {t("sidebar.picker.title")}
+
+ {content}
+
+
+ ) : (
+
+ {trigger}
+
+ {content}
+
+
+ )}
+ {/* Sibling of the surface, never inside its content: a dialog mounted in
there unmounts with the popover the moment it takes focus. */}
void;
-}
-
-const SidebarAgentGroupsContext =
- createContext(null);
-
-export function SidebarAgentGroupsProvider({
- children,
-}: {
- children: ReactNode;
-}) {
- const [empty, setEmpty] = useState(false);
-
- return (
-
- {children}
-
- );
-}
-
-export function useSidebarAgentGroupsEmpty(): boolean {
- return useContext(SidebarAgentGroupsContext)?.empty ?? false;
-}
diff --git a/apps/web/src/i18n/en/header.ts b/apps/web/src/i18n/en/header.ts
index 278262140f..4f2a33c25f 100644
--- a/apps/web/src/i18n/en/header.ts
+++ b/apps/web/src/i18n/en/header.ts
@@ -8,6 +8,4 @@ export const header = {
"header.orgSwitcher.invitedToJoin": "Invited to join",
"header.orgSwitcher.joined": "Joined {name}",
"header.orgSwitcher.unknownOrganization": "Unknown organization",
- "header.shellBreadcrumb.openAgentHome": "Open {name} home",
- "header.shellBreadcrumb.superAgentDefaultName": "Super Agent",
} as const;
diff --git a/apps/web/src/i18n/en/main-panel-tabs.ts b/apps/web/src/i18n/en/main-panel-tabs.ts
index 78457b0481..5bc815db33 100644
--- a/apps/web/src/i18n/en/main-panel-tabs.ts
+++ b/apps/web/src/i18n/en/main-panel-tabs.ts
@@ -520,6 +520,7 @@ export const mainPanelTabs = {
"mainPanelTabs.mobileMainPanelTabSelect.chat": "Chat",
"mainPanelTabs.mobileMainPanelTabSelect.library": "Library",
"mainPanelTabs.mobileMainPanelTabSelect.mainView": "Main view",
+ "mainPanelTabs.mobileMainPanelTabSelect.switchTo": "Switch to {name}",
"mainPanelTabs.mobileMainPanelTabSelect.tasks": "Tasks",
"mainPanelTabs.mobileMainPanelTabSelect.view": "View",
"mainPanelTabs.previewTab.connectGithub": "Connect GitHub",
diff --git a/apps/web/src/i18n/en/sidebar.ts b/apps/web/src/i18n/en/sidebar.ts
index 4b8fc3ef71..1e1590e6e6 100644
--- a/apps/web/src/i18n/en/sidebar.ts
+++ b/apps/web/src/i18n/en/sidebar.ts
@@ -1,5 +1,4 @@
export const sidebar = {
- "sidebar.agentsSection.agents": "Projects",
"sidebar.archiveWorktreeDialog.cancel": "Cancel",
"sidebar.archiveWorktreeDialog.confirm": "Continue",
"sidebar.archiveWorktreeDialog.reclaimFailed":
@@ -9,14 +8,7 @@ export const sidebar = {
"sidebar.archiveWorktreeDialog.stopsAndDeletesBefore":
"This will stop everything running on",
"sidebar.archiveWorktreeDialog.title": "Archive this chat?",
- "sidebar.agentsSection.browseAgents": "Browse projects",
"sidebar.agentsSection.codeAgents": "Code Projects",
- "sidebar.agentsSection.import": "Import",
- "sidebar.agentsSection.newAgent": "New project",
- "sidebar.agentsSection.noAgentsFound": "No projects found",
- "sidebar.agentsSection.searchAgents": "Search projects...",
- "sidebar.agentsSection.seeAllAgents": "See all projects",
- "sidebar.agentsSection.selectAnExistingAgent": "Select an existing project",
"sidebar.header.closeSidebar": "Close sidebar",
"sidebar.header.toggleSidebar": "Toggle sidebar",
"sidebar.navDestinations.discover": "Discover",
@@ -39,6 +31,7 @@ export const sidebar = {
"sidebar.picker.orgsHeading": "Organizations",
"sidebar.picker.projectsHeading": "Projects",
"sidebar.picker.searchPlaceholder": "Search organizations and projects\u2026",
+ "sidebar.picker.title": "Organizations and projects",
"sidebar.picker.moreExist":
"More projects than fit here \u2014 keep typing to narrow",
"sidebar.picker.verbLeaves": "\u00b7 leaves {name}",
diff --git a/apps/web/src/i18n/pt-br/header.ts b/apps/web/src/i18n/pt-br/header.ts
index 4b2d605bf0..8758d1b260 100644
--- a/apps/web/src/i18n/pt-br/header.ts
+++ b/apps/web/src/i18n/pt-br/header.ts
@@ -9,6 +9,4 @@ export const header = {
"header.orgSwitcher.invitedToJoin": "Convidado para entrar",
"header.orgSwitcher.joined": "Entrou em {name}",
"header.orgSwitcher.unknownOrganization": "Organização desconhecida",
- "header.shellBreadcrumb.openAgentHome": "Abrir início de {name}",
- "header.shellBreadcrumb.superAgentDefaultName": "Super Agent",
} satisfies Record;
diff --git a/apps/web/src/i18n/pt-br/main-panel-tabs.ts b/apps/web/src/i18n/pt-br/main-panel-tabs.ts
index 1775492e35..3a606ee143 100644
--- a/apps/web/src/i18n/pt-br/main-panel-tabs.ts
+++ b/apps/web/src/i18n/pt-br/main-panel-tabs.ts
@@ -540,6 +540,7 @@ export const mainPanelTabs = {
"mainPanelTabs.mobileMainPanelTabSelect.chat": "Chat",
"mainPanelTabs.mobileMainPanelTabSelect.library": "Biblioteca",
"mainPanelTabs.mobileMainPanelTabSelect.mainView": "Visualização principal",
+ "mainPanelTabs.mobileMainPanelTabSelect.switchTo": "Ir para {name}",
"mainPanelTabs.mobileMainPanelTabSelect.tasks": "Tarefas",
"mainPanelTabs.mobileMainPanelTabSelect.view": "Visualizar",
"mainPanelTabs.previewTab.connectGithub": "Conectar GitHub",
diff --git a/apps/web/src/i18n/pt-br/sidebar.ts b/apps/web/src/i18n/pt-br/sidebar.ts
index 376d2515dc..b2a0ee4214 100644
--- a/apps/web/src/i18n/pt-br/sidebar.ts
+++ b/apps/web/src/i18n/pt-br/sidebar.ts
@@ -1,7 +1,6 @@
import type { sidebar as sidebarEn } from "../en/sidebar.ts";
export const sidebar = {
- "sidebar.agentsSection.agents": "Projetos",
"sidebar.archiveWorktreeDialog.cancel": "Cancelar",
"sidebar.archiveWorktreeDialog.confirm": "Continuar",
"sidebar.archiveWorktreeDialog.reclaimFailed":
@@ -11,15 +10,7 @@ export const sidebar = {
"sidebar.archiveWorktreeDialog.stopsAndDeletesBefore":
"Isso vai parar tudo o que está rodando em",
"sidebar.archiveWorktreeDialog.title": "Arquivar este chat?",
- "sidebar.agentsSection.browseAgents": "Procurar projetos",
"sidebar.agentsSection.codeAgents": "Projetos de Código",
- "sidebar.agentsSection.import": "Importar",
- "sidebar.agentsSection.newAgent": "Novo projeto",
- "sidebar.agentsSection.noAgentsFound": "Nenhum projeto encontrado",
- "sidebar.agentsSection.searchAgents": "Procurar projetos...",
- "sidebar.agentsSection.seeAllAgents": "Ver todos os projetos",
- "sidebar.agentsSection.selectAnExistingAgent":
- "Selecione um projeto existente",
"sidebar.header.closeSidebar": "Fechar barra lateral",
"sidebar.header.toggleSidebar": "Abrir ou fechar barra lateral",
"sidebar.navDestinations.discover": "Descobrir",
@@ -43,6 +34,7 @@ export const sidebar = {
"sidebar.picker.projectsHeading": "Projetos",
"sidebar.picker.searchPlaceholder":
"Buscar organiza\u00e7\u00f5es e projetos\u2026",
+ "sidebar.picker.title": "Organiza\u00e7\u00f5es e projetos",
"sidebar.picker.moreExist":
"H\u00e1 mais projetos do que cabem aqui \u2014 continue digitando",
"sidebar.picker.verbLeaves": "\u00b7 sai de {name}",
diff --git a/apps/web/src/layouts/main-panel-tabs/mobile-main-panel-tab-select.test.ts b/apps/web/src/layouts/main-panel-tabs/mobile-main-panel-tab-select.test.ts
index de390850e2..1b5ebed9c0 100644
--- a/apps/web/src/layouts/main-panel-tabs/mobile-main-panel-tab-select.test.ts
+++ b/apps/web/src/layouts/main-panel-tabs/mobile-main-panel-tab-select.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test";
-import { resolveMobileMainPanelTabSelectLabel } from "./mobile-main-panel-tab-select";
+import {
+ buildMobileViewOptions,
+ MAIN_SURFACE_VALUE,
+ resolveMobileMainPanelTabSelectLabel,
+} from "./mobile-main-panel-tab-select";
import { en } from "@/i18n/en/index.ts";
import type { TranslationKey } from "@/i18n/en/index.ts";
@@ -66,3 +70,45 @@ describe("resolveMobileMainPanelTabSelectLabel", () => {
).toBe("Main view");
});
});
+
+const icon = { kind: "component", Component: () => null } as const;
+const iconTabs = tabs.map((tab) => ({ ...tab, icon }));
+
+describe("buildMobileViewOptions", () => {
+ test("offers the main surface back when the route declares no tabs", () => {
+ expect(
+ buildMobileViewOptions({ tabs: [], overlayEnabled: false, t }).map(
+ (option) => option.value,
+ ),
+ ).toEqual(["chat", MAIN_SURFACE_VALUE]);
+ });
+
+ test("does not synthesize a main row when tabs already lead back", () => {
+ expect(
+ buildMobileViewOptions({
+ tabs: iconTabs,
+ overlayEnabled: false,
+ t,
+ }).map((option) => option.value),
+ ).toEqual(["chat", "preview", "settings"]);
+ });
+
+ test("adds the Tasks and Library overlays on a task route", () => {
+ expect(
+ buildMobileViewOptions({ tabs: [], overlayEnabled: true, t }).map(
+ (option) => option.value,
+ ),
+ ).toEqual(["chat", MAIN_SURFACE_VALUE, "board", "files"]);
+ });
+
+ test("names the main row, so it is pickable rather than only a label", () => {
+ const options = buildMobileViewOptions({
+ tabs: [],
+ overlayEnabled: false,
+ t,
+ });
+ expect(
+ options.find((option) => option.value === MAIN_SURFACE_VALUE)?.title,
+ ).toBe("Main view");
+ });
+});
diff --git a/apps/web/src/layouts/main-panel-tabs/mobile-main-panel-tab-select.tsx b/apps/web/src/layouts/main-panel-tabs/mobile-main-panel-tab-select.tsx
index e79e0ab2a6..b92a9abd6e 100644
--- a/apps/web/src/layouts/main-panel-tabs/mobile-main-panel-tab-select.tsx
+++ b/apps/web/src/layouts/main-panel-tabs/mobile-main-panel-tab-select.tsx
@@ -1,5 +1,10 @@
import { useNavigate, useParams } from "@tanstack/react-router";
-import { Columns03, Folder, MessageCircle01 } from "@untitledui/icons";
+import {
+ Columns03,
+ Folder,
+ LayoutAlt01,
+ MessageCircle01,
+} from "@untitledui/icons";
import {
Select,
SelectContent,
@@ -19,6 +24,11 @@ import { useT } from "@/i18n/use-t.ts";
const MOBILE_SELECT_SENTINEL = "__mobile-main-panel-tab-select__";
+/** The route's own main surface, for routes that declare no tabs of their own
+ * (the org home is the one people meet first). Not a tab id — nothing opens
+ * it by name; it is the "put the main panel back" half of the mobile pair. */
+export const MAIN_SURFACE_VALUE = "main";
+
export function resolveMobileMainPanelTabSelectLabel({
tabs,
activeTab,
@@ -41,13 +51,77 @@ type ViewOption = { value: string; title: string; icon: TabIcon };
const CHAT_ICON: TabIcon = { kind: "component", Component: MessageCircle01 };
const TASKS_ICON: TabIcon = { kind: "component", Component: Columns03 };
const LIBRARY_ICON: TabIcon = { kind: "component", Component: Folder };
+const MAIN_ICON: TabIcon = { kind: "component", Component: LayoutAlt01 };
+
+/**
+ * The surfaces this route can show, in the order the control lists them.
+ *
+ * The synthetic main-surface row is the fix for a dead end: on a route with no
+ * tabs of its own the list was `[Chat]` alone, so tapping Chat left you on a
+ * control whose only option was the thing you were already looking at, and the
+ * main view was unreachable without the browser's back button. "Main view" was
+ * a *label* there, never an option. Where the route does declare tabs, those
+ * tabs already are the way back — opening one opens the main panel — so
+ * synthesizing another entry beside them would only duplicate the first.
+ */
+export function buildMobileViewOptions({
+ tabs,
+ overlayEnabled,
+ t,
+}: {
+ tabs: Array<{ id: string; title: string; icon: TabIcon }>;
+ /** Tasks / Library are destinations of their own; they need a task route to
+ * act on (the same gate as the desktop toggles). */
+ overlayEnabled: boolean;
+ t: ReturnType;
+}): ViewOption[] {
+ return [
+ {
+ value: "chat",
+ title: t("mainPanelTabs.mobileMainPanelTabSelect.chat"),
+ icon: CHAT_ICON,
+ },
+ ...(tabs.length > 0
+ ? tabs.map((tab) => ({
+ value: tab.id,
+ title: tab.title,
+ icon: tab.icon,
+ }))
+ : [
+ {
+ value: MAIN_SURFACE_VALUE,
+ title: t("mainPanelTabs.mobileMainPanelTabSelect.mainView"),
+ icon: MAIN_ICON,
+ },
+ ]),
+ ...(overlayEnabled
+ ? [
+ {
+ value: "board",
+ title: t("mainPanelTabs.mobileMainPanelTabSelect.tasks"),
+ icon: TASKS_ICON,
+ },
+ {
+ value: "files",
+ title: t("mainPanelTabs.mobileMainPanelTabSelect.library"),
+ icon: LIBRARY_ICON,
+ },
+ ]
+ : []),
+ ];
+}
/**
* Mobile view selector. On mobile there's no side-by-side split, so a single
- * surface is visible at a time. This dropdown mirrors the desktop panel bar —
- * Chat, controls local to the current surface, contextual/per-thread views, and
- * the Tasks / Library overlays. Durable project views stay in the responsive
+ * surface is visible at a time. This mirrors the desktop panel bar — Chat,
+ * controls local to the current surface, contextual/per-thread views, and the
+ * Tasks / Library overlays. Durable project views stay in the responsive
* sidebar on both desktop and mobile.
+ *
+ * Two surfaces get a toggle rather than a dropdown: a menu that opens to offer
+ * one alternative is two taps and a list to read where one tap and a label will
+ * do. Three or more keep the select, because then "the other one" stops being a
+ * thing you can name.
*/
export function MobileMainPanelTabSelect({
virtualMcpId,
@@ -71,51 +145,35 @@ export function MobileMainPanelTabSelect({
const reportsOnly = useReportsOnly();
const onReportAgent = virtualMcpId === getCommerceDiscoveryAgentId(org.id);
- // Tasks / Library are destinations of their own; they need a task route
- // to act on (same gate as the desktop toggles).
- const overlayEnabled = !!(params.org && params.taskId);
-
- const options: ViewOption[] = [
- {
- value: "chat",
- title: t("mainPanelTabs.mobileMainPanelTabSelect.chat"),
- icon: CHAT_ICON,
- },
- ...tabs.map((tab) => ({
- value: tab.id,
- title: tab.title,
- icon: tab.icon,
- })),
- ...(overlayEnabled
- ? [
- {
- value: "board",
- title: t("mainPanelTabs.mobileMainPanelTabSelect.tasks"),
- icon: TASKS_ICON,
- },
- ]
- : []),
- ...(overlayEnabled
- ? [
- {
- value: "files",
- title: t("mainPanelTabs.mobileMainPanelTabSelect.library"),
- icon: LIBRARY_ICON,
- },
- ]
- : []),
- ];
+ const options = buildMobileViewOptions({
+ tabs,
+ overlayEnabled: !!(params.org && params.taskId),
+ t,
+ });
- // Chat is the surface whenever the main panel is closed; otherwise the active
- // main tab (which includes board/files when an overlay is open).
+ /** Chat while the main panel is closed; else the active tab, or the main
+ * surface itself on a route that declares none. */
const currentValue = !mainOpen
? "chat"
- : (options.find((o) => o.value === activeTab)?.value ?? activeTab);
+ : (options.find((o) => o.value === activeTab)?.value ?? MAIN_SURFACE_VALUE);
const selected = options.find((o) => o.value === currentValue);
const label =
selected?.title ??
resolveMobileMainPanelTabSelectLabel({ tabs, activeTab, mainOpen, t });
+ /** Both halves of the mobile pair are surface swaps on the same route, so
+ * they write the same pair of params and differ only in which one wins. */
+ const showSurface = (surface: "chat" | "main") => {
+ navigate({
+ to: ".",
+ search: (prev: Record) => ({
+ ...prev,
+ ...mobileSurfaceSearch(surface),
+ }),
+ replace: true,
+ });
+ };
+
const handleSelect = (value: string) => {
if (value === MOBILE_SELECT_SENTINEL) return;
track("main_panel_tab_clicked", {
@@ -123,9 +181,9 @@ export function MobileMainPanelTabSelect({
tab_id: value,
source: "mobile_select",
});
- // Reports-only: the storefront Preview/Code live on the Report Agent, so
- // from any other shell deep-link into it instead of opening a source-less
- // panel on the current agent (mirrors setActiveTab in useMainPanelTabs).
+ /** Reports-only: the storefront Preview/Code live on the Report Agent, so
+ * from any other shell deep-link into it instead of opening a source-less
+ * panel on the current agent (mirrors setActiveTab in useMainPanelTabs). */
if (shouldDeepLinkSourceTab({ reportsOnly, onReportAgent, tabId: value })) {
openPanel(value, {
virtualmcpid: getCommerceDiscoveryAgentId(org.id),
@@ -134,15 +192,8 @@ export function MobileMainPanelTabSelect({
});
return;
}
- if (value === "chat") {
- navigate({
- to: ".",
- search: (prev: Record) => ({
- ...prev,
- ...mobileSurfaceSearch("chat"),
- }),
- replace: true,
- });
+ if (value === "chat" || value === MAIN_SURFACE_VALUE) {
+ showSurface(value === "chat" ? "chat" : "main");
return;
}
/** The view is the path now, so only the chat half needs writing: naming a
@@ -152,6 +203,30 @@ export function MobileMainPanelTabSelect({
});
};
+ /** The one this control would switch you to — only meaningful in the two-
+ * option case; with a longer list there is no single "other". */
+ const other = options.find((option) => option.value !== currentValue);
+
+ if (options.length === 2 && other) {
+ return (
+
+ );
+ }
+
return (