@iloveagents/foundry-web-ui 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
3
  import { NavLink, useLocation, useNavigate } from "react-router";
4
- import { Ellipsis, PanelLeft, PanelLeftOpen, SquarePen, ChevronDown, ChevronRight, GripVertical, Plus, Upload, } from "lucide-react";
4
+ import { Ellipsis, PanelLeft, PanelLeftOpen, SquarePen, ChevronDown, ChevronRight, GripVertical, Plus, Upload, ChevronLeft, } from "lucide-react";
5
5
  import { cn } from "@iloveagents/foundry-web-primitives";
6
6
  import { useSidebarStore, RAIL_WIDTH, SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH, } from "../lib/sidebar-store.js";
7
7
  import { useToolPanelStore } from "../lib/tool-panel-store.js";
@@ -83,12 +83,19 @@ const SIDEBAR_ITEM_GAP_CLASS = "gap-2";
83
83
  // ChatGPT / Linear / Notion "header sits on its children" pattern.
84
84
  // Symmetric ``py-2`` made the header float midway between sections
85
85
  // instead of belonging to the group below it.
86
- const SIDEBAR_GROUP_HEADER_PADDING =
87
- // ``border border-transparent`` for the same reason every item row
88
- // carries one: the border is part of the box model that decides where
89
- // the padding box starts. Without it the header chevron lands 1px left
90
- // of every row's disclosure cell and the column is visibly ragged.
91
- "border border-transparent pl-3 pr-3 pt-4 pb-0";
86
+ // Vertical rhythm: rows are tight (depth-0 ``py-1.5``, nested ``py-1``) and
87
+ // sections keep their ``pt-4`` gap, so what separates is the group, not
88
+ // every row; the header label sits a step brighter than the rows' muted
89
+ // text so the section reads above its items (user's test: rows were
90
+ // airy and headers lighter than their children).
91
+ const SIDEBAR_GROUP_HEADER_PADDING = "pt-4 pb-0";
92
+ // The header's own box: the hover surface spans the row exactly like an
93
+ // item row's does — same radius, same transparent border (part of the box
94
+ // model that decides where the padding box starts: without it the chevron
95
+ // lands 1px left of every row's disclosure cell) and the same horizontal
96
+ // inset, so header and rows highlight on one outline (user's test: the
97
+ // narrower inset box read as misaligned and cramped).
98
+ const SIDEBAR_GROUP_HEADER_BOX = "rounded-xl border border-transparent pl-3 pr-3";
92
99
  // Typography for the group-header LABEL text (CHATS / WORKSPACES /
93
100
  // ADMIN / …). Stays at ``text-xs`` (12px) — same size as before —
94
101
  // but bumps weight from ``font-medium`` to ``font-semibold``. The
@@ -99,10 +106,13 @@ const SIDEBAR_GROUP_HEADER_PADDING =
99
106
  // Bumping size AND weight together stacked too much visual mass
100
107
  // against the already-wide uppercase tracking — felt shouty.
101
108
  const SIDEBAR_GROUP_HEADER_LABEL_CLASS = "text-xs font-semibold uppercase tracking-wider";
102
- /** Body padding for a row that is NOT in a tree. Flat rows render no
103
- * disclosure cell, so reserving its column just pushes them right of the
104
- * sidebar's other flat chrome which is what made Activity look indented
105
- * under New Thread. */
109
+ /** Body padding for a flat row with nothing to indent under: a row in a
110
+ * group without a visible header (New Thread, Activity) sits with the
111
+ * sidebar's other flat chrome, and a focused group's heading sits under
112
+ * its header's chevron. Flat rows UNDER a visible header indent like
113
+ * depth-0 tree rows instead, so their icon sits under the header label
114
+ * and the group reads as a group (user's test: Build's rows sat level
115
+ * with their header). */
106
116
  const NAV_FLAT_BODY_CLASS = "pl-3";
