@tangle-network/ui 11.15.0 → 11.15.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 +13 -0
- package/dist/chat/chat-container.js +31 -9
- package/dist/chat/synthetic-text.js +52 -0
- package/dist/chat/user-message.d.ts +2 -4
- package/dist/chat/user-message.js +34 -13
- package/dist/files/rich-file-tree.js +29 -6
- package/dist/run/run-group.js +89 -62
- package/package.json +1 -1
- package/src/chat/chat-container.synthetic.stories.tsx +82 -0
- package/src/chat/chat-container.synthetic.test.tsx +171 -0
- package/src/chat/chat-container.tsx +33 -16
- package/src/chat/synthetic-text.test.tsx +80 -0
- package/src/chat/synthetic-text.tsx +51 -0
- package/src/chat/user-message.tsx +54 -36
- package/src/files/rich-file-tree.test.tsx +50 -2
- package/src/files/rich-file-tree.tsx +25 -12
- package/src/run/run-group.tsx +99 -64
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
});
|
|
@@ -8,7 +8,7 @@ import { ArrowDown } from "lucide-react";
|
|
|
8
8
|
import { focusRing } from "../lib/focus";
|
|
9
9
|
import { cn } from "../lib/utils";
|
|
10
10
|
import type { SessionMessage } from "../types/message";
|
|
11
|
-
import type { SessionPart,
|
|
11
|
+
import type { SessionPart, ToolPart } from "../types/parts";
|
|
12
12
|
import type { AgentBranding } from "../types/branding";
|
|
13
13
|
import type { CustomToolRenderer } from "../types/tool-display";
|
|
14
14
|
import type { Run } from "../types/run";
|
|
@@ -16,6 +16,7 @@ import { useRunGroups } from "../hooks/use-run-groups";
|
|
|
16
16
|
import { useRunCollapseState } from "../hooks/use-run-collapse-state";
|
|
17
17
|
import { useAutoScroll } from "../hooks/use-auto-scroll";
|
|
18
18
|
import { MessageList } from "./message-list";
|
|
19
|
+
import { SyntheticText, userTextSegments } from "./synthetic-text";
|
|
19
20
|
import {
|
|
20
21
|
AgentTimeline,
|
|
21
22
|
type AgentTimelineItem,
|
|
@@ -219,21 +220,25 @@ function buildTimelineItems(
|
|
|
219
220
|
const parts = partMap[message.id] ?? [];
|
|
220
221
|
|
|
221
222
|
if (message.role === "user") {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
.
|
|
225
|
-
.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
223
|
+
const segments = userTextSegments(parts);
|
|
224
|
+
for (const segment of segments) {
|
|
225
|
+
const id = segments.length === 1 ? message.id : `${message.id}-user-${segment.index}`;
|
|
226
|
+
if (segment.synthetic) {
|
|
227
|
+
items.push({
|
|
228
|
+
id,
|
|
229
|
+
kind: "custom",
|
|
230
|
+
content: <div className="mx-3"><SyntheticText text={segment.text} /></div>,
|
|
231
|
+
});
|
|
232
|
+
} else {
|
|
233
|
+
items.push({
|
|
234
|
+
id,
|
|
235
|
+
kind: "message",
|
|
236
|
+
role: "user",
|
|
237
|
+
content: segment.text.trim(),
|
|
238
|
+
timestamp: createdAtFromMessage(message),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
237
242
|
continue;
|
|
238
243
|
}
|
|
239
244
|
|
|
@@ -289,6 +294,18 @@ function buildTimelineItems(
|
|
|
289
294
|
|
|
290
295
|
flushToolBuffer(index);
|
|
291
296
|
|
|
297
|
+
// A note is neither an assistant reply nor an OpenUI instruction.
|
|
298
|
+
if (part.type === "text" && part.synthetic) {
|
|
299
|
+
if (part.text.trim()) {
|
|
300
|
+
items.push({
|
|
301
|
+
id: itemId,
|
|
302
|
+
kind: "custom",
|
|
303
|
+
content: <div className="mx-3"><SyntheticText text={part.text} /></div>,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
292
309
|
if (part.type === "text" && !part.synthetic && part.text.trim()) {
|
|
293
310
|
// Check if the text itself contains an OpenUI JSON block
|
|
294
311
|
if (enableOpenUI) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { SessionPart } from "../types/parts";
|
|
4
|
+
import { SyntheticText, userTextSegments } from "./synthetic-text";
|
|
5
|
+
import { UserMessage } from "./user-message";
|
|
6
|
+
|
|
7
|
+
const mixed: SessionPart[] = [
|
|
8
|
+
{ type: "text", text: "First" },
|
|
9
|
+
{ type: "text", text: "Second", synthetic: false },
|
|
10
|
+
{ type: "text", text: "Receipt", synthetic: true },
|
|
11
|
+
{ type: "text", text: "Third" },
|
|
12
|
+
{ type: "text", text: "Last receipt", synthetic: true },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
describe("user text boundaries", () => {
|
|
16
|
+
it("joins adjacent authored parts, keeps source indices and does not mutate the input", () => {
|
|
17
|
+
const before = JSON.stringify(mixed);
|
|
18
|
+
expect(userTextSegments(mixed)).toEqual([
|
|
19
|
+
{ index: 0, synthetic: false, text: "First\nSecond" },
|
|
20
|
+
{ index: 2, synthetic: true, text: "Receipt" },
|
|
21
|
+
{ index: 3, synthetic: false, text: "Third" },
|
|
22
|
+
{ index: 4, synthetic: true, text: "Last receipt" },
|
|
23
|
+
]);
|
|
24
|
+
expect(JSON.stringify(mixed)).toBe(before);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("skips empty content without merging across a synthetic boundary", () => {
|
|
28
|
+
expect(userTextSegments([
|
|
29
|
+
{ type: "text", text: "Before" },
|
|
30
|
+
{ type: "text", text: " \n", synthetic: true },
|
|
31
|
+
{ type: "text", text: "After" },
|
|
32
|
+
])).toEqual([
|
|
33
|
+
{ index: 0, synthetic: false, text: "Before" },
|
|
34
|
+
{ index: 2, synthetic: false, text: "After" },
|
|
35
|
+
]);
|
|
36
|
+
expect(userTextSegments([])).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("preserves a single joined bubble when no application note is present", () => {
|
|
40
|
+
const { container } = render(<UserMessage parts={mixed.slice(0, 2)} />);
|
|
41
|
+
expect(container.querySelectorAll(".bg-foreground.text-background")).toHaveLength(1);
|
|
42
|
+
expect(container.querySelector(".whitespace-pre-wrap")?.textContent).toBe("First\nSecond");
|
|
43
|
+
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it.each(["", "Direct content"])("preserves explicit content precedence: %j", (content) => {
|
|
47
|
+
const { container } = render(<UserMessage content={content} parts={mixed} />);
|
|
48
|
+
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
|
49
|
+
expect(container.textContent).toBe(content);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("renders message actions once after all segments, even after a trailing note", () => {
|
|
53
|
+
render(<UserMessage parts={mixed} actions={<button type="button">Retry message</button>} />);
|
|
54
|
+
const action = screen.getByRole("button", { name: "Retry message" });
|
|
55
|
+
const last = screen.getByText("Last receipt");
|
|
56
|
+
expect(last.compareDocumentPosition(action) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
57
|
+
expect(screen.getAllByRole("button", { name: "Retry message" })).toHaveLength(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("supports a note-only user message with one action and no inverse-theme bubble", () => {
|
|
61
|
+
const { container } = render(<UserMessage parts={[mixed[2]]} actions={<button type="button">Inspect receipt</button>} />);
|
|
62
|
+
expect(screen.getByRole("note")).toHaveTextContent("Receipt");
|
|
63
|
+
expect(screen.getByRole("button", { name: "Inspect receipt" })).toBeInTheDocument();
|
|
64
|
+
expect(container.querySelector(".bg-foreground")).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("retains direct-content timestamps", () => {
|
|
68
|
+
const { container } = render(<UserMessage content="Original words" timestamp={new Date("2026-01-01T12:00:00Z")} />);
|
|
69
|
+
expect(container.querySelectorAll("[data-user-message-time]")).toHaveLength(1);
|
|
70
|
+
expect(screen.getByText("Original words")).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("renders a visible, named plain-text note and omits blanks", () => {
|
|
74
|
+
const { rerender } = render(<SyntheticText text="No trace captured" />);
|
|
75
|
+
expect(screen.getByRole("note", { name: "Application note" })).toHaveTextContent("No trace captured");
|
|
76
|
+
expect(screen.getByText("Application note")).toBeVisible();
|
|
77
|
+
rerender(<SyntheticText text={" \n "} />);
|
|
78
|
+
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SessionPart } from "../types/parts";
|
|
2
|
+
|
|
3
|
+
/** Internal transcript chrome, never attributed to a user or model. */
|
|
4
|
+
export function SyntheticText({ text }: { text: string }) {
|
|
5
|
+
if (!text.trim()) return null;
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
role="note"
|
|
9
|
+
aria-label="Application note"
|
|
10
|
+
data-synthetic-text=""
|
|
11
|
+
className="min-w-0 rounded-md border border-dashed border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
|
|
12
|
+
>
|
|
13
|
+
<div className="mb-1 font-medium">Application note</div>
|
|
14
|
+
{/* Plain React text: receipts are not Markdown or executable OpenUI. */}
|
|
15
|
+
<div className="break-words whitespace-pre-wrap">{text}</div>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UserTextSegment {
|
|
21
|
+
/** Original part index, so subsequent streamed parts do not change keys. */
|
|
22
|
+
index: number;
|
|
23
|
+
synthetic: boolean;
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Keep the existing joined user bubble, splitting only at application notes. */
|
|
28
|
+
export function userTextSegments(parts: SessionPart[]): UserTextSegment[] {
|
|
29
|
+
const segments: UserTextSegment[] = [];
|
|
30
|
+
let pending: string[] = [];
|
|
31
|
+
let start = 0;
|
|
32
|
+
const flush = () => {
|
|
33
|
+
const text = pending.join("\n");
|
|
34
|
+
if (text.trim()) segments.push({ index: start, synthetic: false, text });
|
|
35
|
+
pending = [];
|
|
36
|
+
};
|
|
37
|
+
parts.forEach((part, index) => {
|
|
38
|
+
if (part.type !== "text") return;
|
|
39
|
+
if (part.synthetic) {
|
|
40
|
+
flush();
|
|
41
|
+
if (part.text.trim()) {
|
|
42
|
+
segments.push({ index, synthetic: true, text: part.text });
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
if (pending.length === 0) start = index;
|
|
46
|
+
pending.push(part.text);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
flush();
|
|
50
|
+
return segments;
|
|
51
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo, type ReactNode } from "react";
|
|
2
2
|
import type { SessionMessage } from "../types/message";
|
|
3
3
|
import type { SessionPart } from "../types/parts";
|
|
4
|
+
import { SyntheticText, userTextSegments } from "./synthetic-text";
|
|
4
5
|
|
|
5
6
|
export interface UserMessageProps {
|
|
6
7
|
/** Session-model input: text is derived from these parts. */
|
|
@@ -16,47 +17,64 @@ function formatTime(date: Date): string {
|
|
|
16
17
|
return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
function MessageActions({ children }: { children: ReactNode }) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex flex-wrap items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
|
23
|
+
{children}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function UserBubble({ text, timestamp, actions }: {
|
|
29
|
+
text: string;
|
|
30
|
+
timestamp?: Date;
|
|
31
|
+
actions?: ReactNode;
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex justify-end">
|
|
35
|
+
<div className="group flex min-w-0 max-w-[78%] flex-col items-end gap-2">
|
|
36
|
+
<div className="relative w-full min-w-0 rounded-2xl bg-foreground px-4 py-3 text-background">
|
|
37
|
+
{timestamp ? (
|
|
38
|
+
<span
|
|
39
|
+
data-user-message-time=""
|
|
40
|
+
className="pointer-events-none absolute right-full top-1/2 mr-3 -translate-y-1/2 whitespace-nowrap text-[var(--font-size-xs)] text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
|
41
|
+
>
|
|
42
|
+
{formatTime(timestamp)}
|
|
43
|
+
</span>
|
|
44
|
+
) : null}
|
|
45
|
+
<div className="break-words whitespace-pre-wrap text-[var(--font-size-base)] leading-[1.5]">
|
|
46
|
+
{text}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
{actions ? <MessageActions>{actions}</MessageActions> : null}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
19
55
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* the run/message list (session-model `parts`) and AgentTimeline (direct
|
|
23
|
-
* `content`/`timestamp`).
|
|
56
|
+
* User speech retains the inverse-theme bubble and direct-content contract.
|
|
57
|
+
* Application notes interrupt the bubble in source order, outside its attribution.
|
|
24
58
|
*/
|
|
25
59
|
export const UserMessage = memo(
|
|
26
60
|
({ message: _message, parts, content, timestamp, actions }: UserMessageProps) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
61
|
+
// Explicit direct content (including empty content) still overrides parts.
|
|
62
|
+
if (content != null) {
|
|
63
|
+
return content.trim() ? <UserBubble text={content} timestamp={timestamp} actions={actions} /> : null;
|
|
64
|
+
}
|
|
65
|
+
const segments = userTextSegments(parts ?? []);
|
|
66
|
+
if (segments.length === 0) return null;
|
|
67
|
+
if (segments.length === 1 && !segments[0].synthetic) {
|
|
68
|
+
return <UserBubble text={segments[0].text} timestamp={timestamp} actions={actions} />;
|
|
69
|
+
}
|
|
36
70
|
return (
|
|
37
|
-
<div className="
|
|
38
|
-
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
data-user-message-time=""
|
|
45
|
-
className="pointer-events-none absolute right-full top-1/2 mr-3 -translate-y-1/2 whitespace-nowrap text-[var(--font-size-xs)] text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
|
46
|
-
>
|
|
47
|
-
{formatTime(timestamp)}
|
|
48
|
-
</span>
|
|
49
|
-
) : null}
|
|
50
|
-
<div className="break-words whitespace-pre-wrap text-[var(--font-size-base)] leading-[1.5]">
|
|
51
|
-
{text}
|
|
52
|
-
</div>
|
|
53
|
-
</div>
|
|
54
|
-
{actions ? (
|
|
55
|
-
<div className="flex flex-wrap items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
|
56
|
-
{actions}
|
|
57
|
-
</div>
|
|
58
|
-
) : null}
|
|
59
|
-
</div>
|
|
71
|
+
<div className="space-y-2">
|
|
72
|
+
{segments.map((segment) => segment.synthetic ? (
|
|
73
|
+
<SyntheticText key={segment.index} text={segment.text} />
|
|
74
|
+
) : (
|
|
75
|
+
<UserBubble key={segment.index} text={segment.text} timestamp={timestamp} />
|
|
76
|
+
))}
|
|
77
|
+
{actions ? <MessageActions>{actions}</MessageActions> : null}
|
|
60
78
|
</div>
|
|
61
79
|
);
|
|
62
80
|
},
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest"
|
|
2
|
-
import { render } from "@testing-library/react"
|
|
1
|
+
import { describe, it, expect, vi } from "vitest"
|
|
2
|
+
import { fireEvent, render, waitFor } from "@testing-library/react"
|
|
3
3
|
import { RichFileTree } from "./rich-file-tree"
|
|
4
4
|
|
|
5
|
+
function treeRow(container: HTMLElement, path: string): HTMLElement {
|
|
6
|
+
const host = container.querySelector("file-tree-container");
|
|
7
|
+
const row = Array.from(host?.shadowRoot?.querySelectorAll("[data-item-path]") ?? [])
|
|
8
|
+
.find((element) => element.getAttribute("data-item-path") === path);
|
|
9
|
+
if (!(row instanceof HTMLElement)) throw new Error(`Missing tree row: ${path}`);
|
|
10
|
+
return row;
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
describe("RichFileTree", () => {
|
|
6
14
|
it("renders without crashing given a flat path list", () => {
|
|
7
15
|
const { container } = render(
|
|
@@ -34,6 +42,46 @@ describe("RichFileTree", () => {
|
|
|
34
42
|
expect(container.querySelectorAll("*").length).toBeGreaterThan(0)
|
|
35
43
|
})
|
|
36
44
|
|
|
45
|
+
it("calls the current selection callback after its owner rerenders", async () => {
|
|
46
|
+
const original = vi.fn();
|
|
47
|
+
const current = vi.fn();
|
|
48
|
+
const paths = ["brief.md", "outreach.md"];
|
|
49
|
+
const { container, rerender } = render(<RichFileTree paths={paths} onSelect={original} />);
|
|
50
|
+
rerender(<RichFileTree paths={paths} onSelect={current} />);
|
|
51
|
+
fireEvent.click(treeRow(container, "outreach.md"));
|
|
52
|
+
await waitFor(() => expect(current).toHaveBeenCalledWith("outreach.md"));
|
|
53
|
+
expect(original).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("clears controlled selection so a closed file can be selected again", async () => {
|
|
57
|
+
const onSelect = vi.fn();
|
|
58
|
+
const paths = ["brief.md", "outreach.md"];
|
|
59
|
+
const { container, rerender } = render(
|
|
60
|
+
<RichFileTree paths={paths} selectedPath="brief.md" onSelect={onSelect} />,
|
|
61
|
+
);
|
|
62
|
+
rerender(<RichFileTree paths={paths} selectedPath={undefined} onSelect={onSelect} />);
|
|
63
|
+
await waitFor(() => expect(treeRow(container, "brief.md")).toHaveAttribute("aria-selected", "false"));
|
|
64
|
+
fireEvent.click(treeRow(container, "brief.md"));
|
|
65
|
+
await waitFor(() => expect(onSelect).toHaveBeenCalledExactlyOnceWith("brief.md"));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("preserves uncontrolled selection when the file listing refreshes", async () => {
|
|
69
|
+
const { container, rerender } = render(<RichFileTree paths={["brief.md"]} />);
|
|
70
|
+
fireEvent.click(treeRow(container, "brief.md"));
|
|
71
|
+
await waitFor(() => expect(treeRow(container, "brief.md")).toHaveAttribute("aria-selected", "true"));
|
|
72
|
+
rerender(<RichFileTree paths={["brief.md", "outreach.md"]} />);
|
|
73
|
+
await waitFor(() => expect(treeRow(container, "brief.md")).toHaveAttribute("aria-selected", "true"));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not notify the owner when a controlled selection is synchronized", async () => {
|
|
77
|
+
const onSelect = vi.fn();
|
|
78
|
+
const paths = ["brief.md", "outreach.md"];
|
|
79
|
+
const { container, rerender } = render(<RichFileTree paths={paths} selectedPath="brief.md" onSelect={onSelect} />);
|
|
80
|
+
rerender(<RichFileTree paths={paths} selectedPath="outreach.md" onSelect={onSelect} />);
|
|
81
|
+
await waitFor(() => expect(treeRow(container, "outreach.md")).toHaveAttribute("aria-selected", "true"));
|
|
82
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
37
85
|
it("throws when both root and paths are passed", () => {
|
|
38
86
|
expect(() =>
|
|
39
87
|
render(<RichFileTree root={{ name: "x", path: "x", type: "file" }} paths={["y"]} />),
|
|
@@ -27,7 +27,7 @@ import type {
|
|
|
27
27
|
GitStatus,
|
|
28
28
|
GitStatusEntry,
|
|
29
29
|
} from "@pierre/trees";
|
|
30
|
-
import { useEffect, useMemo, type CSSProperties, type ReactNode } from "react";
|
|
30
|
+
import { useEffect, useMemo, useRef, type CSSProperties, type ReactNode } from "react";
|
|
31
31
|
import type { FileNode } from "./file-tree";
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -169,13 +169,22 @@ export function RichFileTree({
|
|
|
169
169
|
return [];
|
|
170
170
|
}, [paths, root]);
|
|
171
171
|
|
|
172
|
+
// Pierre creates its model once; event handlers must read the current owner.
|
|
173
|
+
const selectionRef = useRef({ selectedPath, onSelect });
|
|
174
|
+
selectionRef.current = { selectedPath, onSelect };
|
|
175
|
+
const syncingSelectionRef = useRef(false);
|
|
176
|
+
const previousSelectedPathRef = useRef(selectedPath);
|
|
177
|
+
|
|
172
178
|
const { model } = usePierreFileTree({
|
|
173
179
|
paths: flatPaths,
|
|
174
180
|
search,
|
|
175
181
|
initialExpansion,
|
|
176
182
|
onSelectionChange: (selected) => {
|
|
177
|
-
const next = selected[0]
|
|
178
|
-
|
|
183
|
+
const next = selected[0];
|
|
184
|
+
const current = selectionRef.current;
|
|
185
|
+
if (!syncingSelectionRef.current && next && next !== current.selectedPath) {
|
|
186
|
+
current.onSelect?.(next);
|
|
187
|
+
}
|
|
179
188
|
},
|
|
180
189
|
});
|
|
181
190
|
|
|
@@ -192,17 +201,21 @@ export function RichFileTree({
|
|
|
192
201
|
model.resetPaths(flatPaths);
|
|
193
202
|
}, [model, flatPaths]);
|
|
194
203
|
|
|
195
|
-
// Drive selection
|
|
196
|
-
// to avoid an emit loop with onSelectionChange.
|
|
204
|
+
// Drive controlled selection with Pierre's public item API, including clearing it.
|
|
197
205
|
useEffect(() => {
|
|
198
|
-
|
|
206
|
+
const previous = previousSelectedPathRef.current;
|
|
207
|
+
previousSelectedPathRef.current = selectedPath;
|
|
208
|
+
if (selectedPath === undefined && previous === undefined) return;
|
|
199
209
|
const current = model.getSelectedPaths();
|
|
200
|
-
if (current.length === 1 && current[0] === selectedPath) return;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
210
|
+
if (current.length === (selectedPath ? 1 : 0) && current[0] === selectedPath) return;
|
|
211
|
+
syncingSelectionRef.current = true;
|
|
212
|
+
try {
|
|
213
|
+
for (const path of [...current]) model.getItem(path)?.deselect();
|
|
214
|
+
if (selectedPath) model.getItem(selectedPath)?.select();
|
|
215
|
+
} finally {
|
|
216
|
+
syncingSelectionRef.current = false;
|
|
217
|
+
}
|
|
218
|
+
}, [model, selectedPath, flatPaths]);
|
|
206
219
|
|
|
207
220
|
const theme = { ...DEFAULT_THEME, ...themeOverrides };
|
|
208
221
|
const themeStyle = useMemo<CSSProperties>(
|