@tangle-network/ui 11.51.0 → 11.51.2

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @tangle-network/ui
2
2
 
3
+ ## 11.51.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d8990b5: Re-export canonical server-safe speaker grouping from AgentInterface; preserve every UI chat name. Record the adopted GTM heading reduction in the owning drift baseline.
8
+
9
+ ## 11.51.1
10
+
11
+ ### Patch Changes
12
+
13
+ - dfdfc77: Keep streamed blockquotes stable under host typography styles by suppressing generated quotation marks. Explicit quotation marks in Markdown content remain visible.
14
+
3
15
  ## 11.51.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -26,6 +26,10 @@ export function App() {
26
26
  }
27
27
  ```
28
28
 
29
+ `partitionBySpeaker` and the speaker types in `/chat` re-export the canonical
30
+ `@tangle-network/agent-interface/speaker` contract. Server consumers import that
31
+ entry directly; `SpeakerHeader` remains the UI renderer.
32
+
29
33
  ## Styling
30
34
 
31
35
  This package ships no CSS. Its components are Tailwind v4 class names in source, and the build emits JavaScript only, so two pieces of consumer setup carry real weight.
@@ -1,45 +1,5 @@
1
1
  import { HTMLAttributes } from "react";
2
- /** Where one responder is in its answer. */
3
- export type SpeakerState = "answering" | "done" | "failed";
4
- /**
5
- * A responder of a turn addressed to several agents. In the turn's parts it is
6
- * a `{ type: "speaker", id, name, handle?, state? }` part written before the
7
- * responder's answer.
8
- */
9
- export interface Speaker {
10
- /** The agent's stable id. It keys the avatar's tone and a live part's `speaker` tag. */
11
- id: string;
12
- name: string;
13
- /** What a person types after `@` to address the agent. */
14
- handle?: string;
15
- /** Set once the responder ended; absent while it answers and on rows written before responders reported an end. */
16
- state?: SpeakerState;
17
- }
18
- export type SpeakerPart = Speaker & {
19
- type: "speaker";
20
- };
21
- /** One responder's share of a turn: its speaker part and the parts it wrote. */
22
- export interface SpeakerSection<T> {
23
- /** The latest speaker part for this responder; null for the parts no speaker owns. */
24
- speaker: (T & SpeakerPart) | null;
25
- parts: T[];
26
- }
27
- /**
28
- * Splits one assistant turn's parts by the agent that wrote them.
29
- *
30
- * A stored row holds a speaker part before each responder's answer, so a part
31
- * belongs to the speaker part before it. A live stream announces every
32
- * responder first and then interleaves their output, so a part names its owner
33
- * in `speaker` (the responder's id), which wins over position. A later speaker
34
- * part with an id already seen replaces that responder's speaker part, which is
35
- * how its end state arrives, and never opens a second section.
36
- *
37
- * Sections keep the order speakers were first announced. Parts no speaker owns
38
- * come first, in a section whose `speaker` is null; a turn without speaker
39
- * parts is that one section. No part is dropped, copied or reordered within its
40
- * section.
41
- */
42
- export declare function partitionBySpeaker<T extends object>(parts: readonly T[]): SpeakerSection<T>[];
2
+ import { Speaker, Speaker as Speaker$1, SpeakerPart, SpeakerSection, SpeakerState, partitionBySpeaker } from "@tangle-network/agent-interface/speaker";
43
3
  export interface SpeakerHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
44
4
  speaker: Speaker;
45
5
  /** The turn is still running, so a responder that has not reported an end is answering. */
@@ -54,4 +14,5 @@ export interface SpeakerHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>,
54
14
  * failed glyph with the word, both from `RunStatusIcon`, and a finished one
55
15
  * nothing, as a settled run row does. `data-speaker-state` carries all three.
56
16
  */
57
- export declare function SpeakerHeader({ speaker, live, ...props }: SpeakerHeaderProps): import("react").JSX.Element;
17
+ export declare function SpeakerHeader({ speaker, live, ...props }: SpeakerHeaderProps): import("react").JSX.Element;
18
+ export { type Speaker$1 as Speaker, type SpeakerPart, type SpeakerSection, type SpeakerState, partitionBySpeaker };
@@ -1,49 +1,8 @@
1
1
  import { RunStatusIcon } from "../run/status-meta.js";
2
2
  import { MessageAuthor } from "./message-author.js";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { partitionBySpeaker } from "@tangle-network/agent-interface/speaker";
4
5
  // src/chat/speaker.tsx
5
- function isSpeakerPart(part) {
6
- const record = part;
7
- return !!record && record.type === "speaker" && typeof record.id === "string" && typeof record.name === "string";
8
- }
9
- /**
10
- * Splits one assistant turn's parts by the agent that wrote them.
11
- *
12
- * A stored row holds a speaker part before each responder's answer, so a part
13
- * belongs to the speaker part before it. A live stream announces every
14
- * responder first and then interleaves their output, so a part names its owner
15
- * in `speaker` (the responder's id), which wins over position. A later speaker
16
- * part with an id already seen replaces that responder's speaker part, which is
17
- * how its end state arrives, and never opens a second section.
18
- *
19
- * Sections keep the order speakers were first announced. Parts no speaker owns
20
- * come first, in a section whose `speaker` is null; a turn without speaker
21
- * parts is that one section. No part is dropped, copied or reordered within its
22
- * section.
23
- */
24
- function partitionBySpeaker(parts) {
25
- const sections = /* @__PURE__ */ new Map();
26
- const unowned = [];
27
- let current;
28
- for (const part of parts) {
29
- if (isSpeakerPart(part)) {
30
- current = sections.get(part.id);
31
- if (current) current.speaker = part;
32
- else sections.set(part.id, current = {
33
- speaker: part,
34
- parts: []
35
- });
36
- continue;
37
- }
38
- const tag = part.speaker;
39
- ((typeof tag === "string" ? sections.get(tag) : current)?.parts ?? unowned).push(part);
40
- }
41
- const owned = [...sections.values()];
42
- return unowned.length > 0 || owned.length === 0 ? [{
43
- speaker: null,
44
- parts: unowned
45
- }, ...owned] : owned;
46
- }
47
6
  /**
48
7
  * The line above one responder's answer: the author line every participant
49
8
  * gets, then the handle a person types to address the agent, then its state.
@@ -166,7 +166,7 @@ function MarkdownImage({ node: _node, src, alt, className, ...props }) {
166
166
  */
167
167
  const Markdown = memo(({ children, className, urlTransform, linkChip }) => {
168
168
  return /* @__PURE__ */ jsx("div", {
169
- className: cn("tangle-prose max-w-none text-sm", className),
169
+ className: cn("tangle-prose max-w-none text-sm [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none", className),
170
170
  children: /* @__PURE__ */ jsx(LinkChipContext.Provider, {
171
171
  value: linkChip,
172
172
  children: /* @__PURE__ */ jsx(ReactMarkdown, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.51.0",
3
+ "version": "11.51.2",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -149,6 +149,7 @@
149
149
  "@radix-ui/react-switch": "^1.4.1",
150
150
  "@radix-ui/react-tabs": "^1.2.1",
151
151
  "@radix-ui/react-tooltip": "^1.4.1",
152
+ "@tangle-network/agent-interface": "^5.10.3",
152
153
  "class-variance-authority": "^0.7.1",
153
154
  "clsx": "^2.1.1",
154
155
  "github-slugger": "^2.0.0",
@@ -166,7 +167,7 @@
166
167
  "peerDependencies": {
167
168
  "@hocuspocus/provider": ">=2.15.0 <5.0.0",
168
169
  "@nanostores/react": ">=0.8.4 <3.0.0",
169
- "@tangle-network/brand": "^1.31.0",
170
+ "@tangle-network/brand": "^1.31.2",
170
171
  "@tiptap/core": ">=3.20.4 <4.0.0",
171
172
  "@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
172
173
  "@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
@@ -21,11 +21,6 @@ const segmentedParts: SessionPart[] = [
21
21
  // The snapshots below were recorded from the components before `author`
22
22
  // existed. A transcript that names no author must keep that exact markup.
23
23
  describe("a transcript without authors", () => {
24
- it("renders ChatMessage as before", () => {
25
- expect(html(<ChatMessage role="user" content="Can I break my lease early?" />)).toMatchInlineSnapshot(`"<div class="flex flex-col gap-1 items-end"><div class="flex items-center gap-2 px-1 flex-row-reverse"><span class="font-medium text-foreground text-xs">You</span></div><div class="min-w-0 max-w-[85%] space-y-1 rounded-[var(--radius-lg)] bg-foreground px-[var(--chat-message-px)] py-[var(--chat-message-py)] text-background"><div class="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>"`)
26
- expect(html(<ChatMessage role="assistant" content="It depends on the lease terms." />)).toMatchInlineSnapshot(`"<div class="flex flex-col gap-1 items-start"><div class="flex items-center gap-2 px-1"><span class="font-medium text-foreground text-xs">Agent</span></div><div class="min-w-0 max-w-[85%] space-y-1 text-foreground"><div class="tangle-prose max-w-none tangle-prose tangle-transcript-text text-[length:var(--transcript-font-size)] leading-[var(--transcript-line-height)]"><p>It depends on the lease terms.</p></div></div></div>"`)
27
- expect(html(<ChatMessage role="system" content="Session started." />)).toMatchInlineSnapshot(`"<div class="flex flex-col gap-1 items-start"><div class="flex items-center gap-2 px-1"><span class="font-medium text-foreground text-xs">Agent</span></div><div class="min-w-0 max-w-[85%] space-y-1 rounded-[var(--radius-lg)] border border-border bg-card px-[var(--chat-message-px)] py-[var(--chat-message-py)]"><div class="tangle-prose max-w-none tangle-prose tangle-transcript-text text-[length:var(--transcript-font-size)] leading-[var(--transcript-line-height)]"><p>Session started.</p></div></div></div>"`)
28
- })
29
24
 