107
117
  function getNavDepthOffsets(depth) {
108
118
  return NAV_DEPTH_OFFSETS[depth] ?? NAV_DEPTH_OFFSETS[NAV_DEPTH_OFFSETS.length - 1];
@@ -477,7 +487,7 @@ function NavLeafItem({ item, isActive, onClick, depth = 0, }) {
477
487
  !isDisabled &&
478
488
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
479
489
  !isDisabled &&
480
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, label: item.label, isActive: isActive && !isDisabled, depth: depth }), _jsxs("button", { type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, className: cn("flex items-center gap-2 flex-1 min-w-0 py-1.5 text-sm text-left", bodyPl, "pr-3 transition-[padding] duration-150", NAV_ROW_ACTIONS_CLEARANCE, isDisabled
490
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, label: item.label, isActive: isActive && !isDisabled, depth: depth }), _jsxs("button", { type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, className: cn("flex items-center gap-2 flex-1 min-w-0 py-1 text-sm text-left", bodyPl, "pr-3 transition-[padding] duration-150", NAV_ROW_ACTIONS_CLEARANCE, isDisabled
481
491
  ? "text-sidebar-foreground/45 cursor-default"
482
492
  : isActive
483
493
  ? ACTIVE_NAV_TEXT_CLASS
@@ -485,7 +495,7 @@ function NavLeafItem({ item, isActive, onClick, depth = 0, }) {
485
495
  ? ACTIVE_NAV_BADGE_CLASS
486
496
  : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }));
487
497
  }
488
- return (_jsxs("button", { ref: activeButtonRef, "data-active-nav-row": isActive ? "true" : undefined, "data-nav-depth": depth, type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, ...(isDisabled ? {} : draggableProps), ...(isDisabled ? {} : dropTargetProps), className: cn("relative flex items-center gap-2 w-full", "rounded-lg border border-transparent pr-3 py-1.5 text-sm text-left", bodyPl, isDisabled
498
+ return (_jsxs("button", { ref: activeButtonRef, "data-active-nav-row": isActive ? "true" : undefined, "data-nav-depth": depth, type: "button", disabled: isDisabled, onClick: isDisabled ? undefined : onClick, ...(isDisabled ? {} : draggableProps), ...(isDisabled ? {} : dropTargetProps), className: cn("relative flex items-center gap-2 w-full", "rounded-lg border border-transparent pr-3 py-1 text-sm text-left", bodyPl, isDisabled
489
499
  ? "opacity-40 cursor-default"
490
500
  : "text-sidebar-foreground/80 transition-colors hover:bg-sidebar-accent/70", !isDisabled && isActive && ACTIVE_NAV_ROW_CLASS, isDragOver &&
491
501
  !isFileDragOver &&
@@ -576,7 +586,7 @@ function NestedFolderItem({ item, depth }) {
576
586
  .join("\n") || null,
577
587
  });
578
588
  navigate(item.to);
579
- }, className: cn("flex items-center gap-2 flex-1 min-w-0", bodyPl, "pr-3 py-1.5 text-sm text-left transition-[padding] duration-150", hasActions && NAV_ROW_ACTIONS_CLEARANCE, isDisabled
589
+ }, className: cn("flex items-center gap-2 flex-1 min-w-0", bodyPl, "pr-3 py-1 text-sm text-left transition-[padding] duration-150", hasActions && NAV_ROW_ACTIONS_CLEARANCE, isDisabled
580
590
  ? "text-sidebar-foreground/45 cursor-default"
581
591
  : isActive
582
592
  ? ACTIVE_NAV_TEXT_CLASS
@@ -608,7 +618,7 @@ function NestedFolderItem({ item, depth }) {
608
618
  } }, child.to + child.label));
609
619
  }), item.dnd?.canDrop() && _jsx(ContainerDropZone, { dnd: item.dnd })] }))] }));
610
620
  }
