@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/chat/chat-message.js +1 -1
  3. package/dist/chat/user-message.js +1 -1
  4. package/dist/hooks.js +1 -1
  5. package/dist/index.d.ts +5 -5
  6. package/dist/index.js +6 -6
  7. package/dist/primitives/expandable-panel.d.ts +19 -0
  8. package/dist/primitives/expandable-panel.js +69 -0
  9. package/dist/primitives.d.ts +2 -1
  10. package/dist/primitives.js +2 -1
  11. package/dist/run/run-row-shell.d.ts +0 -13
  12. package/dist/run/run-row-shell.js +3 -15
  13. package/dist/run/status-meta.d.ts +0 -2
  14. package/dist/run/status-meta.js +1 -7
  15. package/dist/run.d.ts +3 -4
  16. package/dist/run.js +3 -4
  17. package/dist/utils/format.d.ts +0 -4
  18. package/dist/utils/format.js +1 -12
  19. package/dist/utils.d.ts +2 -2
  20. package/dist/utils.js +2 -2
  21. package/package.json +1 -1
  22. package/src/chat/chat-message.tsx +1 -1
  23. package/src/chat/message-author.test.tsx +6 -6
  24. package/src/chat/speaker.stories.tsx +1 -1
  25. package/src/chat/synthetic-text.test.tsx +2 -2
  26. package/src/chat/user-message.tsx +1 -1
  27. package/src/primitives/control-presentation.test.tsx +0 -92
  28. package/src/primitives/expandable-panel.stories.tsx +36 -0
  29. package/src/primitives/expandable-panel.test.tsx +49 -0
  30. package/src/primitives/expandable-panel.tsx +108 -0
  31. package/src/primitives/index.ts +3 -0
  32. package/src/run/index.ts +0 -3
  33. package/src/run/run-row-shell.tsx +4 -20
  34. package/src/run/status-meta.tsx +0 -3
  35. package/src/utils/format.ts +0 -13
  36. package/dist/run/run-item-primitives.d.ts +0 -3
  37. package/dist/run/run-item-primitives.js +0 -12
  38. package/src/primitives/switch.test.tsx +0 -39
  39. package/src/primitives/toolbar.test.tsx +0 -103
  40. 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-foreground px-4 py-3 text-background"><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-foreground px-4 py-3 text-background"><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-foreground px-4 py-3 text-background"><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>"`)
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-foreground px-4 py-3 text-background"><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>"`)
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-foreground")
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-foreground")
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-foreground")
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-foreground px-4 py-3 text-background ${TRANSCRIPT_TEXT}`}>
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-foreground.text-background")).toHaveLength(1);
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-foreground")).toBeNull();
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-foreground px-4 py-3 text-background">
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 };
@@ -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",
@@ -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)];
@@ -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,3 +0,0 @@
1
- export declare function LiveDuration({ startTime }: {
2
- startTime: number;
3
- }): import("react").JSX.Element;
@@ -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
- });