From a9b89e41ab8cd945e7c562bcbb2d97b04062c886 Mon Sep 17 00:00:00 2001 From: hope Date: Sat, 14 Mar 2026 14:38:20 +0800 Subject: [PATCH 1/2] fix(ui): prevent empty state overlay from blocking chat input (#45707) Problem: WebChat empty state covers input box for tool-only sessions (heartbeat/cron), causing complete session lockout. Three-layer defense: 1. Fix emptiness logic (check history + tools, not just renderable) 2. Force input z-index: 100 above overlay 3. Disable overlay pointer-events for click-through Added: chat.empty-state.test.ts (4 test cases) Fixes #45707 --- ui/src/styles/chat/layout.css | 11 ++++++ ui/src/ui/views/chat.empty-state.test.ts | 43 ++++++++++++++++++++++++ ui/src/ui/views/chat.ts | 7 +++- 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 ui/src/ui/views/chat.empty-state.test.ts diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 8c68a2327b4..c9a9b43735a 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -363,8 +363,10 @@ box-sizing: border-box; } +/* Fix #45707: Ensure input always accessible, even when empty state renders */ .agent-chat__input { position: relative; + z-index: 100; /* Force above empty state overlay */ display: flex; flex-direction: column; margin: 0 18px 14px; @@ -903,6 +905,7 @@ } /* Welcome state (new session) */ +/* Fix #45707: Constrain to message area, never cover input */ .agent-chat__welcome { display: flex; flex-direction: column; @@ -913,6 +916,14 @@ padding: 48px 24px; flex: 1; min-height: 0; + /* Ensure welcome state stays below input box in stacking context */ + z-index: 1; + pointer-events: none; /* Allow clicks to pass through to input area */ +} + +/* Re-enable pointer events on interactive elements within welcome state */ +.agent-chat__welcome > * { + pointer-events: auto; } .agent-chat__welcome-glow { diff --git a/ui/src/ui/views/chat.empty-state.test.ts b/ui/src/ui/views/chat.empty-state.test.ts new file mode 100644 index 00000000000..707572230be --- /dev/null +++ b/ui/src/ui/views/chat.empty-state.test.ts @@ -0,0 +1,43 @@ +/** + * Tests for #45707 - WebChat empty state overlay blocking input + */ + +import { describe, it, expect } from "vitest"; + +describe("chat empty state logic (#45707)", () => { + it("should not treat session with tool messages as empty", () => { + const history: unknown[] = []; + const tools: unknown[] = [{ role: "tool", content: "Heartbeat", timestamp: Date.now() }]; + const hasAnyMessages = history.length > 0 || tools.length > 0; + const isEmpty = !hasAnyMessages; + expect(hasAnyMessages).toBe(true); + expect(isEmpty).toBe(false); + }); + + it("should treat truly empty session as empty", () => { + const history: unknown[] = []; + const tools: unknown[] = []; + const hasAnyMessages = history.length > 0 || tools.length > 0; + const isEmpty = !hasAnyMessages; + expect(hasAnyMessages).toBe(false); + expect(isEmpty).toBe(true); + }); + + it("should not treat session with history messages as empty", () => { + const history: unknown[] = [{ role: "user", content: "Hello", timestamp: Date.now() }]; + const tools: unknown[] = []; + const hasAnyMessages = history.length > 0 || tools.length > 0; + const isEmpty = !hasAnyMessages; + expect(hasAnyMessages).toBe(true); + expect(isEmpty).toBe(false); + }); + + it("should handle mixed history and tool messages", () => { + const history: unknown[] = [{ role: "user", content: "Check", timestamp: Date.now() }]; + const tools: unknown[] = [{ role: "tool", content: "Cron", timestamp: Date.now() }]; + const hasAnyMessages = history.length > 0 || tools.length > 0; + const isEmpty = !hasAnyMessages; + expect(hasAnyMessages).toBe(true); + expect(isEmpty).toBe(false); + }); +}); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 88a712706f0..d3a988155ed 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -850,7 +850,12 @@ export function renderChat(props: ChatProps) { }; const chatItems = buildChatItems(props); - const isEmpty = chatItems.length === 0 && !props.loading; + // Fix #45707: Check total message count, not just renderable items + // Sessions with tool-call messages (heartbeat/cron) should not be treated as empty + const history = Array.isArray(props.messages) ? props.messages : []; + const tools = Array.isArray(props.toolMessages) ? props.toolMessages : []; + const hasAnyMessages = history.length > 0 || tools.length > 0; + const isEmpty = !hasAnyMessages && !props.loading; const thread = html`
Date: Sat, 14 Mar 2026 15:08:17 +0800 Subject: [PATCH 2/2] fix(ui): incorporate PR #45743 improvements for #45707 Enhancements from PR #45743: 1. Check streamSegments and live stream for session activity 2. Add min-height: 0 to .chat-main for flex layout fix 3. Expand test coverage to 6 cases (streaming scenarios) Retained improvements: - Three-layer defense (z-index, pointer-events) - Comprehensive PR description - Defense-in-depth against regressions Fixes #45707 --- ui/src/styles/chat/sidebar.css | 1 + ui/src/ui/views/chat.empty-state.test.ts | 112 ++++++++++++++++++----- ui/src/ui/views/chat.ts | 20 ++-- 3 files changed, 100 insertions(+), 33 deletions(-) diff --git a/ui/src/styles/chat/sidebar.css b/ui/src/styles/chat/sidebar.css index de6010f3ed7..9e1d4483da6 100644 --- a/ui/src/styles/chat/sidebar.css +++ b/ui/src/styles/chat/sidebar.css @@ -9,6 +9,7 @@ .chat-main { min-width: 400px; + min-height: 0; /* Prevent flex overflow that can hide input area */ display: flex; flex-direction: column; overflow: hidden; diff --git a/ui/src/ui/views/chat.empty-state.test.ts b/ui/src/ui/views/chat.empty-state.test.ts index 707572230be..31dba98b538 100644 --- a/ui/src/ui/views/chat.empty-state.test.ts +++ b/ui/src/ui/views/chat.empty-state.test.ts @@ -1,43 +1,105 @@ /** * Tests for #45707 - WebChat empty state overlay blocking input + * + * This test ensures that sessions with tool-call messages (heartbeat/cron), + * streaming content, or live streams are not treated as empty, preventing + * the welcome overlay from blocking the input box. */ import { describe, it, expect } from "vitest"; describe("chat empty state logic (#45707)", () => { it("should not treat session with tool messages as empty", () => { - const history: unknown[] = []; - const tools: unknown[] = [{ role: "tool", content: "Heartbeat", timestamp: Date.now() }]; - const hasAnyMessages = history.length > 0 || tools.length > 0; - const isEmpty = !hasAnyMessages; - expect(hasAnyMessages).toBe(true); - expect(isEmpty).toBe(false); + const messages: unknown[] = []; + const toolMessages: unknown[] = [{ role: "tool", content: "Heartbeat", timestamp: Date.now() }]; + const streamSegments: Array<{ text: string; ts: number }> = []; + const stream: string | null = null; + + const hasSessionActivity = + messages.length > 0 || + toolMessages.length > 0 || + streamSegments.some((segment) => segment.text.trim()) || + stream !== null; + + expect(hasSessionActivity).toBe(true); }); it("should treat truly empty session as empty", () => { - const history: unknown[] = []; - const tools: unknown[] = []; - const hasAnyMessages = history.length > 0 || tools.length > 0; - const isEmpty = !hasAnyMessages; - expect(hasAnyMessages).toBe(false); - expect(isEmpty).toBe(true); + const messages: unknown[] = []; + const toolMessages: unknown[] = []; + const streamSegments: Array<{ text: string; ts: number }> = []; + const stream: string | null = null; + + const hasSessionActivity = + messages.length > 0 || + toolMessages.length > 0 || + streamSegments.some((segment) => segment.text.trim()) || + stream !== null; + + expect(hasSessionActivity).toBe(false); }); it("should not treat session with history messages as empty", () => { - const history: unknown[] = [{ role: "user", content: "Hello", timestamp: Date.now() }]; - const tools: unknown[] = []; - const hasAnyMessages = history.length > 0 || tools.length > 0; - const isEmpty = !hasAnyMessages; - expect(hasAnyMessages).toBe(true); - expect(isEmpty).toBe(false); + const messages: unknown[] = [{ role: "user", content: "Hello", timestamp: Date.now() }]; + const toolMessages: unknown[] = []; + const streamSegments: Array<{ text: string; ts: number }> = []; + const stream: string | null = null; + + const hasSessionActivity = + messages.length > 0 || + toolMessages.length > 0 || + streamSegments.some((segment) => segment.text.trim()) || + stream !== null; + + expect(hasSessionActivity).toBe(true); }); - it("should handle mixed history and tool messages", () => { - const history: unknown[] = [{ role: "user", content: "Check", timestamp: Date.now() }]; - const tools: unknown[] = [{ role: "tool", content: "Cron", timestamp: Date.now() }]; - const hasAnyMessages = history.length > 0 || tools.length > 0; - const isEmpty = !hasAnyMessages; - expect(hasAnyMessages).toBe(true); - expect(isEmpty).toBe(false); + it("should not treat session with streaming content as empty", () => { + const messages: unknown[] = []; + const toolMessages: unknown[] = []; + const streamSegments: Array<{ text: string; ts: number }> = [ + { text: "Streaming response...", ts: Date.now() }, + ]; + const stream: string | null = null; + + const hasSessionActivity = + messages.length > 0 || + toolMessages.length > 0 || + streamSegments.some((segment) => segment.text.trim()) || + stream !== null; + + expect(hasSessionActivity).toBe(true); + }); + + it("should not treat session with live stream as empty", () => { + const messages: unknown[] = []; + const toolMessages: unknown[] = []; + const streamSegments: Array<{ text: string; ts: number }> = []; + const stream: string | null = "Live streaming..."; + + const hasSessionActivity = + messages.length > 0 || + toolMessages.length > 0 || + streamSegments.some((segment) => segment.text.trim()) || + stream !== null; + + expect(hasSessionActivity).toBe(true); + }); + + it("should handle mixed messages and streaming", () => { + const messages: unknown[] = [{ role: "user", content: "Check", timestamp: Date.now() }]; + const toolMessages: unknown[] = [{ role: "tool", content: "Cron", timestamp: Date.now() }]; + const streamSegments: Array<{ text: string; ts: number }> = [ + { text: "Thinking...", ts: Date.now() }, + ]; + const stream: string | null = null; + + const hasSessionActivity = + messages.length > 0 || + toolMessages.length > 0 || + streamSegments.some((segment) => segment.text.trim()) || + stream !== null; + + expect(hasSessionActivity).toBe(true); }); }); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index d3a988155ed..9797ed55d44 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -850,12 +850,16 @@ export function renderChat(props: ChatProps) { }; const chatItems = buildChatItems(props); - // Fix #45707: Check total message count, not just renderable items - // Sessions with tool-call messages (heartbeat/cron) should not be treated as empty - const history = Array.isArray(props.messages) ? props.messages : []; - const tools = Array.isArray(props.toolMessages) ? props.toolMessages : []; - const hasAnyMessages = history.length > 0 || tools.length > 0; - const isEmpty = !hasAnyMessages && !props.loading; + // Fix #45707: Check for any session activity, not just renderable messages + // Includes tool-call messages (heartbeat/cron), streaming content, and live streams + const hasSessionActivity = + (Array.isArray(props.messages) && props.messages.length > 0) || + (Array.isArray(props.toolMessages) && props.toolMessages.length > 0) || + (Array.isArray(props.streamSegments) && + props.streamSegments.some((segment) => segment.text.trim())) || + props.stream !== null; + const showWelcomeState = !hasSessionActivity && !props.loading && !vs.searchOpen; + const showEmptySearch = chatItems.length === 0 && !props.loading && vs.searchOpen; const thread = html`
No matching messages
`