611
- function CollapsibleNavItem({ item, inTree = true }) {
621
+ function CollapsibleNavItem({ item, inTree = true, heading = false, underHeader = false, }) {
612
622
  const navigate = useNavigate();
613
623
  const currentPath = useLocation().pathname;
614
624
  const setNavContext = useAppStore((s) => s.setNavContext);
@@ -617,10 +627,19 @@ function CollapsibleNavItem({ item, inTree = true }) {
617
627
  const isActive = currentPath === item.to;
618
628
  const isAncestorActive = isInsideSubtree && !isActive;
619
629
  const [manualOverride, setManualOverride] = useState(null);
620
- const isExpanded = manualOverride ?? isInsideSubtree;
630
+ // A heading is never collapsed and carries no chevron: its children ARE
631
+ // the tree it stands for.
632
+ const isExpanded = heading ? true : (manualOverride ?? isInsideSubtree);
621
633
  const hasChildren = Boolean(item.children && item.children.length > 0);
622
634
  const isDisabled = item.disabled === true;
623
635
  const hasActions = Boolean(!isDisabled && item.actions && item.actions.length > 0);
636
+ const rowInTree = inTree && !heading;
637
+ const bodyClass = rowInTree
638
+ ? getNavDepthOffsets(0).body
639
+ : heading || !underHeader
640
+ ? NAV_FLAT_BODY_CLASS
641
+ : getNavDepthOffsets(0).body;
642
+ const headingClass = heading && !isDisabled && "font-semibold text-sidebar-foreground";
624
643
  const { draggableProps, dropTargetProps, isDragOver, isFileDragOver } = useNavItemDnd(item.dnd);
625
644
  const rendersRow = Boolean(hasChildren || hasActions);
626
645
  const activeRowRef = useActiveNavRowScroll(isActive && rendersRow);
@@ -635,7 +654,7 @@ function CollapsibleNavItem({ item, inTree = true }) {
635
654
  !isDisabled &&
636
655
  "ring-2 ring-inset ring-sidebar-ring/50 bg-sidebar-selected", isFileDragOver &&
637
656
  !isDisabled &&
638
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: hasChildren, childrenUnloaded: item.childrenUnloaded, inTree: inTree, isExpanded: isExpanded, onToggle: hasChildren ? () => setManualOverride(!isExpanded) : undefined, label: item.label, isActive: (selected || ancestor) && !isDisabled, depth: 0 }), _jsxs("button", { type: "button", onClick: () => {
657
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && !isDisabled && "opacity-50"), children: [_jsx(NavTwisty, { hasChildren: hasChildren && !heading, childrenUnloaded: item.childrenUnloaded, inTree: rowInTree, isExpanded: isExpanded, onToggle: hasChildren && !heading ? () => setManualOverride(!isExpanded) : undefined, label: item.label, isActive: (selected || ancestor) && !isDisabled, depth: 0 }), _jsxs("button", { type: "button", onClick: () => {
639
658
  if (isDisabled)
640
659
  return;
641
660
  setNavContext({
@@ -650,36 +669,36 @@ function CollapsibleNavItem({ item, inTree = true }) {
650
669
  if (hasChildren)
651
670
  setManualOverride(true);
652
671
  navigate(item.to);
653
- }, className: cn("flex items-center gap-2 flex-1 min-w-0 py-2 text-sm text-left", inTree ? getNavDepthOffsets(0).body : NAV_FLAT_BODY_CLASS, "pr-3 transition-[padding] duration-150", hasActions && NAV_ROW_ACTIONS_CLEARANCE, isDisabled
672
+ }, className: cn("flex items-center gap-2 flex-1 min-w-0 py-1.5 text-sm text-left", bodyClass, "pr-3 transition-[padding] duration-150", hasActions && NAV_ROW_ACTIONS_CLEARANCE, isDisabled
654
673
  ? "text-sidebar-foreground/45 cursor-default"
655
674
  : selected
656
675
  ? ACTIVE_NAV_TEXT_CLASS
657
676
  : ancestor
658
677
  ? "text-sidebar-foreground font-medium"
659
- : "text-sidebar-foreground/85"), disabled: isDisabled, children: [_jsx(item.icon, { className: cn("size-4 shrink-0", selected && !isDisabled
678
+ : "text-sidebar-foreground/85", headingClass), disabled: isDisabled, children: [_jsx(item.icon, { className: cn("size-4 shrink-0", selected && !isDisabled
660
679
  ? ACTIVE_NAV_ICON_CLASS
661
680
  : ancestor
662
681
  ? "text-sidebar-foreground/80"
663
682
  : "text-sidebar-icon") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", selected
664
683
  ? ACTIVE_NAV_BADGE_CLASS
665
684
  : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot })] }), isFileDragOver && !isDisabled && (_jsx(Upload, { "aria-hidden": "true", className: "size-3.5 mr-2 shrink-0 text-primary pointer-events-none" })), _jsx(NavRowControls, { item: item })] }));
666
- return (_jsxs("div", { ...(isDisabled ? {} : dropTargetProps), ...(isDisabled ? {} : draggableProps), children: [inner(isActive, isAncestorActive), hasChildren && isExpanded && (_jsxs(_Fragment, { children: [_jsx(SubNavItems, { items: item.children, parentPath: item.to }), item.dnd?.canDrop() && _jsx(ContainerDropZone, { dnd: item.dnd })] }))] }));
685
+ return (_jsxs("div", { ...(isDisabled ? {} : dropTargetProps), ...(isDisabled ? {} : draggableProps), children: [inner(isActive, isAncestorActive), hasChildren && isExpanded && (_jsxs(_Fragment, { children: [_jsx(SubNavItems, { items: item.children, parentPath: item.to, depth: heading ? 0 : 1 }), item.dnd?.canDrop() && _jsx(ContainerDropZone, { dnd: item.dnd })] }))] }));
667
686
  }
668
687
  if (isDisabled) {
669
- return (_jsxs("div", { "data-nav-depth": 0, className: cn("relative flex items-center gap-2 w-full opacity-40", inTree ? getNavDepthOffsets(0).body : NAV_FLAT_BODY_CLASS,
688
+ return (_jsxs("div", { "data-nav-depth": 0, className: cn("relative flex items-center gap-2 w-full opacity-40", bodyClass,
670
689
  // Transparent border for box-model parity — see the NavLink variant.
671
- "rounded-lg border border-transparent pr-3 py-2 text-left text-sm text-sidebar-foreground/45"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, inTree: inTree, label: item.label, depth: 0 }), _jsx(item.icon, { className: "size-4 shrink-0 text-sidebar-foreground/45" }), _jsx("span", { className: "truncate", children: item.label })] }));
690
+ "rounded-lg border border-transparent pr-3 py-1.5 text-left text-sm text-sidebar-foreground/45"), children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, inTree: rowInTree, label: item.label, depth: 0 }), _jsx(item.icon, { className: "size-4 shrink-0 text-sidebar-foreground/45" }), _jsx("span", { className: "truncate", children: item.label })] }));
672
691
  }
673
- return (_jsx(NavLink, { ref: activeLinkRef, "data-active-nav-row": currentPath === item.to ? "true" : undefined, "data-nav-depth": 0, to: item.to, ...draggableProps, ...dropTargetProps, className: ({ isActive }) => cn("relative flex items-center gap-2 w-full", inTree ? getNavDepthOffsets(0).body : NAV_FLAT_BODY_CLASS,
692
+ return (_jsx(NavLink, { ref: activeLinkRef, "data-active-nav-row": currentPath === item.to ? "true" : undefined, "data-nav-depth": 0, to: item.to, ...draggableProps, ...dropTargetProps, className: ({ isActive }) => cn("relative flex items-center gap-2 w-full", bodyClass,
674
693
  // The transparent border is NOT decoration: it is what makes this
675
694
  // row's box model match every other variant. An absolutely
676
695
  // positioned twisty resolves against the PADDING box, so a row
677
696
  // without the border puts its disclosure cell 1px left of its
678
697
  // siblings' and the column goes ragged.
679
- "rounded-xl border border-transparent pr-3 py-2 text-sm text-left", "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70", isActive && cn(ACTIVE_NAV_ROW_CLASS, ACTIVE_NAV_TEXT_CLASS), isDragOver &&
698
+ "rounded-xl border border-transparent pr-3 py-1.5 text-sm text-left", "text-sidebar-foreground/85 transition-colors hover:bg-sidebar-accent/70", headingClass, isActive && cn(ACTIVE_NAV_ROW_CLASS, ACTIVE_NAV_TEXT_CLASS), isDragOver &&
680
699
  !isFileDragOver &&
681
700
  "bg-sidebar-selected ring-2 ring-inset ring-sidebar-ring/50", isFileDragOver &&
682
- "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && "opacity-50"), children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, inTree: inTree, label: item.label, isActive: isActive, depth: 0 }), _jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-icon") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
701
+ "outline-dashed outline-2 -outline-offset-2 outline-primary bg-primary/10", item.dimmed && "opacity-50"), children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx(NavTwisty, { hasChildren: false, childrenUnloaded: item.childrenUnloaded, inTree: rowInTree, label: item.label, isActive: isActive, depth: 0 }), _jsx(item.icon, { className: cn("size-4 shrink-0", isActive ? ACTIVE_NAV_ICON_CLASS : "text-sidebar-icon") }), _jsx("span", { className: "truncate", children: item.label }), item.badge && (_jsx("span", { className: cn("shrink-0 rounded px-1 py-0.5 text-[10px] font-medium", isActive
683
702
  ? ACTIVE_NAV_BADGE_CLASS
684
703
  : "bg-sidebar-accent text-sidebar-accent-foreground"), children: item.badge })), _jsx(NavStatusIcon, { statusIcon: item.statusIcon }), _jsx(NavStatusDot, { statusDot: item.statusDot }), isFileDragOver && (_jsx(Upload, { "aria-hidden": "true", className: "ml-auto size-3.5 shrink-0 text-primary pointer-events-none" }))] })) }));
