From d726fb65cf730ad33142e0c3f56f3ac7550f1b37 Mon Sep 17 00:00:00 2001 From: gimenes Date: Fri, 4 Sep 2026 14:15:53 -0300 Subject: [PATCH 1/3] fix(sidebar): let the org/project picker scroll on touch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On mobile the sidebar is a Radix Sheet — a modal dialog — and its scroll lock (react-remove-scroll) calls preventDefault() on every wheel and touchmove that lands outside the dialog's own subtree. The picker's popover is portalled to the body, so it is outside: the list clipped at its max height and would not move under a finger. The repo already knew this failure mode for wheel (page-template-select.tsx patches it by hand). Making the popover modal gives it its own lock — only the innermost lock acts, and that one counts the list as scrollable — and it restores the menu semantics this control inherited from the dropdown it replaced. While in there: the Command's max-h-[min(560px,70dvh)] was dead, because CommandList's shared 300px default is smaller, so the picker was always ~373px tall whatever that number said. The list now takes the Command's budget, and the budget clamps to --radix-popover-content-available-height so a picker opened low on a short screen ends where the screen does. Verified against the real Sheet + PopoverContent + Command components in Chrome with a dispatched touch sequence over the list: touchmove is prevented before the change, allowed after, and still prevented once the list reaches its end, so nothing chains to the page behind. Layout at an 806px viewport: Command 560, input 40, strip 33, list 487 and scrollable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AVd1o11cJLXR7HPSTyuyBC --- .../components/sidebar/org-project-picker.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/sidebar/org-project-picker.tsx b/apps/web/src/components/sidebar/org-project-picker.tsx index 0518a7f3c6..2c895d760c 100644 --- a/apps/web/src/components/sidebar/org-project-picker.tsx +++ b/apps/web/src/components/sidebar/org-project-picker.tsx @@ -259,14 +259,23 @@ function PickerContent({ shouldFilter={false} value={active} onValueChange={setActive} - className="max-h-[min(560px,70dvh)]" + /* The third term is what Radix measured between the trigger and the + viewport edge, so a picker opened low on a short screen ends where the + screen does instead of running off it. */ + className="max-h-[min(560px,70dvh,var(--radix-popover-content-available-height,70dvh))]" > - + {/* The list is the part that gives, so it owns the Command's height + budget rather than `CommandList`'s shared 300px default — which was + the smaller of the two and made the number above it a cap nothing + ever reached. `min-h-0` so it may shrink inside the flex column; + `flex-1` grows it only into space that exists, so a short list stays + short and the input and strip keep their own heights. */} + {searching ? ( <> {/* A failed search is not an empty one: saying "nothing matches" @@ -550,7 +559,16 @@ export function OrgProjectPicker({ return ( <> - + {/* `modal`, and it is load-bearing on touch. On mobile the sidebar IS a + Radix dialog (the sheet), whose scroll lock cancels every wheel and + touchmove that lands outside the dialog's own subtree — and this + popover is portalled to the body, so the list clipped at its max + height and would not move under a finger. A modal popover brings its + own lock, only the innermost lock acts, and that one counts the list + as scrollable. Cheaper than teaching the sheet about its portals, and + it restores the menu semantics this control inherited from the + dropdown it replaced. */} + {trigger} Date: Fri, 4 Sep 2026 16:15:59 -0300 Subject: [PATCH 2/3] feat(mobile): one selector in the sheet, a drawer to open it, a toggle for two views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the mobile shell got wrong, reported from a phone. **Two selectors over one entity.** The sidebar sheet header carried an agent switcher beside the org/project picker. Agents and projects are both virtual MCPs, so those were the same things listed twice under two names. The agent switcher goes; the picker stays and is now the single selector there. That orphaned its whole browser — agents-section.tsx, the agent crumb, and read-cached-task-branch, ~1000 lines nothing else imported — so they go with it, along with the i18n keys only they read. **A popover hung off a control under your thumb.** The picker now opens as a bottom drawer on mobile and stays a popover on the desktop it was drawn for. That also settles the scroll bug from the branch below this one without the `modal` popover: portalled outside the sheet, the popover sat outside that dialog's scroll lock, which cancels every touchmove over it. A drawer is its own modal layer, so the list scrolls. Picking now closes the sheet too, or what you just chose stays hidden behind it. **Chat was a dead end.** On a route with no tabs of its own the view select listed `[Chat]` alone — "Main view" was a label it fell back to, never an option — so tapping Chat left no way back but the browser's back button. The options builder is now a pure function that synthesizes a main-surface row exactly when no tab already leads back, and it is unit-tested. Two surfaces render a toggle instead of a dropdown, per the ask: one tap and a label beats a menu that opens to offer one alternative. Three or more keep the select. Verified on the running app at 390x844 with touch emulation: the toggle round trips main -> chat -> main one tap each; the drawer opens from the bottom with search and both groups; a touchmove over its list is no longer cancelled (defaultPrevented false with the list scrollable). Desktop unchanged — popover, no drawer, Command capped at the 560px it always meant to be, body pointer-events back to `auto` now that it is non-modal again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AVd1o11cJLXR7HPSTyuyBC --- .../components/header/shell-breadcrumb.tsx | 183 +---- .../src/components/sidebar/agents-section.tsx | 643 ------------------ apps/web/src/components/sidebar/header.tsx | 23 +- apps/web/src/components/sidebar/index.tsx | 45 +- .../components/sidebar/org-project-picker.tsx | 104 ++- .../sidebar/sidebar-agent-groups-context.tsx | 27 - apps/web/src/i18n/en/header.ts | 2 - apps/web/src/i18n/en/main-panel-tabs.ts | 1 + apps/web/src/i18n/en/sidebar.ts | 9 +- apps/web/src/i18n/pt-br/header.ts | 2 - apps/web/src/i18n/pt-br/main-panel-tabs.ts | 1 + apps/web/src/i18n/pt-br/sidebar.ts | 10 +- .../mobile-main-panel-tab-select.test.ts | 48 +- .../mobile-main-panel-tab-select.tsx | 181 +++-- apps/web/src/lib/read-cached-task-branch.ts | 23 - 15 files changed, 286 insertions(+), 1016 deletions(-) delete mode 100644 apps/web/src/components/sidebar/agents-section.tsx delete mode 100644 apps/web/src/components/sidebar/sidebar-agent-groups-context.tsx delete mode 100644 apps/web/src/lib/read-cached-task-branch.ts 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/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 ( -
- {t("sidebar.agentsSection.noAgentsFound")} -
- ); -} - -function SeeAllAgentsFooter({ - org, - onClose, -}: { - org: { slug: string }; - onClose: () => void; -}) { - const t = useT(); - return ( -
- onClose()} - className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center justify-center" - > - {t("sidebar.agentsSection.seeAllAgents")} - -
- ); -} - -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 ( -
- {/* Search */} - - - {/* Scrollable content */} -
- {/* 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 ( <> - {/* `modal`, and it is load-bearing on touch. On mobile the sidebar IS a - Radix dialog (the sheet), whose scroll lock cancels every wheel and - touchmove that lands outside the dialog's own subtree — and this - popover is portalled to the body, so the list clipped at its max - height and would not move under a finger. A modal popover brings its - own lock, only the innermost lock acts, and that one counts the list - as scrollable. Cheaper than teaching the sheet about its portals, and - it restores the menu semantics this control inherited from the - dropdown it replaced. */} - - {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 (