@tangle-network/ui 11.33.0 → 11.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/editor/document-editor-pane.d.ts +7 -1
- package/dist/editor/document-editor-pane.js +27 -12
- package/dist/editor/markdown-document-editor.js +30 -12
- package/dist/editor/source-markdown-editor.js +256 -0
- package/dist/editor/source-markdown.js +495 -0
- package/dist/files/file-artifact-pane.d.ts +2 -0
- package/dist/files/file-artifact-pane.js +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/markdown/markdown-table.js +1 -1
- package/dist/primitives/segmented-control.d.ts +6 -3
- package/dist/primitives/segmented-control.js +105 -56
- package/dist/run/inline-thinking-item.d.ts +7 -1
- package/dist/run/inline-thinking-item.js +6 -5
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.js +9 -1
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +2 -2
- package/package.json +2 -2
- package/src/editor/document-editor-pane.tsx +54 -16
- package/src/editor/markdown-document-editor.tsx +33 -18
- package/src/editor/source-markdown-editor.test.tsx +156 -0
- package/src/editor/source-markdown-editor.ts +296 -0
- package/src/editor/source-markdown.fixtures.ts +90 -0
- package/src/editor/source-markdown.test.ts +235 -0
- package/src/editor/source-markdown.ts +627 -0
- package/src/files/file-artifact-pane.test.tsx +34 -1
- package/src/files/file-artifact-pane.tsx +3 -0
- package/src/markdown/markdown-table.test.tsx +9 -0
- package/src/markdown/markdown-table.tsx +5 -1
- package/src/primitives/segmented-control.stories.tsx +89 -0
- package/src/primitives/segmented-control.test.tsx +20 -7
- package/src/primitives/segmented-control.tsx +90 -29
- package/src/run/inline-thinking-item.test.tsx +29 -0
- package/src/run/inline-thinking-item.tsx +11 -4
- package/src/utils/format.test.ts +16 -1
- package/src/utils/format.ts +17 -0
- package/src/utils/index.ts +1 -1
|
@@ -45,6 +45,15 @@ describe("Markdown tables", () => {
|
|
|
45
45
|
expect(document.body.innerHTML).not.toContain("text-xs");
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it("contains its screen-reader status line, so a table deep in a scrolling panel cannot stretch the page", () => {
|
|
49
|
+
render(<Markdown>{TABLE}</Markdown>);
|
|
50
|
+
const status = screen.getByRole("status");
|
|
51
|
+
expect(status.className).toContain("sr-only");
|
|
52
|
+
// The status is absolutely positioned; the card it sits in must be its containing block.
|
|
53
|
+
const card = status.closest(".not-prose") as HTMLElement;
|
|
54
|
+
expect(card.className.split(" ")).toEqual(expect.arrayContaining(["relative", "overflow-hidden"]));
|
|
55
|
+
});
|
|
56
|
+
|
|
48
57
|
it("copies the table as CSV with quoting and formula-safe cells", async () => {
|
|
49
58
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
50
59
|
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
|
|
@@ -138,7 +138,11 @@ export function MarkdownTable({ node, className, children, ...props }: Component
|
|
|
138
138
|
return { csv: tableCsv([...header, ...body]), kinds: columnKinds(body), rowCount: body.length };
|
|
139
139
|
}, [node]);
|
|
140
140
|
return (
|
|
141
|
-
|
|
141
|
+
// `relative` makes the card the containing block of the copy control's
|
|
142
|
+
// screen-reader status line. Without it that absolutely positioned line
|
|
143
|
+
// resolves against the nearest positioned ancestor, often the page, and a
|
|
144
|
+
// table far down a scrolling panel stretches the page past the viewport.
|
|
145
|
+
<div className="not-prose relative my-4 overflow-hidden rounded-lg border border-border bg-card">
|
|
142
146
|
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-1">
|
|
143
147
|
<span className="text-sm text-muted-foreground">{rowCount === 1 ? "1 row" : `${rowCount} rows`}</span>
|
|
144
148
|
<CopyCsvButton csv={csv} />
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
import { SegmentedControl, type SegmentedControlOption } from "./segmented-control"
|
|
4
|
+
|
|
5
|
+
const meta: Meta = {
|
|
6
|
+
title: "Primitives/SegmentedControl",
|
|
7
|
+
parameters: { layout: "fullscreen" },
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default meta
|
|
11
|
+
type Story = StoryObj
|
|
12
|
+
|
|
13
|
+
const workTabs: SegmentedControlOption[] = [
|
|
14
|
+
{ value: "today", label: "Today" },
|
|
15
|
+
{ value: "messages", label: "Messages" },
|
|
16
|
+
{ value: "tasks", label: "Tasks" },
|
|
17
|
+
{ value: "operations", label: "Operations" },
|
|
18
|
+
{ value: "payments", label: "Payments" },
|
|
19
|
+
{ value: "approvals", label: "Approvals" },
|
|
20
|
+
{ value: "activity", label: "Activity" },
|
|
21
|
+
{ value: "people", label: "People" },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const fileSources: SegmentedControlOption[] = [
|
|
25
|
+
{ value: "knowledge", label: "Knowledge" },
|
|
26
|
+
{ value: "attachments", label: "Attachments" },
|
|
27
|
+
{ value: "apps", label: "Apps & chats" },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
function Controlled({
|
|
31
|
+
initial,
|
|
32
|
+
render,
|
|
33
|
+
}: {
|
|
34
|
+
initial: string
|
|
35
|
+
render: (value: string, setValue: (value: string) => void) => React.ReactNode
|
|
36
|
+
}) {
|
|
37
|
+
const [value, setValue] = React.useState(initial)
|
|
38
|
+
return <>{render(value, setValue)}</>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Eight section tabs on a 390 px phone, as a workspace header renders them:
|
|
43
|
+
* full width with the page's 16 px inline padding on the strip itself. Most
|
|
44
|
+
* tabs sit past the right edge, the selected one starts off screen, and the
|
|
45
|
+
* cut lands in the gap between two tabs, so only a chevron shows that the strip
|
|
46
|
+
* scrolls.
|
|
47
|
+
*/
|
|
48
|
+
export const OverflowingTabs: Story = {
|
|
49
|
+
render: () => (
|
|
50
|
+
<div className="w-full max-w-[390px] py-4">
|
|
51
|
+
<Controlled
|
|
52
|
+
initial="approvals"
|
|
53
|
+
render={(value, setValue) => (
|
|
54
|
+
<SegmentedControl
|
|
55
|
+
data-testid="overflow-tabs"
|
|
56
|
+
aria-label="Work"
|
|
57
|
+
variant="tabs"
|
|
58
|
+
value={value}
|
|
59
|
+
onValueChange={setValue}
|
|
60
|
+
options={workTabs}
|
|
61
|
+
className="w-full px-4"
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A row control a host made scroll instead of wrap, beside a page title. */
|
|
70
|
+
export const OverflowingRow: Story = {
|
|
71
|
+
render: () => (
|
|
72
|
+
<div className="flex w-full max-w-[390px] items-center gap-3 px-4 py-4">
|
|
73
|
+
<h2 className="shrink-0 text-lg font-semibold">Files</h2>
|
|
74
|
+
<Controlled
|
|
75
|
+
initial="knowledge"
|
|
76
|
+
render={(value, setValue) => (
|
|
77
|
+
<SegmentedControl
|
|
78
|
+
data-testid="overflow-row"
|
|
79
|
+
aria-label="File source"
|
|
80
|
+
value={value}
|
|
81
|
+
onValueChange={setValue}
|
|
82
|
+
options={fileSources}
|
|
83
|
+
className="min-w-0 flex-nowrap overflow-x-auto"
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
),
|
|
89
|
+
}
|
|
@@ -361,13 +361,14 @@ describe("SegmentedControl tabs on a narrow strip", () => {
|
|
|
361
361
|
Object.defineProperty(proto, key, { configurable: true, ...descriptor })
|
|
362
362
|
}
|
|
363
363
|
const isStrip = (el: Element) => el.getAttribute("role") === "radiogroup"
|
|
364
|
-
|
|
364
|
+
// Options sit side by side; the edge chevrons take no room in the row.
|
|
365
|
+
const index = (el: Element) => Array.from(el.parentElement?.querySelectorAll(':scope > [role="radio"]') ?? []).indexOf(el)
|
|
365
366
|
|
|
366
367
|
beforeEach(() => {
|
|
367
368
|
define(HTMLElement.prototype, "offsetLeft", { get(this: HTMLElement) { return isStrip(this) ? 0 : index(this) * TAB } })
|
|
368
369
|
define(HTMLElement.prototype, "offsetWidth", { get(this: HTMLElement) { return isStrip(this) ? STRIP : TAB } })
|
|
369
370
|
define(Element.prototype, "clientWidth", { get(this: Element) { return isStrip(this) ? STRIP : TAB } })
|
|
370
|
-
define(Element.prototype, "scrollWidth", { get(this: Element) { return isStrip(this) ? this.
|
|
371
|
+
define(Element.prototype, "scrollWidth", { get(this: Element) { return isStrip(this) ? this.querySelectorAll(':scope > [role="radio"]').length * TAB : TAB } })
|
|
371
372
|
define(Element.prototype, "scrollLeft", {
|
|
372
373
|
get(this: Element) { return scroll.get(this) ?? 0 },
|
|
373
374
|
set(this: Element, v: number) { scroll.set(this, v) },
|
|
@@ -390,32 +391,44 @@ describe("SegmentedControl tabs on a narrow strip", () => {
|
|
|
390
391
|
<SegmentedControl aria-label="Work" variant="tabs" value="payments" onValueChange={vi.fn()} options={work} />,
|
|
391
392
|
)
|
|
392
393
|
const strip = screen.getByRole("radiogroup")
|
|
393
|
-
// Payments spans 400-500px: its right edge
|
|
394
|
-
expect(strip.scrollLeft).toBe(500 - STRIP +
|
|
394
|
+
// Payments spans 400-500px: its right edge clears the 32px chevron, plus a 4px gap, in the 250px view.
|
|
395
|
+
expect(strip.scrollLeft).toBe(500 - STRIP + 32 + 4)
|
|
395
396
|
|
|
396
397
|
rerender(<SegmentedControl aria-label="Work" variant="tabs" value="today" onValueChange={vi.fn()} options={work} />)
|
|
397
398
|
expect(strip.scrollLeft).toBe(0)
|
|
398
399
|
})
|
|
399
400
|
|
|
400
|
-
it("
|
|
401
|
+
it("shows a chevron at the edge that has more tabs past it", () => {
|
|
401
402
|
const { rerender } = render(
|
|
402
403
|
<SegmentedControl aria-label="Work" variant="tabs" value="today" onValueChange={vi.fn()} options={work} />,
|
|
403
404
|
)
|
|
404
405
|
const strip = screen.getByRole("radiogroup")
|
|
405
406
|
expect(strip).toHaveAttribute("data-overflow-end", "true")
|
|
406
407
|
expect(strip).not.toHaveAttribute("data-overflow-start")
|
|
407
|
-
expect(strip.
|
|
408
|
+
expect(strip.querySelector('[data-scroll-cue="end"]')).not.toBeNull()
|
|
409
|
+
expect(strip.querySelector('[data-scroll-cue="start"]')).toBeNull()
|
|
408
410
|
|
|
409
411
|
rerender(<SegmentedControl aria-label="Work" variant="tabs" value="people" onValueChange={vi.fn()} options={work} />)
|
|
410
412
|
expect(strip).toHaveAttribute("data-overflow-start", "true")
|
|
411
413
|
expect(strip).not.toHaveAttribute("data-overflow-end")
|
|
414
|
+
expect(strip.querySelector('[data-scroll-cue="start"]')).not.toBeNull()
|
|
415
|
+
expect(strip.querySelector('[data-scroll-cue="end"]')).toBeNull()
|
|
412
416
|
})
|
|
413
417
|
|
|
414
|
-
it("
|
|
418
|
+
it("keeps the chevrons out of the radio group and the tab order", () => {
|
|
419
|
+
render(<SegmentedControl aria-label="Work" variant="tabs" value="today" onValueChange={vi.fn()} options={work} />)
|
|
420
|
+
expect(screen.getAllByRole("radio")).toHaveLength(work.length)
|
|
421
|
+
const cue = screen.getByRole("radiogroup").querySelector('[data-scroll-cue="end"]')
|
|
422
|
+
expect(cue).toHaveAttribute("aria-hidden", "true")
|
|
423
|
+
expect(cue).toHaveAttribute("tabindex", "-1")
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it("leaves a strip that fits without chevrons", () => {
|
|
415
427
|
render(<SegmentedControl aria-label="Work" variant="tabs" value="all" onValueChange={vi.fn()} options={options.slice(0, 2)} />)
|
|
416
428
|
const strip = screen.getByRole("radiogroup")
|
|
417
429
|
expect(strip).not.toHaveAttribute("data-overflow-start")
|
|
418
430
|
expect(strip).not.toHaveAttribute("data-overflow-end")
|
|
431
|
+
expect(strip.querySelector("[data-scroll-cue]")).toBeNull()
|
|
419
432
|
})
|
|
420
433
|
})
|
|
421
434
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
declare const process: { env: { NODE_ENV?: string } } | undefined
|
|
4
4
|
|
|
5
|
+
import { ChevronLeft, ChevronRight } from "lucide-react"
|
|
5
6
|
import * as React from "react"
|
|
6
7
|
import { touchTarget } from "../lib/control-presentation"
|
|
7
8
|
import { focusRing, focusRingInset } from "../lib/focus"
|
|
@@ -32,9 +33,12 @@ import { cn } from "../lib/utils"
|
|
|
32
33
|
* - Every label keeps one weight. Selection is colour plus surface or
|
|
33
34
|
* underline, so changing the value never reflows the row.
|
|
34
35
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* past it
|
|
36
|
+
* When the options outgrow the row they scroll sideways (the tabs variant
|
|
37
|
+
* always can; the row variant can when a host gives it `flex-nowrap
|
|
38
|
+
* overflow-x-auto`). A chevron button pinned to each edge with options past it
|
|
39
|
+
* shows that the strip scrolls and pages it on click or tap; touch and
|
|
40
|
+
* trackpad scroll it natively, and arrow keys move the selection, which is
|
|
41
|
+
* kept fully in view clear of the chevrons.
|
|
38
42
|
*/
|
|
39
43
|
export interface SegmentedControlOption<T extends string = string> {
|
|
40
44
|
value: T
|
|
@@ -61,14 +65,47 @@ export interface SegmentedControlProps<T extends string = string>
|
|
|
61
65
|
"data-testid"?: string
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
/** Size of the edge chevron, in px. The selected option is kept this far
|
|
69
|
+
* (plus the strip's own padding) from an edge so the chevron never covers it. */
|
|
70
|
+
const SCROLL_CUE_SIZE = 32
|
|
71
|
+
/** The strip's flex gap (`gap-1`). A cue's negative margin cancels its width
|
|
72
|
+
* and this gap, so showing it never shifts the options: a shift is not a
|
|
73
|
+
* resize, so nothing would re-reveal the selected option afterwards. */
|
|
74
|
+
const STRIP_GAP = 4
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A chevron pinned to one edge of the scrolling strip. It is a sticky flex
|
|
78
|
+
* item with a negative margin equal to its width plus the gap, so it takes no room in the
|
|
79
|
+
* row (showing it never reflows the options) and stays at the visible edge
|
|
80
|
+
* while the strip scrolls. A solid surface keeps it legible on any plane,
|
|
81
|
+
* which an edge fade is not: a fade over the gap between two options, or over
|
|
82
|
+
* a host's inline padding, shows nothing.
|
|
83
|
+
*/
|
|
84
|
+
function ScrollCue({ side, onPage }: { side: "start" | "end"; onPage: () => void }) {
|
|
85
|
+
const Icon = side === "start" ? ChevronLeft : ChevronRight
|
|
86
|
+
return (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
// Pointer and touch affordance only: keyboard users move the selection
|
|
90
|
+
// with the arrow keys, and the strip keeps it in view.
|
|
91
|
+
tabIndex={-1}
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
data-scroll-cue={side}
|
|
94
|
+
onClick={onPage}
|
|
95
|
+
className={cn(
|
|
96
|
+
"sticky z-[1] flex shrink-0 items-center justify-center self-center rounded-full border border-border bg-card text-muted-foreground shadow-sm transition-colors hover:text-foreground",
|
|
97
|
+
side === "start" ? "left-0" : "right-0",
|
|
98
|
+
)}
|
|
99
|
+
style={{
|
|
100
|
+
width: SCROLL_CUE_SIZE,
|
|
101
|
+
height: SCROLL_CUE_SIZE,
|
|
102
|
+
[side === "start" ? "marginRight" : "marginLeft"]: -(SCROLL_CUE_SIZE + STRIP_GAP),
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
106
|
+
</button>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
72
109
|
|
|
73
110
|
export function SegmentedControl<T extends string = string>({
|
|
74
111
|
value,
|
|
@@ -92,7 +129,9 @@ export function SegmentedControl<T extends string = string>({
|
|
|
92
129
|
const optionRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map())
|
|
93
130
|
const stripRef = React.useRef<HTMLDivElement>(null)
|
|
94
131
|
const [overflow, setOverflow] = React.useState({ start: false, end: false })
|
|
95
|
-
|
|
132
|
+
// Re-observe when the option set changes, not on every render of a host
|
|
133
|
+
// that builds its options array inline.
|
|
134
|
+
const optionKey = options.map((o) => o.value).join("\u0000")
|
|
96
135
|
|
|
97
136
|
const measureOverflow = React.useCallback(() => {
|
|
98
137
|
const strip = stripRef.current
|
|
@@ -102,32 +141,52 @@ export function SegmentedControl<T extends string = string>({
|
|
|
102
141
|
setOverflow((prev) => (prev.start === start && prev.end === end ? prev : { start, end }))
|
|
103
142
|
}, [])
|
|
104
143
|
|
|
105
|
-
// Keep the selected
|
|
106
|
-
// never moves, which scrollIntoView cannot
|
|
107
|
-
|
|
108
|
-
|
|
144
|
+
// Keep the selected option inside the strip and clear of the chevrons.
|
|
145
|
+
// Only the strip scrolls: the page never moves, which scrollIntoView cannot
|
|
146
|
+
// promise. A strip that does not overflow ignores the assignment.
|
|
147
|
+
const valueRef = React.useRef(value)
|
|
148
|
+
valueRef.current = value
|
|
149
|
+
const revealSelected = React.useCallback(() => {
|
|
109
150
|
const strip = stripRef.current
|
|
110
|
-
const option = optionRefs.current.get(
|
|
111
|
-
if (strip && option) {
|
|
151
|
+
const option = optionRefs.current.get(valueRef.current)
|
|
152
|
+
if (strip && option && strip.scrollWidth > strip.clientWidth) {
|
|
153
|
+
const style = getComputedStyle(strip)
|
|
154
|
+
// offsetLeft/offsetWidth round to whole pixels while the tab is fractional (a 101.42 px tab overlapped the
|
|
155
|
+
// chevron by 0.66 px at 390 px), so keep a small gap beside each cue rather than touching its edge.
|
|
156
|
+
const gap = 4
|
|
157
|
+
const reserveStart = SCROLL_CUE_SIZE + gap + (Number.parseFloat(style.paddingLeft) || 0)
|
|
158
|
+
const reserveEnd = SCROLL_CUE_SIZE + gap + (Number.parseFloat(style.paddingRight) || 0)
|
|
112
159
|
const start = option.offsetLeft
|
|
113
160
|
const end = start + option.offsetWidth
|
|
114
|
-
if (start < strip.scrollLeft
|
|
115
|
-
strip.scrollLeft = Math.max(0, start -
|
|
116
|
-
} else if (end > strip.scrollLeft + strip.clientWidth
|
|
117
|
-
strip.scrollLeft = end - strip.clientWidth
|
|
161
|
+
if (start - reserveStart < strip.scrollLeft) {
|
|
162
|
+
strip.scrollLeft = Math.max(0, start - reserveStart)
|
|
163
|
+
} else if (end + reserveEnd > strip.scrollLeft + strip.clientWidth) {
|
|
164
|
+
strip.scrollLeft = end + reserveEnd - strip.clientWidth
|
|
118
165
|
}
|
|
119
166
|
}
|
|
120
167
|
measureOverflow()
|
|
121
|
-
}, [
|
|
168
|
+
}, [measureOverflow])
|
|
122
169
|
|
|
170
|
+
React.useLayoutEffect(revealSelected, [value, options.length, revealSelected])
|
|
171
|
+
|
|
172
|
+
// The strip's own size and its options' sizes (late web fonts, a changed
|
|
173
|
+
// label or adornment) move both the selected option and the overflow edges.
|
|
123
174
|
React.useEffect(() => {
|
|
124
|
-
if (
|
|
175
|
+
if (typeof ResizeObserver === "undefined") return
|
|
125
176
|
const strip = stripRef.current
|
|
126
177
|
if (!strip) return
|
|
127
|
-
const observer = new ResizeObserver(
|
|
178
|
+
const observer = new ResizeObserver(revealSelected)
|
|
128
179
|
observer.observe(strip)
|
|
180
|
+
for (const option of optionRefs.current.values()) observer.observe(option)
|
|
129
181
|
return () => observer.disconnect()
|
|
130
|
-
}, [
|
|
182
|
+
}, [optionKey, revealSelected])
|
|
183
|
+
|
|
184
|
+
const page = (direction: 1 | -1) => {
|
|
185
|
+
const strip = stripRef.current
|
|
186
|
+
if (!strip) return
|
|
187
|
+
const reduce = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
188
|
+
strip.scrollBy({ left: direction * strip.clientWidth * 0.75, behavior: reduce ? "auto" : "smooth" })
|
|
189
|
+
}
|
|
131
190
|
|
|
132
191
|
const hasMatch = options.some((o) => o.value === value)
|
|
133
192
|
|
|
@@ -161,16 +220,17 @@ export function SegmentedControl<T extends string = string>({
|
|
|
161
220
|
data-overflow-end={overflow.end || undefined}
|
|
162
221
|
ref={stripRef}
|
|
163
222
|
onKeyDown={handleKeyDown}
|
|
164
|
-
onScroll={
|
|
223
|
+
onScroll={measureOverflow}
|
|
165
224
|
className={cn(
|
|
166
|
-
"flex gap-1",
|
|
225
|
+
"relative flex gap-1",
|
|
167
226
|
variant === "row" &&
|
|
168
227
|
"flex-wrap items-center rounded-lg border border-[var(--field-border)] bg-[var(--well)] p-1",
|
|
169
228
|
variant === "tabs" &&
|
|
170
|
-
|
|
229
|
+
"flex-nowrap items-end border-b border-border pb-0 overflow-x-auto",
|
|
171
230
|
className,
|
|
172
231
|
)}
|
|
173
232
|
>
|
|
233
|
+
{overflow.start && <ScrollCue side="start" onPage={() => page(-1)} />}
|
|
174
234
|
{options.map((option, i) => {
|
|
175
235
|
const active = option.value === value
|
|
176
236
|
const focusable = active || (!hasMatch && i === 0)
|
|
@@ -214,6 +274,7 @@ export function SegmentedControl<T extends string = string>({
|
|
|
214
274
|
</button>
|
|
215
275
|
)
|
|
216
276
|
})}
|
|
277
|
+
{overflow.end && <ScrollCue side="end" onPage={() => page(1)} />}
|
|
217
278
|
</div>
|
|
218
279
|
)
|
|
219
280
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { ReasoningPart } from "../types/parts";
|
|
4
|
+
import { InlineThinkingItem } from "./inline-thinking-item";
|
|
5
|
+
|
|
6
|
+
const TEXT = "**Ensuring workspace configuration** I need to be cautious because the saved file is not verifiable.";
|
|
7
|
+
|
|
8
|
+
function part(active: boolean): ReasoningPart {
|
|
9
|
+
return { type: "reasoning", text: TEXT, time: active ? { start: Date.now() } : { start: 0, end: 4_000 } };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("InlineThinkingItem", () => {
|
|
13
|
+
it("previews the reasoning as plain text, never raw markdown marks", () => {
|
|
14
|
+
const { container } = render(<InlineThinkingItem part={part(false)} />);
|
|
15
|
+
expect(container.textContent).toContain("Ensuring workspace configuration I need to be cautious");
|
|
16
|
+
expect(container.textContent).not.toContain("**");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("opens while the model thinks, by default", () => {
|
|
20
|
+
render(<InlineThinkingItem part={part(true)} />);
|
|
21
|
+
expect(screen.getByRole("button", { expanded: true })).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("stays closed while the model thinks when the product keeps deliberation out of view", () => {
|
|
25
|
+
render(<InlineThinkingItem part={part(true)} openWhileActive={false} />);
|
|
26
|
+
expect(screen.getByRole("button", { expanded: false })).toBeInTheDocument();
|
|
27
|
+
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { memo, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { Brain } from "lucide-react";
|
|
3
|
-
import { truncateText } from "../utils/format";
|
|
3
|
+
import { plainTextFromMarkdown, truncateText } from "../utils/format";
|
|
4
4
|
import type { ReasoningPart } from "../types/parts";
|
|
5
5
|
import { Markdown } from "../markdown/markdown";
|
|
6
6
|
import { TextShimmer } from "../primitives/text-shimmer";
|
|
@@ -11,6 +11,12 @@ export interface InlineThinkingItemProps {
|
|
|
11
11
|
part: ReasoningPart;
|
|
12
12
|
defaultOpen?: boolean;
|
|
13
13
|
autoCollapse?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Open the row while the model is still thinking. A product that keeps
|
|
16
|
+
* deliberation out of the reader's way passes false: the row stays as
|
|
17
|
+
* `defaultOpen` left it, with its one-line preview, until the reader opens it.
|
|
18
|
+
*/
|
|
19
|
+
openWhileActive?: boolean;
|
|
14
20
|
className?: string;
|
|
15
21
|
contentClassName?: string;
|
|
16
22
|
}
|
|
@@ -20,6 +26,7 @@ export const InlineThinkingItem = memo(
|
|
|
20
26
|
part,
|
|
21
27
|
defaultOpen = false,
|
|
22
28
|
autoCollapse = true,
|
|
29
|
+
openWhileActive = true,
|
|
23
30
|
className,
|
|
24
31
|
contentClassName,
|
|
25
32
|
}: InlineThinkingItemProps) => {
|
|
@@ -31,12 +38,12 @@ export const InlineThinkingItem = memo(
|
|
|
31
38
|
const durationMs =
|
|
32
39
|
startTime != null && endTime != null ? endTime - startTime : undefined;
|
|
33
40
|
const isActive = startTime != null && endTime == null;
|
|
34
|
-
const preview = part.text ? truncateText(part.text, 120) : undefined;
|
|
41
|
+
const preview = part.text ? truncateText(plainTextFromMarkdown(part.text), 120) : undefined;
|
|
35
42
|
|
|
36
43
|
useEffect(() => {
|
|
37
44
|
if (isActive) {
|
|
38
45
|
autoCollapsedRef.current = false;
|
|
39
|
-
setOpen(true);
|
|
46
|
+
if (openWhileActive) setOpen(true);
|
|
40
47
|
return;
|
|
41
48
|
}
|
|
42
49
|
|
|
@@ -48,7 +55,7 @@ export const InlineThinkingItem = memo(
|
|
|
48
55
|
|
|
49
56
|
return () => window.clearTimeout(timer);
|
|
50
57
|
}
|
|
51
|
-
}, [autoCollapse, durationMs, isActive]);
|
|
58
|
+
}, [autoCollapse, durationMs, isActive, openWhileActive]);
|
|
52
59
|
|
|
53
60
|
return (
|
|
54
61
|
<RunRowShell
|
package/src/utils/format.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { formatBytes, formatUptime } from "./format";
|
|
2
|
+
import { formatBytes, formatUptime, plainTextFromMarkdown } from "./format";
|
|
3
3
|
|
|
4
4
|
describe("formatUptime", () => {
|
|
5
5
|
it("renders seconds for short durations", () => {
|
|
@@ -41,3 +41,18 @@ describe("formatBytes", () => {
|
|
|
41
41
|
expect(formatBytes(-1)).toBe("—");
|
|
42
42
|
});
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
describe("plainTextFromMarkdown", () => {
|
|
46
|
+
it("drops emphasis, headings, list markers, code marks and link syntax", () => {
|
|
47
|
+
expect(plainTextFromMarkdown("**Ensuring workspace configuration** I need to check")).toBe("Ensuring workspace configuration I need to check");
|
|
48
|
+
expect(plainTextFromMarkdown("## Plan\n- item one\n- *soft* point")).toBe("Plan\nitem one\nsoft point");
|
|
49
|
+
expect(plainTextFromMarkdown("see [docs](https://example.com) and _this_")).toBe("see docs and this");
|
|
50
|
+
expect(plainTextFromMarkdown("**bold** and __also__ ~~gone~~")).toBe("bold and also gone");
|
|
51
|
+
expect(plainTextFromMarkdown("1. First\n2) Second")).toBe("First\nSecond");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps characters that are not markdown marks", () => {
|
|
55
|
+
expect(plainTextFromMarkdown("read `src/a_b.ts` and snake_case_name")).toBe("read src/a_b.ts and snake_case_name");
|
|
56
|
+
expect(plainTextFromMarkdown("2 * 3 * 4 = 24")).toBe("2 * 3 * 4 = 24");
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/utils/format.ts
CHANGED
|
@@ -8,6 +8,23 @@ export function formatDuration(ms: number): string {
|
|
|
8
8
|
return remaining > 0 ? `${minutes}m ${remaining}s` : `${minutes}m`;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Markdown read as plain text, for a one-line preview: code fences, emphasis,
|
|
13
|
+
* headings, list and quote markers, and link syntax are removed, so a preview
|
|
14
|
+
* never shows `**` or `#`. Underscores inside words (`snake_case`) are kept.
|
|
15
|
+
*/
|
|
16
|
+
export function plainTextFromMarkdown(text: string): string {
|
|
17
|
+
return text
|
|
18
|
+
.replace(/```[^\n]*\n?([\s\S]*?)```/g, "$1")
|
|
19
|
+
.replace(/`([^`]*)`/g, "$1")
|
|
20
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
21
|
+
.replace(/^[ \t]{0,3}(?:#{1,6}[ \t]+|>[ \t]?|[-*+][ \t]+|\d+[.)][ \t]+)/gm, "")
|
|
22
|
+
.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2")
|
|
23
|
+
.replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, "$1$2")
|
|
24
|
+
.replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, "$1$2")
|
|
25
|
+
.replace(/~~(?=\S)([\s\S]*?\S)~~/g, "$1");
|
|
26
|
+
}
|
|
27
|
+
|
|
11
28
|
/** Truncate text to `max` characters, appending "..." if truncated. */
|
|
12
29
|
export function truncateText(text: string, max: number): string {
|
|
13
30
|
const cleaned = text.replace(/\s+/g, " ").trim();
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { copyText } from './copy-text';
|
|
2
|
-
export { formatBytes, formatDuration, formatUptime, truncateText } from './format';
|
|
2
|
+
export { formatBytes, formatDuration, formatUptime, plainTextFromMarkdown, truncateText } from './format';
|
|
3
3
|
export { timeAgo } from './time-ago';
|
|
4
4
|
export {
|
|
5
5
|
getToolCategory,
|