From 030860f3b61580a9e8a2428025730b423b1da175 Mon Sep 17 00:00:00 2001 From: Andrew Khadder <54488379+khandrew1@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:08:07 -0800 Subject: [PATCH 1/7] feat(mcp-apps): add streaming playback debug feature After a tool call completes, developers can replay the streaming progression via a new "Streaming" debug tab. The playback bar shows transport controls, a tick-mark timeline with real timing, a speed selector, and a collapsible JSON panel for inspecting each partial. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/mcp-apps-renderer.test.tsx | 13 +- .../__tests__/streaming-playback-bar.test.tsx | 160 +++++++ .../__tests__/useToolInputStreaming.test.ts | 218 ++++++++++ .../thread/mcp-apps/mcp-apps-renderer.tsx | 28 ++ .../mcp-apps/streaming-playback-bar.tsx | 398 ++++++++++++++++++ .../thread/mcp-apps/useToolInputStreaming.ts | 102 ++++- .../chat-v2/thread/parts/tool-part.tsx | 60 ++- .../client/src/stores/widget-debug-store.ts | 44 ++ 8 files changed, 1010 insertions(+), 13 deletions(-) create mode 100644 mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx create mode 100644 mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/streaming-playback-bar.tsx diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx index 5060923e8..9957c9f85 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/mcp-apps-renderer.test.tsx @@ -139,6 +139,9 @@ vi.mock("@/stores/widget-debug-store", () => ({ clearCspViolations: stableStoreFns.clearCspViolations, setWidgetModelContext: stableStoreFns.setWidgetModelContext, setWidgetHtml: stableStoreFns.setWidgetHtml, + setStreamingHistoryCount: vi.fn(), + setStreamingPlaybackActive: vi.fn(), + widgets: new Map(), }), })); @@ -156,9 +159,13 @@ vi.mock("@/lib/mcp-ui/mcp-apps-utils", () => ({ isVisibleToModelOnly: () => false, })); -vi.mock("lucide-react", () => ({ - X: (props: any) =>
, -})); +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + X: (props: any) =>
, + }; +}); vi.mock("../mcp-apps-modal", () => ({ McpAppsModal: () => null, diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx new file mode 100644 index 000000000..f9b4abc55 --- /dev/null +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { StreamingPlaybackBar } from "../streaming-playback-bar"; +import type { PartialHistoryEntry } from "../useToolInputStreaming"; + +// Mock the widget debug store +vi.mock("@/stores/widget-debug-store", () => ({ + useWidgetDebugStore: vi.fn((selector: (s: any) => any) => + selector({ + setStreamingPlaybackActive: vi.fn(), + }), + ), +})); + +function createHistory(count: number): PartialHistoryEntry[] { + return Array.from({ length: count }, (_, i) => ({ + timestamp: 1000 + i * 100, + elapsedFromStart: i * 100, + input: { code: "x".repeat(i + 1) }, + isFinal: i === count - 1, + })); +} + +describe("StreamingPlaybackBar", () => { + const defaultProps = { + replayToPosition: vi.fn(), + exitReplay: vi.fn(), + isReplayActive: false, + toolCallId: "call-1", + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders all transport control buttons", () => { + const history = createHistory(4); + render( + , + ); + + expect(screen.getByLabelText("First")).toBeInTheDocument(); + expect(screen.getByLabelText("Previous")).toBeInTheDocument(); + expect(screen.getByLabelText("Play")).toBeInTheDocument(); + expect(screen.getByLabelText("Next")).toBeInTheDocument(); + expect(screen.getByLabelText("Last")).toBeInTheDocument(); + expect(screen.getByLabelText("Close playback")).toBeInTheDocument(); + }); + + it("displays position label", () => { + const history = createHistory(4); + render( + , + ); + + // Initially at last position: "4/4" + expect(screen.getByText(/4\/4/)).toBeInTheDocument(); + }); + + it("Previous button calls replayToPosition with correct index", () => { + const history = createHistory(4); + const replayToPosition = vi.fn(); + render( + , + ); + + // First click Previous to move from position 3 to 2 + fireEvent.click(screen.getByLabelText("Previous")); + + expect(replayToPosition).toHaveBeenCalledWith(2); + }); + + it("Next button is disabled at last position", () => { + const history = createHistory(4); + render( + , + ); + + const nextButton = screen.getByLabelText("Next"); + expect(nextButton).toBeDisabled(); + }); + + it("First button calls replayToPosition with 0", () => { + const history = createHistory(4); + const replayToPosition = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByLabelText("First")); + + expect(replayToPosition).toHaveBeenCalledWith(0); + }); + + it("First button is disabled at position 0", () => { + const history = createHistory(4); + const replayToPosition = vi.fn(); + render( + , + ); + + // Navigate to first position + fireEvent.click(screen.getByLabelText("First")); + + // Now First and Previous should be disabled + expect(screen.getByLabelText("First")).toBeDisabled(); + expect(screen.getByLabelText("Previous")).toBeDisabled(); + }); + + it("Close button calls exitReplay", () => { + const history = createHistory(4); + const exitReplay = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByLabelText("Close playback")); + + expect(exitReplay).toHaveBeenCalledTimes(1); + }); + + it("renders speed selector with default value", () => { + const history = createHistory(4); + render( + , + ); + + expect(screen.getByLabelText("Playback speed")).toBeInTheDocument(); + }); + + it("renders Raw JSON collapsible trigger", () => { + const history = createHistory(4); + render( + , + ); + + expect(screen.getByText("Raw JSON")).toBeInTheDocument(); + }); +}); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/useToolInputStreaming.test.ts b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/useToolInputStreaming.test.ts index 9e63072ea..bfb7838f8 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/useToolInputStreaming.test.ts +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/useToolInputStreaming.test.ts @@ -5,6 +5,7 @@ import { useToolInputStreaming, PARTIAL_INPUT_THROTTLE_MS, STREAMING_REVEAL_FALLBACK_MS, + PARTIAL_HISTORY_MAX_ENTRIES, type ToolState, } from "../useToolInputStreaming"; @@ -444,4 +445,221 @@ describe("useToolInputStreaming", () => { expect(bridge.sendToolCancelled).toHaveBeenCalledTimes(1); }); + + // ── Partial history tests ─────────────────────────────────────────────── + + describe("partialHistory", () => { + it("is empty during streaming, populated after transition to input-available", () => { + const props = createDefaultProps(bridge); + props.toolState = "input-streaming"; + props.toolInput = { code: "he" }; + + const { result, rerender } = renderHook(() => + useToolInputStreaming(props), + ); + + // During streaming, partialHistory state has not been snapshotted yet + expect(result.current.partialHistory).toEqual([]); + + // Transition to input-available + props.toolState = "input-available"; + props.toolInput = { code: "hello" }; + rerender(); + + // Now partialHistory should be populated (includes the streaming partial + final) + expect(result.current.partialHistory.length).toBeGreaterThan(0); + }); + + it("final entry has isFinal: true", () => { + const props = createDefaultProps(bridge); + props.toolState = "input-streaming"; + props.toolInput = { code: "he" }; + + const { result, rerender } = renderHook(() => + useToolInputStreaming(props), + ); + + // Transition to input-available + props.toolState = "input-available"; + props.toolInput = { code: "hello" }; + rerender(); + + const history = result.current.partialHistory; + expect(history.length).toBeGreaterThan(0); + expect(history[history.length - 1].isFinal).toBe(true); + }); + + it("elapsedFromStart is relative to first entry", () => { + const props = createDefaultProps(bridge); + props.toolState = "input-streaming"; + props.toolInput = { code: "h" }; + + const { result, rerender } = renderHook(() => + useToolInputStreaming(props), + ); + + // Advance time, change input + act(() => { + vi.advanceTimersByTime(PARTIAL_INPUT_THROTTLE_MS + 10); + }); + props.toolInput = { code: "he" }; + rerender(); + + // Transition to complete + props.toolState = "input-available"; + props.toolInput = { code: "hello" }; + rerender(); + + const history = result.current.partialHistory; + expect(history.length).toBeGreaterThanOrEqual(2); + expect(history[0].elapsedFromStart).toBe(0); + // Later entries should have positive elapsed time + for (let i = 1; i < history.length; i++) { + expect(history[i].elapsedFromStart).toBeGreaterThanOrEqual(0); + } + }); + + it("history resets on toolCallId change", () => { + const props = createDefaultProps(bridge); + props.toolState = "input-streaming"; + props.toolInput = { code: "hello" }; + + const { result, rerender } = renderHook(() => + useToolInputStreaming(props), + ); + + // Complete to populate history + props.toolState = "input-available"; + rerender(); + + expect(result.current.partialHistory.length).toBeGreaterThan(0); + + // Change toolCallId — history should reset + props.toolCallId = "call-2"; + props.toolState = "input-streaming"; + props.toolInput = { code: "new" }; + rerender(); + + expect(result.current.partialHistory).toEqual([]); + }); + + it("history is capped at PARTIAL_HISTORY_MAX_ENTRIES", () => { + const props = createDefaultProps(bridge); + props.toolState = "input-streaming"; + + const { result, rerender } = renderHook(() => + useToolInputStreaming(props), + ); + + // Send many distinct partials + for (let i = 0; i < PARTIAL_HISTORY_MAX_ENTRIES + 50; i++) { + act(() => { + vi.advanceTimersByTime(PARTIAL_INPUT_THROTTLE_MS + 10); + }); + props.toolInput = { code: `input-${i}` }; + rerender(); + } + + // Complete to snapshot history + props.toolState = "input-available"; + props.toolInput = { code: "final" }; + rerender(); + + // The final entry addition is also guarded, so total should be capped + expect( + result.current.partialHistory.length, + ).toBeLessThanOrEqual(PARTIAL_HISTORY_MAX_ENTRIES); + }); + }); + + // ── Replay tests ──────────────────────────────────────────────────────── + + describe("replay", () => { + function setupWithHistory(b: ReturnType) { + const props = createDefaultProps(b); + props.toolState = "input-streaming"; + props.toolInput = { code: "h" }; + + const hookResult = renderHook(() => useToolInputStreaming(props)); + + // Advance and add another partial + act(() => { + vi.advanceTimersByTime(PARTIAL_INPUT_THROTTLE_MS + 10); + }); + props.toolInput = { code: "he" }; + hookResult.rerender(); + + // Complete + props.toolState = "input-available"; + props.toolInput = { code: "hello" }; + hookResult.rerender(); + + // Clear mock call history for cleaner assertions + b.sendToolInputPartial.mockClear(); + b.sendToolInput.mockClear(); + + return { hookResult, props }; + } + + it("replayToPosition(0) calls bridge.sendToolInputPartial", () => { + const { hookResult } = setupWithHistory(bridge); + // The first entry in history is { code: "he" } — the mount-time entry + // { code: "h" } gets cleared by the reset effect that runs in the same cycle. + const firstEntry = hookResult.result.current.partialHistory[0]; + + act(() => { + hookResult.result.current.replayToPosition(0); + }); + + expect(bridge.sendToolInputPartial).toHaveBeenCalledWith({ + arguments: firstEntry.input, + }); + }); + + it("replayToPosition(lastIndex) calls bridge.sendToolInput for final entry", () => { + const { hookResult } = setupWithHistory(bridge); + const lastIndex = + hookResult.result.current.partialHistory.length - 1; + + act(() => { + hookResult.result.current.replayToPosition(lastIndex); + }); + + expect(bridge.sendToolInput).toHaveBeenCalledWith({ + arguments: { code: "hello" }, + }); + }); + + it("replayToPosition sets isReplayActive = true", () => { + const { hookResult } = setupWithHistory(bridge); + + expect(hookResult.result.current.isReplayActive).toBe(false); + + act(() => { + hookResult.result.current.replayToPosition(0); + }); + + expect(hookResult.result.current.isReplayActive).toBe(true); + }); + + it("exitReplay calls bridge.sendToolInput with final args and sets isReplayActive = false", () => { + const { hookResult } = setupWithHistory(bridge); + + act(() => { + hookResult.result.current.replayToPosition(0); + }); + expect(hookResult.result.current.isReplayActive).toBe(true); + + bridge.sendToolInput.mockClear(); + + act(() => { + hookResult.result.current.exitReplay(); + }); + + expect(hookResult.result.current.isReplayActive).toBe(false); + expect(bridge.sendToolInput).toHaveBeenCalledWith({ + arguments: { code: "hello" }, + }); + }); + }); }); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx index 322c279cb..e8558613c 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx @@ -65,6 +65,7 @@ import { handleUploadFileMessage, } from "./widget-file-messages"; import { CheckoutDialogV2 } from "./checkout-dialog-v2"; +import { StreamingPlaybackBar } from "./streaming-playback-bar"; import { fetchMcpAppsWidgetContent } from "./fetch-widget-content"; import type { CheckoutSession } from "@/shared/acp-types"; import { listResources, readResource } from "@/lib/apis/mcp-resources-api"; @@ -306,6 +307,10 @@ export function MCPAppsRenderer({ canRenderStreamingInput, signalStreamingRender, resetStreamingState, + partialHistory, + replayToPosition, + exitReplay, + isReplayActive, } = useToolInputStreaming({ bridgeRef, isReady, @@ -446,6 +451,12 @@ export function MCPAppsRenderer({ (s) => s.setWidgetModelContext, ); const setWidgetHtmlStore = useWidgetDebugStore((s) => s.setWidgetHtml); + const setStreamingHistoryCount = useWidgetDebugStore( + (s) => s.setStreamingHistoryCount, + ); + const streamingPlaybackActive = useWidgetDebugStore( + (s) => s.widgets.get(toolCallId)?.streamingPlaybackActive ?? false, + ); // Clear CSP violations when CSP mode changes (stale data from previous mode) useEffect(() => { @@ -520,6 +531,13 @@ export function MCPAppsRenderer({ setWidgetGlobals, ]); + // Write streaming history count to debug store + useEffect(() => { + if (partialHistory.length > 0) { + setStreamingHistoryCount(toolCallId, partialHistory.length); + } + }, [partialHistory.length, toolCallId, setStreamingHistoryCount]); + // CSS Variables for theming (SEP-1865 styles.variables) // These are sent via hostContext.styles.variables - the SDK should pass them through const useChatGPTStyle = isPlaygroundActive && hostStyle === "chatgpt"; @@ -1162,6 +1180,16 @@ export function MCPAppsRenderer({ )} + {streamingPlaybackActive && partialHistory.length > 1 && ( + + )} + {/* Uses SandboxedIframe for DRY double-iframe architecture */} void; + exitReplay: () => void; + isReplayActive: boolean; + toolCallId: string; +} + +const SPEED_OPTIONS = ["0.25", "0.5", "1", "2", "4"] as const; + +function getPlaybackDelay( + entries: PartialHistoryEntry[], + current: number, + next: number, + speed: number, +): number { + const gap = + entries[next].elapsedFromStart - entries[current].elapsedFromStart; + const scaled = gap / speed; + const maxGap = 2000 / speed; + return Math.max(32, Math.min(scaled, maxGap)); +} + +function TickMarkRail({ + entries, + currentPosition, + onTickClick, +}: { + entries: PartialHistoryEntry[]; + currentPosition: number; + onTickClick: (position: number) => void; +}) { + const totalDuration = + entries.length > 1 ? entries[entries.length - 1].elapsedFromStart : 1; + + const progressPercent = + entries.length > 1 + ? (entries[currentPosition].elapsedFromStart / totalDuration) * 100 + : 100; + + return ( +
+ {/* Track background */} +
+ {/* Progress fill */} +
+ {/* Tick marks */} + {entries.map((entry, index) => { + const leftPercent = + totalDuration > 0 + ? (entry.elapsedFromStart / totalDuration) * 100 + : (index / Math.max(entries.length - 1, 1)) * 100; + + const isCurrent = index === currentPosition; + const isFinal = !!entry.isFinal; + + return ( + + + + + + Step {index + 1} (+{entry.elapsedFromStart}ms) + {isFinal ? " (final)" : ""} + + + ); + })} +
+ ); +} + +export function StreamingPlaybackBar({ + partialHistory, + replayToPosition, + exitReplay, + isReplayActive, + toolCallId, +}: StreamingPlaybackBarProps) { + const [currentPosition, setCurrentPosition] = useState( + partialHistory.length - 1, + ); + const [isPlaying, setIsPlaying] = useState(false); + const [playbackSpeed, setPlaybackSpeed] = useState("1"); + const [jsonPanelOpen, setJsonPanelOpen] = useState(false); + const playTimerRef = useRef(null); + const setStreamingPlaybackActive = useWidgetDebugStore( + (s) => s.setStreamingPlaybackActive, + ); + + const lastIndex = partialHistory.length - 1; + + const stopPlayback = useCallback(() => { + if (playTimerRef.current !== null) { + window.clearTimeout(playTimerRef.current); + playTimerRef.current = null; + } + setIsPlaying(false); + }, []); + + const goToPosition = useCallback( + (position: number) => { + const clamped = Math.max(0, Math.min(position, lastIndex)); + setCurrentPosition(clamped); + replayToPosition(clamped); + }, + [lastIndex, replayToPosition], + ); + + // Auto-play effect + useEffect(() => { + if (!isPlaying) return; + if (currentPosition >= lastIndex) { + setIsPlaying(false); + return; + } + + const speed = parseFloat(playbackSpeed); + const delay = getPlaybackDelay( + partialHistory, + currentPosition, + currentPosition + 1, + speed, + ); + + playTimerRef.current = window.setTimeout(() => { + playTimerRef.current = null; + const nextPos = currentPosition + 1; + setCurrentPosition(nextPos); + replayToPosition(nextPos); + }, delay); + + return () => { + if (playTimerRef.current !== null) { + window.clearTimeout(playTimerRef.current); + playTimerRef.current = null; + } + }; + }, [isPlaying, currentPosition, lastIndex, playbackSpeed, partialHistory, replayToPosition]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (playTimerRef.current !== null) { + window.clearTimeout(playTimerRef.current); + } + }; + }, []); + + const handleClose = () => { + stopPlayback(); + exitReplay(); + setStreamingPlaybackActive(toolCallId, false); + }; + + const handlePlayPause = () => { + if (isPlaying) { + stopPlayback(); + } else { + // If at the end, restart from beginning + if (currentPosition >= lastIndex) { + setCurrentPosition(0); + replayToPosition(0); + } + setIsPlaying(true); + } + }; + + const currentEntry = partialHistory[currentPosition]; + const elapsedMs = currentEntry?.elapsedFromStart ?? 0; + + return ( +
+ {/* Transport controls row */} +
+ {/* Navigation buttons */} +
+ + + + + First + + + + + + + Previous + + + + + + + {isPlaying ? "Pause" : "Play"} + + + + + + + Next + + + + + + + Last + +
+ + {/* Separator */} +
+ + {/* Position label */} + + {currentPosition + 1}/{partialHistory.length}{" "} + +{elapsedMs}ms + + + {/* Timeline rail */} + { + stopPlayback(); + goToPosition(pos); + }} + /> + + {/* Speed selector */} + + + {/* Close button */} + + + + + Close playback + +
+ + {/* Collapsible JSON panel */} + + + + Raw JSON + + +
+ +
+
+
+
+ ); +} diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts index d640278f7..652e9431d 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts @@ -19,9 +19,17 @@ export const SIGNATURE_MAX_DEPTH = 4; export const SIGNATURE_MAX_ARRAY_ITEMS = 24; export const SIGNATURE_MAX_OBJECT_KEYS = 32; export const SIGNATURE_STRING_EDGE_LENGTH = 24; +export const PARTIAL_HISTORY_MAX_ENTRIES = 200; // ── Types ──────────────────────────────────────────────────────────────────── +export interface PartialHistoryEntry { + timestamp: number; + elapsedFromStart: number; + input: Record; + isFinal?: boolean; +} + export type ToolState = | "input-streaming" | "input-available" @@ -147,6 +155,14 @@ export interface UseToolInputStreamingReturn { signalStreamingRender: () => void; /** Called on CSP mode change (or externally) to clear all streaming state. */ resetStreamingState: () => void; + /** Recorded history of partial inputs (populated after streaming completes). */ + partialHistory: PartialHistoryEntry[]; + /** Replay the widget to a specific history position. */ + replayToPosition: (position: number) => void; + /** Exit replay mode and restore widget to final state. */ + exitReplay: () => void; + /** Whether the hook is currently in replay mode. */ + isReplayActive: boolean; } // ── Hook ───────────────────────────────────────────────────────────────────── @@ -176,11 +192,19 @@ export function useToolInputStreaming({ const toolInputSentRef = useRef(false); const previousToolStateRef = useRef(toolState); + // ── History recording refs ────────────────────────────────────────────── + const partialHistoryRef = useRef([]); + const recordingStartTimeRef = useRef(null); + // ── Internal state ─────────────────────────────────────────────────────── const [streamingRenderSignaled, setStreamingRenderSignaled] = useState(false); const [hasDeliveredStreamingInput, setHasDeliveredStreamingInput] = useState(false); + const [partialHistory, setPartialHistory] = useState( + [], + ); + const [isReplayActive, setIsReplayActive] = useState(false); // ── Derived values ─────────────────────────────────────────────────────── @@ -214,12 +238,79 @@ export function useToolInputStreaming({ toolInputSentRef.current = false; setStreamingRenderSignaled(false); setHasDeliveredStreamingInput(false); + partialHistoryRef.current = []; + recordingStartTimeRef.current = null; + setPartialHistory([]); + setIsReplayActive(false); }, []); const signalStreamingRender = useCallback(() => { setStreamingRenderSignaled(true); }, []); + const recordPartialEntry = useCallback( + (input: Record, isFinal?: boolean) => { + if (partialHistoryRef.current.length >= PARTIAL_HISTORY_MAX_ENTRIES) + return; + const now = Date.now(); + if (recordingStartTimeRef.current === null) { + recordingStartTimeRef.current = now; + } + partialHistoryRef.current.push({ + timestamp: now, + elapsedFromStart: now - recordingStartTimeRef.current, + input: structuredClone(input), + isFinal, + }); + }, + [], + ); + + // ── Replay callbacks ────────────────────────────────────────────────────── + + const replayToPosition = useCallback( + (position: number) => { + const bridge = bridgeRef.current; + if (!bridge || !isReadyRef.current) return; + const history = partialHistoryRef.current; + if (position < 0 || position >= history.length) return; + + setIsReplayActive(true); + + // Clear dedup guards so bridge accepts the replayed message + lastToolInputRef.current = null; + lastToolInputPartialRef.current = null; + + const entry = history[position]; + if (entry.isFinal) { + Promise.resolve( + bridge.sendToolInput({ arguments: entry.input }), + ).catch(() => {}); + } else { + Promise.resolve( + bridge.sendToolInputPartial({ arguments: entry.input }), + ).catch(() => {}); + } + }, + [bridgeRef, isReadyRef], + ); + + const exitReplay = useCallback(() => { + setIsReplayActive(false); + + // Restore widget to final state + const bridge = bridgeRef.current; + if (!bridge || !isReadyRef.current) return; + const history = partialHistoryRef.current; + if (history.length === 0) return; + + const finalEntry = history[history.length - 1]; + lastToolInputRef.current = null; + Promise.resolve( + bridge.sendToolInput({ arguments: finalEntry.input }), + ).catch(() => {}); + }, [bridgeRef, isReadyRef]); + // ── Effects ────────────────────────────────────────────────────────────── // 1. Clear reveal timer when signaled @@ -280,6 +371,7 @@ export function useToolInputStreaming({ lastToolInputPartialSentAtRef.current = Date.now(); setHasDeliveredStreamingInput(true); setStreamingRenderSignaled(true); + recordPartialEntry(pending); Promise.resolve( bridge.sendToolInputPartial({ arguments: pending }), ).catch(() => {}); @@ -306,7 +398,7 @@ export function useToolInputStreaming({ partialInputTimerRef.current = null; flushPartialInput(); }, PARTIAL_INPUT_THROTTLE_MS - elapsed); - }, [hasToolInputData, isReady, toolInput, toolState, bridgeRef, isReadyRef]); + }, [hasToolInputData, isReady, toolInput, toolState, bridgeRef, isReadyRef, recordPartialEntry]); // 5. Complete input delivery useEffect(() => { @@ -335,13 +427,15 @@ export function useToolInputStreaming({ } lastToolInputRef.current = serialized; toolInputSentRef.current = true; + recordPartialEntry(resolvedToolInput, true); + setPartialHistory([...partialHistoryRef.current]); Promise.resolve( bridge.sendToolInput({ arguments: resolvedToolInput }), ).catch(() => { toolInputSentRef.current = false; lastToolInputRef.current = null; }); - }, [isReady, toolInput, toolState, bridgeRef]); + }, [isReady, toolInput, toolState, bridgeRef, recordPartialEntry]); // 6. Tool result delivery useEffect(() => { @@ -390,5 +484,9 @@ export function useToolInputStreaming({ canRenderStreamingInput, signalStreamingRender, resetStreamingState, + partialHistory, + replayToPosition, + exitReplay, + isReplayActive, }; } diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx index 11aa6651c..973dd5ce1 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx @@ -5,6 +5,7 @@ import { ChevronDown, Database, Layers, + ListVideo, Loader2, Maximize2, MessageCircle, @@ -124,7 +125,7 @@ export function ToolPart({ const [userExpanded, setUserExpanded] = useState(false); const isExpanded = needsApproval || userExpanded; const [activeDebugTab, setActiveDebugTab] = useState< - "data" | "state" | "csp" | "context" | null + "data" | "state" | "csp" | "context" | "streaming" | null >("data"); const [hasUsedSaveViewButton, setHasUsedSaveViewButton] = useState(true); @@ -138,6 +139,12 @@ export function ToolPart({ const widgetDebugInfo = useWidgetDebugStore((s) => toolCallId ? s.widgets.get(toolCallId) : undefined, ); + const streamingHistoryCount = useWidgetDebugStore((s) => + toolCallId ? (s.widgets.get(toolCallId)?.streamingHistoryCount ?? 0) : 0, + ); + const setStreamingPlaybackActive = useWidgetDebugStore( + (s) => s.setStreamingPlaybackActive, + ); const hasWidgetDebug = !!widgetDebugInfo; const showDisplayModeControls = @@ -158,7 +165,7 @@ export function ToolPart({ const debugOptions = useMemo(() => { const options: { - tab: "data" | "state" | "csp" | "context"; + tab: "data" | "state" | "csp" | "context" | "streaming"; icon: typeof Database; label: string; badge?: number; @@ -184,20 +191,43 @@ export function ToolPart({ badge: widgetDebugInfo?.csp?.violations?.length, }); + if (uiType === UIType.MCP_APPS && streamingHistoryCount > 1) { + options.push({ + tab: "streaming", + icon: ListVideo, + label: "Streaming", + badge: streamingHistoryCount, + }); + } + return options; }, [ uiType, widgetDebugInfo?.csp?.violations?.length, widgetDebugInfo?.modelContext, + streamingHistoryCount, ]); - const handleDebugClick = (tab: "data" | "state" | "csp" | "context") => { + const handleDebugClick = ( + tab: "data" | "state" | "csp" | "context" | "streaming", + ) => { + // Deactivate streaming playback when switching away from streaming tab + if (activeDebugTab === "streaming" && tab !== "streaming" && toolCallId) { + setStreamingPlaybackActive(toolCallId, false); + } + if (activeDebugTab === tab) { + if (tab === "streaming" && toolCallId) { + setStreamingPlaybackActive(toolCallId, false); + } setActiveDebugTab(null); setUserExpanded(false); } else { setActiveDebugTab(tab); setUserExpanded(true); + if (tab === "streaming" && toolCallId) { + setStreamingPlaybackActive(toolCallId, true); + } } }; @@ -303,7 +333,9 @@ export function ToolPart({ ? "State" : tab === "csp" ? "CSP" - : "Context"; + : tab === "streaming" + ? "Streaming" + : "Context"; const tooltipLabel = tab === "data" ? "Data" @@ -311,7 +343,9 @@ export function ToolPart({ ? "Widget State" : tab === "csp" ? "CSP" - : "Model Context"; + : tab === "streaming" + ? "Streaming Playback" + : "Model Context"; return ( @@ -326,7 +360,7 @@ export function ToolPart({ className={`inline-flex items-center gap-1 px-1.5 py-1 rounded transition-colors cursor-pointer relative ${ activeDebugTab === tab ? "bg-background text-foreground shadow-sm" - : badge && badge > 0 + : badge && badge > 0 && tab !== "streaming" ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground/60 hover:text-muted-foreground hover:bg-background/50" }`} @@ -337,8 +371,12 @@ export function ToolPart({ {badge !== undefined && badge > 0 && ( {badge} @@ -667,6 +705,12 @@ export function ToolPart({ )}
)} + {hasWidgetDebug && activeDebugTab === "streaming" && ( +
+ Streaming playback controls are above the widget.{" "} + {streamingHistoryCount} streaming snapshots recorded. +
+ )} {!hasWidgetDebug && (
{hasInput && ( diff --git a/mcpjam-inspector/client/src/stores/widget-debug-store.ts b/mcpjam-inspector/client/src/stores/widget-debug-store.ts index 9fda9a6c1..5a0a27c84 100644 --- a/mcpjam-inspector/client/src/stores/widget-debug-store.ts +++ b/mcpjam-inspector/client/src/stores/widget-debug-store.ts @@ -95,6 +95,10 @@ export interface WidgetDebugInfo { } | null; /** Cached widget HTML for offline rendering */ widgetHtml?: string; + /** Number of recorded streaming history entries (for debug tab badge) */ + streamingHistoryCount?: number; + /** Whether streaming playback is currently active */ + streamingPlaybackActive?: boolean; } interface WidgetDebugStore { @@ -147,6 +151,12 @@ interface WidgetDebugStore { // Set widget HTML for offline rendering cache setWidgetHtml: (toolCallId: string, html: string) => void; + + // Set streaming history count for a widget + setStreamingHistoryCount: (toolCallId: string, count: number) => void; + + // Set streaming playback active state for a widget + setStreamingPlaybackActive: (toolCallId: string, active: boolean) => void; } export const useWidgetDebugStore = create((set, get) => ({ @@ -172,6 +182,8 @@ export const useWidgetDebugStore = create((set, get) => ({ csp: existing?.csp, // Preserve CSP violations across updates widgetHtml: existing?.widgetHtml, // Preserve cached HTML for save view feature modelContext: existing?.modelContext, // Preserve model context across updates + streamingHistoryCount: existing?.streamingHistoryCount, + streamingPlaybackActive: existing?.streamingPlaybackActive, updatedAt: Date.now(), }); return { widgets }; @@ -320,10 +332,42 @@ export const useWidgetDebugStore = create((set, get) => ({ globals: existing?.globals ?? { theme: "dark", displayMode: "inline" }, csp: existing?.csp, modelContext: existing?.modelContext, + streamingHistoryCount: existing?.streamingHistoryCount, + streamingPlaybackActive: existing?.streamingPlaybackActive, widgetHtml: html, updatedAt: Date.now(), }); return { widgets }; }); }, + + setStreamingHistoryCount: (toolCallId, count) => { + set((state) => { + const existing = state.widgets.get(toolCallId); + if (!existing) return state; + + const widgets = new Map(state.widgets); + widgets.set(toolCallId, { + ...existing, + streamingHistoryCount: count, + updatedAt: Date.now(), + }); + return { widgets }; + }); + }, + + setStreamingPlaybackActive: (toolCallId, active) => { + set((state) => { + const existing = state.widgets.get(toolCallId); + if (!existing) return state; + + const widgets = new Map(state.widgets); + widgets.set(toolCallId, { + ...existing, + streamingPlaybackActive: active, + updatedAt: Date.now(), + }); + return { widgets }; + }); + }, })); From 615e1e3710d10599c28fd24da75b1980673a97df Mon Sep 17 00:00:00 2001 From: Andrew Khadder <54488379+khandrew1@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:32:15 -0800 Subject: [PATCH 2/7] feat(mcp-apps): replace tick-mark rail with time-proportional slider Replace TickMarkRail (individual dots per history entry) with a smooth Radix Slider where position maps to elapsed milliseconds. Uses binary search to snap to the nearest entry, keeping interaction responsive at any history size. Also adds replay session reset nonce to properly reinitialize the sandbox iframe during replay. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/streaming-playback-bar.test.tsx | 8 ++ .../thread/mcp-apps/mcp-apps-renderer.tsx | 39 ++++++++ .../mcp-apps/streaming-playback-bar.tsx | 96 ++++++++----------- 3 files changed, 85 insertions(+), 58 deletions(-) diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx index f9b4abc55..b68679fc0 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx @@ -149,6 +149,14 @@ describe("StreamingPlaybackBar", () => { expect(screen.getByLabelText("Playback speed")).toBeInTheDocument(); }); + it("renders timeline slider", () => { + const history = createHistory(4); + render( + , + ); + expect(screen.getByLabelText("Streaming timeline")).toBeInTheDocument(); + }); + it("renders Raw JSON collapsible trigger", () => { const history = createHistory(4); render( diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx index e8558613c..8b647b81b 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx @@ -253,6 +253,7 @@ export function MCPAppsRenderer({ const [widgetPermissive, setWidgetPermissive] = useState(false); const [prefersBorder, setPrefersBorder] = useState(true); const [loadedCspMode, setLoadedCspMode] = useState(null); + const [replayResetNonce, setReplayResetNonce] = useState(0); // Modal state const [modalOpen, setModalOpen] = useState(false); const [modalParams, setModalParams] = useState>({}); @@ -275,6 +276,11 @@ export function MCPAppsRenderer({ const hostContextRef = useRef(null); const isReadyRef = useRef(false); const lastInlineHeightRef = useRef("400px"); + const replayResetNonceRef = useRef(0); + const pendingReplayResetRef = useRef<{ + nonce: number; + resolve: () => void; + } | null>(null); const onSendFollowUpRef = useRef(onSendFollowUp); const onCallToolRef = useRef(onCallTool); @@ -303,6 +309,21 @@ export function MCPAppsRenderer({ const themeModeRef = useRef(themeMode); themeModeRef.current = themeMode; + const requestReplaySessionReset = useCallback((): Promise => { + setIsReady(false); + isReadyRef.current = false; + + const nextNonce = replayResetNonceRef.current + 1; + replayResetNonceRef.current = nextNonce; + setReplayResetNonce(nextNonce); + + return new Promise((resolve) => { + // Unblock any older in-flight replay reset request. + pendingReplayResetRef.current?.resolve(); + pendingReplayResetRef.current = { nonce: nextNonce, resolve }; + }); + }, []); + const { canRenderStreamingInput, signalStreamingRender, @@ -315,6 +336,7 @@ export function MCPAppsRenderer({ bridgeRef, isReady, isReadyRef, + requestReplaySessionReset, toolState, toolInput, toolOutput, @@ -639,6 +661,11 @@ export function MCPAppsRenderer({ bridge.oninitialized = () => { setIsReady(true); isReadyRef.current = true; + const pendingReset = pendingReplayResetRef.current; + if (pendingReset && pendingReset.nonce === replayResetNonceRef.current) { + pendingReplayResetRef.current = null; + pendingReset.resolve(); + } const appCaps = bridge.getAppCapabilities(); onAppSupportedDisplayModesChangeRef.current?.( appCaps?.availableDisplayModes as DisplayMode[] | undefined, @@ -890,6 +917,11 @@ export function MCPAppsRenderer({ let isActive = true; bridge.connect(transport).catch((error) => { if (!isActive) return; + const pendingReset = pendingReplayResetRef.current; + if (pendingReset && pendingReset.nonce === replayResetNonceRef.current) { + pendingReplayResetRef.current = null; + pendingReset.resolve(); + } setLoadError( error instanceof Error ? error.message : "Failed to connect MCP App", ); @@ -902,6 +934,11 @@ export function MCPAppsRenderer({ bridge.teardownResource({}).catch(() => {}); } bridge.close().catch(() => {}); + const pendingReset = pendingReplayResetRef.current; + if (pendingReset && pendingReset.nonce === replayResetNonceRef.current) { + pendingReplayResetRef.current = null; + pendingReset.resolve(); + } // Clear model context on widget teardown setWidgetModelContext(toolCallId, null); }; @@ -910,6 +947,7 @@ export function MCPAppsRenderer({ serverId, toolCallId, widgetHtml, + replayResetNonce, registerBridgeHandlers, setWidgetModelContext, ]); @@ -1192,6 +1230,7 @@ export function MCPAppsRenderer({ {/* Uses SandboxedIframe for DRY double-iframe architecture */} > 1; + if (entries[mid].elapsedFromStart < timeMs) lo = mid + 1; + else hi = mid; + } + if (lo > 0) { + const before = Math.abs(entries[lo - 1].elapsedFromStart - timeMs); + const after = Math.abs(entries[lo].elapsedFromStart - timeMs); + return before <= after ? lo - 1 : lo; + } + return lo; +} + +function TimeSlider({ entries, currentPosition, - onTickClick, + onSeek, }: { entries: PartialHistoryEntry[]; currentPosition: number; - onTickClick: (position: number) => void; + onSeek: (position: number) => void; }) { const totalDuration = entries.length > 1 ? entries[entries.length - 1].elapsedFromStart : 1; - - const progressPercent = - entries.length > 1 - ? (entries[currentPosition].elapsedFromStart / totalDuration) * 100 - : 100; + const currentTime = entries[currentPosition]?.elapsedFromStart ?? 0; return ( -
- {/* Track background */} -
- {/* Progress fill */} -
- {/* Tick marks */} - {entries.map((entry, index) => { - const leftPercent = - totalDuration > 0 - ? (entry.elapsedFromStart / totalDuration) * 100 - : (index / Math.max(entries.length - 1, 1)) * 100; - - const isCurrent = index === currentPosition; - const isFinal = !!entry.isFinal; - - return ( - - - - - - Step {index + 1} (+{entry.elapsedFromStart}ms) - {isFinal ? " (final)" : ""} - - - ); - })} -
+ { + const index = findNearestEntryIndex(entries, time); + onSeek(index); + }} + className="flex-1 mx-1" + aria-label="Streaming timeline" + /> ); } @@ -326,11 +306,11 @@ export function StreamingPlaybackBar({ +{elapsedMs}ms - {/* Timeline rail */} - { + onSeek={(pos) => { stopPlayback(); goToPosition(pos); }} From 540f5247cc8336abfeeca02625223b3a55a6a5c9 Mon Sep 17 00:00:00 2001 From: Andrew Khadder <54488379+khandrew1@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:58:07 -0800 Subject: [PATCH 3/7] feat(mcp-apps): embed StreamingPlaybackBar in ToolPart's streaming debug tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the streaming playback bar from above the widget iframe (in MCPAppsRenderer) into the Streaming debug tab panel inside ToolPart. Uses callback-prop pattern to lift streaming data from MCPAppsRenderer through part-switch.tsx down to ToolPart — same approach as onAppSupportedDisplayModesChange. Removes close button from the playback bar since closing is now handled by toggling the tab off. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/streaming-playback-bar.test.tsx | 26 ----------- .../thread/mcp-apps/mcp-apps-renderer.tsx | 45 ++++++++++++------- .../mcp-apps/streaming-playback-bar.tsx | 26 ----------- .../thread/mcp-apps/useToolInputStreaming.ts | 7 +++ .../components/chat-v2/thread/part-switch.tsx | 5 +++ .../chat-v2/thread/parts/tool-part.tsx | 42 ++++++++++------- 6 files changed, 66 insertions(+), 85 deletions(-) diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx index b68679fc0..2f1bf8d78 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx @@ -3,15 +3,6 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { StreamingPlaybackBar } from "../streaming-playback-bar"; import type { PartialHistoryEntry } from "../useToolInputStreaming"; -// Mock the widget debug store -vi.mock("@/stores/widget-debug-store", () => ({ - useWidgetDebugStore: vi.fn((selector: (s: any) => any) => - selector({ - setStreamingPlaybackActive: vi.fn(), - }), - ), -})); - function createHistory(count: number): PartialHistoryEntry[] { return Array.from({ length: count }, (_, i) => ({ timestamp: 1000 + i * 100, @@ -49,7 +40,6 @@ describe("StreamingPlaybackBar", () => { expect(screen.getByLabelText("Play")).toBeInTheDocument(); expect(screen.getByLabelText("Next")).toBeInTheDocument(); expect(screen.getByLabelText("Last")).toBeInTheDocument(); - expect(screen.getByLabelText("Close playback")).toBeInTheDocument(); }); it("displays position label", () => { @@ -124,22 +114,6 @@ describe("StreamingPlaybackBar", () => { expect(screen.getByLabelText("Previous")).toBeDisabled(); }); - it("Close button calls exitReplay", () => { - const history = createHistory(4); - const exitReplay = vi.fn(); - render( - , - ); - - fireEvent.click(screen.getByLabelText("Close playback")); - - expect(exitReplay).toHaveBeenCalledTimes(1); - }); - it("renders speed selector with default value", () => { const history = createHistory(4); render( diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx index 8b647b81b..c34510a5d 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx @@ -17,7 +17,11 @@ import { useCallback, type CSSProperties, } from "react"; -import { useToolInputStreaming, type ToolState } from "./useToolInputStreaming"; +import { + useToolInputStreaming, + type ToolState, + type StreamingPlaybackData, +} from "./useToolInputStreaming"; import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; import { useUIPlaygroundStore, @@ -65,7 +69,6 @@ import { handleUploadFileMessage, } from "./widget-file-messages"; import { CheckoutDialogV2 } from "./checkout-dialog-v2"; -import { StreamingPlaybackBar } from "./streaming-playback-bar"; import { fetchMcpAppsWidgetContent } from "./fetch-widget-content"; import type { CheckoutSession } from "@/shared/acp-types"; import { listResources, readResource } from "@/lib/apis/mcp-resources-api"; @@ -122,6 +125,8 @@ interface MCPAppsRendererProps { ) => void; /** Callback when app declares its supported display modes during ui/initialize */ onAppSupportedDisplayModesChange?: (modes: DisplayMode[] | undefined) => void; + /** Callback when streaming playback data changes (for embedding playback bar elsewhere) */ + onStreamingPlaybackDataChange?: (data: StreamingPlaybackData | null) => void; /** Whether the server is offline (for using cached content) */ isOffline?: boolean; /** URL to cached widget HTML for offline rendering */ @@ -151,6 +156,7 @@ export function MCPAppsRenderer({ onExitFullscreen, onModelContextUpdate, onAppSupportedDisplayModesChange, + onStreamingPlaybackDataChange, isOffline, cachedWidgetHtmlUrl, }: MCPAppsRendererProps) { @@ -476,10 +482,6 @@ export function MCPAppsRenderer({ const setStreamingHistoryCount = useWidgetDebugStore( (s) => s.setStreamingHistoryCount, ); - const streamingPlaybackActive = useWidgetDebugStore( - (s) => s.widgets.get(toolCallId)?.streamingPlaybackActive ?? false, - ); - // Clear CSP violations when CSP mode changes (stale data from previous mode) useEffect(() => { if (loadedCspMode !== null && loadedCspMode !== cspMode) { @@ -560,6 +562,27 @@ export function MCPAppsRenderer({ } }, [partialHistory.length, toolCallId, setStreamingHistoryCount]); + // Push streaming playback data to parent for embedding in ToolPart + useEffect(() => { + if (!onStreamingPlaybackDataChange) return; + if (partialHistory.length > 1) { + onStreamingPlaybackDataChange({ + partialHistory, + replayToPosition, + exitReplay, + isReplayActive, + }); + } else { + onStreamingPlaybackDataChange(null); + } + }, [ + partialHistory, + replayToPosition, + exitReplay, + isReplayActive, + onStreamingPlaybackDataChange, + ]); + // CSS Variables for theming (SEP-1865 styles.variables) // These are sent via hostContext.styles.variables - the SDK should pass them through const useChatGPTStyle = isPlaygroundActive && hostStyle === "chatgpt"; @@ -1218,16 +1241,6 @@ export function MCPAppsRenderer({ )} - {streamingPlaybackActive && partialHistory.length > 1 && ( - - )} - {/* Uses SandboxedIframe for DRY double-iframe architecture */} (null); - const setStreamingPlaybackActive = useWidgetDebugStore( - (s) => s.setStreamingPlaybackActive, - ); const lastIndex = partialHistory.length - 1; @@ -178,12 +173,6 @@ export function StreamingPlaybackBar({ }; }, []); - const handleClose = () => { - stopPlayback(); - exitReplay(); - setStreamingPlaybackActive(toolCallId, false); - }; - const handlePlayPause = () => { if (isPlaying) { stopPlayback(); @@ -333,21 +322,6 @@ export function StreamingPlaybackBar({ ))} - - {/* Close button */} - - - - - Close playback -
{/* Collapsible JSON panel */} diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts index 652e9431d..cf5c8bce7 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts @@ -30,6 +30,13 @@ export interface PartialHistoryEntry { isFinal?: boolean; } +export interface StreamingPlaybackData { + partialHistory: PartialHistoryEntry[]; + replayToPosition: (position: number) => void; + exitReplay: () => void; + isReplayActive: boolean; +} + export type ToolState = | "input-streaming" | "input-available" diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/part-switch.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/part-switch.tsx index 9f3ab3f5e..4eaeee304 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/part-switch.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/part-switch.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useMemo } from "react"; +import type { StreamingPlaybackData } from "./mcp-apps/useToolInputStreaming"; import { type ToolUIPart, type DynamicToolUIPart, type UITools } from "ai"; import { UIMessage } from "@ai-sdk/react"; import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js"; @@ -94,6 +95,8 @@ export function PartSwitch({ const [appSupportedDisplayModes, setAppSupportedDisplayModes] = useState< DisplayMode[] | undefined >(); + const [streamingPlaybackData, setStreamingPlaybackData] = + useState(null); void messageParts; // Get auth and app state for saving views @@ -427,6 +430,7 @@ export function PartSwitch({ onRequestPip={onRequestPip} onExitPip={onExitPip} appSupportedDisplayModes={appSupportedDisplayModes} + streamingPlaybackData={streamingPlaybackData} onSaveView={showSaveViewButton ? handleSaveView : undefined} canSaveView={showSaveViewButton ? canSaveView : undefined} saveDisabledReason={ @@ -461,6 +465,7 @@ export function PartSwitch({ onRequestFullscreen={onRequestFullscreen} onExitFullscreen={onExitFullscreen} onAppSupportedDisplayModesChange={setAppSupportedDisplayModes} + onStreamingPlaybackDataChange={setStreamingPlaybackData} isOffline={renderOverride?.isOffline} cachedWidgetHtmlUrl={renderOverride?.cachedWidgetHtmlUrl} /> diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx index 973dd5ce1..fdfbc425b 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx @@ -38,6 +38,8 @@ import { import { CspDebugPanel } from "../csp-debug-panel"; import { JsonEditor } from "@/components/ui/json-editor"; import { cn } from "@/lib/chat-utils"; +import type { StreamingPlaybackData } from "../mcp-apps/useToolInputStreaming"; +import { StreamingPlaybackBar } from "../mcp-apps/streaming-playback-bar"; type ApprovalVisualState = "pending" | "approved" | "denied"; const SAVE_VIEW_BUTTON_USED_KEY = "mcpjam-save-view-button-used"; @@ -62,6 +64,7 @@ export function ToolPart({ canSaveView, saveDisabledReason, isSaving, + streamingPlaybackData, }: { part: ToolUIPart | DynamicToolUIPart; uiType?: UIType | null; @@ -75,6 +78,8 @@ export function ToolPart({ onExitPip?: (toolCallId: string) => void; /** Display modes the app declared support for. If undefined, all modes are available. */ appSupportedDisplayModes?: DisplayMode[]; + /** Streaming playback data lifted from MCPAppsRenderer for embedding playback bar */ + streamingPlaybackData?: StreamingPlaybackData | null; approvalId?: string; onApprove?: (id: string) => void; onDeny?: (id: string) => void; @@ -142,9 +147,6 @@ export function ToolPart({ const streamingHistoryCount = useWidgetDebugStore((s) => toolCallId ? (s.widgets.get(toolCallId)?.streamingHistoryCount ?? 0) : 0, ); - const setStreamingPlaybackActive = useWidgetDebugStore( - (s) => s.setStreamingPlaybackActive, - ); const hasWidgetDebug = !!widgetDebugInfo; const showDisplayModeControls = @@ -211,23 +213,20 @@ export function ToolPart({ const handleDebugClick = ( tab: "data" | "state" | "csp" | "context" | "streaming", ) => { - // Deactivate streaming playback when switching away from streaming tab - if (activeDebugTab === "streaming" && tab !== "streaming" && toolCallId) { - setStreamingPlaybackActive(toolCallId, false); + // Exit streaming replay when switching away from streaming tab + if (activeDebugTab === "streaming" && tab !== "streaming") { + streamingPlaybackData?.exitReplay(); } if (activeDebugTab === tab) { - if (tab === "streaming" && toolCallId) { - setStreamingPlaybackActive(toolCallId, false); + if (tab === "streaming") { + streamingPlaybackData?.exitReplay(); } setActiveDebugTab(null); setUserExpanded(false); } else { setActiveDebugTab(tab); setUserExpanded(true); - if (tab === "streaming" && toolCallId) { - setStreamingPlaybackActive(toolCallId, true); - } } }; @@ -705,12 +704,21 @@ export function ToolPart({ )}
)} - {hasWidgetDebug && activeDebugTab === "streaming" && ( -
- Streaming playback controls are above the widget.{" "} - {streamingHistoryCount} streaming snapshots recorded. -
- )} + {hasWidgetDebug && activeDebugTab === "streaming" && + (streamingPlaybackData && + streamingPlaybackData.partialHistory.length > 1 ? ( + + ) : ( +
+ {streamingHistoryCount} streaming snapshots recorded. +
+ ))} {!hasWidgetDebug && (
{hasInput && ( From 49cc7e7a4fed0cc0f4fe54374882aba567e893a8 Mon Sep 17 00:00:00 2001 From: Andrew Khadder <54488379+khandrew1@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:16:29 -0800 Subject: [PATCH 4/7] Remove streaming history cap and update hook test --- .../mcp-apps/__tests__/useToolInputStreaming.test.ts | 12 +++++------- .../chat-v2/thread/mcp-apps/useToolInputStreaming.ts | 3 --- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/useToolInputStreaming.test.ts b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/useToolInputStreaming.test.ts index bfb7838f8..53a600f3f 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/useToolInputStreaming.test.ts +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/useToolInputStreaming.test.ts @@ -5,7 +5,6 @@ import { useToolInputStreaming, PARTIAL_INPUT_THROTTLE_MS, STREAMING_REVEAL_FALLBACK_MS, - PARTIAL_HISTORY_MAX_ENTRIES, type ToolState, } from "../useToolInputStreaming"; @@ -543,7 +542,7 @@ describe("useToolInputStreaming", () => { expect(result.current.partialHistory).toEqual([]); }); - it("history is capped at PARTIAL_HISTORY_MAX_ENTRIES", () => { + it("history keeps growing past 200 entries", () => { const props = createDefaultProps(bridge); props.toolState = "input-streaming"; @@ -552,7 +551,8 @@ describe("useToolInputStreaming", () => { ); // Send many distinct partials - for (let i = 0; i < PARTIAL_HISTORY_MAX_ENTRIES + 50; i++) { + const partialCount = 250; + for (let i = 0; i < partialCount; i++) { act(() => { vi.advanceTimersByTime(PARTIAL_INPUT_THROTTLE_MS + 10); }); @@ -565,10 +565,8 @@ describe("useToolInputStreaming", () => { props.toolInput = { code: "final" }; rerender(); - // The final entry addition is also guarded, so total should be capped - expect( - result.current.partialHistory.length, - ).toBeLessThanOrEqual(PARTIAL_HISTORY_MAX_ENTRIES); + // Includes all unique partials plus the final full input entry. + expect(result.current.partialHistory.length).toBeGreaterThan(partialCount); }); }); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts index cf5c8bce7..093c3cda3 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts @@ -19,7 +19,6 @@ export const SIGNATURE_MAX_DEPTH = 4; export const SIGNATURE_MAX_ARRAY_ITEMS = 24; export const SIGNATURE_MAX_OBJECT_KEYS = 32; export const SIGNATURE_STRING_EDGE_LENGTH = 24; -export const PARTIAL_HISTORY_MAX_ENTRIES = 200; // ── Types ──────────────────────────────────────────────────────────────────── @@ -257,8 +256,6 @@ export function useToolInputStreaming({ const recordPartialEntry = useCallback( (input: Record, isFinal?: boolean) => { - if (partialHistoryRef.current.length >= PARTIAL_HISTORY_MAX_ENTRIES) - return; const now = Date.now(); if (recordingStartTimeRef.current === null) { recordingStartTimeRef.current = now; From 46fe989aac9acd53f8c6e83f16a7bb7efbbadd5f Mon Sep 17 00:00:00 2001 From: Andrew Khadder <54488379+khandrew1@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:18:28 -0800 Subject: [PATCH 5/7] fix(mcp-apps): remove First/Last buttons from streaming playback bar Jumping to arbitrary positions can leave the widget in an invalid state when intermediate state transitions are skipped. Keep Previous/Next for safe step-by-step navigation and the timeline slider for seeking. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/streaming-playback-bar.test.tsx | 37 ------------------ .../mcp-apps/streaming-playback-bar.tsx | 38 ------------------- 2 files changed, 75 deletions(-) diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx index 2f1bf8d78..478fc4d04 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx @@ -35,11 +35,9 @@ describe("StreamingPlaybackBar", () => { , ); - expect(screen.getByLabelText("First")).toBeInTheDocument(); expect(screen.getByLabelText("Previous")).toBeInTheDocument(); expect(screen.getByLabelText("Play")).toBeInTheDocument(); expect(screen.getByLabelText("Next")).toBeInTheDocument(); - expect(screen.getByLabelText("Last")).toBeInTheDocument(); }); it("displays position label", () => { @@ -79,41 +77,6 @@ describe("StreamingPlaybackBar", () => { expect(nextButton).toBeDisabled(); }); - it("First button calls replayToPosition with 0", () => { - const history = createHistory(4); - const replayToPosition = vi.fn(); - render( - , - ); - - fireEvent.click(screen.getByLabelText("First")); - - expect(replayToPosition).toHaveBeenCalledWith(0); - }); - - it("First button is disabled at position 0", () => { - const history = createHistory(4); - const replayToPosition = vi.fn(); - render( - , - ); - - // Navigate to first position - fireEvent.click(screen.getByLabelText("First")); - - // Now First and Previous should be disabled - expect(screen.getByLabelText("First")).toBeDisabled(); - expect(screen.getByLabelText("Previous")).toBeDisabled(); - }); - it("renders speed selector with default value", () => { const history = createHistory(4); render( diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/streaming-playback-bar.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/streaming-playback-bar.tsx index b3ecb86e3..6411b782d 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/streaming-playback-bar.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/streaming-playback-bar.tsx @@ -1,7 +1,5 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { - ChevronFirst, - ChevronLast, Play, Pause, SkipBack, @@ -195,24 +193,6 @@ export function StreamingPlaybackBar({
{/* Navigation buttons */}
- - - - - First - - - - Last -
{/* Separator */} From d131639b7b75043436fda51c3fad4967897a144f Mon Sep 17 00:00:00 2001 From: Andrew Khadder <54488379+khandrew1@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:53:53 -0800 Subject: [PATCH 6/7] fix(mcp-apps): fix badge clipping and nested border in streaming playback UI Co-Authored-By: Claude Opus 4.6 --- .../chat-v2/thread/mcp-apps/streaming-playback-bar.tsx | 2 +- .../client/src/components/chat-v2/thread/parts/tool-part.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/streaming-playback-bar.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/streaming-playback-bar.tsx index 6411b782d..548636715 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/streaming-playback-bar.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/streaming-playback-bar.tsx @@ -188,7 +188,7 @@ export function StreamingPlaybackBar({ const elapsedMs = currentEntry?.elapsedFromStart ?? 0; return ( -
+
{/* Transport controls row */}
{/* Navigation buttons */} diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx index fdfbc425b..85e33c366 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx @@ -511,7 +511,7 @@ export function ToolPart({
)} e.stopPropagation()} > {renderDebugOptionButtons()} From af0aecd903a1f3c6d9bb7d15ff79504d998dec2c Mon Sep 17 00:00:00 2001 From: Andrew Khadder <54488379+khandrew1@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:58:06 -0800 Subject: [PATCH 7/7] chore: format --- .../__tests__/streaming-playback-bar.test.tsx | 24 +++++-------------- .../__tests__/useToolInputStreaming.test.ts | 7 +++--- .../thread/mcp-apps/mcp-apps-renderer.tsx | 5 +++- .../mcp-apps/streaming-playback-bar.tsx | 17 ++++++------- .../thread/mcp-apps/useToolInputStreaming.ts | 16 +++++++++---- .../chat-v2/thread/parts/tool-part.tsx | 7 +++--- 6 files changed, 38 insertions(+), 38 deletions(-) diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx index 478fc4d04..146952d4e 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/streaming-playback-bar.test.tsx @@ -31,9 +31,7 @@ describe("StreamingPlaybackBar", () => { it("renders all transport control buttons", () => { const history = createHistory(4); - render( - , - ); + render(); expect(screen.getByLabelText("Previous")).toBeInTheDocument(); expect(screen.getByLabelText("Play")).toBeInTheDocument(); @@ -42,9 +40,7 @@ describe("StreamingPlaybackBar", () => { it("displays position label", () => { const history = createHistory(4); - render( - , - ); + render(); // Initially at last position: "4/4" expect(screen.getByText(/4\/4/)).toBeInTheDocument(); @@ -69,9 +65,7 @@ describe("StreamingPlaybackBar", () => { it("Next button is disabled at last position", () => { const history = createHistory(4); - render( - , - ); + render(); const nextButton = screen.getByLabelText("Next"); expect(nextButton).toBeDisabled(); @@ -79,26 +73,20 @@ describe("StreamingPlaybackBar", () => { it("renders speed selector with default value", () => { const history = createHistory(4); - render( - , - ); + render(); expect(screen.getByLabelText("Playback speed")).toBeInTheDocument(); }); it("renders timeline slider", () => { const history = createHistory(4); - render( - , - ); + render(); expect(screen.getByLabelText("Streaming timeline")).toBeInTheDocument(); }); it("renders Raw JSON collapsible trigger", () => { const history = createHistory(4); - render( - , - ); + render(); expect(screen.getByText("Raw JSON")).toBeInTheDocument(); }); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/useToolInputStreaming.test.ts b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/useToolInputStreaming.test.ts index 53a600f3f..732831faf 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/useToolInputStreaming.test.ts +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/__tests__/useToolInputStreaming.test.ts @@ -566,7 +566,9 @@ describe("useToolInputStreaming", () => { rerender(); // Includes all unique partials plus the final full input entry. - expect(result.current.partialHistory.length).toBeGreaterThan(partialCount); + expect(result.current.partialHistory.length).toBeGreaterThan( + partialCount, + ); }); }); @@ -616,8 +618,7 @@ describe("useToolInputStreaming", () => { it("replayToPosition(lastIndex) calls bridge.sendToolInput for final entry", () => { const { hookResult } = setupWithHistory(bridge); - const lastIndex = - hookResult.result.current.partialHistory.length - 1; + const lastIndex = hookResult.result.current.partialHistory.length - 1; act(() => { hookResult.result.current.replayToPosition(lastIndex); diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx index c34510a5d..e92f09a44 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/mcp-apps-renderer.tsx @@ -685,7 +685,10 @@ export function MCPAppsRenderer({ setIsReady(true); isReadyRef.current = true; const pendingReset = pendingReplayResetRef.current; - if (pendingReset && pendingReset.nonce === replayResetNonceRef.current) { + if ( + pendingReset && + pendingReset.nonce === replayResetNonceRef.current + ) { pendingReplayResetRef.current = null; pendingReset.resolve(); } diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/streaming-playback-bar.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/streaming-playback-bar.tsx index 548636715..3fcf57f50 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/streaming-playback-bar.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/streaming-playback-bar.tsx @@ -1,11 +1,5 @@ import { useState, useRef, useEffect, useCallback } from "react"; -import { - Play, - Pause, - SkipBack, - SkipForward, - ChevronDown, -} from "lucide-react"; +import { Play, Pause, SkipBack, SkipForward, ChevronDown } from "lucide-react"; import { Tooltip, TooltipContent, @@ -160,7 +154,14 @@ export function StreamingPlaybackBar({ playTimerRef.current = null; } }; - }, [isPlaying, currentPosition, lastIndex, playbackSpeed, partialHistory, replayToPosition]); + }, [ + isPlaying, + currentPosition, + lastIndex, + playbackSpeed, + partialHistory, + replayToPosition, + ]); // Cleanup on unmount useEffect(() => { diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts index 093c3cda3..97e54a718 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/mcp-apps/useToolInputStreaming.ts @@ -287,9 +287,9 @@ export function useToolInputStreaming({ const entry = history[position]; if (entry.isFinal) { - Promise.resolve( - bridge.sendToolInput({ arguments: entry.input }), - ).catch(() => {}); + Promise.resolve(bridge.sendToolInput({ arguments: entry.input })).catch( + () => {}, + ); } else { Promise.resolve( bridge.sendToolInputPartial({ arguments: entry.input }), @@ -402,7 +402,15 @@ export function useToolInputStreaming({ partialInputTimerRef.current = null; flushPartialInput(); }, PARTIAL_INPUT_THROTTLE_MS - elapsed); - }, [hasToolInputData, isReady, toolInput, toolState, bridgeRef, isReadyRef, recordPartialEntry]); + }, [ + hasToolInputData, + isReady, + toolInput, + toolState, + bridgeRef, + isReadyRef, + recordPartialEntry, + ]); // 5. Complete input delivery useEffect(() => { diff --git a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx index 85e33c366..89dfdfd6e 100644 --- a/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx +++ b/mcpjam-inspector/client/src/components/chat-v2/thread/parts/tool-part.tsx @@ -372,9 +372,7 @@ export function ToolPart({ {badge} @@ -704,7 +702,8 @@ export function ToolPart({ )}
)} - {hasWidgetDebug && activeDebugTab === "streaming" && + {hasWidgetDebug && + activeDebugTab === "streaming" && (streamingPlaybackData && streamingPlaybackData.partialHistory.length > 1 ? (