cortena-ui 1.4.2 → 1.5.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 (133) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +149 -1
  3. package/dist/a2ui/views.js +2 -2
  4. package/dist/agent-chat/a2ui-block.d.ts +60 -0
  5. package/dist/agent-chat/a2ui-block.js +69 -0
  6. package/dist/agent-chat/a2ui-block.js.map +1 -0
  7. package/dist/agent-chat/agui-client.d.ts +40 -0
  8. package/dist/agent-chat/agui-client.js +251 -0
  9. package/dist/agent-chat/agui-client.js.map +1 -0
  10. package/dist/agent-chat/bridge.d.ts +109 -0
  11. package/dist/agent-chat/bridge.js +349 -0
  12. package/dist/agent-chat/bridge.js.map +1 -0
  13. package/dist/agent-chat/session.d.ts +58 -0
  14. package/dist/agent-chat/session.js +249 -0
  15. package/dist/agent-chat/session.js.map +1 -0
  16. package/dist/agent-chat/store.d.ts +67 -0
  17. package/dist/agent-chat/store.js +548 -0
  18. package/dist/agent-chat/store.js.map +1 -0
  19. package/dist/agent-chat/types.d.ts +187 -0
  20. package/dist/agent-chat/types.js +17 -0
  21. package/dist/agent-chat/types.js.map +1 -0
  22. package/dist/agent-chat.d.ts +10 -0
  23. package/dist/agent-chat.js +10 -0
  24. package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
  25. package/dist/components/admin-permissions/admin-permissions.js +101 -0
  26. package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
  27. package/dist/components/admin-permissions/context.d.ts +70 -0
  28. package/dist/components/admin-permissions/context.js +258 -0
  29. package/dist/components/admin-permissions/context.js.map +1 -0
  30. package/dist/components/admin-permissions/index.d.ts +10 -0
  31. package/dist/components/admin-permissions/licence.d.ts +15 -0
  32. package/dist/components/admin-permissions/licence.js +78 -0
  33. package/dist/components/admin-permissions/licence.js.map +1 -0
  34. package/dist/components/admin-permissions/matrix.d.ts +20 -0
  35. package/dist/components/admin-permissions/matrix.js +191 -0
  36. package/dist/components/admin-permissions/matrix.js.map +1 -0
  37. package/dist/components/admin-permissions/members.d.ts +18 -0
  38. package/dist/components/admin-permissions/members.js +185 -0
  39. package/dist/components/admin-permissions/members.js.map +1 -0
  40. package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
  41. package/dist/components/admin-permissions/role-assignment.js +174 -0
  42. package/dist/components/admin-permissions/role-assignment.js.map +1 -0
  43. package/dist/components/admin-permissions/roles.d.ts +25 -0
  44. package/dist/components/admin-permissions/roles.js +168 -0
  45. package/dist/components/admin-permissions/roles.js.map +1 -0
  46. package/dist/components/admin-permissions/types.d.ts +152 -0
  47. package/dist/components/admin-permissions/types.js +63 -0
  48. package/dist/components/admin-permissions/types.js.map +1 -0
  49. package/dist/components/agent-chat-popup.d.ts +29 -0
  50. package/dist/components/agent-chat-popup.js +188 -0
  51. package/dist/components/agent-chat-popup.js.map +1 -0
  52. package/dist/components/agent-chat.d.ts +143 -0
  53. package/dist/components/agent-chat.js +578 -0
  54. package/dist/components/agent-chat.js.map +1 -0
  55. package/dist/components/app-shell.d.ts +126 -0
  56. package/dist/components/app-shell.js +297 -0
  57. package/dist/components/app-shell.js.map +1 -0
  58. package/dist/components/badge.d.ts +1 -1
  59. package/dist/components/button-link.js +1 -1
  60. package/dist/components/button.d.ts +2 -2
  61. package/dist/components/checkbox.d.ts +1 -1
  62. package/dist/components/combobox.d.ts +1 -1
  63. package/dist/components/combobox.js +1 -1
  64. package/dist/components/consent-screen.d.ts +65 -0
  65. package/dist/components/consent-screen.js +123 -0
  66. package/dist/components/consent-screen.js.map +1 -0
  67. package/dist/components/data-table/data-table.d.ts +15 -1
  68. package/dist/components/data-table/data-table.js +18 -4
  69. package/dist/components/data-table/data-table.js.map +1 -1
  70. package/dist/components/data-table/index.d.ts +4 -4
  71. package/dist/components/data-table/parts.d.ts +27 -3
  72. package/dist/components/data-table/parts.js +175 -55
  73. package/dist/components/data-table/parts.js.map +1 -1
  74. package/dist/components/data-table/types.d.ts +61 -0
  75. package/dist/components/data-table/use-data-table.js +91 -6
  76. package/dist/components/data-table/use-data-table.js.map +1 -1
  77. package/dist/components/data-table/use-server-source.js +119 -28
  78. package/dist/components/data-table/use-server-source.js.map +1 -1
  79. package/dist/components/help-panel.d.ts +131 -0
  80. package/dist/components/help-panel.js +545 -0
  81. package/dist/components/help-panel.js.map +1 -0
  82. package/dist/components/login-screen.d.ts +127 -0
  83. package/dist/components/login-screen.js +339 -0
  84. package/dist/components/login-screen.js.map +1 -0
  85. package/dist/components/session-guard.d.ts +268 -0
  86. package/dist/components/session-guard.js +632 -0
  87. package/dist/components/session-guard.js.map +1 -0
  88. package/dist/components/toast.d.ts +1 -1
  89. package/dist/core.d.ts +5 -1
  90. package/dist/core.js +11 -7
  91. package/dist/data-table.d.ts +13 -4
  92. package/dist/data-table.js +10 -2
  93. package/dist/hooks/use-cortena-theme.js +49 -3
  94. package/dist/hooks/use-cortena-theme.js.map +1 -1
  95. package/dist/index.d.ts +17 -4
  96. package/dist/index.js +21 -8
  97. package/dist/markdown.d.ts +2 -1
  98. package/dist/markdown.js +2 -1
  99. package/package.json +16 -4
  100. package/src/agent-chat/a2ui-block.ts +118 -0
  101. package/src/agent-chat/agui-client.ts +405 -0
  102. package/src/agent-chat/bridge.ts +433 -0
  103. package/src/agent-chat/session.ts +392 -0
  104. package/src/agent-chat/store.ts +738 -0
  105. package/src/agent-chat/types.ts +213 -0
  106. package/src/components/admin-permissions/admin-permissions.tsx +130 -0
  107. package/src/components/admin-permissions/context.tsx +376 -0
  108. package/src/components/admin-permissions/index.tsx +32 -0
  109. package/src/components/admin-permissions/licence.tsx +84 -0
  110. package/src/components/admin-permissions/matrix.tsx +257 -0
  111. package/src/components/admin-permissions/members.tsx +204 -0
  112. package/src/components/admin-permissions/role-assignment.tsx +239 -0
  113. package/src/components/admin-permissions/roles.tsx +169 -0
  114. package/src/components/admin-permissions/types.ts +231 -0
  115. package/src/components/agent-chat-popup.tsx +289 -0
  116. package/src/components/agent-chat.tsx +843 -0
  117. package/src/components/app-shell.tsx +502 -0
  118. package/src/components/consent-screen.tsx +239 -0
  119. package/src/components/data-table/data-table.tsx +36 -0
  120. package/src/components/data-table/index.tsx +6 -1
  121. package/src/components/data-table/parts.tsx +223 -47
  122. package/src/components/data-table/types.ts +68 -0
  123. package/src/components/data-table/use-data-table.ts +152 -4
  124. package/src/components/data-table/use-server-source.ts +150 -12
  125. package/src/components/help-panel.tsx +765 -0
  126. package/src/components/login-screen.tsx +479 -0
  127. package/src/components/session-guard.tsx +1071 -0
  128. package/src/entries/agent-chat.ts +113 -0
  129. package/src/entries/core.ts +8 -0
  130. package/src/entries/data-table.ts +41 -0
  131. package/src/entries/markdown.ts +25 -0
  132. package/src/hooks/use-cortena-theme.ts +63 -4
  133. package/src/index.ts +6 -0
