@iloveagents/foundry-web-ui 0.26.0 → 0.27.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.
Files changed (82) hide show
  1. package/dist/components/assistant-chat.d.ts +12 -1
  2. package/dist/components/assistant-chat.js +18 -13
  3. package/dist/components/chat-bubble.js +9 -6
  4. package/dist/components/chat-context.d.ts +2 -0
  5. package/dist/components/chat-context.js +70 -0
  6. package/dist/components/chat-slots.d.ts +10 -0
  7. package/dist/components/chat-slots.js +7 -0
  8. package/dist/components/composer-add-menu.js +3 -2
  9. package/dist/components/composer-input.d.ts +4 -0
  10. package/dist/components/composer-input.js +23 -0
  11. package/dist/components/context-badges.d.ts +4 -1
  12. package/dist/components/context-badges.js +7 -4
  13. package/dist/components/context-bar.d.ts +1 -0
  14. package/dist/components/context-bar.js +11 -3
  15. package/dist/components/data-table/data-table-frame.d.ts +1 -22
  16. package/dist/components/data-table/data-table-frame.js +21 -48
  17. package/dist/components/data-table/data-table-overflow.d.ts +5 -0
  18. package/dist/components/data-table/data-table-overflow.js +42 -0
  19. package/dist/components/data-table/data-table.js +88 -87
  20. package/dist/components/data-table/focus-pane-resize.d.ts +8 -0
  21. package/dist/components/data-table/focus-pane-resize.js +88 -0
  22. package/dist/components/focus-chat-pane.d.ts +8 -0
  23. package/dist/components/focus-chat-pane.js +9 -0
  24. package/dist/components/focus-dialog.d.ts +8 -0
  25. package/dist/components/focus-dialog.js +103 -0
  26. package/dist/components/focus-source-pane.d.ts +7 -0
  27. package/dist/components/focus-source-pane.js +15 -0
  28. package/dist/components/loading-indicator.js +1 -1
  29. package/dist/components/message-timestamp.js +3 -2
  30. package/dist/components/pin-state-icon.d.ts +5 -0
  31. package/dist/components/pin-state-icon.js +7 -0
  32. package/dist/components/pin-toggle.d.ts +7 -0
  33. package/dist/components/pin-toggle.js +10 -0
  34. package/dist/components/sidebar.d.ts +18 -1
  35. package/dist/components/sidebar.js +124 -74
  36. package/dist/components/tool-panel-layout.d.ts +3 -1
  37. package/dist/components/tool-panel-layout.js +5 -5
  38. package/dist/components/tool-panel.d.ts +8 -1
  39. package/dist/components/tool-panel.js +12 -8
  40. package/dist/components/user-menu.js +19 -3
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.js +2 -0
  43. package/dist/lib/app-store.d.ts +0 -8
  44. package/dist/lib/app-store.js +14 -4
  45. package/dist/lib/chat-context-summary.d.ts +11 -0
  46. package/dist/lib/chat-context-summary.js +37 -0
  47. package/dist/lib/chat-lifecycle-store.d.ts +2 -0
  48. package/dist/lib/chat-lifecycle-store.js +6 -1
  49. package/dist/lib/client-tools.js +7 -0
  50. package/dist/lib/create-ui-id.d.ts +2 -0
  51. package/dist/lib/create-ui-id.js +9 -0
  52. package/dist/lib/focus-context.d.ts +2 -0
  53. package/dist/lib/focus-context.js +78 -0
  54. package/dist/lib/nav-config.d.ts +7 -0
  55. package/dist/lib/tool-panel-store.d.ts +7 -6
  56. package/dist/lib/tool-panel-store.js +29 -3
  57. package/dist/lib/use-new-conversation.js +1 -1
  58. package/dist/workbench/chat-header.d.ts +14 -0
  59. package/dist/workbench/chat-header.js +12 -0
  60. package/dist/workbench/chat-resize-handle.d.ts +4 -0
  61. package/dist/workbench/chat-resize-handle.js +40 -0
  62. package/dist/workbench/content-pane.d.ts +14 -0
  63. package/dist/workbench/content-pane.js +14 -0
  64. package/dist/workbench/conversation-history.d.ts +11 -0
  65. package/dist/workbench/conversation-history.js +83 -0
  66. package/dist/workbench/navigation.d.ts +6 -0
  67. package/dist/workbench/navigation.js +6 -0
  68. package/dist/workbench/selection-bridge.d.ts +5 -0
  69. package/dist/workbench/selection-bridge.js +84 -0
  70. package/dist/workbench/use-chat-width.d.ts +14 -0
  71. package/dist/workbench/use-chat-width.js +56 -0
  72. package/dist/workbench/use-workbench-state.d.ts +50 -0
  73. package/dist/workbench/use-workbench-state.js +145 -0
  74. package/dist/workbench/welcome-intro.d.ts +6 -0
  75. package/dist/workbench/welcome-intro.js +10 -0
  76. package/dist/workbench/workbench-styles.d.ts +1 -0
  77. package/dist/workbench/workbench-styles.js +290 -0
  78. package/dist/workbench/workbench.d.ts +5 -0
  79. package/dist/workbench/workbench.js +32 -0
  80. package/dist/workbench.d.ts +2 -0
  81. package/dist/workbench.js +2 -0
  82. package/package.json +8 -4
