feat(web): interleave thinking blocks with tool calls
Fold thinking deltas into the turn's stream-ordered entries as a thinking entry, merged back-to-back the way the stream delivers them, instead of one flat string rendered above every call. Each thinking block now sits where it streamed — between the calls it precedes. Live turns keep their thinking expanded; finished turns render it collapsed.
This commit is contained in:
1 parent
3cab0efd39
commit
0e5a6c5658
3 files changed
+70
-47
No files matched your search
@@ -50,8 +50,20 @@ function onScroll(): void {
|
|||||||
pinned.value = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
pinned.value = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Thinking grows inside the trailing entry without changing the
|
||||||
|
// entries length, so its total length joins the watch to keep the
|
||||||
|
// stream pinned at the bottom.
|
||||||
|
function liveThinkingLength(): number {
|
||||||
|
return (
|
||||||
|
props.live?.entries.reduce(
|
||||||
|
(count, entry) => (entry.kind === 'thinking' ? count + entry.text.length : count),
|
||||||
|
0,
|
||||||
|
) ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.items.length, props.live?.reply.length, props.live?.thinking.length, props.live?.entries.length],
|
() => [props.items.length, props.live?.reply.length, liveThinkingLength(), props.live?.entries.length],
|
||||||
async () => {
|
async () => {
|
||||||
if (!pinned.value) return
|
if (!pinned.value) return
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -224,10 +236,6 @@ function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
|
|||||||
<AgentAvatar :avatar-url="avatarUrl" :mark="identity?.mark" :initials="identity?.initials ?? identity?.name?.slice(0, 1)" :size="32" />
|
<AgentAvatar :avatar-url="avatarUrl" :mark="identity?.mark" :initials="identity?.initials ?? identity?.name?.slice(0, 1)" :size="32" />
|
||||||
<div class="msg-body">
|
<div class="msg-body">
|
||||||
<div class="who">{{ agentName }}</div>
|
<div class="who">{{ agentName }}</div>
|
||||||
<details v-if="item.turn.thinking" class="thinking">
|
|
||||||
<summary>thinking</summary>
|
|
||||||
<pre>{{ item.turn.thinking }}</pre>
|
|
||||||
</details>
|
|
||||||
<TurnEntries
|
<TurnEntries
|
||||||
:entries="item.turn.entries"
|
:entries="item.turn.entries"
|
||||||
@consent-answer="(id, answer) => emit('consentAnswer', id, answer)"
|
@consent-answer="(id, answer) => emit('consentAnswer', id, answer)"
|
||||||
@@ -260,12 +268,9 @@ function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
|
|||||||
<AgentAvatar :avatar-url="avatarUrl" :mark="identity?.mark" :initials="identity?.initials ?? identity?.name?.slice(0, 1)" :size="32" />
|
<AgentAvatar :avatar-url="avatarUrl" :mark="identity?.mark" :initials="identity?.initials ?? identity?.name?.slice(0, 1)" :size="32" />
|
||||||
<div class="msg-body">
|
<div class="msg-body">
|
||||||
<div class="who">{{ agentName }}</div>
|
<div class="who">{{ agentName }}</div>
|
||||||
<details v-if="live.thinking" class="thinking" open>
|
|
||||||
<summary>thinking</summary>
|
|
||||||
<pre>{{ live.thinking }}</pre>
|
|
||||||
</details>
|
|
||||||
<TurnEntries
|
<TurnEntries
|
||||||
:entries="live.entries"
|
:entries="live.entries"
|
||||||
|
:open-thinking="true"
|
||||||
@consent-answer="(id, answer) => emit('consentAnswer', id, answer)"
|
@consent-answer="(id, answer) => emit('consentAnswer', id, answer)"
|
||||||
@consent-deny="(id) => emit('consentDeny', id)"
|
@consent-deny="(id) => emit('consentDeny', id)"
|
||||||
@download-attachment="(id, name) => emit('downloadAttachment', id, name)"
|
@download-attachment="(id, name) => emit('downloadAttachment', id, name)"
|
||||||
@@ -436,30 +441,6 @@ function streamTone(status: StreamStatus): 'ok' | 'warn' | 'error' {
|
|||||||
color: var(--accent-text);
|
color: var(--accent-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thinking {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: var(--text3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thinking summary {
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thinking pre {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
padding: 8px 10px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text3);
|
|
||||||
font-style: italic;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply {
|
.reply {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// A turn's stream-ordered middle: tool rows, the agent's asks, its
|
// A turn's stream-ordered middle: tool rows, pieces of the thinking
|
||||||
// handovers — each at the position the stream delivered it, so an ask
|
// trace, the agent's asks, its handovers — each at the position the
|
||||||
// sits at the tool call that raised it rather than at the turn's top.
|
// stream delivered it, so an ask sits at the tool call that raised it
|
||||||
|
// and thinking sits between the calls it precedes.
|
||||||
import type { TurnEntry } from '~/composables/useTranscript'
|
import type { TurnEntry } from '~/composables/useTranscript'
|
||||||
import type { ConsentAnswer } from '~/composables/useClaw'
|
import type { ConsentAnswer } from '~/composables/useClaw'
|
||||||
|
|
||||||
defineProps<{ entries: TurnEntry[] }>()
|
defineProps<{
|
||||||
|
entries: TurnEntry[]
|
||||||
|
/** A live turn's thinking blocks start expanded; finished ones collapse. */
|
||||||
|
openThinking?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
consentAnswer: [id: string, answer: ConsentAnswer]
|
consentAnswer: [id: string, answer: ConsentAnswer]
|
||||||
@@ -18,6 +23,10 @@ const emit = defineEmits<{
|
|||||||
<div v-if="entries.length > 0" class="entries">
|
<div v-if="entries.length > 0" class="entries">
|
||||||
<template v-for="(entry, index) in entries" :key="index">
|
<template v-for="(entry, index) in entries" :key="index">
|
||||||
<TurnToolRow v-if="entry.kind === 'tool'" :row="entry.row" />
|
<TurnToolRow v-if="entry.kind === 'tool'" :row="entry.row" />
|
||||||
|
<details v-else-if="entry.kind === 'thinking'" class="thinking" :open="openThinking">
|
||||||
|
<summary>thinking</summary>
|
||||||
|
<pre>{{ entry.text }}</pre>
|
||||||
|
</details>
|
||||||
<TurnConsentCard
|
<TurnConsentCard
|
||||||
v-else-if="entry.kind === 'consent'"
|
v-else-if="entry.kind === 'consent'"
|
||||||
:consent="entry.consent"
|
:consent="entry.consent"
|
||||||
@@ -40,4 +49,27 @@ const emit = defineEmits<{
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thinking {
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking pre {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text3);
|
||||||
|
font-style: italic;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -20,19 +20,21 @@ export interface ToolRow {
|
|||||||
command: string | null
|
command: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One stream-ordered thing inside a turn: a tool call, the agent's ask
|
/** One stream-ordered thing inside a turn: a tool call, a piece of the
|
||||||
* (a consent card), or a handover (an attachment). The turn renders its
|
* thinking trace (adjacent deltas merged into one block), the agent's
|
||||||
* entries in stream order, so a card sits where the event that raised
|
* ask (a consent card), or a handover (an attachment). The turn renders
|
||||||
* it happened — an ask appears at its tool call, not at the turn's top. */
|
* its entries in stream order, so a card sits where the event that
|
||||||
|
* raised it happened — an ask appears at its tool call, not at the
|
||||||
|
* turn's top, and thinking sits between the calls it precedes. */
|
||||||
export type TurnEntry =
|
export type TurnEntry =
|
||||||
| { kind: 'tool'; row: ToolRow }
|
| { kind: 'tool'; row: ToolRow }
|
||||||
|
| { kind: 'thinking'; text: string }
|
||||||
| { kind: 'consent'; consent: ConsentCard }
|
| { kind: 'consent'; consent: ConsentCard }
|
||||||
| { kind: 'attachment'; attachment: AttachmentCard }
|
| { kind: 'attachment'; attachment: AttachmentCard }
|
||||||
|
|
||||||
/** One turn as the UI renders it: thinking trace, the stream-ordered
|
/** One turn as the UI renders it: the stream-ordered entries and the
|
||||||
* entries, the streamed reply. */
|
* streamed reply. */
|
||||||
export interface TurnBlock {
|
export interface TurnBlock {
|
||||||
thinking: string
|
|
||||||
reply: string
|
reply: string
|
||||||
entries: TurnEntry[]
|
entries: TurnEntry[]
|
||||||
/** Set by `turn_done`: `handled`, `failed`, or whatever the claw reports. */
|
/** Set by `turn_done`: `handled`, `failed`, or whatever the claw reports. */
|
||||||
@@ -148,7 +150,7 @@ export function useTranscript() {
|
|||||||
let nextId = 1
|
let nextId = 1
|
||||||
|
|
||||||
function ensureLive(): TurnBlock {
|
function ensureLive(): TurnBlock {
|
||||||
if (!live.value) live.value = { thinking: '', reply: '', entries: [], outcome: null }
|
if (!live.value) live.value = { reply: '', entries: [], outcome: null }
|
||||||
return live.value
|
return live.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +158,7 @@ export function useTranscript() {
|
|||||||
* not alias the live slot's objects. */
|
* not alias the live slot's objects. */
|
||||||
function cloneEntry(entry: TurnEntry): TurnEntry {
|
function cloneEntry(entry: TurnEntry): TurnEntry {
|
||||||
if (entry.kind === 'tool') return { kind: 'tool', row: { ...entry.row } }
|
if (entry.kind === 'tool') return { kind: 'tool', row: { ...entry.row } }
|
||||||
|
if (entry.kind === 'thinking') return { kind: 'thinking', text: entry.text }
|
||||||
if (entry.kind === 'consent') return { kind: 'consent', consent: { ...entry.consent } }
|
if (entry.kind === 'consent') return { kind: 'consent', consent: { ...entry.consent } }
|
||||||
return { kind: 'attachment', attachment: { ...entry.attachment } }
|
return { kind: 'attachment', attachment: { ...entry.attachment } }
|
||||||
}
|
}
|
||||||
@@ -172,9 +175,16 @@ export function useTranscript() {
|
|||||||
items.value.push({ kind: 'sent', id: nextId++, text: event.text })
|
items.value.push({ kind: 'sent', id: nextId++, text: event.text })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'thinking_delta':
|
case 'thinking_delta': {
|
||||||
ensureLive().thinking += event.text
|
// Thinking joins the entries at its stream position: a delta
|
||||||
|
// extends the trailing block, a gap (a tool call, a reply)
|
||||||
|
// opens the next one.
|
||||||
|
const entries = ensureLive().entries
|
||||||
|
const last = entries[entries.length - 1]
|
||||||
|
if (last?.kind === 'thinking') last.text += event.text
|
||||||
|
else entries.push({ kind: 'thinking', text: event.text })
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 'text_delta':
|
case 'text_delta':
|
||||||
ensureLive().reply += event.text
|
ensureLive().reply += event.text
|
||||||
break
|
break
|
||||||
|
|||||||
Reference in new issue
Block a user