@iloveagents/foundry-web-ui 0.24.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))) })] }));
@@ -10,7 +10,7 @@ export type ToolCallStatus = {
10
10
  type: "requires-action";
11
11
  reason?: string;
12
12
  };
13
- interface ToolCallCardProps {
13
+ export interface ToolCallCardProps {
14
14
  icon?: ReactNode;
15
15
  title: string;
16
16
  description?: string;
@@ -22,6 +22,17 @@ interface ToolCallCardProps {
22
22
  };
23
23
  children?: ReactNode;
24
24
  className?: string;
25
+ /** Start expanded. Ignored when `open` is supplied. */
26
+ defaultOpen?: boolean;
27
+ /**
28
+ * Controlled expansion.
29
+ *
30
+ * A card whose body is expensive to mount — a live force simulation, a media
31
+ * player — needs to know whether it is expanded so it can defer that cost,
32
+ * and needs to react when the user opens it. Left undefined, the card manages
33
+ * its own state exactly as before.
34
+ */
35
+ open?: boolean;
36
+ onOpenChange?: (open: boolean) => void;
25
37
  }
26
- export declare function ToolCallCard({ icon, title, description, status, action, children, className, }: ToolCallCardProps): import("react/jsx-runtime").JSX.Element;
27
- export {};
38
+ export declare function ToolCallCard({ icon, title, description, status, action, children, className, defaultOpen, open, onOpenChange, }: ToolCallCardProps): import("react/jsx-runtime").JSX.Element;
@@ -3,8 +3,15 @@ import { Loader2, CheckCircle2, XCircle, AlertCircle, ChevronDown } from "lucide
3
3
  import { cn } from "@iloveagents/foundry-web-primitives";
4
4
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
5
5
  import { useState } from "react";
6
- export function ToolCallCard({ icon, title, description, status, action, children, className, }) {
7
- const [isOpen, setIsOpen] = useState(false);
6
+ export function ToolCallCard({ icon, title, description, status, action, children, className, defaultOpen = false, open, onOpenChange, }) {
7
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
8
+ const isControlled = open !== undefined;
9
+ const isOpen = isControlled ? open : uncontrolledOpen;
10
+ const setIsOpen = (next) => {
11
+ if (!isControlled)
12
+ setUncontrolledOpen(next);
13
+ onOpenChange?.(next);
14
+ };
8
15
  const isSuccess = status.type === "complete";
9
16
  const isError = status.type === "incomplete" && status.reason === "error";
10
17
  const isRunning = status.type === "running";
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export { ChatHeader } from "./components/chat-header.js";
14
14
  export { ComposerAttachment, UserMessageAttachment, userAttachmentComponents, composerAttachmentComponents, } from "./components/chat-attachments.js";
15
15
  export { ShowDocumentToolUI } from "./components/show-document-tool-ui.js";
16
16
  export { ToolCallCard } from "./components/tool-call-card.js";
17
- export type { ToolCallStatus } from "./components/tool-call-card.js";
17
+ export type { ToolCallStatus, ToolCallCardProps } from "./components/tool-call-card.js";
18
18
  export { ToolFallback } from "./components/tool-fallback.js";
19
19
  export { ConfirmationCard } from "./components/confirmation-card.js";
20
20
  export { TooltipIconButton } from "./components/tooltip-icon-button.js";
@@ -50,6 +50,94 @@ function compactReferenceTarget(target) {
50
50
  proposedValue: compactContextValue(target.proposedValue),
51
51
  };
52
52
  }
53
+ /**
54
+ * Structural comparison that survives what a `payload` actually carries.
55
+ *
56
+ * Compared by WALKING the two values, not by serialising them. A payload is
57
+ * often a database row, and a `bigint` is ordinary there — the Neo4j driver
58
+ * returns them natively — which rules out `JSON.stringify` twice over. It
59
+ * throws on a bigint outright, and the obvious repair (a replacer that tags
60
+ * one) cannot be made correct: `10n` as `"10"` collides with the string
61
+ * `"10"`, as `"10n"` with the string `"10n"`, as `{__bigint:"10"}` with a
62
+ * payload field that happens to be that object. `payload` is arbitrary, so
63
+ * every encoding can be imitated by data shaped like the encoding, and a
64
+ * collision here makes a real change read as "unchanged" — leaving stale
65
+ * context attached to every later message.
66
+ *
67
+ * Comparing types directly has nothing to imitate: `typeof 10n` is not
68
+ * `typeof "10"`. It is also key-order independent (which the serialising
69
+ * version never actually was, despite its name) and cheaper, since it stops at
70
+ * the first difference instead of building two strings.
71
+ *
72
+ * Cycles are tracked as PAIRS. A single visited-set marks an object on first
73
+ * sight, so the second appearance of a shared but acyclic sub-object reads as
74
+ * a cycle — and two values differing only there would compare equal.
75
+ */
76
+ function isDate(value) {
77
+ return Object.prototype.toString.call(value) === "[object Date]";
78
+ }
79
+ /** Own enumerable keys describe this object completely. */
80
+ function isPlainObject(value) {
81
+ const proto = Object.getPrototypeOf(value);
82
+ return proto === Object.prototype || proto === null;
83
+ }
84
+ function deepEqual(a, b, seen) {
85
+ if (Object.is(a, b))
86
+ return true;
87
+ if (typeof a !== typeof b)
88
+ return false;
89
+ if (typeof a !== "object" || a === null || b === null)
90
+ return false;
91
+ const left = a;
92
+ const right = b;
93
+ const pairs = seen ?? new Map();
94
+ const already = pairs.get(left);
95
+ // Assumed equal while in flight: two structures that recurse identically are
96
+ // equal, and re-entering would not terminate.
97
+ if (already?.has(right))
98
+ return true;
99
+ if (already)
100
+ already.add(right);
101
+ else
102
+ pairs.set(left, new Set([right]));
103
+ if (Array.isArray(left) || Array.isArray(right)) {
104
+ if (!Array.isArray(left) || !Array.isArray(right))
105
+ return false;
106
+ if (left.length !== right.length)
107
+ return false;
108
+ return left.every((item, index) => deepEqual(item, right[index], pairs));
109
+ }
110
+ // Enumerable keys only describe a PLAIN object. A `Date`, `Map`, `Set` or
111
+ // `RegExp` keeps its state internally and has none, so comparing key sets
112
+ // said two different dates were the same value — and this guard's entire job
113
+ // is deciding whether something changed. `Date` is worth spelling out
114
+ // because it is what a host or an adapter actually puts in a property; every
115
+ // other exotic object falls through to "not equal unless identical", which
116
+ // is the safe direction: a spurious change costs one redundant update, a
117
+ // missed one loses the user's data silently.
118
+ if (isDate(left) || isDate(right)) {
119
+ return isDate(left) && isDate(right) && Object.is(left.getTime(), right.getTime());
120
+ }
121
+ if (!isPlainObject(left) || !isPlainObject(right))
122
+ return false;
123
+ const leftKeys = Object.keys(left);
124
+ const rightKeys = new Set(Object.keys(right));
125
+ if (leftKeys.length !== rightKeys.size)
126
+ return false;
127
+ return leftKeys.every((key) => rightKeys.has(key) &&
128
+ deepEqual(left[key], right[key], pairs));
129
+ }
130
+ /**
131
+ * Do two keyed context items carry the same value?
132
+ *
133
+ * Compares everything the consumer sees, ignoring the identity fields the
134
+ * store owns (`id`, `createdAt`). `payload` is arbitrary — a selected row, a
135
+ * graph node and its properties — so it is compared structurally.
136
+ */
137
+ function sameContextValue(current, next) {
138
+ const { id: _id, createdAt: _createdAt, ...rest } = current;
139
+ return deepEqual(rest, next);
140
+ }
53
141
  export const useAppStore = create((set, get) => ({
54
142
  // Thread state
55
143
  threadActive: false,
@@ -135,11 +223,18 @@ export const useAppStore = create((set, get) => ({
135
223
  const label = item.label.length > MAX_LABEL_LENGTH
136
224
  ? `${item.label.slice(0, MAX_LABEL_LENGTH)}...`
137
225
  : item.label;
226
+ // Setting the same value twice is not a change. Without this the method
227
+ // mints a fresh `id` and a fresh array every call, so a UI that mirrors a
228
+ // selection into context — re-asserting the same item as it re-renders —
229
+ // churns the store, re-renders every subscriber, and can drive itself
230
+ // into an update loop. The null branch above already declines to write
231
+ // when nothing changes; this is the same rule for the write path.
232
+ const current = get().contextItems.find((existing) => existing.key === key);
233
+ const next = { ...item, key, label };
234
+ if (current && sameContextValue(current, next))
235
+ return;
138
236
  set({
139
- contextItems: [
140
- ...withoutKey,
141
- { ...item, key, label, id: crypto.randomUUID(), createdAt: Date.now() },
142
- ],
237
+ contextItems: [...withoutKey, { ...next, id: crypto.randomUUID(), createdAt: Date.now() }],
143
238
  });
144
239
  },
145
240
  removeContextItem: (id) => set({ contextItems: get().contextItems.filter((i) => i.id !== 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) */
@@ -12,16 +12,34 @@ export interface PdfHighlightRef {
12
12
  export interface ToolPanelContent {
13
13
  title: string;
14
14
  content: string;
15
- type: "markdown" | "text" | "page" | "pdf";
15
+ type: "markdown" | "text" | "page" | "pdf" | "graph";
16
16
  /** PDF highlights for citation overlays (only when type === "pdf") */
17
17
  pdfHighlights?: PdfHighlightRef[];
18
18
  /** Initial page to scroll to (only when type === "pdf") */
19
19
  initialPage?: number;
20
20
  /** SPACES entity ID — enables pin and navigate actions in panel header */
21
21
  entityId?: string;
22
+ /**
23
+ * Structured payload for renderers that need more than a string — a graph,
24
+ * a chart, a table. `content` is still expected to carry a serialization, so
25
+ * the header's Copy action and the built-in text fallback keep working when
26
+ * no renderer matches.
27
+ */
28
+ data?: unknown;
22
29
  }
23
30
  /** Pluggable content renderer for the tool panel — registered by feature modules. */
24
31
  export interface PanelRendererEntry {
32
+ /**
33
+ * Stable identity. When present, re-registering replaces the previous entry
34
+ * with the same id instead of appending a second copy.
35
+ *
36
+ * Registration usually happens in a module's `useInit`, which runs again on
37
+ * every remount — a StrictMode double-render, a route change, a hot reload.
38
+ * Without an id those calls stack up, `renderers` grows without bound, and
39
+ * `find()` keeps matching the oldest (now stale) closure. Entries without an
40
+ * id keep the original append behaviour.
41
+ */
42
+ id?: string;
25
43
  /** Check if this renderer handles the given content */
26
44
  match: (content: ToolPanelContent) => boolean;
27
45
  /** Lazy component to render. Receives content as prop. */
@@ -55,7 +55,18 @@ export const useToolPanelStore = create((set, get) => ({
55
55
  isFullscreen: false,
56
56
  renderers: [],
57
57
  openPanel: (content) => set({ isOpen: true, content }),
58
- registerRenderer: (entry) => set((state) => ({ renderers: [...state.renderers, entry] })),
58
+ registerRenderer: (entry) => set((state) => {
59
+ if (entry.id === undefined)
60
+ return { renderers: [...state.renderers, entry] };
61
+ const index = state.renderers.findIndex((existing) => existing.id === entry.id);
62
+ if (index === -1)
63
+ return { renderers: [...state.renderers, entry] };
64
+ // Replace in place: registration order decides which renderer wins a
65
+ // match, so re-registering must not silently reprioritise it.
66
+ const renderers = [...state.renderers];
67
+ renderers[index] = entry;
68
+ return { renderers };
69
+ }),
59
70
  closePanel: () => set({ isOpen: false, isFullscreen: false }),
60
71
  setContent: (content) => set({ content }),
61
72
  setPanelWidth: (width) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.24.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.24.0",
80
- "@iloveagents/foundry-web-primitives": "^0.24.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",