@softize/opus 25.6.0 → 26.0.0
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 +108 -1
- package/docs/adr/0017-ui-assumes-a-fixed-desktop-layout.md +19 -0
- package/docs/adr/0057-doc-browser-permite-busca-no-chrome-do-consumidor.md +152 -0
- package/docs/adr/0058-mensagem-da-ia-aceita-imagem-e-o-chat-recebe-anexos.md +201 -0
- package/docs/adr/README.md +12 -0
- package/package.json +1 -1
- package/src/ai/drivers/anthropic.ts +49 -9
- package/src/ai/index.ts +5 -2
- package/src/core/ai-message.ts +20 -0
- package/src/core/index.ts +5 -0
- package/src/core/types.ts +25 -2
- package/src/ui/components/patterns/sidebar.tsx +32 -11
- package/src/ui/components/primitives/chat.tsx +179 -22
- package/src/ui/components/primitives/composer.tsx +397 -7
- package/src/ui/docs/DocBrowser.tsx +404 -69
- package/src/ui/docs/DocCatalog.tsx +255 -0
- package/src/ui/docs/DocHeader.tsx +74 -0
- package/src/ui/docs/browser.tsx +10 -1
- package/src/ui/docs/changelog.tsx +16 -41
- package/src/ui/docs/content/actions.md +3 -3
- package/src/ui/docs/content/ai.md +46 -0
- package/src/ui/docs/content/chat.md +97 -2
- package/src/ui/docs/content/composer.md +144 -1
- package/src/ui/docs/content/customization.md +5 -1
- package/src/ui/docs/content/documentation-portal.md +190 -0
- package/src/ui/docs/content/getting-started.md +129 -87
- package/src/ui/docs/content/installation.md +94 -0
- package/src/ui/docs/content/mcp.md +2 -2
- package/src/ui/docs/content/overview.md +58 -0
- package/src/ui/docs/content/sidebar.md +4 -0
- package/src/ui/docs/index.ts +8 -1
- package/src/ui/docs/markdown.tsx +5 -3
- package/src/ui/docs/records.ts +144 -0
- package/src/ui/docs/registry.tsx +149 -59
- package/src/ui/docs/types.ts +2 -0
- package/src/ui/lib/prepare-image.ts +386 -0
- package/src/ui/meta.ts +2 -2
- package/src/ui/react.tsx +18 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AiMessage } from './types.ts'
|
|
2
|
+
|
|
3
|
+
/** Separa as partes de texto de uma mensagem na leitura: cada parte continua um parágrafo. */
|
|
4
|
+
const TEXT_PART_SEPARATOR = '\n\n'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* O texto de uma mensagem da IA (ADR 0058): o próprio `content` quando ele é texto, ou as partes
|
|
8
|
+
* de texto unidas por uma linha em branco. As imagens não entram.
|
|
9
|
+
*
|
|
10
|
+
* Use onde só o texto importa — título de conversa, contagem de caracteres, bloco de contexto
|
|
11
|
+
* montado por interpolação. Interpolar `content` direto produz "[object Object]" quando a
|
|
12
|
+
* mensagem tem partes, e o compilador não aponta esse uso.
|
|
13
|
+
*/
|
|
14
|
+
export function aiMessageText(message: Pick<AiMessage, 'content'>): string {
|
|
15
|
+
const { content } = message
|
|
16
|
+
if (typeof content === 'string') return content
|
|
17
|
+
return content
|
|
18
|
+
.flatMap((part) => (part.type === 'text' ? [part.text] : []))
|
|
19
|
+
.join(TEXT_PART_SEPARATOR)
|
|
20
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -113,6 +113,8 @@ export type {
|
|
|
113
113
|
AiCompleteOptions,
|
|
114
114
|
AiTool,
|
|
115
115
|
AiMessage,
|
|
116
|
+
AiContentPart,
|
|
117
|
+
AiImageMediaType,
|
|
116
118
|
AiRunOptions,
|
|
117
119
|
AiRunResult,
|
|
118
120
|
BoundAi,
|
|
@@ -243,6 +245,9 @@ export type {
|
|
|
243
245
|
} from './runtime.ts'
|
|
244
246
|
export { countReturnedRecords, hasInvalidEffect, resolveActionEffect } from './action-nature.ts'
|
|
245
247
|
|
|
248
|
+
// — Mensagem da IA (ADR 0058) ———————————————————————————————————————————————————
|
|
249
|
+
export { aiMessageText } from './ai-message.ts'
|
|
250
|
+
|
|
246
251
|
// — Assistente do recurso (ADR 0053) ————————————————————————————————————————————
|
|
247
252
|
export {
|
|
248
253
|
ASSISTANT_CONTEXT_DEFAULT_CHARS,
|
package/src/core/types.ts
CHANGED
|
@@ -1383,10 +1383,33 @@ export interface AiTool {
|
|
|
1383
1383
|
}
|
|
1384
1384
|
}
|
|
1385
1385
|
|
|
1386
|
-
/**
|
|
1386
|
+
/** Formatos de imagem que a mensagem aceita: os três que os provedores conhecidos leem (ADR 0058). */
|
|
1387
|
+
export type AiImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp'
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Parte de conteúdo de uma mensagem, sem a forma de nenhum provedor (ADR 0058). Cada driver
|
|
1391
|
+
* converte as partes para o formato da sua API e recusa com erro o que não conhece.
|
|
1392
|
+
*
|
|
1393
|
+
* A imagem não leva nome: o rótulo que a pessoa e o modelo usam ("Imagem 1: print.png") é uma
|
|
1394
|
+
* parte de texto que o consumidor monta antes dela.
|
|
1395
|
+
*/
|
|
1396
|
+
export type AiContentPart =
|
|
1397
|
+
| { type: 'text'; text: string }
|
|
1398
|
+
| {
|
|
1399
|
+
type: 'image'
|
|
1400
|
+
mediaType: AiImageMediaType
|
|
1401
|
+
/** Bytes da imagem em base64, sem o prefixo `data:`. */
|
|
1402
|
+
data: string
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* Turno de conversa (histórico multi-turn do chat). `content` é texto ou uma lista de partes
|
|
1407
|
+
* com texto e imagem (ADR 0058). Para ler só o texto, use `aiMessageText(message)`: interpolar
|
|
1408
|
+
* `content` direto num template produz "[object Object]" quando a mensagem tem partes.
|
|
1409
|
+
*/
|
|
1387
1410
|
export interface AiMessage {
|
|
1388
1411
|
role: 'user' | 'assistant'
|
|
1389
|
-
content: string
|
|
1412
|
+
content: string | AiContentPart[]
|
|
1390
1413
|
}
|
|
1391
1414
|
|
|
1392
1415
|
/** Uma opção de resposta de uma pergunta de esclarecimento (`ask_user`). */
|
|
@@ -77,7 +77,9 @@ export interface SidebarItemProps {
|
|
|
77
77
|
/** Complemento do tooltip no rail recolhido. */
|
|
78
78
|
tooltipHint?: ReactNode;
|
|
79
79
|
className?: string;
|
|
80
|
-
onClick
|
|
80
|
+
/** Destino nativo. Com onClick, intercepta somente a navegação simples; preserva novas abas. */
|
|
81
|
+
href?: string;
|
|
82
|
+
onClick?: () => void;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
/** Linha única de navegação: o alvo principal e seus controles são irmãos, nunca botões aninhados. */
|
|
@@ -95,8 +97,29 @@ export function SidebarItem({
|
|
|
95
97
|
tooltipHint,
|
|
96
98
|
className,
|
|
97
99
|
onClick,
|
|
100
|
+
href,
|
|
98
101
|
}: SidebarItemProps): React.ReactElement {
|
|
99
102
|
const collapsed = useSidebarCollapsed();
|
|
103
|
+
const Target = href !== undefined && !disabled ? "a" : "button";
|
|
104
|
+
const targetProps =
|
|
105
|
+
Target === "a" ? { href } : { type: "button" as const, disabled };
|
|
106
|
+
const activate = (
|
|
107
|
+
event: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>,
|
|
108
|
+
) => {
|
|
109
|
+
if (href !== undefined) {
|
|
110
|
+
if (
|
|
111
|
+
event.button !== 0 ||
|
|
112
|
+
event.metaKey ||
|
|
113
|
+
event.ctrlKey ||
|
|
114
|
+
event.shiftKey ||
|
|
115
|
+
event.altKey ||
|
|
116
|
+
!onClick
|
|
117
|
+
)
|
|
118
|
+
return;
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
}
|
|
121
|
+
onClick?.();
|
|
122
|
+
};
|
|
100
123
|
const hasActions = actions !== undefined && actions !== null;
|
|
101
124
|
// O rótulo cortado esmaece em vez de terminar em reticências; a medição do transbordo
|
|
102
125
|
// mantém a máscara fora do rótulo que cabe inteiro na linha.
|
|
@@ -104,12 +127,11 @@ export function SidebarItem({
|
|
|
104
127
|
useOverflowing<HTMLSpanElement>(label);
|
|
105
128
|
if (collapsed) {
|
|
106
129
|
const button = (
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
disabled={disabled}
|
|
130
|
+
<Target
|
|
131
|
+
{...targetProps}
|
|
110
132
|
aria-label={label}
|
|
111
133
|
aria-current={active ? "page" : undefined}
|
|
112
|
-
onClick={
|
|
134
|
+
onClick={activate}
|
|
113
135
|
className={cn(
|
|
114
136
|
"mx-auto grid size-10 place-items-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-40",
|
|
115
137
|
"outline-none",
|
|
@@ -125,7 +147,7 @@ export function SidebarItem({
|
|
|
125
147
|
>
|
|
126
148
|
{icon}
|
|
127
149
|
</span>
|
|
128
|
-
</
|
|
150
|
+
</Target>
|
|
129
151
|
);
|
|
130
152
|
// `className` vai no MESMO elemento nos dois modos — a linha —, senão a prop mudaria de alvo
|
|
131
153
|
// conforme o rail estivesse recolhido, e o consumidor não teria como saber o que estilizou.
|
|
@@ -209,11 +231,10 @@ export function SidebarItem({
|
|
|
209
231
|
)}
|
|
210
232
|
</span>
|
|
211
233
|
)}
|
|
212
|
-
<
|
|
213
|
-
|
|
214
|
-
disabled={disabled}
|
|
234
|
+
<Target
|
|
235
|
+
{...targetProps}
|
|
215
236
|
aria-current={active ? "page" : undefined}
|
|
216
|
-
onClick={
|
|
237
|
+
onClick={activate}
|
|
217
238
|
className={cn(
|
|
218
239
|
"flex min-w-0 flex-1 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm disabled:pointer-events-none",
|
|
219
240
|
"outline-none",
|
|
@@ -254,7 +275,7 @@ export function SidebarItem({
|
|
|
254
275
|
{badge}
|
|
255
276
|
</span>
|
|
256
277
|
)}
|
|
257
|
-
</
|
|
278
|
+
</Target>
|
|
258
279
|
{hasActions && (
|
|
259
280
|
<div
|
|
260
281
|
data-slot="sidebar-item-actions"
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
|
+
import { ImageOffIcon } from 'lucide-react'
|
|
2
3
|
import type { ChatEvent } from '../../../core/index.ts'
|
|
3
4
|
import { cn } from '../../lib/cn.ts'
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
Composer,
|
|
7
|
+
ComposerDropBridge,
|
|
8
|
+
dragHasFiles,
|
|
9
|
+
type ComposerAttachment,
|
|
10
|
+
type ComposerAttachments,
|
|
11
|
+
} from './composer.tsx'
|
|
5
12
|
import { Markdown } from './markdown.tsx'
|
|
6
13
|
|
|
7
14
|
export interface ChatMessage {
|
|
@@ -20,10 +27,35 @@ export interface ChatArtifact {
|
|
|
20
27
|
title?: string
|
|
21
28
|
}
|
|
22
29
|
|
|
30
|
+
/** Estado de um anexo já enviado: com a imagem disponível ou expirada pela retenção do app. */
|
|
31
|
+
export type ChatAttachmentStatus = 'available' | 'expired'
|
|
32
|
+
|
|
33
|
+
/** Imagem anexada a uma fala da pessoa (ADR 0058). A guarda é do app, que fornece o endereço. */
|
|
34
|
+
export interface ChatAttachment {
|
|
35
|
+
id: string
|
|
36
|
+
/** Nome do arquivo; é o texto alternativo da miniatura e o título do cartão de expirada. */
|
|
37
|
+
name: string
|
|
38
|
+
/** Tipo da imagem (`image/png`, `image/jpeg`, `image/webp`). */
|
|
39
|
+
mediaType: string
|
|
40
|
+
width?: number
|
|
41
|
+
height?: number
|
|
42
|
+
status: ChatAttachmentStatus
|
|
43
|
+
/** Endereço da imagem, servido pela própria aplicação ou em `data:` (ADR 0050). Ausente na
|
|
44
|
+
* imagem expirada. */
|
|
45
|
+
url?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
23
48
|
/** Item do transcript no modo CONTROLADO (o app é o dono do estado — ex.: sala
|
|
24
49
|
* server-autoritativa com replay, como o ChatRail do Maestro). */
|
|
25
50
|
export type ChatTranscriptItem =
|
|
26
|
-
| {
|
|
51
|
+
| {
|
|
52
|
+
id?: string
|
|
53
|
+
createdAt?: string
|
|
54
|
+
role: 'user' | 'assistant' | 'error'
|
|
55
|
+
content: string
|
|
56
|
+
/** Imagens da fala `user`, mostradas acima do texto. Ignorado nos outros papéis. */
|
|
57
|
+
attachments?: ChatAttachment[]
|
|
58
|
+
}
|
|
27
59
|
| { id?: string; createdAt?: string; role: 'artifact'; artifact: ChatArtifact }
|
|
28
60
|
|
|
29
61
|
export type ChatTranscriptMessage = Extract<ChatTranscriptItem, { content: string }>
|
|
@@ -37,9 +69,61 @@ function defaultHumanizeTool(name: string): string {
|
|
|
37
69
|
return 'Trabalhando…'
|
|
38
70
|
}
|
|
39
71
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
72
|
+
/** As imagens de uma fala da pessoa, acima do texto. São um componente próprio, e não `![]()`
|
|
73
|
+
* no Markdown: a fala continua sendo só o que a pessoa escreveu (ADR 0058). */
|
|
74
|
+
function ChatUserAttachments({ attachments }: { attachments: ChatAttachment[] }): React.ReactElement {
|
|
42
75
|
return (
|
|
76
|
+
<ul
|
|
77
|
+
data-slot="chat-user-attachments"
|
|
78
|
+
aria-label="Imagens enviadas"
|
|
79
|
+
className="flex max-w-[88%] flex-wrap justify-end gap-1.5 self-end"
|
|
80
|
+
>
|
|
81
|
+
{attachments.map((attachment) =>
|
|
82
|
+
attachment.status === 'available' && attachment.url !== undefined ? (
|
|
83
|
+
<li key={attachment.id} data-slot="chat-attachment" data-status="available">
|
|
84
|
+
<img
|
|
85
|
+
src={attachment.url}
|
|
86
|
+
alt={attachment.name}
|
|
87
|
+
width={attachment.width}
|
|
88
|
+
height={attachment.height}
|
|
89
|
+
loading="lazy"
|
|
90
|
+
decoding="async"
|
|
91
|
+
className="h-24 w-auto max-w-48 rounded-lg border border-border bg-muted object-cover"
|
|
92
|
+
/>
|
|
93
|
+
</li>
|
|
94
|
+
) : (
|
|
95
|
+
<li
|
|
96
|
+
key={attachment.id}
|
|
97
|
+
data-slot="chat-attachment"
|
|
98
|
+
data-status={attachment.status}
|
|
99
|
+
className="flex max-w-48 items-center gap-2 rounded-lg border border-dashed border-border bg-muted/40 px-3 py-2 text-xs"
|
|
100
|
+
>
|
|
101
|
+
<ImageOffIcon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
|
102
|
+
<span className="min-w-0 leading-tight">
|
|
103
|
+
<span className="block truncate font-medium text-foreground" title={attachment.name}>
|
|
104
|
+
{attachment.name}
|
|
105
|
+
</span>
|
|
106
|
+
{attachment.status === 'expired' && (
|
|
107
|
+
<span className="block text-muted-foreground">Imagem expirada</span>
|
|
108
|
+
)}
|
|
109
|
+
</span>
|
|
110
|
+
</li>
|
|
111
|
+
),
|
|
112
|
+
)}
|
|
113
|
+
</ul>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A fala do usuário: fundo discreto, largura pelo conteúdo e sem se sobrepor ao histórico.
|
|
118
|
+
* As imagens vêm acima do texto; uma fala só com imagem não mostra balão vazio. */
|
|
119
|
+
const UserTurn = React.memo(function UserTurn({
|
|
120
|
+
content,
|
|
121
|
+
attachments,
|
|
122
|
+
}: {
|
|
123
|
+
content: string
|
|
124
|
+
attachments: ChatAttachment[] | undefined
|
|
125
|
+
}): React.ReactElement {
|
|
126
|
+
const bubble = content !== '' && (
|
|
43
127
|
<div
|
|
44
128
|
data-slot="chat-user"
|
|
45
129
|
className="w-fit max-w-[88%] self-end break-words rounded-xl bg-muted px-3 py-2 text-sm leading-snug"
|
|
@@ -47,6 +131,14 @@ const UserTurn = React.memo(function UserTurn({ content }: { content: string }):
|
|
|
47
131
|
<Markdown content={content} />
|
|
48
132
|
</div>
|
|
49
133
|
)
|
|
134
|
+
if (attachments === undefined || attachments.length === 0) return <>{bubble}</>
|
|
135
|
+
// O espaço entre imagens e texto fica neste invólucro, sem mudar o ritmo da fala só com texto.
|
|
136
|
+
return (
|
|
137
|
+
<div data-slot="chat-user-content" className="flex w-full flex-col items-end gap-1.5">
|
|
138
|
+
<ChatUserAttachments attachments={attachments} />
|
|
139
|
+
{bubble}
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
50
142
|
})
|
|
51
143
|
|
|
52
144
|
export interface ChatProps {
|
|
@@ -58,9 +150,11 @@ export interface ChatProps {
|
|
|
58
150
|
/** Modo CONTROLADO: o transcript vem daqui e o app é o dono do estado (transporte
|
|
59
151
|
* próprio — SSE com replay, etc.). Presente → `onSend`/`busy`/`activity` assumem. */
|
|
60
152
|
messages?: ChatTranscriptItem[]
|
|
61
|
-
/** Modo controlado: recebe o texto enviado
|
|
62
|
-
*
|
|
63
|
-
|
|
153
|
+
/** Modo controlado: recebe o texto enviado e, só quando há `composerAttachments`, os anexos
|
|
154
|
+
* prontos (lista possivelmente vazia). Sem `composerAttachments`, a chamada é `onSend(text)`,
|
|
155
|
+
* como antes. Retornar `false` devolve o texto ao composer (falha de envio — nada de perder o
|
|
156
|
+
* que o usuário digitou); os anexos continuam na faixa, que é do app. */
|
|
157
|
+
onSend?: (text: string, attachments?: ComposerAttachment[]) => void | boolean | Promise<void | boolean>
|
|
64
158
|
/** Modo controlado: trava o composer enquanto o turno corre. */
|
|
65
159
|
busy?: boolean
|
|
66
160
|
/** Modo controlado: indicador vivo — `undefined` esconde, `null` mostra "Pensando…",
|
|
@@ -71,6 +165,10 @@ export interface ChatProps {
|
|
|
71
165
|
/** Seletores discretos na barra do composer (o `actions` do `<Composer>`): agente,
|
|
72
166
|
* app, escopo… O lugar deles é a barra — `notice` é pra aviso, não pra controle. */
|
|
73
167
|
composerActions?: React.ReactNode
|
|
168
|
+
/** Modo controlado: anexos da mensagem (o `attachments` do `<Composer>`, ADR 0058). Liga a
|
|
169
|
+
* faixa de anexos, o botão **Anexar imagem**, colar e soltar imagens sobre a conversa. O app
|
|
170
|
+
* prepara, envia, guarda e limpa os anexos. Ignorado no modo autogerenciado. */
|
|
171
|
+
composerAttachments?: ComposerAttachments
|
|
74
172
|
/** Ajuste do contêiner externo do composer. O Chat mantém `p-3` por padrão; shells
|
|
75
173
|
* integrados podem alinhar esse inset aos painéis vizinhos sem alcançar o DOM interno. */
|
|
76
174
|
composerClassName?: string
|
|
@@ -192,7 +290,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
192
290
|
data-message-created-at={turn.user.createdAt}
|
|
193
291
|
className="group/chat-message mb-1 flex flex-col items-end justify-end py-1"
|
|
194
292
|
>
|
|
195
|
-
<UserTurn content={turn.user.content} />
|
|
293
|
+
<UserTurn content={turn.user.content} attachments={turn.user.attachments} />
|
|
196
294
|
{renderMessageActions !== undefined && (
|
|
197
295
|
<div
|
|
198
296
|
data-slot="chat-message-actions"
|
|
@@ -305,6 +403,7 @@ export function Chat({
|
|
|
305
403
|
activity: activityProp,
|
|
306
404
|
notice,
|
|
307
405
|
composerActions,
|
|
406
|
+
composerAttachments,
|
|
308
407
|
composerClassName,
|
|
309
408
|
greeting,
|
|
310
409
|
empty,
|
|
@@ -323,13 +422,21 @@ export function Chat({
|
|
|
323
422
|
const [ownActivity, setOwnActivity] = React.useState<string | null>(null)
|
|
324
423
|
const historyCursor = React.useRef<number | null>(null)
|
|
325
424
|
const historyDraft = React.useRef('')
|
|
425
|
+
// Anexo só no modo controlado: o autogerenciado não tem transporte para a imagem (ADR 0058).
|
|
426
|
+
const attachments = controlled ? composerAttachments : undefined
|
|
427
|
+
const dropIntake = React.useRef<((files: File[]) => void) | null>(null)
|
|
428
|
+
const dragDepth = React.useRef(0)
|
|
429
|
+
const [dragging, setDragging] = React.useState(false)
|
|
326
430
|
|
|
327
431
|
const items = controlled ? messages : ownItems
|
|
328
432
|
const busy = controlled ? (busyProp ?? false) : ownBusy
|
|
329
433
|
// Indicador: no controlado o app manda (undefined esconde); no autogerenciado segue o busy.
|
|
330
434
|
const indicator = controlled ? activityProp : ownBusy ? ownActivity : undefined
|
|
331
435
|
const inputHistory = React.useMemo(
|
|
332
|
-
() =>
|
|
436
|
+
() =>
|
|
437
|
+
items
|
|
438
|
+
.filter((item): item is ChatMessage => item.role === 'user' && item.content !== '')
|
|
439
|
+
.map((item) => item.content),
|
|
333
440
|
[items],
|
|
334
441
|
)
|
|
335
442
|
|
|
@@ -379,13 +486,16 @@ export function Chat({
|
|
|
379
486
|
|
|
380
487
|
async function submit(): Promise<void> {
|
|
381
488
|
const text = input.trim()
|
|
382
|
-
|
|
489
|
+
const ready = (attachments?.items ?? []).filter((item) => item.status === 'ready')
|
|
490
|
+
if ((text === '' && ready.length === 0) || busy) return
|
|
383
491
|
historyCursor.current = null
|
|
384
492
|
historyDraft.current = ''
|
|
385
493
|
setInput('')
|
|
386
494
|
|
|
387
495
|
if (controlled) {
|
|
388
|
-
|
|
496
|
+
// Sem anexo ligado, a chamada é a de sempre: um handler que já tinha um segundo parâmetro
|
|
497
|
+
// opcional, de outro significado, não recebe uma lista inesperada.
|
|
498
|
+
const ok = attachments !== undefined ? await onSend?.(text, ready) : await onSend?.(text)
|
|
389
499
|
if (ok === false) setInput(text)
|
|
390
500
|
return
|
|
391
501
|
}
|
|
@@ -444,8 +554,52 @@ export function Chat({
|
|
|
444
554
|
}
|
|
445
555
|
}
|
|
446
556
|
|
|
557
|
+
// Soltar sobre a conversa inteira: a contagem de profundidade ignora o entra-e-sai dos filhos.
|
|
558
|
+
// O arraste de arquivos sobre o Chat é consumido aqui e não sobe: um contêiner do app com o
|
|
559
|
+
// próprio soltar (e a própria dica) não recebe o mesmo arraste uma segunda vez.
|
|
560
|
+
const dropHandlers: React.HTMLAttributes<HTMLDivElement> =
|
|
561
|
+
attachments === undefined
|
|
562
|
+
? {}
|
|
563
|
+
: {
|
|
564
|
+
onDragEnter: (e) => {
|
|
565
|
+
if (!dragHasFiles(e.dataTransfer)) return
|
|
566
|
+
e.stopPropagation()
|
|
567
|
+
dragDepth.current += 1
|
|
568
|
+
setDragging(true)
|
|
569
|
+
},
|
|
570
|
+
onDragOver: (e) => {
|
|
571
|
+
if (!dragHasFiles(e.dataTransfer)) return
|
|
572
|
+
e.preventDefault()
|
|
573
|
+
e.stopPropagation()
|
|
574
|
+
e.dataTransfer.dropEffect = 'copy'
|
|
575
|
+
},
|
|
576
|
+
onDragLeave: (e) => {
|
|
577
|
+
if (!dragHasFiles(e.dataTransfer)) return
|
|
578
|
+
e.stopPropagation()
|
|
579
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1)
|
|
580
|
+
if (dragDepth.current === 0) setDragging(false)
|
|
581
|
+
},
|
|
582
|
+
onDrop: (e) => {
|
|
583
|
+
if (!dragHasFiles(e.dataTransfer)) return
|
|
584
|
+
e.preventDefault()
|
|
585
|
+
e.stopPropagation()
|
|
586
|
+
dragDepth.current = 0
|
|
587
|
+
setDragging(false)
|
|
588
|
+
dropIntake.current?.(Array.from(e.dataTransfer.files))
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
|
|
447
592
|
return (
|
|
448
|
-
<div data-slot="chat" className={cn('flex h-full flex-col', className)}>
|
|
593
|
+
<div data-slot="chat" className={cn('relative flex h-full flex-col', className)} {...dropHandlers}>
|
|
594
|
+
{dragging && (
|
|
595
|
+
<div
|
|
596
|
+
data-slot="chat-drop-hint"
|
|
597
|
+
aria-hidden
|
|
598
|
+
className="pointer-events-none absolute inset-2 z-10 grid place-items-center rounded-xl border-2 border-dashed border-primary bg-background/85 text-sm text-foreground"
|
|
599
|
+
>
|
|
600
|
+
Solte imagens para anexá-las à conversa.
|
|
601
|
+
</div>
|
|
602
|
+
)}
|
|
449
603
|
<ChatTranscript
|
|
450
604
|
items={items}
|
|
451
605
|
indicator={indicator}
|
|
@@ -459,16 +613,19 @@ export function Chat({
|
|
|
459
613
|
app…); o <Composer> sozinho segue sendo o caminho de quem não tem chat. */}
|
|
460
614
|
<div data-slot="chat-composer" className={cn('shrink-0 p-3', composerClassName)}>
|
|
461
615
|
{notice}
|
|
462
|
-
<
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
616
|
+
<ComposerDropBridge.Provider value={dropIntake}>
|
|
617
|
+
<Composer
|
|
618
|
+
value={input}
|
|
619
|
+
onChange={changeInput}
|
|
620
|
+
onSubmit={() => void submit()}
|
|
621
|
+
onHistoryPrevious={showPreviousInput}
|
|
622
|
+
onHistoryNext={showNextInput}
|
|
623
|
+
busy={busy}
|
|
624
|
+
placeholder={placeholder}
|
|
625
|
+
actions={composerActions}
|
|
626
|
+
attachments={attachments}
|
|
627
|
+
/>
|
|
628
|
+
</ComposerDropBridge.Provider>
|
|
472
629
|
</div>
|
|
473
630
|
</div>
|
|
474
631
|
)
|