685
704
  }
@@ -847,7 +866,26 @@ function NavGroupSection({ group }) {
847
866
  // hideLabel + collapsible are mutually exclusive — a hidden label
848
867
  // can't host a chevron. The collapsible flag is the opt-in; legacy
849
868
  // groups stay uncollapsable so behaviour is unchanged.
850
- const isCollapsible = !group.hideLabel && Boolean(group.collapsible);
869
+ // Focus see ``NavGroup.focusOn``: the group draws one item as a heading
870
+ // under a header that leads back. Needs a visible label to lead back
871
+ // from and a ``to`` to lead back to; otherwise it draws as usual.
872
+ const focusedItem = group.focusOn !== undefined && !group.hideLabel && group.to
873
+ ? group.items.find((item) => item.to === group.focusOn)
874
+ : undefined;
875
+ const focused = focusedItem !== undefined;
876
+ // The new shape fades in over the old one's place: keyed on the focus,
877
+ // and only after a CHANGE in either direction — not on first paint
878
+ // (user's test: the hard swap felt abrupt). The previous key is read
879
+ // during render and written after it, so a focus that returns to its
880
+ // initial value still counts as a change.
881
+ const focusKey = focused ? focusedItem.to : "";
882
+ const previousFocusKey = useRef(focusKey);
883
+ const animateRows = previousFocusKey.current !== focusKey;
884
+ useEffect(() => {
885
+ previousFocusKey.current = focusKey;
886
+ });
887
+ // Focus takes precedence over collapsing: a focused header is the way back.
888
+ const isCollapsible = !group.hideLabel && Boolean(group.collapsible) && !focused;
851
889
  const persistedCollapsed = useGroupCollapsed(group.label, group.defaultCollapsed);
852
890
  const collapsed = isCollapsible && persistedCollapsed;
853
891
  // Bubble: show the first child statusDot on the collapsed header so
@@ -877,7 +915,7 @@ function NavGroupSection({ group }) {
877
915
  // to hit a tiny target. The clickable invisible padding
878
916
  // OVERLAPS the gap rather than pushing the label right
879
917
  // (negative-margin + extra padding cancel out).
880
- _jsxs("div", { className: cn("group/header flex items-center justify-between rounded-lg transition-colors hover:bg-sidebar-accent/70", SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("button", { type: "button", "data-nav-group-toggle": true, onClick: () => toggleGroupCollapsed(group.label, persistedCollapsed), "aria-expanded": !collapsed, "aria-label": collapsed ? `Expand ${group.label}` : `Collapse ${group.label}`,
918
+ _jsxs("div", { "data-nav-group-header": true, className: cn("group/header flex items-center justify-between transition-colors hover:bg-sidebar-accent/70", SIDEBAR_GROUP_HEADER_BOX, SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("button", { type: "button", "data-nav-group-toggle": true, onClick: () => toggleGroupCollapsed(group.label, persistedCollapsed), "aria-expanded": !collapsed, "aria-label": collapsed ? `Expand ${group.label}` : `Collapse ${group.label}`,
881
919
  // Visual cell width = ``SIDEBAR_ITEM_ICON_CLASS`` (14px)
882
920
  // so the chevron sits in the same column as item icons
883
921
  // and the group label aligns with item labels. The
@@ -888,20 +926,25 @@ function NavGroupSection({ group }) {
888
926
  // visual feedback even on near-misses.
889
927
  className: cn("inline-flex items-center justify-center rounded text-sidebar-foreground/50 hover:text-sidebar-foreground", SIDEBAR_ITEM_ICON_CLASS), children: collapsed ? (_jsx(ChevronRight, { className: "size-3.5 transition-transform" })) : (_jsx(ChevronDown, { className: "size-3.5 transition-transform" })) }), group.to ? (_jsx(NavLink, { to: group.to, end: true, className: "flex min-w-0 flex-1 items-center gap-1.5 py-1.5", children: ({ isActive }) => (_jsxs(_Fragment, { children: [_jsx("span", { className: cn("truncate", SIDEBAR_GROUP_HEADER_LABEL_CLASS, isActive
890
928
  ? "text-sidebar-selected-foreground"
891
- : "text-sidebar-foreground/65 group-hover/header:text-sidebar-foreground"), children: group.label }), _jsx(NavStatusDot, { statusDot: bubbledStatusDot })] })) })) : (_jsxs("button", { type: "button", onClick: () => toggleGroupCollapsed(group.label, persistedCollapsed), className: "flex min-w-0 flex-1 items-center gap-1.5 py-1.5 text-left", children: [_jsx("span", { className: cn("truncate text-sidebar-foreground/65 group-hover/header:text-sidebar-foreground", SIDEBAR_GROUP_HEADER_LABEL_CLASS), children: group.label }), _jsx(NavStatusDot, { statusDot: bubbledStatusDot })] })), group.createActions && group.createActions.length > 0 && (_jsx(CreateActionsMenu, { group: group }))] })) : group.to ? (
929
+ : "text-sidebar-foreground/75 group-hover/header:text-sidebar-foreground"), children: group.label }), _jsx(NavStatusDot, { statusDot: bubbledStatusDot })] })) })) : (_jsxs("button", { type: "button", onClick: () => toggleGroupCollapsed(group.label, persistedCollapsed), className: "flex min-w-0 flex-1 items-center gap-1.5 py-1.5 text-left", children: [_jsx("span", { className: cn("truncate text-sidebar-foreground/75 group-hover/header:text-sidebar-foreground", SIDEBAR_GROUP_HEADER_LABEL_CLASS), children: group.label }), _jsx(NavStatusDot, { statusDot: bubbledStatusDot })] })), group.createActions && group.createActions.length > 0 && (_jsx(CreateActionsMenu, { group: group }))] })) : focused ? (
930
+ // The focused header is the way back: the collapse chevron's
931
+ // cell holds a back chevron, the label links to the group's
932
+ // page, and the metrics are the collapsible header's — so the
933
+ // header stays put, in the same type, when the group focuses.
934
+ _jsxs("div", { "data-nav-group-header": true, className: cn("flex items-center justify-between py-1.5 transition-colors hover:bg-sidebar-accent/70", SIDEBAR_GROUP_HEADER_BOX, SIDEBAR_ITEM_GAP_CLASS), children: [_jsxs(NavLink, { to: group.to, end: true, "data-nav-group-back": true, title: group.description, className: cn("flex min-w-0 flex-1 items-center", SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("span", { "aria-hidden": "true", className: cn(SIDEBAR_ITEM_ICON_CLASS, "flex items-center justify-center"), children: _jsx(ChevronLeft, { className: "size-3.5 text-sidebar-foreground/75" }) }), _jsx("span", { className: cn("truncate", SIDEBAR_GROUP_HEADER_LABEL_CLASS, "text-sidebar-foreground/75 hover:text-sidebar-foreground"), children: group.label })] }), group.createActions && group.createActions.length > 0 && (_jsx(CreateActionsMenu, { group: group }))] })) : group.to ? (
892
935
  // Non-collapsible header: no chevron, but we still emit an
893
936
  // invisible spacer of ``SIDEBAR_ITEM_ICON_CLASS`` width + the
894
937
  // shared gap so the group label lands in the same column as
895
938
  // collapsible headers and item labels.
896
- _jsxs("div", { className: cn("flex items-center justify-between rounded-lg py-1.5 transition-colors hover:bg-sidebar-accent/70", SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("span", { "aria-hidden": "true", className: SIDEBAR_ITEM_ICON_CLASS }), _jsx(NavLink, { to: group.to, end: true, className: "min-w-0 flex-1", children: ({ isActive }) => (_jsx("span", { className: cn("block", SIDEBAR_GROUP_HEADER_LABEL_CLASS, isActive
939
+ _jsxs("div", { "data-nav-group-header": true, className: cn("flex items-center justify-between py-1.5 transition-colors hover:bg-sidebar-accent/70", SIDEBAR_GROUP_HEADER_BOX, SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("span", { "aria-hidden": "true", className: SIDEBAR_ITEM_ICON_CLASS }), _jsx(NavLink, { to: group.to, end: true, className: "min-w-0 flex-1", children: ({ isActive }) => (_jsx("span", { className: cn("block", SIDEBAR_GROUP_HEADER_LABEL_CLASS, isActive
897
940
  ? "text-sidebar-selected-foreground"
898
- : "text-sidebar-foreground/65 hover:text-sidebar-foreground"), children: group.label })) }), group.createActions && group.createActions.length > 0 && (_jsx(CreateActionsMenu, { group: group }))] })) : (_jsxs("div", { className: cn("flex items-center", SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("span", { "aria-hidden": "true", className: SIDEBAR_ITEM_ICON_CLASS }), _jsx("span", { className: cn("text-sidebar-foreground/65", SIDEBAR_GROUP_HEADER_LABEL_CLASS), children: group.label })] })) })), !collapsed &&
899
- group.items.map((item) => (_jsx(CollapsibleNavItem, { item: item,
941
+ : "text-sidebar-foreground/75 hover:text-sidebar-foreground"), children: group.label })) }), group.createActions && group.createActions.length > 0 && (_jsx(CreateActionsMenu, { group: group }))] })) : (_jsxs("div", { "data-nav-group-header": true, className: cn("flex items-center", SIDEBAR_GROUP_HEADER_BOX, SIDEBAR_ITEM_GAP_CLASS), children: [_jsx("span", { "aria-hidden": "true", className: SIDEBAR_ITEM_ICON_CLASS }), _jsx("span", { className: cn("text-sidebar-foreground/75", SIDEBAR_GROUP_HEADER_LABEL_CLASS), children: group.label })] })) })), !collapsed && (_jsx("div", { className: cn(animateRows &&
942
+ "motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200"), children: (focused ? [focusedItem] : group.items).map((item) => (_jsx(CollapsibleNavItem, { item: item,
900
943
  // A leaf dot distinguishes "nothing under this" from a sibling
901
944
  // that branches. Decided per GROUP: Workspaces has branches, so
902
945
  // its leaves are marked; Chats has none, so a dot on every row
903
946
  // would mark a distinction that does not exist.
904
- inTree: groupHasBranches }, item.to + item.label)))] }));
947
+ inTree: groupHasBranches, heading: focused, underHeader: !group.hideLabel }, item.to + item.label))) }, focusKey))] }));
905
948
  }
