Skip to content

Conversation

@JoziGila
Copy link

@JoziGila JoziGila commented Jan 8, 2026

Summary

  • Refactors streaming text/thinking blocks to use established useSignal() pattern (like ToolBlock)
  • Replaces alienEffect() + Vue ref bridge with direct signal consumption
  • Adds StreamingController for concurrent subagent stream support via parent_tool_use_id
  • Fixes mermaid diagram rendering (classDiagram, pie, gantt) with ESM alias and CSP updates

Changes

Streaming Architecture

  • ContentBlockWrapper.ts: Added textSignal, streamingSignal following toolResultSignal pattern
  • TextBlock.vue / ThinkingBlock.vue: Now use useSignal(props.wrapper.text) instead of manual effect bridge
  • StreamingController.ts: New event router with adaptive timeouts (30s text, 2min thinking, 1min tool_use)
  • Message.ts: Added streaming state signals, createStreaming() factory, lifecycle methods

Mermaid Fix

  • webViewService.ts: CSP now includes 'unsafe-eval' blob: for mermaid dynamic imports
  • vite.config.ts: Alias mermaid to pre-built ESM bundle, fixing dynamic import failures in webview

Other

  • MarkdownContent.vue: Wrapper component with streaming vs final mode configuration
  • themeDetector.ts: Singleton MutationObserver pattern (already implemented)

Supersedes

Closes the issues raised in #45 review feedback:

  • Singleton observer pattern ✓ (already in themeDetector.ts)
  • MarkdownContent wrapper component ✓ (added)

Test plan

  • Send message and verify text streams character-by-character
  • Enable thinking and verify thinking blocks stream correctly
  • Verify mermaid diagrams render (classDiagram, pie, gantt, flowchart)
  • Toggle VSCode theme and verify mermaid updates colors

🤖 Generated with Claude Code

Summary by Sourcery

Refactor the webview’s assistant streaming architecture to use reactive signals and a centralized streaming controller while integrating a new markdown/mermaid rendering pipeline that works reliably in the VS Code webview.

New Features:

  • Introduce a StreamingController to manage Claude SDK stream events, concurrent subagent streams, and adaptive timeouts per content block type.
  • Add reactive streaming state, interruption, and error tracking to messages and content blocks, enabling live text/thinking updates and UI indicators.
  • Integrate a MarkdownContent wrapper component powered by markstream-vue with support for mermaid diagrams and VS Code theme awareness.
  • Provide a custom MermaidDiagram component with fullscreen, zoom, and copy capabilities tailored to the VS Code webview environment.

Bug Fixes:

  • Fix mermaid diagram rendering in the VS Code webview by using a pre-built ESM bundle, adjusting CSP script policies, and enabling required timeouts and imports.

Enhancements:

  • Unify text and thinking block rendering around a shared signal-based pattern, removing ad-hoc streaming bridges in Vue components.
  • Improve assistant message UI with streaming and interruption visual states, including an interruption badge and subtle streaming indicator.
  • Adopt a singleton theme detection utility to keep markdown and mermaid rendering in sync with VS Code’s dark/light theme changes.

Build:

  • Update Vite dev server configuration and module resolution to alias mermaid to its ESM bundle and pre-optimize it for the webview context.

@sourcery-ai
Copy link

sourcery-ai bot commented Jan 8, 2026

Reviewer's Guide

Refactors the webview streaming pipeline to use alien-signals-based message/block signals and a centralized StreamingController, while swapping the markdown/mermaid stack to markstream-vue with a custom Mermaid wrapper and relaxed CSP/ESM configuration so diagrams render correctly in VS Code.

Sequence diagram for StreamingController-driven message streaming

