diff --git a/site/src/index.css b/site/src/index.css index 10292db8bd..2d10dfa13e 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -156,6 +156,81 @@ } } +@layer components { + /* Map each stripe variant to a color token so the + pseudo-element rules can stay DRY. */ + .navbar-stripe-devel { + --stripe-color: var(--content-warning); + } + + .navbar-stripe-rc { + --stripe-color: var(--border-sky); + } + + /* Thin stripe bars at the top and bottom edges of the + navbar. Using pseudo-elements keeps the stripes out of + the content area so nav links stay readable. */ + .navbar-stripe-devel::before, + .navbar-stripe-devel::after, + .navbar-stripe-rc::before, + .navbar-stripe-rc::after { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 4px; + background: repeating-linear-gradient( + -45deg, + transparent, + transparent 4px, + hsl(var(--stripe-color) / 0.5) 4px, + hsl(var(--stripe-color) / 0.5) 8px + ); + pointer-events: none; + } + + .navbar-stripe-devel::before, + .navbar-stripe-rc::before { + top: 0; + } + + .navbar-stripe-devel::after, + .navbar-stripe-rc::after { + bottom: 0; + } + + @media (max-width: 767px) { + /* + * Full-width mobile dropdowns. We set a --mobile-dropdown-bottom + * custom property on the chat input container so the dropdown + * position tracks the actual input box, not a hardcoded offset. + */ + [data-radix-popper-content-wrapper]:has(> .mobile-full-width-dropdown) { + position: fixed !important; + left: 1rem !important; + width: calc(100vw - 2rem) !important; + min-width: 0 !important; + transform: none !important; + bottom: var(--mobile-dropdown-bottom, 5rem) !important; + top: auto !important; + } + [data-radix-popper-content-wrapper]:has(> .mobile-full-width-dropdown-top) { + bottom: auto !important; + top: var(--mobile-dropdown-top, 3.5rem) !important; + } + [data-radix-popper-content-wrapper]:has( + > .mobile-full-width-dropdown-top-below-header + ) { + bottom: auto !important; + top: 5rem !important; + } + .mobile-full-width-dropdown { + width: 100% !important; + min-width: 0 !important; + max-width: none !important; + } + } +} @layer base { * { @apply border-border; diff --git a/site/src/pages/AgentsPage/AgentChatPageView.tsx b/site/src/pages/AgentsPage/AgentChatPageView.tsx index b087187b3e..a5327454b7 100644 --- a/site/src/pages/AgentsPage/AgentChatPageView.tsx +++ b/site/src/pages/AgentsPage/AgentChatPageView.tsx @@ -467,7 +467,7 @@ export const AgentChatPageView: FC = ({ /> -
+
= ({
-
+
{}} initialValue="" diff --git a/site/src/pages/AgentsPage/AgentCreatePage.tsx b/site/src/pages/AgentsPage/AgentCreatePage.tsx index 7dbe1ec3a3..210e3d0978 100644 --- a/site/src/pages/AgentsPage/AgentCreatePage.tsx +++ b/site/src/pages/AgentsPage/AgentCreatePage.tsx @@ -1,6 +1,8 @@ -import type { FC } from "react"; +import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { getErrorMessage } from "#/api/errors"; import { chatModelConfigs, chatModels, @@ -9,6 +11,7 @@ import { } from "#/api/queries/chats"; import { workspaces } from "#/api/queries/workspaces"; import type * as TypesGen from "#/api/typesGenerated"; +import { useWebpushNotifications } from "#/contexts/useWebpushNotifications"; import { useAuthenticated } from "#/hooks/useAuthenticated"; import { AgentCreateForm, @@ -17,6 +20,7 @@ import { import { AgentPageHeader } from "./components/AgentPageHeader"; import { ChimeButton } from "./components/ChimeButton"; import { WebPushButton } from "./components/WebPushButton"; +import { getChimeEnabled, setChimeEnabled } from "./utils/chime"; import { getModelOptionsFromConfigs } from "./utils/modelOptions"; import { buildAgentChatPath } from "./utils/navigation"; @@ -33,6 +37,8 @@ const AgentCreatePage: FC = () => { const mcpServersQuery = useQuery(mcpServerConfigs()); const workspacesQuery = useQuery(workspaces({ q: "owner:me", limit: 0 })); const createMutation = useMutation(createChat(queryClient)); + const webPush = useWebpushNotifications(); + const [chimeEnabled, setChimeEnabledState] = useState(getChimeEnabled); const catalogModelOptions = getModelOptionsFromConfigs( chatModelConfigsQuery.data, @@ -77,11 +83,35 @@ const AgentCreatePage: FC = () => { navigate(buildAgentChatPath({ chatId: createdChat.id })); }; + const handleChimeToggle = () => { + const next = !chimeEnabled; + setChimeEnabledState(next); + setChimeEnabled(next); + }; + + const handleNotificationToggle = async () => { + try { + if (webPush.subscribed) { + await webPush.unsubscribe(); + } else { + await webPush.subscribe(); + } + } catch (error) { + const action = webPush.subscribed ? "disable" : "enable"; + toast.error(getErrorMessage(error, `Failed to ${action} notifications.`)); + } + }; + return ( <> - - - + + + = ({ ? "hidden md:block shrink-0 h-[42dvh] min-h-[240px] border-b border-border-default" : isSettingsDetail || isAnalytics ? "hidden md:block shrink-0" - : "order-2 md:order-none flex-1 min-h-0 border-t border-border-default md:flex-none md:border-t-0", + : "order-2 md:order-none flex-1 min-h-0 border-b border-border-default md:flex-none md:border-t-0 md:border-b-0", isSidebarCollapsed && "md:hidden", )} > @@ -207,7 +207,7 @@ export const AgentsPageView: FC = ({ !agentId && !isSettingsDetail && sidebarView.panel === "chats" && - "order-1 md:order-none flex-none md:flex-1", + "contents md:flex md:flex-1 md:flex-col", )} > diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 50c9831435..3d18cb923f 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -1,4 +1,5 @@ import { + ArrowLeftIcon, ArrowUpIcon, CheckIcon, ChevronRightIcon, @@ -49,7 +50,7 @@ import { } from "#/components/Tooltip/Tooltip"; import { cn } from "#/utils/cn"; import { countInvisibleCharacters } from "#/utils/invisibleUnicode"; -import { isMobileViewport } from "#/utils/mobile"; +import { isBelowMdViewport, isMobileViewport } from "#/utils/mobile"; import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth"; import { useOverflowCount } from "../hooks/useOverflowCount"; import { useSpeechRecognition } from "../hooks/useSpeechRecognition"; @@ -310,6 +311,9 @@ export const AgentChatInput: FC = ({ null, ); const [plusMenuOpen, setPlusMenuOpen] = useState(false); + const [plusMenuView, setPlusMenuView] = useState<"main" | "workspace">( + "main", + ); const [workspacePickerOpen, setWorkspacePickerOpen] = useState(false); const [mcpConnectingId, setMcpConnectingId] = useState(null); const mcpPopupRef = useRef(null); @@ -442,6 +446,35 @@ export const AgentChatInput: FC = ({ }; const fileInputRef = useRef(null); + const [composerElement, setComposerElement] = useState( + null, + ); + useEffect(() => { + if (!composerElement) return; + const update = () => { + const rect = composerElement.getBoundingClientRect(); + const bottom = Math.max(0, window.innerHeight - rect.bottom); + document.documentElement.style.setProperty( + "--mobile-dropdown-bottom", + `${bottom}px`, + ); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(composerElement); + window.addEventListener("resize", update); + const viewport = window.visualViewport; + viewport?.addEventListener("resize", update); + viewport?.addEventListener("scroll", update); + return () => { + ro.disconnect(); + window.removeEventListener("resize", update); + viewport?.removeEventListener("resize", update); + viewport?.removeEventListener("scroll", update); + document.documentElement.style.removeProperty("--mobile-dropdown-bottom"); + }; + }, [composerElement]); + const handleFileSelect = (e: React.ChangeEvent) => { if (e.target.files && onAttach) { onAttach(Array.from(e.target.files)); @@ -668,8 +701,9 @@ export const AgentChatInput: FC = ({ /> )}
= ({
{/* Plus menu */} - + { + setPlusMenuOpen(open); + if (!open) setPlusMenuView("main"); + }} + > + {" "} - )} - {onPlanModeToggle && ( - - )} - {workspaceOptions && onWorkspaceChange && ( - - + {plusMenuView === "workspace" ? ( +
+ + + + + + + No workspaces found + + + {workspaceOptions?.map((workspace) => ( + { + onWorkspaceChange?.(workspace.id); + setPlusMenuOpen(false); + }} + > + {workspace.name} + {selectedWorkspaceId === workspace.id && ( + + )} + + ))} + + + +
+ ) : ( + <> + {onAttach && ( + )} + {onPlanModeToggle && ( + -
- - - - - - No workspaces found - - - {workspaceOptions.map((workspace) => ( - { - onWorkspaceChange(workspace.id); - setWorkspacePickerOpen(false); - setPlusMenuOpen(false); - }} - > - {workspace.name} - {selectedWorkspaceId === workspace.id && ( - - )} - - ))} - - - - -
- )} - {enabledMcpServers.length > 0 && ( - <> - - {enabledMcpServers.map((server) => { - const isForceOn = server.availability === "force_on"; - const isSelected = - isForceOn || - (selectedMCPServerIds?.includes(server.id) ?? false); - const needsAuth = - server.auth_type === "oauth2" && !server.auth_connected; - const isConnecting = mcpConnectingId === server.id; - return ( -
setPlusMenuView("workspace")} + className="group flex h-8 w-full cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:text-content-primary disabled:cursor-not-allowed disabled:opacity-50" > - {server.icon_url ? ( - - ) : ( - - )} - - {server.display_name} - - {needsAuth ? ( - + ) : ( + + + - ) : ( - - handleMcpToggle(server.id, checked) - } - disabled={isDisabled || isForceOn} - aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`} - /> - )} -
- ); - })} + + Attach workspace + + +
+ + + + + + No workspaces found + + + {workspaceOptions.map((workspace) => ( + { + onWorkspaceChange(workspace.id); + setWorkspacePickerOpen(false); + setPlusMenuOpen(false); + }} + > + {workspace.name} + {selectedWorkspaceId === workspace.id && ( + + )} + + ))} + + + + +
+ ))} + {enabledMcpServers.length > 0 && ( + <> + + {enabledMcpServers.map((server) => { + const isForceOn = server.availability === "force_on"; + const isSelected = + isForceOn || + (selectedMCPServerIds?.includes(server.id) ?? + false); + const needsAuth = + server.auth_type === "oauth2" && + !server.auth_connected; + const isConnecting = mcpConnectingId === server.id; + return ( +
+ {server.icon_url ? ( + + ) : ( + + )} + + {server.display_name} + + {needsAuth ? ( + + ) : ( + + handleMcpToggle(server.id, checked) + } + disabled={isDisabled || isForceOn} + aria-label={`${isSelected ? "Disable" : "Enable"} ${server.display_name}`} + /> + )} +
+ ); + })} + + )} )} @@ -954,14 +1058,15 @@ export const AgentChatInput: FC = ({ formatProviderLabel={formatProviderLabel} dropdownSide="top" dropdownAlign="center" + enableMobileFullWidthDropdown /> )} {planModeEnabled && ( - + Planning - )} + )}{" "} {/* Badge row — all badges and the pill always * render so the DOM structure never changes. * Overflow badges use invisible + order-1 to @@ -969,13 +1074,15 @@ export const AgentChatInput: FC = ({ * when there's no overflow but still occupies * layout space, preventing measurement flicker. */} {workspace && workspaceAgent && chatId && ( - + + + )}
= ({ {overflowBadges.map((badge) => ( = ({ return ( <> -
+
{isForbidden ? ( diff --git a/site/src/pages/AgentsPage/components/AgentPageHeader.stories.tsx b/site/src/pages/AgentsPage/components/AgentPageHeader.stories.tsx new file mode 100644 index 0000000000..9305a168db --- /dev/null +++ b/site/src/pages/AgentsPage/components/AgentPageHeader.stories.tsx @@ -0,0 +1,231 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { type FC, useMemo, useState } from "react"; +import { Outlet } from "react-router"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { withDashboardProvider } from "#/testHelpers/storybook"; +import { AgentPageHeader } from "./AgentPageHeader"; +import { ChimeButton } from "./ChimeButton"; +import { WebPushButton } from "./WebPushButton"; + +type MediaChangeListener = (event: MediaQueryListEvent) => void; + +const createMatchMediaController = (initialDesktop: boolean) => { + let desktop = initialDesktop; + const listeners = new Set(); + const eventListenerWrappers = new Map< + EventListenerOrEventListenerObject, + MediaChangeListener + >(); + + const getWrappedEventListener = ( + listener: EventListenerOrEventListenerObject | null, + ): MediaChangeListener | undefined => { + if (!listener) { + return undefined; + } + + const existing = eventListenerWrappers.get(listener); + if (existing) { + return existing; + } + + const wrapped: MediaChangeListener = (event) => { + if (typeof listener === "function") { + listener(event); + return; + } + listener.handleEvent(event); + }; + + eventListenerWrappers.set(listener, wrapped); + return wrapped; + }; + + const dispatch = (): void => { + const event = { + matches: desktop, + media: "(min-width: 768px)", + } as MediaQueryListEvent; + for (const listener of listeners) { + listener(event); + } + }; + + const matchMedia = ((query: string): MediaQueryList => { + const isDesktopQuery = /\(\s*min-width\s*:\s*768px\s*\)/.test(query); + return { + matches: isDesktopQuery ? desktop : false, + media: query, + onchange: null, + addEventListener: ( + _type: string, + listener: EventListenerOrEventListenerObject | null, + ) => { + if (isDesktopQuery) { + const wrapped = getWrappedEventListener(listener); + if (wrapped) { + listeners.add(wrapped); + } + } + }, + removeEventListener: ( + _type: string, + listener: EventListenerOrEventListenerObject | null, + ) => { + if (isDesktopQuery) { + const wrapped = getWrappedEventListener(listener); + if (wrapped) { + listeners.delete(wrapped); + } + if (listener) { + eventListenerWrappers.delete(listener); + } + } + }, + dispatchEvent: () => true, + addListener: (listener: MediaChangeListener) => { + if (isDesktopQuery) { + listeners.add(listener); + } + }, + removeListener: (listener: MediaChangeListener) => { + if (isDesktopQuery) { + listeners.delete(listener); + } + }, + }; + }) as typeof window.matchMedia; + + return { + matchMedia, + setDesktop: (value: boolean) => { + desktop = value; + dispatch(); + }, + }; +}; + +const HeaderStateHarness: FC = () => { + const [chimeEnabled, setChimeEnabled] = useState(true); + const [webpushSubscribed, setWebpushSubscribed] = useState(false); + const [webpushLoading, setWebpushLoading] = useState(false); + + const webPush = useMemo( + () => ({ + enabled: true, + subscribed: webpushSubscribed, + loading: webpushLoading, + subscribe: async () => { + setWebpushLoading(true); + await Promise.resolve(); + setWebpushSubscribed(true); + setWebpushLoading(false); + }, + unsubscribe: async () => { + setWebpushLoading(true); + await Promise.resolve(); + setWebpushSubscribed(false); + setWebpushLoading(false); + }, + }), + [webpushLoading, webpushSubscribed], + ); + + const handleNotificationToggle = async () => { + if (webpushSubscribed) { + await webPush.unsubscribe(); + } else { + await webPush.subscribe(); + } + }; + + return ( + setChimeEnabled((enabled) => !enabled)} + webPush={webPush} + onToggleNotifications={handleNotificationToggle} + > + setChimeEnabled((enabled) => !enabled)} + /> + + + ); +}; + +const meta: Meta = { + title: "pages/AgentsPage/AgentPageHeader", + component: AgentPageHeader, + decorators: [withDashboardProvider], + beforeEach: () => { + const originalMatchMedia = window.matchMedia; + const controller = createMatchMediaController(true); + window.matchMedia = controller.matchMedia; + + return () => { + window.matchMedia = originalMatchMedia; + }; + }, +}; + +export default meta; +type Story = StoryObj; + +export const ToggleStateStaysInSyncAcrossBreakpoints: Story = { + render: () => , + parameters: { + reactRouter: { + location: { + path: "/agents", + }, + routing: [ + { + path: "/", + element: ( + undefined, + }} + /> + ), + children: [{ path: "agents", useStoryElement: true }], + }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const desktopSoundButton = await canvas.findByRole("button", { + name: "Mute completion chime", + }); + await userEvent.click(desktopSoundButton); + await waitFor(() => { + expect( + canvas.getByRole("button", { name: "Enable completion chime" }), + ).toBeVisible(); + }); + + const desktopNotificationButton = canvas.getByRole("button", { + name: "Enable notifications", + }); + await userEvent.click(desktopNotificationButton); + await waitFor(() => { + expect( + canvas.getByRole("button", { name: "Disable notifications" }), + ).toBeVisible(); + }); + + await userEvent.click( + canvas.getByRole("button", { name: "Disable notifications" }), + ); + await waitFor(() => { + expect( + canvas.getByRole("button", { name: "Enable notifications" }), + ).toBeVisible(); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/AgentPageHeader.tsx b/site/src/pages/AgentsPage/components/AgentPageHeader.tsx index 833665bab5..5083b8e9f1 100644 --- a/site/src/pages/AgentsPage/components/AgentPageHeader.tsx +++ b/site/src/pages/AgentsPage/components/AgentPageHeader.tsx @@ -1,49 +1,130 @@ import { + ArrowLeftIcon, BarChart3Icon, - ChevronLeftIcon, + BellIcon, + BellOffIcon, + EllipsisIcon, PanelLeftIcon, SettingsIcon, + Volume2Icon, + VolumeOffIcon, } from "lucide-react"; import type { FC, ReactNode } from "react"; +import { useEffect, useState } from "react"; import { Link, NavLink, useLocation, useOutletContext } from "react-router"; +import { toast } from "sonner"; +import { getErrorMessage } from "#/api/errors"; import { Button } from "#/components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "#/components/DropdownMenu/DropdownMenu"; import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; import { CoderIcon } from "#/components/Icons/CoderIcon"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { useWebpushNotifications } from "#/contexts/useWebpushNotifications"; import { useDashboard } from "#/modules/dashboard/useDashboard"; -import { cn } from "#/utils/cn"; import type { AgentsOutletContext } from "../AgentsPageView"; -import { isSettingsView, sidebarViewFromPath } from "./Sidebar/AgentsSidebar"; +import { getChimeEnabled, setChimeEnabled } from "../utils/chime"; interface AgentPageHeaderProps { children?: ReactNode; /** When set, shows a back link on mobile instead of the logo * and hides the settings/analytics nav buttons. */ mobileBack?: { to: string; label: string }; + chimeEnabled?: boolean; + onToggleChime?: () => void; + webPush?: ReturnType; + onToggleNotifications?: () => Promise | void; } export const AgentPageHeader: FC = ({ children, mobileBack, + chimeEnabled: controlledChimeEnabled, + onToggleChime, + webPush: controlledWebPush, + onToggleNotifications, }) => { const { isSidebarCollapsed, onExpandSidebar } = useOutletContext(); const { appearance } = useDashboard(); const logoUrl = appearance.logo_url; const location = useLocation(); - const sidebarView = sidebarViewFromPath(location.pathname); - const isSettingsPanel = isSettingsView(sidebarView); + const [internalChimeEnabled, setInternalChimeEnabled] = + useState(getChimeEnabled); + const internalWebPush = useWebpushNotifications(); + const chimeEnabled = controlledChimeEnabled ?? internalChimeEnabled; + const webPush = controlledWebPush ?? internalWebPush; + const [isDesktop, setIsDesktop] = useState(() => { + return window.matchMedia("(min-width: 768px)").matches; + }); + + useEffect(() => { + const mediaQuery = window.matchMedia("(min-width: 768px)"); + const onMediaChange = (event: MediaQueryListEvent) => { + setIsDesktop(event.matches); + }; + + setIsDesktop(mediaQuery.matches); + if (typeof mediaQuery.addEventListener === "function") { + mediaQuery.addEventListener("change", onMediaChange); + } else { + mediaQuery.addListener(onMediaChange); + } + return () => { + if (typeof mediaQuery.removeEventListener === "function") { + mediaQuery.removeEventListener("change", onMediaChange); + } else { + mediaQuery.removeListener(onMediaChange); + } + }; + }, []); + + const handleChimeToggle = () => { + if (onToggleChime) { + onToggleChime(); + return; + } + const next = !chimeEnabled; + setInternalChimeEnabled(next); + setChimeEnabled(next); + }; + + const handleNotificationToggle = async () => { + if (onToggleNotifications) { + await onToggleNotifications(); + return; + } + try { + if (webPush.subscribed) { + await webPush.unsubscribe(); + } else { + await webPush.subscribe(); + } + } catch (error) { + const action = webPush.subscribed ? "disable" : "enable"; + toast.error(getErrorMessage(error, `Failed to ${action} notifications.`)); + } + }; return ( -
+
{mobileBack ? ( - - - {mobileBack.label} - + + + + ) : ( {logoUrl ? ( @@ -65,41 +146,74 @@ export const AgentPageHeader: FC = ({ )}
- {/* Mobile-only nav buttons mirroring the sidebar toolbar - * which is hidden below the md breakpoint. */} - {!mobileBack && ( -
-
+ )} + {/* Mobile: meatball menu with all actions */} + {!mobileBack && !isDesktop && ( + + + + + - - - - - -
- )}{" "} - {children &&
{children}
} + + + )}
); }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/CompactOrgSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/CompactOrgSelector.tsx index e99ce0c4e1..f7bd82fa75 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/CompactOrgSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/CompactOrgSelector.tsx @@ -83,7 +83,7 @@ export const CompactOrgSelector: FC = ({ diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index febf601d82..ba12eff387 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -35,6 +35,10 @@ interface ModelSelectorProps { dropdownSide?: "top" | "bottom" | "left" | "right"; dropdownAlign?: "start" | "center" | "end"; contentClassName?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onTriggerTouchStart?: () => void; + enableMobileFullWidthDropdown?: boolean; } const defaultFormatProviderLabel = (provider: string): string => { @@ -78,6 +82,10 @@ export const ModelSelector: FC = ({ dropdownSide = "bottom", dropdownAlign = "start", contentClassName, + open, + onOpenChange, + onTriggerTouchStart, + enableMobileFullWidthDropdown = false, }) => { const selectedModel = options.find((option) => option.id === value); const optionsByProvider = (() => { @@ -97,13 +105,20 @@ export const ModelSelector: FC = ({ const isDisabled = disabled || options.length === 0; return ( - svg]:transition-colors [&>svg]:hover:text-content-primary", + "h-8 min-w-0 shrink md:shrink-0 md:w-auto gap-0.5 md:gap-1.5 border-0 bg-transparent px-1 text-xs shadow-none transition-colors hover:bg-transparent hover:text-content-primary focus:ring-0 [&>span]:truncate [&>svg]:shrink-0 [&>svg]:transition-colors [&>svg]:hover:text-content-primary", className, )} + onTouchStart={onTriggerTouchStart} > {selectedModel ? getOptionLabel(selectedModel) : placeholder} @@ -112,7 +127,12 @@ export const ModelSelector: FC = ({ {optionsByProvider.map(([provider, providerOptions]) => { @@ -124,6 +144,7 @@ export const ModelSelector: FC = ({ key={option.id} option={option} providerLabel={providerLabel} + isSelected={option.id === value} /> ))} @@ -143,24 +164,49 @@ export const ModelSelector: FC = ({ interface ModelOptionItemProps { option: ModelSelectorOption; providerLabel: string; + isSelected: boolean; } const ModelOptionItem: FC = ({ option, providerLabel, + isSelected, }) => { + const label = getOptionLabel(option); + const contextInfo = + option.contextLimit != null && option.contextLimit > 0 + ? formatContextLimit(option.contextLimit) + : null; + const subtext = contextInfo + ? `via ${providerLabel}, ${contextInfo}` + : `via ${providerLabel}`; + return ( - {getOptionLabel(option)} + + + {label} + + {subtext} + + + - + - {getOptionLabel(option)} via {providerLabel} + {label} via {providerLabel} - {option.contextLimit != null && option.contextLimit > 0 && ( + {contextInfo && ( - {formatContextLimit(option.contextLimit)} + {contextInfo} )} diff --git a/site/src/pages/AgentsPage/components/ChatTopBar.tsx b/site/src/pages/AgentsPage/components/ChatTopBar.tsx index 59892188cf..e30a69c5f2 100644 --- a/site/src/pages/AgentsPage/components/ChatTopBar.tsx +++ b/site/src/pages/AgentsPage/components/ChatTopBar.tsx @@ -188,7 +188,7 @@ export const ChatTopBar: FC = ({ {!isArchived && onRegenerateTitle && ( <> diff --git a/site/src/pages/AgentsPage/components/ChimeButton.tsx b/site/src/pages/AgentsPage/components/ChimeButton.tsx index e2fff0df27..a1db1cd5b0 100644 --- a/site/src/pages/AgentsPage/components/ChimeButton.tsx +++ b/site/src/pages/AgentsPage/components/ChimeButton.tsx @@ -8,12 +8,23 @@ import { } from "#/components/Tooltip/Tooltip"; import { getChimeEnabled, setChimeEnabled } from "../utils/chime"; -export const ChimeButton: FC = () => { - const [enabled, setEnabled] = useState(getChimeEnabled); +interface ChimeButtonProps { + enabled?: boolean; + onToggle?: () => void; +} + +export const ChimeButton: FC = ({ enabled, onToggle }) => { + const [internalEnabled, setInternalEnabled] = useState(getChimeEnabled); + const isControlled = enabled !== undefined && onToggle !== undefined; + const isEnabled = isControlled ? enabled : internalEnabled; const handleClick = () => { - const next = !enabled; - setEnabled(next); + if (isControlled) { + onToggle(); + return; + } + const next = !internalEnabled; + setInternalEnabled(next); setChimeEnabled(next); }; @@ -25,11 +36,11 @@ export const ChimeButton: FC = () => { size="icon" onClick={handleClick} aria-label={ - enabled ? "Mute completion chime" : "Enable completion chime" + isEnabled ? "Mute completion chime" : "Enable completion chime" } className="h-7 w-7 text-content-secondary hover:text-content-primary" > - {enabled ? ( + {isEnabled ? ( ) : ( @@ -37,7 +48,7 @@ export const ChimeButton: FC = () => { - {enabled ? "Disable completion sound" : "Enable completion sound"} + {isEnabled ? "Disable completion sound" : "Enable completion sound"} ); diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx index 7187adfa6d..5085cd883a 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx @@ -262,7 +262,10 @@ export const ContextUsageIndicator: FC<{ usage: AgentContextUsage | null }> = ({ return ( {triggerButton} - + {panelContent} diff --git a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx index fafacadc1a..488c0595be 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/AgentsSidebar.tsx @@ -20,11 +20,11 @@ import { AlertTriangleIcon, ArchiveIcon, ArchiveRestoreIcon, + ArrowLeftIcon, BotIcon, BoxesIcon, CheckIcon, ChevronDownIcon, - ChevronLeftIcon, ChevronRightIcon, CoinsIcon, EllipsisIcon, @@ -598,7 +598,7 @@ const ChatTreeNode: FC = ({ chat, isChildNode }) => {
= (props) => { onArchivedFilterChange?.("active")}> Active @@ -1127,147 +1127,157 @@ export const AgentsSidebar: FC = (props) => { disabled={isCreating} />
- -
- {loadError ? ( -
- - {onRetryLoad && ( - - )} -
- ) : isLoading ? ( - <> - -
- {Array.from({ length: 6 }, (_, i) => ( -
- -
- - -
-
- ))} +
+ +
+ {loadError ? ( +
+ + {onRetryLoad && ( + + )}
- - ) : ( - - {visibleRootIDs.length === 0 ? ( -
-

- {normalizedSearch - ? "No matching agents" - : archivedFilter === "archived" - ? "No archived agents" - : "No agents yet"} -

- + ) : isLoading ? ( + <> + +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ +
+ + +
+
+ ))}
- ) : ( -
- {visibleRootIDs.length > 0 && ( -
- {/* ── Pinned section ── */} - {pinnedChats.length > 0 && ( -
-
- Pinned - {showFilterOnPinned && filterDropdown} -
- - + ) : ( + + {visibleRootIDs.length === 0 ? ( +
+

+ {normalizedSearch + ? "No matching agents" + : archivedFilter === "archived" + ? "No archived agents" + : "No agents yet"} +

+ +
+ ) : ( +
+ {visibleRootIDs.length > 0 && ( +
+ {/* ── Pinned section ── */} + {pinnedChats.length > 0 && ( +
+
+ Pinned + {showFilterOnPinned && filterDropdown} +
+ -
- {sortedPinnedChats.map((chat) => ( - + {sortedPinnedChats.map((chat) => ( + + ))} +
+ +
+
+ )} + {/* ── Time-grouped sections ── */} + {TIME_GROUPS.map((group) => { + const groupChats = visibleRootIDs + .map((id) => chatById.get(id)) + .filter( + (chat): chat is Chat => + chat !== undefined && + getTimeGroup(chat.updated_at) === group && + chat.pin_order === 0, + ); + if (groupChats.length === 0) return null; + return ( +
+
+ {group} + {group === firstNonEmptyGroup && + filterDropdown} +
+
+ {groupChats.map((chat) => ( + ))}
- - -
- )} - {/* ── Time-grouped sections ── */} - {TIME_GROUPS.map((group) => { - const groupChats = visibleRootIDs - .map((id) => chatById.get(id)) - .filter( - (chat): chat is Chat => - chat !== undefined && - getTimeGroup(chat.updated_at) === group && - chat.pin_order === 0, +
); - if (groupChats.length === 0) return null; - return ( -
-
- {group} - {group === firstNonEmptyGroup && filterDropdown} -
-
- {groupChats.map((chat) => ( - - ))} -
-
- ); - })} -
- )} -
- )} - {(hasNextPage || isFetchingNextPage) && ( - - )} - - )} -
- + })} +
+ )} +
+ )} + {(hasNextPage || isFetchingNextPage) && ( + + )} +
+ )} +
+
+
@@ -1338,13 +1348,13 @@ export const AgentsSidebar: FC = (props) => { state={location.state} aria-label="Back to Settings" > - + ) : ( - + )} diff --git a/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx b/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx index 28e8cfa933..cff9fcdd68 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx @@ -1,10 +1,10 @@ import { + ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, MaximizeIcon, MinimizeIcon, PanelLeftIcon, - XIcon, } from "lucide-react"; import type { ReactNode } from "react"; import { type FC, useEffect, useId, useRef, useState } from "react"; @@ -154,8 +154,19 @@ export const SidebarTabView: FC = ({ {/* Tab bar – always visible for the expand button. */}
+ {onClose && ( + + )}
{isExpanded && chatTitle && ( @@ -163,17 +174,6 @@ export const SidebarTabView: FC = ({ )}
- {onClose && ( - - )} + )} {/* Sidebar toggle – only when expanded and sidebar is collapsed */} {isExpanded && isSidebarCollapsed && onToggleSidebarCollapsed && (
)} - {/* Right side: close (mobile) / expand (desktop) */} - {onClose && ( - - )} + {/* Expand/collapse (desktop only) */} - {webPush.subscribed ? "Disable notifications" : "Enable notifications"} + {webPushState.subscribed + ? "Disable notifications" + : "Enable notifications"} ); diff --git a/site/src/pages/AgentsPage/components/WorkspacePill.tsx b/site/src/pages/AgentsPage/components/WorkspacePill.tsx index dba9558045..2d71327e0f 100644 --- a/site/src/pages/AgentsPage/components/WorkspacePill.tsx +++ b/site/src/pages/AgentsPage/components/WorkspacePill.tsx @@ -92,30 +92,36 @@ export const WorkspacePill: FC = ({ type="button" aria-label={`${workspace.name} workspace menu`} className={cn( - "inline-flex min-w-[2.75rem] max-w-[200px] items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content-secondary overflow-hidden", + "inline-flex min-w-0 items-center gap-1 rounded-full bg-surface-secondary text-xs font-medium text-content-secondary overflow-hidden md:min-w-[2.75rem]", "cursor-pointer border-0 transition-colors hover:bg-surface-tertiary hover:text-content-primary", + "size-7 justify-center p-0 md:size-auto md:max-w-[200px] md:justify-start md:px-2 md:py-0.5", )} > - - {workspace.name} - {/* The menu opens upward (side="top"), so the chevron - points away from the menu when closed (default) and - toward it when open (rotate-180). */} + + + {workspace.name} + - {statusLabel} + + {statusLabel} + + {hasVSCode && ( { } return window.matchMedia("(max-width: 639px)").matches; }; + +/** + * Returns `true` when the viewport width is below the `md` Tailwind + * breakpoint (< 768 px). Use this for layout branching that needs to + * align with `md:` Tailwind utilities (e.g. the mobile full-width + * dropdown / inline menu layout), so that viewports between 640 and + * 767 px (common on landscape phones and small tablets) pick the + * mobile branch instead of the desktop flyout branch. + */ +export const isBelowMdViewport = (): boolean => { + if (typeof window === "undefined" || !window.matchMedia) { + return false; + } + return window.matchMedia("(max-width: 767px)").matches; +};