@agents24/chat-react 0.5.5 → 0.5.7
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/dist/index.cjs +11 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +11 -6
- package/dist/index.js.map +1 -1
- package/dist/latest-thread-scroller.d.ts +2 -1
- package/dist/runtime/index.cjs +17 -11
- package/dist/runtime/index.cjs.map +1 -1
- package/dist/runtime/index.js +17 -11
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/use-agent-chat-runtime.d.ts +7 -2
- package/dist/scaffold/components/chat/chat-composer.d.ts +2 -1
- package/dist/scaffold/components/chat/chat-message.d.ts +3 -1
- package/dist/scaffold/components/chat/turn-outline.d.ts +1 -1
- package/dist/scaffold/index.cjs +195 -154
- package/dist/scaffold/index.cjs.map +1 -1
- package/dist/scaffold/index.js +215 -168
- package/dist/scaffold/index.js.map +1 -1
- package/dist/styles.css +25 -6
- package/dist/ui/adapters.d.ts +14 -2
- package/dist/ui/agent-chat-composer.d.ts +2 -9
- package/dist/ui/agent-chat-message.d.ts +7 -1
- package/dist/ui/composer-attachments.d.ts +11 -0
- package/dist/ui/index.cjs +781 -389
- package/dist/ui/index.cjs.map +1 -1
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +766 -375
- package/dist/ui/index.js.map +1 -1
- package/package.json +4 -3
- package/scaffold/src/components/chat/chat-composer.tsx +43 -19
- package/scaffold/src/components/chat/chat-message.tsx +19 -10
- package/scaffold/src/components/chat/chat-shell.tsx +65 -34
- package/scaffold/src/components/chat/turn-outline.tsx +17 -59
- package/scaffold/src/components/ui/message-scroller.tsx +1 -1
- package/scaffold/src/components/ui/message.tsx +1 -1
- package/scaffold/src/components/ui/sidebar.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agents24/chat-react",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "Accessible web UI and complete default layout for Agents24 chat applications.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -58,8 +58,9 @@
|
|
|
58
58
|
"prepack": "pnpm run build && pnpm run check:boundaries"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@
|
|
62
|
-
"@agents24/
|
|
61
|
+
"@untitledui/file-icons": "0.0.9",
|
|
62
|
+
"@agents24/client": "0.2.4",
|
|
63
|
+
"@agents24/react": "0.2.4",
|
|
63
64
|
"@fontsource-variable/inter": "^5.3.0",
|
|
64
65
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
|
65
66
|
"@fontsource-variable/merriweather": "^5.3.0",
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import { ArrowUp, ChevronDown, LoaderCircle, Mic, Square
|
|
1
|
+
import { ArrowUp, ChevronDown, LoaderCircle, Mic, Square } from "lucide-react"
|
|
2
2
|
import * as React from "react"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AgentChatContextStatus,
|
|
5
|
+
ChatAttachmentRows,
|
|
6
|
+
createAgentChatComposerFile,
|
|
7
|
+
revokeAgentChatComposerFiles,
|
|
8
|
+
useAudioRecorder,
|
|
9
|
+
type AgentChatComposerFile,
|
|
10
|
+
} from "@agents24/chat-react/ui"
|
|
4
11
|
import { canonicalInputMimeType, inputMimeTypes, validateAgentChatSubmission, type AgentChatInputContract } from "@agents24/chat-react/runtime"
|
|
5
12
|
import type { ContextWindow } from "@agents24/client/protocol"
|
|
6
13
|
|
|
@@ -29,7 +36,7 @@ type ChatComposerProps = {
|
|
|
29
36
|
onModelChange: (modelId: string) => void
|
|
30
37
|
onAgentChange: (agentId: string) => void
|
|
31
38
|
onStop: () => void
|
|
32
|
-
onSubmit: (text: string, files:
|
|
39
|
+
onSubmit: (text: string, files: AgentChatComposerFile[]) => Promise<void>
|
|
33
40
|
utilityRow?: "inline" | "below"
|
|
34
41
|
}
|
|
35
42
|
|
|
@@ -52,15 +59,24 @@ export function ChatComposer({
|
|
|
52
59
|
utilityRow = "inline",
|
|
53
60
|
}: ChatComposerProps) {
|
|
54
61
|
const [text, setText] = React.useState("")
|
|
55
|
-
const [files, setFiles] = React.useState<
|
|
62
|
+
const [files, setFiles] = React.useState<AgentChatComposerFile[]>([])
|
|
56
63
|
const [attachmentError, setAttachmentError] = React.useState<string | null>(null)
|
|
57
64
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
58
65
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
|
|
66
|
+
const filesRef = React.useRef<AgentChatComposerFile[]>([])
|
|
67
|
+
const inFlightFilesRef = React.useRef<AgentChatComposerFile[]>([])
|
|
59
68
|
const submittingRef = React.useRef(false)
|
|
60
69
|
const draftRevisionRef = React.useRef(0)
|
|
61
70
|
const attachmentMimeTypes = inputMimeTypes(inputContract)
|
|
62
71
|
const allowAttachments = attachmentMimeTypes.length > 0
|
|
63
72
|
const recordingAllowed = inputContract.modalities.audio.recording_enabled
|
|
73
|
+
React.useEffect(() => {
|
|
74
|
+
filesRef.current = files
|
|
75
|
+
}, [files])
|
|
76
|
+
React.useEffect(() => () => {
|
|
77
|
+
revokeAgentChatComposerFiles(filesRef.current)
|
|
78
|
+
revokeAgentChatComposerFiles(inFlightFilesRef.current)
|
|
79
|
+
}, [])
|
|
64
80
|
React.useEffect(() => {
|
|
65
81
|
if (disabled || isRunning) return
|
|
66
82
|
const frame = window.requestAnimationFrame(() => textareaRef.current?.focus({ preventScroll: true }))
|
|
@@ -68,14 +84,14 @@ export function ChatComposer({
|
|
|
68
84
|
}, [disabled, focusKey, isRunning])
|
|
69
85
|
const addRecording = React.useCallback((file: File) => {
|
|
70
86
|
draftRevisionRef.current += 1
|
|
71
|
-
setFiles((current) => [...current, file])
|
|
87
|
+
setFiles((current) => [...current, createAgentChatComposerFile(file)])
|
|
72
88
|
}, [])
|
|
73
89
|
const recorder = useAudioRecorder(addRecording)
|
|
74
90
|
const isExpanded = forceExpanded || text.includes("\n") || text.length > 62
|
|
75
91
|
const validationError = validateAgentChatSubmission(
|
|
76
92
|
inputContract,
|
|
77
93
|
text,
|
|
78
|
-
files.map((file) => ({ data: file, mediaType: file.
|
|
94
|
+
files.map((file) => ({ data: file.source, mediaType: file.mediaType })),
|
|
79
95
|
)
|
|
80
96
|
|
|
81
97
|
const selectedAgent = agents.find((item) => item.id === agentId)
|
|
@@ -143,16 +159,23 @@ export function ChatComposer({
|
|
|
143
159
|
const clearedRevision = draftRevisionRef.current + 1
|
|
144
160
|
draftRevisionRef.current = clearedRevision
|
|
145
161
|
submittingRef.current = true
|
|
162
|
+
inFlightFilesRef.current = selectedFiles
|
|
146
163
|
setText("")
|
|
147
164
|
setFiles([])
|
|
148
165
|
textareaRef.current?.focus()
|
|
149
166
|
try {
|
|
150
167
|
await onSubmit(value, selectedFiles)
|
|
168
|
+
revokeAgentChatComposerFiles(selectedFiles)
|
|
169
|
+
inFlightFilesRef.current = []
|
|
151
170
|
} catch {
|
|
152
171
|
if (draftRevisionRef.current === clearedRevision) {
|
|
153
172
|
draftRevisionRef.current += 1
|
|
154
173
|
setText(value)
|
|
155
174
|
setFiles(selectedFiles)
|
|
175
|
+
inFlightFilesRef.current = []
|
|
176
|
+
} else {
|
|
177
|
+
revokeAgentChatComposerFiles(selectedFiles)
|
|
178
|
+
inFlightFilesRef.current = []
|
|
156
179
|
}
|
|
157
180
|
} finally {
|
|
158
181
|
submittingRef.current = false
|
|
@@ -165,18 +188,19 @@ export function ChatComposer({
|
|
|
165
188
|
data-agents24-chat-composer=""
|
|
166
189
|
>
|
|
167
190
|
{allowAttachments && files.length > 0 && (
|
|
168
|
-
<div className="mb-2
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
191
|
+
<div className="mb-2 min-w-0">
|
|
192
|
+
<ChatAttachmentRows
|
|
193
|
+
attachments={files}
|
|
194
|
+
onRemove={(attachment) => {
|
|
195
|
+
if (!attachment.id) return
|
|
196
|
+
draftRevisionRef.current += 1
|
|
197
|
+
setFiles((current) => {
|
|
198
|
+
const removed = current.find((file) => file.id === attachment.id)
|
|
199
|
+
if (removed) revokeAgentChatComposerFiles([removed])
|
|
200
|
+
return current.filter((file) => file.id !== attachment.id)
|
|
201
|
+
})
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
180
204
|
</div>
|
|
181
205
|
)}
|
|
182
206
|
{attachmentError || recorder.error ? <p className="mb-2 text-xs text-destructive" role="alert">{attachmentError || recorder.error}</p> : null}
|
|
@@ -201,7 +225,7 @@ export function ChatComposer({
|
|
|
201
225
|
attachmentMimeTypes.includes(canonicalInputMimeType(file.type))
|
|
202
226
|
))
|
|
203
227
|
draftRevisionRef.current += 1
|
|
204
|
-
setFiles((current) => [...current, ...accepted])
|
|
228
|
+
setFiles((current) => [...current, ...accepted.map(createAgentChatComposerFile)])
|
|
205
229
|
setAttachmentError(
|
|
206
230
|
accepted.length === selected.length
|
|
207
231
|
? null
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ChatHitlPart, ChatMessage as ChatMessageModel } from "@agents24/react"
|
|
2
2
|
import type { FeedbackReason, HitlResponse } from "@agents24/client"
|
|
3
3
|
import type { AgentChatRuntimeHitlActionState } from "@agents24/chat-react/runtime"
|
|
4
|
-
import { AgentChatDefaultActions,
|
|
4
|
+
import { AgentChatDefaultActions, AgentChatUserMessageActionRow, ChatAttachmentRows, type ChatAttachmentContentResolver } from "@agents24/chat-react/ui"
|
|
5
5
|
import * as React from "react"
|
|
6
6
|
|
|
7
7
|
import { MessageParts } from "./message-parts"
|
|
@@ -20,6 +20,7 @@ type Props = {
|
|
|
20
20
|
input: { rating: "like" | "dislike" | null; reason?: FeedbackReason | null; comment?: string | null },
|
|
21
21
|
) => Promise<void>
|
|
22
22
|
Timeline?: React.ComponentType<Agents24ChatTimelineSlotProps>
|
|
23
|
+
resolveAttachmentContent?: ChatAttachmentContentResolver
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function ChatMessage({
|
|
@@ -30,21 +31,19 @@ export function ChatMessage({
|
|
|
30
31
|
onHitlAction,
|
|
31
32
|
onRegenerate,
|
|
32
33
|
onSetFeedback,
|
|
34
|
+
resolveAttachmentContent,
|
|
33
35
|
Timeline,
|
|
34
36
|
}: Props) {
|
|
35
37
|
const isUser = message.role === "user"
|
|
36
38
|
return (
|
|
37
39
|
<Message align={isUser ? "end" : "start"}>
|
|
38
|
-
<MessageContent>
|
|
40
|
+
<MessageContent className={isUser ? "gap-0" : undefined}>
|
|
39
41
|
{isUser && message.attachments?.length ? (
|
|
40
|
-
<
|
|
41
|
-
{message.attachments
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
/>
|
|
46
|
-
))}
|
|
47
|
-
</ChatAttachments>
|
|
42
|
+
<ChatAttachmentRows
|
|
43
|
+
attachments={message.attachments}
|
|
44
|
+
className="mb-2"
|
|
45
|
+
resolveContent={resolveAttachmentContent}
|
|
46
|
+
/>
|
|
48
47
|
) : null}
|
|
49
48
|
<Bubble variant={isUser ? "secondary" : "ghost"} align={isUser ? "end" : "start"} className={isUser ? "max-w-[min(75%,42rem)]" : "max-w-full overflow-visible"}>
|
|
50
49
|
<BubbleContent className={isUser ? "px-4 py-2.5" : "w-full overflow-visible"}>
|
|
@@ -57,6 +56,16 @@ export function ChatMessage({
|
|
|
57
56
|
/>
|
|
58
57
|
</BubbleContent>
|
|
59
58
|
</Bubble>
|
|
59
|
+
{isUser && (
|
|
60
|
+
<AgentChatUserMessageActionRow>
|
|
61
|
+
<AgentChatDefaultActions
|
|
62
|
+
handlers={{
|
|
63
|
+
onCopy: (content) => { void navigator.clipboard.writeText(content).catch(() => undefined) },
|
|
64
|
+
}}
|
|
65
|
+
message={message}
|
|
66
|
+
/>
|
|
67
|
+
</AgentChatUserMessageActionRow>
|
|
68
|
+
)}
|
|
60
69
|
{!isUser && message.isFinal !== false && (
|
|
61
70
|
<MessageFooter className="gap-1 opacity-70 transition-opacity group-hover/message:opacity-100">
|
|
62
71
|
<AgentChatDefaultActions
|
|
@@ -100,11 +100,27 @@ export function ChatShell({
|
|
|
100
100
|
|| controller.runState === "reconnecting"
|
|
101
101
|
|| controller.runState === "paused"
|
|
102
102
|
|| controller.runState === "cancelling"
|
|
103
|
-
|
|
103
|
+
const isUploading = controller.runState === "uploading" || runtime.isUploading
|
|
104
104
|
const isCancelling = controller.runState === "cancelling"
|
|
105
105
|
const activeTitle = controller.threads.find((thread) => thread.id === controller.activeThreadId)?.title
|
|
106
106
|
const hasBlockingError = Boolean(runtime.fatalError && controller.messages.length === 0)
|
|
107
107
|
const isEmptyState = controller.activeThreadId === null && controller.messages.length === 0 && !hasBlockingError
|
|
108
|
+
const composerOverlayRef = React.useRef<HTMLDivElement>(null)
|
|
109
|
+
const [composerOverlayHeight, setComposerOverlayHeight] = React.useState(192)
|
|
110
|
+
|
|
111
|
+
React.useEffect(() => {
|
|
112
|
+
const overlay = composerOverlayRef.current
|
|
113
|
+
if (!overlay || isEmptyState) return
|
|
114
|
+
|
|
115
|
+
const updateHeight = () => {
|
|
116
|
+
setComposerOverlayHeight(Math.ceil(overlay.getBoundingClientRect().height))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
updateHeight()
|
|
120
|
+
const resizeObserver = new ResizeObserver(updateHeight)
|
|
121
|
+
resizeObserver.observe(overlay)
|
|
122
|
+
return () => resizeObserver.disconnect()
|
|
123
|
+
}, [isEmptyState])
|
|
108
124
|
|
|
109
125
|
return (
|
|
110
126
|
<SidebarProvider
|
|
@@ -127,7 +143,7 @@ export function ChatShell({
|
|
|
127
143
|
/>}
|
|
128
144
|
<SidebarInset className="h-svh min-h-0 overflow-hidden bg-background">
|
|
129
145
|
<header
|
|
130
|
-
className="flex h-12 shrink-0 items-center gap-2 border-b border-border/
|
|
146
|
+
className="flex h-12 shrink-0 items-center gap-2 border-b border-border/50 px-3 sm:px-4"
|
|
131
147
|
data-agents24-chat-header=""
|
|
132
148
|
>
|
|
133
149
|
<CollapsedHeaderActions onNew={runtime.newThread} />
|
|
@@ -151,7 +167,7 @@ export function ChatShell({
|
|
|
151
167
|
<ChatComposer
|
|
152
168
|
inputContract={runtime.inputContract || LOADING_INPUT_CONTRACT}
|
|
153
169
|
contextWindow={runtime.contextWindow}
|
|
154
|
-
disabled={runtime.isBootstrapping || runtime.isChangingAgent}
|
|
170
|
+
disabled={runtime.isBootstrapping || runtime.isChangingAgent || isUploading}
|
|
155
171
|
focusKey={controller.activeThreadId}
|
|
156
172
|
forceExpanded
|
|
157
173
|
isCancelling={isCancelling}
|
|
@@ -166,10 +182,11 @@ export function ChatShell({
|
|
|
166
182
|
onSubmit={(text, files) => runtime.submit({
|
|
167
183
|
text,
|
|
168
184
|
files: files.map((file) => ({
|
|
169
|
-
data: file,
|
|
170
|
-
mediaType: file.
|
|
171
|
-
name: file.
|
|
185
|
+
data: file.source,
|
|
186
|
+
mediaType: file.mediaType,
|
|
187
|
+
name: file.filename,
|
|
172
188
|
})),
|
|
189
|
+
attachments: files,
|
|
173
190
|
})}
|
|
174
191
|
/>
|
|
175
192
|
</div>
|
|
@@ -183,7 +200,8 @@ export function ChatShell({
|
|
|
183
200
|
<MessageScroller className="relative min-h-0 flex-1">
|
|
184
201
|
<MessageScrollerViewport preserveScrollOnPrepend>
|
|
185
202
|
<MessageScrollerContent
|
|
186
|
-
className="mx-auto w-full max-w-3xl gap-
|
|
203
|
+
className="mx-auto w-full max-w-3xl gap-3 px-4 pt-8 pb-0 sm:px-6 sm:pt-12"
|
|
204
|
+
style={{ paddingBottom: composerOverlayHeight + 72 }}
|
|
187
205
|
>
|
|
188
206
|
{controller.hasOlderTurns && (
|
|
189
207
|
<Button className="self-center" variant="ghost" size="sm" disabled={controller.isLoadingOlder} onClick={() => { void controller.loadOlder().catch(() => undefined) }}>
|
|
@@ -222,6 +240,7 @@ export function ChatShell({
|
|
|
222
240
|
onHitlAction={runtime.onHitlAction}
|
|
223
241
|
onRegenerate={() => controller.regenerate(message)}
|
|
224
242
|
onSetFeedback={(input) => controller.setFeedback(message, input)}
|
|
243
|
+
resolveAttachmentContent={runtime.resolveAttachmentContent}
|
|
225
244
|
Timeline={components?.Timeline}
|
|
226
245
|
/>
|
|
227
246
|
</MessageScrollerItem>
|
|
@@ -229,35 +248,47 @@ export function ChatShell({
|
|
|
229
248
|
</MessageScrollerContent>
|
|
230
249
|
</MessageScrollerViewport>
|
|
231
250
|
<TurnOutline messages={controller.messages} />
|
|
232
|
-
<MessageScrollerButton />
|
|
233
251
|
</MessageScroller>
|
|
252
|
+
|
|
253
|
+
<div
|
|
254
|
+
ref={composerOverlayRef}
|
|
255
|
+
className="pointer-events-none absolute inset-x-0 bottom-0 z-30 overflow-visible pt-10"
|
|
256
|
+
style={{
|
|
257
|
+
background: "linear-gradient(to bottom, transparent 0%, color-mix(in oklch, var(--background) 85%, transparent) 45%, var(--background) 65%, var(--background) 100%)",
|
|
258
|
+
}}
|
|
259
|
+
>
|
|
260
|
+
<div className="pointer-events-auto absolute inset-x-0 top-0 flex -translate-y-full justify-center pb-3">
|
|
261
|
+
<MessageScrollerButton className="!relative !inset-auto !translate-x-0 rtl:!translate-x-0" />
|
|
262
|
+
</div>
|
|
263
|
+
<div className="pointer-events-auto">
|
|
264
|
+
<ChatComposer
|
|
265
|
+
inputContract={runtime.inputContract || LOADING_INPUT_CONTRACT}
|
|
266
|
+
contextWindow={runtime.contextWindow}
|
|
267
|
+
disabled={runtime.isBootstrapping || runtime.isChangingAgent || hasBlockingError || isUploading}
|
|
268
|
+
focusKey={controller.activeThreadId}
|
|
269
|
+
isCancelling={isCancelling}
|
|
270
|
+
isRunning={isRunning}
|
|
271
|
+
agentId={runtime.agentId}
|
|
272
|
+
agents={runtime.agents}
|
|
273
|
+
onAgentChange={(agentId) => { void runtime.changeAgent(agentId) }}
|
|
274
|
+
onStop={() => { void controller.cancelRun().catch(() => undefined) }}
|
|
275
|
+
modelId={runtime.modelId}
|
|
276
|
+
models={runtime.models}
|
|
277
|
+
onModelChange={runtime.changeModel}
|
|
278
|
+
onSubmit={(text, files) => runtime.submit({
|
|
279
|
+
text,
|
|
280
|
+
files: files.map((file) => ({
|
|
281
|
+
data: file.source,
|
|
282
|
+
mediaType: file.mediaType,
|
|
283
|
+
name: file.filename,
|
|
284
|
+
})),
|
|
285
|
+
attachments: files,
|
|
286
|
+
})}
|
|
287
|
+
utilityRow="below"
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
234
291
|
</MessageScrollerProvider>
|
|
235
|
-
<div className="relative z-30 shrink-0 bg-background pt-3">
|
|
236
|
-
<ChatComposer
|
|
237
|
-
inputContract={runtime.inputContract || LOADING_INPUT_CONTRACT}
|
|
238
|
-
contextWindow={runtime.contextWindow}
|
|
239
|
-
disabled={runtime.isBootstrapping || runtime.isChangingAgent || hasBlockingError}
|
|
240
|
-
focusKey={controller.activeThreadId}
|
|
241
|
-
isCancelling={isCancelling}
|
|
242
|
-
isRunning={isRunning}
|
|
243
|
-
agentId={runtime.agentId}
|
|
244
|
-
agents={runtime.agents}
|
|
245
|
-
onAgentChange={(agentId) => { void runtime.changeAgent(agentId) }}
|
|
246
|
-
onStop={() => { void controller.cancelRun().catch(() => undefined) }}
|
|
247
|
-
modelId={runtime.modelId}
|
|
248
|
-
models={runtime.models}
|
|
249
|
-
onModelChange={runtime.changeModel}
|
|
250
|
-
onSubmit={(text, files) => runtime.submit({
|
|
251
|
-
text,
|
|
252
|
-
files: files.map((file) => ({
|
|
253
|
-
data: file,
|
|
254
|
-
mediaType: file.type || "application/octet-stream",
|
|
255
|
-
name: file.name,
|
|
256
|
-
})),
|
|
257
|
-
})}
|
|
258
|
-
utilityRow="below"
|
|
259
|
-
/>
|
|
260
|
-
</div>
|
|
261
292
|
</>
|
|
262
293
|
)}
|
|
263
294
|
</section>
|
|
@@ -1,73 +1,31 @@
|
|
|
1
|
+
import { LatestThreadScroller, type LatestThreadScrollerOutlineItem } from "@agents24/chat-react"
|
|
1
2
|
import type { ChatMessage } from "@agents24/react"
|
|
2
|
-
import { useState } from "react"
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import { cn } from "../../lib/utils"
|
|
7
|
-
|
|
8
|
-
type Turn = { id: string; label: string; messageIds: string[]; response: string }
|
|
9
|
-
|
|
10
|
-
function turnsFromMessages(messages: ChatMessage[]): Turn[] {
|
|
11
|
-
return messages.reduce<Turn[]>((turns, message) => {
|
|
4
|
+
function turnsFromMessages(messages: ChatMessage[]): LatestThreadScrollerOutlineItem[] {
|
|
5
|
+
return messages.reduce<LatestThreadScrollerOutlineItem[]>((turns, message) => {
|
|
12
6
|
if (message.role === "user") {
|
|
13
|
-
turns.push({
|
|
7
|
+
turns.push({
|
|
8
|
+
id: message.id,
|
|
9
|
+
label: message.content.trim() || `Turn ${turns.length + 1}`,
|
|
10
|
+
messageIds: [message.id],
|
|
11
|
+
responsePreview: "",
|
|
12
|
+
})
|
|
14
13
|
} else if (turns.length) {
|
|
15
14
|
const turn = turns[turns.length - 1]
|
|
16
|
-
turn.messageIds
|
|
17
|
-
if (!turn.
|
|
15
|
+
turn.messageIds?.push(message.id)
|
|
16
|
+
if (!turn.responsePreview) turn.responsePreview = message.content.trim()
|
|
18
17
|
}
|
|
19
18
|
return turns
|
|
20
19
|
}, [])
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
export function TurnOutline({ messages }: { messages: ChatMessage[] }) {
|
|
24
|
-
const turns = turnsFromMessages(messages)
|
|
25
|
-
const { scrollToMessage } = useMessageScroller()
|
|
26
|
-
const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()
|
|
27
|
-
const visible = new Set(visibleMessageIds)
|
|
28
|
-
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
|
29
|
-
const focusIndex = hoveredIndex ?? -1
|
|
30
|
-
if (turns.length < 2) return null
|
|
31
|
-
|
|
32
23
|
return (
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
? 12
|
|
40
|
-
: distance === 0 ? 50 : distance === 1 ? 36 : distance === 2 ? 23 : 10
|
|
41
|
-
const barOpacity = focusIndex < 0
|
|
42
|
-
? active ? 1 : 0.4
|
|
43
|
-
: distance === 0 ? 1 : distance === 1 ? 0.78 : distance === 2 ? 0.58 : 0.36
|
|
44
|
-
return (
|
|
45
|
-
<Tooltip key={turn.id}>
|
|
46
|
-
<TooltipTrigger asChild>
|
|
47
|
-
<button
|
|
48
|
-
type="button"
|
|
49
|
-
aria-label={`Jump to ${turn.label}`}
|
|
50
|
-
className="group relative flex h-2 w-14 items-center justify-end outline-none after:pointer-events-auto after:absolute after:-inset-y-0.5 after:inset-x-0 after:content-['']"
|
|
51
|
-
onBlur={() => setHoveredIndex(null)}
|
|
52
|
-
onFocus={() => setHoveredIndex(index)}
|
|
53
|
-
onMouseEnter={() => setHoveredIndex(index)}
|
|
54
|
-
onMouseLeave={() => setHoveredIndex(null)}
|
|
55
|
-
onClick={() => scrollToMessage(turn.id, { align: "start", behavior: "smooth" })}
|
|
56
|
-
>
|
|
57
|
-
<span
|
|
58
|
-
className={cn("h-0.5 rounded-full bg-muted-foreground/40 transition-[width,background-color,opacity] duration-150 ease-out group-hover:bg-foreground group-focus-visible:bg-foreground", active && "bg-foreground")}
|
|
59
|
-
style={{ opacity: barOpacity, width: barWidth }}
|
|
60
|
-
/>
|
|
61
|
-
</button>
|
|
62
|
-
</TooltipTrigger>
|
|
63
|
-
<TooltipContent side="left" className="w-64 flex-col items-stretch gap-0 overflow-hidden p-3">
|
|
64
|
-
<p className="line-clamp-2 max-w-full wrap-break-word text-sm font-medium">{turn.label}</p>
|
|
65
|
-
{turn.response && <p className="mt-1 line-clamp-2 max-w-full wrap-break-word text-xs opacity-70">{turn.response}</p>}
|
|
66
|
-
</TooltipContent>
|
|
67
|
-
</Tooltip>
|
|
68
|
-
)
|
|
69
|
-
})}
|
|
70
|
-
</div>
|
|
71
|
-
</nav>
|
|
24
|
+
<LatestThreadScroller.Outline
|
|
25
|
+
items={turnsFromMessages(messages)}
|
|
26
|
+
minSideGutterForRail={0}
|
|
27
|
+
side="left"
|
|
28
|
+
verticalOffset="3.5rem"
|
|
29
|
+
/>
|
|
72
30
|
)
|
|
73
31
|
}
|
|
@@ -55,7 +55,7 @@ function MessageScrollerContent({
|
|
|
55
55
|
return (
|
|
56
56
|
<MessageScrollerPrimitive.Content
|
|
57
57
|
data-slot="message-scroller-content"
|
|
58
|
-
className={cn("flex h-max min-h-full flex-col gap-
|
|
58
|
+
className={cn("flex h-max min-h-full flex-col gap-3", className)}
|
|
59
59
|
{...props}
|
|
60
60
|
/>
|
|
61
61
|
)
|
|
@@ -74,7 +74,7 @@ function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
74
74
|
<div
|
|
75
75
|
data-slot="message-footer"
|
|
76
76
|
className={cn(
|
|
77
|
-
"flex max-w-full min-w-0 items-center
|
|
77
|
+
"flex max-w-full min-w-0 items-center pl-3.5 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
|
|
78
78
|
className
|
|
79
79
|
)}
|
|
80
80
|
{...props}
|
|
@@ -215,7 +215,7 @@ function Sidebar({
|
|
|
215
215
|
// Adjust the padding for floating and inset variants.
|
|
216
216
|
variant === "floating" || variant === "inset"
|
|
217
217
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
|
218
|
-
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
|
218
|
+
: "border-border/50 group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
|
219
219
|
className
|
|
220
220
|
)}
|
|
221
221
|
{...props}
|