-
Notifications
You must be signed in to change notification settings - Fork 70
refactor: streaming architecture with useSignal pattern and mermaid ESM fix #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
refactor: streaming architecture with useSignal pattern and mermaid ESM fix #47
Conversation
Reviewer's GuideRefactors 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 streamingsequenceDiagram
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
Updated class diagram for Message, ContentBlockWrapper, and StreamingControllerclassDiagram
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
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this 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_jsonis passed directly intoappendDeltawhich expects a string; ifpartial_jsonis an object forinput_json_deltaevents, consider explicitly serializing (e.g.JSON.stringify) or handling that case to avoid[object Object]output or runtime issues. - The
cancelmethod onStreamingControllercurrently disposes the stream context without updating the associatedMessage(e.g.markInterrupted/setStreaming(false)), which may leave the UI in an inconsistent streaming state compared to timeout/error paths; consider aligning cancellation behavior withhandleTimeout/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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
Summary
useSignal()pattern (like ToolBlock)alienEffect()+ Vue ref bridge with direct signal consumptionparent_tool_use_idChanges
Streaming Architecture
textSignal,streamingSignalfollowingtoolResultSignalpatternuseSignal(props.wrapper.text)instead of manual effect bridgecreateStreaming()factory, lifecycle methodsMermaid Fix
'unsafe-eval' blob:for mermaid dynamic importsOther
Supersedes
Closes the issues raised in #45 review feedback:
Test plan
🤖 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:
Bug Fixes:
Enhancements:
Build: