@iloveagents/foundry-web-ui 0.2.1 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,103 @@
1
1
  # @iloveagents/foundry-web-ui
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Patch Changes
6
+
7
+ - @iloveagents/foundry-agent@0.3.0
8
+ - @iloveagents/foundry-web-primitives@0.3.0
9
+
10
+ ## 0.2.2
11
+
12
+ ### Patch Changes
13
+
14
+ - 395f0cd: web-ui: popover New Thread didn't actually start a new thread
15
+
16
+ When the user clicks the compose / SquarePen "New Thread" button in
17
+ the floating chat popover (`ChatBubble` on workspace/entity pages with
18
+ `navigateToChat: false`), the runtime was supposed to discard the
19
+ current thread and start fresh. In practice the popover header
20
+ subtitle flipped to "New conversation" but the message list stayed
21
+ populated with the prior chat's messages — the button looked like a
22
+ no-op.
23
+
24
+ Root cause: `useNewConversation` bumped the AGUIRuntime inner key to
25
+ remount, but the parent `RuntimeBody`'s `effectiveThreadId`
26
+ (`urlMatch ?? sticky ?? freshIdRef.current`) stayed pinned to the
27
+ sticky active-chat id. The history adapter on the fresh inner mount
28
+ immediately loaded the same chat's messages from the server →
29
+ nothing visibly changed.
30
+
31
+ The sidebar "New Thread" button avoided this only by side-effect —
32
+ it navigates to `/`, which triggers `useTrackActiveChatFromUrl` to
33
+ clear sticky. The popover preserves the user's workspace context and
34
+ doesn't navigate, so sticky never cleared.
35
+
36
+ Fix: add a tiny DI store `useChatLifecycleStore` exposing an
37
+ `onNewConversation` callback. `useNewConversation` invokes it BEFORE
38
+ `resetThread` so the host (spaces-web-ui in lastspace) can clear its
39
+ sticky id; the next render's `effectiveThreadId` has already advanced
40
+ to a freshly-minted UUID, and the history adapter binds to that
41
+ instead of re-hydrating the prior chat.
42
+
43
+ Customers that don't track sticky ids leave `onNewConversation`
44
+ unset; the call site no-ops gracefully.
45
+
46
+ - 1ba01ef: web-ui(sidebar): align group-header labels (CHATS, WORKSPACES, ADMIN, …)
47
+ with the leftmost edge of their item labels
48
+
49
+ Previously the collapsible group header used `px-3` on the outer wrapper
50
+ and a `size-6` chevron, while item rows used `pl-5` (depth-0 indent) +
51
+ `size-3.5` icon + `gap-2`. Result: group labels landed at ~36px from the
52
+ sidebar edge while item labels landed at 42px — group labels drifted
53
+ right of the children they introduced and the visual rhythm broke.
54
+
55
+ Make the header's leading column a shared `SIDEBAR_ITEM_ICON_CLASS` +
56
+ `SIDEBAR_ITEM_GAP_CLASS` cell — the same constants item icons use — so
57
+ every group label (collapsible, link-style, or plain) lands at the same
58
+ x-coordinate as item labels. The chevron now sits in the icon column;
59
+ its hit-area is preserved at 28px via `-m-2 p-2` (negative-margin
60
+ absorbs the extra padding so the visual cell stays 14px and label
61
+ geometry doesn't shift).
62
+
63
+ Non-collapsible header branches get an explicit invisible spacer of the
64
+ same width so their labels align too.
65
+
66
+ - 8184a8c: shell: URL is authoritative for the runtime threadId — fixes
67
+ resume-creates-new-conversation race
68
+
69
+ `ChatConversationAwareRuntime` previously computed
70
+ `effectiveThreadId = sticky ?? urlMatch ?? freshIdRef.current`.
71
+ When the user navigated from one chat to another via a Recents
72
+ click, the synchronous render that followed the URL change had:
73
+ - `urlMatch = B` (read from the now-updated `useLocation`)
74
+ - `sticky = A` (the active-chat-store hadn't been updated by
75
+ `useTrackActiveChatFromUrl`'s `useEffect` yet — effects run
76
+ AFTER the commit phase)
77
+
78
+ `sticky ?? urlMatch` picked `A` (the previous chat). The AG-UI
79
+ adapter was constructed with `threadId = A`. The first message the
80
+ user sent went to `/api/agent` with `thread_id = A`, the middleware
81
+ created a new row keyed by `A`... wait actually no, here's what
82
+ happens — the runtime mints a fresh runner UUID when given a
83
+ mismatched threadId; that fresh UUID became a new conversation row,
84
+ appearing as "Untitled chat" at the top of the sidebar while the
85
+ URL still said `/chat/B`. Two active-looking dots: one for `B`
86
+ (NavLink URL match), one for the new row (statusDot from the
87
+ updated active-chat-store after the lazy ensure ran).
88
+
89
+ Swap the priority: `urlMatch ?? sticky ?? freshIdRef.current`. The
90
+ URL is authoritative whenever it's set (i.e. on `/chat/<id>`
91
+ routes), eliminating the stale-sticky race entirely. `sticky` is
92
+ still consulted as the second-priority fallback for non-chat
93
+ routes (`/spaces`, `/tasks`) so the popout chat stays "live" while
94
+ the user browses workspaces — that's the original purpose of
95
+ `useStickyConversationId` and it's preserved.
96
+
97
+ - Updated dependencies [8184a8c]
98
+ - @iloveagents/foundry-agent@0.2.2
99
+ - @iloveagents/foundry-web-primitives@0.2.2
100
+
3
101
  ## 0.2.1
