@softize/opus 9.0.3 → 9.0.5
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
CHANGED
|
@@ -11,6 +11,19 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
11
11
|
> tinha ficado sem registro nenhum, o que é exatamente o caso que este arquivo existe
|
|
12
12
|
> pra cobrir.
|
|
13
13
|
|
|
14
|
+
## 9.0.5 — 2026-08-18
|
|
15
|
+
|
|
16
|
+
**Cards de usuário no `Chat` ficam planos enquanto percorrem o histórico.** A sombra passa
|
|
17
|
+
a indicar somente o turno efetivamente pinado no topo durante o scroll, em vez de elevar
|
|
18
|
+
todas as mensagens que apenas têm comportamento `sticky` disponível.
|
|
19
|
+
|
|
20
|
+
## 9.0.4 — 2026-08-18
|
|
21
|
+
|
|
22
|
+
**Digitar no `Chat` deixa de reprocessar o histórico inteiro.** O transcript agora é uma
|
|
23
|
+
fronteira memoizada separada do estado do composer, e cada bloco Markdown reaproveita o
|
|
24
|
+
resultado enquanto seu conteúdo não muda. Conversas longas mantêm o custo de digitação
|
|
25
|
+
constante; durante streaming, somente a mensagem alterada volta a passar pelo parser.
|
|
26
|
+
|
|
14
27
|
## 9.0.3 — 2026-08-12
|
|
15
28
|
|
|
16
29
|
**O pre-push ignora worktrees internos do Maestro.** Projetos sob `.maestro/` pertencem
|
package/package.json
CHANGED
|
@@ -49,7 +49,13 @@ function defaultHumanizeTool(name: string): string {
|
|
|
49
49
|
* dela sugere). A medida só vale COLAPSADO: aberto, `scrollHeight === clientHeight` e a
|
|
50
50
|
* pergunta "transborda?" passaria a responder não, sumindo com o botão de fechar.
|
|
51
51
|
*/
|
|
52
|
-
|
|
52
|
+
const UserTurn = React.memo(function UserTurn({
|
|
53
|
+
content,
|
|
54
|
+
pinned,
|
|
55
|
+
}: {
|
|
56
|
+
content: string
|
|
57
|
+
pinned: boolean
|
|
58
|
+
}): React.ReactElement {
|
|
53
59
|
const ref = React.useRef<HTMLDivElement>(null)
|
|
54
60
|
const [overflows, setOverflows] = React.useState(false)
|
|
55
61
|
const [expanded, setExpanded] = React.useState(false)
|
|
@@ -97,7 +103,13 @@ function UserTurn({ content }: { content: string }): React.ReactElement {
|
|
|
97
103
|
}, [content, expanded])
|
|
98
104
|
|
|
99
105
|
return (
|
|
100
|
-
<div
|
|
106
|
+
<div
|
|
107
|
+
data-slot="chat-user"
|
|
108
|
+
className={cn(
|
|
109
|
+
'break-words rounded-xl border border-border bg-card px-3 py-2 text-sm leading-snug',
|
|
110
|
+
pinned && 'shadow-sm',
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
101
113
|
<div
|
|
102
114
|
ref={ref}
|
|
103
115
|
data-slot="chat-user-body"
|
|
@@ -131,7 +143,7 @@ function UserTurn({ content }: { content: string }): React.ReactElement {
|
|
|
131
143
|
)}
|
|
132
144
|
</div>
|
|
133
145
|
)
|
|
134
|
-
}
|
|
146
|
+
})
|
|
135
147
|
|
|
136
148
|
export interface ChatProps {
|
|
137
149
|
/** Modo AUTOGERENCIADO: envia a conversa (o histórico, já com a nova mensagem) e devolve
|
|
@@ -194,6 +206,171 @@ function groupTurns(items: ChatTranscriptItem[]): Array<{ user: ChatTranscriptIt
|
|
|
194
206
|
return turns
|
|
195
207
|
}
|
|
196
208
|
|
|
209
|
+
interface ChatTranscriptProps {
|
|
210
|
+
items: ChatTranscriptItem[]
|
|
211
|
+
indicator: string | null | undefined
|
|
212
|
+
greeting: string | undefined
|
|
213
|
+
renderArtifact: ChatProps['renderArtifact']
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* O transcript é uma fronteira de renderização: editar o composer não muda nenhuma destas
|
|
218
|
+
* props e, portanto, não deve percorrer nem reprocessar o histórico. Quando o transcript
|
|
219
|
+
* realmente muda (streaming/replay), o Markdown memoizado abaixo de cada item preserva as
|
|
220
|
+
* mensagens cujo conteúdo continua igual.
|
|
221
|
+
*/
|
|
222
|
+
const ChatTranscript = React.memo(function ChatTranscript({
|
|
223
|
+
items,
|
|
224
|
+
indicator,
|
|
225
|
+
greeting,
|
|
226
|
+
renderArtifact,
|
|
227
|
+
}: ChatTranscriptProps): React.ReactElement {
|
|
228
|
+
const scrollRef = React.useRef<HTMLDivElement>(null)
|
|
229
|
+
const turns = React.useMemo(() => groupTurns(items), [items])
|
|
230
|
+
const indicatorVisible = indicator !== undefined
|
|
231
|
+
const [pinnedTurn, setPinnedTurn] = React.useState<number | null>(null)
|
|
232
|
+
const pinFrameRef = React.useRef<number | undefined>(undefined)
|
|
233
|
+
|
|
234
|
+
const measurePinnedTurn = React.useCallback((): void => {
|
|
235
|
+
const scroll = scrollRef.current
|
|
236
|
+
if (scroll === null) return
|
|
237
|
+
|
|
238
|
+
const rect = scroll.getBoundingClientRect()
|
|
239
|
+
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + 1)
|
|
240
|
+
const header = hit?.closest<HTMLElement>('[data-slot="chat-turn-user"]') ?? null
|
|
241
|
+
const index = Number(header?.dataset['turn'])
|
|
242
|
+
const next = header !== null && Number.isInteger(index) ? index : null
|
|
243
|
+
setPinnedTurn((current) => current === next ? current : next)
|
|
244
|
+
}, [])
|
|
245
|
+
|
|
246
|
+
const schedulePinnedTurnMeasure = React.useCallback((): void => {
|
|
247
|
+
if (pinFrameRef.current !== undefined) return
|
|
248
|
+
pinFrameRef.current = requestAnimationFrame(() => {
|
|
249
|
+
pinFrameRef.current = undefined
|
|
250
|
+
measurePinnedTurn()
|
|
251
|
+
})
|
|
252
|
+
}, [measurePinnedTurn])
|
|
253
|
+
|
|
254
|
+
React.useLayoutEffect(() => {
|
|
255
|
+
const scroll = scrollRef.current
|
|
256
|
+
if (scroll === null) return
|
|
257
|
+
measurePinnedTurn()
|
|
258
|
+
scroll.addEventListener('scroll', schedulePinnedTurnMeasure, { passive: true })
|
|
259
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
260
|
+
return () => {
|
|
261
|
+
if (pinFrameRef.current !== undefined) {
|
|
262
|
+
cancelAnimationFrame(pinFrameRef.current)
|
|
263
|
+
pinFrameRef.current = undefined
|
|
264
|
+
}
|
|
265
|
+
scroll.removeEventListener('scroll', schedulePinnedTurnMeasure)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const observer = new ResizeObserver(measurePinnedTurn)
|
|
269
|
+
observer.observe(scroll)
|
|
270
|
+
return () => {
|
|
271
|
+
if (pinFrameRef.current !== undefined) {
|
|
272
|
+
cancelAnimationFrame(pinFrameRef.current)
|
|
273
|
+
pinFrameRef.current = undefined
|
|
274
|
+
}
|
|
275
|
+
observer.disconnect()
|
|
276
|
+
scroll.removeEventListener('scroll', schedulePinnedTurnMeasure)
|
|
277
|
+
}
|
|
278
|
+
}, [turns.length, measurePinnedTurn, schedulePinnedTurnMeasure])
|
|
279
|
+
|
|
280
|
+
React.useEffect(() => {
|
|
281
|
+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
|
|
282
|
+
measurePinnedTurn()
|
|
283
|
+
}, [items, indicator, measurePinnedTurn])
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<div
|
|
287
|
+
ref={scrollRef}
|
|
288
|
+
data-slot="chat-scroll"
|
|
289
|
+
className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4"
|
|
290
|
+
>
|
|
291
|
+
{items.length === 0 && !indicatorVisible && greeting !== undefined && (
|
|
292
|
+
<div data-slot="chat-empty" className="m-auto max-w-[280px] text-center text-muted-foreground">
|
|
293
|
+
<div className="mb-2 text-4xl">✦</div>
|
|
294
|
+
<p className="text-sm leading-relaxed">{greeting}</p>
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
{turns.map((turn, ti) => (
|
|
298
|
+
// Hierarquia do espaço: as falas de um mesmo turno são um raciocínio contínuo
|
|
299
|
+
// (o agente narra o que vai fazendo), então andam juntas; quem separa é o gap
|
|
300
|
+
// MAIOR entre turnos. Com o mesmo gap nos dois níveis, um turno de oito falas
|
|
301
|
+
// curtas virava uma parede uniforme, sem começo nem fim visíveis.
|
|
302
|
+
<div key={ti} className="flex flex-col gap-1">
|
|
303
|
+
{turn.user !== null && turn.user.role === 'user' && (
|
|
304
|
+
// Cabeçalho do turno: gruda no topo enquanto o turno está em vista (igual
|
|
305
|
+
// Claude) — a mensagem do usuário num card, não num balão preenchido.
|
|
306
|
+
<div
|
|
307
|
+
data-slot="chat-turn-user"
|
|
308
|
+
data-turn={ti}
|
|
309
|
+
data-pinned={pinnedTurn === ti ? 'true' : undefined}
|
|
310
|
+
className="sticky top-0 z-10 py-1.5"
|
|
311
|
+
>
|
|
312
|
+
<UserTurn content={turn.user.content} pinned={pinnedTurn === ti} />
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
{turn.rest.map((item, i) => {
|
|
316
|
+
if (item.role === 'artifact') {
|
|
317
|
+
return (
|
|
318
|
+
<div key={i} data-slot="chat-artifact" className="flex justify-start">
|
|
319
|
+
{renderArtifact !== undefined ? (
|
|
320
|
+
renderArtifact(item.artifact)
|
|
321
|
+
) : (
|
|
322
|
+
<a
|
|
323
|
+
href={item.artifact.ref}
|
|
324
|
+
target="_blank"
|
|
325
|
+
rel="noreferrer"
|
|
326
|
+
className="rounded-xl border bg-card px-3.5 py-2 text-sm underline-offset-2 hover:underline"
|
|
327
|
+
>
|
|
328
|
+
{item.artifact.title ?? item.artifact.ref}
|
|
329
|
+
</a>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
if (item.role === 'error') {
|
|
335
|
+
return (
|
|
336
|
+
<div
|
|
337
|
+
key={i}
|
|
338
|
+
className="max-w-[88%] self-start rounded-xl border border-destructive/30 bg-destructive/10 px-3.5 py-2.5 text-sm text-destructive"
|
|
339
|
+
>
|
|
340
|
+
{item.content}
|
|
341
|
+
</div>
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
// O assistente responde direto no corpo, em Markdown (convenção da casa).
|
|
345
|
+
return (
|
|
346
|
+
<div key={i} className="break-words px-1 text-sm leading-snug">
|
|
347
|
+
<Markdown content={item.content} />
|
|
348
|
+
</div>
|
|
349
|
+
)
|
|
350
|
+
})}
|
|
351
|
+
</div>
|
|
352
|
+
))}
|
|
353
|
+
{indicatorVisible && (
|
|
354
|
+
<div
|
|
355
|
+
data-slot="chat-activity"
|
|
356
|
+
className="flex items-center gap-2 py-1 text-xs text-muted-foreground"
|
|
357
|
+
>
|
|
358
|
+
<span className="flex gap-1">
|
|
359
|
+
{[0, 1, 2].map((i) => (
|
|
360
|
+
<span
|
|
361
|
+
key={i}
|
|
362
|
+
className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
|
363
|
+
style={{ animationDelay: `${i * 150}ms` }}
|
|
364
|
+
/>
|
|
365
|
+
))}
|
|
366
|
+
</span>
|
|
367
|
+
<span>{indicator ?? 'Pensando…'}</span>
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
)
|
|
372
|
+
})
|
|
373
|
+
|
|
197
374
|
/**
|
|
198
375
|
* Chat da casa: lista de mensagens + composer (Enter envia / Shift+Enter quebra linha),
|
|
199
376
|
* transcript em turnos com o cabeçalho sticky (a pergunta fica à vista enquanto a
|
|
@@ -229,17 +406,12 @@ export function Chat({
|
|
|
229
406
|
const [input, setInput] = React.useState('')
|
|
230
407
|
const [ownBusy, setOwnBusy] = React.useState(false)
|
|
231
408
|
const [ownActivity, setOwnActivity] = React.useState<string | null>(null)
|
|
232
|
-
const scrollRef = React.useRef<HTMLDivElement>(null)
|
|
233
409
|
|
|
234
410
|
const items = controlled ? messages : ownItems
|
|
235
411
|
const busy = controlled ? (busyProp ?? false) : ownBusy
|
|
236
412
|
// Indicador: no controlado o app manda (undefined esconde); no autogerenciado segue o busy.
|
|
237
413
|
const indicator = controlled ? activityProp : ownBusy ? ownActivity : undefined
|
|
238
414
|
|
|
239
|
-
React.useEffect(() => {
|
|
240
|
-
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
|
|
241
|
-
}, [items, indicator])
|
|
242
|
-
|
|
243
415
|
const kickoffRan = React.useRef(false)
|
|
244
416
|
React.useEffect(() => {
|
|
245
417
|
if (controlled || kickoff === undefined || kickoffRan.current) return
|
|
@@ -312,86 +484,14 @@ export function Chat({
|
|
|
312
484
|
}
|
|
313
485
|
}
|
|
314
486
|
|
|
315
|
-
const indicatorVisible = indicator !== undefined
|
|
316
|
-
|
|
317
487
|
return (
|
|
318
488
|
<div data-slot="chat" className={cn('flex h-full flex-col', className)}>
|
|
319
|
-
<
|
|
320
|
-
{items
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
)}
|
|
326
|
-
{groupTurns(items).map((turn, ti) => (
|
|
327
|
-
// Hierarquia do espaço: as falas de um mesmo turno são um raciocínio contínuo
|
|
328
|
-
// (o agente narra o que vai fazendo), então andam juntas; quem separa é o gap
|
|
329
|
-
// MAIOR entre turnos. Com o mesmo gap nos dois níveis, um turno de oito falas
|
|
330
|
-
// curtas virava uma parede uniforme, sem começo nem fim visíveis.
|
|
331
|
-
<div key={ti} className="flex flex-col gap-1">
|
|
332
|
-
{turn.user !== null && turn.user.role === 'user' && (
|
|
333
|
-
// Cabeçalho do turno: gruda no topo enquanto o turno está em vista (igual
|
|
334
|
-
// Claude) — a mensagem do usuário num card, não num balão preenchido.
|
|
335
|
-
<div className="sticky top-0 z-10 py-1.5">
|
|
336
|
-
<UserTurn content={turn.user.content} />
|
|
337
|
-
</div>
|
|
338
|
-
)}
|
|
339
|
-
{turn.rest.map((item, i) => {
|
|
340
|
-
if (item.role === 'artifact') {
|
|
341
|
-
return (
|
|
342
|
-
<div key={i} data-slot="chat-artifact" className="flex justify-start">
|
|
343
|
-
{renderArtifact !== undefined ? (
|
|
344
|
-
renderArtifact(item.artifact)
|
|
345
|
-
) : (
|
|
346
|
-
<a
|
|
347
|
-
href={item.artifact.ref}
|
|
348
|
-
target="_blank"
|
|
349
|
-
rel="noreferrer"
|
|
350
|
-
className="rounded-xl border bg-card px-3.5 py-2 text-sm underline-offset-2 hover:underline"
|
|
351
|
-
>
|
|
352
|
-
{item.artifact.title ?? item.artifact.ref}
|
|
353
|
-
</a>
|
|
354
|
-
)}
|
|
355
|
-
</div>
|
|
356
|
-
)
|
|
357
|
-
}
|
|
358
|
-
if (item.role === 'error') {
|
|
359
|
-
return (
|
|
360
|
-
<div
|
|
361
|
-
key={i}
|
|
362
|
-
className="max-w-[88%] self-start rounded-xl border border-destructive/30 bg-destructive/10 px-3.5 py-2.5 text-sm text-destructive"
|
|
363
|
-
>
|
|
364
|
-
{item.content}
|
|
365
|
-
</div>
|
|
366
|
-
)
|
|
367
|
-
}
|
|
368
|
-
// O assistente responde direto no corpo, em Markdown (convenção da casa).
|
|
369
|
-
return (
|
|
370
|
-
<div
|
|
371
|
-
key={i}
|
|
372
|
-
className="break-words px-1 text-sm leading-snug"
|
|
373
|
-
>
|
|
374
|
-
<Markdown content={item.content} />
|
|
375
|
-
</div>
|
|
376
|
-
)
|
|
377
|
-
})}
|
|
378
|
-
</div>
|
|
379
|
-
))}
|
|
380
|
-
{indicatorVisible && (
|
|
381
|
-
<div data-slot="chat-activity" className="flex items-center gap-2 py-1 text-xs text-muted-foreground">
|
|
382
|
-
<span className="flex gap-1">
|
|
383
|
-
{[0, 1, 2].map((i) => (
|
|
384
|
-
<span
|
|
385
|
-
key={i}
|
|
386
|
-
className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/50"
|
|
387
|
-
style={{ animationDelay: `${i * 150}ms` }}
|
|
388
|
-
/>
|
|
389
|
-
))}
|
|
390
|
-
</span>
|
|
391
|
-
<span>{indicator ?? 'Pensando…'}</span>
|
|
392
|
-
</div>
|
|
393
|
-
)}
|
|
394
|
-
</div>
|
|
489
|
+
<ChatTranscript
|
|
490
|
+
items={items}
|
|
491
|
+
indicator={indicator}
|
|
492
|
+
greeting={greeting}
|
|
493
|
+
renderArtifact={renderArtifact}
|
|
494
|
+
/>
|
|
395
495
|
{/* Composer da casa (pílula elevada, enviar dentro). Extraído no <Composer> — o Chat
|
|
396
496
|
liga texto/envio e repassa `composerActions` pros seletores da conversa (agente,
|
|
397
497
|
app…); o <Composer> sozinho segue sendo o caminho de quem não tem chat. */}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* A tipografia é delegada ao `prose` (@tailwindcss/typography), mapeado pros tokens da casa
|
|
11
11
|
* no theme.css; as tags saem SEMÂNTICAS e sem classe.
|
|
12
12
|
*/
|
|
13
|
+
import * as React from 'react'
|
|
13
14
|
import MarkdownIt from 'markdown-it'
|
|
14
15
|
|
|
15
16
|
/** A instância ÚNICA — a doc (`DocMarkdown`) importa esta mesma. Duas instâncias com
|
|
@@ -21,7 +22,10 @@ export interface MarkdownProps {
|
|
|
21
22
|
className?: string
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
export
|
|
25
|
+
export const Markdown = React.memo(function Markdown({
|
|
26
|
+
content,
|
|
27
|
+
className,
|
|
28
|
+
}: MarkdownProps): React.ReactElement {
|
|
25
29
|
return (
|
|
26
30
|
<div
|
|
27
31
|
data-slot="markdown"
|
|
@@ -32,4 +36,4 @@ export function Markdown({ content, className }: MarkdownProps): React.ReactElem
|
|
|
32
36
|
dangerouslySetInnerHTML={{ __html: markdownIt.render(content) }}
|
|
33
37
|
/>
|
|
34
38
|
)
|
|
35
|
-
}
|
|
39
|
+
})
|