30
25
  it("renders UserMessage actions and application notes as before", () => {
31
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>"`)
@@ -36,18 +31,6 @@ describe("a transcript without authors", () => {
36
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>"`)
37
32
  })
38
33
 
39
- it("renders AgentTimeline messages as before", () => {
40
- expect(
41
- html(
42
- <AgentTimeline
43
- items={[
44
- { id: "u1", kind: "message", role: "user", content: "Can I break my lease early?" },
45
- { id: "a1", kind: "message", role: "assistant", content: "It depends on the lease terms." },
46
- ]}
47
- />,
48
- ),
49
- ).toMatchInlineSnapshot(`"<div class="mx-auto flex w-full max-w-5xl flex-col px-4 py-4"><div data-timeline-step="user" class=""><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></div><div data-timeline-step="prose" class="mt-4"><div><div class="tangle-prose max-w-none tangle-prose tangle-transcript-text text-[length:var(--transcript-font-size)] leading-[var(--transcript-line-height)]"><p>It depends on the lease terms.</p></div></div></div></div>"`)
50
- })
51
34
  })
52
35
 
53
36
  const client: ChatAuthor = { id: "client-maria", name: "Maria Lopez", role: "Client" }
@@ -2,71 +2,8 @@ import type { HTMLAttributes } from "react";
2
2
  import { RunStatusIcon } from "../run/status-meta";