sequenceDiagram
    actor User
    participant VSCodeExtension as VSCodeExtension
    participant WebView as WebviewSession
    participant StreamingController as StreamingController
    participant Message as Message
    participant BlockWrapper as ContentBlockWrapper
    participant Vue as VueComponents

    User->>VSCodeExtension: Send prompt
    VSCodeExtension->>WebView: Stream events (AsyncIterable)

    loop For each incoming event
        VSCodeExtension->>WebView: event
        WebView->>WebView: processIncomingMessage(event)
        alt event.type == stream_event
            WebView->>StreamingController: handleStreamEvent(streamEvent, parentToolUseId)

            alt streamEvent.type == message_start
                StreamingController->>Message: createStreaming(parentToolUseId)
                Message-->>StreamingController: streaming Message
                StreamingController->>StreamingController: createStreamContext()
                StreamingController->>Message: setStreaming(true)
                StreamingController-->>WebView: onMessageCreated(message, parentToolUseId)
                WebView->>WebView: messages([...messages, message])
            else streamEvent.type == content_block_start
                StreamingController->>StreamingController: get StreamContext
                StreamingController->>BlockWrapper: new ContentBlockWrapper(content_block)
                StreamingController->>BlockWrapper: startStreaming()
                StreamingController->>Message: addBlockToMessage(message, wrapper)
            else streamEvent.type == content_block_delta
                StreamingController->>StreamingController: get StreamContext
                StreamingController->>BlockWrapper: appendDelta(text)
            else streamEvent.type == content_block_stop
                StreamingController->>StreamingController: get StreamContext
                StreamingController->>BlockWrapper: finalizeStreaming()
            else streamEvent.type == message_delta
                StreamingController->>StreamingController: update usage
                StreamingController-->>WebView: onUsageUpdate(usage)
            else streamEvent.type == message_stop
                StreamingController->>StreamingController: finalize all wrappers
                StreamingController->>Message: setStreaming(false)
                StreamingController-->>WebView: onMessageFinalized(message, parentToolUseId)
                StreamingController->>StreamingController: remove StreamContext
            else streamEvent.type == error
                StreamingController->>Message: setError(error)
                StreamingController->>Message: markInterrupted()
                StreamingController->>StreamingController: finalize wrappers and cleanup
            end
        else Non-stream events
            WebView->>WebView: processMessage(event)
            WebView->>WebView: processAndAttachMessage(messages, event)
        end

        WebView-->>Vue: messages signal updated
        Vue->>Vue: Re-render AssistantMessage
        Vue->>Vue: TextBlock/ThinkingBlock use useSignal(wrapper.text, wrapper.isStreaming)
    end

    Vue-->>User: Character-by-character streaming text and thinking blocks
Loading

Updated class diagram for Message, ContentBlockWrapper, and StreamingController

classDiagram
    class Message {
      +MessageRole type
      +MessageData message
      +number timestamp
      +string subtype
      +string session_id
      +boolean is_error
      +string parentToolUseId
      -signal<boolean> streamingSignal
      -signal<boolean> interruptedSignal
      -string errorMessage
      +Message(type: MessageRole, message: MessageData, timestamp: number, extra: any)
      +get isStreaming() signal~boolean~
      +get isInterrupted() signal~boolean~
      +getError(): string
      +setStreaming(streaming: boolean): void
      +markInterrupted(): void
      +setError(error: string): void
      +getIsStreaming(): boolean
      +getIsInterrupted(): boolean
      +disposeStreaming(): void
      +get isEmpty(): boolean
      +static fromRaw(raw: any): Message
      +static createStreaming(parentToolUseId: string): Message
    }

    class ContentBlockWrapper {
      +ContentBlockType content
      -signal~ToolResultBlock~ toolResultSignal
      -signal~string~ textSignal
      -signal~boolean~ streamingSignal
      +any toolUseResult
      +ContentBlockWrapper(content: ContentBlockType)
      +get text() signal~string~
      +get isStreaming() signal~boolean~
      +appendDelta(delta: string): void
      +startStreaming(): void
      +finalizeStreaming(finalContent: string): void
      +getIsStreaming(): boolean
      +getTextValue(): string
      +get toolResult() signal~ToolResultBlock~
      +setToolResult(result: ToolResultBlock): void
      +hasToolResult(): boolean
      +getToolResultValue(): ToolResultBlock
      -getInitialText(): string
    }

    class StreamingController {
      -Map~string, StreamContext~ activeStreams
      -boolean disposed
      -Function onMessageCreated
      -Function onMessageFinalized
      -Function onUsageUpdate
      +setOnMessageCreated(callback: Function): void
      +setOnMessageFinalized(callback: Function): void
      +setOnUsageUpdate(callback: Function): void
      +hasActiveStreams(): boolean
      +handleStreamEvent(event: any, parentToolUseId: string): void
      +cancel(parentToolUseId: string): void
      +dispose(): void
      -handleMessageStart(event: any, parentToolUseId: string): void
      -handleContentBlockStart(event: any, parentToolUseId: string): void
      -handleContentBlockDelta(event: any, parentToolUseId: string): void
      -handleContentBlockStop(event: any, parentToolUseId: string): void
      -handleMessageDelta(event: any, parentToolUseId: string): void
      -handleMessageStop(event: any, parentToolUseId: string): void
      -handlePing(parentToolUseId: string): void
      -handleError(event: any, parentToolUseId: string): void
      -createStreamContext(parentToolUseId: string): StreamContext
      -disposeStreamContext(context: StreamContext): void
      -setTimeoutForContext(context: StreamContext): void
      -clearTimeout(context: StreamContext): void
      -handleTimeout(context: StreamContext): void
      -getCurrentTimeout(context: StreamContext): number
      -addBlockToMessage(message: Message, wrapper: ContentBlockWrapper): void
      -getBlockType(contentBlock: any): ContentBlockType
    }

    class StreamContext {
      +string parentToolUseId
      +Message message
      +Map~number, ContentBlockWrapper~ wrappers
      +number currentBlockIndex
      +ContentBlockType currentBlockType
      +Timeout timeoutId
    }

    Message "1" o-- "*" ContentBlockWrapper : contains
    StreamingController "1" o-- "*" StreamContext : manages
    StreamContext "1" o-- "1" Message : owns
    StreamContext "1" o-- "*" ContentBlockWrapper : tracks