4
102
 
5
103
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-ui",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "types": "./src/index.ts",
@@ -46,8 +46,8 @@
46
46
  "react-markdown": "^10.0.0",
47
47
  "remark-gfm": "^4.0.0",
48
48
  "shiki": "^4.0.0",
49
- "@iloveagents/foundry-agent": "0.2.1",
50
- "@iloveagents/foundry-web-primitives": "0.2.1"
49
+ "@iloveagents/foundry-agent": "0.3.0",
50
+ "@iloveagents/foundry-web-primitives": "0.3.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "typescript": "~5.9.3",
@@ -27,6 +27,39 @@ const ACTIVE_NAV_BADGE_CLASS =
27
27
  "border border-sidebar-border bg-background/60 text-sidebar-foreground";
28
28
  const NESTED_NAV_INDENTS = ["pl-5", "pl-7", "pl-9", "pl-11", "pl-13"] as const;
29
29
 
30
+ // Shared icon-column geometry. Top-level item rows render their
31
+ // leading icon at ``size-4`` (16px) followed by ``gap-2`` (8px)
32
+ // before the label. The group-header chevron uses the SAME column
33
+ // width + gap so the group label and item labels share an exact
34
+ // x-coordinate — without this invariant headers visually drift right
35
+ // (previously the chevron defaulted to ``size-6`` = 24px) and the
36
+ // sidebar looks ragged. Verified empirically against the live DOM:
37
+ // item icon x-min ≈ header chevron x-min, item label x ≈ header
38
+ // label x (within 1px allowing for the item's ``border-transparent``
39
+ // inset).
40
+ const SIDEBAR_ITEM_ICON_CLASS = "size-4 shrink-0";
41
+ const SIDEBAR_ITEM_GAP_CLASS = "gap-2";
42
+ // Outer padding for the group-header wrapper. Horizontal matches
43
+ // ``px-3`` on item buttons so the chevron column lines up with the
44
+ // item icon column. Vertical is asymmetric: ``pt-3`` (12px) above
45
+ // gives breathing room between sections, ``pb-1`` (4px) below bonds
46
+ // the header to its first child — the ChatGPT / Linear / Notion
47
+ // "header sits on its children" pattern. Symmetric ``py-2`` made
48
+ // the header float midway between sections instead of belonging
49
+ // to the group below it.
50
+ const SIDEBAR_GROUP_HEADER_PADDING = "px-3 pt-4 pb-0";
51
+ // Typography for the group-header LABEL text (CHATS / WORKSPACES /
52
+ // ADMIN / …). Stays at ``text-xs`` (12px) — same size as before —
53
+ // but bumps weight from ``font-medium`` to ``font-semibold``. The
54
+ // extra weight gives section headers the structural presence they
55
+ // need to feel like section dividers without spending vertical
56
+ // space; combined with ``uppercase tracking-wider`` it's the
57
+ // ChatGPT / Linear / Notion small-caps section-label rhythm.
58
+ // Bumping size AND weight together stacked too much visual mass
59
+ // against the already-wide uppercase tracking — felt shouty.
60
+ const SIDEBAR_GROUP_HEADER_LABEL_CLASS =
61
+ "text-xs font-semibold uppercase tracking-wider";
62
+
30
63
  type NavStatusDot = NonNullable<NavItem["statusDot"]>;
