From a77afd13c0c12ccdbdd23e05ce721682cd8e3d6e Mon Sep 17 00:00:00 2001 From: Om Gupta Date: Fri, 1 May 2026 18:03:43 +0530 Subject: [PATCH] feat(chat): readability polish + message actions, regenerate, feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump markdown/message font sizes and tighten line-height for readability - Redesign composer Add menu: larger surface, viewport clamping, brand-chip stack with motion - Add per-message actions on assistant turns: copy, thumbs up/down, regenerate - Wire thumbs up/down feedback to Braintrust via per-turn (messageId → spanId) registration and a new `feedback` protocol message - Add `regenerate` protocol message + server handler: pop the last user→ assistant pair on Pi-SDK sessions, or re-feed the prior user text on Codex harness sessions (legacy Claude Code harness not supported) - Auto-collapse TurnProgress steps once the agent finishes writing to the main conversation, while respecting any manual toggle the user makes Co-Authored-By: Claude Opus 4.7 --- packages/agent-core/src/harness/index.ts | 2 + packages/agent-core/src/harness/mirror.ts | 106 ++++++++++++ packages/agent-core/src/index.ts | 4 +- packages/agent-core/src/session.ts | 115 +++++++++++++ packages/agent-core/src/tracing.ts | 23 +++ packages/agent-server/src/server.ts | 136 +++++++++++++++- .../src/components/chat/ComposerAddMenu.tsx | 16 +- .../src/components/chat/MessageBubble.tsx | 98 ++++++++++- .../src/components/chat/TurnProgress.tsx | 17 +- packages/desktop/src/index.css | 154 ++++++++++++++---- packages/desktop/src/lib/connection.ts | 8 + packages/desktop/src/lib/store.ts | 49 ++++++ .../lib/store/handlers/interactionHandler.ts | 8 + packages/protocol/src/messages.ts | 23 +++ 14 files changed, 710 insertions(+), 49 deletions(-) diff --git a/packages/agent-core/src/harness/index.ts b/packages/agent-core/src/harness/index.ts index bbdd3711..ab11d427 100644 --- a/packages/agent-core/src/harness/index.ts +++ b/packages/agent-core/src/harness/index.ts @@ -54,7 +54,9 @@ export { synthesizeHarnessTurn, ensureHarnessSessionInit, appendHarnessTurn, + popLastTurnFromHarness, readHarnessHistory, + readLastUserFromHarness, writeHarnessSessionTitle, type HarnessSessionInitOpts, type AppendHarnessTurnOpts, diff --git a/packages/agent-core/src/harness/mirror.ts b/packages/agent-core/src/harness/mirror.ts index 10d11124..6c9b1155 100644 --- a/packages/agent-core/src/harness/mirror.ts +++ b/packages/agent-core/src/harness/mirror.ts @@ -451,6 +451,112 @@ export function readHarnessHistory(sessionId: string, projectId?: string): Sessi return entries } +// ── Regenerate helpers ───────────────────────────────────────────── + +/** + * Read the last user message text from `messages.jsonl` without mutating + * the file. Used by the Codex regenerate path: re-feeding the same user + * text as a fresh turn produces a regenerated assistant response while + * leaving the CLI's own thread state intact (we can't truncate Codex's + * internal history without a `thread/resume` RPC, which doesn't exist). + */ +export function readLastUserFromHarness( + sessionId: string, + projectId?: string, +): { userText: string } | null { + const dir = resolveSessionDir(sessionId, projectId) + const msgsPath = join(dir, 'messages.jsonl') + if (!existsSync(msgsPath)) return null + + let raw: string + try { + raw = readFileSync(msgsPath, 'utf-8') + } catch (err) { + log.warn({ err, sessionId }, 'failed to read messages.jsonl for regenerate') + return null + } + + const lines = raw.split('\n').filter((l) => l.trim().length > 0) + for (let i = lines.length - 1; i >= 0; i--) { + let parsed: { role?: unknown; content?: unknown } + try { + parsed = JSON.parse(lines[i]) + } catch { + continue + } + if (parsed.role !== 'user') continue + let userText = '' + if (typeof parsed.content === 'string') { + userText = parsed.content + } else if (Array.isArray(parsed.content)) { + const blocks = parsed.content as Array<{ type?: string; text?: string }> + userText = blocks + .filter((b) => b?.type === 'text' && typeof b.text === 'string') + .map((b) => b.text as string) + .join('') + } + return { userText } + } + return null +} + +/** + * Pop the last user→assistant exchange from `messages.jsonl`, returning + * the user's text so the caller can re-feed it to a fresh CLI process. + * + * Walks backwards looking for the last `role:'user'` line, truncates the + * file to lines preceding it, and extracts the plain-text content. Tool + * messages between the user and assistant are dropped along with the + * assistant turn. + * + * Returns null if there's no prior user message — nothing to regenerate. + */ +export function popLastTurnFromHarness( + sessionId: string, + projectId?: string, +): { userText: string } | null { + const dir = resolveSessionDir(sessionId, projectId) + const msgsPath = join(dir, 'messages.jsonl') + if (!existsSync(msgsPath)) return null + + let raw: string + try { + raw = readFileSync(msgsPath, 'utf-8') + } catch (err) { + log.warn({ err, sessionId }, 'failed to read messages.jsonl for regenerate') + return null + } + + const lines = raw.split('\n').filter((l) => l.trim().length > 0) + let lastUserIdx = -1 + let userText = '' + for (let i = lines.length - 1; i >= 0; i--) { + let parsed: { role?: unknown; content?: unknown } + try { + parsed = JSON.parse(lines[i]) + } catch { + continue + } + if (parsed.role !== 'user') continue + lastUserIdx = i + if (typeof parsed.content === 'string') { + userText = parsed.content + } else if (Array.isArray(parsed.content)) { + const textBlocks = parsed.content as Array<{ type?: string; text?: string }> + userText = textBlocks + .filter((b) => b?.type === 'text' && typeof b.text === 'string') + .map((b) => b.text as string) + .join('') + } + break + } + if (lastUserIdx < 0) return null + + const truncated = lines.slice(0, lastUserIdx).join('\n') + writeFileSync(msgsPath, truncated.length > 0 ? `${truncated}\n` : '', 'utf-8') + return { userText } +} + // ── Internal helpers ─────────────────────────────────────────────── function resolveSessionDir(sessionId: string, projectId?: string): string { diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 667efbe6..9c097736 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -69,7 +69,7 @@ export { type ProviderTokenResolver, type ResolvedProviderToken, } from './tools/factories.js' -export { initTracing, flushTraces, hashPromptVersion } from './tracing.js' +export { initTracing, flushTraces, hashPromptVersion, logSpanFeedback } from './tracing.js' export { closeBrowserSession } from './tools/browser.js' export { type HarnessAdapter, @@ -99,7 +99,9 @@ export { synthesizeHarnessTurn, ensureHarnessSessionInit, appendHarnessTurn, + popLastTurnFromHarness, readHarnessHistory, + readLastUserFromHarness, writeHarnessSessionTitle, buildReplaySeed, extractHarnessMemoriesFromMirror, diff --git a/packages/agent-core/src/session.ts b/packages/agent-core/src/session.ts index 48ebd7bf..40f339be 100644 --- a/packages/agent-core/src/session.ts +++ b/packages/agent-core/src/session.ts @@ -289,6 +289,7 @@ import { estimateCost, hashPromptVersion, logScore, + logSpanFeedback, startChildTrace, startTrace, } from './tracing.js' @@ -478,6 +479,13 @@ export class Session { private parentTraceSpan?: Span // when this is a sub-agent, inherit parent's span private workflowMetadata?: { workflowId: string; agentKey: string; promptVersion: string } currentTraceSpan?: Span // current turn's trace span (exposed for sub-agent threading) + // (messageId → Braintrust span id) for completed turns in this session. + // Bounded to the last 200 turns; older entries fall off the front when + // capacity is exceeded so long-lived sessions don't grow unbounded. + // Server assigns the messageId, registers it via `registerAssistantMessage`, + // and clients echo it back via the `feedback` protocol message. + private messageSpanIds: Map = new Map() + private static readonly MESSAGE_SPAN_CAP = 200 private _promptVersion?: string // hash of assembled system prompt // Safety limits @@ -2307,6 +2315,113 @@ export class Session { return structuredClone(this.piAgent.state.messages) } + /** + * Get the Braintrust span id for the current turn. Returns undefined if + * tracing is disabled or if called outside an active turn. Used by the + * server to register the (messageId → spanId) mapping when emitting the + * `done` event so feedback can be correlated post-hoc. + */ + getCurrentSpanId(): string | undefined { + const span = this.currentTraceSpan as (Span & { id?: string }) | undefined + return typeof span?.id === 'string' ? span.id : undefined + } + + /** + * Pair a server-assigned messageId with a Braintrust span id so the + * server's feedback handler can attach thumbs-up/down to the right + * event later. Pruned to MESSAGE_SPAN_CAP entries (oldest first). + */ + registerAssistantMessage(messageId: string, spanId: string): void { + if (this.messageSpanIds.size >= Session.MESSAGE_SPAN_CAP) { + const firstKey = this.messageSpanIds.keys().next().value + if (firstKey !== undefined) this.messageSpanIds.delete(firstKey) + } + this.messageSpanIds.set(messageId, spanId) + } + + /** + * Record thumbs-up/down user feedback for a specific assistant message. + * No-op when tracing is disabled or the messageId is unknown (e.g. session + * was resumed from disk and the in-memory map didn't survive the restart). + */ + logUserFeedback(messageId: string, value: 'up' | 'down'): void { + const spanId = this.messageSpanIds.get(messageId) + if (!spanId) return + logSpanFeedback(spanId, { + user_feedback: value === 'up' ? 1 : 0, + }) + } + + /** + * Prepare the session for regenerating its last assistant response. + * + * Pops the last user→assistant pair from `piAgent.state.messages` and + * returns the user content (text + image attachments) so the caller can + * re-feed it via `processMessage`. Returns null if there's nothing to + * regenerate (no prior user turn). + * + * This does NOT call processMessage itself — that's the caller's job, so + * the existing event-streaming pipeline in the server stays in one place. + */ + prepareRegenerate(): { + userText: string + attachments: ChatImageAttachmentInput[] + } | null { + type ContentBlock = { + type: string + text?: string + mimeType?: string + data?: string + name?: string + sizeBytes?: number + } + type RawMsg = { role: string; content?: string | ContentBlock[] } + + const messages = this.piAgent.state.messages as RawMsg[] + let lastAssistantIdx = -1 + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'assistant') { + lastAssistantIdx = i + break + } + } + if (lastAssistantIdx < 0) return null + + let lastUserIdx = -1 + for (let i = lastAssistantIdx - 1; i >= 0; i--) { + if (messages[i].role === 'user') { + lastUserIdx = i + break + } + } + if (lastUserIdx < 0) return null + + const userMsg = messages[lastUserIdx] + let userText = '' + const attachments: ChatImageAttachmentInput[] = [] + if (typeof userMsg.content === 'string') { + userText = userMsg.content + } else if (Array.isArray(userMsg.content)) { + for (const block of userMsg.content) { + if (block.type === 'text') userText += block.text ?? '' + else if (block.type === 'image' && block.data && block.mimeType) { + attachments.push({ + id: `regen-${attachments.length}`, + name: block.name ?? 'image', + mimeType: block.mimeType, + data: block.data, + sizeBytes: block.sizeBytes ?? 0, + }) + } + } + } + + // Truncate to before the last user message — caller will re-feed it. + this.piAgent.replaceMessages(messages.slice(0, lastUserIdx) as AgentMessage[]) + this.persist() + return { userText, attachments } + } + /** Get the current tools array. Used by fork sub-agents. */ getTools(): import('@mariozechner/pi-agent-core').AgentTool[] { return [...this.piAgent.state.tools] diff --git a/packages/agent-core/src/tracing.ts b/packages/agent-core/src/tracing.ts index cc605878..8fce276a 100644 --- a/packages/agent-core/src/tracing.ts +++ b/packages/agent-core/src/tracing.ts @@ -103,6 +103,29 @@ export function logScore( }) } +/** + * Log feedback (e.g. thumbs up/down) against a span by its id. Works AFTER + * the span has been ended — Braintrust treats this as a post-hoc score on + * the persisted event. + */ +export function logSpanFeedback( + spanId: string, + scores: Record, + metadata?: Record, +): void { + if (!tracingEnabled || !logger) return + try { + logger.logFeedback({ + id: spanId, + scores, + ...(metadata ? { metadata } : {}), + source: 'app', + }) + } catch (err) { + log.warn({ err, spanId }, 'logSpanFeedback failed') + } +} + // ── Cost estimation ────────────────────────────────────────────────── /** Price per million tokens: { input, output } in USD. */ diff --git a/packages/agent-server/src/server.ts b/packages/agent-server/src/server.ts index ab6cef26..68fe04d0 100644 --- a/packages/agent-server/src/server.ts +++ b/packages/agent-server/src/server.ts @@ -116,6 +116,7 @@ import { matchesSurface, probeMcpShim, readHarnessHistory, + readLastUserFromHarness, resolveModel, resumeSession, synthesizeHarnessTurn, @@ -1994,6 +1995,26 @@ export class AgentServer { this.promptResolvers.get(msg.id)!(msg) } break + + // ── Feedback (thumbs up/down) — relayed to Braintrust ── + case 'feedback': { + const fbSessionId = msg.sessionId || DEFAULT_SESSION_ID + const fbSession = this.sessions.get(fbSessionId) + if (fbSession && !isHarnessSession(fbSession) && msg.messageId) { + fbSession.logUserFeedback(msg.messageId, msg.value) + } + log.info( + { messageId: msg.messageId, sessionId: fbSessionId, value: msg.value }, + 'User feedback recorded', + ) + break + } + + // ── Regenerate last assistant turn ── + case 'regenerate': { + await this.handleRegenerate(msg as { messageId: string; sessionId?: string }) + break + } } } @@ -5328,6 +5349,12 @@ export class AgentServer { const turnStartMs = Date.now() let accumulatedText = '' let toolCallCount = 0 + // Per-turn id for the assistant message produced by this turn. Sent + // back on the `done` event so the client adopts it as the message id; + // also paired with the Braintrust span (if tracing is on) so the + // feedback handler can later attach thumbs up/down to the right event. + const assistantMessageId = `m_${randomBytes(8).toString('base64url')}` + let spanRegistered = false // Track update_project_context tool call data const pendingToolNames = new Map() let projectContextUpdate: { sessionSummary?: string; projectSummary?: string } | null = null @@ -5341,6 +5368,16 @@ export class AgentServer { if (event.type === 'text') { accumulatedText += event.content textBuffer.push(event.content) + // First text of the turn — the trace span exists by now. + // Register the (messageId → spanId) mapping so feedback for this + // message can land on the right Braintrust event later. + if (!spanRegistered && !isHarnessSession(session)) { + const spanId = session.getCurrentSpanId() + if (spanId) { + session.registerAssistantMessage(assistantMessageId, spanId) + spanRegistered = true + } + } // Send "Writing response..." status only once per text block, not per token if (!writingStatusSent) { @@ -5443,7 +5480,14 @@ export class AgentServer { } } - this.sendToClient(Channel.AI, { ...event, sessionId } as Record) + // Stamp `done` with the per-turn assistantMessageId so the client + // can adopt it as the assistant message's id (used later when the + // user sends thumbs up/down feedback). + const outgoing = + event.type === 'done' + ? ({ ...event, sessionId, messageId: assistantMessageId } as Record) + : ({ ...event, sessionId } as Record) + this.sendToClient(Channel.AI, outgoing) } // Flush any remaining buffered text after the loop @@ -5559,6 +5603,96 @@ export class AgentServer { return eventCount } + // ── Regenerate handler ────────────────────────────────────────── + // + // Recovers the prior user message and re-feeds it via the normal + // chat pipeline so streaming, persistence, and project bookkeeping + // all stay in one place. + // + // • Pi-SDK: truncates `piAgent.state.messages` back past the last + // assistant turn so the regenerated answer replaces the old one + // in conversation history (clean state). + // • Codex: leaves the CLI's thread state alone and re-feeds the + // same user text as a follow-up turn (no `thread/resume` RPC + // exists). The previous assistant turn lingers in Codex's own + // context but the user-facing UI swaps cleanly. + // • Claude Code legacy harness: not supported (no kill-and-replay + // story, no resume primitive). + private async handleRegenerate(msg: { messageId: string; sessionId?: string }): Promise { + const sessionId = msg.sessionId || DEFAULT_SESSION_ID + const session = this.sessions.get(sessionId) + if (!session) { + this.sendToClient(Channel.AI, { + type: 'error', + code: 'session_not_found', + message: `Session not found: ${sessionId}`, + sessionId, + }) + return + } + + if (this.activeTurns.has(sessionId)) { + log.warn({ sessionId }, 'Regenerate ignored — turn already in flight') + return + } + + let userText: string | null = null + let attachments: { id: string; name: string; mimeType: string; data: string; sizeBytes: number }[] = [] + + if (!isHarnessSession(session)) { + // Pi-SDK path: pop the last user→assistant turn off the agent's + // message array and re-feed the user content through the normal + // turn pipeline. + const prep = session.prepareRegenerate() + if (!prep) { + this.sendToClient(Channel.AI, { + type: 'error', + message: 'Nothing to regenerate yet.', + sessionId, + }) + return + } + userText = prep.userText + attachments = prep.attachments + } else if (session instanceof CodexHarnessSession) { + // Codex harness: simulate regenerate by re-feeding the original + // user message as a new turn. Codex's app-server v2 protocol has + // no `thread/resume` / rewind RPC, and killing the subprocess + // would discard all prior context — so we leave the thread state + // alone and let Codex produce a fresh answer to the same question. + // Slight downside: the previous assistant turn stays in Codex's + // internal context window for future turns. The client UI + // optimistically removes the old assistant message so users see + // a clean swap. + const projectId = this.extractProjectId(sessionId) + const prep = readLastUserFromHarness(sessionId, projectId) + if (!prep || !prep.userText.trim()) { + this.sendToClient(Channel.AI, { + type: 'error', + message: 'Nothing to regenerate yet.', + sessionId, + }) + return + } + userText = prep.userText + } else { + // Claude Code legacy harness — not yet supported. + this.sendToClient(Channel.AI, { + type: 'error', + message: 'Regenerate is not available on this provider yet.', + sessionId, + }) + return + } + + log.info({ sessionId, chars: userText.length }, 'Regenerating last assistant turn') + await this.handleChatMessage({ + sessionId, + content: userText, + attachments: attachments.length > 0 ? attachments : undefined, + }) + } + private async handleCompactCommand(session: Session, sessionId: string, content: string) { const customInstructions = content.slice('/compact'.length).trim() || undefined diff --git a/packages/desktop/src/components/chat/ComposerAddMenu.tsx b/packages/desktop/src/components/chat/ComposerAddMenu.tsx index e48161cc..5f7e99c1 100644 --- a/packages/desktop/src/components/chat/ComposerAddMenu.tsx +++ b/packages/desktop/src/components/chat/ComposerAddMenu.tsx @@ -92,20 +92,24 @@ export function ComposerAddMenu({ open, onClose, onAddImages, onAddFiles, anchor // `filter` / `will-change` on a composer ancestor (StreamHome's // .home-stack uses translateY(-6%)) can't create a containing block // that would break `position: fixed`. - const ESTIMATED_MENU_HEIGHT = 220 + const ESTIMATED_MENU_HEIGHT = 240 + const ESTIMATED_MENU_WIDTH = 360 + const VIEWPORT_PADDING = 8 const spaceBelow = window.innerHeight - anchorRect.bottom const flipAbove = spaceBelow < ESTIMATED_MENU_HEIGHT + 16 + const maxLeft = window.innerWidth - ESTIMATED_MENU_WIDTH - VIEWPORT_PADDING + const left = Math.max(VIEWPORT_PADDING, Math.min(anchorRect.left, maxLeft)) const style: React.CSSProperties = flipAbove ? { position: 'fixed', - left: anchorRect.left, - bottom: window.innerHeight - anchorRect.top + 6, + left, + bottom: window.innerHeight - anchorRect.top + 8, zIndex: 50, } : { position: 'fixed', - left: anchorRect.left, - top: anchorRect.bottom + 6, + left, + top: anchorRect.bottom + 8, zIndex: 50, } @@ -170,7 +174,7 @@ export function ComposerAddMenu({ open, onClose, onAddImages, onAddFiles, anchor diff --git a/packages/desktop/src/components/chat/MessageBubble.tsx b/packages/desktop/src/components/chat/MessageBubble.tsx index 60d86a81..f7d6bc24 100644 --- a/packages/desktop/src/components/chat/MessageBubble.tsx +++ b/packages/desktop/src/components/chat/MessageBubble.tsx @@ -1,6 +1,8 @@ import { AnimatePresence, motion } from 'framer-motion' import { AlertTriangle, + Check, + Copy, File as FileIcon, FileImage, FileSpreadsheet, @@ -9,10 +11,14 @@ import { Image as ImageIcon, ImageOff, Loader2, + RotateCw, + ThumbsDown, + ThumbsUp, } from 'lucide-react' -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { classifyUpload } from '../../lib/artifacts.js' import { useAttachmentBlobUrl } from '../../lib/attachments.js' +import { connection } from '../../lib/connection.js' import type { ChatImageAttachment, CitationSource } from '../../lib/store.js' import { type ChatMessage, useStore } from '../../lib/store.js' import { artifactStore } from '../../lib/store/artifactStore.js' @@ -297,6 +303,93 @@ function UserMessageContent({ ) } +type FeedbackValue = 'up' | 'down' + +function AssistantMessageActions({ + message, + sessionId, +}: { + message: ChatMessage + sessionId: string | undefined +}) { + const [copied, setCopied] = useState(false) + const [feedback, setFeedback] = useState(null) + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(message.content).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + }, [message.content]) + + const handleFeedback = useCallback( + (value: FeedbackValue) => { + const next = feedback === value ? null : value + setFeedback(next) + if (next) connection.sendFeedback(message.id, next, sessionId) + }, + [feedback, message.id, sessionId], + ) + + const handleRegenerate = useCallback(() => { + // Optimistically drop this assistant message so the new streaming + // answer replaces it cleanly instead of stacking next to it. + useStore.getState().removeMessage(message.id, sessionId) + connection.sendRegenerate(message.id, sessionId) + }, [message.id, sessionId]) + + return ( +
+ + + + +
+ ) +} + export function MessageBubble({ message, sessionId, isLastThinking }: Props) { const citations = useStore((s) => s.citations.get(message.id)) // For assistant final answers, the model may reference citations across @@ -346,6 +439,9 @@ export function MessageBubble({ message, sessionId, isLastThinking }: Props) {
{citations && citations.length > 0 && } + {message.content.trim().length > 0 && !isAgentWorking && ( + + )}
)} diff --git a/packages/desktop/src/components/chat/TurnProgress.tsx b/packages/desktop/src/components/chat/TurnProgress.tsx index ceb48196..c794d829 100644 --- a/packages/desktop/src/components/chat/TurnProgress.tsx +++ b/packages/desktop/src/components/chat/TurnProgress.tsx @@ -1,6 +1,6 @@ import { AnimatePresence, motion } from 'framer-motion' import { ChevronRight } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { CitationSource } from '../../lib/store.js' import { parseCitationSources } from '../../lib/store/handlers/citationParser.js' import { ActionsGroup } from './ActionsGroup.js' @@ -98,12 +98,21 @@ function collectSearchSources(items: GroupedItem[]): CitationSource[] { export function TurnProgress({ items, isWorking }: Props) { const [open, setOpen] = useState(!!isWorking) + const userToggledRef = useRef(false) - // Auto-open while the agent is working; let the user collapse it once done. + // Open while working; auto-collapse once the assistant has written its + // reply to the main conversation. Skip the auto behavior once the user + // has manually toggled this turn so we respect their choice. useEffect(() => { - if (isWorking) setOpen(true) + if (userToggledRef.current) return + setOpen(!!isWorking) }, [isWorking]) + const handleToggle = () => { + userToggledRef.current = true + setOpen((o) => !o) + } + const title = deriveTitle(items, isWorking) const stepCount = countSteps(items) const sources = useMemo(() => collectSearchSources(items), [items]) @@ -126,7 +135,7 @@ export function TurnProgress({ items, isWorking }: Props) { )} -