@iloveagents/foundry-web-ui 0.2.1 → 0.2.2

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,96 @@
1
1
  # @iloveagents/foundry-web-ui
2
2
 
3
+ ## 0.2.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 395f0cd: web-ui: popover New Thread didn't actually start a new thread
8
+
9
+ When the user clicks the compose / SquarePen "New Thread" button in
10
+ the floating chat popover (`ChatBubble` on workspace/entity pages with
11
+ `navigateToChat: false`), the runtime was supposed to discard the
12
+ current thread and start fresh. In practice the popover header
13
+ subtitle flipped to "New conversation" but the message list stayed
14
+ populated with the prior chat's messages — the button looked like a
15
+ no-op.
16
+
17
+ Root cause: `useNewConversation` bumped the AGUIRuntime inner key to
18
+ remount, but the parent `RuntimeBody`'s `effectiveThreadId`
19
+ (`urlMatch ?? sticky ?? freshIdRef.current`) stayed pinned to the
20
+ sticky active-chat id. The history adapter on the fresh inner mount
21
+ immediately loaded the same chat's messages from the server →
22
+ nothing visibly changed.
23
+
24
+ The sidebar "New Thread" button avoided this only by side-effect —
25
+ it navigates to `/`, which triggers `useTrackActiveChatFromUrl` to
26
+ clear sticky. The popover preserves the user's workspace context and
27
+ doesn't navigate, so sticky never cleared.
28
+
29
+ Fix: add a tiny DI store `useChatLifecycleStore` exposing an
30
+ `onNewConversation` callback. `useNewConversation` invokes it BEFORE
31
+ `resetThread` so the host (spaces-web-ui in lastspace) can clear its
32
+ sticky id; the next render's `effectiveThreadId` has already advanced
33
+ to a freshly-minted UUID, and the history adapter binds to that
34
+ instead of re-hydrating the prior chat.
35
+
36
+ Customers that don't track sticky ids leave `onNewConversation`
37
+ unset; the call site no-ops gracefully.
38
+
39
+ - 1ba01ef: web-ui(sidebar): align group-header labels (CHATS, WORKSPACES, ADMIN, …)
40
+ with the leftmost edge of their item labels
41
+
42
+ Previously the collapsible group header used `px-3` on the outer wrapper
43
+ and a `size-6` chevron, while item rows used `pl-5` (depth-0 indent) +
44
+ `size-3.5` icon + `gap-2`. Result: group labels landed at ~36px from the
45
+ sidebar edge while item labels landed at 42px — group labels drifted
46
+ right of the children they introduced and the visual rhythm broke.
47
+
48
+ Make the header's leading column a shared `SIDEBAR_ITEM_ICON_CLASS` +
49
+ `SIDEBAR_ITEM_GAP_CLASS` cell — the same constants item icons use — so
50
+ every group label (collapsible, link-style, or plain) lands at the same
51
+ x-coordinate as item labels. The chevron now sits in the icon column;
52
+ its hit-area is preserved at 28px via `-m-2 p-2` (negative-margin
53
+ absorbs the extra padding so the visual cell stays 14px and label
54
+ geometry doesn't shift).
55
+
56
+ Non-collapsible header branches get an explicit invisible spacer of the
57
+ same width so their labels align too.
58
+
59
+ - 8184a8c: shell: URL is authoritative for the runtime threadId — fixes
60
+ resume-creates-new-conversation race
61
+
62
+ `ChatConversationAwareRuntime` previously computed
63
+ `effectiveThreadId = sticky ?? urlMatch ?? freshIdRef.current`.
64
+ When the user navigated from one chat to another via a Recents
65
+ click, the synchronous render that followed the URL change had:
66
+ - `urlMatch = B` (read from the now-updated `useLocation`)
67
+ - `sticky = A` (the active-chat-store hadn't been updated by
68
+ `useTrackActiveChatFromUrl`'s `useEffect` yet — effects run
69
+ AFTER the commit phase)
70
+
71
+ `sticky ?? urlMatch` picked `A` (the previous chat). The AG-UI
72
+ adapter was constructed with `threadId = A`. The first message the
73
+ user sent went to `/api/agent` with `thread_id = A`, the middleware
74
+ created a new row keyed by `A`... wait actually no, here's what
75
+ happens — the runtime mints a fresh runner UUID when given a
76
+ mismatched threadId; that fresh UUID became a new conversation row,
77
+ appearing as "Untitled chat" at the top of the sidebar while the
78
+ URL still said `/chat/B`. Two active-looking dots: one for `B`
79
+ (NavLink URL match), one for the new row (statusDot from the
80
+ updated active-chat-store after the lazy ensure ran).
81
+
82
+ Swap the priority: `urlMatch ?? sticky ?? freshIdRef.current`. The
83
+ URL is authoritative whenever it's set (i.e. on `/chat/<id>`
84
+ routes), eliminating the stale-sticky race entirely. `sticky` is
85
+ still consulted as the second-priority fallback for non-chat
86
+ routes (`/spaces`, `/tasks`) so the popout chat stays "live" while
87
+ the user browses workspaces — that's the original purpose of
88
+ `useStickyConversationId` and it's preserved.
89
+
90
+ - Updated dependencies [8184a8c]
91
+ - @iloveagents/foundry-agent@0.2.2
92
+ - @iloveagents/foundry-web-primitives@0.2.2
93
+
3
94
  ## 0.2.1
4
95
 
5
96
  ### 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.2.2",
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.2.2",
50
+ "@iloveagents/foundry-web-primitives": "0.2.2"
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
  )}
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
+ }));
@@ -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) {