@@ -1,61 +1,34 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- /**
3
- * The frame around a list: normally a plain container, in focus mode a
4
- * fullscreen layer (Escape leaves it). `DataTableToolbar` shows the focus
5
- * button whenever it renders inside a frame — users with wide lists get a
6
- * way out of a cramped page without the host doing anything.
7
- *
8
- * The focus layer stays where it is in the tree — only its classes change —
9
- * so entering and leaving focus never remounts the list (its rows, drawer,
10
- * selection and in-flight edits survive) and it keeps inheriting the host's
11
- * theme scope. It never scrolls itself: it is a fixed flex column whose
12
- * growing child — the table — owns the scrolling, which keeps the toolbar in
13
- * place while the rows move under a sticky header (see `DataTable`, which
14
- * reads this context).
15
- *
16
- * A `position: fixed` layer is relative to the viewport unless an ancestor
17
- * establishes a containing block (`transform`, `filter`, `perspective`,
18
- * `contain: paint`); a host that does that on a list's ancestor has to opt
19
- * out of focus mode.
20
- */
21
- import { createContext, forwardRef, useContext, useEffect, useMemo, useRef, useState, } from "react";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, forwardRef, useContext, useMemo, useRef, useState, } from "react";
22
3
  import { cn } from "@iloveagents/foundry-web-primitives";
4
+ import { TooltipProvider } from "../../ui/tooltip.js";
5
+ import { Dialog, DialogPortal } from "../../ui/dialog.js";
6
+ import { FocusDialog } from "../focus-dialog.js";
23
7
  const DataTableFrameContext = createContext(null);
24
- /** The enclosing frame's focus state, or `null` outside a frame. */
25
8
  export function useDataTableFrame() {
26
9
  return useContext(DataTableFrameContext);
27
10
  }
28
11
  export const DataTableFrame = forwardRef(function DataTableFrame({ children, className, focusClassName, defaultFocused = false, onFocusChange, ...rest }, ref) {
29
12
  const [focused, setFocused] = useState(defaultFocused);
30
- const changeRef = useRef(onFocusChange);
31
- changeRef.current = onFocusChange;
32
- useEffect(() => {
33
- if (!focused)
34
- return;
35
- const onKeyDown = (event) => {
36
- // Through the same door as the button: a host told about focus mode
37
- // must hear about it however it ended.
38
- if (event.key === "Escape") {
39
- setFocused(false);
40
- changeRef.current?.(false);
41
- }
42
- };
43
- document.addEventListener("keydown", onKeyDown);
44
- const previousOverflow = document.body.style.overflow;
45
- document.body.style.overflow = "hidden";
46
- return () => {
47
- document.removeEventListener("keydown", onKeyDown);
48
- document.body.style.overflow = previousOverflow;
49
- };
50
- }, [focused]);
13
+ const [host] = useState(() => typeof document === "undefined" ? null : document.createElement("div"));
14
+ const returnFocus = useRef(null);
51
15
  const value = useMemo(() => ({
52
16
  focused,
53
17
  setFocused: (next) => {
18
+ if (next && !focused)
19
+ returnFocus.current = document.activeElement;
54
20
  setFocused(next);
55
- changeRef.current?.(next);
21
+ onFocusChange?.(next);
56
22
  },
57
- }), [focused]);
58
- return (_jsx(DataTableFrameContext.Provider, { value: value, children: _jsx("div", { ...rest, ref: ref, "data-focused": focused ? "" : undefined, className: focused
59
- ? cn("fixed inset-0 z-50 flex flex-col gap-3 overflow-hidden bg-background p-4 sm:p-6", focusClassName)
60
- : className, children: children }) }));
23
+ }), [focused, onFocusChange]);
24
+ const attach = (element) => {
25
+ if (element && host && host.parentElement !== element) {
26
+ host.style.display = "contents";
27
+ element.appendChild(host);
28
+ }
29
+ };
30
+ const content = (_jsx("div", { ...rest, ref: ref, "data-focused": focused ? "" : undefined, "data-focus-title": rest["aria-label"] || "Focused content", className: focused
31
+ ? cn("flex min-h-0 flex-1 flex-col gap-3 overflow-hidden bg-background p-4 sm:p-6", focusClassName)
32
+ : className, children: children }));
33
+ return (_jsx(DataTableFrameContext.Provider, { value: value, children: _jsxs(TooltipProvider, { children: [_jsx("div", { ref: focused ? undefined : attach, className: "contents" }), _jsxs(Dialog, { open: focused, onOpenChange: value.setFocused, children: [_jsx(FocusDialog, { focused: focused, setFocused: value.setFocused, host: host, attach: attach, title: rest["aria-label"] || "Table focus", onReturnFocus: () => returnFocus.current?.focus() }), host ? (_jsx(DialogPortal, { container: host, forceMount: true, children: content })) : (content)] })] }) }));
61
34
  });