906
949
  /**
907
950
  * The "+" dropdown shown next to a group label when the group declares
@@ -912,7 +955,7 @@ function NavGroupSection({ group }) {
912
955
  * doesn't also toggle the surrounding collapsible-header button.
913
956
  */
914
957
  function CreateActionsMenu({ group }) {
915
- return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx("button", { type: "button", onClick: (e) => e.stopPropagation(), className: "inline-flex size-6 items-center justify-center text-sidebar-foreground/65 transition-colors hover:text-sidebar-foreground", "aria-label": `Create in ${group.label}`, children: _jsx(Plus, { className: "size-4" }) }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-48", children: (group.createActions ?? []).map((action) => (_jsx(DropdownMenuItem, { onSelect: (e) => {
958
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx("button", { type: "button", onClick: (e) => e.stopPropagation(), className: "inline-flex size-6 items-center justify-center text-sidebar-foreground/75 transition-colors hover:text-sidebar-foreground", "aria-label": `Create in ${group.label}`, children: _jsx(Plus, { className: "size-4" }) }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-48", children: (group.createActions ?? []).map((action) => (_jsx(DropdownMenuItem, { onSelect: (e) => {
916
959
  e.preventDefault();
917
960
  action.handler();
918
961
  }, children: action.label }, action.id))) })] }));