3
3
  import { MessageAuthor } from "./message-author";
4
4
 
5
- /** Where one responder is in its answer. */
6
- export type SpeakerState = "answering" | "done" | "failed";
7
-
8
- /**
9
- * A responder of a turn addressed to several agents. In the turn's parts it is
10
- * a `{ type: "speaker", id, name, handle?, state? }` part written before the
11
- * responder's answer.
12
- */
13
- export interface Speaker {
14
- /** The agent's stable id. It keys the avatar's tone and a live part's `speaker` tag. */
15
- id: string;
16
- name: string;
17
- /** What a person types after `@` to address the agent. */
18
- handle?: string;
19
- /** Set once the responder ended; absent while it answers and on rows written before responders reported an end. */
20
- state?: SpeakerState;
21
- }
22
-
23
- export type SpeakerPart = Speaker & { type: "speaker" };
24
-
25
- /** One responder's share of a turn: its speaker part and the parts it wrote. */
26
- export interface SpeakerSection<T> {
27
- /** The latest speaker part for this responder; null for the parts no speaker owns. */
28
- speaker: (T & SpeakerPart) | null;
29
- parts: T[];
30
- }
31
-
32
- function isSpeakerPart<T>(part: T): part is T & SpeakerPart {
33
- const record = part as { type?: unknown; id?: unknown; name?: unknown } | null;
34
- return !!record && record.type === "speaker" && typeof record.id === "string" && typeof record.name === "string";
35
- }
36
-
37
- /**
38
- * Splits one assistant turn's parts by the agent that wrote them.
39
- *
40
- * A stored row holds a speaker part before each responder's answer, so a part
41
- * belongs to the speaker part before it. A live stream announces every
42
- * responder first and then interleaves their output, so a part names its owner
43
- * in `speaker` (the responder's id), which wins over position. A later speaker
44
- * part with an id already seen replaces that responder's speaker part, which is
45
- * how its end state arrives, and never opens a second section.
46
- *
47
- * Sections keep the order speakers were first announced. Parts no speaker owns
48
- * come first, in a section whose `speaker` is null; a turn without speaker
49
- * parts is that one section. No part is dropped, copied or reordered within its
50
- * section.
51
- */
52
- export function partitionBySpeaker<T extends object>(parts: readonly T[]): SpeakerSection<T>[] {
53
- const sections = new Map<string, SpeakerSection<T>>();
54
- const unowned: T[] = [];
55
- let current: SpeakerSection<T> | undefined;
56
- for (const part of parts) {
57
- if (isSpeakerPart(part)) {
58
- current = sections.get(part.id);
59
- if (current) current.speaker = part;
60
- else sections.set(part.id, (current = { speaker: part, parts: [] }));
61
- continue;
62
- }
63
- const tag = (part as { speaker?: unknown }).speaker;
64
- const owner = typeof tag === "string" ? sections.get(tag) : current;
65
- (owner?.parts ?? unowned).push(part);
66
- }
67
- const owned = [...sections.values()];
68
- return unowned.length > 0 || owned.length === 0 ? [{ speaker: null, parts: unowned }, ...owned] : owned;
69
- }
5
+ import type { Speaker, SpeakerState } from "@tangle-network/agent-interface/speaker";
6
+ export { partitionBySpeaker, type Speaker, type SpeakerPart, type SpeakerSection, type SpeakerState } from "@tangle-network/agent-interface/speaker";
70
7
 
