@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.15.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 11d515d: Keep file-tree selection callbacks current after rerenders and synchronize controlled selection through the native item API.
|
|
8
|
+
Closing a file clears its selection so the same file can be opened again.
|
|
9
|
+
|
|
10
|
+
## 11.15.1
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
3
16
|
## 11.15.0
|
|
4
17
|
|
|
5
18
|
### 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
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo } from "react";
|
|
1
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import { jsx } from "react/jsx-runtime";
|
|
3
3
|
import { FileTree, useFileTree } from "@pierre/trees/react";
|
|
4
4
|
//#region src/files/rich-file-tree.tsx
|
|
@@ -66,13 +66,24 @@ function RichFileTree({ root, paths, selectedPath, onSelect, search = true, init
|
|
|
66
66
|
if (root) return flattenFileNode(root);
|
|
67
67
|
return [];
|
|
68
68
|
}, [paths, root]);
|
|
69
|
+
const selectionRef = useRef({
|
|
70
|
+
selectedPath,
|
|
71
|
+
onSelect
|
|
72
|
+
});
|
|
73
|
+
selectionRef.current = {
|
|
74
|
+
selectedPath,
|
|
75
|
+
onSelect
|
|
76
|
+
};
|
|
77
|
+
const syncingSelectionRef = useRef(false);
|
|
78
|
+
const previousSelectedPathRef = useRef(selectedPath);
|
|
69
79
|
const { model } = useFileTree({
|
|
70
80
|
paths: flatPaths,
|
|
71
81
|
search,
|
|
72
82
|
initialExpansion,
|
|
73
83
|
onSelectionChange: (selected) => {
|
|
74
84
|
const next = selected[0];
|
|
75
|
-
|
|
85
|
+
const current = selectionRef.current;
|
|
86
|
+
if (!syncingSelectionRef.current && next && next !== current.selectedPath) current.onSelect?.(next);
|
|
76
87
|
}
|
|
77
88
|
});
|
|
78
89
|
useEffect(() => {
|
|
@@ -83,11 +94,23 @@ function RichFileTree({ root, paths, selectedPath, onSelect, search = true, init
|
|
|
83
94
|
model.resetPaths(flatPaths);
|
|
84
95
|
}, [model, flatPaths]);
|
|
85
96
|
useEffect(() => {
|
|
86
|
-
|
|
97
|
+
const previous = previousSelectedPathRef.current;
|
|
98
|
+
previousSelectedPathRef.current = selectedPath;
|
|
99
|
+
if (selectedPath === void 0 && previous === void 0) return;
|
|
87
100
|
const current = model.getSelectedPaths();
|
|
88
|
-
if (current.length === 1 && current[0] === selectedPath) return;
|
|
89
|
-
|
|
90
|
-
|
|
101
|
+
if (current.length === (selectedPath ? 1 : 0) && current[0] === selectedPath) return;
|
|
102
|
+
syncingSelectionRef.current = true;
|
|
103
|
+
try {
|
|
104
|
+
for (const path of [...current]) model.getItem(path)?.deselect();
|
|
105
|
+
if (selectedPath) model.getItem(selectedPath)?.select();
|
|
106
|
+
} finally {
|
|
107
|
+
syncingSelectionRef.current = false;
|
|
108
|
+
}
|
|
109
|
+
}, [
|
|
110
|
+
model,
|
|
111
|
+
selectedPath,
|
|
112
|
+
flatPaths
|
|
113
|
+
]);
|
|
91
114
|
const theme = {
|
|
92
115
|
...DEFAULT_THEME,
|
|
93
116
|
...themeOverrides
|
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" } };
|