@@ -138,6 +138,25 @@ export interface NavGroup {
138
138
  to?: string;
139
139
  /** Hide the group header while preserving group semantics. */
140
140
  hideLabel?: boolean;
141
+ /**
142
+ * The ``to`` of the item this group is focused on, if any.
143
+ *
144
+ * A focused group draws that item alone: the header keeps its label,
145
+ * typography and height and leads back to ``to`` (a back chevron in the
146
+ * collapse chevron's column, the label as the link); the item is drawn
147
+ * as a flat, emphasised heading with its children beneath it — one
148
+ * indent step left of a branch's children, at their usual row size —
149
+ * and its siblings are not drawn. Nothing above the item moves and no
150
+ * row resizes when the group focuses or unfocuses.
151
+ *
152
+ * Focus takes precedence over ``collapsible``. It needs a visible label
153
+ * and a ``to`` to lead back to; without either, or when no item carries
154
+ * that ``to``, the group draws as usual. Only the group's own items can
155
+ * be focused, not their descendants. The value is the caller's: the
156
+ * back link navigates to ``to`` and nothing more, so the caller derives
157
+ * ``focusOn`` from the route (or its own state) and clears it there.
158
+ */
159
+ focusOn?: string;
141
160
  /** Group description sent to the agent */
142
161
  description?: string;
143
162
  /** Group-level metadata (e.g., org settings, workspace config, custom instructions) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "license": "MIT",
5
5
  "description": "React agent UI core for Foundry UI — chat, composer, AG-UI adapter for assistant-ui, tool cards, panels, sidebar, theme runtime, and UI stores.",
6
6
  "keywords": [
@@ -76,8 +76,8 @@
76
76
  "recharts": "^3.10.1",
77
77
  "remark-gfm": "^4.0.0",
78
78
  "tailwind-merge": "^3.5.0",
79
- "@iloveagents/foundry-agent": "^0.25.0",
80
- "@iloveagents/foundry-web-primitives": "^0.25.0"
79
+ "@iloveagents/foundry-agent": "^0.26.0",
80
+ "@iloveagents/foundry-web-primitives": "^0.26.0"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@ag-ui/client": "^0.0.52",