@tangle-network/ui 11.48.0 → 11.50.1
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 +28 -0
- package/dist/chat/agent-timeline.js +4 -3
- package/dist/chat/speaker.d.ts +3 -3
- package/dist/chat/speaker.js +12 -18
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/markdown/code-block.js +1 -1
- package/dist/presentation.d.ts +12 -0
- package/dist/presentation.js +85 -1
- package/dist/primitives/metric-strip.d.ts +2 -2
- package/dist/primitives/page-shell.d.ts +6 -3
- package/dist/primitives/page-shell.js +3 -60
- package/dist/primitives/progress.d.ts +8 -0
- package/dist/primitives/progress.js +8 -2
- package/dist/primitives/settings.d.ts +28 -1
- package/dist/primitives/settings.js +78 -29
- package/dist/primitives/status-pill.js +11 -2
- package/dist/primitives.d.ts +3 -3
- package/dist/primitives.js +3 -3
- package/dist/run/expanded-tool-detail.js +12 -4
- package/dist/run/run-group.js +21 -9
- package/dist/run/run-phase-list.js +5 -8
- package/dist/run/status-meta.d.ts +25 -0
- package/dist/run/status-meta.js +105 -0
- package/dist/run.d.ts +2 -1
- package/dist/run.js +2 -1
- package/package.json +2 -2
- package/src/chat/agent-timeline.test.tsx +9 -0
- package/src/chat/agent-timeline.tsx +4 -3
- package/src/chat/speaker.test.tsx +3 -1
- package/src/chat/speaker.tsx +13 -14
- package/src/markdown/code-block.tsx +2 -1
- package/src/markdown/markdown.stories.tsx +11 -0
- package/src/presentation.ts +68 -0
- package/src/primitives/index.ts +2 -1
- package/src/primitives/metric-strip.tsx +2 -2
- package/src/primitives/page-shell.tsx +9 -58
- package/src/primitives/progress.stories.tsx +10 -0
- package/src/primitives/progress.tsx +13 -2
- package/src/primitives/settings.stories.tsx +94 -3
- package/src/primitives/settings.tsx +80 -19
- package/src/primitives/status-pill.tsx +8 -1
- package/src/run/expanded-tool-detail.tsx +4 -10
- package/src/run/index.ts +8 -0
- package/src/run/run-group.tsx +8 -16
- package/src/run/run-phase-list.stories.tsx +19 -0
- package/src/run/run-phase-list.test.tsx +9 -1
- package/src/run/run-phase-list.tsx +3 -3
- package/src/run/status-meta.tsx +92 -0
- package/src/styles/presentation.css +3 -3
|
@@ -2,7 +2,6 @@ import { TRANSCRIPT_TEXT } from "./transcript-text";
|
|
|
2
2
|
import { useState, type KeyboardEvent, type ReactNode } from "react";
|
|
3
3
|
import {
|
|
4
4
|
AlertTriangle,
|
|
5
|
-
CheckCircle2,
|
|
6
5
|
CircleDot,
|
|
7
6
|
FileText,
|
|
8
7
|
Info,
|
|
@@ -16,6 +15,7 @@ import { Markdown } from "../markdown/markdown";
|
|
|
16
15
|
import { ThinkingIndicator } from "./thinking-indicator";
|
|
17
16
|
import { type ToolCallData } from "../run/tool-call-feed";
|
|
18
17
|
import { ToolCallGroup, ToolCallStep } from "../run/tool-call-step";
|
|
18
|
+
import { statusMeta } from "../run/status-meta";
|
|
19
19
|
import type { ChatAuthor } from "../types/message";
|
|
20
20
|
import type { ToolPart } from "../types/parts";
|
|
21
21
|
|
|
@@ -132,7 +132,7 @@ const TONE_STYLES: Record<AgentTimelineTone, { card: string; text: string; suppo
|
|
|
132
132
|
card: "border-[var(--surface-success-border)] bg-[var(--surface-success-bg)]",
|
|
133
133
|
text: "text-[var(--surface-success-text)]",
|
|
134
134
|
supportingText: "text-[var(--surface-success-text)]",
|
|
135
|
-
icon:
|
|
135
|
+
icon: statusMeta("done").icon,
|
|
136
136
|
},
|
|
137
137
|
warning: {
|
|
138
138
|
card: "border-[var(--surface-warning-border)] bg-[var(--surface-warning-bg)]",
|
|
@@ -144,7 +144,8 @@ const TONE_STYLES: Record<AgentTimelineTone, { card: string; text: string; suppo
|
|
|
144
144
|
card: "border-[var(--surface-danger-border)] bg-[var(--surface-danger-bg)]",
|
|
145
145
|
text: "text-[var(--surface-danger-text)]",
|
|
146
146
|
supportingText: "text-[var(--surface-danger-text)]",
|
|
147
|
-
|
|
147
|
+
// Failed is always the failed glyph; the triangle is for warnings.
|
|
148
|
+
icon: statusMeta("failed").icon,
|
|
148
149
|
},
|
|
149
150
|
};
|
|
150
151
|
|
|
@@ -52,6 +52,7 @@ describe("SpeakerHeader", () => {
|
|
|
52
52
|
expect(screen.getByText("@writer")).toBeInTheDocument()
|
|
53
53
|
expect(screen.getByText("Answering")).toBeInTheDocument()
|
|
54
54
|
expect(container.querySelector("[data-speaker-state]")?.getAttribute("data-speaker-state")).toBe("answering")
|
|
55
|
+
expect(container.querySelector("[data-run-status]")?.getAttribute("data-run-status")).toBe("running")
|
|
55
56
|
})
|
|
56
57
|
|
|
57
58
|
it("says nothing more once the responder has ended, even while others answer", () => {
|
|
@@ -62,8 +63,9 @@ describe("SpeakerHeader", () => {
|
|
|
62
63
|
})
|
|
63
64
|
|
|
64
65
|
it("shows a failed responder as failed while the turn still runs", () => {
|
|
65
|
-
render(<SpeakerHeader speaker={{ id: "ap_research", name: "Research Lead", state: "failed" }} live />)
|
|
66
|
+
const { container } = render(<SpeakerHeader speaker={{ id: "ap_research", name: "Research Lead", state: "failed" }} live />)
|
|
66
67
|
expect(screen.getByText("Failed")).toBeInTheDocument()
|
|
68
|
+
expect(container.querySelector("[data-run-status]")?.getAttribute("data-run-status")).toBe("failed")
|
|
67
69
|
expect(screen.queryByText("Answering")).not.toBeInTheDocument()
|
|
68
70
|
})
|
|
69
71
|
|
package/src/chat/speaker.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HTMLAttributes } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { RunStatusIcon } from "../run/status-meta";
|
|
3
3
|
import { MessageAuthor } from "./message-author";
|
|
4
4
|
|
|
5
5
|
/** Where one responder is in its answer. */
|
|
@@ -79,9 +79,9 @@ export interface SpeakerHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>,
|
|
|
79
79
|
* gets, then the handle a person types to address the agent, then its state.
|
|
80
80
|
*
|
|
81
81
|
* The state comes from the speaker's own end state, else from `live`. An
|
|
82
|
-
* answering responder shows the
|
|
83
|
-
* failed
|
|
84
|
-
* run row does. `data-speaker-state` carries all three.
|
|
82
|
+
* answering responder shows the running glyph with the word, a failed one the
|
|
83
|
+
* failed glyph with the word, both from `RunStatusIcon`, and a finished one
|
|
84
|
+
* nothing, as a settled run row does. `data-speaker-state` carries all three.
|
|
85
85
|
*/
|
|
86
86
|
export function SpeakerHeader({ speaker, live = false, ...props }: SpeakerHeaderProps) {
|
|
87
87
|
const state: SpeakerState = speaker.state ?? (live ? "answering" : "done");
|
|
@@ -92,19 +92,18 @@ export function SpeakerHeader({ speaker, live = false, ...props }: SpeakerHeader
|
|
|
92
92
|
{...props}
|
|
93
93
|
>
|
|
94
94
|
{speaker.handle ? (
|
|
95
|
-
|
|
95
|
+
// A person reads the name first. The handle has no base width and grows only into the space
|
|
96
|
+
// the name and state leave, up to its own width, so it gives way before the name loses a pixel.
|
|
97
|
+
<span className="min-w-0 max-w-fit flex-1 truncate text-muted-foreground text-sm" title={`@${speaker.handle}`}>
|
|
96
98
|
@{speaker.handle}
|
|
97
99
|
</span>
|
|
98
100
|
) : null}
|
|
99
|
-
{state === "
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
Failed
|
|
106
|
-
</StatusPill>
|
|
107
|
-
) : null}
|
|
101
|
+
{state === "done" ? null : (
|
|
102
|
+
<span className="inline-flex shrink-0 items-center gap-1.5 font-medium text-sm">
|
|
103
|
+
<RunStatusIcon status={state === "failed" ? "failed" : "running"} aria-hidden />
|
|
104
|
+
{state === "failed" ? "Failed" : "Answering"}
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
108
107
|
</MessageAuthor>
|
|
109
108
|
);
|
|
110
109
|
}
|
|
@@ -169,7 +169,8 @@ export const CopyButton = memo(({ text, label: actionLabel, copiedLabel="Copied"
|
|
|
169
169
|
onClick={handleCopy}
|
|
170
170
|
className={cn(
|
|
171
171
|
codePresentation.copy,
|
|
172
|
-
|
|
172
|
+
// The header's type, not the code font the block sets.
|
|
173
|
+
actionLabel && "gap-2 px-3 font-sans text-sm",
|
|
173
174
|
focusRing,
|
|
174
175
|
className,
|
|
175
176
|
)}
|
|
@@ -221,6 +221,17 @@ export const CodeBlockOnly: Story = {
|
|
|
221
221
|
),
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
export const CodeBlockLabelledCopy: Story = {
|
|
225
|
+
name: 'CodeBlock — labelled copy action',
|
|
226
|
+
render: () => (
|
|
227
|
+
<div className="w-full max-w-[680px] p-6 rounded-xl bg-card">
|
|
228
|
+
<CodeBlock code={`<script src="https://embed.tangle.tools/agent.js" data-agent="support"></script>`} language="html" label="HTML">
|
|
229
|
+
<CopyButton text={`<script src="https://embed.tangle.tools/agent.js" data-agent="support"></script>`} label="Copy embed code" />
|
|
230
|
+
</CodeBlock>
|
|
231
|
+
</div>
|
|
232
|
+
),
|
|
233
|
+
}
|
|
234
|
+
|
|
224
235
|
export const CodeBlockNoLanguage: Story = {
|
|
225
236
|
name: 'CodeBlock — no language',
|
|
226
237
|
render: () => (
|
package/src/presentation.ts
CHANGED
|
@@ -110,6 +110,7 @@ export const metricPresentation = {
|
|
|
110
110
|
} as const;
|
|
111
111
|
|
|
112
112
|
export const metricLayouts = {
|
|
113
|
+
2: "grid-cols-1 sm:grid-cols-2 max-sm:[&>div+div]:border-t sm:[&>div:not(:nth-child(2n+1))]:border-l",
|
|
113
114
|
3: "grid-cols-2 sm:grid-cols-3 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(3n+1))]:border-l",
|
|
114
115
|
4: "grid-cols-2 sm:grid-cols-4 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:[&>div:not(:nth-child(4n+1))]:border-l",
|
|
115
116
|
5: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 max-sm:[&>div:not(:nth-child(2n+1))]:border-l sm:max-lg:[&>div:not(:nth-child(3n+1))]:border-l lg:[&>div:not(:nth-child(5n+1))]:border-l",
|
|
@@ -180,6 +181,73 @@ export function bindDialog(dialog: HTMLDialogElement, trigger: HTMLElement): ()
|
|
|
180
181
|
return () => {trigger.removeEventListener("click",show);dialog.removeEventListener("close",close);dialog.removeEventListener("click",click);dialog.removeEventListener("keydown",key);};
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
/**
|
|
185
|
+
* The settings section links of PageShell `measure="settings"`, for any host: PageShell binds its
|
|
186
|
+
* nav with it, and a server-rendered host binds the same markup. On the tab strip the current link
|
|
187
|
+
* (`aria-current="page"`) is centred by scrolling the strip only, never the page. It centres when
|
|
188
|
+
* the current link changes or first becomes reachable, never on scroll or on a change that keeps
|
|
189
|
+
* the same current link, so a user who scrolled to the first or last section stays there; resize
|
|
190
|
+
* and font swaps re-centre only until the user first scrolls the strip. A plain click on a link to
|
|
191
|
+
* another document makes it current at once, while that document loads, and a page restored from
|
|
192
|
+
* the back-forward cache shows the link it left.
|
|
193
|
+
*/
|
|
194
|
+
export function bindSettingsNav(nav: HTMLElement): () => void {
|
|
195
|
+
let centredKey: string | null = null;
|
|
196
|
+
let userScrolled = false;
|
|
197
|
+
let left: { from: Element | null; to: Element } | null = null;
|
|
198
|
+
const current = () => nav.querySelector<HTMLElement>('[aria-current="page"]');
|
|
199
|
+
const reveal = (layoutOnly: boolean) => {
|
|
200
|
+
const link = current();
|
|
201
|
+
if (nav.scrollWidth <= nav.clientWidth || !link) return;
|
|
202
|
+
const key = link.getAttribute("href") ?? link.textContent ?? "";
|
|
203
|
+
if (key !== centredKey) userScrolled = false;
|
|
204
|
+
else if (!layoutOnly || userScrolled) return;
|
|
205
|
+
centredKey = key;
|
|
206
|
+
const strip = nav.getBoundingClientRect(), box = link.getBoundingClientRect();
|
|
207
|
+
nav.scrollLeft += box.left + box.width / 2 - (strip.left + strip.width / 2);
|
|
208
|
+
};
|
|
209
|
+
const mark = (from: Element | null, to: Element | null) => { from?.removeAttribute("aria-current"); to?.setAttribute("aria-current", "page"); };
|
|
210
|
+
// On window, so it runs after the page's own handlers: a client router that took the click
|
|
211
|
+
// (preventDefault) sets the current link itself.
|
|
212
|
+
const click = (event: MouseEvent) => {
|
|
213
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
214
|
+
const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
|
|
215
|
+
if (!(link instanceof HTMLAnchorElement) || !nav.contains(link) || (link.target && link.target !== "_self") || link.hasAttribute("download")) return;
|
|
216
|
+
if (link.origin !== location.origin || link.pathname + link.search === location.pathname + location.search) return;
|
|
217
|
+
const from = current();
|
|
218
|
+
if (from === link) return;
|
|
219
|
+
left = { from, to: link };
|
|
220
|
+
mark(from, link);
|
|
221
|
+
};
|
|
222
|
+
const restore = (event: PageTransitionEvent) => {
|
|
223
|
+
if (!event.persisted || !left) return;
|
|
224
|
+
mark(left.to, left.from);
|
|
225
|
+
left = null;
|
|
226
|
+
};
|
|
227
|
+
const onUser = () => { userScrolled = true; };
|
|
228
|
+
const userEvents = ["pointerdown", "touchstart", "wheel", "keydown"] as const;
|
|
229
|
+
for (const type of userEvents) nav.addEventListener(type, onUser, { passive: true });
|
|
230
|
+
window.addEventListener("click", click);
|
|
231
|
+
window.addEventListener("pageshow", restore);
|
|
232
|
+
let live = true;
|
|
233
|
+
reveal(false);
|
|
234
|
+
// A web font swap widens the tabs without resizing the strip.
|
|
235
|
+
void document.fonts?.ready.then(() => { if (live) reveal(true); });
|
|
236
|
+
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => reveal(true));
|
|
237
|
+
resize?.observe(nav);
|
|
238
|
+
// Links a host replaces (sections loaded later, a new aria-current) can change the current link.
|
|
239
|
+
const links = new MutationObserver(() => reveal(false));
|
|
240
|
+
links.observe(nav, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["aria-current"] });
|
|
241
|
+
return () => {
|
|
242
|
+
live = false;
|
|
243
|
+
resize?.disconnect();
|
|
244
|
+
links.disconnect();
|
|
245
|
+
for (const type of userEvents) nav.removeEventListener(type, onUser);
|
|
246
|
+
window.removeEventListener("click", click);
|
|
247
|
+
window.removeEventListener("pageshow", restore);
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
183
251
|
export const labelVariants = cva(
|
|
184
252
|
"font-medium text-[length:var(--font-size-label,0.875rem)] leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
|
185
253
|
);
|
package/src/primitives/index.ts
CHANGED
|
@@ -203,13 +203,14 @@ export type { PageShellProps } from "./page-shell";
|
|
|
203
203
|
export { Field, fieldMessageId } from "./field";
|
|
204
204
|
export type { FieldProps } from "./field";
|
|
205
205
|
|
|
206
|
-
export { SettingsList, SettingsListItem, SettingsRow, SettingsSection } from "./settings";
|
|
206
|
+
export { SettingsList, SettingsListItem, SettingsNote, SettingsRow, SettingsSection } from "./settings";
|
|
207
207
|
export type {
|
|
208
208
|
SettingsListAction,
|
|
209
209
|
SettingsListColumns,
|
|
210
210
|
SettingsListItemProps,
|
|
211
211
|
SettingsListMenuItem,
|
|
212
212
|
SettingsListProps,
|
|
213
|
+
SettingsNoteProps,
|
|
213
214
|
SettingsRowLayout,
|
|
214
215
|
SettingsRowProps,
|
|
215
216
|
SettingsSectionProps,
|
|
@@ -8,8 +8,8 @@ export interface MetricStripProps
|
|
|
8
8
|
extends React.HTMLAttributes<HTMLDListElement> {
|
|
9
9
|
children: React.ReactNode;
|
|
10
10
|
/** Desktop columns. Default retains the existing two / sm:four layout.
|
|
11
|
-
*
|
|
12
|
-
columns?: 3 | 4 | 5 | 6;
|
|
11
|
+
* Two stack below sm; five and six use three columns between sm and lg. Never inferred from data. */
|
|
12
|
+
columns?: 2 | 3 | 4 | 5 | 6;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const MetricStrip = React.forwardRef<HTMLDListElement, MetricStripProps>(
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import type { ReactNode } from "react";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
|
+
import { bindSettingsNav } from "../presentation";
|
|
6
7
|
|
|
7
8
|
export interface PageShellProps {
|
|
8
9
|
children?: ReactNode;
|
|
@@ -17,9 +18,12 @@ export interface PageShellProps {
|
|
|
17
18
|
* column. `settings` is a 48rem column of cards whose navigation is a sticky
|
|
18
19
|
* 13rem column of rows when its frame is at least 60rem wide, and a sticky
|
|
19
20
|
* 56px tab strip under the header below that; the current link
|
|
20
|
-
* (`aria-current="page"`) is centred by scrolling the strip only
|
|
21
|
-
* links (or a list of links) in `navigation`;
|
|
22
|
-
* in the column, and an element with
|
|
21
|
+
* (`aria-current="page"`) is centred by scrolling the strip only
|
|
22
|
+
* ({@link bindSettingsNav}). Put links (or a list of links) in `navigation`;
|
|
23
|
+
* an `svg` in a link shows only in the column, and an element with
|
|
24
|
+
* `data-count` sits at its end. A host whose page scrolls under its own
|
|
25
|
+
* sticky header sets `--tangle-settings-sticky-top` on the frame or an
|
|
26
|
+
* ancestor, such as `var(--shell-header-height)`, and the nav sticks below it.
|
|
23
27
|
*/
|
|
24
28
|
measure?: "reading" | "settings";
|
|
25
29
|
navigationLabel?: string;
|
|
@@ -27,60 +31,6 @@ export interface PageShellProps {
|
|
|
27
31
|
className?: string;
|
|
28
32
|
}
|
|
29
33
|
|
|
30
|
-
/**
|
|
31
|
-
* Centre the current link of a horizontally scrolling nav, so the links on
|
|
32
|
-
* both sides show that the strip scrolls. It moves the strip, never the page.
|
|
33
|
-
* It centres when the current link changes (or first becomes reachable), never
|
|
34
|
-
* on scroll, arrow taps or re-renders that keep the same current link, so a
|
|
35
|
-
* user who scrolls to the first or last section stays there. Resize and font
|
|
36
|
-
* swaps re-centre only until the user first scrolls the strip.
|
|
37
|
-
*/
|
|
38
|
-
function useCurrentLinkInView(ref: React.RefObject<HTMLElement | null>, enabled: boolean) {
|
|
39
|
-
React.useEffect(() => {
|
|
40
|
-
const nav = ref.current;
|
|
41
|
-
if (!enabled || !nav) return;
|
|
42
|
-
let centredKey: string | null = null;
|
|
43
|
-
let userScrolled = false;
|
|
44
|
-
const reveal = (layoutOnly: boolean) => {
|
|
45
|
-
if (nav.scrollWidth <= nav.clientWidth) return;
|
|
46
|
-
const current = nav.querySelector<HTMLElement>('[aria-current="page"]');
|
|
47
|
-
if (!current) return;
|
|
48
|
-
const key = current.getAttribute("href") ?? current.textContent ?? "";
|
|
49
|
-
if (key !== centredKey) userScrolled = false;
|
|
50
|
-
else if (!layoutOnly || userScrolled) return;
|
|
51
|
-
centredKey = key;
|
|
52
|
-
const strip = nav.getBoundingClientRect();
|
|
53
|
-
const link = current.getBoundingClientRect();
|
|
54
|
-
nav.scrollLeft += link.left + link.width / 2 - (strip.left + strip.width / 2);
|
|
55
|
-
};
|
|
56
|
-
const onUser = () => { userScrolled = true; };
|
|
57
|
-
nav.addEventListener("pointerdown", onUser, { passive: true });
|
|
58
|
-
nav.addEventListener("touchstart", onUser, { passive: true });
|
|
59
|
-
nav.addEventListener("wheel", onUser, { passive: true });
|
|
60
|
-
nav.addEventListener("keydown", onUser);
|
|
61
|
-
let live = true;
|
|
62
|
-
reveal(false);
|
|
63
|
-
// A web font swap widens the tabs without resizing the strip, so the
|
|
64
|
-
// resize observer alone would leave the current tab where it was.
|
|
65
|
-
void document.fonts?.ready.then(() => { if (live) reveal(true); });
|
|
66
|
-
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => reveal(true));
|
|
67
|
-
resize?.observe(nav);
|
|
68
|
-
// Links that a caller replaces (sections loaded later, a new aria-current)
|
|
69
|
-
// can change the current link; the same current link is left where it is.
|
|
70
|
-
const links = new MutationObserver(() => reveal(false));
|
|
71
|
-
links.observe(nav, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["aria-current"] });
|
|
72
|
-
return () => {
|
|
73
|
-
live = false;
|
|
74
|
-
resize?.disconnect();
|
|
75
|
-
links.disconnect();
|
|
76
|
-
nav.removeEventListener("pointerdown", onUser);
|
|
77
|
-
nav.removeEventListener("touchstart", onUser);
|
|
78
|
-
nav.removeEventListener("wheel", onUser);
|
|
79
|
-
nav.removeEventListener("keydown", onUser);
|
|
80
|
-
};
|
|
81
|
-
}, [ref, enabled]);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
34
|
/**
|
|
85
35
|
* Ported from sandbox-ui/src/primitives/page-shell.tsx. An optional bounded
|
|
86
36
|
* width, gutter and vertical rhythm, not an application shell or a card grid.
|
|
@@ -93,7 +43,8 @@ function useCurrentLinkInView(ref: React.RefObject<HTMLElement | null>, enabled:
|
|
|
93
43
|
export function PageShell({ children, className, navigation, article, outline, header, measure = "reading", navigationLabel = "Sections", outlineLabel = "On this page" }: PageShellProps) {
|
|
94
44
|
const navRef = React.useRef<HTMLElement>(null);
|
|
95
45
|
const settings = measure === "settings";
|
|
96
|
-
|
|
46
|
+
const hasNavigation = Boolean(navigation);
|
|
47
|
+
React.useEffect(() => (settings && hasNavigation && navRef.current ? bindSettingsNav(navRef.current) : undefined), [settings, hasNavigation]);
|
|
97
48
|
if (navigation || article || outline || header || settings) {
|
|
98
49
|
const shell = <div className={cn("tangle-reading-shell", !settings && className)} data-measure={settings ? "settings" : undefined}>
|
|
99
50
|
{header && <div className="tangle-reading-header">{header}</div>}
|
|
@@ -38,6 +38,16 @@ export const SandboxWithValue: Story = {
|
|
|
38
38
|
),
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export const Tones: Story = {
|
|
42
|
+
render: () => (
|
|
43
|
+
<div className="flex w-80 flex-col gap-4">
|
|
44
|
+
{(['neutral', 'success', 'warning', 'danger'] as const).map((tone) => (
|
|
45
|
+
<Progress key={tone} value={tone === 'danger' ? 100 : 70} tone={tone} aria-label={`${tone} reading`} />
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
),
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
export const Empty: Story = {
|
|
42
52
|
render: () => <Progress value={0} className="w-80" />,
|
|
43
53
|
}
|
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { cn } from "../lib/utils";
|
|
4
|
+
import type { StatusTone } from "./status-pill";
|
|
5
|
+
|
|
6
|
+
// A status ink is also its fill (Brand tokens), so a toned bar matches the state text beside it.
|
|
7
|
+
const TONE_FILL = {
|
|
8
|
+
neutral: "bg-[var(--surface-neutral-text)]",
|
|
9
|
+
success: "bg-[var(--surface-success-text)]",
|
|
10
|
+
warning: "bg-[var(--surface-warning-text)]",
|
|
11
|
+
danger: "bg-[var(--surface-danger-text)]",
|
|
12
|
+
} satisfies Partial<Record<StatusTone, string>>;
|
|
4
13
|
|
|
5
14
|
const Progress = React.forwardRef<
|
|
6
15
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
|
7
16
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
|
|
8
17
|
variant?: "default" | "sandbox";
|
|
18
|
+
/** A state the reading is in, such as `danger` at a limit; it replaces the variant's fill. */
|
|
19
|
+
tone?: keyof typeof TONE_FILL;
|
|
9
20
|
showValue?: boolean;
|
|
10
21
|
}
|
|
11
22
|
>(
|
|
12
23
|
(
|
|
13
|
-
{ className, value, variant = "default", showValue = false, ...props },
|
|
24
|
+
{ className, value, variant = "default", tone, showValue = false, ...props },
|
|
14
25
|
ref,
|
|
15
26
|
) => {
|
|
16
27
|
const indicatorVariants = {
|
|
@@ -31,7 +42,7 @@ const Progress = React.forwardRef<
|
|
|
31
42
|
<ProgressPrimitive.Indicator
|
|
32
43
|
className={cn(
|
|
33
44
|
"h-full w-full flex-1 transition-all duration-300 ease-out",
|
|
34
|
-
indicatorVariants[variant],
|
|
45
|
+
tone ? TONE_FILL[tone] : indicatorVariants[variant],
|
|
35
46
|
)}
|
|
36
47
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
|
37
48
|
/>
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { Bell, Bot, Check, CreditCard, Inbox, Loader2, Settings, Users, Workflow } from "lucide-react";
|
|
2
|
-
import { useEffect, useState } from "react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
4
4
|
import { Avatar, AvatarFallback } from "./avatar";
|
|
5
5
|
import { Button } from "./button";
|
|
6
6
|
import { Card } from "./card";
|
|
7
7
|
import { Field, fieldMessageId } from "./field";
|
|
8
8
|
import { Input, Textarea } from "./input";
|
|
9
|
+
import { Metric, MetricStrip } from "./metric-strip";
|
|
9
10
|
import { PageHeader } from "./page-header";
|
|
10
11
|
import { PageShell } from "./page-shell";
|
|
11
12
|
import { SegmentedControl } from "./segmented-control";
|
|
12
13
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
|
13
|
-
import { SettingsList, SettingsListItem, SettingsRow, SettingsSection } from "./settings";
|
|
14
|
+
import { SettingsList, SettingsListItem, SettingsNote, SettingsRow, SettingsSection } from "./settings";
|
|
15
|
+
import type { StatusTone } from "./status-pill";
|
|
14
16
|
import { Switch } from "./switch";
|
|
17
|
+
import { bindSettingsNav } from "../presentation";
|
|
15
18
|
|
|
16
19
|
/** Example data only: no routing, persistence or fetching. Every state is drawn at once so one screenshot shows them all. */
|
|
17
20
|
|
|
@@ -35,6 +38,8 @@ function Saved() {
|
|
|
35
38
|
|
|
36
39
|
interface Automation { name: string; meta: string; state: string; verb: string; ownerOnly?: boolean; error?: string }
|
|
37
40
|
|
|
41
|
+
const AUTOMATION_TONE: Record<string, StatusTone> = { Active: "success", Paused: "neutral", "Couldn't start": "danger" };
|
|
42
|
+
|
|
38
43
|
const automations: Automation[] = [
|
|
39
44
|
{ name: "Weekly competitor scan", meta: "Mondays at 09:00", state: "Active", verb: "Pause" },
|
|
40
45
|
{ name: "Inbound lead triage", meta: "When a signed webhook arrives", state: "Paused", verb: "Resume", ownerOnly: true },
|
|
@@ -152,7 +157,8 @@ function SettingsPage({ sectionsLoadLater = false, withoutNavigation = false, cl
|
|
|
152
157
|
key={job.name}
|
|
153
158
|
title={<a href={`#${job.name}`}>{job.name}</a>}
|
|
154
159
|
meta={job.meta}
|
|
155
|
-
status={job.
|
|
160
|
+
status={job.state}
|
|
161
|
+
tone={AUTOMATION_TONE[job.state]}
|
|
156
162
|
error={job.error}
|
|
157
163
|
action={{
|
|
158
164
|
label: job.verb,
|
|
@@ -277,6 +283,85 @@ function SettingsValuesExample() {
|
|
|
277
283
|
);
|
|
278
284
|
}
|
|
279
285
|
|
|
286
|
+
const webhooks: Array<{ url: string; events: string[]; state: string; tone: StatusTone }> = [
|
|
287
|
+
{ url: "https://hooks.fieldnote-analytics.example/intelligence/v1/trace-events", events: ["trace.completed", "eval.regressed", "budget.exceeded"], state: "Active", tone: "success" },
|
|
288
|
+
{ url: "https://ops.fieldnote.example/alerts/webhooks/intelligence_regressions", events: ["eval.regressed"], state: "Inactive", tone: "neutral" },
|
|
289
|
+
{ url: "https://n8n.fieldnote.example/webhook/7f3c2a91-5d4e-4b8a-9c61-2e0f8d7a4b13", events: ["budget.exceeded", "alert.triggered"], state: "Failing", tone: "danger" },
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
const alertRules: Array<{ name: string; rule: string; severity: string; tone: StatusTone }> = [
|
|
293
|
+
{ name: "Error rate", rule: "Above 5% for 10 minutes", severity: "Critical", tone: "danger" },
|
|
294
|
+
{ name: "p95 latency", rule: "Above 4 s for 15 minutes", severity: "Warning", tone: "warning" },
|
|
295
|
+
{ name: "Daily spend", rule: "Above $40 in a day", severity: "Notice", tone: "neutral" },
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
/** Notes (figure, empty, loading, notice, refusal), toned list states, and long URLs and event names in narrow columns. */
|
|
299
|
+
function NotesAndTonesExample() {
|
|
300
|
+
return (
|
|
301
|
+
<div className="mx-auto max-w-3xl space-y-6 p-6 max-sm:p-4">
|
|
302
|
+
<SettingsSection id="webhooks" title="Webhooks" description="Signed POSTs when traces, evals or budgets change.">
|
|
303
|
+
<SettingsNote>
|
|
304
|
+
<MetricStrip columns={2} aria-label="Last 7 days">
|
|
305
|
+
<Metric label="Deliveries" value="1,284" />
|
|
306
|
+
<Metric label="Failed" value="3" />
|
|
307
|
+
</MetricStrip>
|
|
308
|
+
</SettingsNote>
|
|
309
|
+
<SettingsList aria-label="Webhook endpoints" columns={{ status: "7rem", action: "8rem", menu: "36px" }}>
|
|
310
|
+
{webhooks.map((hook) => (
|
|
311
|
+
<SettingsListItem
|
|
312
|
+
key={hook.url}
|
|
313
|
+
title={<a href={`#${hook.state}`}>{hook.url}</a>}
|
|
314
|
+
meta={hook.events.join(", ")}
|
|
315
|
+
status={hook.state}
|
|
316
|
+
tone={hook.tone}
|
|
317
|
+
action={{ label: "Send test", onSelect: () => undefined }}
|
|
318
|
+
menu={[{ label: "Delete…", tone: "danger", onSelect: () => undefined }]}
|
|
319
|
+
/>
|
|
320
|
+
))}
|
|
321
|
+
</SettingsList>
|
|
322
|
+
<SettingsNote tone="warning" role="status">One endpoint failed its last three deliveries. It pauses after five.</SettingsNote>
|
|
323
|
+
</SettingsSection>
|
|
324
|
+
<SettingsSection id="alert-rules" title="Alert rules">
|
|
325
|
+
<SettingsList aria-label="Alert rules" columns={{ status: "7rem", menu: "36px" }}>
|
|
326
|
+
{alertRules.map((rule) => (
|
|
327
|
+
<SettingsListItem key={rule.name} title={rule.name} meta={rule.rule} status={rule.severity} tone={rule.tone} menu={[{ label: "Delete…", tone: "danger", onSelect: () => undefined }]} />
|
|
328
|
+
))}
|
|
329
|
+
</SettingsList>
|
|
330
|
+
</SettingsSection>
|
|
331
|
+
<SettingsSection id="saved-versions" title="Saved versions">
|
|
332
|
+
<SettingsNote tone="muted">No saved versions yet.</SettingsNote>
|
|
333
|
+
<SettingsNote tone="muted" role="status"><span className="inline-flex items-center gap-1.5"><Loader2 aria-hidden="true" className="size-4 animate-spin motion-reduce:animate-none" />Loading saved versions…</span></SettingsNote>
|
|
334
|
+
<SettingsNote tone="danger" role="alert">Couldn't load saved versions. The workspace didn't answer; nothing changed.</SettingsNote>
|
|
335
|
+
</SettingsSection>
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const HOST_SECTIONS = [["account", "Account"], ["keys", "API keys"], ["usage", "Usage"], ["billing", "Billing"], ["webhooks", "Webhooks"], ["team", "Team"]] as const;
|
|
341
|
+
|
|
342
|
+
/** What a server-rendered host such as tangle-router writes: PageShell's settings markup, with no React inside it. */
|
|
343
|
+
const HOST_MARKUP = `<div class="tangle-settings-frame" style="--tangle-settings-sticky-top: var(--shell-header-height)">
|
|
344
|
+
<div class="tangle-reading-shell" data-measure="settings">
|
|
345
|
+
<div class="tangle-reading-header"><h1 class="tangle-heading" data-variant="section">Settings</h1></div>
|
|
346
|
+
<nav aria-label="Settings sections" class="tangle-reading-nav"><ul role="list">${HOST_SECTIONS.map(([id, label]) => `<li><a href="/settings-doc/${id}"${id === "usage" ? ' aria-current="page"' : ""}>${label}</a></li>`).join("")}</ul></nav>
|
|
347
|
+
<article class="tangle-reading-article">${["Usage", "Limits", "Sources"].map((name) => `<section role="region" aria-label="${name}" data-slot="card" class="rounded-[var(--radius-lg)] border border-border bg-card p-5" style="min-height: 28rem"><h2 class="font-semibold">${name}</h2></section>`).join("")}</article>
|
|
348
|
+
</div></div>`;
|
|
349
|
+
|
|
350
|
+
/** A host whose document scrolls under its own sticky header, with server markup bound by bindSettingsNav. */
|
|
351
|
+
function ServerRenderedHostExample() {
|
|
352
|
+
const root = useRef<HTMLDivElement>(null);
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
const nav = root.current?.querySelector<HTMLElement>("nav");
|
|
355
|
+
return nav ? bindSettingsNav(nav) : undefined;
|
|
356
|
+
}, []);
|
|
357
|
+
return (
|
|
358
|
+
<>
|
|
359
|
+
<header className="sticky top-0 z-10 flex h-[var(--shell-header-height)] items-center border-b border-border bg-background px-6 font-medium text-sm">Host header</header>
|
|
360
|
+
<div ref={root} dangerouslySetInnerHTML={{ __html: HOST_MARKUP }} />
|
|
361
|
+
</>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
280
365
|
const meta = {
|
|
281
366
|
title: "Primitives/Settings",
|
|
282
367
|
component: SettingsSection,
|
|
@@ -302,3 +387,9 @@ export const Fields: Story = { render: () => <FieldsExample /> };
|
|
|
302
387
|
|
|
303
388
|
/** Values preserve ReactNode content and coexist with controls; only value rows stack in a narrow compact layout. */
|
|
304
389
|
export const Values: Story = { render: () => <SettingsValuesExample /> };
|
|
390
|
+
|
|
391
|
+
/** Every line that is not a row, list states in their tone, and long URLs and event names wrapping whole at 390. */
|
|
392
|
+
export const NotesAndTones: Story = { render: () => <NotesAndTonesExample /> };
|
|
393
|
+
|
|
394
|
+
/** tangle-router's case: server-rendered settings markup under a sticky host header, bound with bindSettingsNav. */
|
|
395
|
+
export const ServerRenderedHost: Story = { render: () => <ServerRenderedHostExample /> };
|