Loading

File-Level Changes

Change Details Files
Introduce signal-based streaming state on Message and ContentBlockWrapper and wire it into assistant/text/thinking rendering.
  • Add streaming, interruption, and error signal fields and helpers on Message, plus a createStreaming factory and disposeStreaming lifecycle method.
  • Add textSignal and streamingSignal to ContentBlockWrapper with startStreaming/appendDelta/finalizeStreaming helpers and initialize from static content.
  • Update AssistantMessage.vue to consume message.isStreaming/isInterrupted via useSignal and adjust classes/visuals, including an interrupted badge.
  • Update TextBlock.vue and ThinkingBlock.vue to consume wrapper.text and wrapper.isStreaming via useSignal and to use MarkdownContent where appropriate.
  • Update ContentBlock.vue to pass wrappers to text and thinking blocks and gate wrapper usage behind a needsWrapper computed.
src/webview/src/models/Message.ts
src/webview/src/models/ContentBlockWrapper.ts
src/webview/src/components/Messages/AssistantMessage.vue
src/webview/src/components/Messages/blocks/TextBlock.vue
src/webview/src/components/Messages/blocks/ThinkingBlock.vue
src/webview/src/components/Messages/ContentBlock.vue
Add StreamingController to manage SDK stream_event lifecycles, concurrent subagent streams, and adaptive timeouts, and integrate it into Session.
  • Implement StreamingController with per-parentToolUseId StreamContext, mapping SDK events to ContentBlockWrapper/Message mutations and enforcing CLEANUP via dispose/cancel.
  • Support adaptive timeouts per content block type and mark messages interrupted on timeout or error, finalizing any in-flight blocks.
  • Integrate StreamingController into Session with callbacks to append/finalize streaming messages and update usage.
  • Route stream_event messages through StreamingController in Session.processIncomingMessage and ensure controller is disposed in Session.dispose.
src/webview/src/core/StreamingController.ts
src/webview/src/core/Session.ts
Replace marked-based markdown rendering with a configurable MarkdownContent wrapper using markstream-vue and hook up Mermaid rendering with a custom component and theme detection.
  • Introduce MarkdownContent.vue to render markdown via MarkdownRender with separate streaming vs final configurations and mermaid enabled.
  • Add a singleton-based useThemeDetector composable to track VS Code light/dark theme via MutationObserver.
  • Wire markstream-vue into main.ts, registering MermaidDiagram as the custom mermaid renderer and enabling mermaid globally.
  • Replace local marked usage in TextBlock.vue and ExitPlanMode.vue with MarkdownContent/MarkdownRender.
  • Add markstream-vue and mermaid dependencies and remove marked from package.json.
src/webview/src/components/Messages/MarkdownContent.vue
src/webview/src/utils/themeDetector.ts
src/webview/src/main.ts
src/webview/src/components/Messages/blocks/TextBlock.vue
src/webview/src/components/Messages/blocks/tools/ExitPlanMode.vue
package.json
Implement a custom MermaidDiagram wrapper around markstream-vue’s MermaidBlockNode with VS Code–style UI, copy support, fullscreen auto-zoom, and theme sync.
  • Create MermaidDiagram.vue that wraps MermaidBlockNode, integrates useThemeDetector, and exposes a custom copy button and hover actions.
  • Use DOM querying and MutationObserver to detect fullscreen modal open/close, auto-fit diagrams by simulating zoom controls, and reset inline zoom on close.
  • Style the embedded header/controls to appear as floating buttons and hide inline zoom while preserving functionality.
src/webview/src/components/Messages/MermaidDiagram.vue
src/webview/src/utils/themeDetector.ts
Relax CSP and Vite config to support mermaid ESM bundle and dynamic imports inside the VS Code webview.
  • Extend webview CSP script-src to allow 'unsafe-eval' and blob: sources where needed for mermaid’s dynamic imports and workers.
  • Add Vite dev server CORS headers and alias mermaid to the pre-built ESM bundle, plus optimizeDeps include for mermaid.
  • Ensure worker-src and related directives remain compatible with mermaid’s web worker usage.