@@ -0,0 +1,5 @@
1
+ import { type RefObject } from "react";
2
+ /** Discoverable, keyboard-accessible navigation for a table wider than its pane. */
3
+ export declare function DataTableOverflow({ scroller }: {
4
+ scroller: RefObject<HTMLDivElement | null>;
5
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { ChevronLeft, ChevronRight } from "lucide-react";
4
+ import { Button } from "@iloveagents/foundry-web-primitives";
5
+ /** Discoverable, keyboard-accessible navigation for a table wider than its pane. */
6
+ export function DataTableOverflow({ scroller }) {
7
+ const [edges, setEdges] = useState({ previous: false, next: false });
8
+ useEffect(() => {
9
+ const element = scroller.current;
10
+ if (!element)
11
+ return;
12
+ const read = () => {
13
+ const position = Math.abs(element.scrollLeft);
14
+ const maximum = Math.max(0, element.scrollWidth - element.clientWidth);
15
+ const next = { previous: position > 1, next: position < maximum - 1 };
16
+ setEdges((current) => current.previous === next.previous && current.next === next.next ? current : next);
17
+ };
18
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(read);
19
+ observer?.observe(element);
20
+ if (element.firstElementChild)
21
+ observer?.observe(element.firstElementChild);
22
+ element.addEventListener("scroll", read, { passive: true });
23
+ read();
24
+ return () => {
25
+ observer?.disconnect();
26
+ element.removeEventListener("scroll", read);
27
+ };
28
+ }, [scroller]);
29
+ if (!edges.previous && !edges.next)
30
+ return null;
31
+ const move = (direction) => {
32
+ const element = scroller.current;
33
+ if (!element)
34
+ return;
35
+ const sign = getComputedStyle(element).direction === "rtl" ? -1 : 1;
36
+ element.scrollBy({
37
+ left: direction * sign * Math.max(160, element.clientWidth * 0.7),
38
+ behavior: "auto",
39
+ });
40
+ };
41
+ return (_jsxs("div", { className: "flex shrink-0 items-center justify-between gap-3 border-t border-border px-3 py-1 text-xs text-muted-foreground", children: [_jsx("span", { children: edges.next ? "More columns to explore" : "You’re at the last column" }), _jsxs("div", { className: "flex items-center gap-1", role: "group", "aria-label": "Scroll table columns", children: [_jsx(Button, { variant: "ghost", size: "icon", className: "size-8 [@media(pointer:coarse)]:size-11", "aria-label": "Previous columns", disabled: !edges.previous, onClick: () => move(-1), children: _jsx(ChevronLeft, { className: "size-4 rtl:rotate-180" }) }), _jsx(Button, { variant: "ghost", size: "icon", className: "size-8 [@media(pointer:coarse)]:size-11", "aria-label": "Next columns", disabled: !edges.next, onClick: () => move(1), children: _jsx(ChevronRight, { className: "size-4 rtl:rotate-180" }) })] })] }));
42
+ }
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useRef, useState, } from "react";
11
11
  import { DATA_TABLE_MIN_WIDTH } from "./facets.js";
12
12
  import { useDataTableFrame } from "./data-table-frame.js";
13
13
  import { usePinnedOffsets } from "./use-pinned-offsets.js";
14
+ import { DataTableOverflow } from "./data-table-overflow.js";
14
15
  import { useElementWidth } from "./use-element-width.js";
15
16
  import { cn } from "@iloveagents/foundry-web-primitives";
16
17
  // Native controls, the ARIA widgets that own the same keys (a slider or a
@@ -207,97 +208,97 @@ export function DataTable({ table, onRowClick, onRowOpen, activeRowId, onActiveR
207
208
  return;
208
209
  handler(row, event);
209
210
  };
210
- return (_jsx("div", { ref: rootRef, "data-scrolled-x": scrolledX ? "" : undefined, className: cn("overflow-hidden rounded-2xl border border-border/45 bg-card/60", filling && "flex min-h-0 flex-1 flex-col", className), children: _jsx("div", { ref: scrollerRef, onScroll: (event) => {
211
- const next = event.currentTarget.scrollLeft > 0;
212
- setScrolledX((current) => (current === next ? current : next));
213
- }, "data-list-scroller": "", style: bounded ? { maxHeight } : undefined, className: cn(filling ? "min-h-0 flex-1 overflow-auto" : bounded ? "overflow-auto" : "overflow-x-auto"), children: _jsxs("table", { className: "w-full caption-bottom text-sm", "aria-label": ariaLabel, onKeyDown: onActiveRowChange
214
- ? (event) => {
215
- // Let a control that owns these keys keep them.
216
- if (event.target.closest(INTERACTIVE))
217
- return;
218
- if (event.key === "ArrowDown")
219
- moveActive(1);
220
- else if (event.key === "ArrowUp")
221
- moveActive(-1);
222
- else if (event.key === "Home")
223
- moveActive("first");
224
- else if (event.key === "End")
225
- moveActive("last");
226
- else if (event.key === "Escape") {
227
- // Only when there IS a row to let go of. One Escape, one
228
- // effect: it clears the active row without also leaving
229
- // focus mode — but with no active row it belongs to the
230
- // frame, so it is left alone to bubble there.
231
- if (!activeRowId)
211
+ return (_jsxs("div", { ref: rootRef, "data-scrolled-x": scrolledX ? "" : undefined, className: cn("overflow-hidden rounded-2xl border border-border/45 bg-card/60", filling && "flex min-h-0 flex-1 flex-col", className), children: [_jsx("div", { ref: scrollerRef, onScroll: (event) => {
212
+ const next = Math.abs(event.currentTarget.scrollLeft) > 0;
213
+ setScrolledX((current) => (current === next ? current : next));
214
+ }, "data-list-scroller": "", role: "region", "aria-label": `${ariaLabel ?? "Table"} scroll area`, tabIndex: 0, style: bounded ? { maxHeight } : undefined, className: cn(filling ? "min-h-0 flex-1 overflow-auto" : bounded ? "overflow-auto" : "overflow-x-auto"), children: _jsxs("table", { className: "w-full caption-bottom text-sm", "aria-label": ariaLabel, onKeyDown: onActiveRowChange
215
+ ? (event) => {
216
+ // Let a control that owns these keys keep them.
217
+ if (event.target.closest(INTERACTIVE))
232
218
  return;
233
- onActiveRowChange(null);
234
- event.stopPropagation();
235
- event.nativeEvent.stopImmediatePropagation();
236
- }
237
- else
238
- return;
239
- event.preventDefault();
240
- }
241
- : undefined, children: [_jsx("thead", { className: cn(sticky && "sticky top-0 z-10 bg-card"), children: table.getHeaderGroups().map((headerGroup) => (_jsxs("tr", { className: "border-b border-border/45", children: [headerGroup.headers.map((header) => {
242
- const meta = header.column.columnDef.meta;
243
- // A grouping header spans its leaves; when the width gate
244
- // drops some of them it has to narrow, and when it drops all
245
- // of them the group header goes too.
246
- const span = fittingLeafCount(header);
247
- if (span === 0)
248
- return null;
249
- // `getSize()` sums EVERY descendant, including the ones the
250
- // width gate just dropped, so a half-gated group would hold
251
- // space for a column that is not there and push the table
252
- // wider than its own columns.
253
- const sized = header.subHeaders.length
254
- ? fittingLeafWidth(header)
255
- : header.getSize();
256
- return (_jsx("th", { colSpan: span, scope: "col",
257
- // Every column in the run, not only the marked ones: the
258
- // run can start with an unmarked leader (a selection
259
- // checkbox), and an unmeasured leader leaves the column
260
- // after it stacked at zero, overlapping it.
261
- ref: pinnedIds.includes(header.column.id) ? measure(header.column.id) : undefined, ...pinnedProps(header.column.id, "header"), style: {
262
- ...(sized !== undefined && sized !== 150 ? { width: sized } : {}),
263
- ...pinnedStyle(header.column.id),
264
- }, className: cn("h-10 px-3 text-left align-middle text-xs font-medium text-muted-foreground", meta?.align === "right" && "text-right", meta?.align === "center" && "text-center", meta?.headerClassName), children: header.isPlaceholder
265
- ? null
266
- : flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
267
- }), spacer ? _jsx("th", { "aria-hidden": "true", className: "w-full p-0" }) : null] }, headerGroup.id))) }), _jsx("tbody", { ref: bodyRef, children: rows.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: columnCount, className: "h-24 text-center text-sm text-muted-foreground", children: emptyState }) })) : (rows.map((row) => row.getIsGrouped() ? (_jsx("tr", { "data-group-row": "", className: "border-t border-border/45 bg-muted/35 text-sm font-medium", children: _jsx("td", { colSpan: columnCount, className: "p-0", children: _jsxs("div", { "data-group-content": "", style: width ? { width } : undefined, className: "flex items-center gap-2 px-3 py-1.5", children: [_jsx("button", { type: "button", onClick: row.getToggleExpandedHandler(), "aria-expanded": row.getIsExpanded(), "aria-label": row.getIsExpanded() ? "Collapse group" : "Expand group", className: "rounded-md p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground", children: row.getIsExpanded() ? (_jsx(ChevronDown, { className: "size-4" })) : (_jsx(ChevronRight, { className: "size-4" })) }), _jsx("div", { className: "min-w-0 flex-1", children: renderGroup ? renderGroup(row) : _jsx(DefaultGroupLabel, { row: row }) })] }) }) }, row.id)) : (_jsxs("tr", { "data-row-id": row.id, "data-state": row.getIsSelected() ? "selected" : undefined, "data-active": activeRowId != null && row.id === activeRowId ? "" : undefined, "aria-current": activeRowId != null && row.id === activeRowId ? "true" : undefined, "aria-selected": row.getIsSelected() || undefined, "data-clickable": onRowClick || onRowOpen ? "" : undefined, "data-focusable": onRowClick || onRowOpen || onActiveRowChange ? "" : undefined, tabIndex: onRowClick || onRowOpen || onActiveRowChange ? 0 : undefined,
268
- // `detail` counts the clicks in the sequence: the second
269
- // one belongs to the double-click that follows, and
270
- // letting it through would toggle a selection off again
271
- // right before the row opens.
272
- onClick: (event) => {
273
- if (event.detail > 1)
219
+ if (event.key === "ArrowDown")
220
+ moveActive(1);
221
+ else if (event.key === "ArrowUp")
222
+ moveActive(-1);
223
+ else if (event.key === "Home")
224
+ moveActive("first");
225
+ else if (event.key === "End")
226
+ moveActive("last");
227
+ else if (event.key === "Escape") {
228
+ // Only when there IS a row to let go of. One Escape, one
229
+ // effect: it clears the active row without also leaving
230
+ // focus mode but with no active row it belongs to the
231
+ // frame, so it is left alone to bubble there.
232
+ if (!activeRowId)
274
233
  return;
275
- activate(row, event, onRowClick);
276
- }, onDoubleClick: (event) => activate(row, event, onRowOpen), onKeyDown: (event) => {
277
- if (event.key === "Enter") {
278
- if (event.target.closest(INTERACTIVE))
279
- return;
280
- event.preventDefault();
281
- activate(row, event, onRowOpen ?? onRowClick);
282
- }
283
- else if (event.key === " ") {
284
- if (event.target.closest(INTERACTIVE))
234
+ onActiveRowChange(null);
235
+ event.stopPropagation();
236
+ event.nativeEvent.stopImmediatePropagation();
237
+ }
238
+ else
239
+ return;
240
+ event.preventDefault();
241
+ }
242
+ : undefined, children: [_jsx("thead", { className: cn(sticky && "sticky top-0 z-10 bg-card"), children: table.getHeaderGroups().map((headerGroup) => (_jsxs("tr", { className: "border-b border-border/45", children: [headerGroup.headers.map((header) => {
243
+ const meta = header.column.columnDef.meta;
244
+ // A grouping header spans its leaves; when the width gate
245
+ // drops some of them it has to narrow, and when it drops all
246
+ // of them the group header goes too.
247
+ const span = fittingLeafCount(header);
248
+ if (span === 0)
249
+ return null;
250
+ // `getSize()` sums EVERY descendant, including the ones the
251
+ // width gate just dropped, so a half-gated group would hold
252
+ // space for a column that is not there and push the table
253
+ // wider than its own columns.
254
+ const sized = header.subHeaders.length
255
+ ? fittingLeafWidth(header)
256
+ : header.getSize();
257
+ return (_jsx("th", { colSpan: span, scope: "col",
258
+ // Every column in the run, not only the marked ones: the
259
+ // run can start with an unmarked leader (a selection
260
+ // checkbox), and an unmeasured leader leaves the column
261
+ // after it stacked at zero, overlapping it.
262
+ ref: pinnedIds.includes(header.column.id) ? measure(header.column.id) : undefined, ...pinnedProps(header.column.id, "header"), style: {
263
+ ...(sized !== undefined && sized !== 150 ? { width: sized } : {}),
264
+ ...pinnedStyle(header.column.id),
265
+ }, className: cn("h-10 px-3 text-left align-middle text-xs font-medium text-muted-foreground", meta?.align === "right" && "text-right", meta?.align === "center" && "text-center", meta?.headerClassName), children: header.isPlaceholder
266
+ ? null
267
+ : flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
268
+ }), spacer ? _jsx("th", { "aria-hidden": "true", className: "w-full p-0" }) : null] }, headerGroup.id))) }), _jsx("tbody", { ref: bodyRef, children: rows.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: columnCount, className: "h-24 text-center text-sm text-muted-foreground", children: emptyState }) })) : (rows.map((row) => row.getIsGrouped() ? (_jsx("tr", { "data-group-row": "", className: "border-t border-border/45 bg-muted/35 text-sm font-medium", children: _jsx("td", { colSpan: columnCount, className: "p-0", children: _jsxs("div", { "data-group-content": "", style: width ? { width } : undefined, className: "flex items-center gap-2 px-3 py-1.5", children: [_jsx("button", { type: "button", onClick: row.getToggleExpandedHandler(), "aria-expanded": row.getIsExpanded(), "aria-label": row.getIsExpanded() ? "Collapse group" : "Expand group", className: "rounded-md p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground", children: row.getIsExpanded() ? (_jsx(ChevronDown, { className: "size-4" })) : (_jsx(ChevronRight, { className: "size-4" })) }), _jsx("div", { className: "min-w-0 flex-1", children: renderGroup ? renderGroup(row) : _jsx(DefaultGroupLabel, { row: row }) })] }) }) }, row.id)) : (_jsxs("tr", { "data-row-id": row.id, "data-state": row.getIsSelected() ? "selected" : undefined, "data-active": activeRowId != null && row.id === activeRowId ? "" : undefined, "aria-current": activeRowId != null && row.id === activeRowId ? "true" : undefined, "aria-selected": row.getIsSelected() || undefined, "data-clickable": onRowClick || onRowOpen ? "" : undefined, "data-focusable": onRowClick || onRowOpen || onActiveRowChange ? "" : undefined, tabIndex: onRowClick || onRowOpen || onActiveRowChange ? 0 : undefined,
269
+ // `detail` counts the clicks in the sequence: the second
270
+ // one belongs to the double-click that follows, and
271
+ // letting it through would toggle a selection off again
272
+ // right before the row opens.
273
+ onClick: (event) => {
274
+ if (event.detail > 1)
285
275
  return;
286
- event.preventDefault();
287
276
  activate(row, event, onRowClick);
288
- }
289
- }, className: cn("group/row border-t border-border/35 transition-colors", (onRowClick || onRowOpen) && "cursor-pointer hover:bg-muted/30",
290
- // Anything focusable has to SHOW where the keyboard is —
291
- // a list navigated with the arrows is focusable without
292
- // being clickable, and silent focus leaves a keyboard
293
- // user guessing which row the next key belongs to.
294
- (onRowClick || onRowOpen || onActiveRowChange) &&
295
- "focus-visible:bg-muted/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring", "data-[state=selected]:bg-primary/6 data-[active]:bg-primary/8", rowClassName?.(row)), children: [row.getVisibleCells().map((cell) => {
296
- const meta = cell.column.columnDef.meta;
297
- if (!fits(meta))
298
- return null;
299
- return (_jsx("td", { ...pinnedProps(cell.column.id, "cell"), style: pinnedStyle(cell.column.id), className: cn(cellPad, "align-middle", meta?.align === "right" && "text-right", meta?.align === "center" && "text-center", meta?.className), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
300
- }), spacer ? _jsx("td", { "aria-hidden": "true", className: "w-full p-0" }) : null] }, row.id)))) })] }) }) }));
277
+ }, onDoubleClick: (event) => activate(row, event, onRowOpen), onKeyDown: (event) => {
278
+ if (event.key === "Enter") {
279
+ if (event.target.closest(INTERACTIVE))
280
+ return;
281
+ event.preventDefault();
282
+ activate(row, event, onRowOpen ?? onRowClick);
283
+ }
284
+ else if (event.key === " ") {
285
+ if (event.target.closest(INTERACTIVE))
286
+ return;
287
+ event.preventDefault();
288
+ activate(row, event, onRowClick);
289
+ }
290
+ }, className: cn("group/row border-t border-border/35 transition-colors", (onRowClick || onRowOpen) && "cursor-pointer hover:bg-muted/30",
291
+ // Anything focusable has to SHOW where the keyboard is —
292
+ // a list navigated with the arrows is focusable without
293
+ // being clickable, and silent focus leaves a keyboard
294
+ // user guessing which row the next key belongs to.
295
+ (onRowClick || onRowOpen || onActiveRowChange) &&
296
+ "focus-visible:bg-muted/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring", "data-[state=selected]:bg-primary/6 data-[active]:bg-primary/8", rowClassName?.(row)), children: [row.getVisibleCells().map((cell) => {
297
+ const meta = cell.column.columnDef.meta;
298
+ if (!fits(meta))
299
+ return null;
300
+ return (_jsx("td", { ...pinnedProps(cell.column.id, "cell"), style: pinnedStyle(cell.column.id), className: cn(cellPad, "align-middle", meta?.align === "right" && "text-right", meta?.align === "center" && "text-center", meta?.className), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
301
+ }), spacer ? _jsx("td", { "aria-hidden": "true", className: "w-full p-0" }) : null] }, row.id)))) })] }) }), _jsx(DataTableOverflow, { scroller: scrollerRef })] }));
301
302
  }
302
303
  function DefaultGroupLabel({ row }) {
303
304
  const grouping = row
@@ -0,0 +1,8 @@
1
+ /** Resize a focus-view side pane without remounting its chat or source. */
2
+ export declare function FocusPaneResize({ label, side, width, onResize, breakpoint, }: {
3
+ breakpoint?: "md" | "xl";
4
+ label: string;
5
+ side: "left" | "right";
6
+ width?: number;
7
+ onResize: (width?: number) => void;
8
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ /** Resize a focus-view side pane without remounting its chat or source. */
4
+ export function FocusPaneResize({ label, side, width, onResize, breakpoint = "md", }) {
5
+ const start = useRef(null);
6
+ const elementRef = useRef(null);
7
+ const [maximum, setMaximum] = useState(880);
8
+ const [renderedWidth, setRenderedWidth] = useState(240);
9
+ const getMaximum = (element) => {
10
+ const pane = element.parentElement;
11
+ const dialog = pane.closest('[role="dialog"]');
12
+ const other = [...(pane.parentElement?.querySelectorAll(":scope > aside") ?? [])]
13
+ .filter((n) => n !== pane && !["absolute", "fixed"].includes(getComputedStyle(n).position))
14
+ .reduce((sum, n) => sum + n.getBoundingClientRect().width, 0);
15
+ return Math.max(240, (dialog?.getBoundingClientRect().width ?? 1200) - other - 320);
16
+ };
17
+ useEffect(() => {
18
+ const element = elementRef.current;
19
+ if (!element)
20
+ return;
21
+ const update = () => {
22
+ const maximum = getMaximum(element);
23
+ setMaximum(maximum);
24
+ setRenderedWidth(Math.round(element.parentElement.getBoundingClientRect().width));
25
+ // Automatic percentage widths obey the same content reserve as a drag.
26
+ if (getComputedStyle(element).display !== "none" &&
27
+ element.parentElement.getBoundingClientRect().width > maximum)
28
+ onResize(maximum);
29
+ };
30
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
31
+ const dialog = element.closest('[role="dialog"]');
32
+ const layout = element.parentElement?.parentElement;
33
+ const observePanes = () => {
34
+ observer?.disconnect();
35
+ if (dialog) {
36
+ observer?.observe(dialog);
37
+ layout?.querySelectorAll(":scope > aside").forEach((pane) => observer?.observe(pane));
38
+ }
39
+ update();
40
+ };
41
+ observePanes();
42
+ const mutations = typeof MutationObserver === "undefined" ? null : new MutationObserver(observePanes);
43
+ if (layout)
44
+ mutations?.observe(layout, { childList: true });
45
+ return () => {
46
+ observer?.disconnect();
47
+ mutations?.disconnect();
48
+ };
49
+ }, [onResize]);
50
+ const adjust = (element, next) => {
51
+ const maximum = getMaximum(element);
52
+ setMaximum(maximum);
53
+ onResize(Math.round(Math.max(240, Math.min(maximum, next))));
54
+ };
55
+ return (_jsx("div", { ref: elementRef, role: "separator", "aria-orientation": "vertical", "aria-label": `Resize ${label}`, tabIndex: 0, "aria-valuemin": 240, "aria-valuemax": maximum, "aria-valuenow": width ?? renderedWidth, "aria-valuetext": width ? `${width} pixels` : `${renderedWidth} pixels (automatic)`, title: "Drag to resize. Double-click to reset.", className: `absolute inset-y-0 z-30 hidden w-2 cursor-col-resize touch-none hover:bg-border/60 focus-visible:bg-border focus-visible:outline-none ${breakpoint === "xl" ? "xl:block" : "md:block"} ${side === "right" ? "right-0" : "left-0"}`, onPointerDown: (event) => {
56
+ if (event.button !== 0)
57
+ return;
58
+ event.preventDefault();
59
+ start.current = {
60
+ x: event.clientX,
61
+ width: event.currentTarget.parentElement.getBoundingClientRect().width,
62
+ original: width,
63
+ };
64
+ event.currentTarget.setPointerCapture(event.pointerId);
65
+ }, onPointerMove: (event) => {
66
+ if (start.current)
67
+ adjust(event.currentTarget, start.current.width + (event.clientX - start.current.x) * (side === "right" ? 1 : -1));
68
+ }, onPointerUp: () => {
69
+ start.current = null;
70
+ }, onPointerCancel: () => {
71
+ if (start.current)
72
+ onResize(start.current.original);
73
+ start.current = null;
74
+ }, onLostPointerCapture: () => {
75
+ start.current = null;
76
+ }, onDoubleClick: () => onResize(), onKeyDown: (event) => {
77
+ if (event.key === "Home") {
78
+ event.preventDefault();
79
+ onResize();
80
+ return;
81
+ }
82
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight")
83
+ return;
84
+ event.preventDefault();
85
+ adjust(event.currentTarget, event.currentTarget.parentElement.getBoundingClientRect().width +
86
+ (event.key === "ArrowRight" ? 32 : -32) * (side === "right" ? 1 : -1));
87
+ } }));
88
+ }
@@ -0,0 +1,8 @@
1
+ export declare function FocusChatPane({ chatOpen, setChatOpen, chatWidth, setChatWidth, source, onClose, }: {
2
+ chatOpen: boolean;
3
+ setChatOpen: (v: boolean) => void;
4
+ chatWidth?: number;
5
+ setChatWidth: (v?: number) => void;
6
+ source: boolean;
7
+ onClose: () => void;
8
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, cn } from "@iloveagents/foundry-web-primitives";
3
+ import { X, PanelLeft, MessageSquare } from "lucide-react";
4
+ import { TooltipIconButton } from "./tooltip-icon-button.js";
5
+ import { FocusPaneResize } from "./data-table/focus-pane-resize.js";
6
+ import { ChatContent } from "./assistant-chat.js";
7
+ export function FocusChatPane({ chatOpen, setChatOpen, chatWidth, setChatWidth, source, onClose, }) {
8
+ return (_jsxs("aside", { "aria-label": "Focused content chat", style: { "--focus-chat-width": `${chatWidth ?? 384}px` }, className: cn("relative flex min-h-0 shrink-0 flex-col border-r border-border bg-background", chatOpen ? "w-full md:w-[var(--focus-chat-width)]" : "w-11", source && "hidden xl:flex"), children: [_jsx("div", { className: cn("flex h-12 shrink-0 items-center", chatOpen ? "gap-1 border-b border-border px-2" : "justify-center"), children: chatOpen ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "min-w-0 flex-1 px-2 text-sm font-semibold", children: "Chat" }), _jsx(TooltipIconButton, { tooltip: "Collapse chat", "aria-label": "Collapse chat", className: "size-8", onClick: () => setChatOpen(false), children: _jsx(PanelLeft, { size: 18 }) }), _jsx(Button, { variant: "ghost", size: "icon", title: "Close", "aria-label": "Close", className: "size-8 md:hidden", onClick: () => onClose(), children: _jsx(X, { size: 18 }) })] })) : (_jsx(TooltipIconButton, { tooltip: "Expand chat", "aria-label": "Expand chat", "aria-expanded": false, className: "size-8", onClick: () => setChatOpen(true), children: _jsx(MessageSquare, { size: 18 }) })) }), chatOpen && (_jsx(FocusPaneResize, { label: "chat", side: "right", width: chatWidth, onResize: setChatWidth })), chatOpen && (_jsx(ChatContent, { compactComposer: true, showPinButton: false, hideCurrentPageBadge: true, starterSuggestions: [] }))] }));
9
+ }
@@ -0,0 +1,8 @@
1
+ export declare function FocusDialog({ focused, setFocused, host, attach, title, onReturnFocus, }: {
2
+ focused: boolean;
3
+ setFocused: (v: boolean) => void;
4
+ host: HTMLElement | null;
5
+ attach: (el: HTMLDivElement | null) => void;
6
+ title: string;
7
+ onReturnFocus: () => void;
8
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,103 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { useAuiState } from "@assistant-ui/react";
4
+ import { Button, cn } from "@iloveagents/foundry-web-primitives";
5
+ import { X } from "lucide-react";
6
+ import { DialogContent, DialogTitle } from "../ui/dialog.js";
7
+ import { useToolPanelStore } from "../lib/tool-panel-store.js";
8
+ import { FocusChatPane } from "./focus-chat-pane.js";
9
+ import { FocusSourcePane } from "./focus-source-pane.js";
10
+ export function FocusDialog({ focused, setFocused, host, attach, title, onReturnFocus, }) {
11
+ const [chatOpen, setChatOpen] = useState(false);
12
+ const [chatWidth, setChatWidth] = useState();
13
+ const [sourceWidth, setSourceWidth] = useState();
14
+ const [source, setSource] = useState(null);
15
+ const hasChat = useAuiState((state) => Boolean(state.optional.thread));
16
+ const dialogRef = useRef(null);
17
+ const sourceTrigger = useRef(null);
18
+ const ownedSource = useRef(undefined);
19
+ const previousPanel = useRef(null);
20
+ const releaseSource = () => {
21
+ if (previousPanel.current &&
22
+ useToolPanelStore.getState().isWindowHosted &&
23
+ useToolPanelStore.getState().isOpen &&
24
+ useToolPanelStore.getState().content?.sourceId === ownedSource.current)
25
+ useToolPanelStore.setState(previousPanel.current);
26
+ previousPanel.current = null;
27
+ ownedSource.current = undefined;
28
+ };
29
+ const closeSource = () => {
30
+ releaseSource();
31
+ setSource(null);
32
+ requestAnimationFrame(() => sourceTrigger.current?.focus());
33
+ };
34
+ useEffect(() => {
35
+ if (!focused) {
36
+ releaseSource();
37
+ setChatOpen(false);
38
+ setSource(null);
39
+ }
40
+ }, [focused]);
41
+ useEffect(() => {
42
+ if (!focused)
43
+ return;
44
+ const openSource = (event) => {
45
+ const request = event;
46
+ if (request.defaultPrevented || typeof request.detail?.content !== "string")
47
+ return;
48
+ const dialogs = Array.from(document.querySelectorAll('[role="dialog"]:not([aria-modal="false"])'));
49
+ if (host?.closest('[role="dialog"]') !== dialogs[dialogs.length - 1])
50
+ return;
51
+ request.preventDefault();
52
+ if (event.type === "preview:open-source") {
53
+ const { content, isOpen, isWindowHosted, hostedOwnerContent } = useToolPanelStore.getState();
54
+ previousPanel.current ?? (previousPanel.current = { content, isOpen, isWindowHosted, hostedOwnerContent });
55
+ ownedSource.current = request.detail.sourceId;
56
+ }
57
+ const active = document.activeElement;
58
+ if (active instanceof HTMLElement && !active.closest('[aria-label="Citation source"]'))
59
+ sourceTrigger.current = active;
60
+ setSource(request.detail);
61
+ };
62
+ const updateSource = (event) => {
63
+ const content = event.detail;
64
+ if (ownedSource.current && content.sourceId === ownedSource.current)
65
+ setSource(content);
66
+ };
67
+ const unsubscribe = useToolPanelStore.subscribe((state) => {
68
+ const dialogs = Array.from(document.querySelectorAll('[role="dialog"]:not([aria-modal="false"])'));
69
+ const isActiveHost = host?.closest('[role="dialog"]') === dialogs[dialogs.length - 1];
70
+ if (ownedSource.current &&
71
+ (!state.isOpen ||
72
+ !state.isWindowHosted ||
73
+ (isActiveHost && state.content?.sourceId !== ownedSource.current))) {
74
+ previousPanel.current = null;
75
+ ownedSource.current = undefined;
76
+ setSource(null);
77
+ }
78
+ });
79
+ window.addEventListener("preview:update-source", updateSource);
80
+ window.addEventListener("focus:open-source", openSource);
81
+ window.addEventListener("preview:open-source", openSource, true);
82
+ return () => {
83
+ unsubscribe();
84
+ releaseSource();
85
+ window.removeEventListener("preview:update-source", updateSource);
86
+ window.removeEventListener("focus:open-source", openSource);
87
+ window.removeEventListener("preview:open-source", openSource, true);
88
+ };
89
+ }, [focused, host]);
90
+ return (_jsxs(DialogContent, { ref: dialogRef, tabIndex: -1, onOpenAutoFocus: (event) => {
91
+ event.preventDefault();
92
+ dialogRef.current?.focus();
93
+ }, showCloseButton: false, "aria-describedby": undefined, onEscapeKeyDown: (event) => {
94
+ event.preventDefault();
95
+ if (source)
96
+ closeSource();
97
+ else
98
+ setFocused(false);
99
+ }, onCloseAutoFocus: (event) => {
100
+ event.preventDefault();
101
+ onReturnFocus();
102
+ }, className: "inset-0 left-0 top-0 flex h-dvh w-screen max-w-none translate-x-0 translate-y-0 flex-row gap-0 rounded-none border-0 bg-background p-0 sm:max-w-none", children: [hasChat && (_jsx(FocusChatPane, { chatOpen: chatOpen, setChatOpen: setChatOpen, chatWidth: chatWidth, setChatWidth: setChatWidth, source: !!source, onClose: () => setFocused(false) })), _jsxs("div", { className: cn("min-h-0 min-w-0 flex-1 flex-col", source ? "hidden xl:flex" : chatOpen ? "hidden md:flex" : "flex"), children: [_jsxs("div", { className: "flex h-12 shrink-0 items-center gap-3 border-b border-border px-4", children: [_jsx(DialogTitle, { className: "min-w-0 flex-1 truncate text-sm", children: title }), _jsx(Button, { variant: "ghost", size: "icon", title: "Close", "aria-label": "Close", className: "size-8", onClick: () => setFocused(false), children: _jsx(X, { size: 18 }) })] }), _jsx("div", { ref: focused ? attach : undefined, className: "flex min-h-0 min-w-0 flex-1 flex-col" })] }), source && (_jsx(FocusSourcePane, { source: source, sourceWidth: sourceWidth, setSourceWidth: setSourceWidth, closeSource: closeSource }))] }));
103
+ }
@@ -0,0 +1,7 @@
1
+ import type { ToolPanelContent } from "../lib/tool-panel-store.js";
2
+ export declare function FocusSourcePane({ source, sourceWidth, setSourceWidth, closeSource, }: {
3
+ source: ToolPanelContent;
4
+ sourceWidth?: number;
5
+ setSourceWidth: (v?: number) => void;
6
+ closeSource: () => void;
7
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ import { Button } from "@iloveagents/foundry-web-primitives";
4
+ import { X } from "lucide-react";
5
+ import { FocusPaneResize } from "./data-table/focus-pane-resize.js";
6
+ import { PanelContentRenderer } from "./tool-panel.js";
7
+ export function FocusSourcePane({ source, sourceWidth, setSourceWidth, closeSource, }) {
8
+ const closeRef = useRef(null);
9
+ useEffect(() => {
10
+ closeRef.current?.focus();
11
+ }, []);
12
+ return (_jsxs("aside", { "aria-label": "Citation source", style: {
13
+ "--focus-source-width": sourceWidth ? `${sourceWidth}px` : "40%",
14
+ }, "data-focus-source-entity": source.entityId, "data-focus-source-title": source.title, className: "absolute inset-0 z-20 flex min-h-0 flex-col border-l border-border bg-background xl:relative xl:inset-auto xl:w-[var(--focus-source-width)] xl:min-w-60 xl:shrink-0", children: [_jsx(FocusPaneResize, { breakpoint: "xl", label: "source", side: "left", width: sourceWidth, onResize: setSourceWidth }), _jsxs("div", { className: "flex h-12 shrink-0 items-center gap-2 border-b border-border px-4", children: [_jsx("span", { className: "min-w-0 flex-1 truncate text-sm font-medium", title: source.title, children: source.title }), _jsx(Button, { ref: closeRef, variant: "ghost", size: "icon", "aria-label": "Close citation", title: "Close citation", className: "size-8", onClick: closeSource, children: _jsx(X, { size: 18 }) })] }), _jsx("div", { className: "relative min-h-0 flex-1 overflow-auto", children: _jsx(PanelContentRenderer, { content: source }) })] }));
15
+ }
@@ -36,5 +36,5 @@ export function LoadingIndicator() {
36
36
  // above it — measured, not guessed: the rows sit at x=469 and so does
37
37
  // this. The old `h-10` did NOT belong: a fixed 40px block on top of the
38
38
  // parts' own gap-4 left a visible hole under the last tool row.
39
- _jsx("div", { className: "min-h-6 px-4", children: _jsxs("div", { className: "flex items-center gap-2 py-0.5 text-foreground leading-relaxed animate-in fade-in duration-300", children: [_jsx("span", { className: stalled ? "text-sm text-warning" : "text-sm text-muted-foreground", children: label }), _jsx("span", { "aria-hidden": "true", className: `size-1.5 rounded-full ${stalled ? "bg-warning/70" : "bg-primary/60"} animate-[pulse_1.4s_ease-in-out_infinite]` })] }) }));
39
+ _jsx("div", { role: "status", "aria-live": "polite", className: "min-h-6 px-4", children: _jsxs("div", { className: "flex items-center gap-2 py-0.5 text-foreground leading-relaxed animate-in fade-in duration-300", children: [_jsx("span", { className: stalled ? "text-xs text-warning" : "text-xs text-muted-foreground", children: label }), _jsx("span", { "aria-hidden": "true", className: `size-1.5 rounded-full ${stalled ? "bg-warning/70" : "bg-primary/60"} animate-[pulse_1.4s_ease-in-out_infinite]` })] }) }));
40
40
  }