@softize/opus 25.7.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.
@@ -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 { Composer } from './composer.tsx'
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
- | { id?: string; createdAt?: string; role: 'user' | 'assistant' | 'error'; content: string }
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
- /** A fala do usuário: fundo discreto, largura pelo conteúdo e sem se sobrepor ao histórico. */
41
- const UserTurn = React.memo(function UserTurn({ content }: { content: string }): React.ReactElement {
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. Retornar `false` devolve o texto ao
62
- * composer (falha de envio — nada de perder o que o usuário digitou). */
63
- onSend?: (text: string) => void | boolean | Promise<void | boolean>
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
- () => items.filter((item): item is ChatMessage => item.role === 'user').map((item) => item.content),
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
- if (text === '' || busy) return
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
- const ok = await onSend?.(text)
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
- <Composer
463
- value={input}
464
- onChange={changeInput}
465
- onSubmit={() => void submit()}
466
- onHistoryPrevious={showPreviousInput}
467
- onHistoryNext={showNextInput}
468
- busy={busy}
469
- placeholder={placeholder}
470
- actions={composerActions}
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
  )