71
8
  export interface SpeakerHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
72
9
  speaker: Speaker;
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react'
2
+ import { useEffect, useState } from 'react'
2
3
  import { Markdown } from './markdown'
3
4
  import { CodeBlock, CopyButton } from './code-block'
4
5
  import { codePresentation } from '../lib/code-presentation'
@@ -11,7 +12,7 @@ const markdownMeta: Meta<typeof Markdown> = {
11
12
  parameters: { layout: 'centered', backgrounds: { default: 'dark' } },
12
13
  decorators: [
13
14
  (Story) => (
14
- <div className="w-[680px] rounded-xl bg-card p-6 text-foreground">
15
+ <div className="w-[680px] max-w-full rounded-xl bg-card p-6 text-foreground">
15
16
  <Story />
16
17
  </div>
17
18
  ),
@@ -262,3 +263,33 @@ export const CodeBlockLight: Story = {
262
263
  </div>
263
264
  ),
264
265
  }
266
+
267
+
268
+ /** Host typography must not add a moving closing quote to streamed text. */
269
+ function StreamingBlockquoteFixture() {
270
+ const text = '> **A researcher collects the evidence and a writer prepares the draft.**\n> You decide when the work leaves the workspace. Each person sees the source, the proposed action and the result before approving it.'
271
+ const [length, setLength] = useState(0)
272
+ useEffect(() => {
273
+ let interval: ReturnType<typeof setInterval>
274
+ const delay = setTimeout(() => {
275
+ let shown = 0
276
+ interval = setInterval(() => {
277
+ shown = Math.min(shown + 4, text.length); setLength(shown)
278
+ if (shown === text.length) clearInterval(interval)
279
+ }, 30)
280
+ }, 1500)
281
+ return () => { clearTimeout(delay); clearInterval(interval) }
282
+ }, [])
283
+ return <div className="streaming-blockquote-fixture" data-stream-length={length} data-stream-complete={length === text.length}>
284
+ <style>{`@layer base {
285
+ .streaming-blockquote-fixture blockquote p::before { content: open-quote; }
286
+ .streaming-blockquote-fixture blockquote p::after { content: close-quote; }
287
+ }`}</style>
288
+ <Markdown>{text.slice(0, length)}</Markdown>
289
+ </div>
290
+ }
291
+
292
+ export const StreamingBlockquote: Story = {
293
+ parameters: { layout: 'fullscreen' },
294
+ render: () => <StreamingBlockquoteFixture />,
295
+ }
@@ -240,7 +240,7 @@ export interface MarkdownProps {
240
240
  export const Markdown = memo(({ children, className, urlTransform, linkChip }: MarkdownProps) => {
241
241
  return (
242
242
  <div
243
- className={cn("tangle-prose max-w-none text-sm", className)}
243
+ className={cn("tangle-prose max-w-none text-sm [&_blockquote_p]:before:content-none [&_blockquote_p]:after:content-none", className)}
244
244
  >
245
245
  <LinkChipContext.Provider value={linkChip}>
246
246
  <ReactMarkdown