@tangle-network/ui 1.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 3.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [8152d92]
8
+ - @tangle-network/brand@0.4.0
9
+
10
+ ## 2.1.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 12f5565: Add `<RedactedDocument>` viewer (`@tangle-network/ui/redaction`): renders a server-produced redacted document with masked, click-to-reveal spans. The client holds only `{ id, kind }` per span; revealing one round-trips through an `onReveal` callback so authorization and the audit trail stay server-side (pairs with `@tangle-network/agent-app/redact`'s `buildRedactedDocument` / `revealSpan`).
15
+
16
+ ## 2.0.0
17
+
18
+ ### Major Changes
19
+
20
+ - 4d7cc77: Drop `editor` re-exports from `@tangle-network/ui` root barrel. The `@tangle-network/ui/editor` subpath is unchanged.
21
+
22
+ **Rationale:** the editor surface drags `@tiptap/*`, `yjs`, and `@hocuspocus/provider` type chains into the package root's `.d.ts`. These are specialized collaboration tooling, not generic UI primitives — they should not appear in a consumer's default import.
23
+
24
+ **Migration:**
25
+
26
+ ```ts
27
+ // before
28
+ import {
29
+ TiptapEditor,
30
+ EditorToolbar,
31
+ DocumentEditorPane,
32
+ } from "@tangle-network/ui";
33
+
34
+ // after
35
+ import {
36
+ TiptapEditor,
37
+ EditorToolbar,
38
+ DocumentEditorPane,
39
+ } from "@tangle-network/ui/editor";
40
+ ```
41
+
42
+ `sed` recipe:
43
+
44
+ ```bash
45
+ grep -rl '"@tangle-network/ui"' src/ \
46
+ | xargs sed -i '' -E '/Tiptap|Editor|Collaborat|useYjs|useAwareness|DocumentEditor|ConnectionState/s|"@tangle-network/ui"|"@tangle-network/ui/editor"|g'
47
+ ```
48
+
49
+ **Known residual:** `dist/index.d.ts` still emits type-only side-effect imports for `@hocuspocus/provider` and `yjs`. These come from `FileArtifactPaneEditorOptions` in `files/file-artifact-pane.tsx`, which types its optional collaboration config against `DocumentEditorMode`/`DocumentEditorBackend`/`DocumentEditorPaneCollaborationConfig` from `editor/`. The actual editor symbols (`TiptapEditor`, `EditorProvider`, etc.) and `@tiptap/*` types are no longer at the root. A follow-up PR can either extract the editor option types out of `editor/` or drop `./files` from the root barrel; both exceed this PR's scope.
50
+
3
51
  ## 1.0.1
4
52
 
5
53
  ### Patch Changes