src/services/webViewService.ts
src/webview/vite.config.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • In StreamingController.handleContentBlockDelta, delta.partial_json is passed directly into appendDelta which expects a string; if partial_json is an object for input_json_delta events, consider explicitly serializing (e.g. JSON.stringify) or handling that case to avoid [object Object] output or runtime issues.
  • The cancel method on StreamingController currently disposes the stream context without updating the associated Message (e.g. markInterrupted/setStreaming(false)), which may leave the UI in an inconsistent streaming state compared to timeout/error paths; consider aligning cancellation behavior with handleTimeout/handleError.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `StreamingController.handleContentBlockDelta`, `delta.partial_json` is passed directly into `appendDelta` which expects a string; if `partial_json` is an object for `input_json_delta` events, consider explicitly serializing (e.g. `JSON.stringify`) or handling that case to avoid `[object Object]` output or runtime issues.
- The `cancel` method on `StreamingController` currently disposes the stream context without updating the associated `Message` (e.g. `markInterrupted`/`setStreaming(false)`), which may leave the UI in an inconsistent streaming state compared to timeout/error paths; consider aligning cancellation behavior with `handleTimeout`/`handleError`.

## Individual Comments

### Comment 1
<location> `src/webview/src/core/StreamingController.ts:190-197` </location>
<code_context>
+  /**
+   * Cancel an active stream
+   */
+  cancel(parentToolUseId: string | null = null): void {
+    const context = this.activeStreams.get(parentToolUseId);
+    if (!context) {
+      return;
+    }
+
+    this.disposeStreamContext(context);
+    this.activeStreams.delete(parentToolUseId);
+  }
+
</code_context>

<issue_to_address>
**issue (bug_risk):** Canceling a stream does not update the Message streaming/interrupted state, which can leave the UI in an inconsistent state.

Right now `cancel` only disposes the stream context and never updates the `Message` state, so an in-progress message can remain `isStreaming = true` after a user cancel. Please mirror the state updates done in `handleTimeout`/`handleError` (e.g., mark as interrupted or at least not streaming and finalize any active wrappers) before disposing the context.
</issue_to_address>

### Comment 2
<location> `src/webview/src/core/StreamingController.ts:284` </location>
<code_context>
+    }
+
+    // Extract text from delta and append to wrapper
+    const text = delta.text ?? delta.thinking ?? delta.partial_json ?? '';
+    if (text) {
+      wrapper.appendDelta(text);
</code_context>

<issue_to_address>
**issue (bug_risk):** Delta handling assumes a `partial_json` field which may not match the SDK’s actual delta schema.

Given the type definition (`DeltaType = 'text_delta' | 'input_json_delta' | ...`), JSON deltas may come through as `input_json_delta`, but this code only reads `delta.partial_json`. If the SDK’s actual shape uses a different field (e.g. `input_json`, nested `delta`, etc.), those updates will be lost. Consider aligning this with the real SDK schema (e.g. branching on `delta.type`) or handling all known JSON delta shapes explicitly.
</issue_to_address>

### Comment 3
<location> `src/services/webViewService.ts:267-269` </location>
<code_context>
 			`style-src ${webview.cspSource} 'unsafe-inline' https://*.vscode-cdn.net;`,
 			`font-src ${webview.cspSource} data:;`,
-			`script-src ${webview.cspSource} 'nonce-${nonce}';`,
+			`script-src ${webview.cspSource} 'nonce-${nonce}' 'unsafe-eval' blob:;`,
 			`connect-src ${webview.cspSource} https:;`,
 			`worker-src ${webview.cspSource} blob:;`,
 		].join(' ');
</code_context>

<issue_to_address>
**🚨 issue (security):** Relaxing CSP with `unsafe-eval` and `blob:` for scripts has security implications and might be broader than necessary.

Allowing both `'unsafe-eval'` and `blob:` in the main webview greatly expands what JS can do. If this is only for Mermaid/markstream, can you confirm which directives are strictly required (e.g., do you actually need both, and do they need to apply to the main webview rather than just workers)? Also consider whether this can be limited to dev builds or a narrower origin to keep the CSP as tight as possible in the VS Code webview context.
</issue_to_address>

### Comment 4
<location> `src/webview/src/models/Message.ts:222` </location>
<code_context>
     const content = this.message.content;

-    // 字符串内容不会是 empty
+    // String content is not empty if has length
     if (typeof content === 'string') {
       return content.length === 0;
</code_context>

<issue_to_address>
**nitpick:** The comment for the string content branch contradicts the actual emptiness check.

The logic here is correct, but the comment states the opposite of what the code does: it suggests non-empty content, while the branch returns `true` only when `content.length === 0`. Please update or remove the comment so it accurately reflects that this branch returns `true` for empty strings.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@JoziGila JoziGila closed this Jan 8, 2026
@JoziGila JoziGila deleted the feat/streaming-message-architecture branch January 8, 2026 20:16
@JoziGila JoziGila restored the feat/streaming-message-architecture branch January 8, 2026 20:20
@JoziGila JoziGila reopened this Jan 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant