@iloveagents/foundry-web-ui 0.14.2 → 0.17.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/README.md +23 -0
- package/dist/components/assistant-chat.js +1 -1
- package/dist/components/chat-message-parts.d.ts +12 -7
- package/dist/components/chat-message-parts.js +52 -17
- package/dist/components/data-table/data-table-column-header.d.ts +8 -0
- package/dist/components/data-table/data-table-column-header.js +12 -0
- package/dist/components/data-table/data-table-faceted-filter.d.ts +16 -0
- package/dist/components/data-table/data-table-faceted-filter.js +32 -0
- package/dist/components/data-table/data-table-pagination.d.ts +8 -0
- package/dist/components/data-table/data-table-pagination.js +15 -0
- package/dist/components/data-table/data-table-row-actions.d.ts +16 -0
- package/dist/components/data-table/data-table-row-actions.js +11 -0
- package/dist/components/data-table/data-table-toolbar.d.ts +27 -0
- package/dist/components/data-table/data-table-toolbar.js +18 -0
- package/dist/components/data-table/data-table-view-options.d.ts +7 -0
- package/dist/components/data-table/data-table-view-options.js +11 -0
- package/dist/components/data-table/data-table.d.ts +25 -0
- package/dist/components/data-table/data-table.js +65 -0
- package/dist/components/data-table/facets.d.ts +82 -0
- package/dist/components/data-table/facets.js +156 -0
- package/dist/components/data-table/selection-column.d.ts +4 -0
- package/dist/components/data-table/selection-column.js +19 -0
- package/dist/components/data-table/state.d.ts +41 -0
- package/dist/components/data-table/state.js +148 -0
- package/dist/components/data-table/use-data-table.d.ts +22 -0
- package/dist/components/data-table/use-data-table.js +102 -0
- package/dist/components/message-disclosure.d.ts +6 -18
- package/dist/components/message-disclosure.js +24 -9
- package/dist/components/reasoning-effort-picker.js +38 -2
- package/dist/components/reasoning-part.d.ts +8 -5
- package/dist/components/reasoning-part.js +15 -11
- package/dist/components/sidebar.js +22 -20
- package/dist/components/turn-activity.d.ts +33 -0
- package/dist/components/turn-activity.js +110 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.js +14 -2
- package/dist/lib/nav-config.d.ts +16 -0
- package/dist/ui/dropdown-menu.d.ts +1 -0
- package/dist/ui/dropdown-menu.js +4 -1
- package/package.json +5 -4
- package/dist/components/tool-run-group.d.ts +0 -29
- package/dist/components/tool-run-group.js +0 -56
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** Facet key for empty cells (`null`, `undefined`, `""`, `[]`). */
|
|
2
|
+
export const FACET_EMPTY = "__none__";
|
|
3
|
+
/**
|
|
4
|
+
* Keys a cell value contributes to a facet — arrays contribute one key per
|
|
5
|
+
* element. A real value that spells the empty sentinel is escaped with a
|
|
6
|
+
* leading backslash so it never masquerades as "no value".
|
|
7
|
+
*/
|
|
8
|
+
export function facetKeys(value) {
|
|
9
|
+
if (value == null || value === "")
|
|
10
|
+
return [FACET_EMPTY];
|
|
11
|
+
if (value === FACET_EMPTY)
|
|
12
|
+
return [`\\${FACET_EMPTY}`];
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
const keys = value.flatMap((item) => facetKeys(item)).filter((key) => key !== FACET_EMPTY);
|
|
15
|
+
return keys.length ? Array.from(new Set(keys)) : [FACET_EMPTY];
|
|
16
|
+
}
|
|
17
|
+
if (typeof value === "object") {
|
|
18
|
+
const record = value;
|
|
19
|
+
const key = record.id ?? record.value ?? record.name;
|
|
20
|
+
return key == null || key === "" ? [FACET_EMPTY] : [String(key)];
|
|
21
|
+
}
|
|
22
|
+
return [String(value)];
|
|
23
|
+
}
|
|
24
|
+
/** A facet filter value is a list of selected keys; anything else means "no filter". */
|
|
25
|
+
export function normalizeFacetValue(filterValue) {
|
|
26
|
+
if (Array.isArray(filterValue))
|
|
27
|
+
return filterValue.map(String).filter(Boolean);
|
|
28
|
+
if (filterValue == null || filterValue === "")
|
|
29
|
+
return [];
|
|
30
|
+
return [String(filterValue)];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* OR within a facet: a row passes when any of its keys is selected. Typed
|
|
34
|
+
* loosely so `filterFn: facetFilterFn` fits any row type; `useDataTable`
|
|
35
|
+
* also registers it as the string `"facet"` for untyped (JSX) columns.
|
|
36
|
+
*/
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
38
|
+
export const facetFilterFn = (row, columnId, filterValue) => {
|
|
39
|
+
const wanted = normalizeFacetValue(filterValue);
|
|
40
|
+
if (!wanted.length)
|
|
41
|
+
return true;
|
|
42
|
+
return facetKeys(row.getValue(columnId)).some((key) => wanted.includes(key));
|
|
43
|
+
};
|
|
44
|
+
facetFilterFn.autoRemove = (value) => normalizeFacetValue(value).length === 0;
|
|
45
|
+
/** Rows per facet key, counted over the rows every OTHER filter lets through. */
|
|
46
|
+
export function facetCounts(column) {
|
|
47
|
+
const counts = new Map();
|
|
48
|
+
for (const row of column.getFacetedRowModel().flatRows) {
|
|
49
|
+
for (const key of facetKeys(row.getValue(column.id))) {
|
|
50
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return counts;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Declared options first (in declared order, kept even at count 0), then
|
|
57
|
+
* every undeclared key seen in the data, then the empty option when some
|
|
58
|
+
* rows have no value.
|
|
59
|
+
*/
|
|
60
|
+
export function facetOptions(column, declared, options) {
|
|
61
|
+
const counts = facetCounts(column);
|
|
62
|
+
const labels = column.columnDef.meta?.facetLabels ?? {};
|
|
63
|
+
const labelFor = (key) => labels[key] ?? options?.labelFor?.(key) ?? key;
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const option of declared ?? []) {
|
|
67
|
+
seen.add(option.value);
|
|
68
|
+
out.push({ ...option, count: counts.get(option.value) ?? 0 });
|
|
69
|
+
}
|
|
70
|
+
const rest = Array.from(counts.keys())
|
|
71
|
+
.filter((key) => !seen.has(key) && key !== FACET_EMPTY)
|
|
72
|
+
.sort((a, b) => labelFor(a).localeCompare(labelFor(b)));
|
|
73
|
+
for (const key of rest)
|
|
74
|
+
out.push({ value: key, label: labelFor(key), count: counts.get(key) ?? 0 });
|
|
75
|
+
if (counts.has(FACET_EMPTY) && !seen.has(FACET_EMPTY)) {
|
|
76
|
+
out.push({
|
|
77
|
+
value: FACET_EMPTY,
|
|
78
|
+
label: labels[FACET_EMPTY] ?? options?.emptyLabel ?? "None",
|
|
79
|
+
count: counts.get(FACET_EMPTY) ?? 0,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** Plain-text projection of a cell value for search and agent read-outs. */
|
|
85
|
+
export function cellText(value) {
|
|
86
|
+
if (value == null)
|
|
87
|
+
return "";
|
|
88
|
+
if (typeof value === "string")
|
|
89
|
+
return value;
|
|
90
|
+
if (typeof value === "number" || typeof value === "bigint")
|
|
91
|
+
return String(value);
|
|
92
|
+
if (typeof value === "boolean")
|
|
93
|
+
return value ? "yes" : "no";
|
|
94
|
+
if (value instanceof Date)
|
|
95
|
+
return value.toISOString();
|
|
96
|
+
if (Array.isArray(value))
|
|
97
|
+
return value.map(cellText).filter(Boolean).join(" ");
|
|
98
|
+
if (typeof value === "object") {
|
|
99
|
+
const record = value;
|
|
100
|
+
if (typeof record.name === "string")
|
|
101
|
+
return record.name;
|
|
102
|
+
if (typeof record.label === "string")
|
|
103
|
+
return record.label;
|
|
104
|
+
try {
|
|
105
|
+
return JSON.stringify(value);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return String(value);
|
|
112
|
+
}
|
|
113
|
+
const searchTextCache = new WeakMap();
|
|
114
|
+
/**
|
|
115
|
+
* Lower-cased text of every searchable cell of a row. Cached per row object
|
|
116
|
+
* and invalidated when the row's cells change identity (new columns or a
|
|
117
|
+
* changed `meta.searchText`), since TanStack keeps row objects across
|
|
118
|
+
* column changes.
|
|
119
|
+
*/
|
|
120
|
+
export function rowSearchText(row) {
|
|
121
|
+
const cells = row.getAllCells();
|
|
122
|
+
const cached = searchTextCache.get(row);
|
|
123
|
+
if (cached && cached.cells === cells)
|
|
124
|
+
return cached.text;
|
|
125
|
+
const text = cells
|
|
126
|
+
.filter((cell) => cell.column.getCanGlobalFilter())
|
|
127
|
+
.map((cell) => {
|
|
128
|
+
const custom = cell.column.columnDef.meta?.searchText;
|
|
129
|
+
return custom ? custom(row.original) : cellText(cell.getValue());
|
|
130
|
+
})
|
|
131
|
+
.join(" ")
|
|
132
|
+
.toLowerCase();
|
|
133
|
+
searchTextCache.set(row, { cells, text });
|
|
134
|
+
return text;
|
|
135
|
+
}
|
|
136
|
+
/** Every whitespace-separated token must appear somewhere in the row (AND across tokens). */
|
|
137
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
138
|
+
export const tokenSearchFilterFn = (row, _columnId, filterValue) => {
|
|
139
|
+
const tokens = String(filterValue ?? "")
|
|
140
|
+
.toLowerCase()
|
|
141
|
+
.split(/\s+/)
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
if (!tokens.length)
|
|
144
|
+
return true;
|
|
145
|
+
const hay = rowSearchText(row);
|
|
146
|
+
return tokens.every((token) => hay.includes(token));
|
|
147
|
+
};
|
|
148
|
+
tokenSearchFilterFn.autoRemove = (value) => !String(value ?? "").trim();
|
|
149
|
+
/** Human label of a column: `meta.label`, then a string header, then the id. */
|
|
150
|
+
export function columnLabel(column) {
|
|
151
|
+
const meta = column.columnDef.meta;
|
|
152
|
+
if (meta?.label)
|
|
153
|
+
return meta.label;
|
|
154
|
+
const header = column.columnDef.header;
|
|
155
|
+
return typeof header === "string" ? header : column.id;
|
|
156
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Checkbox } from "@iloveagents/foundry-web-primitives";
|
|
3
|
+
export const SELECTION_COLUMN_ID = "__select";
|
|
4
|
+
export function selectionColumn() {
|
|
5
|
+
return {
|
|
6
|
+
id: SELECTION_COLUMN_ID,
|
|
7
|
+
size: 36,
|
|
8
|
+
enableSorting: false,
|
|
9
|
+
enableHiding: false,
|
|
10
|
+
enableGlobalFilter: false,
|
|
11
|
+
header: ({ table }) => (_jsx(Checkbox, { checked: table.getIsAllPageRowsSelected()
|
|
12
|
+
? true
|
|
13
|
+
: table.getIsSomePageRowsSelected()
|
|
14
|
+
? "indeterminate"
|
|
15
|
+
: false, onCheckedChange: (value) => table.toggleAllPageRowsSelected(Boolean(value)), "aria-label": "Select all", className: "translate-y-0.5" })),
|
|
16
|
+
cell: ({ row }) => (_jsx(Checkbox, { checked: row.getIsSelected(), disabled: !row.getCanSelect(), onCheckedChange: (value) => row.toggleSelected(Boolean(value)), "aria-label": "Select row", className: "translate-y-0.5" })),
|
|
17
|
+
meta: { label: "Select", headerClassName: "w-9", className: "w-9" },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole user-facing state of a DataTable as one value, plus a URL codec.
|
|
3
|
+
*
|
|
4
|
+
* URL keys are shared with the older collection pages (`q`, `sort`, `f_<id>`)
|
|
5
|
+
* so a link that worked before keeps working; `hide`, `page` and `size` are
|
|
6
|
+
* new. Selection and expansion are never put in the URL. A present-but-empty
|
|
7
|
+
* key (`f_status=`, `sort=`, `hide=`, `q=`) means "cleared", so a default the
|
|
8
|
+
* host configured can be switched off by the user and survive a reload.
|
|
9
|
+
*/
|
|
10
|
+
import type { ColumnFiltersState, ExpandedState, PaginationState, RowSelectionState, SortingState, VisibilityState } from "@tanstack/react-table";
|
|
11
|
+
export interface DataTableState {
|
|
12
|
+
globalFilter: string;
|
|
13
|
+
columnFilters: ColumnFiltersState;
|
|
14
|
+
sorting: SortingState;
|
|
15
|
+
columnVisibility: VisibilityState;
|
|
16
|
+
rowSelection: RowSelectionState;
|
|
17
|
+
pagination: PaginationState;
|
|
18
|
+
expanded: ExpandedState;
|
|
19
|
+
}
|
|
20
|
+
export declare const DEFAULT_PAGE_SIZE = 20;
|
|
21
|
+
export declare const EMPTY_DATA_TABLE_STATE: DataTableState;
|
|
22
|
+
export declare function isDataTableUrlKey(key: string): boolean;
|
|
23
|
+
export interface DataTableUrlOptions {
|
|
24
|
+
/** Page size that is implied when `size` is absent (default 20). */
|
|
25
|
+
defaultPageSize?: number;
|
|
26
|
+
/**
|
|
27
|
+
* State implied when the URL says nothing. Serializing writes an explicit
|
|
28
|
+
* empty key when the user cleared a value the defaults would bring back.
|
|
29
|
+
*/
|
|
30
|
+
defaults?: Partial<DataTableState>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Serialize the URL-worthy part of the state. A key whose value equals the
|
|
34
|
+
* default is omitted; a key the user cleared while a default exists is
|
|
35
|
+
* written empty; a key absent from a partial `state` is omitted.
|
|
36
|
+
*/
|
|
37
|
+
export declare function serializeDataTableState(state: Partial<DataTableState>, options?: DataTableUrlOptions): URLSearchParams;
|
|
38
|
+
/** Parse a URL into a full state; `defaults` fill what the URL doesn't say. */
|
|
39
|
+
export declare function parseDataTableState(params: URLSearchParams, options?: DataTableUrlOptions): DataTableState;
|
|
40
|
+
/** Replace this table's keys in `params`, leaving every other key untouched. */
|
|
41
|
+
export declare function mergeDataTableUrlState(params: URLSearchParams, state: Partial<DataTableState>, options?: DataTableUrlOptions): URLSearchParams;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
export const DEFAULT_PAGE_SIZE = 20;
|
|
2
|
+
export const EMPTY_DATA_TABLE_STATE = {
|
|
3
|
+
globalFilter: "",
|
|
4
|
+
columnFilters: [],
|
|
5
|
+
sorting: [],
|
|
6
|
+
columnVisibility: {},
|
|
7
|
+
rowSelection: {},
|
|
8
|
+
pagination: { pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE },
|
|
9
|
+
expanded: true,
|
|
10
|
+
};
|
|
11
|
+
const FILTER_PREFIX = "f_";
|
|
12
|
+
const OWN_KEYS = new Set(["q", "sort", "hide", "page", "size"]);
|
|
13
|
+
export function isDataTableUrlKey(key) {
|
|
14
|
+
return OWN_KEYS.has(key) || key.startsWith(FILTER_PREFIX);
|
|
15
|
+
}
|
|
16
|
+
function encodePart(value) {
|
|
17
|
+
return value.replace(/%/g, "%25").replace(/,/g, "%2C");
|
|
18
|
+
}
|
|
19
|
+
function decodePart(value) {
|
|
20
|
+
return value.replace(/%2C/gi, ",").replace(/%2D/gi, "-").replace(/%25/g, "%");
|
|
21
|
+
}
|
|
22
|
+
/** Sort ids may contain commas or start with "-"; both are escaped. */
|
|
23
|
+
function encodeSortId(id) {
|
|
24
|
+
const encoded = encodePart(id);
|
|
25
|
+
return encoded.startsWith("-") ? `%2D${encoded.slice(1)}` : encoded;
|
|
26
|
+
}
|
|
27
|
+
function filterValues(value) {
|
|
28
|
+
if (Array.isArray(value))
|
|
29
|
+
return value.map(String).filter(Boolean);
|
|
30
|
+
return value == null || value === "" ? [] : [String(value)];
|
|
31
|
+
}
|
|
32
|
+
function sortKey(sorting) {
|
|
33
|
+
return (sorting ?? [])
|
|
34
|
+
.map((s) => (s.desc ? `-${encodeSortId(s.id)}` : encodeSortId(s.id)))
|
|
35
|
+
.join(",");
|
|
36
|
+
}
|
|
37
|
+
function hiddenKey(visibility) {
|
|
38
|
+
return Object.entries(visibility ?? {})
|
|
39
|
+
.filter(([, visible]) => visible === false)
|
|
40
|
+
.map(([id]) => encodePart(id))
|
|
41
|
+
.sort()
|
|
42
|
+
.join(",");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Serialize the URL-worthy part of the state. A key whose value equals the
|
|
46
|
+
* default is omitted; a key the user cleared while a default exists is
|
|
47
|
+
* written empty; a key absent from a partial `state` is omitted.
|
|
48
|
+
*/
|
|
49
|
+
export function serializeDataTableState(state, options) {
|
|
50
|
+
const defaults = options?.defaults ?? {};
|
|
51
|
+
const params = new URLSearchParams();
|
|
52
|
+
if (state.globalFilter !== undefined && state.globalFilter !== (defaults.globalFilter ?? "")) {
|
|
53
|
+
params.set("q", state.globalFilter);
|
|
54
|
+
}
|
|
55
|
+
if (state.sorting !== undefined && sortKey(state.sorting) !== sortKey(defaults.sorting)) {
|
|
56
|
+
params.set("sort", sortKey(state.sorting));
|
|
57
|
+
}
|
|
58
|
+
if (state.columnFilters !== undefined) {
|
|
59
|
+
const current = new Map(state.columnFilters.map((f) => [f.id, filterValues(f.value).map(encodePart).join(",")]));
|
|
60
|
+
const base = new Map((defaults.columnFilters ?? []).map((f) => [
|
|
61
|
+
f.id,
|
|
62
|
+
filterValues(f.value).map(encodePart).join(","),
|
|
63
|
+
]));
|
|
64
|
+
for (const id of new Set([...current.keys(), ...base.keys()])) {
|
|
65
|
+
const value = current.get(id) ?? "";
|
|
66
|
+
if (value !== (base.get(id) ?? ""))
|
|
67
|
+
params.set(`${FILTER_PREFIX}${id}`, value);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (state.columnVisibility !== undefined &&
|
|
71
|
+
hiddenKey(state.columnVisibility) !== hiddenKey(defaults.columnVisibility)) {
|
|
72
|
+
params.set("hide", hiddenKey(state.columnVisibility));
|
|
73
|
+
}
|
|
74
|
+
const pagination = state.pagination;
|
|
75
|
+
if (pagination) {
|
|
76
|
+
const defaultIndex = defaults.pagination?.pageIndex ?? 0;
|
|
77
|
+
if (pagination.pageIndex !== defaultIndex)
|
|
78
|
+
params.set("page", String(pagination.pageIndex + 1));
|
|
79
|
+
const defaultSize = options?.defaultPageSize ?? defaults.pagination?.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
80
|
+
if (pagination.pageSize !== defaultSize)
|
|
81
|
+
params.set("size", String(pagination.pageSize));
|
|
82
|
+
}
|
|
83
|
+
return params;
|
|
84
|
+
}
|
|
85
|
+
/** Parse a URL into a full state; `defaults` fill what the URL doesn't say. */
|
|
86
|
+
export function parseDataTableState(params, options) {
|
|
87
|
+
const defaults = { ...EMPTY_DATA_TABLE_STATE, ...options?.defaults };
|
|
88
|
+
const defaultSize = options?.defaultPageSize ?? defaults.pagination.pageSize;
|
|
89
|
+
const sortRaw = params.get("sort");
|
|
90
|
+
const sorting = sortRaw == null
|
|
91
|
+
? defaults.sorting
|
|
92
|
+
: sortRaw
|
|
93
|
+
.split(",")
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.map((part) => part.startsWith("-")
|
|
96
|
+
? { id: decodePart(part.slice(1)), desc: true }
|
|
97
|
+
: { id: decodePart(part), desc: false });
|
|
98
|
+
const filtersById = new Map(defaults.columnFilters.map((filter) => [filter.id, filter]));
|
|
99
|
+
for (const [key, raw] of params.entries()) {
|
|
100
|
+
if (!key.startsWith(FILTER_PREFIX))
|
|
101
|
+
continue;
|
|
102
|
+
const id = key.slice(FILTER_PREFIX.length);
|
|
103
|
+
const values = raw.split(",").filter(Boolean).map(decodePart);
|
|
104
|
+
if (values.length)
|
|
105
|
+
filtersById.set(id, { id, value: values });
|
|
106
|
+
else
|
|
107
|
+
filtersById.delete(id);
|
|
108
|
+
}
|
|
109
|
+
const columnFilters = Array.from(filtersById.values());
|
|
110
|
+
const columnVisibility = { ...defaults.columnVisibility };
|
|
111
|
+
const hideRaw = params.get("hide");
|
|
112
|
+
if (hideRaw != null) {
|
|
113
|
+
for (const id of Object.keys(columnVisibility)) {
|
|
114
|
+
if (columnVisibility[id] === false)
|
|
115
|
+
delete columnVisibility[id];
|
|
116
|
+
}
|
|
117
|
+
for (const id of hideRaw.split(",").filter(Boolean).map(decodePart))
|
|
118
|
+
columnVisibility[id] = false;
|
|
119
|
+
}
|
|
120
|
+
const pageRaw = params.get("page");
|
|
121
|
+
const page = Number.parseInt(pageRaw ?? "", 10);
|
|
122
|
+
const size = Number.parseInt(params.get("size") ?? "", 10);
|
|
123
|
+
return {
|
|
124
|
+
...defaults,
|
|
125
|
+
globalFilter: params.has("q") ? (params.get("q") ?? "") : defaults.globalFilter,
|
|
126
|
+
sorting,
|
|
127
|
+
columnFilters,
|
|
128
|
+
columnVisibility,
|
|
129
|
+
pagination: {
|
|
130
|
+
pageIndex: pageRaw == null
|
|
131
|
+
? defaults.pagination.pageIndex
|
|
132
|
+
: Number.isFinite(page) && page > 1
|
|
133
|
+
? page - 1
|
|
134
|
+
: 0,
|
|
135
|
+
pageSize: Number.isFinite(size) && size > 0 ? size : defaultSize,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** Replace this table's keys in `params`, leaving every other key untouched. */
|
|
140
|
+
export function mergeDataTableUrlState(params, state, options) {
|
|
141
|
+
const next = new URLSearchParams();
|
|
142
|
+
for (const [key, value] of params.entries())
|
|
143
|
+
if (!isDataTableUrlKey(key))
|
|
144
|
+
next.append(key, value);
|
|
145
|
+
for (const [key, value] of serializeDataTableState(state, options).entries())
|
|
146
|
+
next.set(key, value);
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ColumnDef, type FilterFn, type Row, type Table } from "@tanstack/react-table";
|
|
2
|
+
import { type DataTableState } from "./state.js";
|
|
3
|
+
export interface UseDataTableOptions<T> {
|
|
4
|
+
data: T[];
|
|
5
|
+
columns: ColumnDef<T, any>[];
|
|
6
|
+
/** Stable row id (defaults to the row index). Required for selection that survives re-sorting. */
|
|
7
|
+
getRowId?: (row: T, index: number, parent?: Row<T>) => string;
|
|
8
|
+
/** Controlled state — pair with `onStateChange` (e.g. URL-bound). */
|
|
9
|
+
state?: Partial<DataTableState>;
|
|
10
|
+
onStateChange?: (next: DataTableState) => void;
|
|
11
|
+
/** Uncontrolled start state. */
|
|
12
|
+
initialState?: Partial<DataTableState>;
|
|
13
|
+
/** Rows per page; omit (or `false`) for an unpaginated table. Ignored while grouped. */
|
|
14
|
+
pageSize?: number | false;
|
|
15
|
+
/** Column id to group rows by (one level). */
|
|
16
|
+
groupBy?: string | null;
|
|
17
|
+
enableRowSelection?: boolean | ((row: Row<T>) => boolean);
|
|
18
|
+
enableMultiRowSelection?: boolean;
|
|
19
|
+
/** Replace the default AND-of-tokens search. */
|
|
20
|
+
globalFilterFn?: FilterFn<T>;
|
|
21
|
+
}
|
|
22
|
+
export declare function useDataTable<T>(options: UseDataTableOptions<T>): Table<T>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One hook that turns rows + columns into a fully wired TanStack table:
|
|
3
|
+
* token search, faceted filters, sorting, column visibility, selection,
|
|
4
|
+
* optional pagination and optional single-level grouping — with the whole
|
|
5
|
+
* state as ONE value so it can live in component state or in the URL.
|
|
6
|
+
*/
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
8
|
+
import { getCoreRowModel, getExpandedRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getGroupedRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table";
|
|
9
|
+
import { facetFilterFn, tokenSearchFilterFn } from "./facets.js";
|
|
10
|
+
import { DEFAULT_PAGE_SIZE, EMPTY_DATA_TABLE_STATE } from "./state.js";
|
|
11
|
+
const PAGE_RESET_KEYS = new Set(["globalFilter", "columnFilters", "sorting"]);
|
|
12
|
+
export function useDataTable(options) {
|
|
13
|
+
const { data, columns, getRowId, state: controlledState, onStateChange, initialState, pageSize, groupBy, enableRowSelection = false, enableMultiRowSelection = true, globalFilterFn, } = options;
|
|
14
|
+
const controlled = controlledState !== undefined && onStateChange !== undefined;
|
|
15
|
+
const basePageSize = pageSize || DEFAULT_PAGE_SIZE;
|
|
16
|
+
const [innerState, setInnerState] = useState(() => ({
|
|
17
|
+
...EMPTY_DATA_TABLE_STATE,
|
|
18
|
+
...initialState,
|
|
19
|
+
pagination: { pageIndex: 0, pageSize: basePageSize, ...initialState?.pagination },
|
|
20
|
+
}));
|
|
21
|
+
const state = useMemo(() => controlled
|
|
22
|
+
? {
|
|
23
|
+
...EMPTY_DATA_TABLE_STATE,
|
|
24
|
+
...controlledState,
|
|
25
|
+
pagination: { pageIndex: 0, pageSize: basePageSize, ...controlledState.pagination },
|
|
26
|
+
}
|
|
27
|
+
: innerState, [basePageSize, controlled, controlledState, innerState]);
|
|
28
|
+
const stateRef = useRef(state);
|
|
29
|
+
stateRef.current = state;
|
|
30
|
+
const tableRef = useRef(null);
|
|
31
|
+
const update = useCallback((key, updater) => {
|
|
32
|
+
const current = stateRef.current;
|
|
33
|
+
const value = typeof updater === "function"
|
|
34
|
+
? updater(current[key])
|
|
35
|
+
: updater;
|
|
36
|
+
if (Object.is(value, current[key]))
|
|
37
|
+
return;
|
|
38
|
+
const next = { ...current, [key]: value };
|
|
39
|
+
if (PAGE_RESET_KEYS.has(key) && next.pagination.pageIndex !== 0) {
|
|
40
|
+
next.pagination = { ...next.pagination, pageIndex: 0 };
|
|
41
|
+
}
|
|
42
|
+
stateRef.current = next;
|
|
43
|
+
// Apply to the instance now, not on the next render: a caller that
|
|
44
|
+
// reads row models right after a mutation (agent tools, tests) sees
|
|
45
|
+
// the new state even while the host owns it (URL, store).
|
|
46
|
+
tableRef.current?.setOptions((prev) => ({
|
|
47
|
+
...prev,
|
|
48
|
+
state: { ...prev.state, [key]: value, pagination: next.pagination },
|
|
49
|
+
}));
|
|
50
|
+
if (controlled)
|
|
51
|
+
onStateChange(next);
|
|
52
|
+
else
|
|
53
|
+
setInnerState(next);
|
|
54
|
+
}, [controlled, onStateChange]);
|
|
55
|
+
const grouped = Boolean(groupBy);
|
|
56
|
+
const tableOptions = {
|
|
57
|
+
data,
|
|
58
|
+
columns,
|
|
59
|
+
getRowId,
|
|
60
|
+
state: { ...state, grouping: groupBy ? [groupBy] : [] },
|
|
61
|
+
onGlobalFilterChange: (updater) => update("globalFilter", updater),
|
|
62
|
+
onColumnFiltersChange: (updater) => update("columnFilters", updater),
|
|
63
|
+
onSortingChange: (updater) => update("sorting", updater),
|
|
64
|
+
onColumnVisibilityChange: (updater) => update("columnVisibility", updater),
|
|
65
|
+
onRowSelectionChange: (updater) => update("rowSelection", updater),
|
|
66
|
+
onPaginationChange: (updater) => update("pagination", updater),
|
|
67
|
+
onExpandedChange: (updater) => update("expanded", updater),
|
|
68
|
+
// String alias for untyped column defs (JSX renderers): `filterFn: "facet"`.
|
|
69
|
+
filterFns: { facet: facetFilterFn },
|
|
70
|
+
globalFilterFn: globalFilterFn ?? tokenSearchFilterFn,
|
|
71
|
+
getColumnCanGlobalFilter: (column) => column.columnDef.enableGlobalFilter ?? column.accessorFn != null,
|
|
72
|
+
enableRowSelection,
|
|
73
|
+
enableMultiRowSelection,
|
|
74
|
+
groupedColumnMode: false,
|
|
75
|
+
autoResetPageIndex: false,
|
|
76
|
+
autoResetExpanded: false,
|
|
77
|
+
getCoreRowModel: getCoreRowModel(),
|
|
78
|
+
getFilteredRowModel: getFilteredRowModel(),
|
|
79
|
+
getSortedRowModel: getSortedRowModel(),
|
|
80
|
+
getFacetedRowModel: getFacetedRowModel(),
|
|
81
|
+
getFacetedUniqueValues: getFacetedUniqueValues(),
|
|
82
|
+
};
|
|
83
|
+
if (grouped) {
|
|
84
|
+
tableOptions.getGroupedRowModel = getGroupedRowModel();
|
|
85
|
+
tableOptions.getExpandedRowModel = getExpandedRowModel();
|
|
86
|
+
}
|
|
87
|
+
else if (pageSize) {
|
|
88
|
+
tableOptions.getPaginationRowModel = getPaginationRowModel();
|
|
89
|
+
}
|
|
90
|
+
const table = useReactTable(tableOptions);
|
|
91
|
+
tableRef.current = table;
|
|
92
|
+
// A shrinking data set (or a tighter filter) can strand a later page.
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (grouped || !pageSize)
|
|
95
|
+
return;
|
|
96
|
+
const index = table.getState().pagination.pageIndex;
|
|
97
|
+
const count = table.getPageCount();
|
|
98
|
+
if (index > 0 && index >= count)
|
|
99
|
+
table.setPageIndex(Math.max(0, count - 1));
|
|
100
|
+
});
|
|
101
|
+
return table;
|
|
102
|
+
}
|
|
@@ -9,10 +9,11 @@ import type { ReactNode } from "react";
|
|
|
9
9
|
* different paddings, different hover treatments, different default states.
|
|
10
10
|
* Read together in a transcript they looked like two unrelated widgets.
|
|
11
11
|
*
|
|
12
|
-
* So the shape lives here once. A row is:
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* So the shape lives here once. A row is: the part's own icon, its label,
|
|
13
|
+
* then the disclosure caret — on a single baseline, with identical metrics.
|
|
14
|
+
* The icon leads because it is the glyph that carries meaning (what kind of
|
|
15
|
+
* thing is folded away, and whether it went well); the caret trails, where
|
|
16
|
+
* the eye lands after the words and where every chat client puts it.
|
|
16
17
|
*
|
|
17
18
|
* Everything is understated on purpose. These rows sit between the user's
|
|
18
19
|
* question and the answer, and they are machinery, not content: muted until
|
|
@@ -20,7 +21,7 @@ import type { ReactNode } from "react";
|
|
|
20
21
|
* hairline rule, so an open row reads as a margin note rather than a second
|
|
21
22
|
* message.
|
|
22
23
|
*/
|
|
23
|
-
export declare function MessageDisclosure({ icon, label, open, onOpenChange, tone,
|
|
24
|
+
export declare function MessageDisclosure({ icon, label, open, onOpenChange, tone, ariaLabel, children, ...rest }: {
|
|
24
25
|
/** The part's own icon — a brain, a wrench, a status glyph. */
|
|
25
26
|
icon: ReactNode;
|
|
26
27
|
label: ReactNode;
|
|
@@ -28,19 +29,6 @@ export declare function MessageDisclosure({ icon, label, open, onOpenChange, ton
|
|
|
28
29
|
onOpenChange: (open: boolean) => void;
|
|
29
30
|
/** ``destructive`` is for a run where everything failed; nothing else. */
|
|
30
31
|
tone?: "muted" | "destructive";
|
|
31
|
-
/**
|
|
32
|
-
* ``after-tools`` sits the row between the tool runs and the answer,
|
|
33
|
-
* wherever the model actually emitted it — reasoning belongs under the
|
|
34
|
-
* work it explains and above the conclusion it produced. (The message
|
|
35
|
-
* text is ordered after it; see `ChatMessageParts`.)
|
|
36
|
-
*
|
|
37
|
-
* It is a flex `order`, so the row moves VISUALLY while staying put in the
|
|
38
|
-
* DOM. That is exactly why such a row carries a different attribute: the
|
|
39
|
-
* compaction below is a DOM-sibling rule, and a row that is first in the
|
|
40
|
-
* DOM but second on screen would otherwise hand its negative margin to
|
|
41
|
-
* whichever row really is on top.
|
|
42
|
-
*/
|
|
43
|
-
placement?: "inline" | "after-tools";
|
|
44
32
|
ariaLabel?: string;
|
|
45
33
|
children: ReactNode;
|
|
46
34
|
} & Record<`data-${string}`, unknown>): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { ChevronDown } from "lucide-react";
|
|
3
3
|
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
4
4
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
|
|
5
5
|
/**
|
|
@@ -12,10 +12,11 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/colla
|
|
|
12
12
|
* different paddings, different hover treatments, different default states.
|
|
13
13
|
* Read together in a transcript they looked like two unrelated widgets.
|
|
14
14
|
*
|
|
15
|
-
* So the shape lives here once. A row is:
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* So the shape lives here once. A row is: the part's own icon, its label,
|
|
16
|
+
* then the disclosure caret — on a single baseline, with identical metrics.
|
|
17
|
+
* The icon leads because it is the glyph that carries meaning (what kind of
|
|
18
|
+
* thing is folded away, and whether it went well); the caret trails, where
|
|
19
|
+
* the eye lands after the words and where every chat client puts it.
|
|
19
20
|
*
|
|
20
21
|
* Everything is understated on purpose. These rows sit between the user's
|
|
21
22
|
* question and the answer, and they are machinery, not content: muted until
|
|
@@ -23,8 +24,22 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/colla
|
|
|
23
24
|
* hairline rule, so an open row reads as a margin note rather than a second
|
|
24
25
|
* message.
|
|
25
26
|
*/
|
|
26
|
-
export function MessageDisclosure({ icon, label, open, onOpenChange, tone = "muted",
|
|
27
|
-
return (_jsxs(Collapsible, { open: open, onOpenChange: onOpenChange,
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
export function MessageDisclosure({ icon, label, open, onOpenChange, tone = "muted", ariaLabel, children, ...rest }) {
|
|
28
|
+
return (_jsxs(Collapsible, { open: open, onOpenChange: onOpenChange, "data-message-disclosure": true,
|
|
29
|
+
// Rows render where the model put them. There was once an
|
|
30
|
+
// ``after-tools`` placement that moved a row with flex `order` — and a
|
|
31
|
+
// separate data attribute, because the compaction below is a
|
|
32
|
+
// DOM-SIBLING rule and a row that is first in the DOM but second on
|
|
33
|
+
// screen would hand its negative margin to the wrong neighbour. Both
|
|
34
|
+
// are gone: the reordering existed to keep a reasoning row from sitting
|
|
35
|
+
// above a short answer, which folding the machinery into one quiet row
|
|
36
|
+
// already solves.
|
|
37
|
+
className: "w-full [[data-message-disclosure]_+_&]:-mt-3", ...rest, children: [_jsx(CollapsibleTrigger, { asChild: true, children: _jsxs("button", { type: "button", "data-disclosure-trigger": true, "aria-label": ariaLabel, className: cn(
|
|
38
|
+
// `w-fit` with `-ml-1.5`: the row is a pill that hugs its own
|
|
39
|
+
// words, and the negative margin cancels the padding so the
|
|
40
|
+
// ICON lines up with the message text's left edge rather than
|
|
41
|
+
// sitting 6px inside it. Full-width was worse on both counts —
|
|
42
|
+
// it made a hover target the width of the message, and it left
|
|
43
|
+
// the caret stranded at the far right once the caret moved.
|
|
44
|
+
"group/disclosure -ml-1.5 flex w-fit max-w-full items-center gap-1.5 rounded-md", "px-1.5 py-0.5 text-left text-xs transition-colors", "text-muted-foreground hover:bg-muted hover:text-foreground", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", tone === "destructive" && "text-destructive hover:text-destructive"), children: [_jsx("span", { className: "flex size-3.5 shrink-0 items-center justify-center", children: icon }), _jsx("span", { className: "truncate", children: label }), _jsx(ChevronDown, { "aria-hidden": "true", className: cn("size-3.5 shrink-0 opacity-60 transition-transform duration-150", "group-hover/disclosure:opacity-100", open && "rotate-180") })] }) }), _jsx(CollapsibleContent, { children: _jsx("div", { className: "mt-1.5 ml-3 border-l border-border pl-3", children: children }) })] }));
|
|
30
45
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useStore } from "zustand";
|
|
3
3
|
import { Check, ChevronDown } from "lucide-react";
|
|
4
4
|
import { reasoningDefaultStore, reasoningEffortStore, REASONING_EFFORT_LABELS, } from "@iloveagents/foundry-agent";
|
|
@@ -11,6 +11,40 @@ const OPTIONS = [
|
|
|
11
11
|
{ value: "high", hint: "Slower, better on hard problems" },
|
|
12
12
|
{ value: "xhigh", hint: "Slowest, for the hardest questions" },
|
|
13
13
|
];
|
|
14
|
+
/** Bar count per level, lowest to highest. */
|
|
15
|
+
const EFFORT_RANK = {
|
|
16
|
+
low: 1,
|
|
17
|
+
medium: 2,
|
|
18
|
+
high: 3,
|
|
19
|
+
xhigh: 4,
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The level as four rising bars.
|
|
23
|
+
*
|
|
24
|
+
* The trigger's job is to answer "how hard is it thinking?" at a glance, and
|
|
25
|
+
* in a narrow composer — the chat bubble, a side panel — the words cost more
|
|
26
|
+
* width than that question is worth. Bars cost ~14px and answer it without
|
|
27
|
+
* reading, so in that case the words drop and this stands in for them.
|
|
28
|
+
*
|
|
29
|
+
* Takes a CONCRETE level, never `null`. A gauge with nothing lit is not a
|
|
30
|
+
* gauge, it is four faint marks; the caller keeps the words instead when
|
|
31
|
+
* there is no level to encode, which makes the empty state unrepresentable
|
|
32
|
+
* here rather than something this has to render politely.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately shows LEVEL only, never whether the level is pinned or
|
|
35
|
+
* inherited from `Auto`. The level is what changes cost and latency; the mode
|
|
36
|
+
* is the half this trigger already drops first when space runs short.
|
|
37
|
+
*
|
|
38
|
+
* `bg-current` throughout, so the bars inherit the button's own muted →
|
|
39
|
+
* foreground hover transition instead of needing their own colour states.
|
|
40
|
+
*/
|
|
41
|
+
function EffortGauge({ level }) {
|
|
42
|
+
const rank = EFFORT_RANK[level];
|
|
43
|
+
return (_jsx("span", { "aria-hidden": "true", className: "flex h-3.5 shrink-0 items-end gap-px", children: [1, 2, 3, 4].map((step) => (_jsx("span", { className: cn("w-0.5 rounded-full bg-current transition-opacity", step === 1 && "h-1", step === 2 && "h-1.5", step === 3 && "h-2", step === 4 && "h-2.5",
|
|
44
|
+
// The unlit bars stay visible: the gauge has to read as "2 of 4",
|
|
45
|
+
// not as a bare pair of marks whose ceiling you cannot see.
|
|
46
|
+
step <= rank ? "opacity-100" : "opacity-25") }, step))) }));
|
|
47
|
+
}
|
|
14
48
|
/**
|
|
15
49
|
* What `Auto` is currently doing, in words.
|
|
16
50
|
*
|
|
@@ -54,5 +88,7 @@ export function ReasoningEffortPicker({ className }) {
|
|
|
54
88
|
? REASONING_EFFORT_LABELS[resolved]
|
|
55
89
|
: REASONING_EFFORT_LABELS[effort];
|
|
56
90
|
const triggerLabel = showsMode ? `${modeLabel} · ${levelLabel}` : levelLabel;
|
|
57
|
-
|
|
91
|
+
// `default` with nothing resolved is the one case with no level to draw.
|
|
92
|
+
const gaugeLevel = effort === "default" ? resolved : effort;
|
|
93
|
+
return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "data-reasoning-effort-trigger": true, className: cn("flex items-center gap-1 rounded-md px-2 py-1", "text-xs text-muted-foreground hover:text-foreground hover:bg-muted", "transition-colors", className), "aria-label": `Intelligence: ${triggerLabel}`, title: `Intelligence: ${triggerLabel}`, children: [gaugeLevel !== null && (_jsx("span", { className: "hidden @max-[24rem]:flex", children: _jsx(EffortGauge, { level: gaugeLevel }) })), _jsxs("span", { className: cn("whitespace-nowrap", gaugeLevel !== null && "@max-[24rem]:hidden"), children: [showsMode && `${modeLabel} · `, levelLabel] }), _jsx(ChevronDown, { className: "size-3.5 shrink-0" })] }) }), _jsxs(DropdownMenuContent, { align: "end", className: "w-56", children: [_jsx(DropdownMenuLabel, { className: "text-xs font-normal text-muted-foreground", children: "Intelligence" }), OPTIONS.map((opt) => (_jsxs(DropdownMenuItem, { onSelect: () => setEffort(opt.value), className: "flex items-start gap-2", children: [_jsx(Check, { className: cn("size-4 mt-0.5 shrink-0", effort === opt.value ? "opacity-100" : "opacity-0") }), _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "text-sm", children: REASONING_EFFORT_LABELS[opt.value] }), _jsx("span", { className: "text-xs text-muted-foreground", children: opt.value === "default" ? autoHint(resolved, scope) : opt.hint })] })] }, opt.value)))] })] }));
|
|
58
94
|
}
|