@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/editor/document-editor-pane.d.ts +7 -1
  3. package/dist/editor/document-editor-pane.js +27 -12
  4. package/dist/editor/markdown-document-editor.js +30 -12
  5. package/dist/editor/source-markdown-editor.js +256 -0
  6. package/dist/editor/source-markdown.js +495 -0
  7. package/dist/files/file-artifact-pane.d.ts +2 -0
  8. package/dist/files/file-artifact-pane.js +1 -0
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +2 -2
  11. package/dist/markdown/markdown-table.js +1 -1
  12. package/dist/primitives/segmented-control.d.ts +6 -3
  13. package/dist/primitives/segmented-control.js +105 -56
  14. package/dist/run/inline-thinking-item.d.ts +7 -1
  15. package/dist/run/inline-thinking-item.js +6 -5
  16. package/dist/utils/format.d.ts +6 -0
  17. package/dist/utils/format.js +9 -1
  18. package/dist/utils.d.ts +2 -2
  19. package/dist/utils.js +2 -2
  20. package/package.json +2 -2
  21. package/src/editor/document-editor-pane.tsx +54 -16
  22. package/src/editor/markdown-document-editor.tsx +33 -18
  23. package/src/editor/source-markdown-editor.test.tsx +156 -0
  24. package/src/editor/source-markdown-editor.ts +296 -0
  25. package/src/editor/source-markdown.fixtures.ts +90 -0
  26. package/src/editor/source-markdown.test.ts +235 -0
  27. package/src/editor/source-markdown.ts +627 -0
  28. package/src/files/file-artifact-pane.test.tsx +34 -1
  29. package/src/files/file-artifact-pane.tsx +3 -0
  30. package/src/markdown/markdown-table.test.tsx +9 -0
  31. package/src/markdown/markdown-table.tsx +5 -1
  32. package/src/primitives/segmented-control.stories.tsx +89 -0
  33. package/src/primitives/segmented-control.test.tsx +20 -7
  34. package/src/primitives/segmented-control.tsx +90 -29
  35. package/src/run/inline-thinking-item.test.tsx +29 -0
  36. package/src/run/inline-thinking-item.tsx +11 -4
  37. package/src/utils/format.test.ts +16 -1
  38. package/src/utils/format.ts +17 -0
  39. 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
- <div className="not-prose my-4 overflow-hidden rounded-lg border border-border bg-card">
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
- const index = (el: Element) => Array.from(el.parentElement?.children ?? []).indexOf(el)
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.children.length * TAB : TAB } })
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 plus the 24px fade fits the 250px view.
394
- expect(strip.scrollLeft).toBe(500 - STRIP + 24)
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("fades the edge that has more tabs past 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.className).toContain("data-[overflow-end]:[mask-image:linear-gradient(to_right,#000_calc(100%-24px),transparent)]")
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("leaves a strip that fits unmasked", () => {
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
- * The tabs variant scrolls sideways when its options outgrow the row: the
36
- * selected tab is kept in view, and a faded edge shows that more tabs sit
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
- /** Width of the faded edge that marks more tabs past the strip, in px. */
65
- const TAB_EDGE_FADE = 24
66
- // Fade whichever edge has tabs past it; the two-attribute rule wins on specificity.
67
- const tabEdgeFade = [
68
- "data-[overflow-start]:[mask-image:linear-gradient(to_right,transparent,#000_24px)]",
69
- "data-[overflow-end]:[mask-image:linear-gradient(to_right,#000_calc(100%-24px),transparent)]",
70
- "data-[overflow-start]:data-[overflow-end]:[mask-image:linear-gradient(to_right,transparent,#000_24px,#000_calc(100%-24px),transparent)]",
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
- const tabs = variant === "tabs"
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 tab inside the strip. Only the strip scrolls: the page
106
- // never moves, which scrollIntoView cannot promise.
107
- React.useLayoutEffect(() => {
108
- if (!tabs) return
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(value)
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 + TAB_EDGE_FADE) {
115
- strip.scrollLeft = Math.max(0, start - TAB_EDGE_FADE)
116
- } else if (end > strip.scrollLeft + strip.clientWidth - TAB_EDGE_FADE) {
117
- strip.scrollLeft = end - strip.clientWidth + TAB_EDGE_FADE
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
- }, [tabs, value, options.length, measureOverflow])
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 (!tabs || typeof ResizeObserver === "undefined") return
175
+ if (typeof ResizeObserver === "undefined") return
125
176
  const strip = stripRef.current
126
177
  if (!strip) return
127
- const observer = new ResizeObserver(measureOverflow)
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
- }, [tabs, measureOverflow])
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={tabs ? measureOverflow : undefined}
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
- ["relative flex-nowrap items-end border-b border-border pb-0 overflow-x-auto", tabEdgeFade],
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
@@ -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
+ });
@@ -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();
@@ -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,