@flamingo-stack/openframe-frontend-core 0.0.172 → 0.0.173
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/{chunk-AY3E5FKM.cjs → chunk-2MEBOTV4.cjs} +2263 -1698
- package/dist/chunk-2MEBOTV4.cjs.map +1 -0
- package/dist/{chunk-FEEPEOW2.js → chunk-J3ZCNPDM.js} +6128 -5563
- package/dist/chunk-J3ZCNPDM.js.map +1 -0
- package/dist/components/chat/approval-batch-message.d.ts +10 -0
- package/dist/components/chat/approval-batch-message.d.ts.map +1 -0
- package/dist/components/chat/chat-input.d.ts.map +1 -1
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/chat-message-loader.d.ts +23 -0
- package/dist/components/chat/chat-message-loader.d.ts.map +1 -0
- package/dist/components/chat/hooks/index.d.ts +1 -0
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-collapsible.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-delayed-flag.d.ts +25 -0
- package/dist/components/chat/hooks/use-delayed-flag.d.ts.map +1 -0
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
- package/dist/components/chat/index.d.ts +3 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/thinking-display.d.ts.map +1 -1
- package/dist/components/chat/tool-call-blocks.d.ts +18 -0
- package/dist/components/chat/tool-call-blocks.d.ts.map +1 -0
- package/dist/components/chat/tool-execution-display.d.ts +1 -1
- package/dist/components/chat/tool-execution-display.d.ts.map +1 -1
- package/dist/components/chat/types/api.types.d.ts +16 -1
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/component.types.d.ts +2 -2
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/chat/types/message.types.d.ts +54 -1
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/types/network.types.d.ts +2 -0
- package/dist/components/chat/types/network.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +14 -1
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -1
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/index.d.ts +1 -0
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +32 -4
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts +2 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/chat/utils/tool-call-helpers.d.ts +38 -0
- package/dist/components/chat/utils/tool-call-helpers.d.ts.map +1 -0
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/index.cjs +14 -2
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +13 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/ui/index.cjs +14 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +13 -1
- package/dist/components/ui/textarea.d.ts +11 -0
- package/dist/components/ui/textarea.d.ts.map +1 -1
- package/dist/index.cjs +14 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +13 -1
- package/package.json +3 -3
- package/src/components/chat/approval-batch-message.tsx +237 -0
- package/src/components/chat/chat-input.tsx +34 -106
- package/src/components/chat/chat-message-enhanced.tsx +11 -0
- package/src/components/chat/chat-message-list.tsx +22 -6
- package/src/components/chat/chat-message-loader.tsx +67 -0
- package/src/components/chat/hooks/index.ts +1 -0
- package/src/components/chat/hooks/use-collapsible.ts +10 -1
- package/src/components/chat/hooks/use-delayed-flag.ts +56 -0
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +109 -20
- package/src/components/chat/index.ts +3 -0
- package/src/components/chat/thinking-display.tsx +76 -12
- package/src/components/chat/tool-call-blocks.tsx +58 -0
- package/src/components/chat/tool-execution-display.tsx +60 -81
- package/src/components/chat/types/api.types.ts +22 -3
- package/src/components/chat/types/component.types.ts +2 -2
- package/src/components/chat/types/message.types.ts +58 -1
- package/src/components/chat/types/network.types.ts +2 -0
- package/src/components/chat/types/processing.types.ts +13 -2
- package/src/components/chat/utils/chunk-parser.ts +40 -5
- package/src/components/chat/utils/extract-incomplete-message-state.ts +19 -1
- package/src/components/chat/utils/index.ts +3 -0
- package/src/components/chat/utils/message-segment-accumulator.ts +136 -12
- package/src/components/chat/utils/process-historical-messages.ts +88 -13
- package/src/components/chat/utils/tool-call-helpers.ts +105 -0
- package/src/components/ui/textarea.tsx +107 -25
- package/dist/chunk-AY3E5FKM.cjs.map +0 -1
- package/dist/chunk-FEEPEOW2.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flamingo-stack/openframe-frontend-core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.173",
|
|
4
4
|
"description": "Shared design system and components for all Flamingo platforms",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -137,8 +137,8 @@
|
|
|
137
137
|
"test:ui": "vitest --ui",
|
|
138
138
|
"test:run": "vitest run",
|
|
139
139
|
"test:coverage": "vitest run --coverage",
|
|
140
|
-
"yalc:watch": "NODE_OPTIONS='--max-old-space-size=
|
|
141
|
-
"yalc:push": "
|
|
140
|
+
"yalc:watch": "NODE_OPTIONS='--max-old-space-size=4096' nodemon --watch src --watch tsconfig.json --watch tsup.config.ts --watch tailwind.config.ts -e ts,tsx,json,css --ignore dist --ignore node_modules --ignore '*.lock' --ignore .yalc --ignore yalc.lock --exec 'tsup && yalc push --changed'",
|
|
141
|
+
"yalc:push": "yalc push",
|
|
142
142
|
"yalc:publish": "yalc publish",
|
|
143
143
|
"generate:icons": "node scripts/generate-icons.mjs",
|
|
144
144
|
"storybook": "storybook dev -p 6006",
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useState } from "react"
|
|
4
|
+
import { CheckCircle, XCircle } from "lucide-react"
|
|
5
|
+
import { cn } from "../../utils/cn"
|
|
6
|
+
import { Button } from "../ui/button"
|
|
7
|
+
import { Tag } from "../ui/tag"
|
|
8
|
+
import { ToolType } from "../platform"
|
|
9
|
+
import { ToolIcon } from "../tool-icon"
|
|
10
|
+
import { CheckCircleIcon, XmarkCircleIcon } from "../icons-v2-generated"
|
|
11
|
+
import { PulseDots } from "../ui/pulse-dots"
|
|
12
|
+
import { ExpandChevron } from "./expand-chevron"
|
|
13
|
+
import { useCollapsible } from "./hooks/use-collapsible"
|
|
14
|
+
import { ArgRow, ResultBlock } from "./tool-call-blocks"
|
|
15
|
+
import type {
|
|
16
|
+
ApprovalBatchExecutionState,
|
|
17
|
+
ApprovalBatchSegment,
|
|
18
|
+
PendingToolCallData,
|
|
19
|
+
} from "./types"
|
|
20
|
+
import {
|
|
21
|
+
COMMAND_BODY_ARG_KEYS,
|
|
22
|
+
getCommandText,
|
|
23
|
+
} from "./utils/tool-call-helpers"
|
|
24
|
+
|
|
25
|
+
export interface ApprovalBatchMessageProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
26
|
+
data: ApprovalBatchSegment["data"]
|
|
27
|
+
status?: ApprovalBatchSegment["status"]
|
|
28
|
+
onApprove?: (requestId?: string) => void | Promise<void>
|
|
29
|
+
onReject?: (requestId?: string) => void | Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const COMMAND_BODY_KEYS = new Set<string>(COMMAND_BODY_ARG_KEYS)
|
|
33
|
+
|
|
34
|
+
function getArgEntries(call: PendingToolCallData): Array<[string, unknown]> {
|
|
35
|
+
const args = call.toolCallArguments
|
|
36
|
+
if (!args || typeof args !== "object") return []
|
|
37
|
+
return Object.entries(args).filter(([k, v]) => !COMMAND_BODY_KEYS.has(k) && v !== null && v !== undefined && v !== "")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Status icon for one tool call inside an approved/done batch.
|
|
42
|
+
* - pending batch → null (chevron-only row, no status icon)
|
|
43
|
+
* - approved, no exec → PulseDots (queued / waiting for backend)
|
|
44
|
+
* - executing → PulseDots
|
|
45
|
+
* - done + success → green check
|
|
46
|
+
* - done + failure → red cross
|
|
47
|
+
*/
|
|
48
|
+
function ExecutionStatusIcon({
|
|
49
|
+
batchStatus,
|
|
50
|
+
execution,
|
|
51
|
+
}: {
|
|
52
|
+
batchStatus: ApprovalBatchSegment["status"]
|
|
53
|
+
execution: ApprovalBatchExecutionState | undefined
|
|
54
|
+
}) {
|
|
55
|
+
if (batchStatus !== "approved") return null
|
|
56
|
+
if (!execution || execution.status === "executing") return <PulseDots size="sm" />
|
|
57
|
+
if (execution.success === false) return <XmarkCircleIcon className="w-4 h-4 text-ods-error" />
|
|
58
|
+
return <CheckCircleIcon className="w-4 h-4 text-ods-success" />
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ToolCallRowProps {
|
|
62
|
+
call: PendingToolCallData
|
|
63
|
+
expanded: boolean
|
|
64
|
+
onToggle: () => void
|
|
65
|
+
batchStatus: ApprovalBatchSegment["status"]
|
|
66
|
+
execution: ApprovalBatchExecutionState | undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ToolCallRow({ call, expanded, onToggle, batchStatus, execution }: ToolCallRowProps) {
|
|
70
|
+
const command = getCommandText(call)
|
|
71
|
+
const args = getArgEntries(call)
|
|
72
|
+
const toolType = (call.toolType as ToolType) || ("OPENFRAME" as ToolType)
|
|
73
|
+
const { innerRef, containerStyle } = useCollapsible({ expanded })
|
|
74
|
+
const result = execution?.status === "done" ? execution.result : undefined
|
|
75
|
+
const hasExpandableBody = args.length > 0 || (typeof result === "string" && result.length > 0)
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="bg-ods-card border-b border-ods-border last:border-b-0 flex flex-col items-start w-full">
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={onToggle}
|
|
82
|
+
className="flex gap-2 items-start w-full p-3 cursor-pointer text-left"
|
|
83
|
+
>
|
|
84
|
+
<div className="flex items-center justify-center shrink-0 w-5 h-5">
|
|
85
|
+
<ToolIcon toolType={toolType} size={16} />
|
|
86
|
+
</div>
|
|
87
|
+
<div
|
|
88
|
+
className={cn(
|
|
89
|
+
"flex-1 min-w-0 font-medium text-sm leading-5",
|
|
90
|
+
expanded
|
|
91
|
+
? "text-ods-text-primary whitespace-pre-wrap break-all"
|
|
92
|
+
: "text-ods-text-secondary line-clamp-2 max-h-10 break-all",
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
{command}
|
|
96
|
+
</div>
|
|
97
|
+
<div className="flex items-center justify-center shrink-0 w-5 h-5">
|
|
98
|
+
<ExecutionStatusIcon batchStatus={batchStatus} execution={execution} />
|
|
99
|
+
</div>
|
|
100
|
+
<div className="flex items-center justify-center shrink-0 w-5 h-5">
|
|
101
|
+
<ExpandChevron expanded={expanded} />
|
|
102
|
+
</div>
|
|
103
|
+
</button>
|
|
104
|
+
|
|
105
|
+
<div className="w-full" style={containerStyle}>
|
|
106
|
+
<div ref={innerRef}>
|
|
107
|
+
{hasExpandableBody && (
|
|
108
|
+
<div className="flex flex-col gap-2 items-start w-full text-sm font-medium leading-5 p-3 bg-ods-card">
|
|
109
|
+
{args.map(([key, value]) => (
|
|
110
|
+
<ArgRow key={key} argKey={key} value={value} />
|
|
111
|
+
))}
|
|
112
|
+
{result && <ResultBlock result={result} />}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const ApprovalBatchMessage = forwardRef<HTMLDivElement, ApprovalBatchMessageProps>(
|
|
122
|
+
({ className, data, onApprove, onReject, status = "pending", ...props }, ref) => {
|
|
123
|
+
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
124
|
+
const [isProcessing, setIsProcessing] = useState(false)
|
|
125
|
+
|
|
126
|
+
const explanations = data.toolCalls
|
|
127
|
+
.map((c) => c.toolExplanation?.trim())
|
|
128
|
+
.filter((s): s is string => !!s)
|
|
129
|
+
|
|
130
|
+
const handleApprove = async () => {
|
|
131
|
+
setIsProcessing(true)
|
|
132
|
+
try {
|
|
133
|
+
await onApprove?.(data.approvalRequestId)
|
|
134
|
+
} finally {
|
|
135
|
+
setIsProcessing(false)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const handleReject = async () => {
|
|
140
|
+
setIsProcessing(true)
|
|
141
|
+
try {
|
|
142
|
+
await onReject?.(data.approvalRequestId)
|
|
143
|
+
} finally {
|
|
144
|
+
setIsProcessing(false)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const showFooterBlock = explanations.length > 0 || status !== undefined
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<div
|
|
152
|
+
ref={ref}
|
|
153
|
+
className={cn(
|
|
154
|
+
"bg-ods-card border border-ods-border rounded-md overflow-hidden flex flex-col mb-2",
|
|
155
|
+
className,
|
|
156
|
+
)}
|
|
157
|
+
{...props}
|
|
158
|
+
>
|
|
159
|
+
{data.toolCalls.map((call) => (
|
|
160
|
+
<ToolCallRow
|
|
161
|
+
key={call.toolExecutionRequestId}
|
|
162
|
+
call={call}
|
|
163
|
+
expanded={expandedId === call.toolExecutionRequestId}
|
|
164
|
+
onToggle={() =>
|
|
165
|
+
setExpandedId((prev) =>
|
|
166
|
+
prev === call.toolExecutionRequestId ? null : call.toolExecutionRequestId,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
batchStatus={status}
|
|
170
|
+
execution={data.executions?.[call.toolExecutionRequestId]}
|
|
171
|
+
/>
|
|
172
|
+
))}
|
|
173
|
+
|
|
174
|
+
{showFooterBlock && (
|
|
175
|
+
<div className="bg-ods-card flex flex-col gap-2 items-start justify-center p-3">
|
|
176
|
+
{explanations.length > 0 && (
|
|
177
|
+
<ul className="list-disc pl-5 text-sm font-medium text-ods-text-primary leading-5 w-full">
|
|
178
|
+
{explanations.map((expl, i) => (
|
|
179
|
+
<li key={i}>{expl}</li>
|
|
180
|
+
))}
|
|
181
|
+
</ul>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{status === "pending" ? (
|
|
185
|
+
<div className="flex gap-2 items-center">
|
|
186
|
+
<Button
|
|
187
|
+
size="small-legacy"
|
|
188
|
+
variant="accent"
|
|
189
|
+
onClick={handleApprove}
|
|
190
|
+
disabled={isProcessing}
|
|
191
|
+
className={cn(
|
|
192
|
+
"bg-ods-accent hover:bg-ods-accent/90",
|
|
193
|
+
"font-mono font-medium md:!text-sm text-ods-bg uppercase tracking-[-0.28px]",
|
|
194
|
+
"px-2 py-1 h-auto",
|
|
195
|
+
)}
|
|
196
|
+
>
|
|
197
|
+
Approve
|
|
198
|
+
</Button>
|
|
199
|
+
<Button
|
|
200
|
+
size="small-legacy"
|
|
201
|
+
variant="outline"
|
|
202
|
+
onClick={handleReject}
|
|
203
|
+
disabled={isProcessing}
|
|
204
|
+
className={cn(
|
|
205
|
+
"bg-ods-card border-ods-border",
|
|
206
|
+
"font-mono font-medium md:!text-sm text-ods-text-primary uppercase tracking-[-0.28px]",
|
|
207
|
+
"hover:bg-ods-bg px-2 py-1 h-auto",
|
|
208
|
+
)}
|
|
209
|
+
>
|
|
210
|
+
Reject
|
|
211
|
+
</Button>
|
|
212
|
+
</div>
|
|
213
|
+
) : (
|
|
214
|
+
<div className="flex">
|
|
215
|
+
<Tag
|
|
216
|
+
label={status === "approved" ? "Approved" : "Rejected"}
|
|
217
|
+
variant={status === "approved" ? "success" : "error"}
|
|
218
|
+
icon={
|
|
219
|
+
status === "approved" ? (
|
|
220
|
+
<CheckCircle className="w-4 h-4" />
|
|
221
|
+
) : (
|
|
222
|
+
<XCircle className="w-4 h-4" />
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
)
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
ApprovalBatchMessage.displayName = "ApprovalBatchMessage"
|
|
236
|
+
|
|
237
|
+
export { ApprovalBatchMessage }
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useImperativeHandle, forwardRef, useCallback, useEffect, useMemo, type KeyboardEvent, type ChangeEvent } from "react"
|
|
4
4
|
import { cn } from "../../utils/cn"
|
|
5
|
-
import { Send01Icon,
|
|
5
|
+
import { Send01Icon, StopCircleIcon } from "../icons-v2-generated"
|
|
6
6
|
import { Textarea } from "../ui/textarea"
|
|
7
7
|
import { ChatTypingIndicator } from "./chat-typing-indicator"
|
|
8
8
|
import { SlashCommandSuggestions } from "./slash-command-suggestions"
|
|
@@ -71,27 +71,18 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
71
71
|
(): ChatInputRef => ({
|
|
72
72
|
focus: () => focusTextarea(),
|
|
73
73
|
blur: () => textareaRef.current?.blur(),
|
|
74
|
-
clear: () =>
|
|
75
|
-
setValue('')
|
|
76
|
-
if (textareaRef.current) {
|
|
77
|
-
textareaRef.current.style.height = 'auto'
|
|
78
|
-
}
|
|
79
|
-
},
|
|
74
|
+
clear: () => setValue(''),
|
|
80
75
|
setValue: (next: string) => {
|
|
81
76
|
setValue(next)
|
|
82
|
-
//
|
|
83
|
-
// the updated DOM. requestAnimationFrame is the standard escape
|
|
84
|
-
// hatch. After focus, set selection to the END of the new value —
|
|
77
|
+
// After focus, set selection to the END of the new value —
|
|
85
78
|
// programmatic `.focus()` on a textarea defaults to caret-at-0
|
|
86
79
|
// (browser-standard), which would land the cursor at the start of
|
|
87
80
|
// the prefilled `/cmd ` and force users to arrow-right past every
|
|
88
|
-
// character before typing.
|
|
81
|
+
// character before typing. Auto-grow is handled by the Textarea's
|
|
82
|
+
// native `field-sizing: content` — no manual height mgmt needed.
|
|
89
83
|
requestAnimationFrame(() => {
|
|
90
84
|
const el = textareaRef.current
|
|
91
|
-
if (!el) return
|
|
92
|
-
el.style.height = 'auto'
|
|
93
|
-
el.style.height = `${el.scrollHeight}px`
|
|
94
|
-
if (disabled || el.disabled) return
|
|
85
|
+
if (!el || disabled || el.disabled) return
|
|
95
86
|
el.focus()
|
|
96
87
|
el.setSelectionRange(next.length, next.length)
|
|
97
88
|
})
|
|
@@ -105,12 +96,9 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
105
96
|
const clamped = Math.max(0, Math.min(cursorOffset, next.length))
|
|
106
97
|
requestAnimationFrame(() => {
|
|
107
98
|
const el = textareaRef.current
|
|
108
|
-
if (el)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
el.focus()
|
|
112
|
-
el.setSelectionRange(clamped, clamped)
|
|
113
|
-
}
|
|
99
|
+
if (!el) return
|
|
100
|
+
el.focus()
|
|
101
|
+
el.setSelectionRange(clamped, clamped)
|
|
114
102
|
})
|
|
115
103
|
},
|
|
116
104
|
submit: (next: string) => {
|
|
@@ -123,9 +111,6 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
123
111
|
if (!trimmed) return
|
|
124
112
|
onSend(trimmed)
|
|
125
113
|
setValue('')
|
|
126
|
-
if (textareaRef.current) {
|
|
127
|
-
textareaRef.current.style.height = 'auto'
|
|
128
|
-
}
|
|
129
114
|
shouldRefocusRef.current = true
|
|
130
115
|
focusTextarea()
|
|
131
116
|
},
|
|
@@ -143,11 +128,6 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
143
128
|
if (message && !sending && !disabled && onSend) {
|
|
144
129
|
onSend(message)
|
|
145
130
|
setValue('')
|
|
146
|
-
|
|
147
|
-
if (textareaRef.current) {
|
|
148
|
-
textareaRef.current.style.height = 'auto'
|
|
149
|
-
}
|
|
150
|
-
|
|
151
131
|
shouldRefocusRef.current = true
|
|
152
132
|
focusTextarea()
|
|
153
133
|
}
|
|
@@ -263,11 +243,6 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
263
243
|
|
|
264
244
|
const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
265
245
|
setValue(e.target.value)
|
|
266
|
-
|
|
267
|
-
if (textareaRef.current) {
|
|
268
|
-
textareaRef.current.style.height = 'auto'
|
|
269
|
-
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
|
|
270
|
-
}
|
|
271
246
|
}, [])
|
|
272
247
|
|
|
273
248
|
const [isStopping, setIsStopping] = useState(false)
|
|
@@ -307,7 +282,6 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
307
282
|
}
|
|
308
283
|
}, [onStop, isStopping])
|
|
309
284
|
|
|
310
|
-
// Show awaiting response state
|
|
311
285
|
if (awaitingResponse) {
|
|
312
286
|
return (
|
|
313
287
|
<div
|
|
@@ -315,34 +289,28 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
315
289
|
"mx-auto w-full max-w-3xl items-end gap-6",
|
|
316
290
|
reserveAvatarOffset ? "grid grid-cols-[32px_1fr]" : "grid grid-cols-[1fr]",
|
|
317
291
|
"flex-shrink-0",
|
|
318
|
-
className
|
|
292
|
+
className,
|
|
319
293
|
)}
|
|
320
294
|
>
|
|
321
295
|
{reserveAvatarOffset && <div className="invisible h-8 w-8" aria-hidden />}
|
|
322
|
-
<div
|
|
323
|
-
className={cn(
|
|
324
|
-
"relative flex items-center justify-center gap-2",
|
|
325
|
-
"rounded-md bg-ods-card border border-ods-border",
|
|
326
|
-
"px-3 py-3",
|
|
327
|
-
"transition-colors",
|
|
328
|
-
)}
|
|
329
|
-
>
|
|
296
|
+
<div className="relative flex items-center justify-center gap-2 rounded-md bg-ods-card border border-ods-border px-3 py-3 transition-colors">
|
|
330
297
|
<ChatTypingIndicator size="sm" dotClassName="bg-ods-text-primary" />
|
|
331
|
-
<p className="text-h4 text-ods-text-secondary">
|
|
332
|
-
Waiting for Technician Response
|
|
333
|
-
</p>
|
|
298
|
+
<p className="text-h4 text-ods-text-secondary">Waiting for Technician Response</p>
|
|
334
299
|
</div>
|
|
335
300
|
</div>
|
|
336
301
|
)
|
|
337
302
|
}
|
|
338
303
|
|
|
304
|
+
const isStopMode = sending && !!onStop
|
|
305
|
+
const sendDisabled = sending || disabled || !value.trim()
|
|
306
|
+
|
|
339
307
|
return (
|
|
340
308
|
<div
|
|
341
309
|
className={cn(
|
|
342
310
|
"mx-auto w-full max-w-3xl items-end gap-6",
|
|
343
311
|
reserveAvatarOffset ? "grid grid-cols-[32px_1fr]" : "grid grid-cols-[1fr]",
|
|
344
312
|
"flex-shrink-0",
|
|
345
|
-
className
|
|
313
|
+
className,
|
|
346
314
|
)}
|
|
347
315
|
>
|
|
348
316
|
{reserveAvatarOffset && <div className="invisible h-8 w-8" aria-hidden />}
|
|
@@ -355,67 +323,27 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
|
|
|
355
323
|
resolveSourceIcon={slashCommands?.resolveSourceIcon}
|
|
356
324
|
onAction={slashCommands?.onAction}
|
|
357
325
|
/>
|
|
358
|
-
<
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
<
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
"flex-1 resize-none bg-transparent px-3 border-none focus-visible:ring-0",
|
|
376
|
-
"font-dm-sans text-[18px] font-medium leading-[24px]",
|
|
377
|
-
"placeholder:text-ods-text-secondary",
|
|
378
|
-
"overflow-hidden text-ellipsis",
|
|
379
|
-
"min-h-[20px] max-h-[160px] focus:outline-none",
|
|
380
|
-
"disabled:opacity-50 disabled:cursor-not-allowed"
|
|
381
|
-
)}
|
|
382
|
-
{...inputProps}
|
|
383
|
-
/>
|
|
384
|
-
|
|
385
|
-
{sending && onStop ? (
|
|
386
|
-
<button
|
|
387
|
-
type="button"
|
|
388
|
-
onClick={handleStop}
|
|
389
|
-
disabled={isStopping}
|
|
390
|
-
className={cn(
|
|
391
|
-
"rounded-md px-3 text-ods-text-secondary transition-all",
|
|
392
|
-
isStopping ? "cursor-not-allowed opacity-40" : "hover:text-ods-accent active:scale-95",
|
|
393
|
-
"focus:outline-none"
|
|
394
|
-
)}
|
|
395
|
-
aria-label="Stop generation"
|
|
396
|
-
>
|
|
397
|
-
<StopIcon size={24} />
|
|
398
|
-
</button>
|
|
399
|
-
) : (
|
|
400
|
-
<button
|
|
401
|
-
type="button"
|
|
402
|
-
onClick={handleSubmit}
|
|
403
|
-
disabled={sending || disabled || !value.trim()}
|
|
404
|
-
className={cn(
|
|
405
|
-
"rounded-md px-3 text-ods-text-secondary transition-all",
|
|
406
|
-
sending || disabled || !value.trim() ? "cursor-not-allowed opacity-40" : "hover:text-ods-text-primary active:scale-95",
|
|
407
|
-
"focus:outline-none"
|
|
408
|
-
)}
|
|
409
|
-
aria-label="Send message"
|
|
410
|
-
>
|
|
411
|
-
<Send01Icon size={24} />
|
|
412
|
-
</button>
|
|
413
|
-
)}
|
|
414
|
-
</div>
|
|
326
|
+
<Textarea
|
|
327
|
+
ref={textareaRef}
|
|
328
|
+
value={value}
|
|
329
|
+
onChange={handleChange}
|
|
330
|
+
onKeyDown={handleKeyDown}
|
|
331
|
+
placeholder={disabled ? "Connection lost. Waiting to reconnect..." : placeholder}
|
|
332
|
+
disabled={sending || disabled}
|
|
333
|
+
rows={1}
|
|
334
|
+
endIcon={isStopMode ? <StopCircleIcon size={20} /> : <Send01Icon size={20} />}
|
|
335
|
+
endIconAsButton
|
|
336
|
+
endIconButtonProps={{
|
|
337
|
+
onClick: isStopMode ? handleStop : handleSubmit,
|
|
338
|
+
disabled: isStopMode ? isStopping : sendDisabled,
|
|
339
|
+
'aria-label': isStopMode ? 'Stop generation' : 'Send message',
|
|
340
|
+
}}
|
|
341
|
+
{...inputProps}
|
|
342
|
+
/>
|
|
415
343
|
</div>
|
|
416
344
|
</div>
|
|
417
345
|
)
|
|
418
|
-
}
|
|
346
|
+
},
|
|
419
347
|
)
|
|
420
348
|
|
|
421
349
|
ChatInput.displayName = "ChatInput"
|
|
@@ -6,6 +6,7 @@ import { SquareAvatar } from "../ui/square-avatar"
|
|
|
6
6
|
import { ChatTypingIndicator } from "./chat-typing-indicator"
|
|
7
7
|
import { ToolExecutionDisplay } from "./tool-execution-display"
|
|
8
8
|
import { ApprovalRequestMessage } from "./approval-request-message"
|
|
9
|
+
import { ApprovalBatchMessage } from "./approval-batch-message"
|
|
9
10
|
import { ErrorMessageDisplay } from "./error-message-display"
|
|
10
11
|
import { ContextCompactionDisplay } from "./context-compaction-display"
|
|
11
12
|
import { ThinkingDisplay } from "./thinking-display"
|
|
@@ -203,6 +204,16 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
|
|
|
203
204
|
onReject={segment.onReject}
|
|
204
205
|
/>
|
|
205
206
|
)
|
|
207
|
+
} else if (segment.type === 'approval_batch') {
|
|
208
|
+
return (
|
|
209
|
+
<ApprovalBatchMessage
|
|
210
|
+
key={index}
|
|
211
|
+
data={segment.data}
|
|
212
|
+
status={segment.status}
|
|
213
|
+
onApprove={segment.onApprove}
|
|
214
|
+
onReject={segment.onReject}
|
|
215
|
+
/>
|
|
216
|
+
)
|
|
206
217
|
} else if (segment.type === 'error') {
|
|
207
218
|
return (
|
|
208
219
|
<ErrorMessageDisplay
|
|
@@ -4,7 +4,9 @@ import { useRef, useEffect, useLayoutEffect, useImperativeHandle, forwardRef } f
|
|
|
4
4
|
import { useStickToBottom } from "use-stick-to-bottom"
|
|
5
5
|
import { cn } from "../../utils/cn"
|
|
6
6
|
import { ChatMessageEnhanced } from "./chat-message-enhanced"
|
|
7
|
-
import {
|
|
7
|
+
import { ChatMessageListLoader } from "./chat-message-loader"
|
|
8
|
+
import { useDelayedFlag } from "./hooks/use-delayed-flag"
|
|
9
|
+
import { PulseDots } from "../ui/pulse-dots"
|
|
8
10
|
import type { ChatMessageListProps } from "./types"
|
|
9
11
|
|
|
10
12
|
/*
|
|
@@ -209,14 +211,17 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
209
211
|
// MutableRefObject<HTMLElement> so we cast to the public type.
|
|
210
212
|
useImperativeHandle(ref, () => scrollRef.current as HTMLDivElement, [scrollRef])
|
|
211
213
|
|
|
212
|
-
|
|
214
|
+
// Gate the loader: only show after 200ms (fast loads never flicker),
|
|
215
|
+
// and once shown, hold for at least 400ms (no sub-frame flash if data
|
|
216
|
+
// arrives a moment later).
|
|
217
|
+
const showLoader = useDelayedFlag(isLoading, { delay: 200, minDuration: 400 })
|
|
218
|
+
|
|
219
|
+
if (showLoader) {
|
|
213
220
|
return (
|
|
214
|
-
<
|
|
221
|
+
<ChatMessageListLoader
|
|
215
222
|
className={className}
|
|
216
|
-
|
|
223
|
+
assistantIcon={assistantIcon}
|
|
217
224
|
assistantType={assistantType}
|
|
218
|
-
contentClassName={contentClassName}
|
|
219
|
-
messageCount={6}
|
|
220
225
|
/>
|
|
221
226
|
)
|
|
222
227
|
}
|
|
@@ -262,9 +267,20 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
262
267
|
)}
|
|
263
268
|
style={{ minHeight: '100%' }}
|
|
264
269
|
>
|
|
270
|
+
{/* Infinite scroll sentinel + loader for older pages */}
|
|
265
271
|
{hasNextPage && (
|
|
266
272
|
<div ref={sentinelRef} className="h-px" />
|
|
267
273
|
)}
|
|
274
|
+
{isFetchingNextPage && (
|
|
275
|
+
<div
|
|
276
|
+
className="flex justify-center py-3 animate-in fade-in duration-200"
|
|
277
|
+
role="status"
|
|
278
|
+
aria-live="polite"
|
|
279
|
+
aria-busy="true"
|
|
280
|
+
>
|
|
281
|
+
<PulseDots size="sm" />
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
268
284
|
<div className="flex-1" />
|
|
269
285
|
{messages.map((message, index) => (
|
|
270
286
|
<ChatMessageEnhanced
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../utils/cn"
|
|
6
|
+
import { MingoIcon } from "../icons/mingo-icon"
|
|
7
|
+
|
|
8
|
+
interface ChatMessageListLoaderProps {
|
|
9
|
+
className?: string
|
|
10
|
+
/**
|
|
11
|
+
* Brand mark rendered at the centre of the loader. When omitted, a neutral
|
|
12
|
+
* MingoIcon is used (color derived from `assistantType`). Pass a custom
|
|
13
|
+
* element to match a different brand.
|
|
14
|
+
*/
|
|
15
|
+
assistantIcon?: ReactNode
|
|
16
|
+
assistantType?: 'mingo' | 'fae'
|
|
17
|
+
/** Defaults to `"Loading conversation..."`. */
|
|
18
|
+
label?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Centered loading indicator for the chat message list. Replaces the
|
|
23
|
+
* multi-row skeleton with a single, brand-neutral pulsating mark — the same
|
|
24
|
+
* pattern Claude/ChatGPT use when fetching a conversation: don't fake the
|
|
25
|
+
* shape of the content, just signal "we're working on it" and let the real
|
|
26
|
+
* messages fade in once they arrive.
|
|
27
|
+
*/
|
|
28
|
+
export function ChatMessageListLoader({
|
|
29
|
+
className,
|
|
30
|
+
assistantIcon,
|
|
31
|
+
assistantType = 'fae',
|
|
32
|
+
label = 'Loading conversation...',
|
|
33
|
+
}: ChatMessageListLoaderProps) {
|
|
34
|
+
const accentColor =
|
|
35
|
+
assistantType === 'mingo'
|
|
36
|
+
? 'var(--ods-flamingo-cyan-base)'
|
|
37
|
+
: 'var(--ods-flamingo-pink-base)'
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
role="status"
|
|
42
|
+
aria-live="polite"
|
|
43
|
+
aria-busy="true"
|
|
44
|
+
className={cn(
|
|
45
|
+
"relative flex-1 min-h-0 flex flex-col items-center justify-center gap-3 px-8",
|
|
46
|
+
"animate-in fade-in duration-300",
|
|
47
|
+
className,
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<div className="relative flex items-center justify-center w-10 h-10">
|
|
51
|
+
<span
|
|
52
|
+
className="absolute inset-0 rounded-full opacity-30 blur-md animate-pulse"
|
|
53
|
+
style={{ backgroundColor: accentColor }}
|
|
54
|
+
aria-hidden="true"
|
|
55
|
+
/>
|
|
56
|
+
<div className="relative motion-safe:animate-pulse">
|
|
57
|
+
{assistantIcon ?? (
|
|
58
|
+
<MingoIcon className="w-8 h-8" eyesColor={accentColor} cornerColor={accentColor} />
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
<span className="text-sm font-medium text-ods-text-secondary tracking-tight motion-safe:animate-pulse">
|
|
63
|
+
{label}
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -35,9 +35,18 @@ export function useCollapsible({
|
|
|
35
35
|
return () => observer.disconnect()
|
|
36
36
|
}, [element, collapsedHeight])
|
|
37
37
|
|
|
38
|
+
// When the caller asks for `"1lh"`, fall back to a measured pixel value
|
|
39
|
+
// (inner element's line-height) as soon as it's available. Setting
|
|
40
|
+
// `max-height: 1lh` via CSS resolves against the OUTER element's
|
|
41
|
+
// line-height, which can differ from the inner content's line-height and
|
|
42
|
+
// leak the top of the next block element past the clip line. The measured
|
|
43
|
+
// pixel value matches the inner content exactly.
|
|
44
|
+
const collapsedValue: number | string =
|
|
45
|
+
collapsedHeight === "1lh" ? collapsedPx || collapsedHeight : collapsedHeight
|
|
46
|
+
|
|
38
47
|
const containerStyle: CSSProperties = {
|
|
39
48
|
overflow: "hidden",
|
|
40
|
-
maxHeight: expanded ? contentHeight :
|
|
49
|
+
maxHeight: expanded ? contentHeight : collapsedValue,
|
|
41
50
|
transition: disableTransition ? "none" : `max-height ${durationMs}ms ease-in-out`,
|
|
42
51
|
}
|
|
43
52
|
|