@tangle-network/ui 11.51.12 → 11.51.14
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 +13 -1
- package/dist/chat/chat-message.js +1 -1
- package/dist/chat/user-message.js +1 -1
- package/dist/hooks.js +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +6 -6
- package/dist/primitives/expandable-panel.d.ts +19 -0
- package/dist/primitives/expandable-panel.js +69 -0
- package/dist/primitives.d.ts +2 -1
- package/dist/primitives.js +2 -1
- package/dist/run/run-row-shell.d.ts +0 -13
- package/dist/run/run-row-shell.js +3 -15
- package/dist/run/status-meta.d.ts +0 -2
- package/dist/run/status-meta.js +1 -7
- package/dist/run.d.ts +3 -4
- package/dist/run.js +3 -4
- package/dist/utils/format.d.ts +0 -4
- package/dist/utils/format.js +1 -12
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +2 -2
- package/package.json +1 -1
- package/src/chat/chat-message.tsx +1 -1
- package/src/chat/message-author.test.tsx +6 -6
- package/src/chat/speaker.stories.tsx +1 -1
- package/src/chat/synthetic-text.test.tsx +2 -2
- package/src/chat/user-message.tsx +1 -1
- package/src/primitives/control-presentation.test.tsx +0 -92
- package/src/primitives/expandable-panel.stories.tsx +36 -0
- package/src/primitives/expandable-panel.test.tsx +49 -0
- package/src/primitives/expandable-panel.tsx +108 -0
- package/src/primitives/index.ts +3 -0
- package/src/run/index.ts +0 -3
- package/src/run/run-row-shell.tsx +4 -20
- package/src/run/status-meta.tsx +0 -3
- package/src/utils/format.ts +0 -13
- package/dist/run/run-item-primitives.d.ts +0 -3
- package/dist/run/run-item-primitives.js +0 -12
- package/src/primitives/switch.test.tsx +0 -39
- package/src/primitives/toolbar.test.tsx +0 -103
- package/src/run/run-item-primitives.tsx +0 -12
|
@@ -23,12 +23,12 @@ const segmentedParts: SessionPart[] = [
|
|
|
23
23
|
describe("a transcript without authors", () => {
|
|
24
24
|
|
|
25
25
|
it("renders UserMessage actions and application notes as before", () => {
|
|
26
|
-
expect(html(<UserMessage content="Can I break my lease early?" actions={<button type="button">Copy</button>} />)).toMatchInlineSnapshot(`"<div class="flex justify-end"><div class="group flex min-w-0 max-w-[78%] flex-col items-end gap-2"><div class="relative w-full min-w-0 rounded-2xl bg-
|
|
27
|
-
expect(html(<UserMessage parts={segmentedParts} actions={<button type="button">Copy</button>} />)).toMatchInlineSnapshot(`"<div class="space-y-2"><div class="flex justify-end"><div class="group flex min-w-0 max-w-[78%] flex-col items-end gap-2"><div class="relative w-full min-w-0 rounded-2xl bg-
|
|
26
|
+
expect(html(<UserMessage content="Can I break my lease early?" actions={<button type="button">Copy</button>} />)).toMatchInlineSnapshot(`"<div class="flex justify-end"><div class="group flex min-w-0 max-w-[78%] flex-col items-end gap-2"><div class="relative w-full min-w-0 rounded-2xl bg-primary/15 px-4 py-3 text-foreground"><div class="break-words whitespace-pre-wrap tangle-transcript-text text-[length:var(--transcript-font-size)] leading-[var(--transcript-line-height)]">Can I break my lease early?</div></div><div class="flex flex-wrap items-center justify-end gap-1.5 text-sm text-muted-foreground"><button type="button">Copy</button></div></div></div>"`)
|
|
27
|
+
expect(html(<UserMessage parts={segmentedParts} actions={<button type="button">Copy</button>} />)).toMatchInlineSnapshot(`"<div class="space-y-2"><div class="flex justify-end"><div class="group flex min-w-0 max-w-[78%] flex-col items-end gap-2"><div class="relative w-full min-w-0 rounded-2xl bg-primary/15 px-4 py-3 text-foreground"><div class="break-words whitespace-pre-wrap tangle-transcript-text text-[length:var(--transcript-font-size)] leading-[var(--transcript-line-height)]">I signed the lease in March.</div></div></div></div><div role="note" aria-label="Application note" data-synthetic-text="" class="min-w-0 rounded-md border border-dashed border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground"><div class="mb-1 font-medium">Application note</div><div class="break-words whitespace-pre-wrap">Lease uploaded: lease.pdf</div></div><div class="flex justify-end"><div class="group flex min-w-0 max-w-[78%] flex-col items-end gap-2"><div class="relative w-full min-w-0 rounded-2xl bg-primary/15 px-4 py-3 text-foreground"><div class="break-words whitespace-pre-wrap tangle-transcript-text text-[length:var(--transcript-font-size)] leading-[var(--transcript-line-height)]">Here it is.</div></div></div></div><div class="flex flex-wrap items-center justify-end gap-1.5 text-sm text-muted-foreground"><button type="button">Copy</button></div></div>"`)
|
|
28
28
|
})
|
|
29
29
|
|
|
30
30
|
it("renders UserMessage as before", () => {
|
|
31
|
-
expect(html(<UserMessage content="Can I break my lease early?" />)).toMatchInlineSnapshot(`"<div class="flex justify-end"><div class="group flex min-w-0 max-w-[78%] flex-col items-end gap-2"><div class="relative w-full min-w-0 rounded-2xl bg-
|
|
31
|
+
expect(html(<UserMessage content="Can I break my lease early?" />)).toMatchInlineSnapshot(`"<div class="flex justify-end"><div class="group flex min-w-0 max-w-[78%] flex-col items-end gap-2"><div class="relative w-full min-w-0 rounded-2xl bg-primary/15 px-4 py-3 text-foreground"><div class="break-words whitespace-pre-wrap tangle-transcript-text text-[length:var(--transcript-font-size)] leading-[var(--transcript-line-height)]">Can I break my lease early?</div></div></div></div>"`)
|
|
32
32
|
})
|
|
33
33
|
|
|
34
34
|
})
|
|
@@ -92,7 +92,7 @@ describe("UserMessage with an author", () => {
|
|
|
92
92
|
const { container } = render(<UserMessage content="Did you give a forwarding address?" author={attorney} viewerId={client.id} />)
|
|
93
93
|
expect(container.firstElementChild?.className).toContain("justify-start")
|
|
94
94
|
expect(bubbleOf("Did you give a forwarding address?").className).toContain("bg-card")
|
|
95
|
-
expect(bubbleOf("Did you give a forwarding address?").className).not.toContain("bg-
|
|
95
|
+
expect(bubbleOf("Did you give a forwarding address?").className).not.toContain("bg-primary/15")
|
|
96
96
|
expect(screen.getByText("Jane Doe")).toBeInTheDocument()
|
|
97
97
|
expect(screen.getByText("Attorney")).toBeInTheDocument()
|
|
98
98
|
})
|
|
@@ -148,7 +148,7 @@ describe("AgentTimeline with three participants", () => {
|
|
|
148
148
|
|
|
149
149
|
it("sets the client's own words apart from the attorney's on the client's screen", () => {
|
|
150
150
|
render(<AgentTimeline items={thread} viewerId={client.id} />)
|
|
151
|
-
expect(bubbleOf("My landlord kept my deposit.").className).toContain("bg-
|
|
151
|
+
expect(bubbleOf("My landlord kept my deposit.").className).toContain("bg-primary/15")
|
|
152
152
|
expect(screen.queryByText("Maria Lopez")).not.toBeInTheDocument()
|
|
153
153
|
expect(bubbleOf("Did you give a forwarding address?").className).toContain("bg-card")
|
|
154
154
|
expect(screen.getByText("Jane Doe")).toBeInTheDocument()
|
|
@@ -156,7 +156,7 @@ describe("AgentTimeline with three participants", () => {
|
|
|
156
156
|
|
|
157
157
|
it("mirrors the thread on the attorney's screen", () => {
|
|
158
158
|
render(<AgentTimeline items={thread} viewerId={attorney.id} />)
|
|
159
|
-
expect(bubbleOf("Did you give a forwarding address?").className).toContain("bg-
|
|
159
|
+
expect(bubbleOf("Did you give a forwarding address?").className).toContain("bg-primary/15")
|
|
160
160
|
expect(bubbleOf("My landlord kept my deposit.").className).toContain("bg-card")
|
|
161
161
|
expect(screen.getByText("Maria Lopez")).toBeInTheDocument()
|
|
162
162
|
expect(screen.queryByText("Jane Doe")).not.toBeInTheDocument()
|
|
@@ -12,7 +12,7 @@ type Part =
|
|
|
12
12
|
function Turn({ parts, live = false }: { parts: Part[]; live?: boolean }) {
|
|
13
13
|
return (
|
|
14
14
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-4 py-6">
|
|
15
|
-
<p className={`ml-auto w-fit max-w-[78%] rounded-2xl bg-
|
|
15
|
+
<p className={`ml-auto w-fit max-w-[78%] rounded-2xl bg-primary/15 px-4 py-3 text-foreground ${TRANSCRIPT_TEXT}`}>
|
|
16
16
|
@writer @research-lead plan the launch announcement for the roster feature.
|
|
17
17
|
</p>
|
|
18
18
|
{partitionBySpeaker(parts).map(({ speaker, parts: own }) => (
|
|
@@ -38,7 +38,7 @@ describe("user text boundaries", () => {
|
|
|
38
38
|
|
|
39
39
|
it("preserves a single joined bubble when no application note is present", () => {
|
|
40
40
|
const { container } = render(<UserMessage parts={mixed.slice(0, 2)} />);
|
|
41
|
-
expect(container.querySelectorAll(".bg-
|
|
41
|
+
expect(container.querySelectorAll(".bg-primary\\/15")).toHaveLength(1);
|
|
42
42
|
expect(container.querySelector(".whitespace-pre-wrap")?.textContent).toBe("First\nSecond");
|
|
43
43
|
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
|
44
44
|
});
|
|
@@ -61,7 +61,7 @@ describe("user text boundaries", () => {
|
|
|
61
61
|
const { container } = render(<UserMessage parts={[mixed[2]]} actions={<button type="button">Inspect receipt</button>} />);
|
|
62
62
|
expect(screen.getByRole("note")).toHaveTextContent("Receipt");
|
|
63
63
|
expect(screen.getByRole("button", { name: "Inspect receipt" })).toBeInTheDocument();
|
|
64
|
-
expect(container.querySelector(".bg-
|
|
64
|
+
expect(container.querySelector(".bg-primary\\/15")).toBeNull();
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
it("retains direct-content timestamps", () => {
|
|
@@ -96,7 +96,7 @@ function UserBubble({ text, timestamp, actions, lines }: {
|
|
|
96
96
|
return (
|
|
97
97
|
<div className="flex justify-end">
|
|
98
98
|
<div className="group flex min-w-0 max-w-[78%] flex-col items-end gap-2">
|
|
99
|
-
<div className="relative w-full min-w-0 rounded-2xl bg-
|
|
99
|
+
<div className="relative w-full min-w-0 rounded-2xl bg-primary/15 px-4 py-3 text-foreground">
|
|
100
100
|
{timestamp ? (
|
|
101
101
|
<span
|
|
102
102
|
data-user-message-time=""
|
|
@@ -1,81 +1,14 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
3
|
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
-
import { controlHeight, controlText, fieldPresentation } from "../lib/control-presentation";
|
|
5
4
|
import { HelpText } from "./help-text";
|
|
6
5
|
import { Label } from "./label";
|
|
7
|
-
import { Button, buttonVariants } from "./button";
|
|
8
6
|
import { Input, Textarea } from "./input";
|
|
9
7
|
import { Select, SelectTrigger, SelectValue } from "./select";
|
|
10
8
|
|
|
11
9
|
afterEach(cleanup);
|
|
12
10
|
|
|
13
11
|
describe("shared control presentation", () => {
|
|
14
|
-
it("uses the field well, not the border/track alias, with scoped foreground", () => {
|
|
15
|
-
// The well is the brand's --well context variable; Toolbar can still pick the fill.
|
|
16
|
-
expect(fieldPresentation).toContain("bg-[var(--field-surface,var(--well))]");
|
|
17
|
-
expect(fieldPresentation.split(" ")).not.toContain("bg-input");
|
|
18
|
-
expect(fieldPresentation.split(" ")).not.toContain("bg-card");
|
|
19
|
-
// ixField's --field-border replaces focusField's resting border-border.
|
|
20
|
-
expect(fieldPresentation.split(" ")).toContain("border-[var(--field-border)]");
|
|
21
|
-
expect(fieldPresentation.split(" ")).not.toContain("border-border");
|
|
22
|
-
expect(fieldPresentation).toContain("text-foreground");
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("puts Button, Input, Textarea and SelectTrigger on one height and text scale", () => {
|
|
26
|
-
const classes = (name: string) => screen.getByLabelText(name).className.split(" ");
|
|
27
|
-
const { rerender } = render(<><Input aria-label="Input" /><Textarea aria-label="Textarea" /><Button aria-label="Action">Action</Button><Select><SelectTrigger aria-label="Select"><SelectValue /></SelectTrigger></Select></>);
|
|
28
|
-
// The default is md for every control: one 36px height, one 14px text size, one corner.
|
|
29
|
-
for (const name of ["Input", "Action", "Select"]) {
|
|
30
|
-
expect(classes(name)).toContain(controlHeight.md);
|
|
31
|
-
expect(classes(name)).toContain(controlText.md);
|
|
32
|
-
expect(classes(name)).toContain("rounded-lg");
|
|
33
|
-
}
|
|
34
|
-
expect(classes("Textarea")).toContain(controlText.md);
|
|
35
|
-
expect(screen.getByLabelText("Textarea").className).toContain("min-h-[120px]");
|
|
36
|
-
for (const size of ["sm", "md", "lg"] as const) {
|
|
37
|
-
rerender(<><Input size={size} aria-label="Input" /><Textarea size={size} aria-label="Textarea" /><Button size={size} aria-label="Action">Action</Button><Select><SelectTrigger size={size} aria-label="Select"><SelectValue /></SelectTrigger></Select></>);
|
|
38
|
-
for (const name of ["Input", "Action", "Select"]) {
|
|
39
|
-
expect(classes(name), `${name} ${size}`).toContain(controlHeight[size]);
|
|
40
|
-
expect(classes(name), `${name} ${size}`).toContain(controlText[size]);
|
|
41
|
-
expect(classes(name), `${name} ${size}`).toContain(size === "sm" ? "rounded-md" : "rounded-lg");
|
|
42
|
-
expect(screen.getByLabelText(name)).not.toHaveAttribute("size");
|
|
43
|
-
}
|
|
44
|
-
expect(classes("Textarea")).toContain(controlText[size]);
|
|
45
|
-
}
|
|
46
|
-
// Fields never drop below 16px on a coarse pointer (iOS zooms on focus otherwise).
|
|
47
|
-
for (const name of ["Input", "Textarea", "Select"]) expect(classes(name)).toContain("pointer-coarse:text-base");
|
|
48
|
-
expect(classes("Action")).not.toContain("pointer-coarse:text-base");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("keeps the explicit compact and touch sizes", () => {
|
|
52
|
-
const { rerender } = render(<><Input size="compact" aria-label="Input" /><Button size="compact">Action</Button><Select><SelectTrigger size="compact" aria-label="Select"><SelectValue /></SelectTrigger></Select></>);
|
|
53
|
-
for (const size of ["compact", "touch"] as const) {
|
|
54
|
-
rerender(<><Input size={size} aria-label="Input" /><Textarea size={size} aria-label="Textarea" /><Button size={size}>Action</Button><Select><SelectTrigger size={size} aria-label="Select"><SelectValue /></SelectTrigger></Select></>);
|
|
55
|
-
for (const name of ["Input", "Select"]) {
|
|
56
|
-
expect(screen.getByLabelText(name).className).toContain(size === "compact" ? "h-[var(--control-height)]" : "min-h-11");
|
|
57
|
-
expect(screen.getByLabelText(name).className.split(" ")).toContain("rounded-lg");
|
|
58
|
-
expect(screen.getByLabelText(name)).not.toHaveAttribute("size");
|
|
59
|
-
}
|
|
60
|
-
expect(screen.getByText("Action").className).toContain(size === "compact" ? "h-[var(--control-height)]" : "min-h-11");
|
|
61
|
-
expect(screen.getByLabelText("Textarea")).not.toHaveAttribute("size");
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("lets a file input's selector button take the field's text size", () => {
|
|
66
|
-
render(<Input type="file" size="lg" aria-label="Upload" />);
|
|
67
|
-
const classes = screen.getByLabelText("Upload").className.split(" ");
|
|
68
|
-
expect(classes).toContain("text-[length:var(--control-text-lg,1rem)]");
|
|
69
|
-
expect(classes.filter((name) => name.startsWith("file:text-"))).toEqual([]);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("gives icon buttons squares on the same heights", () => {
|
|
73
|
-
render(<><Button size="icon-sm" aria-label="Small" /><Button size="icon" aria-label="Medium" /><Button size="icon-lg" aria-label="Large" /></>);
|
|
74
|
-
expect(screen.getByLabelText("Small").className).toContain("size-[var(--control-height-sm,2rem)]");
|
|
75
|
-
expect(screen.getByLabelText("Medium").className).toContain("size-[var(--control-height,2.25rem)]");
|
|
76
|
-
expect(screen.getByLabelText("Large").className).toContain("size-[var(--control-height-lg,2.75rem)]");
|
|
77
|
-
});
|
|
78
|
-
|
|
79
12
|
it("renders labels and help text at one size each, and wires hint and error to the field", () => {
|
|
80
13
|
render(<><Label htmlFor="x">Name</Label><HelpText>Plain</HelpText><Input label="Email" hint="We never share it" error="Enter an address" /></>);
|
|
81
14
|
expect(screen.getByText("Name").className).toContain("text-[length:var(--font-size-label,0.875rem)]");
|
|
@@ -105,29 +38,4 @@ describe("shared control presentation", () => {
|
|
|
105
38
|
expect(trigger.current).toBeDisabled();
|
|
106
39
|
expect(trigger.current!.className.split(" ")).toContain("h-12");
|
|
107
40
|
});
|
|
108
|
-
|
|
109
|
-
it("uses accent ink for link actions while retaining filled action foregrounds and scale motion", () => {
|
|
110
|
-
expect(buttonVariants({ variant: "link" })).toContain("text-[var(--accent-text)]");
|
|
111
|
-
expect(buttonVariants({ variant: "default" })).toContain("text-primary-foreground");
|
|
112
|
-
expect(buttonVariants({ variant: "destructive" })).toContain("text-destructive-foreground");
|
|
113
|
-
expect(buttonVariants({ variant: "sandbox" })).toContain("text-[var(--btn-primary-text)]");
|
|
114
|
-
expect(buttonVariants()).not.toContain("transition-all");
|
|
115
|
-
expect(buttonVariants()).toContain("motion-reduce:active:scale-100");
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
describe("SelectTrigger with a value longer than the trigger", () => {
|
|
120
|
-
it("ends the value in an ellipsis with a gap before the chevron", () => {
|
|
121
|
-
const value = "Luna — ReCenter Guest Concierge & Practitioner Desk";
|
|
122
|
-
render(<Select value="concierge"><SelectTrigger aria-label="Agent" className="w-40"><SelectValue>{value}</SelectValue></SelectTrigger></Select>);
|
|
123
|
-
const trigger = screen.getByLabelText("Agent");
|
|
124
|
-
const classes = trigger.className.split(" ");
|
|
125
|
-
// line-clamp cannot ellipsize a nowrap line: it clipped mid-glyph under the chevron.
|
|
126
|
-
expect(trigger.className).not.toContain("line-clamp");
|
|
127
|
-
expect(classes).toContain("[&>span]:truncate");
|
|
128
|
-
expect(classes).toContain("[&>span]:min-w-0");
|
|
129
|
-
expect(classes).toContain("gap-2");
|
|
130
|
-
// The rule targets the value span, which Radix renders as the trigger's direct child.
|
|
131
|
-
expect(trigger.querySelector(":scope > span")?.textContent).toBe(value);
|
|
132
|
-
});
|
|
133
41
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { Button } from './button'
|
|
3
|
+
import { ExpandablePanel } from './expandable-panel'
|
|
4
|
+
import { MetricChart } from './metric-chart'
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from './popover'
|
|
6
|
+
|
|
7
|
+
const meta: Meta = {
|
|
8
|
+
title: 'Primitives/ExpandablePanel',
|
|
9
|
+
parameters: { layout: 'padded', backgrounds: { default: 'dark' } },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj
|
|
14
|
+
|
|
15
|
+
const t0 = Date.parse('2026-08-24T07:00:00.000Z')
|
|
16
|
+
const points = Array.from({ length: 24 }, (_, i) => ({ at: t0 + i * 3_600_000, value: 40 + Math.round(20 * Math.sin(i / 3)) + i }))
|
|
17
|
+
|
|
18
|
+
// Hover or focus the panel for the toggle; Escape closes the open popover first, then the panel.
|
|
19
|
+
export const ChartPanel: Story = {
|
|
20
|
+
render: () => (
|
|
21
|
+
<ExpandablePanel
|
|
22
|
+
label="Throughput chart"
|
|
23
|
+
className="h-72 w-[560px] rounded-xl border border-border bg-card p-4"
|
|
24
|
+
actions={
|
|
25
|
+
<Popover>
|
|
26
|
+
<PopoverTrigger asChild>
|
|
27
|
+
<Button variant="ghost" size="sm">Range</Button>
|
|
28
|
+
</PopoverTrigger>
|
|
29
|
+
<PopoverContent className="w-48 text-sm">Last 24 hours</PopoverContent>
|
|
30
|
+
</Popover>
|
|
31
|
+
}
|
|
32
|
+
>
|
|
33
|
+
<MetricChart title="Throughput" unit="req/s" points={points} className="min-h-0 flex-1" />
|
|
34
|
+
</ExpandablePanel>
|
|
35
|
+
),
|
|
36
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { ExpandablePanel } from "./expandable-panel";
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
6
|
+
|
|
7
|
+
describe("ExpandablePanel", () => {
|
|
8
|
+
// The journey a run view needs: expand in place, let an inner popover take the
|
|
9
|
+
// first Escape, close the top-most panel on the next, and hand focus back.
|
|
10
|
+
it("expands in place and closes the top-most panel on Escape after inner layers", async () => {
|
|
11
|
+
const user = userEvent.setup();
|
|
12
|
+
render(
|
|
13
|
+
<ExpandablePanel label="Run">
|
|
14
|
+
<p data-testid="content">chart</p>
|
|
15
|
+
<ExpandablePanel label="Chart">
|
|
16
|
+
<Popover>
|
|
17
|
+
<PopoverTrigger>Range</PopoverTrigger>
|
|
18
|
+
<PopoverContent>Last 24 hours</PopoverContent>
|
|
19
|
+
</Popover>
|
|
20
|
+
</ExpandablePanel>
|
|
21
|
+
</ExpandablePanel>,
|
|
22
|
+
);
|
|
23
|
+
const content = screen.getByTestId("content"), outer = content.parentElement!;
|
|
24
|
+
|
|
25
|
+
await user.click(screen.getByRole("button", { name: "Expand Run" }));
|
|
26
|
+
expect(outer).toHaveAttribute("data-expanded");
|
|
27
|
+
expect(outer.className).toContain("fixed");
|
|
28
|
+
expect(screen.getByTestId("content")).toBe(content);
|
|
29
|
+
expect(document.body.style.overflow).toBe("hidden");
|
|
30
|
+
|
|
31
|
+
await user.click(screen.getByRole("button", { name: "Expand Chart" }));
|
|
32
|
+
await user.click(screen.getByRole("button", { name: "Range" }));
|
|
33
|
+
expect(await screen.findByText("Last 24 hours")).toBeInTheDocument();
|
|
34
|
+
|
|
35
|
+
await user.keyboard("{Escape}");
|
|
36
|
+
await waitFor(() => expect(screen.queryByText("Last 24 hours")).not.toBeInTheDocument());
|
|
37
|
+
expect(screen.getByRole("button", { name: "Close full screen Chart" })).toBeInTheDocument();
|
|
38
|
+
|
|
39
|
+
await user.keyboard("{Escape}");
|
|
40
|
+
expect(screen.getByRole("button", { name: "Expand Chart" })).toHaveFocus();
|
|
41
|
+
expect(outer).toHaveAttribute("data-expanded");
|
|
42
|
+
expect(document.body.style.overflow).toBe("hidden");
|
|
43
|
+
|
|
44
|
+
await user.keyboard("{Escape}");
|
|
45
|
+
expect(outer).not.toHaveAttribute("data-expanded");
|
|
46
|
+
expect(screen.getByRole("button", { name: "Expand Run" })).toHaveFocus();
|
|
47
|
+
expect(document.body.style.overflow).toBe("");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Maximize2, Minimize2 } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
|
|
8
|
+
export interface ExpandablePanelProps {
|
|
9
|
+
children?: React.ReactNode;
|
|
10
|
+
/** Accessible name: the toggle reads "Expand <label>" and "Close full screen <label>". */
|
|
11
|
+
label: string;
|
|
12
|
+
expanded?: boolean;
|
|
13
|
+
onExpandedChange?: (expanded: boolean) => void;
|
|
14
|
+
className?: string;
|
|
15
|
+
actions?: React.ReactNode; // controls shown beside the toggle
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Expanded panels in opening order. Escape closes the last one that holds no other.
|
|
19
|
+
const openPanels: HTMLElement[] = [];
|
|
20
|
+
let bodyOverflow = "";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A panel that fills the viewport in place and returns on Escape. The subtree
|
|
24
|
+
* never moves (no portal), so chart, canvas and conversation state and scroll
|
|
25
|
+
* survive the switch. Radix popovers, menus and dialogs inside take Escape
|
|
26
|
+
* first: they prevent its default, which this panel respects. An ancestor
|
|
27
|
+
* with a transform or filter would contain the fixed panel, so avoid one.
|
|
28
|
+
*/
|
|
29
|
+
function ExpandablePanel({
|
|
30
|
+
children,
|
|
31
|
+
label,
|
|
32
|
+
expanded: expandedProp,
|
|
33
|
+
onExpandedChange,
|
|
34
|
+
className,
|
|
35
|
+
actions,
|
|
36
|
+
}: ExpandablePanelProps) {
|
|
37
|
+
const [expandedState, setExpandedState] = React.useState(false);
|
|
38
|
+
const expanded = expandedProp ?? expandedState;
|
|
39
|
+
const toggleRef = React.useRef<HTMLButtonElement>(null);
|
|
40
|
+
const panelRef = React.useRef<HTMLDivElement>(null);
|
|
41
|
+
const wasExpanded = React.useRef(expanded);
|
|
42
|
+
const setExpanded = (next: boolean) => {
|
|
43
|
+
if (expandedProp === undefined) setExpandedState(next);
|
|
44
|
+
onExpandedChange?.(next);
|
|
45
|
+
};
|
|
46
|
+
// A ref, so a new onExpandedChange never re-registers and reorders the open stack.
|
|
47
|
+
const setExpandedRef = React.useRef(setExpanded);
|
|
48
|
+
setExpandedRef.current = setExpanded;
|
|
49
|
+
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
if (wasExpanded.current && !expanded) toggleRef.current?.focus();
|
|
52
|
+
wasExpanded.current = expanded;
|
|
53
|
+
const id = panelRef.current;
|
|
54
|
+
if (!expanded || !id) return;
|
|
55
|
+
if (openPanels.length === 0) bodyOverflow = document.body.style.overflow;
|
|
56
|
+
openPanels.push(id);
|
|
57
|
+
document.body.style.overflow = "hidden";
|
|
58
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
59
|
+
const top = openPanels.filter((p) => !openPanels.some((q) => q !== p && p.contains(q))).pop();
|
|
60
|
+
if (event.key !== "Escape" || event.defaultPrevented || top !== id) return;
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
setExpandedRef.current(false);
|
|
63
|
+
};
|
|
64
|
+
document.addEventListener("keydown", onKeyDown);
|
|
65
|
+
return () => {
|
|
66
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
67
|
+
openPanels.splice(openPanels.indexOf(id), 1);
|
|
68
|
+
if (openPanels.length === 0) document.body.style.overflow = bodyOverflow;
|
|
69
|
+
};
|
|
70
|
+
}, [expanded]);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
ref={panelRef}
|
|
75
|
+
data-expandable-panel=""
|
|
76
|
+
data-expanded={expanded ? "" : undefined}
|
|
77
|
+
className={cn(
|
|
78
|
+
"group/expandable relative flex min-h-0 flex-col",
|
|
79
|
+
className, // before the expanded classes, so a caller's size cannot shrink it
|
|
80
|
+
expanded && "fixed inset-0 z-50 m-0 size-auto min-h-0 min-w-0 max-h-none max-w-none rounded-none transform-none bg-background",
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
{children}
|
|
84
|
+
<div
|
|
85
|
+
className={cn(
|
|
86
|
+
"absolute right-2 top-2 z-10 flex items-center gap-1 transition-opacity",
|
|
87
|
+
expanded
|
|
88
|
+
? "opacity-100"
|
|
89
|
+
: "opacity-0 group-hover/expandable:opacity-100 group-focus-within/expandable:opacity-100 pointer-coarse:opacity-100",
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
{actions}
|
|
93
|
+
<Button
|
|
94
|
+
ref={toggleRef}
|
|
95
|
+
type="button"
|
|
96
|
+
variant="ghost"
|
|
97
|
+
size="icon-sm"
|
|
98
|
+
aria-label={`${expanded ? "Close full screen" : "Expand"} ${label}`}
|
|
99
|
+
onClick={() => setExpanded(!expanded)}
|
|
100
|
+
>
|
|
101
|
+
{expanded ? <Minimize2 aria-hidden /> : <Maximize2 aria-hidden />}
|
|
102
|
+
</Button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export { ExpandablePanel };
|
package/src/primitives/index.ts
CHANGED
|
@@ -279,3 +279,6 @@ export type {
|
|
|
279
279
|
|
|
280
280
|
export { Toolbar, FilterField } from "./toolbar";
|
|
281
281
|
export type { ToolbarProps, FilterFieldProps } from "./toolbar";
|
|
282
|
+
|
|
283
|
+
export { ExpandablePanel } from "./expandable-panel";
|
|
284
|
+
export type { ExpandablePanelProps } from "./expandable-panel";
|
package/src/run/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export { RunGroup, type RunGroupProps } from "./run-group";
|
|
2
2
|
export {
|
|
3
3
|
RunRowShell,
|
|
4
|
-
RunRowStatusDot,
|
|
5
4
|
type RunRowShellProps,
|
|
6
5
|
type RunRowStatus,
|
|
7
6
|
} from "./run-row-shell";
|
|
@@ -14,7 +13,6 @@ export {
|
|
|
14
13
|
ExpandedToolDetail,
|
|
15
14
|
type ExpandedToolDetailProps,
|
|
16
15
|
} from "./expanded-tool-detail";
|
|
17
|
-
export { LiveDuration } from "./run-item-primitives";
|
|
18
16
|
// ToolCallStep/ToolCallGroup are internal adapters over InlineToolItem (used by
|
|
19
17
|
// AgentTimeline + ToolCallFeed); only their status/type vocabulary is public
|
|
20
18
|
// because ToolCallData references it.
|
|
@@ -40,7 +38,6 @@ export { RunPhaseList, type RunPhase } from "./run-phase-list";
|
|
|
40
38
|
export {
|
|
41
39
|
RunStatusIcon,
|
|
42
40
|
runStatus,
|
|
43
|
-
isOpen,
|
|
44
41
|
statusMeta,
|
|
45
42
|
type RunStatus,
|
|
46
43
|
type RunStatusIconProps,
|
|
@@ -1,27 +1,12 @@
|
|
|
1
1
|
import { useState, type ReactNode } from "react";
|
|
2
2
|
import * as Collapsible from "@radix-ui/react-collapsible";
|
|
3
3
|
import { ChevronRight } from "lucide-react";
|
|
4
|
+
import { focusRingInset } from "../lib/focus";
|
|
4
5
|
import { cn } from "../lib/utils";
|
|
5
6
|
import { formatDuration } from "../utils/format";
|
|
6
7
|
|
|
7
8
|
export type RunRowStatus = "running" | "success" | "error" | "idle";
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
-
* A red dot for a failed row, for rows composed outside the shell. The shell
|
|
11
|
-
* itself marks a failure on the row's own glyph, beside its name, never at the
|
|
12
|
-
* far edge. Success is silence, and a running row carries its state in the
|
|
13
|
-
* title — a `TextShimmer` sweep.
|
|
14
|
-
*/
|
|
15
|
-
export function RunRowStatusDot({ status }: { status: RunRowStatus }) {
|
|
16
|
-
if (status !== "error") return null;
|
|
17
|
-
return (
|
|
18
|
-
<span
|
|
19
|
-
data-run-row-status="error"
|
|
20
|
-
className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--surface-danger-text)]"
|
|
21
|
-
/>
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
10
|
export interface RunRowShellProps {
|
|
26
11
|
/** Semantic lead glyph (14px). At rest it is the row's only ornament; on
|
|
27
12
|
* hover and while open the expand chevron takes its slot. */
|
|
@@ -35,10 +20,6 @@ export interface RunRowShellProps {
|
|
|
35
20
|
/** Render the description in mono (tools) vs prose (reasoning). */
|
|
36
21
|
descriptionMono?: boolean;
|
|
37
22
|
status?: RunRowStatus;
|
|
38
|
-
/** @deprecated The shell no longer ticks a live duration while running; the
|
|
39
|
-
* running state lives in the title (`TextShimmer`). Accepted and ignored so
|
|
40
|
-
* existing callers keep compiling. */
|
|
41
|
-
startTime?: number;
|
|
42
23
|
/** Static duration, revealed on hover once the row is no longer running. */
|
|
43
24
|
durationMs?: number;
|
|
44
25
|
/** Radius shaping when rows are stacked into a group. */
|
|
@@ -128,7 +109,10 @@ export function RunRowShell({
|
|
|
128
109
|
// The header is the hover group, not the whole card: rows nested in an
|
|
129
110
|
// open row (a fold's steps) keep their own glyphs while the
|
|
130
111
|
// pointer is over them.
|
|
112
|
+
// Inset focus ring: the row clips overflow, so the shared ring
|
|
113
|
+
// must draw inside the trigger instead of around it.
|
|
131
114
|
className={cn(
|
|
115
|
+
focusRingInset,
|
|
132
116
|
"group flex w-full items-center gap-2 text-left",
|
|
133
117
|
HEADER_INSET,
|
|
134
118
|
expandable ? "cursor-pointer" : "cursor-default",
|
package/src/run/status-meta.tsx
CHANGED
|
@@ -57,9 +57,6 @@ export function runStatus(status: string): RunStatus {
|
|
|
57
57
|
return Object.hasOwn(ALIASES, key) ? ALIASES[key] : "unknown";
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
/** Whether a canonical run state can still advance. */
|
|
61
|
-
export const isOpen = (status: RunStatus | (string & {})): boolean => ["running", "waiting", "needs-you"].includes(runStatus(status));
|
|
62
|
-
|
|
63
60
|
/** The label, glyph and colour token for a status, in every surface. */
|
|
64
61
|
export function statusMeta(status: RunStatus | (string & {})): StatusMeta {
|
|
65
62
|
return META[runStatus(status)];
|
package/src/utils/format.ts
CHANGED
|
@@ -57,19 +57,6 @@ export function formatSpanDuration(ms: number): string {
|
|
|
57
57
|
return ms >= 86_400_000 ? formatAxisDuration(ms) : ms < 1000 ? `${Math.round(ms)}ms` : formatClock(Math.round(ms / precision) * precision)
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
/** $0.12, $0.005, $12.40; never rounds a recorded nonzero cost to $0.00. */
|
|
61
|
-
export function formatCost(usd: number): string {
|
|
62
|
-
if (!Number.isFinite(usd) || usd < 0) return '—'
|
|
63
|
-
if (usd === 0) return '$0.00'
|
|
64
|
-
if (usd < 0.01) return `$${usd.toPrecision(1)}`
|
|
65
|
-
return `$${usd.toFixed(2)}`
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** 9.8k, 754.4k, 1.2M with the exact count left to a title. */
|
|
69
|
-
export function formatCount(n: number): string {
|
|
70
|
-
return !Number.isSafeInteger(n) || n < 0 ? '—' : formatCompact(n)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
60
|
/** Elapsed clock at millisecond precision, so zoomed axis ticks keep distinct labels. */
|
|
74
61
|
export function formatClock(ms: number): string {
|
|
75
62
|
if (!Number.isFinite(ms) || ms < 0) return '—'
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { formatDuration as formatSpanDuration } from "../utils/format.js";
|
|
2
|
-
import { useLiveTime } from "../hooks/use-live-time.js";
|
|
3
|
-
import { jsx } from "react/jsx-runtime";
|
|
4
|
-
// src/run/run-item-primitives.tsx
|
|
5
|
-
function LiveDuration({ startTime }) {
|
|
6
|
-
const elapsed = useLiveTime(100) - startTime;
|
|
7
|
-
return /* @__PURE__ */ jsx("span", {
|
|
8
|
-
className: "text-xs font-mono text-neutral-400 dark:text-neutral-500 tabular-nums",
|
|
9
|
-
children: formatSpanDuration(elapsed)
|
|
10
|
-
});
|
|
11
|
-
}
|
|
12
|
-
export { LiveDuration };
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
-
import { Switch } from "./switch";
|
|
4
|
-
|
|
5
|
-
afterEach(cleanup);
|
|
6
|
-
|
|
7
|
-
/** The classes that apply in a state: unprefixed ones, plus that state's variant. */
|
|
8
|
-
function stateClasses(el: Element, state: "checked" | "unchecked"): string[] {
|
|
9
|
-
return (el.getAttribute("class") ?? "")
|
|
10
|
-
.split(/\s+/)
|
|
11
|
-
.filter((c) => !c.startsWith("data-[") || c.startsWith(`data-[state=${state}]:`))
|
|
12
|
-
.map((c) => c.replace(`data-[state=${state}]:`, ""));
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
describe("Switch", () => {
|
|
16
|
-
it("draws its off state in muted-foreground, so an unchecked switch is visible on a card or the canvas", () => {
|
|
17
|
-
render(<Switch aria-label="Reminders" />);
|
|
18
|
-
const track = screen.getByRole("switch", { name: "Reminders" });
|
|
19
|
-
const thumb = track.firstElementChild as Element;
|
|
20
|
-
expect(track).toHaveAttribute("data-state", "unchecked");
|
|
21
|
-
expect(stateClasses(track, "unchecked")).toContain("border-muted-foreground");
|
|
22
|
-
expect(stateClasses(track, "unchecked")).not.toContain("bg-input");
|
|
23
|
-
expect(stateClasses(thumb, "unchecked")).toContain("bg-muted-foreground");
|
|
24
|
-
expect(stateClasses(thumb, "unchecked")).not.toContain("bg-background");
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("draws its on state as an accent-text track with a card thumb, so a checked switch clears 3:1 in every theme", () => {
|
|
28
|
-
render(<Switch aria-label="Reminders" />);
|
|
29
|
-
const track = screen.getByRole("switch", { name: "Reminders" });
|
|
30
|
-
fireEvent.click(track);
|
|
31
|
-
expect(track).toHaveAttribute("data-state", "checked");
|
|
32
|
-
const thumb = track.firstElementChild as Element;
|
|
33
|
-
expect(stateClasses(track, "checked")).toContain("bg-[var(--accent-text)]");
|
|
34
|
-
expect(stateClasses(track, "checked")).toContain("border-transparent");
|
|
35
|
-
expect(stateClasses(track, "checked")).not.toContain("bg-primary");
|
|
36
|
-
expect(stateClasses(thumb, "checked")).toContain("bg-card");
|
|
37
|
-
expect(stateClasses(thumb, "checked")).not.toContain("bg-background");
|
|
38
|
-
});
|
|
39
|
-
});
|