@@ -0,0 +1,239 @@
1
+ "use client";
2
+
3
+ import type * as React from "react";
4
+ import { Alert, AlertDescription } from "@/components/alert";
5
+ import { Button } from "@/components/button";
6
+ import { AuthScreen } from "@/components/login-screen";
7
+ import { Spinner } from "@/components/spinner";
8
+ import { cn } from "@/lib/cn";
9
+
10
+ /**
11
+ * ConsentScreen — the OAuth authorize page an MCP client lands on
12
+ * (how-to-create-a-cortena-extension §7.4, audit rule P-08, decision DESIGN-D1).
13
+ *
14
+ * It is the consent *variant* of the login screen rather than a screen of its
15
+ * own: same frame, same card, same tokens, same brand row. That is the whole
16
+ * requirement. An MCP client opens a browser onto a page nobody chose to visit,
17
+ * and if that page looks like a different product the only honest reading is
18
+ * that something has gone wrong. Sharing `AuthScreen` with `LoginScreen` is
19
+ * what makes the two impossible to drift apart.
20
+ *
21
+ * It replaces the hand-written HTML cortena-auth serves today
22
+ * (`src/routes/oauth-login.ts`, `renderConsent`), and the props are that page's
23
+ * contents: the client asking, the scopes it wants in plain language, who the
24
+ * user is signed in as, and Allow / Deny. Props-only — the flow id, the CSRF
25
+ * binding cookie, the double-submit guard and the redirect all stay on the
26
+ * server, where they already work.
27
+ */
28
+
29
+ export interface ConsentClient {
30
+ /** The registered client name — "Claude Code". Never the raw client id. */
31
+ name: string;
32
+ /**
33
+ * The client's own logo, when it registered one. **https only** — the value
34
+ * is a third party's, and this is the screen on which a user decides whether
35
+ * to give that third party access, so `AuthScreen` drops a `data:`, `blob:`
36
+ * or `http:` URL and draws the mark instead (`httpsLogoUrl`).
37
+ */
38
+ logoUrl?: string;
39
+ }
40
+
41
+ export interface ConsentScope {
42
+ /** The raw scope string, `task:read`. Shown, small, under the label. */
43
+ id: string;
44
+ /** The plain-language gloss — cortena-auth's `glossFor(scope)`. */
45
+ label: string;
46
+ /** One sentence on what granting it actually allows. */
47
+ description?: string;
48
+ }
49
+
50
+ export interface ConsentUser {
51
+ name: string;
52
+ email: string;
53
+ }
54
+
55
+ export interface ConsentScreenProps extends Omit<React.ComponentProps<"div">, "onSubmit"> {
56
+ client: ConsentClient;
57
+ /**
58
+ * The requested scopes, already glossed. Rendered in the order given, which
59
+ * is the order cortena-auth resolved them in — not re-sorted here, so the
60
+ * screen and the granted token read the same way.
61
+ */
62
+ scopes: readonly ConsentScope[];
63
+ /** Who is about to grant this. Shown, because consent is personal. */
64
+ user: ConsentUser;
65
+ onAllow?: () => void;
66
+ onDeny?: () => void;
67
+ /** Disables both decisions and marks Allow busy. One decision per page. */
68
+ loading?: boolean;
69
+ /** A failed decision, in the user's words. */
70
+ error?: string;
71
+ }
72
+
73
+ function ConsentScreen({
74
+ className,
75
+ client,
76
+ scopes,
77
+ user,
78
+ onAllow,
79
+ onDeny,
80
+ loading,
81
+ error,
82
+ ...props
83
+ }: ConsentScreenProps) {
84
+ return (
85
+ <AuthScreen
86
+ data-slot="consent-screen"
87
+ data-loading={loading ? "" : undefined}
88
+ className={className}
89
+ brand={{ name: client.name, logoUrl: client.logoUrl }}
90
+ title={`${client.name} wants access`}
91
+ subtitle="Review what it is asking for before you allow it."
92
+ notices={
93
+ error ? (
94
+ <Alert variant="destructive" data-slot="consent-screen-error">
95
+ <AlertDescription className="text-[color:var(--ds-foreground)]">
96
+ {error}
97
+ </AlertDescription>
98
+ </Alert>
99
+ ) : null
100
+ }
101
+ footer={
102
+ <span data-slot="consent-screen-footer">
103
+ You can revoke this access at any time from your Cortena settings.
104
+ </span>
105
+ }
106
+ {...props}
107
+ >
108
+ <div
109
+ data-slot="consent-screen-user"
110
+ className={cn(
111
+ "flex flex-col gap-0.5 rounded-[var(--ds-radius-md)] bg-[var(--ds-muted)] px-3.5 py-3",
112
+ "text-[length:var(--ds-text-caption-lg)]",
113
+ "leading-[var(--ds-text-caption-lg--line-height)]",
114
+ )}
115
+ >
116
+ <span className="font-medium text-[color:var(--ds-foreground)]">
117
+ Signed in as {user.name}
118
+ </span>
119
+ <span className="text-[color:var(--ds-muted-foreground)]">{user.email}</span>
120
+ </div>
121
+
122
+ <div data-slot="consent-screen-scopes" className="flex flex-col gap-2">
123
+ <p
124
+ className={cn(
125
+ "text-[length:var(--ds-text-caption)]",
126
+ "leading-[var(--ds-text-caption--line-height)]",
127
+ "text-[color:var(--ds-muted-foreground)]",
128
+ )}
129
+ >
130
+ {client.name} will be able to:
131
+ </p>
132
+ {/* An empty list is a real state: a client can ask for no scopes at
133
+ all, and saying so is better than an empty box the user has to
134
+ interpret. */}
135
+ {scopes.length === 0 ? (
136
+ <p
137
+ data-slot="consent-screen-no-scopes"
138
+ className={cn(
139
+ "text-[length:var(--ds-text-caption-lg)]",
140
+ "leading-[var(--ds-text-caption-lg--line-height)]",
141
+ "text-[color:var(--ds-muted-foreground)]",
142
+ )}
143
+ >
144
+ Nothing beyond confirming who you are.
145
+ </p>
146
+ ) : (
147
+ <ul className="flex list-none flex-col gap-2.5 p-0">
148
+ {scopes.map((scope) => (
149
+ <li
150
+ key={scope.id}
151
+ data-slot="consent-scope"
152
+ data-scope={scope.id}
153
+ className="flex gap-2.5"
154
+ >
155
+ <CheckGlyph />
156
+ <span className="flex min-w-0 flex-col gap-0.5">
157
+ <span
158
+ className={cn(
159
+ "font-medium text-[length:var(--ds-text-caption-lg)]",
160
+ "leading-[var(--ds-text-caption-lg--line-height)]",
161
+ "text-[color:var(--ds-foreground)]",
162
+ )}
163
+ >
164
+ {scope.label}
165
+ </span>
166
+ {scope.description ? (
167
+ <span
168
+ className={cn(
169
+ "text-[length:var(--ds-text-caption)]",
170
+ "leading-[var(--ds-text-caption--line-height)]",
171
+ "text-[color:var(--ds-muted-foreground)]",
172
+ )}
173
+ >
174
+ {scope.description}
175
+ </span>
176
+ ) : null}
177
+ <span
178
+ className={cn(
179
+ "font-mono text-[length:var(--ds-text-caption-sm)]",
180
+ "text-[color:var(--ds-text-tertiary)]",
181
+ )}
182
+ >
183
+ {scope.id}
184
+ </span>
185
+ </span>
186
+ </li>
187
+ ))}
188
+ </ul>
189
+ )}
190
+ </div>
191
+
192
+ {/* Allow first and filled, Deny second and quiet — but both are real
193
+ buttons of the same size, so declining is never the harder click. */}
194
+ <div data-slot="consent-screen-actions" className="flex flex-col gap-2.5">
195
+ <Button
196
+ type="button"
197
+ size="lg"
198
+ disabled={loading}
199
+ data-decision="allow"
200
+ onClick={onAllow}
201
+ >
202
+ {loading ? <Spinner tone="inverse" /> : null}
203
+ {loading ? "Allowing…" : "Allow"}
204
+ </Button>
205
+ <Button
206
+ type="button"
207
+ variant="secondary"
208
+ size="lg"
209
+ disabled={loading}
210
+ data-decision="deny"
211
+ onClick={onDeny}
212
+ >
213
+ Deny
214
+ </Button>
215
+ </div>
216
+ </AuthScreen>
217
+ );
218
+ }
219
+
220
+ /** The scope bullet. Inline rather than a lucide import: one icon, one size. */
221
+ function CheckGlyph() {
222
+ return (
223
+ <svg
224
+ viewBox="0 0 24 24"
225
+ fill="none"
226
+ stroke="currentColor"
227
+ strokeWidth="2"
228
+ strokeLinecap="round"
229
+ strokeLinejoin="round"
230
+ aria-hidden
231
+ focusable="false"
232
+ className="mt-0.5 size-4 shrink-0 text-[color:var(--ds-primary)]"
233
+ >
234
+ <path d="m4 12.5 5 5L20 6.5" />
235
+ </svg>
236
+ );
237
+ }
238
+
239
+ export { ConsentScreen };
@@ -44,8 +44,23 @@ const VIRTUALIZE_ABOVE = 100;
44
44
  const DEFAULT_VIRTUAL_HEIGHT = 600;
