@tangle-network/ui 11.15.0 → 11.15.1
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 +6 -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/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/run/run-group.tsx +99 -64
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.15.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 34c2d3c: Retain non-empty synthetic text as labelled, plain-text Application notes in both transcript presentations. Preserve note ordering and synthetic-only messages without merging notes into user speech, parsing them as OpenUI, or counting them as agent responses. Existing public component props and callbacks are unchanged.
|
|
8
|
+
|
|
3
9
|
## 11.15.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -5,6 +5,7 @@ import { useRunGroups } from "../hooks/use-run-groups.js";
|
|
|
5
5
|
import { useRunCollapseState } from "../hooks/use-run-collapse-state.js";
|
|
6
6
|
import { useAutoScroll } from "../hooks/use-auto-scroll.js";
|
|
7
7
|
import { InlineThinkingItem } from "../run/inline-thinking-item.js";
|
|
8
|
+
import { SyntheticText, userTextSegments } from "./synthetic-text.js";
|
|
8
9
|
import { OpenUIArtifactRenderer } from "../openui/openui-artifact-renderer.js";
|
|
9
10
|
import { MessageList } from "./message-list.js";
|
|
10
11
|
import { AgentTimeline } from "./agent-timeline.js";
|
|
@@ -115,15 +116,25 @@ function buildTimelineItems(messages, partMap, isStreaming, onOpenUIAction, enab
|
|
|
115
116
|
for (const message of messages) {
|
|
116
117
|
const parts = partMap[message.id] ?? [];
|
|
117
118
|
if (message.role === "user") {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
119
|
+
const segments = userTextSegments(parts);
|
|
120
|
+
for (const segment of segments) {
|
|
121
|
+
const id = segments.length === 1 ? message.id : `${message.id}-user-${segment.index}`;
|
|
122
|
+
if (segment.synthetic) items.push({
|
|
123
|
+
id,
|
|
124
|
+
kind: "custom",
|
|
125
|
+
content: /* @__PURE__ */ jsx("div", {
|
|
126
|
+
className: "mx-3",
|
|
127
|
+
children: /* @__PURE__ */ jsx(SyntheticText, { text: segment.text })
|
|
128
|
+
})
|
|
129
|
+
});
|
|
130
|
+
else items.push({
|
|
131
|
+
id,
|
|
132
|
+
kind: "message",
|
|
133
|
+
role: "user",
|
|
134
|
+
content: segment.text.trim(),
|
|
135
|
+
timestamp: createdAtFromMessage(message)
|
|
136
|
+
});
|
|
137
|
+
}
|
|
127
138
|
continue;
|
|
128
139
|
}
|
|
129
140
|
const toolBuffer = [];
|
|
@@ -167,6 +178,17 @@ function buildTimelineItems(messages, partMap, isStreaming, onOpenUIAction, enab
|
|
|
167
178
|
return;
|
|
168
179
|
}
|
|
169
180
|
flushToolBuffer(index);
|
|
181
|
+
if (part.type === "text" && part.synthetic) {
|
|
182
|
+
if (part.text.trim()) items.push({
|
|
183
|
+
id: itemId,
|
|
184
|
+
kind: "custom",
|
|
185
|
+
content: /* @__PURE__ */ jsx("div", {
|
|
186
|
+
className: "mx-3",
|
|
187
|
+
children: /* @__PURE__ */ jsx(SyntheticText, { text: part.text })
|
|
188
|
+
})
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
170
192
|
if (part.type === "text" && !part.synthetic && part.text.trim()) {
|
|
171
193
|
if (enableOpenUI) {
|
|
172
194
|
const jsonMatch = part.text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
//#region src/chat/synthetic-text.tsx
|
|
3
|
+
/** Internal transcript chrome, never attributed to a user or model. */
|
|
4
|
+
function SyntheticText({ text }) {
|
|
5
|
+
if (!text.trim()) return null;
|
|
6
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
7
|
+
role: "note",
|
|
8
|
+
"aria-label": "Application note",
|
|
9
|
+
"data-synthetic-text": "",
|
|
10
|
+
className: "min-w-0 rounded-md border border-dashed border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground",
|
|
11
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
12
|
+
className: "mb-1 font-medium",
|
|
13
|
+
children: "Application note"
|
|
14
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
15
|
+
className: "break-words whitespace-pre-wrap",
|
|
16
|
+
children: text
|
|
17
|
+
})]
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/** Keep the existing joined user bubble, splitting only at application notes. */
|
|
21
|
+
function userTextSegments(parts) {
|
|
22
|
+
const segments = [];
|
|
23
|
+
let pending = [];
|
|
24
|
+
let start = 0;
|
|
25
|
+
const flush = () => {
|
|
26
|
+
const text = pending.join("\n");
|
|
27
|
+
if (text.trim()) segments.push({
|
|
28
|
+
index: start,
|
|
29
|
+
synthetic: false,
|
|
30
|
+
text
|
|
31
|
+
});
|
|
32
|
+
pending = [];
|
|
33
|
+
};
|
|
34
|
+
parts.forEach((part, index) => {
|
|
35
|
+
if (part.type !== "text") return;
|
|
36
|
+
if (part.synthetic) {
|
|
37
|
+
flush();
|
|
38
|
+
if (part.text.trim()) segments.push({
|
|
39
|
+
index,
|
|
40
|
+
synthetic: true,
|
|
41
|
+
text: part.text
|
|
42
|
+
});
|
|
43
|
+
} else {
|
|
44
|
+
if (pending.length === 0) start = index;
|
|
45
|
+
pending.push(part.text);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
flush();
|
|
49
|
+
return segments;
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
export { SyntheticText, userTextSegments };
|
|
@@ -12,10 +12,8 @@ export interface UserMessageProps {
|
|
|
12
12
|
actions?: ReactNode;
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* the run/message list (session-model `parts`) and AgentTimeline (direct
|
|
18
|
-
* `content`/`timestamp`).
|
|
15
|
+
* User speech retains the inverse-theme bubble and direct-content contract.
|
|
16
|
+
* Application notes interrupt the bubble in source order, outside its attribution.
|
|
19
17
|
*/
|
|
20
18
|
export declare const UserMessage: import("react").MemoExoticComponent<({ message: _message, parts, content, timestamp, actions }: UserMessageProps) => import("react").JSX.Element | null>;
|
|
21
19
|
//#endregion
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SyntheticText, userTextSegments } from "./synthetic-text.js";
|
|
1
2
|
import { memo } from "react";
|
|
2
3
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
4
|
//#region src/chat/user-message.tsx
|
|
@@ -7,15 +8,13 @@ function formatTime(date) {
|
|
|
7
8
|
minute: "2-digit"
|
|
8
9
|
});
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const text = content ?? (parts ?? []).filter((p) => p.type === "text").map((p) => p.text).join("\n");
|
|
18
|
-
if (!text.trim()) return null;
|
|
11
|
+
function MessageActions({ children }) {
|
|
12
|
+
return /* @__PURE__ */ jsx("div", {
|
|
13
|
+
className: "flex flex-wrap items-center justify-end gap-1.5 text-xs text-muted-foreground",
|
|
14
|
+
children
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function UserBubble({ text, timestamp, actions }) {
|
|
19
18
|
return /* @__PURE__ */ jsx("div", {
|
|
20
19
|
className: "flex justify-end",
|
|
21
20
|
children: /* @__PURE__ */ jsxs("div", {
|
|
@@ -30,12 +29,34 @@ const UserMessage = memo(({ message: _message, parts, content, timestamp, action
|
|
|
30
29
|
className: "break-words whitespace-pre-wrap text-[var(--font-size-base)] leading-[1.5]",
|
|
31
30
|
children: text
|
|
32
31
|
})]
|
|
33
|
-
}), actions ? /* @__PURE__ */ jsx(
|
|
34
|
-
className: "flex flex-wrap items-center justify-end gap-1.5 text-xs text-muted-foreground",
|
|
35
|
-
children: actions
|
|
36
|
-
}) : null]
|
|
32
|
+
}), actions ? /* @__PURE__ */ jsx(MessageActions, { children: actions }) : null]
|
|
37
33
|
})
|
|
38
34
|
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* User speech retains the inverse-theme bubble and direct-content contract.
|
|
38
|
+
* Application notes interrupt the bubble in source order, outside its attribution.
|
|
39
|
+
*/
|
|
40
|
+
const UserMessage = memo(({ message: _message, parts, content, timestamp, actions }) => {
|
|
41
|
+
if (content != null) return content.trim() ? /* @__PURE__ */ jsx(UserBubble, {
|
|
42
|
+
text: content,
|
|
43
|
+
timestamp,
|
|
44
|
+
actions
|
|
45
|
+
}) : null;
|
|
46
|
+
const segments = userTextSegments(parts ?? []);
|
|
47
|
+
if (segments.length === 0) return null;
|
|
48
|
+
if (segments.length === 1 && !segments[0].synthetic) return /* @__PURE__ */ jsx(UserBubble, {
|
|
49
|
+
text: segments[0].text,
|
|
50
|
+
timestamp,
|
|
51
|
+
actions
|
|
52
|
+
});
|
|
53
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
54
|
+
className: "space-y-2",
|
|
55
|
+
children: [segments.map((segment) => segment.synthetic ? /* @__PURE__ */ jsx(SyntheticText, { text: segment.text }, segment.index) : /* @__PURE__ */ jsx(UserBubble, {
|
|
56
|
+
text: segment.text,
|
|
57
|
+
timestamp
|
|
58
|
+
}, segment.index)), actions ? /* @__PURE__ */ jsx(MessageActions, { children: actions }) : null]
|
|
59
|
+
});
|
|
39
60
|
});
|
|
40
61
|
UserMessage.displayName = "UserMessage";
|
|
41
62
|
//#endregion
|
package/dist/run/run-group.js
CHANGED
|
@@ -3,6 +3,7 @@ import { formatDuration } from "../utils/format.js";
|
|
|
3
3
|
import { InlineToolItem } from "./inline-tool-item.js";
|
|
4
4
|
import { Markdown } from "../markdown/markdown.js";
|
|
5
5
|
import { InlineThinkingItem } from "./inline-thinking-item.js";
|
|
6
|
+
import { SyntheticText } from "../chat/synthetic-text.js";
|
|
6
7
|
import { OpenUIArtifactRenderer } from "../openui/openui-artifact-renderer.js";
|
|
7
8
|
import { memo, useMemo } from "react";
|
|
8
9
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -168,81 +169,107 @@ const RunGroup = memo(({ run, partMap, collapsed, onToggle, branding = DEFAULT_B
|
|
|
168
169
|
return parts;
|
|
169
170
|
}, [run.messages, partMap]);
|
|
170
171
|
const { stats, isStreaming } = run;
|
|
171
|
-
|
|
172
|
+
const hasRenderableParts = allParts.some(({ part }) => {
|
|
172
173
|
if (part.type === "tool" || part.type === "reasoning") return true;
|
|
173
|
-
return part.type === "text" &&
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
]
|
|
197
|
-
})
|
|
174
|
+
return part.type === "text" && part.text.trim().length > 0;
|
|
175
|
+
});
|
|
176
|
+
const pendingResponse = /* @__PURE__ */ jsx(AssistantShell, {
|
|
177
|
+
branding,
|
|
178
|
+
isStreaming: true,
|
|
179
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
180
|
+
className: "flex items-center gap-2 px-0.5 py-0.5 text-sm text-[var(--text-muted)]",
|
|
181
|
+
children: /* @__PURE__ */ jsxs("span", {
|
|
182
|
+
className: "flex gap-[5px]",
|
|
183
|
+
children: [
|
|
184
|
+
/* @__PURE__ */ jsx("span", {
|
|
185
|
+
className: "h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]",
|
|
186
|
+
style: { animationDelay: "0ms" }
|
|
187
|
+
}),
|
|
188
|
+
/* @__PURE__ */ jsx("span", {
|
|
189
|
+
className: "h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]",
|
|
190
|
+
style: { animationDelay: "150ms" }
|
|
191
|
+
}),
|
|
192
|
+
/* @__PURE__ */ jsx("span", {
|
|
193
|
+
className: "h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]",
|
|
194
|
+
style: { animationDelay: "300ms" }
|
|
195
|
+
})
|
|
196
|
+
]
|
|
198
197
|
})
|
|
199
|
-
})
|
|
200
|
-
}
|
|
198
|
+
})
|
|
199
|
+
});
|
|
200
|
+
if (!hasRenderableParts) return isStreaming ? pendingResponse : null;
|
|
201
201
|
if (!allParts.some(({ part }) => {
|
|
202
202
|
if (part.type === "reasoning") return true;
|
|
203
203
|
if (part.type === "tool") return !isOpenUITool(part);
|
|
204
204
|
return false;
|
|
205
|
-
}))
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
205
|
+
})) {
|
|
206
|
+
const segments = [];
|
|
207
|
+
let lastSourceSegment = -1;
|
|
208
|
+
for (const entry of allParts) {
|
|
209
|
+
const { part, msgId, index } = entry;
|
|
210
|
+
if (part.type === "text" && !part.text.trim()) continue;
|
|
209
211
|
const key = `${msgId}-${index}`;
|
|
210
|
-
if (part.type === "
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (toolPart.state.status === "running") return /* @__PURE__ */ jsxs("div", {
|
|
225
|
-
className: "flex items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)] px-4 py-3 text-sm text-muted-foreground",
|
|
226
|
-
children: [/* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin text-primary" }), "Building view…"]
|
|
227
|
-
}, key);
|
|
212
|
+
if (part.type === "text" && part.synthetic) segments.push({
|
|
213
|
+
kind: "note",
|
|
214
|
+
key,
|
|
215
|
+
text: part.text
|
|
216
|
+
});
|
|
217
|
+
else {
|
|
218
|
+
const previous = segments[segments.length - 1];
|
|
219
|
+
if (previous?.kind === "source") previous.entries.push(entry);
|
|
220
|
+
else segments.push({
|
|
221
|
+
kind: "source",
|
|
222
|
+
key,
|
|
223
|
+
entries: [entry]
|
|
224
|
+
});
|
|
225
|
+
lastSourceSegment = segments.length - 1;
|
|
228
226
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
227
|
+
}
|
|
228
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
229
|
+
className: "space-y-3",
|
|
230
|
+
children: [segments.map((segment, segmentIndex) => segment.kind === "note" ? /* @__PURE__ */ jsx(SyntheticText, { text: segment.text }, segment.key) : /* @__PURE__ */ jsx(AssistantShell, {
|
|
231
|
+
branding,
|
|
232
|
+
isStreaming: isStreaming && segmentIndex === lastSourceSegment,
|
|
233
|
+
children: segment.entries.map(({ part, msgId, index }) => {
|
|
234
|
+
const key = `${msgId}-${index}`;
|
|
235
|
+
if (part.type === "tool" && isOpenUITool(part)) {
|
|
236
|
+
const toolPart = part;
|
|
237
|
+
const schema = extractOpenUISchema(toolPart.state.output);
|
|
238
|
+
const summary = getOpenUISummary(toolPart.state.output);
|
|
239
|
+
if (toolPart.state.status === "completed" && schema) return /* @__PURE__ */ jsxs("div", {
|
|
240
|
+
className: "overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)]",
|
|
241
|
+
children: [summary ? /* @__PURE__ */ jsx("div", {
|
|
242
|
+
className: "border-b border-[var(--border-subtle)] px-4 py-3 text-sm leading-6 text-foreground",
|
|
243
|
+
children: summary
|
|
244
|
+
}) : null, /* @__PURE__ */ jsx("div", {
|
|
245
|
+
className: "p-4",
|
|
246
|
+
children: /* @__PURE__ */ jsx(OpenUIArtifactRenderer, { schema })
|
|
247
|
+
})]
|
|
248
|
+
}, key);
|
|
249
|
+
if (toolPart.state.status === "running") return /* @__PURE__ */ jsxs("div", {
|
|
250
|
+
className: "flex items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)] px-4 py-3 text-sm text-muted-foreground",
|
|
251
|
+
children: [/* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin text-primary" }), "Building view…"]
|
|
252
|
+
}, key);
|
|
253
|
+
}
|
|
254
|
+
if (part.type === "text" && !part.synthetic && part.text.trim()) return /* @__PURE__ */ jsx("div", {
|
|
255
|
+
className: "px-0.5",
|
|
256
|
+
children: /* @__PURE__ */ jsx(Markdown, {
|
|
257
|
+
className: "tangle-prose text-[15px] leading-7 text-[var(--text-primary)]",
|
|
258
|
+
children: part.text
|
|
259
|
+
})
|
|
260
|
+
}, key);
|
|
261
|
+
return null;
|
|
234
262
|
})
|
|
235
|
-
}, key)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
});
|
|
263
|
+
}, segment.key)), isStreaming && lastSourceSegment === -1 ? pendingResponse : null]
|
|
264
|
+
});
|
|
265
|
+
}
|
|
239
266
|
const rows = allParts.filter(({ part }) => {
|
|
240
267
|
if (part.type === "tool" || part.type === "reasoning") return true;
|
|
241
|
-
return part.type === "text" &&
|
|
268
|
+
return part.type === "text" && part.text.trim().length > 0;
|
|
242
269
|
});
|
|
243
270
|
const dotAccent = (part) => {
|
|
244
271
|
if (part.type === "reasoning") return "bg-[var(--brand-glow)]";
|
|
245
|
-
if (part.type === "text") return "bg-primary";
|
|
272
|
+
if (part.type === "text" && !part.synthetic) return "bg-primary";
|
|
246
273
|
return "bg-[var(--border-hover)]";
|
|
247
274
|
};
|
|
248
275
|
return /* @__PURE__ */ jsx(Collapsible.Root, {
|
|
@@ -334,7 +361,7 @@ const RunGroup = memo(({ run, partMap, collapsed, onToggle, branding = DEFAULT_B
|
|
|
334
361
|
part,
|
|
335
362
|
defaultOpen: isStreaming
|
|
336
363
|
});
|
|
337
|
-
else if (part.type === "text" &&
|
|
364
|
+
else if (part.type === "text" && part.text.trim()) node = part.synthetic ? /* @__PURE__ */ jsx(SyntheticText, { text: part.text }) : /* @__PURE__ */ jsx("div", {
|
|
338
365
|
className: "px-1 py-0.5",
|
|
339
366
|
children: /* @__PURE__ */ jsx(Markdown, {
|
|
340
367
|
className: "tangle-prose text-[15px] leading-7",
|
package/package.json
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import type { SessionMessage } from "../types/message";
|
|
4
|
+
import type { SessionPart } from "../types/parts";
|
|
5
|
+
import { Button } from "../primitives/button";
|
|
6
|
+
import { ChatContainer } from "./chat-container";
|
|
7
|
+
|
|
8
|
+
const messages: SessionMessage[] = [
|
|
9
|
+
{ id: "user", role: "user" },
|
|
10
|
+
{ id: "assistant", role: "assistant" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
type Props = { presentation: "runs" | "timeline"; state: "mixed" | "notes-only" | "pending" };
|
|
14
|
+
function TranscriptNotes({ presentation, state }: Props) {
|
|
15
|
+
const [appended, setAppended] = useState(false);
|
|
16
|
+
const [inspected, setInspected] = useState<string | null>(null);
|
|
17
|
+
const partMap: Record<string, SessionPart[]> = state === "mixed" ? {
|
|
18
|
+
user: [
|
|
19
|
+
{ type: "text", text: "Inspect the available worker results." },
|
|
20
|
+
{ type: "text", synthetic: true, text: "Delivery receipt recorded by the application." },
|
|
21
|
+
{ type: "text", text: "Tell me which evidence is missing." },
|
|
22
|
+
],
|
|
23
|
+
assistant: [
|
|
24
|
+
{ type: "text", text: "I will inspect the retained result files." },
|
|
25
|
+
{ type: "tool", id: "first", tool: "read", state: { status: "completed", input: { path: "results/first.json" }, output: "First worker completed." } },
|
|
26
|
+
{ type: "text", synthetic: true, text: "No tool trace was captured for the follow-up worker." },
|
|
27
|
+
{ type: "tool", id: "second", tool: "read", state: { status: "error", input: { path: "results/second.json" }, error: "No retained result exists." } },
|
|
28
|
+
{ type: "text", text: "Only the first worker has a retained result. The follow-up remains unverified." },
|
|
29
|
+
],
|
|
30
|
+
} : {
|
|
31
|
+
user: state === "pending" ? [{ type: "text", text: "Inspect the worker results." }] : [],
|
|
32
|
+
assistant: [{ type: "text", synthetic: true, text: state === "pending" ? "Request accepted; no agent response received yet." : "Task not persisted. No agent response or tool trace is available." }],
|
|
33
|
+
};
|
|
34
|
+
if (appended) partMap.assistant.push({ type: "text", synthetic: true, text: "Delivery metadata added locally." });
|
|
35
|
+
return (
|
|
36
|
+
<div className="mx-auto w-full max-w-3xl space-y-4 p-4">
|
|
37
|
+
<p className="text-sm text-muted-foreground">Example transcript data. These controls update local story state only.</p>
|
|
38
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
39
|
+
<Button type="button" onClick={() => setAppended(true)} disabled={appended}>Append application note</Button>
|
|
40
|
+
<span role="status">{inspected ? `Inspected ${inspected}` : "No tool selected"}</span>
|
|
41
|
+
</div>
|
|
42
|
+
<ChatContainer
|
|
43
|
+
messages={messages}
|
|
44
|
+
partMap={partMap}
|
|
45
|
+
isStreaming={state === "pending"}
|
|
46
|
+
presentation={presentation}
|
|
47
|
+
renderToolActions={(part) => <Button type="button" size="sm" variant="ghost" onClick={() => setInspected(part.id)}>Inspect {part.id}</Button>}
|
|
48
|
+
renderTimelineToolActions={(part) => <Button type="button" size="sm" variant="ghost" onClick={() => setInspected(part.id)}>Inspect {part.id}</Button>}
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const meta: Meta<typeof TranscriptNotes> = {
|
|
55
|
+
title: "Chat/Transcript notes",
|
|
56
|
+
component: TranscriptNotes,
|
|
57
|
+
parameters: { layout: "fullscreen" },
|
|
58
|
+
args: { presentation: "runs", state: "mixed" },
|
|
59
|
+
};
|
|
60
|
+
export default meta;
|
|
61
|
+
type Story = StoryObj<typeof TranscriptNotes>;
|
|
62
|
+
|
|
63
|
+
const checkMixed: NonNullable<Story["play"]> = async ({ canvasElement }) => {
|
|
64
|
+
const notes = canvasElement.querySelectorAll('[role="note"]');
|
|
65
|
+
if (notes.length !== 2) throw new Error("Expected both retained application notes");
|
|
66
|
+
for (const note of notes) {
|
|
67
|
+
if (note.closest('.bg-foreground, .tangle-prose, [class*="rounded-[26px]"]')) {
|
|
68
|
+
throw new Error("Application note was attributed to a speaker bubble");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const append = Array.from(canvasElement.querySelectorAll("button")).find((button) => button.textContent === "Append application note");
|
|
72
|
+
if (!append) throw new Error("Missing story action");
|
|
73
|
+
append.click();
|
|
74
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
75
|
+
if (canvasElement.querySelectorAll('[role="note"]').length !== 3) throw new Error("Appended note was lost");
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const MixedRuns: Story = { play: checkMixed };
|
|
79
|
+
export const MixedTimeline: Story = { args: { presentation: "timeline" }, play: checkMixed };
|
|
80
|
+
export const NotesOnly: Story = { args: { state: "notes-only" } };
|
|
81
|
+
export const PendingRuns: Story = { args: { state: "pending" } };
|
|
82
|
+
export const PendingTimeline: Story = { args: { presentation: "timeline", state: "pending" } };
|
|
@@ -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
|
},
|
package/src/run/run-group.tsx
CHANGED
|
@@ -25,6 +25,7 @@ import type { CustomToolRenderer } from "../types/tool-display";
|
|
|
25
25
|
import { InlineToolItem } from "./inline-tool-item";
|
|
26
26
|
import { InlineThinkingItem } from "./inline-thinking-item";
|
|
27
27
|
import { Markdown } from "../markdown/markdown";
|
|
28
|
+
import { SyntheticText } from "../chat/synthetic-text";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* One row on the run's timeline spine: a connector line + accent dot in a
|
|
@@ -319,25 +320,23 @@ export const RunGroup = memo(
|
|
|
319
320
|
return true;
|
|
320
321
|
}
|
|
321
322
|
|
|
322
|
-
return part.type === "text" &&
|
|
323
|
+
return part.type === "text" && part.text.trim().length > 0;
|
|
323
324
|
});
|
|
324
325
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
326
|
+
const pendingResponse = (
|
|
327
|
+
<AssistantShell branding={branding} isStreaming={true}>
|
|
328
|
+
<div className="flex items-center gap-2 px-0.5 py-0.5 text-sm text-[var(--text-muted)]">
|
|
329
|
+
<span className="flex gap-[5px]">
|
|
330
|
+
<span className="h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]" style={{ animationDelay: "0ms" }} />
|
|
331
|
+
<span className="h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]" style={{ animationDelay: "150ms" }} />
|
|
332
|
+
<span className="h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]" style={{ animationDelay: "300ms" }} />
|
|
333
|
+
</span>
|
|
334
|
+
</div>
|
|
335
|
+
</AssistantShell>
|
|
336
|
+
);
|
|
329
337
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
<div className="flex items-center gap-2 px-0.5 py-0.5 text-sm text-[var(--text-muted)]">
|
|
333
|
-
<span className="flex gap-[5px]">
|
|
334
|
-
<span className="h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]" style={{ animationDelay: "0ms" }} />
|
|
335
|
-
<span className="h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]" style={{ animationDelay: "150ms" }} />
|
|
336
|
-
<span className="h-2 w-2 animate-bounce rounded-full bg-[var(--brand-glow)]" style={{ animationDelay: "300ms" }} />
|
|
337
|
-
</span>
|
|
338
|
-
</div>
|
|
339
|
-
</AssistantShell>
|
|
340
|
-
);
|
|
338
|
+
if (!hasRenderableParts) {
|
|
339
|
+
return isStreaming ? pendingResponse : null;
|
|
341
340
|
}
|
|
342
341
|
|
|
343
342
|
const showTraceChrome = allParts.some(({ part }) => {
|
|
@@ -353,72 +352,106 @@ export const RunGroup = memo(
|
|
|
353
352
|
});
|
|
354
353
|
|
|
355
354
|
if (!showTraceChrome) {
|
|
355
|
+
// Notes split authored blocks rather than living inside an Agent bubble.
|
|
356
|
+
// Original message/part indices remain available for keys and callbacks.
|
|
357
|
+
const segments: Array<
|
|
358
|
+
| { kind: "note"; key: string; text: string }
|
|
359
|
+
| { kind: "source"; key: string; entries: typeof allParts }
|
|
360
|
+
> = [];
|
|
361
|
+
let lastSourceSegment = -1;
|
|
362
|
+
for (const entry of allParts) {
|
|
363
|
+
const { part, msgId, index } = entry;
|
|
364
|
+
if (part.type === "text" && !part.text.trim()) continue;
|
|
365
|
+
const key = `${msgId}-${index}`;
|
|
366
|
+
if (part.type === "text" && part.synthetic) {
|
|
367
|
+
segments.push({ kind: "note", key, text: part.text });
|
|
368
|
+
} else {
|
|
369
|
+
const previous = segments[segments.length - 1];
|
|
370
|
+
if (previous?.kind === "source") {
|
|
371
|
+
previous.entries.push(entry);
|
|
372
|
+
} else {
|
|
373
|
+
segments.push({ kind: "source", key, entries: [entry] });
|
|
374
|
+
}
|
|
375
|
+
lastSourceSegment = segments.length - 1;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
356
378
|
return (
|
|
357
|
-
<
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
379
|
+
<div className="space-y-3">
|
|
380
|
+
{segments.map((segment, segmentIndex) => segment.kind === "note" ? (
|
|
381
|
+
<SyntheticText key={segment.key} text={segment.text} />
|
|
382
|
+
) : (
|
|
383
|
+
<AssistantShell
|
|
384
|
+
key={segment.key}
|
|
385
|
+
branding={branding}
|
|
386
|
+
isStreaming={isStreaming && segmentIndex === lastSourceSegment}
|
|
387
|
+
>
|
|
388
|
+
{segment.entries.map(({ part, msgId, index }) => {
|
|
389
|
+
const key = `${msgId}-${index}`;
|
|
365
390
|
|
|
366
|
-
if (
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
391
|
+
if (part.type === "tool" && isOpenUITool(part as ToolPart)) {
|
|
392
|
+
const toolPart = part as ToolPart;
|
|
393
|
+
const schema = extractOpenUISchema(toolPart.state.output);
|
|
394
|
+
const summary = getOpenUISummary(toolPart.state.output);
|
|
395
|
+
|
|
396
|
+
if (toolPart.state.status === "completed" && schema) {
|
|
397
|
+
return (
|
|
398
|
+
<div
|
|
399
|
+
key={key}
|
|
400
|
+
className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)]"
|
|
401
|
+
>
|
|
402
|
+
{summary ? (
|
|
403
|
+
<div className="border-b border-[var(--border-subtle)] px-4 py-3 text-sm leading-6 text-foreground">
|
|
404
|
+
{summary}
|
|
405
|
+
</div>
|
|
406
|
+
) : null}
|
|
407
|
+
<div className="p-4">
|
|
408
|
+
<OpenUIArtifactRenderer schema={schema} />
|
|
375
409
|
</div>
|
|
376
|
-
) : null}
|
|
377
|
-
<div className="p-4">
|
|
378
|
-
<OpenUIArtifactRenderer schema={schema} />
|
|
379
410
|
</div>
|
|
380
|
-
|
|
381
|
-
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (toolPart.state.status === "running") {
|
|
415
|
+
return (
|
|
416
|
+
<div
|
|
417
|
+
key={key}
|
|
418
|
+
className="flex items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--md3-surface-container)] px-4 py-3 text-sm text-muted-foreground"
|
|
419
|
+
>
|
|
420
|
+
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
|
421
|
+
Building view…
|
|
422
|
+
</div>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
382
425
|
}
|
|
383
426
|
|
|
384
|
-
if (
|
|
427
|
+
if (part.type === "text" && !part.synthetic && part.text.trim()) {
|
|
385
428
|
return (
|
|
386
|
-
<div
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
|
391
|
-
Building view…
|
|
429
|
+
<div key={key} className="px-0.5">
|
|
430
|
+
<Markdown className="tangle-prose text-[15px] leading-7 text-[var(--text-primary)]">
|
|
431
|
+
{part.text}
|
|
432
|
+
</Markdown>
|
|
392
433
|
</div>
|
|
393
434
|
);
|
|
394
435
|
}
|
|
395
|
-
}
|
|
396
436
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
</div>
|
|
404
|
-
);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
return null;
|
|
408
|
-
})}
|
|
409
|
-
</AssistantShell>
|
|
437
|
+
return null;
|
|
438
|
+
})}
|
|
439
|
+
</AssistantShell>
|
|
440
|
+
))}
|
|
441
|
+
{isStreaming && lastSourceSegment === -1 ? pendingResponse : null}
|
|
442
|
+
</div>
|
|
410
443
|
);
|
|
411
444
|
}
|
|
412
445
|
|
|
413
|
-
//
|
|
446
|
+
// Keep notes in source order; response statistics still count authored text only.
|
|
414
447
|
const rows = allParts.filter(({ part }) => {
|
|
415
448
|
if (part.type === "tool" || part.type === "reasoning") return true;
|
|
416
|
-
return part.type === "text" &&
|
|
449
|
+
return part.type === "text" && part.text.trim().length > 0;
|
|
417
450
|
});
|
|
418
451
|
|
|
419
452
|
const dotAccent = (part: SessionPart): string => {
|
|
420
453
|
if (part.type === "reasoning") return "bg-[var(--brand-glow)]";
|
|
421
|
-
if (part.type === "text") return "bg-primary";
|
|
454
|
+
if (part.type === "text" && !part.synthetic) return "bg-primary";
|
|
422
455
|
return "bg-[var(--border-hover)]";
|
|
423
456
|
};
|
|
424
457
|
|
|
@@ -532,8 +565,10 @@ export const RunGroup = memo(
|
|
|
532
565
|
node = (
|
|
533
566
|
<InlineThinkingItem part={part as ReasoningPart} defaultOpen={isStreaming} />
|
|
534
567
|
);
|
|
535
|
-
} else if (part.type === "text" &&
|
|
536
|
-
node = (
|
|
568
|
+
} else if (part.type === "text" && part.text.trim()) {
|
|
569
|
+
node = part.synthetic ? (
|
|
570
|
+
<SyntheticText text={part.text} />
|
|
571
|
+
) : (
|
|
537
572
|
<div className="px-1 py-0.5">
|
|
538
573
|
<Markdown className="tangle-prose text-[15px] leading-7">{part.text}</Markdown>
|
|
539
574
|
</div>
|