fix: 0.1.8 — hook-order in AskUserQuestion + per-session stream buffer

P0: AskUserQuestion.tsx had `if (questions.length === 0) return null`
sitting between the `useState` calls and the `useMemo` calls below.
On any render where parseInput(input) flipped between empty and
non-empty (e.g. streaming permission_request input mutates) React
threw "Rendered more hooks than during the previous render". Moved
the early return after every hook call.

P1: chatStore.ts had `pendingDelta` + `flushTimer` at module scope,
shared across every active session. When two sessions streamed at
the same time (e.g. user has a team-member tab open alongside their
own), session B's content_delta would queue onto the same module
buffer as session A; whichever flush timer fired first emptied the
buffer into its own session. Result: text leaked between
conversations.

Replaced with a `Map<sessionId, { pending, timer }>` so each stream
owns its own throttle buffer. `consumePendingDelta(sessionId)` and
`dropBuffer(sessionId)` keep the API similar to before; nine
callsites updated.

Both issues caught by the bug-audit agent run after 0.1.7 build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 11:17:22 +08:00
co-authored by Claude Opus 4.7
parent de239c32d6
commit dea6d251a4
5 changed files with 61 additions and 38 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "heicode-desktop",
"private": true,
"version": "0.1.7",
"version": "0.1.8",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "heicode-desktop"
version = "0.1.7"
version = "0.1.8"
edition = "2021"
[lib]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
"productName": "HeiCode",
"version": "0.1.7",
"version": "0.1.8",
"identifier": "com.heicode.desktop",
"build": {
"frontendDist": "../dist",
@@ -76,8 +76,9 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
const [hasSubmitted, setHasSubmitted] = useState(false)
const composingRef = useRef(false)
if (questions.length === 0) return null
// All `useMemo`/`useEffect`-like calls must stay above any early return,
// otherwise React's hook-ordering invariant breaks the next render. The
// empty-questions short-circuit happens *after* all hooks are registered.
const resultAnswers = useMemo(() => {
if (!result || typeof result !== 'object') return {}
const answers = (result as { answers?: unknown }).answers
@@ -98,6 +99,8 @@ export function AskUserQuestion({ toolUseId, input, result }: Props) {
}, [freeText, questions, resultAnswers, selections])
const submitted = Object.keys(resultAnswers).length > 0 || hasSubmitted
if (questions.length === 0) return null
const handleSelect = (qIndex: number, label: string) => {
if (submitted) return
const isMulti = !!questions[qIndex]?.multiSelect
+53 -33
View File
@@ -127,20 +127,44 @@ const pendingTaskToolUseIds = new Set<string>()
let msgCounter = 0
const nextId = () => `msg-${++msgCounter}-${Date.now()}`
// Streaming throttle for content_delta
let pendingDelta = ''
let flushTimer: ReturnType<typeof setTimeout> | null = null
// Streaming throttle for content_delta — **per session**. The previous
// implementation kept `pendingDelta` and `flushTimer` at module scope,
// which meant two sessions streaming at the same time would interleave
// their text into whichever session's flush callback fired first.
// Keyed by sessionId so each in-flight stream owns its own buffer.
type DeltaBuffer = {
pending: string
timer: ReturnType<typeof setTimeout> | null
}
const deltaBuffers = new Map<string, DeltaBuffer>()
function consumePendingDelta(): string {
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
function getBuffer(sessionId: string): DeltaBuffer {
let b = deltaBuffers.get(sessionId)
if (!b) {
b = { pending: '', timer: null }
deltaBuffers.set(sessionId, b)
}
const text = pendingDelta
pendingDelta = ''
return b
}
function consumePendingDelta(sessionId: string): string {
const b = deltaBuffers.get(sessionId)
if (!b) return ''
if (b.timer) {
clearTimeout(b.timer)
b.timer = null
}
const text = b.pending
b.pending = ''
return text
}
function dropBuffer(sessionId: string): void {
const b = deltaBuffers.get(sessionId)
if (b?.timer) clearTimeout(b.timer)
deltaBuffers.delete(sessionId)
}
function appendAssistantTextMessage(
messages: UIMessage[],
content: string,
@@ -249,11 +273,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
disconnectSession: (sessionId) => {
const session = get().sessions[sessionId]
if (session?.elapsedTimer) clearInterval(session.elapsedTimer)
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
if (pendingDelta) {
const text = consumePendingDelta()
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
const tail = consumePendingDelta(sessionId)
if (tail) {
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + tail })) }))
}
dropBuffer(sessionId)
wsManager.disconnect(sessionId)
set((s) => {
const { [sessionId]: _, ...rest } = s.sessions
@@ -287,11 +311,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
set((s) => {
const session = s.sessions[sessionId] ?? createDefaultSessionState()
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
const bufferedDelta = consumePendingDelta()
const bufferedDelta = consumePendingDelta(sessionId)
const pendingAssistantText = `${session.streamingText}${bufferedDelta}`
const newMessages = pendingAssistantText.trim()
@@ -403,10 +423,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
stopGeneration: (sessionId) => {
wsManager.send(sessionId, { type: 'stop_generation' })
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null }
if (pendingDelta) {
const text = consumePendingDelta()
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
const stopTail = consumePendingDelta(sessionId)
if (stopTail) {
set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + stopTail })) }))
}
set((s) => {
const session = s.sessions[sessionId]
@@ -528,7 +547,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
case 'status':
update((session) => {
const pendingText = `${session.streamingText}${consumePendingDelta()}`
const pendingText = `${session.streamingText}${consumePendingDelta(sessionId)}`
const hasPendingStreamText =
session.chatState === 'streaming' && pendingText.trim().length > 0
// Background task progress can arrive while the assistant is still
@@ -562,7 +581,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
case 'content_start': {
const session = get().sessions[sessionId]
if (!session) break
const pendingText = `${session.streamingText}${consumePendingDelta()}`
const pendingText = `${session.streamingText}${consumePendingDelta(sessionId)}`
if (msg.blockType !== 'text' && pendingText.trim()) {
update((s) => ({
messages: appendAssistantTextMessage(s.messages, pendingText, Date.now()),
@@ -589,12 +608,13 @@ export const useChatStore = create<ChatStore>((set, get) => ({
case 'content_delta':
if (msg.text !== undefined) {
pendingDelta += msg.text
if (!flushTimer) {
flushTimer = setTimeout(() => {
const text = pendingDelta
pendingDelta = ''
flushTimer = null
const buf = getBuffer(sessionId)
buf.pending += msg.text
if (!buf.timer) {
buf.timer = setTimeout(() => {
const text = buf.pending
buf.pending = ''
buf.timer = null
update((s) => ({ streamingText: s.streamingText + text }))
}, 50)
}
@@ -604,7 +624,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
case 'thinking':
update((s) => {
const pendingText = `${s.streamingText}${consumePendingDelta()}`
const pendingText = `${s.streamingText}${consumePendingDelta(sessionId)}`
const base = pendingText.trim()
? appendAssistantTextMessage(s.messages, pendingText, Date.now())
: s.messages
@@ -701,7 +721,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
case 'message_complete': {
const session = get().sessions[sessionId]
if (!session) break
const text = `${session.streamingText}${consumePendingDelta()}`
const text = `${session.streamingText}${consumePendingDelta(sessionId)}`
if (text.trim()) {
update((s) => ({
messages: appendAssistantTextMessage(s.messages, text, Date.now()),
@@ -724,7 +744,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
case 'error':
update((s) => {
const pendingText = `${s.streamingText}${consumePendingDelta()}`
const pendingText = `${s.streamingText}${consumePendingDelta(sessionId)}`
let newMessages = s.messages
if (pendingText.trim()) {
newMessages = appendAssistantTextMessage(newMessages, pendingText, Date.now())