45
45
  const INFINITE_THRESHOLD_PX = 240;
46
46
 
47
+ /** What a `ref` on `DataTable` hands back. */
48
+ export interface DataTableHandle<Row extends RowData> {
49
+ /**
50
+ * Refetch the page on screen through the same query the auto-refresh
51
+ * interval drives — never a second fetch path. A table subscribed to a
52
+ * notification stream calls this from the stream and leaves the interval as
53
+ * the fallback for when the stream is unavailable.
54
+ */
55
+ refresh: () => void;
56
+ /** Everything else the table knows, for a caller that needs more than a refresh. */
57
+ instance: DataTableInstance<Row>;
58
+ }
59
+
47
60
  export interface DataTableViewProps<Row extends RowData> {
48
61
  className?: string;
62
+ /** Imperative handle: `ref.current.refresh()`. */
63
+ ref?: React.Ref<DataTableHandle<Row>>;
49
64
  /** Height of the scrolling body. Required for virtualisation to mean anything; defaults to 600px when virtualised. */
50
65
  maxHeight?: number | string;
51
66
  /** Force virtualisation on or off; undefined virtualises above 100 rows. */
@@ -124,6 +139,7 @@ function DataTableOwned<Row extends RowData>(props: DataTableViewProps<Row> & Us
124
139
  onRowClick,
125
140
  activeRowId,
126
141
  rowClassName,
142
+ ref,
127
143
  ...options
128
144
  } = props;
129
145
  const instance = useDataTable<Row>({
@@ -154,6 +170,7 @@ function DataTableOwned<Row extends RowData>(props: DataTableViewProps<Row> & Us
154
170
  onRowClick={onRowClick}
155
171
  activeRowId={activeRowId}
156
172
  rowClassName={rowClassName}
173
+ ref={ref}
157
174
  />
158
175
  );
159
176
  }
@@ -275,6 +292,7 @@ function DataTableView<Row extends RowData>({
275
292
  onRowClick,
276
293
  activeRowId,
277
294
  rowClassName,
295
+ ref,
278
296
  }: DataTableViewProps<Row> & { instance: DataTableInstance<Row> }) {
279
297
  const { table, paginationMode } = instance;
280
298
  const rows = table.getRowModel().rows;
@@ -291,6 +309,23 @@ function DataTableView<Row extends RowData>({
291
309
  const scrollRef = React.useRef<HTMLDivElement>(null);
292
310
  const tableRef = React.useRef<HTMLTableElement>(null);
293
311
 
312
+ const refresh = instance.refetch;
313
+ React.useImperativeHandle(ref, () => ({ refresh, instance }), [refresh, instance]);
314
+
315
+ // Mandatory on every server-backed table (P-39): a Refresh button beside the
316
+ // search box and a stamp saying which moment these rows are. The switch only
317
+ // appears where the table asked for `autoRefresh`.
318
+ const refreshProps =
319
+ instance.dataSource.kind === "server"
320
+ ? {
321
+ onRefresh: refresh,
322
+ isFetching: instance.isFetching,
323
+ lastUpdatedAt: instance.lastUpdatedAt,
324
+ lastErrorAt: instance.lastErrorAt,
325
+ autoRefresh: instance.autoRefresh,
326
+ }
327
+ : undefined;
328
+
294
329
  /* Virtualisation ------------------------------------------------------ */
295
330
 
296
331
  const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
@@ -587,6 +622,7 @@ function DataTableView<Row extends RowData>({
587
622
  searchPlaceholder={searchPlaceholder}
588
623
  enableExport={enableExport}
589
624
  exportFileName={exportFileName}
625
+ refresh={refreshProps}
590
626
  >
591
627
  {toolbarActions}
592
628
  </DataTableToolbar>
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  export { DataTable, DataTableView } from "./data-table";
4
- export type { DataTableProps, DataTableViewProps } from "./data-table";
4
+ export type { DataTableHandle, DataTableProps, DataTableViewProps } from "./data-table";
5
5
  export {
6
6
  DataTableColumnHeader,
7
7
  DataTableExportMenu,
8
8
  DataTableFacetedFilter,
9
9
  DataTablePagination,
10
+ DataTableRefresh,
10
11
  DataTableToolbar,
11
12
  DataTableViewOptions,
12
13
  } from "./parts";
@@ -16,6 +17,7 @@ export type {
16
17
  DataTableFacetedFilterOption,
17
18
  DataTableFacetedFilterProps,
18
19
  DataTablePaginationProps,
20
+ DataTableRefreshProps,
19
21
  DataTableToolbarProps,
20
22
  DataTableViewOptionsProps,
21
23
  } from "./parts";
@@ -27,6 +29,8 @@ export type { ExportCell, ExportSheet } from "./export";
27
29
  export type {
28
30
  ClientSource,
29
31
  DataSource,
32
+ DataTableAutoRefresh,
33
+ DataTableAutoRefreshState,
30
34
  DataTableCell,
31
35
  DataTableColumn,
32
36
  DataTableColumnDef,
@@ -35,6 +39,7 @@ export type {
35
39
  DataTableEditingCell,
36
40
  DataTableInstance,
37
41
  DataTablePaginationMode,
42
+ DataTableRefreshPause,
38
43
  DataTableRow,
39
44
  DataTableState,
40
45
  DataTableTable,
@@ -14,6 +14,7 @@ import {
14
14
  EyeOff,
15
15
  ListFilter,
16
16
  PinOff,
17
+ RefreshCw,
17
18
  Search,
18
19
  Settings2,
19
20
  X,
@@ -32,9 +33,11 @@ import {
32
33
  } from "@/components/dropdown-menu";
33
34
  import { Input } from "@/components/input";
34
35
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/select";
36
+ import { StatusDot } from "@/components/status-dot";
37
+ import { Switch } from "@/components/switch";
35
38
  import { cn } from "@/lib/cn";
36
39
  import { columnLabel, downloadCsv, downloadExcel } from "./export";
37
- import type { DataTableColumn, DataTableTable } from "./types";
40
+ import type { DataTableAutoRefreshState, DataTableColumn, DataTableTable } from "./types";
38
41
 
39
42
  /* ------------------------------------------------------------------------ */
40
43
  /* Column header */
@@ -404,6 +407,11 @@ export interface DataTableToolbarProps<Row extends RowData>
404
407
  /** @default true */
405
408
  enableExport?: boolean;
406
409
  exportFileName?: string;
410
+ /**
411
+ * Freshness controls beside the search box. `DataTable` fills this in for
412
+ * every server source; a hand-built toolbar must pass it too (P-39).
413
+ */
414
+ refresh?: DataTableRefreshProps;
407
415
  }
408
416
 
409
417
  /**
@@ -418,6 +426,7 @@ function DataTableToolbar<Row extends RowData>({
418
426
  children,
419
427
  enableExport = true,
420
428
  exportFileName,
429
+ refresh,
421
430
  className,
422
431
  ...props
423
432
  }: DataTableToolbarProps<Row>) {
@@ -435,54 +444,59 @@ function DataTableToolbar<Row extends RowData>({
435
444
  className={cn("flex flex-wrap items-center gap-2", className)}
436
445
  {...props}
437
446
  >
438
- {selectedRows.length ? (
439
- <div data-slot="data-table-bulk-actions" className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
440
- <span className="text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-muted-foreground)]">
441
- <span className="font-medium text-[color:var(--ds-foreground)]">{selectedRows.length}</span> selected
442
- </span>
443
- {bulkActions?.({ rows: selectedRows.map((r) => r.original), table })}
444
- <Button variant="ghost" size="sm" onClick={() => table.resetRowSelection()}>
445
- Clear
446
- <X />
447
- </Button>
448
- </div>
449
- ) : (
450
- <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
451
- {canSearch ? (
452
- <div className="relative w-full max-w-64">
453
- <Search
454
- aria-hidden
455
- className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[color:var(--ds-text-tertiary)]"
456
- />
457
- <Input
458
- data-slot="data-table-search"
459
- type="search"
460
- aria-label="Search"
461
- placeholder={searchPlaceholder}
462
- value={globalFilter}
463
- onChange={(e) => table.setGlobalFilter(e.target.value)}
464
- className="h-8 pl-8 text-[length:var(--ds-text-caption-lg)]"
465
- />
466
- </div>
467
- ) : null}
468
- {facetedColumns.map((column) => (
469
- <DataTableFacetedFilter key={column.id} column={column} />
470
- ))}
471
- {isFiltered ? (
472
- <Button
473
- variant="ghost"
474
- size="sm"
475
- onClick={() => {
476
- table.resetColumnFilters();
477
- table.setGlobalFilter("");
478
- }}
479
- >
480
- Reset
447
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
448
+ {selectedRows.length ? (
449
+ <div data-slot="data-table-bulk-actions" className="flex min-w-0 flex-wrap items-center gap-2">
450
+ <span className="text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-muted-foreground)]">
451
+ <span className="font-medium text-[color:var(--ds-foreground)]">{selectedRows.length}</span> selected
452
+ </span>
453
+ {bulkActions?.({ rows: selectedRows.map((r) => r.original), table })}
454
+ <Button variant="ghost" size="sm" onClick={() => table.resetRowSelection()}>
455
+ Clear
481
456
  <X />
482
457
  </Button>
483
- ) : null}
484
- </div>
485
- )}
458
+ </div>
459
+ ) : (
460
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
461
+ {canSearch ? (
462
+ <div className="relative w-64 max-w-full">
463
+ <Search
464
+ aria-hidden
465
+ className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[color:var(--ds-text-tertiary)]"
466
+ />
467
+ <Input
468
+ data-slot="data-table-search"
469
+ type="search"
470
+ aria-label="Search"
471
+ placeholder={searchPlaceholder}
472
+ value={globalFilter}
473
+ onChange={(e) => table.setGlobalFilter(e.target.value)}
474
+ className="h-8 pl-8 text-[length:var(--ds-text-caption-lg)]"
475
+ />
476
+ </div>
477
+ ) : null}
478
+ {facetedColumns.map((column) => (
479
+ <DataTableFacetedFilter key={column.id} column={column} />
480
+ ))}
481
+ {isFiltered ? (
482
+ <Button
483
+ variant="ghost"
484
+ size="sm"
485
+ onClick={() => {
486
+ table.resetColumnFilters();
487
+ table.setGlobalFilter("");
488
+ }}
489
+ >
490
+ Reset
491
+ <X />
492
+ </Button>
493
+ ) : null}
494
+ </div>
495
+ )}
496
+ {/* Beside the search box, and still there while a selection is up: the
497
+ stamp answers "how old is this?", which a selection does not change. */}
498
+ {refresh ? <DataTableRefresh {...refresh} /> : null}
499
+ </div>
486
500
  <div className="flex items-center gap-2">
487
501
  {children}
488
502
  <DataTableViewOptions table={table} />
@@ -492,6 +506,167 @@ function DataTableToolbar<Row extends RowData>({
492
506
  );
493
507
  }
494
508
 
509
+ /* ------------------------------------------------------------------------ */
510
+ /* Refresh + freshness stamp */
511
+ /* ------------------------------------------------------------------------ */
512
+
513
+ /** A failed background refresh says so beside the stamp, then gets out of the way. */
514
+ const ERROR_VISIBLE_MS = 10_000;
515
+ /** Under a minute the stamp is relative; past it, the clock time it was taken. */
516
+ const RELATIVE_WINDOW_MS = 60_000;
517
+
518
+ /**
519
+ * Re-renders once, at a moment named in advance.
520
+ *
521
+ * The stamp has exactly two of them — the minute at which "just now" stops
522
+ * being true, and the ten seconds a failure is worth mentioning — and neither
523
+ * is a good reason to tick on a timer: a clock time, once shown, never changes
524
+ * again, so a periodic re-render would be one every fifteen seconds forever to
525
+ * paint the same eight characters.
526
+ */
527
+ function useDeadline(force: () => void, at: number | null, after: number): void {
528
+ React.useEffect(() => {
529
+ if (at === null) return;
530
+ const left = after - (Date.now() - at);
531
+ if (left <= 0) return;
532
+ const id = setTimeout(force, left);
533
+ return () => clearTimeout(id);
534
+ }, [force, at, after]);
535
+ }
536
+
537
+ function formatClock(at: number): string {
538
+ return new Date(at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
539
+ }
540
+
541
+ function pauseNote(pausedBy: DataTableAutoRefreshState["pausedBy"]): string | null {
542
+ switch (pausedBy) {
543
+ case "hidden":
544
+ return "Paused while this tab is in the background";
545
+ case "editing":
546
+ return "Paused while a cell is being edited";
547
+ case "selection":
548
+ return "Paused while rows are selected";
549
+ case "mutation":
550
+ return "Paused while a change is saving";
551
+ case "app":
552
+ return "Paused";
553
+ default:
554
+ return null;
555
+ }
556
+ }
557
+
558
+ interface StampProps {
559
+ lastUpdatedAt: number | null;
560
+ lastErrorAt: number | null;
561
+ }
562
+
563
+ /**
564
+ * "Updated 14:32", or "Updated just now" for the first minute. A server table
565
+ * is a photograph of a moment, and this is the only thing on screen that says
566
+ * which moment. Its own component so a re-render at the minute boundary costs
567
+ * eight words rather than the whole toolbar.
568
+ */
569
+ function DataTableUpdatedStamp({ lastUpdatedAt, lastErrorAt }: StampProps) {
570
+ const [, force] = React.useReducer((n: number) => n + 1, 0);
571
+ useDeadline(force, lastUpdatedAt, RELATIVE_WINDOW_MS);
572
+ useDeadline(force, lastErrorAt, ERROR_VISIBLE_MS);
573
+ const now = Date.now();
574
+
575
+ if (lastErrorAt !== null && now - lastErrorAt < ERROR_VISIBLE_MS) {
576
+ return (
577
+ <span
578
+ data-slot="data-table-updated"
579
+ data-failed=""
580
+ className="whitespace-nowrap text-[length:var(--ds-text-caption)] text-[color:var(--ds-destructive)]"
581
+ >
582
+ Update failed
583
+ </span>
584
+ );
585
+ }
586
+ if (lastUpdatedAt === null) return null;
587
+ return (
588
+ <span
589
+ data-slot="data-table-updated"
590
+ title={new Date(lastUpdatedAt).toLocaleString()}
591
+ className="whitespace-nowrap text-[length:var(--ds-text-caption)] text-[color:var(--ds-muted-foreground)]"
592
+ >
593
+ {now - lastUpdatedAt < RELATIVE_WINDOW_MS
594
+ ? "Updated just now"
595
+ : `Updated ${formatClock(lastUpdatedAt)}`}
596
+ </span>
597
+ );
598
+ }
599
+
600
+ export interface DataTableRefreshProps extends Omit<React.ComponentProps<"div">, "children"> {
601
+ /** Refetch the page on screen. The same query the interval drives. */
602
+ onRefresh: () => void;
603
+ /** Spins the icon and holds the button while a request is in flight. */
604
+ isFetching?: boolean;
605
+ /** `Date.now()` of the last successful response. */
606
+ lastUpdatedAt?: number | null;
607
+ /** `Date.now()` of the last failure; shown for ten seconds, then dropped. */
608
+ lastErrorAt?: number | null;
609
+ /** Renders the on/off switch and the live indicator. Omit for a table without `autoRefresh`. */
610
+ autoRefresh?: DataTableAutoRefreshState | null;
611
+ }
612
+
613
+ /**
614
+ * The freshness controls: Refresh, the last-updated stamp, and — only where a
615
+ * table opted in — the auto-refresh switch with its live indicator. Mandatory
616
+ * on every server-backed table (P-39); `DataTable` renders it beside the
617
+ * search box on its own.
618
+ */
619
+ function DataTableRefresh({
620
+ onRefresh,
621
+ isFetching,
622
+ lastUpdatedAt = null,
623
+ lastErrorAt = null,
624
+ autoRefresh,
625
+ className,
626
+ ...props
627
+ }: DataTableRefreshProps) {
628
+ const note = autoRefresh ? pauseNote(autoRefresh.pausedBy) : null;
629
+ return (
630
+ <div
631
+ data-slot="data-table-refresh"
632
+ className={cn("flex items-center gap-2", className)}
633
+ {...props}
634
+ >
635
+ <Button
636
+ variant="outline"
637
+ size="icon-sm"
638
+ aria-label="Refresh"
639
+ onClick={onRefresh}
640
+ disabled={isFetching}
641
+ >
642
+ <RefreshCw className={cn(isFetching && "animate-spin")} />
643
+ </Button>
644
+ <DataTableUpdatedStamp lastUpdatedAt={lastUpdatedAt} lastErrorAt={lastErrorAt} />
645
+ {autoRefresh ? (
646
+ <span data-slot="data-table-auto-refresh" className="flex items-center gap-1.5">
647
+ <Switch
648
+ aria-label="Auto refresh"
649
+ checked={autoRefresh.enabled}
650
+ onCheckedChange={autoRefresh.setEnabled}
651
+ data-backing-off={autoRefresh.backingOff ? "" : undefined}
652
+ />
653
+ {autoRefresh.active ? (
654
+ <StatusDot data-slot="data-table-live" tone="success" live label="Live" />
655
+ ) : (
656
+ <span
657
+ data-slot="data-table-auto-label"
658
+ title={note ?? undefined}
659
+ className="whitespace-nowrap text-[length:var(--ds-text-caption)] text-[color:var(--ds-muted-foreground)]"
660
+ >
661
+ {autoRefresh.enabled ? (note ? "Paused" : "Auto") : "Auto"}
662
+ </span>
663
+ )}
664
+ </span>
665
+ ) : null}
666
+ </div>
667
+ );
668
+ }
669
+
495
670
  /* ------------------------------------------------------------------------ */
496
671
  /* Pagination */
497
672
  /* ------------------------------------------------------------------------ */
@@ -578,6 +753,7 @@ export {
578
753
  DataTableExportMenu,
579
754
  DataTableFacetedFilter,
580
755
  DataTablePagination,
756
+ DataTableRefresh,
581
757
  DataTableToolbar,
582
758
  DataTableViewOptions,
583
759
  };