@tangle-network/ui 11.51.1 → 11.51.3
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 +12 -0
- package/README.md +4 -0
- package/dist/chat/speaker.d.ts +3 -42
- package/dist/chat/speaker.js +1 -42
- package/dist/hooks/use-auto-scroll.d.ts +1 -4
- package/dist/hooks/use-auto-scroll.js +7 -41
- package/dist/hooks/use-event-stream.d.ts +15 -0
- package/dist/hooks/use-event-stream.js +71 -0
- package/dist/hooks/use-follow-tail.d.ts +14 -0
- package/dist/hooks/use-follow-tail.js +69 -0
- package/dist/hooks/use-sse-stream.js +36 -33
- package/dist/hooks.d.ts +3 -1
- package/dist/hooks.js +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/package.json +3 -2
- package/src/chat/message-author.test.tsx +0 -17
- package/src/chat/speaker.tsx +2 -65
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-auto-scroll.ts +9 -58
- package/src/hooks/use-event-stream.ts +71 -0
- package/src/hooks/use-follow-tail.ts +32 -0
- package/src/hooks/use-sse-stream.ts +24 -55
- package/src/chat/chat-container.synthetic.test.tsx +0 -171
- package/src/chat/chat-container.test.tsx +0 -189
|
@@ -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" }
|
package/src/chat/speaker.tsx
CHANGED
|
@@ -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
|
-
|
|
6
|
-
export type
|
|
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;
|
package/src/hooks/index.ts
CHANGED
|
@@ -8,3 +8,6 @@ export * from "./use-auth";
|
|
|
8
8
|
export * from "./use-realtime-session";
|
|
9
9
|
export * from "./use-sdk-session";
|
|
10
10
|
export * from "./use-sse-stream";
|
|
11
|
+
|
|
12
|
+
export { useEventStream, type EventStreamHandlers, type EventStreamSource, type EventStreamOpener } from "./use-event-stream";
|
|
13
|
+
export { useFollowTail } from "./use-follow-tail";
|
|
@@ -1,59 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Scroll-to-bottom behaviour: sticks to the bottom while streaming,
|
|
13
|
-
* pauses when user scrolls up, resumes when user scrolls back down.
|
|
14
|
-
*/
|
|
15
|
-
export function useAutoScroll(
|
|
16
|
-
containerRef: RefObject<HTMLElement | null>,
|
|
17
|
-
deps: unknown[] = [],
|
|
18
|
-
) {
|
|
19
|
-
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
20
|
-
const userScrolledUp = useRef(false);
|
|
21
|
-
|
|
22
|
-
const checkBottom = useCallback(() => {
|
|
23
|
-
const el = containerRef.current;
|
|
24
|
-
if (!el) return true;
|
|
25
|
-
return el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD;
|
|
26
|
-
}, [containerRef]);
|
|
27
|
-
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
const el = containerRef.current;
|
|
30
|
-
if (!el) return;
|
|
31
|
-
|
|
32
|
-
const onScroll = () => {
|
|
33
|
-
const atBottom = checkBottom();
|
|
34
|
-
setIsAtBottom(atBottom);
|
|
35
|
-
userScrolledUp.current = !atBottom;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
el.addEventListener("scroll", onScroll, { passive: true });
|
|
39
|
-
return () => el.removeEventListener("scroll", onScroll);
|
|
40
|
-
}, [containerRef, checkBottom]);
|
|
41
|
-
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
if (userScrolledUp.current) return;
|
|
44
|
-
const el = containerRef.current;
|
|
45
|
-
if (!el) return;
|
|
46
|
-
el.scrollTop = el.scrollHeight;
|
|
47
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
-
}, deps);
|
|
49
|
-
|
|
50
|
-
const scrollToBottom = useCallback(() => {
|
|
51
|
-
const el = containerRef.current;
|
|
52
|
-
if (!el) return;
|
|
53
|
-
userScrolledUp.current = false;
|
|
54
|
-
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
55
|
-
setIsAtBottom(true);
|
|
56
|
-
}, [containerRef]);
|
|
57
|
-
|
|
58
|
-
return { isAtBottom, scrollToBottom };
|
|
1
|
+
import { useCallback, useMemo, type RefObject } from 'react'
|
|
2
|
+
import { useFollowTail } from './use-follow-tail'
|
|
3
|
+
|
|
4
|
+
/** Existing chat contract, backed by the shared measured-content following lifecycle. */
|
|
5
|
+
export function useAutoScroll(containerRef: RefObject<HTMLElement | null>, deps: unknown[] = []) {
|
|
6
|
+
const content = useMemo(() => ({}), deps)
|
|
7
|
+
const { atBottom, jump } = useFollowTail(containerRef, { content })
|
|
8
|
+
const scrollToBottom = useCallback(() => jump('smooth'), [jump])
|
|
9
|
+
return { isAtBottom: atBottom, scrollToBottom }
|
|
59
10
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef } from 'react'
|
|
3
|
+
|
|
4
|
+
/** Authenticated hosts own reconnects and session lifecycle; native streams use this lifecycle. */
|
|
5
|
+
export interface EventStreamSource {
|
|
6
|
+
addEventListener(type: string, listener: (event: MessageEvent<string>) => void): void
|
|
7
|
+
close(): void
|
|
8
|
+
}
|
|
9
|
+
export type EventStreamOpener = (url: string, onLiveChange?: (live: boolean) => void) => EventStreamSource
|
|
10
|
+
export interface EventStreamHandlers {
|
|
11
|
+
events: Readonly<Record<string, (event: MessageEvent<string>) => void>>
|
|
12
|
+
opener?: EventStreamOpener
|
|
13
|
+
after?: () => number
|
|
14
|
+
onStart?: () => void
|
|
15
|
+
onLiveChange?: (live: boolean) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Internal policy preserves the buffered hook's existing bounded exponential retries. */
|
|
19
|
+
type NativePolicy = EventStreamHandlers & { terminalEvent?: string; onConnecting?: (attempt: number) => void; retryDelay?: (attempt: number) => number | undefined }
|
|
20
|
+
|
|
21
|
+
/** The sole native EventSource lifecycle, shared by buffered and producer-owned streams. */
|
|
22
|
+
export function openNativeStream(url: string | (() => string), handlers: NativePolicy): { close(): void } {
|
|
23
|
+
let stopped = false, source: EventStreamSource | undefined, retry: ReturnType<typeof setTimeout> | undefined, attempt = 0
|
|
24
|
+
handlers.onStart?.()
|
|
25
|
+
const open = () => {
|
|
26
|
+
handlers.onConnecting?.(attempt)
|
|
27
|
+
const target = new URL(typeof url === 'function' ? url() : url, globalThis.location?.href), after = handlers.after?.()
|
|
28
|
+
if (after !== undefined && after > 0) target.searchParams.set('after', String(after))
|
|
29
|
+
let connection: EventStreamSource
|
|
30
|
+
const live = (value: boolean) => { if (!stopped && (!connection || source === connection)) handlers.onLiveChange?.(value) }
|
|
31
|
+
connection = handlers.opener ? handlers.opener(target.toString(), live) : new EventSource(target.toString())
|
|
32
|
+
source = connection
|
|
33
|
+
for (const name of new Set([...Object.keys(handlers.events), ...(handlers.terminalEvent ? [handlers.terminalEvent] : [])])) connection.addEventListener(name, event => {
|
|
34
|
+
if (stopped || source !== connection) return
|
|
35
|
+
if (name === handlers.terminalEvent) { stopped = true; clearTimeout(retry); connection.close() }
|
|
36
|
+
handlers.events[name]?.(event)
|
|
37
|
+
})
|
|
38
|
+
if (!handlers.opener) {
|
|
39
|
+
const native = connection as EventSource
|
|
40
|
+
native.onopen = () => { attempt = 0; live(true) }
|
|
41
|
+
native.onerror = () => {
|
|
42
|
+
connection.close()
|
|
43
|
+
if (stopped || source !== connection) return
|
|
44
|
+
live(false)
|
|
45
|
+
const delay = handlers.retryDelay ? handlers.retryDelay(attempt++) : 1_000
|
|
46
|
+
if (delay !== undefined) retry = setTimeout(open, delay)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
open()
|
|
51
|
+
return { close: () => { stopped = true; clearTimeout(retry); source?.close() } }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Producer-owned named events; host transports keep their authenticated reconnect lifecycle. */
|
|
55
|
+
export function useEventStream(url: string | undefined, handlers: EventStreamHandlers): void {
|
|
56
|
+
const latest = useRef(handlers)
|
|
57
|
+
latest.current = handlers
|
|
58
|
+
const names = Object.keys(handlers.events).sort().join(',')
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!url || (!handlers.opener && typeof EventSource === 'undefined')) return
|
|
61
|
+
const current: EventStreamHandlers = {
|
|
62
|
+
opener: handlers.opener,
|
|
63
|
+
events: Object.fromEntries(Object.keys(handlers.events).map(name => [name, (event: MessageEvent<string>) => latest.current.events[name]?.(event)])),
|
|
64
|
+
after: () => latest.current.after?.() ?? 0,
|
|
65
|
+
onStart: () => latest.current.onStart?.(),
|
|
66
|
+
onLiveChange: live => latest.current.onLiveChange?.(live),
|
|
67
|
+
}
|
|
68
|
+
const connection = openNativeStream(url, { ...current, terminalEvent: 'end' })
|
|
69
|
+
return () => connection.close()
|
|
70
|
+
}, [url, handlers.opener, names])
|
|
71
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { type RefObject, useCallback, useLayoutEffect, useRef, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
/** Keeps the bottom through measured content growth, until the reader deliberately scrolls away. */
|
|
5
|
+
export function useFollowTail(scroller: RefObject<HTMLElement | null>, { identity, content, follow = true, target, thresholdPx = 40 }: {
|
|
6
|
+
identity?: unknown; content?: unknown; follow?: boolean; target?: unknown; thresholdPx?: number
|
|
7
|
+
}) {
|
|
8
|
+
const following = useRef(false), latest = useRef({ follow, target, thresholdPx })
|
|
9
|
+
latest.current = { follow, target, thresholdPx }
|
|
10
|
+
const [unseen, setUnseen] = useState(false), [atBottom, setAtBottom] = useState(true)
|
|
11
|
+
const jump = useCallback((behavior: ScrollBehavior = 'auto') => { const node = scroller.current; following.current = latest.current.follow; setUnseen(false); setAtBottom(true); if (node) node.scrollTo({ top: node.scrollHeight, behavior }) }, [scroller])
|
|
12
|
+
useLayoutEffect(() => {
|
|
13
|
+
following.current = latest.current.follow && !latest.current.target
|
|
14
|
+
setUnseen(false)
|
|
15
|
+
if (scroller.current && !latest.current.target) scroller.current.scrollTop = following.current ? scroller.current.scrollHeight : 0
|
|
16
|
+
}, [identity])
|
|
17
|
+
useLayoutEffect(() => { if (!follow) following.current = false }, [follow])
|
|
18
|
+
useLayoutEffect(() => {
|
|
19
|
+
const node = scroller.current
|
|
20
|
+
if (!node) return
|
|
21
|
+
const track = () => { const near = node.scrollHeight - node.scrollTop - node.clientHeight < latest.current.thresholdPx; following.current = latest.current.follow && near; setAtBottom(near); if (near) setUnseen(false) }
|
|
22
|
+
node.addEventListener('scroll', track, { passive: true })
|
|
23
|
+
const pin = () => { if (following.current) node.scrollTop = node.scrollHeight }
|
|
24
|
+
if (!following.current) setUnseen(true)
|
|
25
|
+
pin()
|
|
26
|
+
const observer = new ResizeObserver(pin)
|
|
27
|
+
observer.observe(node)
|
|
28
|
+
for (const child of node.children) observer.observe(child)
|
|
29
|
+
return () => { observer.disconnect(); node.removeEventListener('scroll', track) }
|
|
30
|
+
}, [content, follow, identity])
|
|
31
|
+
return { following, unseen, jump, atBottom }
|
|
32
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import { openNativeStream } from "./use-event-stream";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* SSE Event data structure
|
|
@@ -121,7 +122,7 @@ export function useSSEStream<T = unknown>(
|
|
|
121
122
|
const [timeSinceLastEvent, setTimeSinceLastEvent] = React.useState(0);
|
|
122
123
|
|
|
123
124
|
const retryCountRef = React.useRef(0);
|
|
124
|
-
const eventSourceRef = React.useRef<
|
|
125
|
+
const eventSourceRef = React.useRef<{ close(): void } | null>(null);
|
|
125
126
|
const abortControllerRef = React.useRef<AbortController | null>(null);
|
|
126
127
|
const reconnectTimeoutRef = React.useRef<ReturnType<
|
|
127
128
|
typeof setTimeout
|
|
@@ -330,60 +331,28 @@ export function useSSEStream<T = unknown>(
|
|
|
330
331
|
}
|
|
331
332
|
});
|
|
332
333
|
} else {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
setError(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
},
|
|
356
|
-
Math.min(delay, 30000),
|
|
357
|
-
);
|
|
358
|
-
} else {
|
|
359
|
-
setState("error");
|
|
360
|
-
}
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
// Listen for specific event types or default 'message'
|
|
364
|
-
const types = eventTypes || ["message"];
|
|
365
|
-
for (const type of types) {
|
|
366
|
-
es.addEventListener(type, (e: MessageEvent) => {
|
|
367
|
-
try {
|
|
368
|
-
const data = JSON.parse(e.data) as T;
|
|
369
|
-
handleEvent(type, data, e.lastEventId);
|
|
370
|
-
} catch {
|
|
371
|
-
handleEvent(type, e.data as T, e.lastEventId);
|
|
372
|
-
}
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Also listen for unnamed events
|
|
377
|
-
if (!eventTypes) {
|
|
378
|
-
es.onmessage = (e: MessageEvent) => {
|
|
379
|
-
try {
|
|
380
|
-
const data = JSON.parse(e.data) as T;
|
|
381
|
-
handleEvent("message", data, e.lastEventId);
|
|
382
|
-
} catch {
|
|
383
|
-
handleEvent("message", e.data as T, e.lastEventId);
|
|
384
|
-
}
|
|
385
|
-
};
|
|
386
|
-
}
|
|
334
|
+
const events = Object.fromEntries((eventTypes ?? ["message"]).map(type => [type, (event: MessageEvent<string>) => {
|
|
335
|
+
let data: T
|
|
336
|
+
try { data = JSON.parse(event.data) as T } catch { data = event.data as T }
|
|
337
|
+
handleEvent(type, data, event.lastEventId)
|
|
338
|
+
}]))
|
|
339
|
+
eventSourceRef.current = openNativeStream(() => {
|
|
340
|
+
const target = new URL(url, window.location.origin)
|
|
341
|
+
if (lastEventIdRef.current) target.searchParams.set("lastEventId", lastEventIdRef.current)
|
|
342
|
+
return target.toString()
|
|
343
|
+
}, {
|
|
344
|
+
events,
|
|
345
|
+
onConnecting: attempt => { retryCountRef.current = attempt; setState("connecting"); setError(null) },
|
|
346
|
+
onLiveChange: live => {
|
|
347
|
+
if (live) { setState("connected"); retryCountRef.current = 0; return }
|
|
348
|
+
const error = new Error("EventSource connection error"); setError(error); onError?.(error)
|
|
349
|
+
},
|
|
350
|
+
retryDelay: attempt => {
|
|
351
|
+
if (!autoReconnect || !enabled || attempt >= maxRetries) { setState("error"); return undefined }
|
|
352
|
+
setState("reconnecting")
|
|
353
|
+
return Math.min(reconnectDelay * 2 ** attempt, 30000)
|
|
354
|
+
},
|
|
355
|
+
})
|
|
387
356
|
}
|
|
388
357
|
}, [
|
|
389
358
|
url,
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import { render, renderHook, screen } from "@testing-library/react";
|
|
2
|
-
import userEvent from "@testing-library/user-event";
|
|
3
|
-
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
4
|
-
import type { SessionMessage } from "../types/message";
|
|
5
|
-
import type { SessionPart, TextPart, ToolPart } from "../types/parts";
|
|
6
|
-
import { useRunGroups } from "../hooks/use-run-groups";
|
|
7
|
-
import { ChatContainer, type ChatContainerProps } from "./chat-container";
|
|
8
|
-
|
|
9
|
-
// Only the missing browser layout API is stubbed; transcript components are real.
|
|
10
|
-
beforeAll(() => { Element.prototype.scrollIntoView = vi.fn(); });
|
|
11
|
-
|
|
12
|
-
const messages: SessionMessage[] = [
|
|
13
|
-
{ id: "user", role: "user" },
|
|
14
|
-
{ id: "assistant", role: "assistant" },
|
|
15
|
-
];
|
|
16
|
-
const text = (value: string): TextPart => ({ type: "text", text: value });
|
|
17
|
-
const note = (value: string): TextPart => ({ ...text(value), synthetic: true });
|
|
18
|
-
const read = (id: string, path: string): ToolPart => ({
|
|
19
|
-
type: "tool", id, tool: "read",
|
|
20
|
-
state: { status: "completed", input: { path }, output: "File read." },
|
|
21
|
-
});
|
|
22
|
-
const firstTool = read("first", "src/first.ts");
|
|
23
|
-
const secondTool = read("second", "src/second.ts");
|
|
24
|
-
|
|
25
|
-
function expectOrder(...values: string[]) {
|
|
26
|
-
const nodes = values.map((value) => screen.getByText(value));
|
|
27
|
-
for (let i = 1; i < nodes.length; i++) {
|
|
28
|
-
expect(nodes[i - 1].compareDocumentPosition(nodes[i]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe.each(["runs", "timeline"] as const)("synthetic transcript text: %s", (presentation) => {
|
|
33
|
-
function view(partMap: Record<string, SessionPart[]>, extra: Partial<ChatContainerProps> = {}) {
|
|
34
|
-
return <ChatContainer messages={messages} partMap={partMap} isStreaming={false} presentation={presentation} {...extra} />;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
it("keeps mixed user and assistant notes in source order, outside speaker bubbles", () => {
|
|
38
|
-
const { container } = render(view({
|
|
39
|
-
user: [text("User before"), note("Delivery receipt"), text("User after")],
|
|
40
|
-
assistant: [text("Assistant before"), note("Missing trace"), text("Assistant after")],
|
|
41
|
-
}));
|
|
42
|
-
expectOrder("User before", "Delivery receipt", "User after", "Assistant before", "Missing trace", "Assistant after");
|
|
43
|
-
const notes = screen.getAllByRole("note", { name: "Application note" });
|
|
44
|
-
expect(notes).toHaveLength(2);
|
|
45
|
-
for (const element of notes) {
|
|
46
|
-
expect(element.closest('.bg-foreground, .tangle-prose, [class*="rounded-[26px]"]')).toBeNull();
|
|
47
|
-
}
|
|
48
|
-
const bubbles = container.querySelectorAll(".bg-foreground.text-background");
|
|
49
|
-
expect(bubbles).toHaveLength(2);
|
|
50
|
-
for (const bubble of bubbles) expect(bubble).not.toHaveTextContent("Delivery receipt");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("retains synthetic-only messages without inventing speaker text", () => {
|
|
54
|
-
const { container } = render(view({ user: [note("Request receipt")], assistant: [note("No response persisted")] }));
|
|
55
|
-
expectOrder("Request receipt", "No response persisted");
|
|
56
|
-
expect(screen.getAllByRole("note")).toHaveLength(2);
|
|
57
|
-
expect(screen.queryByText("Agent")).not.toBeInTheDocument();
|
|
58
|
-
expect(container.querySelector(".bg-foreground, .tangle-prose")).toBeNull();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("omits blank notes and blank source text", () => {
|
|
62
|
-
render(view({ user: [note(" \n "), text(" ")], assistant: [note("\t"), text("\n")] }));
|
|
63
|
-
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
|
64
|
-
expect(screen.queryByText("Agent")).not.toBeInTheDocument();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("does not parse application notes as Markdown, HTML, or OpenUI", () => {
|
|
68
|
-
const literal = '<em>literal</em>\n**not markdown**\n```json\n{"type":"heading","text":"Not a heading"}\n```';
|
|
69
|
-
render(view({ user: [], assistant: [note(literal)] }));
|
|
70
|
-
const element = screen.getByRole("note");
|
|
71
|
-
expect(element.textContent).toContain(literal);
|
|
72
|
-
expect(element.querySelector("em, strong, h1, h2, h3, button, a")).toBeNull();
|
|
73
|
-
expect(element.innerHTML).toContain("<em>literal</em>");
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("renders notes even when OpenUI rendering is disabled", () => {
|
|
77
|
-
render(view({ user: [], assistant: [note("Receipt with OpenUI off")] }, { enableOpenUI: false }));
|
|
78
|
-
expect(screen.getByRole("note")).toHaveTextContent("Receipt with OpenUI off");
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("retains repeated notes and their position rather than deduplicating content", () => {
|
|
82
|
-
render(view({ user: [], assistant: [note("Same receipt"), text("Between receipts"), note("Same receipt")] }));
|
|
83
|
-
const notes = screen.getAllByRole("note");
|
|
84
|
-
expect(notes).toHaveLength(2);
|
|
85
|
-
const middle = screen.getByText("Between receipts");
|
|
86
|
-
expect(notes[0].compareDocumentPosition(middle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
87
|
-
expect(middle.compareDocumentPosition(notes[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("preserves tool/note order and original tool callback arguments", async () => {
|
|
91
|
-
const user = userEvent.setup();
|
|
92
|
-
const activated = vi.fn();
|
|
93
|
-
const renderRunTool = vi.fn<NonNullable<ChatContainerProps["renderToolActions"]>>((part, context) => (
|
|
94
|
-
<button type="button" onClick={() => activated(part, context)}>Inspect {part.id}</button>
|
|
95
|
-
));
|
|
96
|
-
const renderTimelineTool = vi.fn((part: ToolPart) => (
|
|
97
|
-
<button type="button" onClick={() => activated(part)}>Inspect {part.id}</button>
|
|
98
|
-
));
|
|
99
|
-
render(view({ user: [text("Read files")], assistant: [firstTool, note("Partial trace"), secondTool, text("Source answer")] }, {
|
|
100
|
-
renderToolActions: renderRunTool,
|
|
101
|
-
renderTimelineToolActions: renderTimelineTool,
|
|
102
|
-
}));
|
|
103
|
-
expectOrder("src/first.ts", "Partial trace", "src/second.ts", "Source answer");
|
|
104
|
-
await user.click(screen.getByRole("button", { name: "Inspect second" }));
|
|
105
|
-
expect(activated.mock.calls[0][0]).toBe(secondTool);
|
|
106
|
-
if (presentation === "runs") {
|
|
107
|
-
expect(activated.mock.calls[0][1]).toMatchObject({ messageId: "assistant", partIndex: 2 });
|
|
108
|
-
expect(renderTimelineTool).not.toHaveBeenCalled();
|
|
109
|
-
expect(screen.getByText("2 tools, 1 response")).toBeInTheDocument();
|
|
110
|
-
} else {
|
|
111
|
-
expect(renderRunTool).not.toHaveBeenCalled();
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("does not treat an application receipt as the pending assistant reply", () => {
|
|
116
|
-
const partMap = { user: [text("Question")], assistant: [note("Request accepted")] };
|
|
117
|
-
const { rerender } = render(view(partMap, { isStreaming: true }));
|
|
118
|
-
expect(screen.getByRole("note")).toHaveTextContent("Request accepted");
|
|
119
|
-
if (presentation === "timeline") {
|
|
120
|
-
expect(screen.getByRole("status", { name: "Agent is thinking" })).toBeInTheDocument();
|
|
121
|
-
} else {
|
|
122
|
-
expect(screen.getByText("Thinking")).toBeInTheDocument();
|
|
123
|
-
}
|
|
124
|
-
rerender(view({ ...partMap, assistant: [note("Request accepted"), text("Answer arrived")] }));
|
|
125
|
-
expect(screen.getByRole("note")).toHaveTextContent("Request accepted");
|
|
126
|
-
expect(screen.getByText("Answer arrived")).toBeInTheDocument();
|
|
127
|
-
expect(screen.queryByRole("status", { name: "Agent is thinking" })).not.toBeInTheDocument();
|
|
128
|
-
expect(screen.queryByText("Thinking")).not.toBeInTheDocument();
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
describe("run attribution and manual collapse", () => {
|
|
133
|
-
it.each([false, true])("excludes notes from response statistics and summaries (only notes: %s)", (onlyNotes) => {
|
|
134
|
-
const { result } = renderHook(() => useRunGroups({
|
|
135
|
-
messages,
|
|
136
|
-
partMap: { user: [text("Question")], assistant: onlyNotes ? [note("App receipt")] : [text("Authored answer"), note("App receipt")] },
|
|
137
|
-
isStreaming: false,
|
|
138
|
-
}));
|
|
139
|
-
const group = result.current.find((entry) => entry.type === "run");
|
|
140
|
-
expect(group?.type).toBe("run");
|
|
141
|
-
if (group?.type !== "run") throw new Error("Expected the real run group");
|
|
142
|
-
expect(group.run.stats.textPartCount).toBe(onlyNotes ? 0 : 1);
|
|
143
|
-
expect(group.run.summaryText).toBe(onlyNotes ? null : "Authored answer");
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("restores note order on keyboard expansion, without quoting notes in the run summary", async () => {
|
|
147
|
-
const user = userEvent.setup();
|
|
148
|
-
render(<ChatContainer messages={messages} partMap={{ user: [text("Question")], assistant: [firstTool, note("App-only receipt"), text("Source answer")] }} isStreaming={false} />);
|
|
149
|
-
expectOrder("src/first.ts", "App-only receipt", "Source answer");
|
|
150
|
-
const toggle = screen.getByRole("button", { expanded: true, name: /Agent/ });
|
|
151
|
-
await user.click(toggle);
|
|
152
|
-
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
153
|
-
expect(toggle).toHaveTextContent("1 tool, 1 response");
|
|
154
|
-
expect(toggle).not.toHaveTextContent("App-only receipt");
|
|
155
|
-
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
|
156
|
-
toggle.focus();
|
|
157
|
-
await user.keyboard("{Enter}");
|
|
158
|
-
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
159
|
-
expectOrder("src/first.ts", "App-only receipt", "Source answer");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("restores notes behind the existing explicit timeline limit", async () => {
|
|
163
|
-
const user = userEvent.setup();
|
|
164
|
-
render(<ChatContainer messages={messages} partMap={{ user: [text("Question")], assistant: [firstTool, note("App-only receipt"), text("Source answer")] }} isStreaming={false} presentation="timeline" collapseTimelineAfter={1} />);
|
|
165
|
-
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
|
166
|
-
await user.click(screen.getByRole("button", { name: "Show 2 more steps" }));
|
|
167
|
-
expectOrder("src/first.ts", "App-only receipt", "Source answer");
|
|
168
|
-
await user.click(screen.getByRole("button", { name: "Show less" }));
|
|
169
|
-
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
|
170
|
-
});
|
|
171
|
-
});
|