@softize/opus 15.0.1 → 15.2.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,8 +1,8 @@
1
1
  /**
2
2
  * <Page /> — esqueleto composto de página do back-office.
3
3
  *
4
- * A forma curta cobre o caso comum; a forma explícita expõe a mesma anatomia para composições
5
- * especiais. Ambas preservam o container centralizado com teto de 80rem.
4
+ * A forma curta cobre o caso comum; PageShell coordena a barra persistente quando shell e rota
5
+ * conhecem partes diferentes. Todas preservam o container centralizado com teto de 80rem.
6
6
  */
7
7
 
8
8
  import {
@@ -39,9 +39,114 @@ interface PageContextValue {
39
39
  }
40
40
 
41
41
  const PageContext = createContext<PageContextValue | null>(null);
42
- const PageHeaderContext = createContext<PageHeaderVariant | null>(null);
42
+ type PageHeadingRegion = PageHeaderVariant | "intro";
43
+
44
+ const PageHeaderContext = createContext<PageHeadingRegion | null>(null);
43
45
  const PageIntegralStateContext = createContext(false);
44
46
  const PageActionsTargetContext = createContext<HTMLElement | null>(null);
47
+ const PageShellContext = createContext(false);
48
+ const pageBarClassName =
49
+ "flex h-12 shrink-0 items-center border-b border-border bg-background text-foreground";
50
+ const pageBarContentClassName = "w-full py-2";
51
+
52
+ function PageShellNavigationSlot({
53
+ className,
54
+ ...props
55
+ }: HTMLAttributes<HTMLDivElement>): ReactElement {
56
+ return (
57
+ <div
58
+ data-slot="page-shell-navigation"
59
+ className={cn("min-w-0 flex-1 overflow-hidden", className)}
60
+ {...props}
61
+ />
62
+ );
63
+ }
64
+
65
+ function PageShellActionsSlot({
66
+ targetRef,
67
+ className,
68
+ ...props
69
+ }: HTMLAttributes<HTMLDivElement> & {
70
+ targetRef: (target: HTMLDivElement | null) => void;
71
+ }): ReactElement {
72
+ return (
73
+ <div
74
+ ref={targetRef}
75
+ data-slot="page-shell-actions"
76
+ className={cn("flex shrink-0 items-center gap-2", className)}
77
+ {...props}
78
+ />
79
+ );
80
+ }
81
+
82
+ export interface PageShellProps extends Omit<
83
+ HTMLAttributes<HTMLDivElement>,
84
+ "children"
85
+ > {
86
+ /** Navegação contextual conhecida pelo shell, normalmente um Breadcrumb. */
87
+ navigation?: ReactNode;
88
+ /** Ações do shell ou do recurso que antecedem as ações declaradas pela Page. */
89
+ actions?: ReactNode;
90
+ /** Uma Page descendente, diretamente ou através da rota ativa. */
91
+ children: ReactNode;
92
+ /** Classes do container interno da barra. */
93
+ headerClassName?: string;
94
+ }
95
+
96
+ /** Moldura persistente que coordena navegação do shell e ações da Page ativa. */
97
+ export function PageShell({
98
+ navigation,
99
+ actions,
100
+ children,
101
+ className,
102
+ headerClassName,
103
+ ...props
104
+ }: PageShellProps): ReactElement {
105
+ const [actionsTarget, setActionsTarget] = useState<HTMLDivElement | null>(null);
106
+
107
+ return (
108
+ <div
109
+ data-slot="page-shell"
110
+ className={cn(
111
+ "relative flex h-full min-h-0 min-w-0 flex-1 flex-col",
112
+ className,
113
+ )}
114
+ {...props}
115
+ >
116
+ <SurfaceHeader
117
+ name="PageShell"
118
+ slot="page"
119
+ slots={{
120
+ leading: PageShellNavigationSlot,
121
+ title: PageTitle,
122
+ description: PageDescription,
123
+ actions: PageShellActionsSlot,
124
+ }}
125
+ leadingPlacement="inline"
126
+ titleRequired={false}
127
+ contentClassName={cn(
128
+ pageBarContentClassName,
129
+ "px-3",
130
+ headerClassName,
131
+ )}
132
+ className={pageBarClassName}
133
+ data-variant="bar"
134
+ >
135
+ <PageShellNavigationSlot>{navigation}</PageShellNavigationSlot>
136
+ <PageShellActionsSlot targetRef={setActionsTarget}>
137
+ {actions}
138
+ </PageShellActionsSlot>
139
+ </SurfaceHeader>
140
+ <PageShellContext.Provider value>
141
+ <PageActionsTarget target={actionsTarget}>
142
+ <div data-slot="page-shell-content" className="min-h-0 flex-1">
143
+ {children}
144
+ </div>
145
+ </PageActionsTarget>
146
+ </PageShellContext.Provider>
147
+ </div>
148
+ );
149
+ }
45
150
 
46
151
  export function PageActionsTarget({
47
152
  target,
@@ -97,6 +202,7 @@ export function Page({
97
202
  children,
98
203
  ...props
99
204
  }: PageProps): ReactElement {
205
+ const insideShell = useContext(PageShellContext);
100
206
  const shorthand = title !== undefined;
101
207
  const nodes = Children.toArray(children);
102
208
  const headers = nodes.filter(
@@ -105,6 +211,9 @@ export function Page({
105
211
  const bodies = nodes.filter(
106
212
  (node) => isValidElement(node) && node.type === PageBody,
107
213
  );
214
+ const intros = nodes.filter(
215
+ (node) => isValidElement(node) && node.type === PageIntro,
216
+ );
108
217
  const activeStateCount = useRef(0);
109
218
  const [integralState, setIntegralState] = useState(false);
110
219
  const registerIntegralState = useCallback(() => {
@@ -127,13 +236,22 @@ export function Page({
127
236
  );
128
237
  }
129
238
  if (!shorthand) {
130
- if (
131
- headers.length !== 1 ||
132
- bodies.length !== 1 ||
133
- headers.length + bodies.length !== nodes.length
134
- ) {
239
+ const validIntroComposition =
240
+ intros.length === 1 &&
241
+ headers.length === 0 &&
242
+ bodies.length === 1 &&
243
+ intros.length + bodies.length === nodes.length;
244
+ const validStandaloneComposition =
245
+ !insideShell &&
246
+ headers.length === 1 &&
247
+ intros.length === 0 &&
248
+ bodies.length === 1 &&
249
+ headers.length + bodies.length === nodes.length;
250
+ if (!validIntroComposition && !validStandaloneComposition) {
135
251
  throw new Error(
136
- "Page explícito exige exatamente um PageHeader e um PageBody como filhos diretos.",
252
+ insideShell
253
+ ? "Page explícito dentro de PageShell exige exatamente um PageIntro e um PageBody como filhos diretos."
254
+ : "Page explícito exige exatamente um PageHeader ou PageIntro e um PageBody como filhos diretos.",
137
255
  );
138
256
  }
139
257
  }
@@ -146,7 +264,18 @@ export function Page({
146
264
  containerClassName: className,
147
265
  };
148
266
 
149
- const content = shorthand ? (
267
+ const content = shorthand && insideShell ? (
268
+ <>
269
+ <PageIntro>
270
+ <PageTitle>{title}</PageTitle>
271
+ {description !== undefined && (
272
+ <PageDescription>{description}</PageDescription>
273
+ )}
274
+ {actions !== undefined && <PageActions>{actions}</PageActions>}
275
+ </PageIntro>
276
+ <PageBody>{children}</PageBody>
277
+ </>
278
+ ) : shorthand ? (
150
279
  <>
151
280
  <PageHeader>
152
281
  <PageTitle>{title}</PageTitle>
@@ -169,6 +298,7 @@ export function Page({
169
298
  data-slot="page"
170
299
  className={cn(
171
300
  "min-w-0 flex-1",
301
+ insideShell && "min-h-full",
172
302
  (headerVariant === "bar" || integralState) && "flex flex-col",
173
303
  // A barra contém a rolagem (`min-h-0`); o estado integral ocupa a altura
174
304
  // disponível (`min-h-full`). Emitir os dois juntos deixava o resultado por
@@ -198,6 +328,47 @@ export function Page({
198
328
  );
199
329
  }
200
330
 
331
+ /** Introdução opcional do conteúdo, separada da navegação persistente do shell. */
332
+ export function PageIntro({
333
+ className,
334
+ children,
335
+ ...props
336
+ }: HTMLAttributes<HTMLDivElement>): ReactElement {
337
+ const page = useContext(PageContext);
338
+ const insideShell = useContext(PageShellContext);
339
+ const integralState = useContext(PageIntegralStateContext);
340
+ requireParent(page !== null, "PageIntro", "Page");
341
+ if (integralState) {
342
+ if (!insideShell) return <></>;
343
+ const actions = Children.toArray(children).filter(
344
+ (node) => isValidElement(node) && node.type === PageActions,
345
+ );
346
+ return (
347
+ <PageHeaderContext.Provider value="intro">
348
+ {actions}
349
+ </PageHeaderContext.Provider>
350
+ );
351
+ }
352
+
353
+ return (
354
+ <PageHeaderContext.Provider value="intro">
355
+ <SurfaceHeader
356
+ name="PageIntro"
357
+ slot="page-intro"
358
+ slots={{
359
+ title: PageTitle,
360
+ description: PageDescription,
361
+ actions: PageActions,
362
+ }}
363
+ className={className}
364
+ {...props}
365
+ >
366
+ {children}
367
+ </SurfaceHeader>
368
+ </PageHeaderContext.Provider>
369
+ );
370
+ }
371
+
201
372
  /** A anatomia é a de `SurfaceHeader`, a mesma de `ContentHeader`; só os slots mudam de nome. */
202
373
  export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {
203
374
  /** `default` fica no container; `bar` cria uma faixa compacta no topo da Page. */
@@ -228,12 +399,15 @@ export function PageHeader({
228
399
  leadingPlacement={variant === "bar" ? "inline" : "above"}
229
400
  contentClassName={
230
401
  variant === "bar"
231
- ? cn("mx-auto w-full max-w-7xl px-8 py-2", page?.containerClassName)
402
+ ? cn(
403
+ pageBarContentClassName,
404
+ "mx-auto max-w-7xl px-8",
405
+ page?.containerClassName,
406
+ )
232
407
  : undefined
233
408
  }
234
409
  className={cn(
235
- variant === "bar" &&
236
- "shrink-0 border-b border-border bg-background text-foreground",
410
+ variant === "bar" && pageBarClassName,
237
411
  className,
238
412
  )}
239
413
  {...props}
@@ -249,14 +423,16 @@ export function PageTitle({
249
423
  ...props
250
424
  }: HTMLAttributes<HTMLHeadingElement>): ReactElement {
251
425
  const variant = useContext(PageHeaderContext);
252
- requireParent(variant !== null, "PageTitle", "PageHeader");
426
+ requireParent(variant !== null, "PageTitle", "PageHeader ou PageIntro");
253
427
  return (
254
428
  <h1
255
429
  data-slot="page-title"
256
430
  className={cn(
257
431
  variant === "bar"
258
432
  ? "truncate text-sm font-semibold"
259
- : surfaceHeaderClasses.page.title,
433
+ : variant === "intro"
434
+ ? "text-3xl font-semibold tracking-tight"
435
+ : surfaceHeaderClasses.page.title,
260
436
  className,
261
437
  )}
262
438
  {...props}
@@ -269,7 +445,11 @@ export function PageDescription({
269
445
  ...props
270
446
  }: HTMLAttributes<HTMLParagraphElement>): ReactElement {
271
447
  const variant = useContext(PageHeaderContext);
272
- requireParent(variant !== null, "PageDescription", "PageHeader");
448
+ requireParent(
449
+ variant !== null,
450
+ "PageDescription",
451
+ "PageHeader ou PageIntro",
452
+ );
273
453
  return (
274
454
  <p
275
455
  data-slot="page-description"
@@ -289,13 +469,14 @@ export function PageActions({
289
469
  ...props
290
470
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
291
471
  const variant = useContext(PageHeaderContext);
292
- requireParent(variant !== null, "PageActions", "PageHeader");
472
+ requireParent(variant !== null, "PageActions", "PageHeader ou PageIntro");
293
473
  const target = useContext(PageActionsTargetContext);
474
+ const insideShell = useContext(PageShellContext);
294
475
  const actions = (
295
476
  <div
296
477
  data-slot="page-actions"
297
478
  className={cn(
298
- variant === "bar"
479
+ variant === "bar" || insideShell
299
480
  ? "flex shrink-0 items-center gap-2"
300
481
  : surfaceHeaderClasses.page.actions,
301
482
  className,
@@ -303,6 +484,7 @@ export function PageActions({
303
484
  {...props}
304
485
  />
305
486
  );
487
+ if (insideShell && target === null) return <></>;
306
488
  return target === null ? actions : createPortal(actions, target);
307
489
  }
308
490
 
@@ -57,6 +57,8 @@ export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
57
57
  leadingPlacement?: "above" | "inline";
58
58
  /** Classes de um container interno quando a moldura precisa ocupar toda a largura. */
59
59
  contentClassName?: string;
60
+ /** Permite uma superfície composta apenas pelas regiões leading/actions. */
61
+ titleRequired?: boolean;
60
62
  }
61
63
 
62
64
  /** Expande fragments de primeiro nível: o shorthand monta os slots dentro de um `<>`. */
@@ -74,6 +76,7 @@ export function SurfaceHeader({
74
76
  slots,
75
77
  leadingPlacement = "above",
76
78
  contentClassName,
79
+ titleRequired = true,
77
80
  className,
78
81
  children,
79
82
  ...props
@@ -100,7 +103,7 @@ export function SurfaceHeader({
100
103
  actionSlots.length;
101
104
 
102
105
  if (
103
- titles.length !== 1 ||
106
+ (titleRequired ? titles.length !== 1 : titles.length > 1) ||
104
107
  leading.length > 1 ||
105
108
  counts.length > 1 ||
106
109
  descriptions.length > 1 ||
@@ -130,11 +133,11 @@ export function SurfaceHeader({
130
133
  throw new Error(
131
134
  distinctLeading.size > 1
132
135
  ? `${name} aceita ${[...distinctLeading].map(label).join(" ou ")} na região introdutória, nunca os dois na mesma superfície.`
133
- : `${name} exige um ${label(slots.title)} e aceita no máximo um de cada: ${optionalSlots}.`,
136
+ : `${name} ${titleRequired ? `exige um ${label(slots.title)}` : `aceita no máximo um ${label(slots.title)}`} e aceita no máximo um de cada: ${optionalSlots}.`,
134
137
  );
135
138
  }
136
139
 
137
- const heading = (
140
+ const heading = titles.length + counts.length + descriptions.length > 0 && (
138
141
  <div
139
142
  data-slot={`${slot}-heading`}
140
143
  className={cn("min-w-0", leadingPlacement === "inline" && "flex-1")}
@@ -153,7 +156,13 @@ export function SurfaceHeader({
153
156
  {leadingPlacement === "inline" && leading.length > 0 && (
154
157
  // `min-w-0` aqui é o que deixa a trilha do PageNavigation ceder e truncar; sem ele o
155
158
  // wrapper assume o min-content do breadcrumb e a compressão toda cai sobre o título.
156
- <div data-slot={`${slot}-navigation`} className="flex min-w-0 items-center">
159
+ <div
160
+ data-slot={`${slot}-navigation`}
161
+ className={cn(
162
+ "flex min-w-0 items-center",
163
+ !heading && "flex-1",
164
+ )}
165
+ >
157
166
  {leading}
158
167
  </div>
159
168
  )}
@@ -5,6 +5,10 @@ import { Composer } from './composer.tsx'
5
5
  import { Markdown } from './markdown.tsx'
6
6
 
7
7
  export interface ChatMessage {
8
+ /** Identidade persistida pelo app. Opcional para históricos legados. */
9
+ id?: string
10
+ /** Instante ISO-8601 persistido pelo app. Opcional para históricos legados. */
11
+ createdAt?: string
8
12
  role: 'user' | 'assistant'
9
13
  content: string
10
14
  }
@@ -19,8 +23,10 @@ export interface ChatArtifact {
19
23
  /** Item do transcript no modo CONTROLADO (o app é o dono do estado — ex.: sala
20
24
  * server-autoritativa com replay, como o ChatRail do Maestro). */
21
25
  export type ChatTranscriptItem =
22
- | { role: 'user' | 'assistant' | 'error'; content: string }
23
- | { role: 'artifact'; artifact: ChatArtifact }
26
+ | { id?: string; createdAt?: string; role: 'user' | 'assistant' | 'error'; content: string }
27
+ | { id?: string; createdAt?: string; role: 'artifact'; artifact: ChatArtifact }
28
+
29
+ export type ChatTranscriptMessage = Extract<ChatTranscriptItem, { content: string }>
24
30
 
25
31
  /** Feedback humano do tool em uso (default pt-BR; override por prop). */
26
32
  function defaultHumanizeTool(name: string): string {
@@ -85,6 +91,9 @@ export interface ChatProps {
85
91
  kickoff?: () => Promise<string> | AsyncIterable<ChatEvent>
86
92
  /** Render do evento `artifact` (card, iframe, preview…). Default: link com o título. */
87
93
  renderArtifact?: (artifact: ChatArtifact) => React.ReactNode
94
+ /** Ações contextuais de uma mensagem. O Chat fornece apenas a anatomia; buscar dados
95
+ * detalhados, pedir reautorização e decidir o conteúdo pertencem ao app. */
96
+ renderMessageActions?: (message: ChatTranscriptMessage) => React.ReactNode
88
97
  /** Rótulo humano do tool em uso no indicador vivo (modo autogerenciado). */
89
98
  humanizeTool?: (name: string, detail?: string) => string
90
99
  placeholder?: string
@@ -95,6 +104,14 @@ function isAsyncIterable(value: unknown): value is AsyncIterable<ChatEvent> {
95
104
  return typeof value === 'object' && value !== null && Symbol.asyncIterator in value
96
105
  }
97
106
 
107
+ function transcriptMeta(): { id: string; createdAt: string } {
108
+ const cryptoApi = globalThis.crypto
109
+ const id = typeof cryptoApi?.randomUUID === 'function'
110
+ ? cryptoApi.randomUUID()
111
+ : `message_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
112
+ return { id, createdAt: new Date().toISOString() }
113
+ }
114
+
98
115
  /** Agrupa o transcript em turnos (mensagem do usuário + o que veio em resposta). */
99
116
  function groupTurns(items: ChatTranscriptItem[]): Array<{ user: ChatTranscriptItem | null; rest: ChatTranscriptItem[] }> {
100
117
  const turns: Array<{ user: ChatTranscriptItem | null; rest: ChatTranscriptItem[] }> = []
@@ -114,6 +131,7 @@ interface ChatTranscriptProps {
114
131
  greeting: string | undefined
115
132
  empty: React.ReactNode | undefined
116
133
  renderArtifact: ChatProps['renderArtifact']
134
+ renderMessageActions: ChatProps['renderMessageActions']
117
135
  }
118
136
 
119
137
  /**
@@ -128,6 +146,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
128
146
  greeting,
129
147
  empty,
130
148
  renderArtifact,
149
+ renderMessageActions,
131
150
  }: ChatTranscriptProps): React.ReactElement {
132
151
  const scrollRef = React.useRef<HTMLDivElement>(null)
133
152
  const turns = React.useMemo(() => groupTurns(items), [items])
@@ -164,16 +183,21 @@ const ChatTranscript = React.memo(function ChatTranscript({
164
183
  // (o agente narra o que vai fazendo), então andam juntas; quem separa é o gap
165
184
  // MAIOR entre turnos. Com o mesmo gap nos dois níveis, um turno de oito falas
166
185
  // curtas virava uma parede uniforme, sem começo nem fim visíveis.
167
- <div key={ti} className="flex flex-col gap-1">
186
+ <div key={turn.user?.id ?? turn.rest[0]?.id ?? ti} className="flex flex-col gap-1">
168
187
  {turn.user !== null && turn.user.role === 'user' && (
169
- <div data-slot="chat-turn-user" className="flex justify-end py-1.5">
188
+ <div
189
+ data-slot="chat-turn-user"
190
+ data-message-id={turn.user.id}
191
+ data-message-created-at={turn.user.createdAt}
192
+ className="flex justify-end py-1.5"
193
+ >
170
194
  <UserTurn content={turn.user.content} />
171
195
  </div>
172
196
  )}
173
197
  {turn.rest.map((item, i) => {
174
198
  if (item.role === 'artifact') {
175
199
  return (
176
- <div key={i} data-slot="chat-artifact" className="flex justify-start">
200
+ <div key={item.id ?? i} data-slot="chat-artifact" data-message-id={item.id} className="flex justify-start">
177
201
  {renderArtifact !== undefined ? (
178
202
  renderArtifact(item.artifact)
179
203
  ) : (
@@ -192,7 +216,9 @@ const ChatTranscript = React.memo(function ChatTranscript({
192
216
  if (item.role === 'error') {
193
217
  return (
194
218
  <div
195
- key={i}
219
+ key={item.id ?? i}
220
+ data-slot="chat-error"
221
+ data-message-id={item.id}
196
222
  className="max-w-[88%] self-start rounded-xl border border-context-danger-border bg-context-danger-subtle px-3.5 py-2.5 text-sm text-context-danger-emphasis"
197
223
  >
198
224
  {item.content}
@@ -201,8 +227,22 @@ const ChatTranscript = React.memo(function ChatTranscript({
201
227
  }
202
228
  // O assistente responde direto no corpo, em Markdown (convenção da casa).
203
229
  return (
204
- <div key={i} className="break-words px-1 text-sm leading-snug">
230
+ <div
231
+ key={item.id ?? i}
232
+ data-slot="chat-message"
233
+ data-message-id={item.id}
234
+ data-message-created-at={item.createdAt}
235
+ className="group/chat-message break-words px-1 text-sm leading-snug"
236
+ >
205
237
  <Markdown content={item.content} />
238
+ {renderMessageActions !== undefined && (
239
+ <div
240
+ data-slot="chat-message-actions"
241
+ className="mt-1 flex min-h-5 items-center opacity-0 transition-opacity group-hover/chat-message:opacity-100 focus-within:opacity-100"
242
+ >
243
+ {renderMessageActions(item)}
244
+ </div>
245
+ )}
206
246
  </div>
207
247
  )
208
248
  })}
@@ -257,6 +297,7 @@ export function Chat({
257
297
  initialMessages,
258
298
  kickoff,
259
299
  renderArtifact,
300
+ renderMessageActions,
260
301
  humanizeTool = defaultHumanizeTool,
261
302
  placeholder = 'Escreva uma mensagem…',
262
303
  className,
@@ -335,7 +376,7 @@ export function Chat({
335
376
  return
336
377
  }
337
378
 
338
- const next: ChatTranscriptItem[] = [...ownItems, { role: 'user', content: text }]
379
+ const next: ChatTranscriptItem[] = [...ownItems, { ...transcriptMeta(), role: 'user', content: text }]
339
380
  setOwnItems(next)
340
381
  const transcript = next.filter((i): i is ChatMessage => i.role === 'user' || i.role === 'assistant')
341
382
  await consume(() => send!(transcript))
@@ -349,16 +390,18 @@ export function Chat({
349
390
  const result = run()
350
391
  if (isAsyncIterable(result)) {
351
392
  let streaming = false
393
+ let streamingMeta: { id: string; createdAt: string } | undefined
352
394
  for await (const event of result) {
353
395
  if (event.type === 'text') {
354
396
  setOwnActivity(null)
355
397
  setOwnItems((list) => {
356
398
  const last = list[list.length - 1]
357
399
  if (streaming && last !== undefined && last.role === 'assistant') {
358
- return [...list.slice(0, -1), { role: 'assistant', content: last.content + event.delta }]
400
+ return [...list.slice(0, -1), { ...last, content: last.content + event.delta }]
359
401
  }
360
402
  streaming = true
361
- return [...list, { role: 'assistant', content: event.delta }]
403
+ streamingMeta = transcriptMeta()
404
+ return [...list, { ...streamingMeta, role: 'assistant', content: event.delta }]
362
405
  })
363
406
  } else if (event.type === 'tool') {
364
407
  streaming = false
@@ -367,20 +410,20 @@ export function Chat({
367
410
  streaming = false
368
411
  setOwnItems((list) => [
369
412
  ...list,
370
- { role: 'artifact', artifact: { kind: event.kind, ref: event.ref, title: event.title } },
413
+ { ...transcriptMeta(), role: 'artifact', artifact: { kind: event.kind, ref: event.ref, title: event.title } },
371
414
  ])
372
415
  } else if (event.type === 'done') {
373
416
  if (!event.ok) {
374
- setOwnItems((list) => [...list, { role: 'error', content: event.error ?? 'Desculpe, algo falhou.' }])
417
+ setOwnItems((list) => [...list, { ...transcriptMeta(), role: 'error', content: event.error ?? 'Desculpe, algo falhou.' }])
375
418
  }
376
419
  }
377
420
  }
378
421
  } else {
379
422
  const reply = await result
380
- setOwnItems((list) => [...list, { role: 'assistant', content: reply }])
423
+ setOwnItems((list) => [...list, { ...transcriptMeta(), role: 'assistant', content: reply }])
381
424
  }
382
425
  } catch {
383
- setOwnItems((list) => [...list, { role: 'error', content: 'Desculpe, algo falhou.' }])
426
+ setOwnItems((list) => [...list, { ...transcriptMeta(), role: 'error', content: 'Desculpe, algo falhou.' }])
384
427
  } finally {
385
428
  setOwnBusy(false)
386
429
  setOwnActivity(null)
@@ -395,6 +438,7 @@ export function Chat({
395
438
  greeting={greeting}
396
439
  empty={empty}
397
440
  renderArtifact={renderArtifact}
441
+ renderMessageActions={renderMessageActions}
398
442
  />
399
443
  {/* Composer da casa (pílula elevada, enviar dentro). Extraído no <Composer> — o Chat
400
444
  liga texto/envio e repassa `composerActions` pros seletores da conversa (agente,
@@ -92,6 +92,11 @@ Reidratação: passe `initialMessages` com o histórico persistido e troque a `k
92
92
  componente ao trocar de conversa. O rótulo do indicador é customizável por
93
93
  `humanizeTool={(name) => '…'}`.
94
94
 
95
+ Itens controlados podem carregar `id` e `createdAt`. O Chat os projeta no elemento da
96
+ mensagem e oferece `renderMessageActions` para ações contextuais discretas. O slot não
97
+ busca nem conhece detalhes: o app deve carregar informação complementar somente após a
98
+ interação da pessoa e aplicar novamente sua autorização.
99
+
95
100
  ## Propriedades de Chat
96
101
 
97
102
  | Propriedade | Tipo | Padrão | Descrição |
@@ -109,5 +114,6 @@ componente ao trocar de conversa. O rótulo do indicador é customizável por
109
114
  | `initialMessages` | `ChatMessage[]` | | Histórico inicial do modo autogerenciado; troque a `key` ao trocar de conversa. |
110
115
  | `kickoff` | `() => Promise<string> \| AsyncIterable<ChatEvent>` | | Conversa que começa pelo assistente, uma vez, quando o transcript nasce vazio. |
111
116
  | `renderArtifact` | `(artifact: ChatArtifact) => ReactNode` | link com o título | Render do evento `artifact`. |
117
+ | `renderMessageActions` | `(message: ChatTranscriptMessage) => ReactNode` | | Ações contextuais da mensagem; ficam visíveis em hover ou foco. |
112
118
  | `humanizeTool` | `(name: string, detail?: string) => string` | pt-BR embutido | Rótulo humano do tool em uso no indicador vivo. |
113
119
  | `placeholder` | `string` | `'Escreva uma mensagem…'` | Placeholder do composer. |