package/dist/editor.js CHANGED
@@ -1,4 +1,3 @@
1
- import "./chunk-Q7EIIWTC.js";
2
1
  import {
3
2
  CollaboratorsList,
4
3
  DocumentEditorPane,
package/dist/index.d.ts CHANGED
@@ -7,12 +7,10 @@ export { ExpandedToolDetail, ExpandedToolDetailProps, InlineThinkingItem, Inline
7
7
  export { F as FeedSegment, T as ToolCallData, a as ToolCallFeed, b as ToolCallFeedProps, c as ToolCallGroup, d as ToolCallGroupProps, e as ToolCallStatus, f as ToolCallStep, g as ToolCallStepProps, h as ToolCallType, p as parseToolEvent } from './tool-call-feed-Bs3MyQMT.js';
8
8
  export { OpenUIAction, OpenUIActionsNode, OpenUIArtifactRenderer, OpenUIArtifactRendererProps, OpenUIBadgeNode, OpenUICardNode, OpenUICodeNode, OpenUIComponentNode, OpenUIGridNode, OpenUIHeadingNode, OpenUIKeyValueNode, OpenUIMarkdownNode, OpenUIPrimitive, OpenUISeparatorNode, OpenUIStackNode, OpenUIStatNode, OpenUITableNode, OpenUITextNode } from './openui.js';
9
9
  export { FileArtifactPane, FileArtifactPaneProps, FileNode, FilePreview, FilePreviewProps, FileTabData, FileTabs, FileTabsProps, FileTree, FileTreeProps, FileTreeVisibilityOptions, RichFileTree, RichFileTreeGitEntry, RichFileTreeGitStatus, RichFileTreeProps, RichFileTreeThemeVars, filterFileTree } from './files.js';
10
- export { C as Collaborator, a as ConnectionState, D as DocumentEditorBackend, b as DocumentEditorMode, c as DocumentEditorPane, d as DocumentEditorPaneCollaborationConfig, e as DocumentEditorPaneProps, E as EditorContextValue, f as EditorProvider, g as EditorProviderProps, h as EditorTokenRefreshResult, i as EditorUser, u as useEditorContext } from './document-editor-pane-DyDEX_Zm.js';
11
- export { CollaboratorsList, EditorToolbar, TiptapEditor, TiptapEditorProps, useAwareness, useCollaboratorPresence, useCollaborators, useDocumentChanges, useEditorConnection, useYjsState } from './editor.js';
12
10
  export { Markdown, MarkdownProps } from './markdown.js';
13
11
  export { C as CodeBlock, a as CodeBlockProps, b as CopyButton } from './code-block-DjXf8eOG.js';
14
12
  export { AuthHeader, AuthHeaderProps, GitHubLoginButton, GitHubLoginButtonProps, LoginLayout, LoginLayoutProps, SessionUser, UserMenu, UserMenuProps } from './auth.js';
15
- export { AgentStreamEvent, AppendUserMessageOptions, ApplySdkEventOptions, AutomationStreamEvent, BeginAssistantMessageOptions, BotStreamEvent, CompleteAssistantMessageOptions, RealtimeSessionOptions, RealtimeSessionRegistry, RealtimeSessionRegistryProps, RealtimeSessionState, RealtimeSessionTarget, SSEEvent, SdkSessionAttachment, SdkSessionEvent, SdkSessionSeed, TaskStreamEvent, TerminalStreamEvent, UseRunGroupsOptions, UseSSEStreamOptions, UseSSEStreamResult, UseSdkSessionOptions, UseSdkSessionReturn, UseToolCallStreamReturn, useAutoScroll, useDropdownMenu, useRealtimeSession, useRunCollapseState, useRunGroups, useSSEStream, useSdkSession, useToolCallStream } from './sdk-hooks.js';
13
+ export { AgentStreamEvent, AppendUserMessageOptions, ApplySdkEventOptions, AutomationStreamEvent, BeginAssistantMessageOptions, BotStreamEvent, CompleteAssistantMessageOptions, ConnectionState, RealtimeSessionOptions, RealtimeSessionRegistry, RealtimeSessionRegistryProps, RealtimeSessionState, RealtimeSessionTarget, SSEEvent, SdkSessionAttachment, SdkSessionEvent, SdkSessionSeed, TaskStreamEvent, TerminalStreamEvent, UseRunGroupsOptions, UseSSEStreamOptions, UseSSEStreamResult, UseSdkSessionOptions, UseSdkSessionReturn, UseToolCallStreamReturn, useAutoScroll, useDropdownMenu, useRealtimeSession, useRunCollapseState, useRunGroups, useSSEStream, useSdkSession, useToolCallStream } from './sdk-hooks.js';
16
14
  export { AuthUser, UseAuthOptions, UseAuthResult, createAuthFetcher, useApiKey, useAuth, useLiveTime } from './hooks.js';
17
15
  export { A as ActiveProjectActivity, a as ActiveSessionActivityOptions, b as ActiveSessionConnectionOptions, c as ActiveSessionConnectionState, d as ActiveSessionReconnectState, e as ActiveSessionRecord, f as ActiveSessionStatus, g as ActiveSessionTransportMode, h as ActiveSessionsState, R as RegisterActiveSessionOptions, S as SessionProjectKey, i as activeSessionsAtom, j as bumpActiveSessionActivity, k as getActiveSession, l as getAllActiveSessions, m as getAllProjectActivity, n as getSessionsByActivity, o as getSessionsForNavbar, p as getSessionsForProject, q as getTotalRunningSessionCount, r as hasBackgroundRunningSessions, s as registerActiveSession, t as resetActiveSessions, u as setActiveSessionAttention, v as setActiveSessionConnection, w as setActiveSessionError, x as setActiveSessionRunning, y as setForegroundActiveSession, z as unregisterActiveSession, B as updateActiveSessionMeta, C as useActiveSession, D as useActiveSessions, E as useActiveSessionsState, F as useHasBackgroundRunningSessions, G as useNavbarSessions, H as useProjectActivity, I as useProjectSessions, J as useSessionsByActivity, K as useTotalRunningSessions } from './active-sessions-store-CeOmXgv5.js';
18
16
  export { addMessage, addParts, clearChat, isStreamingAtom, messagesAtom, partMapAtom, updatePart } from './stores.js';
@@ -23,8 +21,8 @@ export { F as FinalTextPart, G as GroupedMessage, M as MessageRun, a as MessageU
23
21
  export { C as CustomToolRenderer, D as DisplayVariant, T as ToolDisplayMetadata } from './tool-display-z4JcDmMQ.js';
24
22
  export { TOOL_CATEGORY_ICONS, cn, copyText, formatBytes, formatDuration, formatUptime, getToolCategory, getToolDisplayMetadata, getToolErrorText, timeAgo, truncateText } from './utils.js';
25
23
  export { CommandPreview, CommandPreviewProps, DiffPreview, DiffPreviewProps, GlobResultsPreview, GlobResultsPreviewProps, GrepResultsPreview, GrepResultsPreviewProps, QuestionPreview, QuestionPreviewProps, WebSearchPreview, WebSearchPreviewProps, WriteFilePreview, WriteFilePreviewProps } from './tool-previews.js';
24
+ import { ReactNode } from 'react';
26
25
  import 'class-variance-authority/types';
27
- import 'react';
28
26
  import 'react/jsx-runtime';
29
27
  import '@radix-ui/react-dialog';
30
28
  import 'class-variance-authority';
@@ -36,8 +34,49 @@ import '@radix-ui/react-progress';
36
34
  import '@radix-ui/react-switch';
37
35
  import '@radix-ui/react-label';
38
36
  import '@pierre/trees';
37
+ import './document-editor-pane-DyDEX_Zm.js';
39
38
  import '@hocuspocus/provider';
40
39
  import 'yjs';
41
- import '@tiptap/react';
42
40
  import 'nanostores';
43
41
  import 'clsx';
42
+
43
+ /**
44
+ * Viewer for a server-produced redacted document. Renders text inline and each
45
+ * redacted span as a masked chip; clicking a chip asks the server to reveal that
46
+ * one span. The original plaintext is NEVER in the document the client holds —
47
+ * the chip carries only an id + kind; `onReveal` round-trips to the server, where
48
+ * `@tangle-network/agent-app/redact`'s `revealSpan` runs the authorization check
49
+ * and writes the audit trail. So authz + audit are server-truth; this is display.
50
+ *
51
+ * Structural types (no `@tangle-network/agent-app` dependency) — the viewer needs
52
+ * only `{ id, kind }` per span; the cipher stays server-side.
53
+ */
54
+ type RedactedDocSegment = {
55
+ type: "text";
56
+ text: string;
57
+ } | {
58
+ type: "redacted";
59
+ id: string;
60
+ kind: string;
61
+ };
62
+ interface RedactedDocumentData {
63
+ segments: RedactedDocSegment[];
64
+ }
65
+ interface RevealResult {
66
+ ok: boolean;
67
+ value?: string;
68
+ /** e.g. `forbidden` | `not_found` when `ok` is false. */
69
+ reason?: string;
70
+ }
71
+ interface RedactedDocumentProps {
72
+ document: RedactedDocumentData;
73
+ /** Reveal one span by id. Wire to a server route that calls agent-app's
74
+ * `revealSpan` (authz + audit happen there). Resolves with the original. */
75
+ onReveal: (spanId: string) => Promise<RevealResult>;
76
+ /** Display label for a redaction kind (default: the kind, upper-cased). */
77
+ labelForKind?: (kind: string) => string;
78
+ className?: string;
79
+ }
80
+ declare function RedactedDocument({ document, onReveal, labelForKind, className, }: RedactedDocumentProps): ReactNode;
81
+
82
+ export { type RedactedDocSegment, RedactedDocument, type RedactedDocumentData, type RedactedDocumentProps, type RevealResult };
package/dist/index.js CHANGED
@@ -216,21 +216,6 @@ import {
216
216
  RichFileTree,
217
217
  filterFileTree
218
218
  } from "./chunk-HJKCSXCH.js";
219
- import "./chunk-Q7EIIWTC.js";
220
- import {
221
- CollaboratorsList,
222
- DocumentEditorPane,
223
- EditorProvider,
224
- EditorToolbar,
225
- TiptapEditor,
226
- useAwareness,
227
- useCollaboratorPresence,
228
- useCollaborators,
229
- useDocumentChanges,
230
- useEditorConnection,
231
- useEditorContext,
232
- useYjsState
233
- } from "./chunk-EEE55AVS.js";
234
219
  import {
235
220
  Tabs,
236
221
  TabsContent,
@@ -251,6 +236,103 @@ import {
251
236
  import {
252
237
  cn
253
238
  } from "./chunk-RQHJBTEU.js";
239
+
240
+ // src/redaction/redacted-document.tsx
241
+ import { useCallback, useState } from "react";
242
+ import { Eye, EyeOff, Loader2, ShieldAlert } from "lucide-react";
243
+ import { jsx, jsxs } from "react/jsx-runtime";
244
+ var defaultLabel = (kind) => kind.replace(/[-_]/g, " ").toUpperCase();
245
+ function RedactedChip({
246
+ kind,
247
+ label,
248
+ onReveal
249
+ }) {
250
+ const [state, setState] = useState({ status: "masked" });
251
+ const reveal = useCallback(async () => {
252
+ setState({ status: "loading" });
253
+ try {
254
+ const r = await onReveal();
255
+ setState(
256
+ r.ok && r.value !== void 0 ? { status: "revealed", value: r.value } : { status: "denied", reason: r.reason }
257
+ );
258
+ } catch {
259
+ setState({ status: "denied", reason: "error" });
260
+ }
261
+ }, [onReveal]);
262
+ if (state.status === "revealed") {
263
+ return /* @__PURE__ */ jsxs(
264
+ "button",
265
+ {
266
+ type: "button",
267
+ onClick: () => setState({ status: "masked" }),
268
+ title: "Revealed \u2014 click to hide",
269
+ "aria-label": `${label}: revealed, click to hide`,
270
+ className: cn(
271
+ "inline-flex items-center gap-1 rounded-[var(--radius-sm)] px-1 font-medium",
272
+ "bg-[color-mix(in_oklch,var(--color-warning,orange)_18%,transparent)] text-foreground ring-1 ring-warning/40"
273
+ ),
274
+ children: [
275
+ state.value,
276
+ /* @__PURE__ */ jsx(EyeOff, { className: "size-3 opacity-60" })
277
+ ]
278
+ }
279
+ );
280
+ }
281
+ if (state.status === "denied") {
282
+ return /* @__PURE__ */ jsxs(
283
+ "span",
284
+ {
285
+ title: `Restricted${state.reason ? ` (${state.reason})` : ""}`,
286
+ "aria-label": `${label}: restricted`,
287
+ className: "inline-flex items-center gap-1 rounded-[var(--radius-sm)] bg-muted px-1 text-muted-foreground",
288
+ children: [
289
+ /* @__PURE__ */ jsx(ShieldAlert, { className: "size-3" }),
290
+ " ",
291
+ label
292
+ ]
293
+ }
294
+ );
295
+ }
296
+ return /* @__PURE__ */ jsxs(
297
+ "button",
298
+ {
299
+ type: "button",
300
+ disabled: state.status === "loading",
301
+ onClick: reveal,
302
+ title: `${label} \u2014 click to reveal`,
303
+ "aria-label": `${label} redacted, click to reveal`,
304
+ className: cn(
305
+ "inline-flex items-center gap-1 rounded-[var(--radius-sm)] px-1 font-medium tracking-wide",
306
+ "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground transition-colors",
307
+ "cursor-pointer select-none"
308
+ ),
309
+ children: [
310
+ state.status === "loading" ? /* @__PURE__ */ jsx(Loader2, { className: "size-3 animate-spin" }) : /* @__PURE__ */ jsx(Eye, { className: "size-3 opacity-60" }),
311
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: "\u2588\u2588\u2588" }),
312
+ " ",
313
+ label
314
+ ]
315
+ }
316
+ );
317
+ }
318
+ function RedactedDocument({
319
+ document,
320
+ onReveal,
321
+ labelForKind = defaultLabel,
322
+ className
323
+ }) {
324
+ return /* @__PURE__ */ jsx("div", { className: cn("whitespace-pre-wrap break-words leading-relaxed", className), children: document.segments.map(
325
+ (seg, i) => seg.type === "text" ? /* @__PURE__ */ jsx("span", { children: seg.text }, i) : /* @__PURE__ */ jsx(
326
+ RedactedChip,
327
+ {
328
+ kind: seg.kind,
329
+ label: labelForKind(seg.kind),
330
+ onReveal: () => onReveal(seg.id)
331
+ },
332
+ seg.id
333
+ )
334
+ ) });
335
+ }
254
336
  export {
255
337
  AgentTimeline,
256
338
  ArtifactPane,
@@ -270,7 +352,6 @@ export {
270
352
  ChatInput,
271
353
  ChatMessage,
272
354
  CodeBlock,
273
- CollaboratorsList,
274
355
  CommandPreview,
275
356
  CopyButton,
276
357
  Dialog,
@@ -284,7 +365,6 @@ export {
284
365
  DialogTitle,
285
366
  DialogTrigger,
286
367
  DiffPreview,
287
- DocumentEditorPane,
288
368
  DropZone,
289
369
  DropdownMenu,
290
370
  DropdownMenuCheckboxItem,
@@ -301,8 +381,6 @@ export {
301
381
  DropdownMenuSubContent,
302
382
  DropdownMenuSubTrigger,
303
383
  DropdownMenuTrigger,
304
- EditorProvider,
305
- EditorToolbar,
306
384
  EmptyState,
307
385
  ExpandedToolDetail,
308
386
  FileArtifactPane,
@@ -326,6 +404,7 @@ export {
326
404
  Progress,
327
405
  QuestionPreview,
328
406
  RealtimeSessionRegistry,
407
+ RedactedDocument,
329
408
  RichFileTree,
330
409
  RunGroup,
331
410
  SegmentedControl,
@@ -366,7 +445,6 @@ export {
366
445
  Textarea,
367
446
  ThemeToggle,
368
447
  ThinkingIndicator,
369
- TiptapEditor,
370
448
  ToastContainer,
371
449
  ToastProvider,
372
450
  ToolCallFeed,
@@ -424,13 +502,7 @@ export {
424
502
  useApiKey,
425
503
  useAuth,
426
504
  useAutoScroll,
427
- useAwareness,
428
- useCollaboratorPresence,
429
- useCollaborators,
430
- useDocumentChanges,
431
505
  useDropdownMenu,
432
- useEditorConnection,
433
- useEditorContext,
434
506
  useHasBackgroundRunningSessions,
435
507
  useLiveTime,
436
508
  useNavbarSessions,
@@ -445,6 +517,5 @@ export {
445
517
  useTheme,
446
518
  useToast,
447
519
  useToolCallStream,
448
- useTotalRunningSessions,
449
- useYjsState
520
+ useTotalRunningSessions
450
521
  };
package/dist/nav.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { LinkProps, NavLinkProps } from 'react-router';
3
+ export { LinkProps, NavLinkProps } from 'react-router';
4
+
5
+ /**
6
+ * Navigation primitives that make agent-app products feel snappy by default.
7
+ *
8
+ * The dominant source of the "1–2s when I click through pages" latency in the
9
+ * fleet is NOT slow queries (route loaders' D1 indexes already cover their
10
+ * filters) — it is that every click is a COLD loader round-trip the user waits
11
+ * on, because bare `<Link>`s do no prefetching. React Router can fire the
12
+ * target route's loader on hover/focus (`prefetch="intent"`), overlapping the
13
+ * round-trip with the user's mouse travel so the transition feels instant.
14
+ *
15
+ * These wrappers default `prefetch="intent"` so a product gets that behaviour
16
+ * by importing the shared `<Link>` instead of remembering the flag on every
17
+ * nav element. The default is overridable — a caller that passes `prefetch`
18
+ * wins (the spread is applied after the default).
19
+ */
20
+ /** `react-router` `<Link>` with `prefetch="intent"` on by default. */
21
+ declare function Link({ prefetch, ...props }: LinkProps): react_jsx_runtime.JSX.Element;
22
+ /** `react-router` `<NavLink>` with `prefetch="intent"` on by default. */
23
+ declare function NavLink({ prefetch, ...props }: NavLinkProps): react_jsx_runtime.JSX.Element;
24
+
25
+ export { Link, NavLink };
package/dist/nav.js ADDED
@@ -0,0 +1,16 @@
1
+ // src/nav/index.tsx
2
+ import {
3
+ Link as RRLink,
4
+ NavLink as RRNavLink
5
+ } from "react-router";
6
+ import { jsx } from "react/jsx-runtime";
7
+ function Link({ prefetch = "intent", ...props }) {
8
+ return /* @__PURE__ */ jsx(RRLink, { prefetch, ...props });
9
+ }
10
+ function NavLink({ prefetch = "intent", ...props }) {
11
+ return /* @__PURE__ */ jsx(RRNavLink, { prefetch, ...props });
12
+ }
13
+ export {
14
+ Link,
15
+ NavLink
16
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "1.0.1",
3
+ "version": "3.0.0",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -96,6 +96,11 @@
96
96
  "types": "./dist/tool-previews.d.ts",
97
97
  "import": "./dist/tool-previews.js",
98
98
  "default": "./dist/tool-previews.js"
99
+ },
100
+ "./nav": {
101
+ "types": "./dist/nav.d.ts",
102
+ "import": "./dist/nav.js",
103
+ "default": "./dist/nav.js"
99
104
  }
100
105
  },
101
106
  "dependencies": {
@@ -126,7 +131,8 @@
126
131
  "peerDependencies": {
127
132
  "react": "^18 || ^19",
128
133
  "react-dom": "^18 || ^19",
129
- "@tangle-network/brand": "^0.3.0"
134
+ "react-router": "^7",
135
+ "@tangle-network/brand": "^0.4.0"
130
136
  },
131
137
  "peerDependenciesMeta": {
132
138
  "@nanostores/react": {
@@ -158,6 +164,9 @@
158
164
  },
159
165
  "yjs": {
160
166
  "optional": true
167
+ },
168
+ "react-router": {
169
+ "optional": true
161
170
  }
162
171
  },
163
172
  "devDependencies": {
@@ -177,7 +186,8 @@
177
186
  "@storybook/react": "^8.6.18",
178
187
  "tsup": "^8.3.5",
179
188
  "typescript": "^5.6.0",
180
- "yjs": "^13.6.0"
189
+ "yjs": "^13.6.0",
190
+ "react-router": "^7"
181
191
  },
182
192
  "keywords": [
183
193
  "tangle",
package/src/index.ts CHANGED
@@ -3,8 +3,6 @@ export * from "./chat";
3
3
  export * from "./run";
4
4
  export * from "./openui";
5
5
  export * from "./files";
6
- export { type ConnectionState } from "./editor";
7
- export * from "./editor";
8
6
  export * from "./markdown";
9
7
  export * from "./auth";
10
8
  export * from "./hooks";
@@ -12,3 +10,4 @@ export * from "./stores";
12
10
  export * from "./types";
13
11
  export * from "./utils";
14
12
  export * from "./tool-previews";
13
+ export * from "./redaction";
@@ -0,0 +1,34 @@
1
+ import {
2
+ Link as RRLink,
3
+ NavLink as RRNavLink,
4
+ type LinkProps,
5
+ type NavLinkProps,
6
+ } from "react-router";
7
+
8
+ /**
9
+ * Navigation primitives that make agent-app products feel snappy by default.
10
+ *
11
+ * The dominant source of the "1–2s when I click through pages" latency in the
12
+ * fleet is NOT slow queries (route loaders' D1 indexes already cover their
13
+ * filters) — it is that every click is a COLD loader round-trip the user waits
14
+ * on, because bare `<Link>`s do no prefetching. React Router can fire the
15
+ * target route's loader on hover/focus (`prefetch="intent"`), overlapping the
16
+ * round-trip with the user's mouse travel so the transition feels instant.
17
+ *
18
+ * These wrappers default `prefetch="intent"` so a product gets that behaviour
19
+ * by importing the shared `<Link>` instead of remembering the flag on every
20
+ * nav element. The default is overridable — a caller that passes `prefetch`
21
+ * wins (the spread is applied after the default).
22
+ */
23
+
24
+ /** `react-router` `<Link>` with `prefetch="intent"` on by default. */
25
+ export function Link({ prefetch = "intent", ...props }: LinkProps) {
26
+ return <RRLink prefetch={prefetch} {...props} />;
27
+ }
28
+
29
+ /** `react-router` `<NavLink>` with `prefetch="intent"` on by default. */
30
+ export function NavLink({ prefetch = "intent", ...props }: NavLinkProps) {
31
+ return <RRNavLink prefetch={prefetch} {...props} />;
32
+ }
33
+
34
+ export type { LinkProps, NavLinkProps } from "react-router";
@@ -0,0 +1,7 @@
1
+ export {
2
+ RedactedDocument,
3
+ type RedactedDocumentProps,
4
+ type RedactedDocumentData,
5
+ type RedactedDocSegment,
6
+ type RevealResult,
7
+ } from "./redacted-document";
@@ -0,0 +1,150 @@
1
+ import { useCallback, useState, type ReactNode } from "react";
2
+ import { Eye, EyeOff, Loader2, ShieldAlert } from "lucide-react";
3
+ import { cn } from "../lib/utils";
4
+
5
+ /**
6
+ * Viewer for a server-produced redacted document. Renders text inline and each
7
+ * redacted span as a masked chip; clicking a chip asks the server to reveal that
8
+ * one span. The original plaintext is NEVER in the document the client holds —
9
+ * the chip carries only an id + kind; `onReveal` round-trips to the server, where
10
+ * `@tangle-network/agent-app/redact`'s `revealSpan` runs the authorization check
11
+ * and writes the audit trail. So authz + audit are server-truth; this is display.
12
+ *
13
+ * Structural types (no `@tangle-network/agent-app` dependency) — the viewer needs
14
+ * only `{ id, kind }` per span; the cipher stays server-side.
15
+ */
16
+
17
+ export type RedactedDocSegment =
18
+ | { type: "text"; text: string }
19
+ | { type: "redacted"; id: string; kind: string };
20
+
21
+ export interface RedactedDocumentData {
22
+ segments: RedactedDocSegment[];
23
+ }
24
+
25
+ export interface RevealResult {
26
+ ok: boolean;
27
+ value?: string;
28
+ /** e.g. `forbidden` | `not_found` when `ok` is false. */
29
+ reason?: string;
30
+ }
31
+
32
+ export interface RedactedDocumentProps {
33
+ document: RedactedDocumentData;
34
+ /** Reveal one span by id. Wire to a server route that calls agent-app's
35
+ * `revealSpan` (authz + audit happen there). Resolves with the original. */
36
+ onReveal: (spanId: string) => Promise<RevealResult>;
37
+ /** Display label for a redaction kind (default: the kind, upper-cased). */
38
+ labelForKind?: (kind: string) => string;
39
+ className?: string;
40
+ }
41
+
42
+ type ChipState =
43
+ | { status: "masked" }
44
+ | { status: "loading" }
45
+ | { status: "revealed"; value: string }
46
+ | { status: "denied"; reason?: string };
47
+
48
+ const defaultLabel = (kind: string) => kind.replace(/[-_]/g, " ").toUpperCase();
49
+
50
+ function RedactedChip({
51
+ kind,
52
+ label,
53
+ onReveal,
54
+ }: {
55
+ kind: string;
56
+ label: string;
57
+ onReveal: () => Promise<RevealResult>;
58
+ }) {
59
+ const [state, setState] = useState<ChipState>({ status: "masked" });
60
+
61
+ const reveal = useCallback(async () => {
62
+ setState({ status: "loading" });
63
+ try {
64
+ const r = await onReveal();
65
+ setState(
66
+ r.ok && r.value !== undefined
67
+ ? { status: "revealed", value: r.value }
68
+ : { status: "denied", reason: r.reason },
69
+ );
70
+ } catch {
71
+ setState({ status: "denied", reason: "error" });
72
+ }
73
+ }, [onReveal]);
74
+
75
+ if (state.status === "revealed") {
76
+ return (
77
+ <button
78
+ type="button"
79
+ onClick={() => setState({ status: "masked" })}
80
+ title="Revealed — click to hide"
81
+ aria-label={`${label}: revealed, click to hide`}
82
+ className={cn(
83
+ "inline-flex items-center gap-1 rounded-[var(--radius-sm)] px-1 font-medium",
84
+ "bg-[color-mix(in_oklch,var(--color-warning,orange)_18%,transparent)] text-foreground ring-1 ring-warning/40",
85
+ )}
86
+ >
87
+ {state.value}
88
+ <EyeOff className="size-3 opacity-60" />
89
+ </button>
90
+ );
91
+ }
92
+
93
+ if (state.status === "denied") {
94
+ return (
95
+ <span
96
+ title={`Restricted${state.reason ? ` (${state.reason})` : ""}`}
97
+ aria-label={`${label}: restricted`}
98
+ className="inline-flex items-center gap-1 rounded-[var(--radius-sm)] bg-muted px-1 text-muted-foreground"
99
+ >
100
+ <ShieldAlert className="size-3" /> {label}
101
+ </span>
102
+ );
103
+ }
104
+
105
+ return (
106
+ <button
107
+ type="button"
108
+ disabled={state.status === "loading"}
109
+ onClick={reveal}
110
+ title={`${label} — click to reveal`}
111
+ aria-label={`${label} redacted, click to reveal`}
112
+ className={cn(
113
+ "inline-flex items-center gap-1 rounded-[var(--radius-sm)] px-1 font-medium tracking-wide",
114
+ "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground transition-colors",
115
+ "cursor-pointer select-none",
116
+ )}
117
+ >
118
+ {state.status === "loading" ? (
119
+ <Loader2 className="size-3 animate-spin" />
120
+ ) : (
121
+ <Eye className="size-3 opacity-60" />
122
+ )}
123
+ <span aria-hidden>{"███"}</span> {label}
124
+ </button>
125
+ );
126
+ }
127
+
128
+ export function RedactedDocument({
129
+ document,
130
+ onReveal,
131
+ labelForKind = defaultLabel,
132
+ className,
133
+ }: RedactedDocumentProps): ReactNode {
134
+ return (
135
+ <div className={cn("whitespace-pre-wrap break-words leading-relaxed", className)}>
136
+ {document.segments.map((seg, i) =>
137
+ seg.type === "text" ? (
138
+ <span key={i}>{seg.text}</span>
139
+ ) : (
140
+ <RedactedChip
141
+ key={seg.id}
142
+ kind={seg.kind}
143
+ label={labelForKind(seg.kind)}
144
+ onReveal={() => onReveal(seg.id)}
145
+ />
146
+ ),
147
+ )}
148
+ </div>
149
+ );
150
+ }
File without changes