diff --git a/dashboard/app/(product)/app/layout.tsx b/dashboard/app/(product)/app/layout.tsx index 47f22a84..b8e0268c 100644 --- a/dashboard/app/(product)/app/layout.tsx +++ b/dashboard/app/(product)/app/layout.tsx @@ -8,6 +8,8 @@ import { AccountMenu } from "@/components/product/AccountMenu"; import { NotificationsBell } from "@/components/product/NotificationsBell"; import { CommandSearch } from "@/components/product/CommandSearch"; import { SideNav } from "@/components/product/SideNav"; +import { MobileMenu } from "@/components/product/MobileMenu"; +import { TableLabels } from "@/components/product/TableLabels"; import { AppChrome } from "@/components/product/AppChrome"; import { Crumbs } from "@/components/product/Crumbs"; import { WORKSPACE_NAV } from "@/lib/product/nav"; @@ -131,6 +133,8 @@ export default async function AppLayout({ children }: { children: React.ReactNod guard your agents + + }> {signedIn ? : } diff --git a/dashboard/app/af-theme.css b/dashboard/app/af-theme.css index 1adb3064..bc0ca51f 100644 --- a/dashboard/app/af-theme.css +++ b/dashboard/app/af-theme.css @@ -643,3 +643,149 @@ a.gm-add { align-self: flex-start; text-decoration: none; } .lib-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; } .lib-foot { margin-top: auto; } .lib-default { border-color: color-mix(in srgb, var(--af-brand) 35%, var(--af-line)); } + +/* ── Phones (≤760px) ───────────────────────────────────────────────────────── + * The rail becomes a top bar: the mark, the project switcher and a Menu button + * (MobileMenu). The nav opens below it as a full, labelled list with 44px rows, + * rather than main's row of unlabelled icons. Pages get a 16px gutter. */ +.side-menu-btn { display: none; } +@media (max-width: 760px) { + nav.side { + width: 100%; flex-basis: auto; flex-direction: row; flex-wrap: wrap; align-items: center; + gap: 6px; padding: 6px 12px; overflow: visible; + position: sticky; top: 0; z-index: 40; + border-right: 0; border-bottom: 1px solid var(--af-line); + background: var(--af-bg); backdrop-filter: none; -webkit-backdrop-filter: none; + } + nav.side .brand { border-bottom: 0; padding: 4px 2px; flex: none; } + nav.side .brand-text { display: none; } + nav.side .project-switch { flex: 1 1 auto; min-width: 0; margin: 0; } + nav.side .project-switch-btn { padding: 7px 10px; width: 100%; } + nav.side .project-switch-name small { display: none; } + nav.side .project-switch-menu { position: fixed; top: 56px; left: 12px; right: 12px; min-width: 0; max-width: none; width: auto; } + .side-menu-btn { + display: inline-flex; align-items: center; gap: 6px; flex: none; + min-height: 40px; padding: 0 12px; border: 1px solid var(--af-line-2); border-radius: 3px; + background: transparent; color: var(--af-fg); font: inherit; font-size: 13px; cursor: pointer; + } + /* The nav groups and the foot: hidden until Menu is opened, then a full-width list. */ + /* As specific as globals.css's `display: contents` rule for the same divs, plus .shell. */ + .shell nav.side > div:not(.brand):not(.theme-toggle):not(.project-switch) { display: none; order: 4; } + .shell nav.side[data-open] > div:not(.brand):not(.theme-toggle):not(.project-switch) { display: block; flex-basis: 100%; } + nav.side .brand { order: 1; } + nav.side .project-switch { order: 2; } + .side-menu-btn { order: 3; } + nav.side[data-open] { max-height: 100dvh; overflow-y: auto; padding-bottom: 12px; } + nav.side[data-open] .group { display: block; padding: 12px 4px 4px; border-top: 0; } + nav.side[data-open] a { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 10px 8px; white-space: normal; font-size: 15px; } + nav.side[data-open] a span { position: static; width: auto; height: auto; margin: 0; overflow: visible; clip: auto; clip-path: none; white-space: normal; } + nav.side[data-open] .side-foot { display: flex; padding: 12px 4px 0; border-top: 1px solid var(--af-line); margin-top: 8px; } + + /* The top bar under it: crumbs on one line, the actions beside them; no email text. */ + .topbar { position: static; padding: 8px 16px; gap: 8px; flex-wrap: wrap; } + .topbar .account-summary { display: none; } + .shell main { padding: 20px 16px 48px; } +} +@media (max-width: 760px) { + /* The map: most of the screen, not a 600px box; the panel below grows with its content. */ + .gm { --gm-h: 62vh; } + .gm-canvas { min-height: 380px; } + .gm-side { height: auto; } + .gm-side > .gm-panel, .gm-side > .gm-list { max-height: none; } +} +@media (max-width: 760px) { + /* Full-bleed tables bleed into the phone gutter (16px), not the desktop one (32px); + wide ones scroll sideways inside themselves. */ + .obs-table { margin: 0 -16px; } + .obs-table th:first-child, .obs-table td:first-child { padding-left: 16px; } +} +@media (max-width: 760px) { .gm-side { scroll-margin-top: 64px; } } +@media (max-width: 760px) { + nav.side .project-switch-menu button, nav.side .project-switch-menu .project-switch-foot { min-height: 44px; } + .notif-body, .account-menu-body { position: fixed; top: auto; left: 12px; right: 12px; width: auto; max-width: none; max-height: 70dvh; overflow-y: auto; } + .account-menu-row { min-height: 44px; display: flex; align-items: center; } +} +/* The map's lines as tappable buttons, phones only (LineList). */ +.gm-lines { display: flex; flex-direction: column; gap: 8px; padding: 14px; border: 1px solid var(--af-line); border-radius: var(--af-r-card); background: var(--af-surface); } +.gm-lines-head { margin: 0; font-size: 13px; color: var(--af-fg-2); } +.gm-lines-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.gm-line-btn { display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px; min-height: 52px; padding: 8px 12px; border: 1px solid var(--af-line-2); border-radius: 8px; background: var(--af-bg); color: var(--af-fg); font: inherit; font-size: 14px; font-weight: 500; text-align: left; cursor: pointer; } +.gm-line-btn small { font-size: 12px; font-weight: 400; color: var(--af-muted); } +.gm-line-btn:active { background: var(--af-subtle); } +@media (max-width: 760px) { + /* The legend explains the map's pills, which a phone uses through the line list instead. */ + .gm-bar .gm-legend { display: none; } + .gm-bar:not(:has(.gm-bar-end)) { display: none; } +} +@media (max-width: 760px) { + /* Tables become stacked rows (TableLabels names each cell). `:root` lifts these above + globals.css's `.k-table .tight { width: 1% }`, which otherwise squeezes the chips. + Short cells as a line of chips, the row's title in full, then each other cell + under its column's name. */ + :root .shell main :is(table:has(> thead), table.k-table), :root .shell main :is(table:has(> thead), table.k-table) tbody { display: block; width: 100%; min-width: 0; } + :root .shell main :is(table:has(> thead), table.k-table) thead { display: none; } + :root .shell main :is(table:has(> thead), table.k-table) tr { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 12px 14px; border-bottom: 1px solid var(--af-line); } + :root .shell main :is(table:has(> thead), table.k-table) tr:last-child { border-bottom: 0; } + :root .shell main :is(table:has(> thead), table.k-table) td { display: block; width: auto; min-width: 0; height: auto; padding: 0; border: 0; white-space: normal; text-align: left; overflow-wrap: anywhere; } + :root .shell main :is(table:has(> thead), table.k-table) td:empty { display: none; } + :root .shell main :is(table:has(> thead), table.k-table) td.tight { order: 0; } + :root .shell main :is(table:has(> thead), table.k-table) td:not(.tight) { order: 1; flex-basis: 100%; } + :root .shell main :is(table:has(> thead), table.k-table) td[data-primary] { font-size: 14px; } + /* Other wide cells: two to a line, each under its column's name. */ + :root .shell main :is(table:has(> thead), table.k-table) td:not(.tight):not([data-primary])[data-label] { flex: 1 1 calc(50% - 10px); } + :root .shell main :is(table:has(> thead), table.k-table) td:not(.tight):not([data-primary])[data-label]::before { + content: attr(data-label); display: block; margin-bottom: 1px; font-size: 11px; font-weight: 500; color: var(--af-muted); + } + :root .shell main :is(table:has(> thead), table.k-table) tbody tr:hover td { background: transparent; } +} +@media (max-width: 760px) { + /* Page titles: big enough to be the title, not half the first screen. */ + :where(.shell) h1 { font-size: 26px; line-height: 32px; } + /* Fields with a fixed desktop width (inline style) fill the line; their button wraps under. */ + .shell main input.k-input, .shell main textarea.k-input, .shell main select.k-input { max-width: 100%; } + .shell main input.k-input[style*="width"], .shell main .k-input[style*="width"] { width: 100% !important; flex: 1 1 100%; } +} +@media (max-width: 760px) { + /* List rows with actions on the right (k-list): the actions drop under the text. */ + .shell main .k-list > li { flex-wrap: wrap; } + .shell main .k-list > li > .k-list-end { flex: 1 1 100%; justify-content: flex-start !important; } +} +@media (max-width: 760px) { + /* Key/value tables (two cells a row): the key above its value, long ids wrapped. */ + .shell main table:not(.k-table) tr:has(> td:nth-child(2):last-child) > td { display: block; width: auto; max-width: none !important; white-space: normal !important; overflow-wrap: anywhere; } + .shell main table:not(.k-table) tr:has(> td:nth-child(2):last-child) > td:first-child { padding-bottom: 0; border-bottom: 0; } +} +@media (max-width: 760px) { + .shell main table:not(.k-table):has(> tbody > tr > td:nth-child(2):last-child), + .shell main table:not(.k-table):has(> tbody > tr > td:nth-child(2):last-child) > tbody, + .shell main table:not(.k-table) tr:has(> td:nth-child(2):last-child) { display: block; width: 100%; min-width: 0; } + /* globals.css gives tables in a panel a 900px floor so wide ones scroll; a key/value list stacks instead. */ + .shell main table:not(.k-table):has(> tbody > tr > td:nth-child(2):last-child) { min-width: 0; } +} +@media (max-width: 760px) { + /* New project: the connection card is the point; the empty placeholders go. */ + .np-cards { display: none; } + /* Tab rows that scroll sideways fade at the edge, so a cut-off tab reads as "more". */ + .shell main .k-tabs { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); padding-right: 24px; } + /* Library descriptions in full: the clamp hid how a library starts (watching first). */ + .lib-card p { display: block; -webkit-line-clamp: unset; overflow: visible; } + /* A check's remove button: a finger-sized target, not an 11px icon. */ + .gm-rule-checks .gm-chip button { min-width: 32px; min-height: 32px; margin: -8px -8px -8px 0; display: inline-flex; align-items: center; justify-content: center; } + /* No keyboard shortcut on a phone. */ + .topbar .cmdk-kbd { display: none; } +} +@media (max-width: 760px) { + /* Stacked, the Observe tables no longer need to bleed past the gutter or scroll. */ + .obs-table { margin: 0; overflow-x: visible; } + .obs-table td { height: auto; white-space: normal; } +} +@media (max-width: 760px) { + /* A table without a header row (a list of named things with controls): keep its own + order, the name in full first, its chips and controls on the line below. */ + :root .shell main table.k-table:not(:has(> thead)) td.tight { order: 2; } +} +@media (max-width: 760px) { + /* Attack test: Run and + at finger size; the library opens across the panel. */ + .at-icon { width: 40px; height: 40px; } + .at-lib { left: 0; right: 0; width: auto; max-width: none; } +} diff --git a/dashboard/components/product/MobileMenu.tsx b/dashboard/components/product/MobileMenu.tsx new file mode 100644 index 00000000..51ad8d89 --- /dev/null +++ b/dashboard/components/product/MobileMenu.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +/** + * The sidebar's "Menu" button, shown on phones only (see af-theme.css). On a narrow + * screen the sidebar is a top bar: the mark, the project switcher and this button; + * the nav itself opens below it as a full, labelled list. It closes when a page is + * chosen, on Escape, and when the screen widens back to the desktop rail. + */ +export function MobileMenu() { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const pathname = usePathname(); + + useEffect(() => { + const nav = ref.current?.closest("nav.side"); + if (!nav) return; + if (open) nav.setAttribute("data-open", ""); + else nav.removeAttribute("data-open"); + }, [open]); + + useEffect(() => setOpen(false), [pathname]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + const wide = window.matchMedia("(min-width: 761px)"); + const onWide = () => wide.matches && setOpen(false); + window.addEventListener("keydown", onKey); + wide.addEventListener("change", onWide); + return () => { + window.removeEventListener("keydown", onKey); + wide.removeEventListener("change", onWide); + }; + }, [open]); + + return ( + + ); +} diff --git a/dashboard/components/product/TableLabels.tsx b/dashboard/components/product/TableLabels.tsx new file mode 100644 index 00000000..7c5c0057 --- /dev/null +++ b/dashboard/components/product/TableLabels.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect } from "react"; + +/** + * Gives every table cell its column's name (`data-label`) and marks each row's + * first wide cell as its title (`data-primary`). On a phone af-theme.css turns those + * tables into stacked rows: short cells (`.tight`: time, severity, outcome) as a line + * of chips, the title in full, and every other cell under its column's name. Without + * the names a stacked "3" or "2m ago" means nothing. Desktop ignores both attributes. + */ +export function TableLabels() { + const pathname = usePathname(); + useEffect(() => { + const label = () => { + for (const table of document.querySelectorAll("main table")) { + const names = [...table.querySelectorAll("thead th")].map((th) => th.textContent?.trim() ?? ""); + for (const row of table.querySelectorAll("tbody tr")) { + let primary = false; + [...row.cells].forEach((cell, i) => { + if (names[i] && cell.dataset.label !== names[i]) cell.dataset.label = names[i]; + if (!primary && !cell.classList.contains("tight") && cell.colSpan === 1) { + cell.dataset.primary = ""; + primary = true; + } + }); + } + } + }; + // Not before React has hydrated the server's HTML: an attribute added first is a + // hydration mismatch. So wait for the page to load, then a beat more. + let seen: MutationObserver | null = null; + let wait = 0; + const start = () => { + label(); + const main = document.querySelector("main"); + if (!main) return; + // Tables arrive late (streamed, filtered, paged): label them as they do. + seen = new MutationObserver(label); + seen.observe(main, { childList: true, subtree: true }); + }; + const later = () => { + wait = window.setTimeout(start, 400); + }; + if (document.readyState === "complete") later(); + else window.addEventListener("load", later, { once: true }); + return () => { + window.removeEventListener("load", later); + window.clearTimeout(wait); + seen?.disconnect(); + }; + }, [pathname]); + return null; +} diff --git a/dashboard/components/product/agent/GuardrailMap.tsx b/dashboard/components/product/agent/GuardrailMap.tsx index b717e7be..350b8213 100644 --- a/dashboard/components/product/agent/GuardrailMap.tsx +++ b/dashboard/components/product/agent/GuardrailMap.tsx @@ -21,7 +21,7 @@ import Link from "next/link"; import { Logo } from "@/components/product/Logo"; import { useRouter } from "next/navigation"; import { Bot, Cpu, FileText, Plus, Search, User, Wrench, X, type LucideIcon } from "lucide-react"; -import { useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { ModePill, Pill } from "@/components/kit"; import { GROUP_BY, @@ -1209,6 +1209,53 @@ function Legend() { ); } +/** The lines drawn on the map, in reading order: the ones a phone lists as buttons. */ +const LINE_ORDER = ["input", "output", "prompt", "response", "tool_call", "tool_result", "context"]; + +/** + * On a phone the map's pills are too small to tap, so its lines are also listed here + * as full-size buttons: each opens the same panel as tapping the line on the map. + */ +function LineList({ map, onSelect }: { map: AgentMap; onSelect: (next: Selected) => void }) { + const lines = LINE_ORDER.map((key) => map.stages.find((s) => s.key === key)).filter((s): s is Stage => Boolean(s)); + return ( +
+

Tap a line to see and change its guardrails

+
+ {lines.map((s) => ( + + ))} + {map.tools.length > 0 && ( + + )} +
+
+ ); +} + +/** + * A phone-width screen. There one finger must scroll the page, not drag the map + * (otherwise a visitor's finger is trapped on the canvas): the map pans and zooms + * with two fingers or its +/- buttons instead. + */ +function useNarrow(): boolean { + const [narrow, setNarrow] = useState(false); + useEffect(() => { + const q = window.matchMedia("(max-width: 760px)"); + const on = () => setNarrow(q.matches); + on(); + q.addEventListener("change", on); + return () => q.removeEventListener("change", on); + }, []); + return narrow; +} + /** * One project's guardrails, drawn where they act. The user outside; inside the * project's box, each agent with its LLM, tools and documents; rails on the lines @@ -1229,6 +1276,11 @@ export function GuardrailMap({ const [active, setActive] = useState(focus); const [selected, setSelected] = useState(null); const [hovered, setHovered] = useState(null); + const narrow = useNarrow(); + // On a phone the panel sits below the map: bring it into view when a line or tool is tapped. + useEffect(() => { + if (narrow && selected) document.querySelector(".gm-side")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, [narrow, selected]); const [expanded, setExpanded] = useState>(new Set()); const [query, setQuery] = useState(""); const [by, setBy] = useState("impact"); @@ -1322,10 +1374,13 @@ export function GuardrailMap({ fitViewOptions={ maps.length > 1 ? { padding: 0.25, maxZoom: 1, nodes: laid.nodes.filter((n) => (n.id.startsWith(`${active}|`) || (n.id === "user" && laid.edges.some((e) => e.source === "user" && e.target === `${active}|agent`))) && !n.parentId).map((n) => ({ id: n.id })) } - : { padding: 0.12, maxZoom: 1 } + : { padding: narrow ? 0.04 : 0.12, maxZoom: 1 } } minZoom={0.15} maxZoom={1.5} + panOnDrag={!narrow} + zoomOnScroll={!narrow} + preventScrolling={!narrow} nodesDraggable={false} nodesConnectable={false} proOptions={{ hideAttribution: true }} @@ -1346,6 +1401,7 @@ export function GuardrailMap({
{agentSwitch} + {narrow && !selected && }