31
64
  type NavStatusIcon = NonNullable<NavItem["statusIcon"]>;
32
65
 
@@ -1184,7 +1217,12 @@ function NavGroupSection({ group }: { group: NavGroup }) {
1184
1217
  return (
1185
1218
  <div data-nav-group-label={group.label}>
1186
1219
  {!group.hideLabel && (
1187
- <div className="px-3 py-2">
1220
+ // Outer padding matches the depth-0 item button's ``px-3`` so
1221
+ // the chevron / leading spacer sits in the SAME icon column as
1222
+ // item icons, and the group label ends up at the exact x-coordinate
1223
+ // as item labels. See ``SIDEBAR_GROUP_HEADER_PADDING`` definition
1224
+ // for the alignment invariant.
1225
+ <div className={SIDEBAR_GROUP_HEADER_PADDING}>
1188
1226
  {isCollapsible ? (
1189
1227
  // Collapsible header is a flex of three siblings: chevron
1190
1228
  // toggle (own button), label (NavLink when ``group.to``
@@ -1192,19 +1230,45 @@ function NavGroupSection({ group }: { group: NavGroup }) {
1192
1230
  // landing page — Workspaces does this), createActions
1193
1231
  // menu. Three SIBLINGS, not nested, so HTML stays valid
1194
1232
  // (no <button> inside <button> / inside Radix's button).
1195
- <div className="group/header flex items-center justify-between rounded-lg transition-colors hover:bg-sidebar-accent/70">
1233
+ //
1234
+ // Geometry: the chevron occupies the SAME width as item
1235
+ // icons (``SIDEBAR_ITEM_ICON_CLASS``) followed by
1236
+ // ``SIDEBAR_ITEM_GAP_CLASS``, so the group label and the
1237
+ // item labels share an exact x-coordinate. The chevron's
1238
+ // click target is larger than its visual size — extended
1239
+ // via padding on the wrapper button — so users don't have
1240
+ // to hit a tiny target. The clickable invisible padding
1241
+ // OVERLAPS the gap rather than pushing the label right
1242
+ // (negative-margin + extra padding cancel out).
1243
+ <div
1244
+ className={cn(
1245
+ "group/header flex items-center justify-between rounded-lg transition-colors hover:bg-sidebar-accent/70",
1246
+ SIDEBAR_ITEM_GAP_CLASS,
1247
+ )}
1248
+ >
1196
1249
  <button
1197
1250
  type="button"
1198
1251
  data-nav-group-toggle
1199
1252
  onClick={() => toggleGroupCollapsed(group.label, persistedCollapsed)}
1200
1253
  aria-expanded={!collapsed}
1201
1254
  aria-label={collapsed ? `Expand ${group.label}` : `Collapse ${group.label}`}
1202
- className="inline-flex size-6 shrink-0 items-center justify-center rounded text-sidebar-foreground/50 hover:text-sidebar-foreground"
1255
+ // Visual cell width = ``SIDEBAR_ITEM_ICON_CLASS`` (14px)
1256
+ // so the chevron sits in the same column as item icons
1257
+ // and the group label aligns with item labels. The
1258
+ // chevron SVG (``size-3`` = 12px) is smaller than the
1259
+ // cell so it reads as a hint, not a primary control.
1260
+ // Hit area is the cell width; the row's ``group/header``
1261
+ // hover state covers the rest of the strip so users get
1262
+ // visual feedback even on near-misses.
1263
+ className={cn(
1264
+ "inline-flex items-center justify-center rounded text-sidebar-foreground/50 hover:text-sidebar-foreground",
1265
+ SIDEBAR_ITEM_ICON_CLASS,
1266
+ )}
1203
1267
  >
1204
1268
  {collapsed ? (
1205
- <ChevronRight className="size-3 transition-transform" />
1269
+ <ChevronRight className="size-3.5 transition-transform" />
1206
1270
  ) : (
1207
- <ChevronDown className="size-3 transition-transform" />
1271
+ <ChevronDown className="size-3.5 transition-transform" />
1208
1272
  )}
1209
1273
  </button>
1210
1274
  {group.to ? (
@@ -1213,7 +1277,8 @@ function NavGroupSection({ group }: { group: NavGroup }) {
1213
1277
  <>
1214
1278
  <span
1215
1279
  className={cn(
1216
- "truncate text-xs font-medium uppercase tracking-wider",
1280
+ "truncate",
1281
+ SIDEBAR_GROUP_HEADER_LABEL_CLASS,
1217
1282
  isActive
1218
1283
  ? "text-sidebar-selected-foreground"
1219
1284
  : "text-sidebar-foreground/65 group-hover/header:text-sidebar-foreground",
@@ -1231,7 +1296,12 @@ function NavGroupSection({ group }: { group: NavGroup }) {
1231
1296
  onClick={() => toggleGroupCollapsed(group.label, persistedCollapsed)}
1232
1297
  className="flex min-w-0 flex-1 items-center gap-1.5 py-1.5 text-left"
1233
1298
  >
1234
- <span className="truncate text-xs font-medium uppercase tracking-wider text-sidebar-foreground/65 group-hover/header:text-sidebar-foreground">
1299
+ <span
1300
+ className={cn(
1301
+ "truncate text-sidebar-foreground/65 group-hover/header:text-sidebar-foreground",
1302
+ SIDEBAR_GROUP_HEADER_LABEL_CLASS,
1303
+ )}
1304
+ >
1235
1305
  {group.label}
1236
1306
  </span>
1237
1307
  <NavStatusDot statusDot={bubbledStatusDot} />
@@ -1242,12 +1312,23 @@ function NavGroupSection({ group }: { group: NavGroup }) {
1242
1312
  )}
1243
1313
  </div>
1244
1314
  ) : group.to ? (
1245
- <div className="flex items-center justify-between rounded-lg py-1.5 transition-colors hover:bg-sidebar-accent/70">
1315
+ // Non-collapsible header: no chevron, but we still emit an
1316
+ // invisible spacer of ``SIDEBAR_ITEM_ICON_CLASS`` width + the
1317
+ // shared gap so the group label lands in the same column as
1318
+ // collapsible headers and item labels.
1319
+ <div
1320
+ className={cn(
1321
+ "flex items-center justify-between rounded-lg py-1.5 transition-colors hover:bg-sidebar-accent/70",
1322
+ SIDEBAR_ITEM_GAP_CLASS,
1323
+ )}
1324
+ >
1325
+ <span aria-hidden="true" className={SIDEBAR_ITEM_ICON_CLASS} />
1246
1326
  <NavLink to={group.to} end className="min-w-0 flex-1">
1247
1327
  {({ isActive }) => (
1248
1328
  <span
1249
1329
  className={cn(
1250
- "block text-xs font-medium uppercase tracking-wider",
1330
+ "block",
1331
+ SIDEBAR_GROUP_HEADER_LABEL_CLASS,
1251
1332
  isActive
1252
1333
  ? "text-sidebar-selected-foreground"
1253
1334
  : "text-sidebar-foreground/65 hover:text-sidebar-foreground",
@@ -1262,9 +1343,17 @@ function NavGroupSection({ group }: { group: NavGroup }) {
1262
1343
  )}
1263
1344
  </div>
1264
1345
  ) : (
1265
- <span className="text-xs font-medium uppercase tracking-wider text-sidebar-foreground/65">
1266
- {group.label}
1267
- </span>
1346
+ <div className={cn("flex items-center", SIDEBAR_ITEM_GAP_CLASS)}>
1347
+ <span aria-hidden="true" className={SIDEBAR_ITEM_ICON_CLASS} />
1348
+ <span
1349
+ className={cn(
1350
+ "text-sidebar-foreground/65",
1351
+ SIDEBAR_GROUP_HEADER_LABEL_CLASS,
1352
+ )}
1353
+ >
1354
+ {group.label}
1355
+ </span>
1356
+ </div>
1268
1357
  )}
1269
1358
  </div>
1270
1359
  )}
@@ -1318,6 +1407,7 @@ function SidebarContent({ collapsed }: { collapsed: boolean }) {
1318
1407
  const toggle = useSidebarStore((s) => s.toggle);
1319
1408
  const handleNewConversation = useNewConversation();
1320
1409
  const rawNavConfig = useNavStore((s) => s.config);
1410
+ const navLoading = useNavStore((s) => s.navLoading);
1321
1411
  // Sort by `priority` descending so feature hooks can declare slot
1322
1412
  // intent (e.g. "Recents above Tasks") instead of relying on the
1323
1413
  // order in which addGroup() happened to be called. Stable sort: ties
@@ -1331,6 +1421,12 @@ function SidebarContent({ collapsed }: { collapsed: boolean }) {
1331
1421
  .map(({ g }) => g),
1332
1422
  [rawNavConfig],
1333
1423
  );
1424
+ // Show skeleton rows during the cold nav fetch — but only until the
1425
+ // integrator's primary dynamic group ("Workspaces") has been published.
1426
+ // Once it exists, real rows render even if a later phase (status dots) is
1427
+ // still resolving, so the skeleton never flashes over a populated tree.
1428
+ const hasWorkspacesGroup = navConfig.some((group) => group.label === "Workspaces");
1429
+ const showNavSkeleton = navLoading && !hasWorkspacesGroup;
1334
1430
  const currentPage = useAppStore((s) => s.currentPage);
1335
1431
  const navContext = useAppStore((s) => s.navContext);
1336
1432
 
@@ -1457,6 +1553,16 @@ function SidebarContent({ collapsed }: { collapsed: boolean }) {
1457
1553
  {navConfig.map((group) => (
1458
1554
  <NavGroupSection key={group.label} group={group} />
1459
1555
  ))}
1556
+ {showNavSkeleton && (
1557
+ <div className="space-y-1 px-1 pt-2" aria-hidden="true">
1558
+ {Array.from({ length: 6 }).map((_, index) => (
1559
+ <div
1560
+ key={index}
1561
+ className="h-7 mx-1 rounded-lg animate-pulse bg-sidebar-accent/40"
1562
+ />
1563
+ ))}
1564
+ </div>
1565
+ )}
1460
1566
  </nav>
1461
1567
 
1462
1568
  {/* Footer actions — registered by feature modules */}
package/src/index.ts CHANGED
@@ -116,6 +116,7 @@ export type {
116
116
  } from "./lib/tool-panel-store.ts";
117
117
  export { useSidebarStore, SIDEBAR_WIDTH, RAIL_WIDTH } from "./lib/sidebar-store.ts";
118
118
  export { useChatBubbleStore } from "./lib/chat-bubble-store.ts";
119
+ export { useChatLifecycleStore } from "./lib/chat-lifecycle-store.ts";
119
120
  export { submitComposerText } from "./lib/composer-submit-store.ts";
120
121
  export {
121
122
  registerSelectionContextResolver,
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Lifecycle DI store for chat-related cross-cutting concerns that
3
+ * foundry-ui needs to invoke but spaces-web-ui owns the implementation
4
+ * for. Avoids a circular dependency between the two packages.
5
+ *
6
+ * Today's only hook: ``onNewConversation`` — called when the user
7
+ * starts a fresh chat (sidebar New Thread, popover compose button,
8
+ * etc.). spaces-web-ui registers a callback at app init that clears
9
+ * the sticky active-chat id so the next render mints a fresh runtime
10
+ * thread id (otherwise the history adapter re-hydrates the previous
11
+ * chat's messages immediately, defeating the "new thread" intent).
12
+ *
13
+ * Registration: ``useChatLifecycleStore.setState({ onNewConversation })``
14
+ * once at app init. Foundry-ui's :func:`useNewConversation` invokes
15
+ * ``onNewConversation`` synchronously inside its returned callback,
16
+ * BEFORE :func:`resetThread` runs — so by the time the AGUIRuntimeProvider
17
+ * re-renders, the sticky id is already null and the effective thread id
18
+ * has already advanced to a freshly-minted UUID.
19
+ *
20
+ * Customers that don't use sticky id tracking can leave
21
+ * ``onNewConversation`` null — the call site no-ops gracefully.
22
+ */
23
+ import { create } from "zustand";
24
+
25
+ interface ChatLifecycleStore {
26
+ /**
27
+ * Called from :func:`useNewConversation` when the user starts a
28
+ * new thread. Spaces wires this to clear the active-chat sticky id.
29
+ */
30
+ onNewConversation: (() => void) | null;
31
+ setOnNewConversation: (fn: (() => void) | null) => void;
32
+ }
33
+
34
+ export const useChatLifecycleStore = create<ChatLifecycleStore>((set) => ({
35
+ onNewConversation: null,
36
+ setOnNewConversation: (fn) => set({ onNewConversation: fn }),
37
+ }));
@@ -24,8 +24,18 @@ export interface NavFooterAction {
24
24
  interface NavStoreState {
25
25
  config: NavGroup[];
26
26
  footerActions: NavFooterAction[];
27
+ /**
28
+ * True while an integrator's nav sync is loading its first batch of
29
+ * groups. Optional/defaulted (starts ``false``) so existing consumers
30
+ * that never set it are unaffected. The sidebar reads it to render
31
+ * skeleton rows instead of an empty list during the cold fetch —
32
+ * avoids the "empty sidebar for N seconds" gap on a slow nav sync.
33
+ */
34
+ navLoading: boolean;
27
35
  /** Replace the entire config */
28
36
  setConfig: (config: NavGroup[]) => void;
37
+ /** Toggle the cold-load skeleton state. */
38
+ setNavLoading: (loading: boolean) => void;
29
39
  /** Add a group (replaces existing group with the same label) */
30
40
  addGroup: (group: NavGroup) => void;
31
41
  /** Remove a group by label */
@@ -39,9 +49,12 @@ interface NavStoreState {
39
49
  export const useNavStore = create<NavStoreState>((set, get) => ({
40
50
  config: DEFAULT_NAV_CONFIG,
41
51
  footerActions: [],
52
+ navLoading: false,
42
53
 
43
54
  setConfig: (config) => set({ config }),
44
55
 
56
+ setNavLoading: (navLoading) => set({ navLoading }),
57
+
45
58
  addGroup: (group) => {
46
59
  const config = get().config;
47
60
  const idx = config.findIndex((g) => g.label === group.label);
@@ -7,6 +7,7 @@ import { useAGUIAdapter } from "../components/ag-ui-runtime-provider.tsx";
7
7
  import { useToolPanelStore } from "./tool-panel-store.ts";
8
8
  import { useAppStore } from "./app-store.ts";
9
9
  import { useSidebarStore } from "./sidebar-store.ts";
10
+ import { useChatLifecycleStore } from "./chat-lifecycle-store.ts";
10
11
 
11
12
  /**
12
13
  * Shared hook for starting a new conversation.
@@ -35,6 +36,15 @@ export function useNewConversation(options?: { navigateToChat?: boolean; preserv
35
36
  }
36
37
  closeMobile();
37
38
  clearCitations();
39
+ // Notify the host app that a new thread is starting. Spaces wires
40
+ // this to clear its active-chat sticky id so the AGUIRuntimeProvider's
41
+ // ``effectiveThreadId`` advances to a freshly-minted UUID instead of
42
+ // staying pinned to the previous chat. Must fire BEFORE ``resetThread``
43
+ // so the next render's history adapter binds to the new id and
44
+ // doesn't re-hydrate the prior chat's messages — the exact bug this
45
+ // wiring fixes for the popover (``navigateToChat: false``) path,
46
+ // where there is no ``/`` navigation to clear sticky for us.
47
+ useChatLifecycleStore.getState().onNewConversation?.();
38
48
  resetThread();
39
49
  aui.threads().switchToNewThread();
40
50
  if (options?.navigateToChat ?? true) {