From ad601a77121e6333c8561a0888893eb1334945b6 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Fri, 14 Aug 2026 12:33:18 +0200 Subject: [PATCH 1/4] feat(sdk): Rovo-style shimmering thinking status replaces typing dots (CLEAN-10 US1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three bouncing dots become a "{title} is thinking…" status line with a traveling light sweep (gradient clipped to the text, CSS-only, themed via --bridle-* tokens, static under prefers-reduced-motion, role=status for AT). A 75s watchdog plus the socket close event clear the status when a turn dies without stream_end/message, so a cancelled run can't leave an infinite shimmer. Co-Authored-By: Claude Fable 5 --- sdk/src/BridleChat.ce.vue | 109 +++++++++++++++++++++++++++++--------- 1 file changed, 84 insertions(+), 25 deletions(-) diff --git a/sdk/src/BridleChat.ce.vue b/sdk/src/BridleChat.ce.vue index 8f36014..55e570e 100644 --- a/sdk/src/BridleChat.ce.vue +++ b/sdk/src/BridleChat.ce.vue @@ -169,6 +169,28 @@ const emit = defineEmits<{ const messages = ref([]) const isConnected = ref(false) const isTyping = ref(false) +// Watchdog for the shimmering thinking status: a cancelled runtime turn +// breaks its loop without ever emitting stream_end/message, so without a +// timeout the shimmer would animate forever. Every typing/stream event +// re-arms it; clearing the status disarms it. +const THINKING_STALE_MS = 75_000 +let thinkingStaleTimer: ReturnType | null = null + +function setTyping(on: boolean): void { + isTyping.value = on + if (thinkingStaleTimer) { + clearTimeout(thinkingStaleTimer) + thinkingStaleTimer = null + } + if (on) { + thinkingStaleTimer = setTimeout(() => { + thinkingStaleTimer = null + isTyping.value = false + }, THINKING_STALE_MS) + } +} + +const thinkingLabel = computed(() => `${props.title} is thinking…`) const connectionError = ref(null) const isOpen = ref(props.mode === 'inline' || coerceBool(props.defaultOpen)) const draft = ref('') @@ -306,6 +328,8 @@ async function connect(): Promise { client.on('close', () => { if (gen !== connectGen) return isConnected.value = false + // Connection gone — nothing can finish this turn, stop the shimmer. + setTyping(false) }) client.on('error', (err) => { if (gen !== connectGen) return @@ -328,17 +352,17 @@ async function connect(): Promise { }) client.on('typing', () => { if (gen !== connectGen) return - isTyping.value = true + setTyping(true) }) client.on('message', (m) => { if (gen !== connectGen) return - isTyping.value = false + setTyping(false) upsert(m) emit('message', m) }) client.on('stream', (m) => { if (gen !== connectGen) return - isTyping.value = false + setTyping(false) upsert(m) }) client.on('stream_end', (m) => { @@ -418,7 +442,7 @@ function send(): void { parts, ts: Date.now(), }) - isTyping.value = true + setTyping(true) client.send(text, parts) draft.value = '' attachments.value = [] @@ -829,7 +853,7 @@ function maybeShowGreeting(): void { } greetingShown.value = true - isTyping.value = true + setTyping(true) const raw = typeof props.greetingDelay === 'string' @@ -839,7 +863,7 @@ function maybeShowGreeting(): void { greetingTimer = setTimeout(() => { greetingTimer = null - isTyping.value = false + setTyping(false) // User snuck a message in during the delay — drop the greeting so we // don't shove it above their first turn. if (messages.value.length > 0) return @@ -905,7 +929,7 @@ async function startNewChat(): Promise { cancelGreetingTimer() messages.value = [] greetingShown.value = false - isTyping.value = false + setTyping(false) connectionError.value = null // Suppress the next transcript replay too — belt-and-braces in case the // archive endpoint was a no-op (older hub without the override) and the @@ -1023,6 +1047,7 @@ onBeforeUnmount(() => { unbindAutoColorMode() cancelGreetingTimer() cancelPopupTimer() + setTyping(false) if (typeof document !== 'undefined') { document.removeEventListener('click', onDocClick) document.removeEventListener('keydown', onDocKeydown) @@ -1411,8 +1436,15 @@ defineExpose({ {{ m.text }} -
- +
+
+ {{ thinkingLabel }} +
@@ -2052,28 +2084,55 @@ defineExpose({ background: rgba(255, 255, 255, 0.08); } -.bridle__typing { +/* ── Thinking status (Rovo-style shimmer) ───────────────────────────── + The status text carries a traveling light sweep: a gradient clipped to + the glyphs, animated across. Solid `background` first so browsers that + reject the gradient/color-mix still render readable muted text. */ +.bridle__thinking { align-self: flex-start; + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 2px; + max-width: 100%; +} +.bridle__thinking-header { display: inline-flex; - gap: 4px; - padding: 10px 14px; - background: var(--bridle-bubble-bg); - border-radius: 14px; - border-bottom-left-radius: 4px; + align-items: center; + gap: 6px; } -.bridle__typing span { - width: 6px; - height: 6px; - border-radius: 50%; +.bridle__thinking-status { + font-size: 14px; + font-weight: 500; + color: var(--bridle-muted); background: var(--bridle-muted); - animation: bridle-bounce 1.4s infinite ease-in-out; + background: linear-gradient( + 90deg, + var(--bridle-muted) 0%, + var(--bridle-muted) 35%, + color-mix(in srgb, var(--bridle-muted) 30%, var(--bridle-bg)) 50%, + var(--bridle-muted) 65%, + var(--bridle-muted) 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: bridle-shimmer 1.6s linear infinite; +} + +@keyframes bridle-shimmer { + 0% { background-position: 100% 0; } + 100% { background-position: -100% 0; } } -.bridle__typing span:nth-child(2) { animation-delay: 0.15s; } -.bridle__typing span:nth-child(3) { animation-delay: 0.3s; } -@keyframes bridle-bounce { - 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } - 30% { opacity: 1; transform: translateY(-3px); } +@media (prefers-reduced-motion: reduce) { + .bridle__thinking-status { + animation: none; + background: none; + -webkit-text-fill-color: currentColor; + color: var(--bridle-muted); + } } .bridle__input { From 905e9c2e4456227832d7800949006080a42e0273 Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Fri, 14 Aug 2026 12:44:36 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(protocol+sdk):=20thinking=20event=20?= =?UTF-8?q?=E2=80=94=20wire=20types,=20hub=20relay,=20shimmering=20step=20?= =?UTF-8?q?timeline=20(CLEAN-10=20US2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New additive 'thinking' wire event (agent → hub → browser): per-step updates {turnId, step{id,label,detail?,state}} plus a terminal {turnId, done:true}. The standalone hub whitelists and relays it like 'stream' (not admin-gated — payload is visitor-safe by contract). The SDK advertises a 'thinking' capability at handshake, and the widget renders a Rovo-style block: shimmer status header, collapsible vertical timeline of steps with expandable markdown detail, active-step sweep, auto-collapse to a summary row when the turn completes. Blocks interleave with messages by timestamp and freeze via terminal event, stale watchdog, or socket close. The agent-side lib gains sendTyping/sendThinking helpers. Co-Authored-By: Claude Fable 5 --- nestjs/domain/bridle.types.ts | 37 +++ nestjs/handlers/bridleAgentWs.handler.ts | 12 + runtime/bridle.repository.ts | 43 ++++ sdk/src/BridleChat.ce.vue | 293 ++++++++++++++++++++++- sdk/src/client.ts | 11 +- sdk/src/types.ts | 41 ++++ 6 files changed, 423 insertions(+), 14 deletions(-) diff --git a/nestjs/domain/bridle.types.ts b/nestjs/domain/bridle.types.ts index 4a58818..13d0af3 100644 --- a/nestjs/domain/bridle.types.ts +++ b/nestjs/domain/bridle.types.ts @@ -135,6 +135,7 @@ export interface IBridleOutgoingEvent { | 'stream' | 'stream_end' | 'typing' + | 'thinking' | 'ping' | 'agent_status' clientId?: string @@ -148,6 +149,42 @@ export interface IBridleOutgoingEvent { connected?: boolean } +// ── Thinking (live reasoning steps) ────────────────────────── + +/** One published unit of agent work inside a thinking timeline. */ +export interface IBridleThinkingStep { + /** Stable per-step id — the `done` update reuses the `active` event's id. */ + id: string + /** Human-readable, visitor-safe step name (e.g. "Search knowledge base"). */ + label: string + /** + * Optional visitor-safe reasoning prose (markdown). Never raw tool + * params or prompts — this event is NOT admin-gated (unlike `debug`). + */ + detail?: string + state: 'active' | 'done' +} + +/** + * Agent → Hub → Browser: live "what the agent is doing" feed, rendered by + * thinking-capable clients as a collapsible timeline while the answer is + * being prepared. Two shapes share the event: a step update (`step` set) + * and turn completion (`done: true`, no step) which closes the open block. + * The hub relays it to the addressed client like `stream`. Agents emit it + * only toward clients whose handshake `capabilities` include `'thinking'`. + */ +export interface IBridleThinkingEvent { + type: 'thinking' + clientId: string + /** Groups every step of one agent turn (minted per loop run). */ + turnId: string + /** Present on step updates; absent on the terminal `done` event. */ + step?: IBridleThinkingStep + /** True on the terminal event of a turn. */ + done?: boolean + ts: number +} + // ── Admin: debug snapshots ─────────────────────────────────── /** diff --git a/nestjs/handlers/bridleAgentWs.handler.ts b/nestjs/handlers/bridleAgentWs.handler.ts index b64aeae..6218523 100644 --- a/nestjs/handlers/bridleAgentWs.handler.ts +++ b/nestjs/handlers/bridleAgentWs.handler.ts @@ -15,6 +15,7 @@ import { type IBridleOutgoingEvent, type IBridleDebugEvent, type IBridleSyncResponse, + type IBridleThinkingEvent, } from '../domain' /** @@ -136,6 +137,17 @@ export class BridleAgentWsHandler implements OnGatewayConnection, OnGatewayDisco } } + @SubscribeMessage('thinking') + handleThinking( + @ConnectedSocket() client: Socket, + @MessageBody() data: IBridleThinkingEvent, + ) { + const agentId = client.data?.agentId as string + if (data?.clientId && data?.turnId && agentId) { + this.hub.handleAgentEvent(agentId, { ...data, type: 'thinking' }) + } + } + @SubscribeMessage('debug') handleDebug( @ConnectedSocket() client: Socket, diff --git a/runtime/bridle.repository.ts b/runtime/bridle.repository.ts index 5a5f52b..a576b10 100644 --- a/runtime/bridle.repository.ts +++ b/runtime/bridle.repository.ts @@ -159,6 +159,20 @@ export interface IBridleMessageData { capabilities?: string[] } +// ── Thinking (live reasoning steps) ────────────────────────── + +/** + * One published unit of agent work inside a thinking timeline. Pass to + * `sendThinking()` — `active` before the work starts, `done` (same `id`) + * when it finishes. Labels/detail must be visitor-safe. + */ +export interface IBridleThinkingStep { + id: string + label: string + detail?: string + state: 'active' | 'done' +} + // ── Admin protocol — debug + sync ───────────────────────────── /** @@ -269,6 +283,35 @@ export class BridleRepository implements IChannelGateway { this.syncHandler = handler } + /** + * Bare typing signal so the browser lights its thinking indicator before + * the first LLM byte (streamSend fires its own once streaming starts). + * No-op if the socket is offline. + */ + sendTyping(to: string): void { + if (!this.socket?.connected) return + this.socket.emit('typing', { clientId: to, ts: Date.now() }) + } + + /** + * Publish one thinking-timeline update: a step (`state: 'active' | 'done'`) + * or, with `step` omitted, the terminal turn-completion event. Emit only + * toward clients whose message `capabilities` include `'thinking'` — + * others can't render it. Payload must stay visitor-safe: humanized step + * labels and reasoning prose only, never raw tool params or prompts. + * No-op if the socket is offline. + */ + sendThinking(to: string, turnId: string, step?: IBridleThinkingStep): void { + if (!this.socket?.connected) return + this.socket.emit('thinking', { + type: 'thinking', + clientId: to, + turnId, + ...(step ? { step } : { done: true }), + ts: Date.now(), + }) + } + /** * Push an LLM round-trip snapshot to the hub. Hub fans it out to admin * clients only. No-op if the socket is offline. diff --git a/sdk/src/BridleChat.ce.vue b/sdk/src/BridleChat.ce.vue index 55e570e..ccb1d9a 100644 --- a/sdk/src/BridleChat.ce.vue +++ b/sdk/src/BridleChat.ce.vue @@ -7,8 +7,11 @@ import type { BridlePart, BridleUiValue, IBridleMessage, + IBridleThinkingEvent, + IBridleThinkingStep, IBridleUiPart, IBridleUiSubmitPart, + IThinkingBlock, } from './types' interface IBridleAttachment { @@ -169,10 +172,39 @@ const emit = defineEmits<{ const messages = ref([]) const isConnected = ref(false) const isTyping = ref(false) -// Watchdog for the shimmering thinking status: a cancelled runtime turn -// breaks its loop without ever emitting stream_end/message, so without a -// timeout the shimmer would animate forever. Every typing/stream event -// re-arms it; clearing the status disarms it. + +// ── Thinking timeline (live reasoning steps) ───────────────────────── +// One block per agent turn, opened by the first `thinking` event and +// frozen by the terminal `done` event (or the stale watchdog below). +// Session-only view state — nothing here is persisted or replayed. +const thinkingBlocks = ref([]) +// turnId → collapsed override. Unset means the default: open while +// thinking, collapsed once done (auto-collapse on completion). +const collapsedBlocks = ref>({}) +// stepId → detail expanded. Steps arrive collapsed. +const expandedSteps = ref>({}) + +const hasOpenThinking = computed(() => + thinkingBlocks.value.some((b) => b.status === 'thinking'), +) + +// Messages and thinking blocks interleaved by timestamp — a frozen block +// stays anchored above the answer it produced, Rovo-style. +interface IChatFlowItem { + message?: IBridleMessage + block?: IThinkingBlock + ts: number +} +const chatItems = computed(() => { + const items: IChatFlowItem[] = messages.value.map((m) => ({ message: m, ts: m.ts })) + for (const b of thinkingBlocks.value) items.push({ block: b, ts: b.ts }) + return items.sort((a, b) => a.ts - b.ts) +}) + +// Watchdog for the thinking UI: a cancelled runtime turn breaks its loop +// without ever emitting stream_end/message or the terminal thinking event, +// so without a timeout the shimmer would animate forever. Every +// typing/thinking/stream event re-arms it while anything is still open. const THINKING_STALE_MS = 75_000 let thinkingStaleTimer: ReturnType | null = null @@ -182,15 +214,79 @@ function setTyping(on: boolean): void { clearTimeout(thinkingStaleTimer) thinkingStaleTimer = null } - if (on) { + if (on || hasOpenThinking.value) { thinkingStaleTimer = setTimeout(() => { thinkingStaleTimer = null isTyping.value = false + freezeOpenThinking() }, THINKING_STALE_MS) } } const thinkingLabel = computed(() => `${props.title} is thinking…`) + +function isBlockCollapsed(b: IThinkingBlock): boolean { + return collapsedBlocks.value[b.turnId] ?? b.status === 'done' +} + +function toggleBlock(b: IThinkingBlock): void { + collapsedBlocks.value[b.turnId] = !isBlockCollapsed(b) +} + +function toggleStep(s: IBridleThinkingStep): void { + expandedSteps.value[s.id] = !expandedSteps.value[s.id] +} + +function freezeThinkingBlock(b: IThinkingBlock): void { + b.status = 'done' + b.steps = b.steps.map((s) => ({ ...s, state: 'done' as const })) +} + +function freezeOpenThinking(): void { + for (const b of thinkingBlocks.value) { + if (b.status === 'thinking') freezeThinkingBlock(b) + } +} + +function clearThinking(): void { + thinkingBlocks.value = [] + collapsedBlocks.value = {} + expandedSteps.value = {} +} + +function onThinkingEvent(e: IBridleThinkingEvent): void { + if (!e?.turnId) return + let block = thinkingBlocks.value.find((x) => x.turnId === e.turnId) + if (e.done || !e.step) { + // Terminal event — the turn is over, auto-collapse to the summary row. + if (block) freezeThinkingBlock(block) + return + } + if (block?.status === 'done') return // straggler after the block froze + if (!block) { + // Linear conversation: a new turn's first step closes any previous block. + freezeOpenThinking() + // Anchor after every message already on screen — wire timestamps come + // from the agent's clock and could otherwise sort above the user's + // message on skewed clocks. + const lastTs = messages.value.length + ? messages.value[messages.value.length - 1].ts + : 0 + block = { + turnId: e.turnId, + steps: [], + status: 'thinking', + ts: Math.max(e.ts ?? Date.now(), lastTs + 1), + } + thinkingBlocks.value.push(block) + } + const idx = block.steps.findIndex((s) => s.id === e.step!.id) + if (idx >= 0) block.steps[idx] = e.step + else block.steps.push(e.step) + // Steps mean the agent is actively working — keep the status shimmer on + // through tool execution and re-arm the stale watchdog. + setTyping(true) +} const connectionError = ref(null) const isOpen = ref(props.mode === 'inline' || coerceBool(props.defaultOpen)) const draft = ref('') @@ -328,7 +424,9 @@ async function connect(): Promise { client.on('close', () => { if (gen !== connectGen) return isConnected.value = false - // Connection gone — nothing can finish this turn, stop the shimmer. + // Connection gone — nothing can finish this turn, stop the shimmer + // and settle any open timeline into its frozen state. + freezeOpenThinking() setTyping(false) }) client.on('error', (err) => { @@ -354,6 +452,10 @@ async function connect(): Promise { if (gen !== connectGen) return setTyping(true) }) + client.on('thinking', (e) => { + if (gen !== connectGen) return + onThinkingEvent(e) + }) client.on('message', (m) => { if (gen !== connectGen) return setTyping(false) @@ -928,6 +1030,7 @@ async function startNewChat(): Promise { } cancelGreetingTimer() messages.value = [] + clearThinking() greetingShown.value = false setTyping(false) connectionError.value = null @@ -974,7 +1077,7 @@ function onKeydown(e: KeyboardEvent): void { } watch( - [messages, isTyping], + [messages, isTyping, thinkingBlocks], async () => { await nextTick() if (scrollEl.value) { @@ -991,6 +1094,7 @@ watch( () => { if (!props.apiUrl || !props.agentId) return messages.value = [] + clearThinking() cancelGreetingTimer() greetingShown.value = false void connect() @@ -1253,9 +1357,12 @@ defineExpose({ + +
= (payload: T) => void /** @@ -81,7 +81,8 @@ export class BridleClient { // What this client can render. The hub forwards the list to the agent // on every message — runtimes use it to skip part types this peer // can't display (e.g. don't emit `ui` parts to a Telegram client). - const capabilities = ['streaming', 'images', 'files', 'ui'] + // `thinking`: live reasoning-step events (SDK ≥ v0.15.0). + const capabilities = ['streaming', 'images', 'files', 'ui', 'thinking'] const socket = io(`${url}/ws/client`, { transports: ['websocket'], reconnection: true, @@ -110,6 +111,9 @@ export class BridleClient { this.fire('welcome', data) }) socket.on('typing', () => this.fire('typing', undefined)) + socket.on('thinking', (data: IBridleThinkingEvent) => + this.fire('thinking', data), + ) socket.on('message', (data: unknown) => this.fire('message', toMessage(data, 'assistant')), ) @@ -138,6 +142,7 @@ export class BridleClient { on(event: 'open' | 'close' | 'typing', handler: () => void): void on(event: 'error', handler: (error: Error) => void): void on(event: 'welcome', handler: (data: { clientId: string }) => void): void + on(event: 'thinking', handler: (data: IBridleThinkingEvent) => void): void on(event: 'message' | 'stream' | 'stream_end', handler: (message: IBridleMessage) => void): void // eslint-disable-next-line @typescript-eslint/no-explicit-any on(event: EventName, handler: (...args: any[]) => void): void { diff --git a/sdk/src/types.ts b/sdk/src/types.ts index fcb3b94..aa11c2b 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -85,6 +85,47 @@ export interface IBridleUiSubmitPart { values: Record } +// ── Thinking (live reasoning steps) ─────────────────────────── +// Agent → Browser while an answer is being prepared. Rendered as a +// Rovo-style collapsible timeline above the incoming answer. + +/** One published unit of agent work inside a thinking timeline. */ +export interface IBridleThinkingStep { + /** Stable per-step id — the `done` update reuses the `active` event's id. */ + id: string + /** Human-readable, visitor-safe step name (e.g. "Search knowledge base"). */ + label: string + /** Optional visitor-safe reasoning prose (markdown). */ + detail?: string + state: 'active' | 'done' +} + +/** + * Wire event: a step update (`step` set) or turn completion (`done: true`). + * Only delivered to clients that advertised the `thinking` capability. + */ +export interface IBridleThinkingEvent { + type: 'thinking' + clientId: string + /** Groups every step of one agent turn. */ + turnId: string + step?: IBridleThinkingStep + done?: boolean + ts: number +} + +/** + * Client-side aggregate of one turn's thinking events. Session-only view + * state — never persisted, never replayed after a page reload. + */ +export interface IThinkingBlock { + turnId: string + steps: IBridleThinkingStep[] + status: 'thinking' | 'done' + /** Arrival time of the first event — anchors the block in the chat flow. */ + ts: number +} + export interface IBridleMessage { id: string role: 'user' | 'assistant' From 127f438e8e87f8865cd3e3653302815b7e12c82d Mon Sep 17 00:00:00 2001 From: "Maksym Hryzodub [DREAM]" Date: Fri, 14 Aug 2026 12:51:20 +0200 Subject: [PATCH 3/4] docs(protocol): document the thinking event; chore(sdk): bump to 0.15.0 (CLEAN-10) Streaming doc gains a Thinking events section: payload shapes, capability gating, the compatibility matrix, and an agent-side emit example. SDK version reflects the new capability + API surface (thinking listener, IThinkingBlock, timeline UI). Co-Authored-By: Claude Fable 5 --- docs/docs/protocol/streaming.md | 47 +++++++++++++++++++++++++++++++++ sdk/package.json | 4 +-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/docs/docs/protocol/streaming.md b/docs/docs/protocol/streaming.md index 67ed392..7638c1b 100644 --- a/docs/docs/protocol/streaming.md +++ b/docs/docs/protocol/streaming.md @@ -119,3 +119,50 @@ Treat all three (`stream`, `stream_end`, `message`) as the same kind of update ``` This means the same client code that handles `parts[]` for non-streaming messages works for streams too — no special-casing. + +## Thinking events + +While the agent works with tools between text outputs, the chat would otherwise sit silent. Two mechanisms cover that window: + +1. **Early `typing`.** The runtime emits `typing` at turn start and again before each tool batch — every client (including older SDKs) shows its thinking indicator through the whole generation, not just the final response. +2. **`thinking` events** — a live, Rovo-style timeline of what the agent is doing, rendered by capable clients as named, expandable steps above the incoming answer. + +``` +thinking { clientId, turnId, step: { id, label, detail?, state }, ts } // step update +thinking { clientId, turnId, done: true, ts } // turn complete +``` + +- `turnId` groups every event of one agent turn (minted per run). +- `step.state` is `'active'` before the work starts and `'done'` (same `step.id`, updated in place) when it finishes. +- `step.label` is a humanized, visitor-safe name (e.g. `"Search knowledge base"`); `step.detail` is optional reasoning prose (markdown). **Never put raw tool params or prompts here** — unlike `debug`, this event is relayed to regular visitors, not admin-gated. +- The terminal `done: true` event closes the timeline; the widget collapses the block into a re-expandable summary row. + +### Capability gating + +The SDK (≥ v0.15.0) advertises `'thinking'` in its handshake `capabilities`. The hub forwards the list on every message, and the runtime emits `thinking` events **only** when the triggering message carried the capability. Telegram clients and older SDKs receive nothing new. + +### Compatibility matrix + +| SDK | Hub | Runtime | Behavior | +|-----|-----|---------|----------| +| old | any | new | indicator appears at turn start (early `typing`) — strict improvement | +| new | old | new | shimmer status only; the old hub's whitelist silently drops `thinking` | +| new | new | old | behavior unchanged from before | +| new | new | new | full thinking timeline | + +Every partial deployment state is safe — the event is strictly additive. Recommended rollout order: hub → runtime → SDK. + +### Emitting from an agent + +```ts +const turnId = crypto.randomUUID() +const step = { id: crypto.randomUUID(), label: 'Search knowledge base' } + +bridle.sendThinking(msg.from, turnId, { ...step, state: 'active' }) +// ... do the work ... +bridle.sendThinking(msg.from, turnId, { ...step, state: 'done' }) +// ... when the whole turn is finished: +bridle.sendThinking(msg.from, turnId) // no step ⇒ done: true +``` + +Gate on `msg.capabilities?.includes('thinking')` before emitting. diff --git a/sdk/package.json b/sdk/package.json index f4d7458..b601f5f 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,7 +1,7 @@ { "name": "@cleanslice/bridle", - "version": "0.14.0", - "description": "Embeddable web chat for Bridle — drop-in