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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "heicode-desktop",
|
||||
"private": true,
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "heicode-desktop"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user