Agent Conversation UI
Agent Conversation UI
Covers the conversation panel’s rendering behaviour: empty state, user and assistant message display, streaming chunk accumulation with blinking cursor, tool call badge lifecycle, system message injection, permission request dialog, error state rendering, panel open/close mechanics, Escape key handling, conversation clear, and auto-scroll. This spec is P0 because the conversation panel is the only user-facing surface for the agent — rendering failures, lost streaming chunks, stuck cursors, or broken permission dialogs directly degrade the core agent experience.
The conversation UI is driven by the Zustand agentStore (apps/desktop/src-react/stores/agentStore.ts) and the
useAgentEventListeners hook. Events arriving on the Tauri bus (agent:message-chunk, agent:session-completed,
agent:session-interrupted, agent:tool-call, agent:error, agent:permission-request, agent:status-changed)
mutate the store, which re-renders ConversationPanel, MessageThread, StreamingMessage, and
PermissionRequestDialog. The panel itself has data-testid="conversation-panel". Individual message types use
data-testid="user-message", data-testid="assistant-message", and data-testid="system-message".
Preconditions
- HTTP bridge running on port 9990
- A workspace initialized via
initialize_workspacebefore each scenario - Bridge shim injected via
playwright.config.ts - Agent panel opened by triggering the relevant UI action (toolbar button, keyboard shortcut, or
dialogStore.openAgentPanel()) before each scenario that requires it to be visible - Streaming/event constraint: The conversation UI reacts to Tauri events and HTTP bridge responses. Full-fidelity
streaming tests require a mock or real LLM provider. Bridge-only tests can exercise the store and component logic by
directly manipulating store state via
window.__agentStoreif exposed, or by using Playwright’spage.evaluate()to dispatch synthetic store actions.
Scenarios
Seed: seed.spec.ts
1. Empty conversation state — initial panel view
When the agent panel is opened for the first time with no prior messages, the empty state is shown.
Steps:
- Initialize a workspace and start the agent.
- Open the agent conversation panel.
- Observe the content area of the panel.
Expected: The message thread shows an empty state with a speech-bubble icon and the text “Start a conversation with
your agent”. No messages are listed. The data-testid="message-input" textarea is visible and not disabled. The
data-testid="send-button" is rendered (not the stop button).
2. Agent stopped state — start prompt shown instead of message thread
When the agent has not been started (lifecycle: "Stopped"), the panel body replaces the message thread with a “Start
Agent” prompt.
Steps:
- Initialize a workspace but do not call
start_agent. - Open the agent conversation panel.
- Observe the panel body.
Expected: The panel body shows “The agent is not running.” and a data-testid="start-agent-button" button. The
MessageThread and MessageInput components are not rendered. The status label reads “Stopped”.
3. Start agent from panel button — status transitions to Running
Clicking “Start Agent” in the stopped state triggers start_agent and updates the status.
Steps:
- Initialize a workspace but do not call
start_agent. - Open the agent conversation panel.
- Click
data-testid="start-agent-button". - Observe the panel body.
Expected: The “Start Agent” button is replaced by the MessageThread and MessageInput components. The status dot
transitions to green. The status label reads “Ready”. The empty conversation state (“Start a conversation with your
agent”) is shown since no messages have been exchanged yet.
4. User message rendered correctly
Sending a message adds a right-aligned user bubble immediately before the backend responds.
Steps:
- Initialize a workspace and start the agent.
- Open the agent conversation panel.
- Type “Tell me about my pages” into
data-testid="message-input". - Click
data-testid="send-button"(or press Enter). - Observe the message thread.
Expected: A data-testid="user-message" bubble appears right-aligned in the thread with the text “Tell me about my
pages”. The message is displayed before any agent response arrives. The textarea is cleared after submission. The
data-testid="send-button" becomes disabled (replaced by data-testid="stop-button") while the agent is streaming.
5. Enter key sends message — Shift+Enter inserts newline
Keyboard behaviour: Enter submits, Shift+Enter inserts a line break.
Steps:
- Initialize a workspace and start the agent.
- Open the agent conversation panel.
- Click
data-testid="message-input"to focus it. - Type “Line one”, then press Shift+Enter.
- Type “Line two”.
- Press Enter (without Shift).
- Observe the user message that appears in the thread.
Expected: The message “Line one\nLine two” is submitted as a single user message. The textarea auto-clears. A
data-testid="user-message" bubble appears in the thread containing both lines. Pressing Shift+Enter did not submit the
message early.
6. Empty message cannot be submitted
The send button is disabled when the textarea is empty or contains only whitespace.
Steps:
- Initialize a workspace and start the agent.
- Open the agent conversation panel.
- Observe the send button with an empty textarea.
- Type spaces into the textarea.
- Observe the send button.
Expected: The data-testid="send-button" has the disabled attribute when the trimmed text is empty. Pressing
Enter on an empty textarea does not add a user message to the thread and does not call send_agent_message. Typing
whitespace only also keeps the button disabled.
7. Streaming response — progressive chunk accumulation
As the agent emits agent:message-chunk events, the assistant message grows progressively without replacing the entire
thread.
Steps:
- Initialize a workspace with a mock streaming LLM provider.
- Open the agent conversation panel.
- Send a message via
data-testid="message-input". - While the response is streaming, observe the assistant message bubble.
Expected: A data-testid="assistant-message" bubble appears with an empty or partial content string. Each incoming
chunk is appended to the existing bubble (not rendered as a new message). The blinking cursor (animate-pulse span) is
visible inside the bubble while isStreaming: true. The textarea shows the placeholder “Agent is thinking…” and is
disabled. The data-testid="stop-button" is rendered in place of the send button.
Note: Requires mock streaming provider for full streaming validation.
8. Streaming cursor disappears after session completes
When the agent:session-completed event fires, the streaming cursor is removed and the message is finalized.
Steps:
- Initialize a workspace with a mock streaming provider.
- Send a message and let the response complete naturally.
- Observe the assistant message bubble after the stream ends.
Expected: After agent:session-completed, the blinking cursor disappears from the assistant message. The
isStreaming flag on the last message is set to false. The data-testid="send-button" reappears (stop button is
gone). The textarea is re-enabled with the default placeholder “Message your agent…”. The status label returns to
“Ready”.
9. Tool call badge — started state shown during tool execution
When the agent invokes a tool, a ToolCallBadge appears below the message thread entries.
Steps:
- Initialize a workspace with a mock LLM provider configured to invoke a tool.
- Open the agent conversation panel and send a message.
- Observe the thread when the
agent:tool-callevent withstatus: "started"fires.
Expected: A tool call badge appears showing the tool name (in monospace) and the label “running…” in the tertiary
text color. The badge is positioned below the message entries (not inside a message bubble). The data-testid for the
badge is not individually specified — verify by presence of the badge element containing the tool name text.
10. Tool call badge — completed state clears after 2 seconds
After agent:tool-call with status: "completed" fires, the badge label changes to “done” and the badge is removed
after a 2-second timeout.
Steps:
- Initialize a workspace with a mock LLM provider that completes a tool call.
- Open the agent conversation panel and send a message that triggers a tool.
- Observe the badge immediately after
status: "completed"fires. - Wait 2 seconds.
- Observe the thread again.
Expected: Immediately after the completed event, the badge label changes to “done” in green (text-status-success).
After ~2 seconds, the badge disappears from the thread. The agentStore.currentToolCall is set to null via the
setTimeout cleanup in useAgentEventListeners.
11. Tool call badge — failed state shown in error color
When agent:tool-call fires with status: "failed", the badge shows “failed” in the danger color.
Steps:
- Initialize a workspace with a mock LLM provider that returns a tool failure.
- Open the agent conversation panel and send a message.
- Observe the badge when the failed event fires.
Expected: The badge label reads “failed” in the text-status-danger color. After ~2 seconds, the badge clears (same
timeout as completed). The tool failure does not crash the panel or leave the badge stuck indefinitely.
12. Session interrupted — system message injected
When agent:session-interrupted fires (user pressed stop), a system message is added to the thread.
Steps:
- Initialize a workspace with a mock streaming provider.
- Open the agent conversation panel and send a message.
- While the response is streaming, click
data-testid="stop-button". - Observe the message thread.
Expected: A data-testid="system-message" is added to the thread with the text “Session interrupted.” The blinking
cursor is removed from the last assistant message (it is finalized with whatever content accumulated). The stop button
is replaced by the send button. The textarea is re-enabled.
13. Agent error — system message injected in thread
When agent:error fires (e.g., provider not configured), a system error message is injected.
Steps:
- Initialize a workspace with no LLM provider configured (stub path).
- Open the agent conversation panel.
- Call
start_agent. - Send a message via
data-testid="message-input". - Observe the message thread after the backend error event fires.
Expected: A data-testid="system-message" appears in the thread prefixed with “Error: ” followed by the error
message (e.g., “Error: LLM provider not configured”). The streaming state is finalized (no blinking cursor). If
notifications.toast is enabled in agent settings, a toast also appears. The send button is re-enabled for a follow-up
message.
Note: The agent:error event path is reachable via the stub provider.
14. Permission request dialog — shown inline in thread
When agent:permission-request fires, a PermissionRequestDialog appears inside the thread.
Steps:
- Initialize a workspace with a mock LLM provider configured to emit a permission request.
- Open the agent conversation panel and send a message.
- Observe the message thread when the permission event fires.
Expected: A data-testid="permission-request-dialog" appears inside the thread (rendered as an alertdialog with
the role attribute). The dialog shows the tool name in monospace, the capability name in bold, and (if provided) the
explanation text. Three buttons are visible: data-testid="permission-allow-once",
data-testid="permission-always-allow", and data-testid="permission-deny".
15. Permission request — Allow Once responds and dismisses dialog
Clicking “Allow Once” calls respond_permission_request and clears the dialog.
Steps:
- (As per scenario 14) Trigger a permission request dialog in the thread.
- Click
data-testid="permission-allow-once". - Observe the thread.
Expected: The PermissionRequestDialog disappears from the thread after the click. respond_permission_request is
called with response: "AllowOnce". The buttons are briefly disabled while the command is in-flight
(isPending: true). After the command returns, setPendingPermission(null) clears the store entry. No toast is shown
for this action specifically.
16. Permission request — Deny responds and dismisses dialog
Clicking “Deny” calls respond_permission_request with Deny and clears the dialog.
Steps:
- (As per scenario 14) Trigger a permission request dialog in the thread.
- Click
data-testid="permission-deny". - Observe the thread.
Expected: The PermissionRequestDialog disappears. respond_permission_request is called with response: "Deny".
The conversation continues without the denied tool.
17. Status label shows “Thinking…” during streaming
The status label in the panel header reflects the streaming state.
Steps:
- Initialize a workspace with a mock streaming provider.
- Open the agent conversation panel.
- Start the agent.
- Send a message.
- Observe the
data-testid="status-label"element during streaming.
Expected: While isStreaming is true, the status label reads “Thinking…”. The status dot animates (CSS
animate-pulse). After the session completes, the label returns to “Ready” and the dot stops pulsing. These transitions
reflect the StatusLabel and StatusDot components reacting to the Zustand store.
Note: The stopped state (“Stopped”) is directly testable; streaming state requires a mock streaming provider.
18. Status label shows tool name during tool call
When a tool call is in progress, the status label reflects the active tool.
Steps:
- Initialize a workspace with a mock provider that invokes a tool named
search_pages. - Open the agent conversation panel and send a message.
- Observe
data-testid="status-label"when the tool call starts.
Expected: While currentToolCall.status === "started" and currentToolCall.name === "search_pages", the status
label reads “Using search_pages…”. This is rendered by the StatusLabel component’s tool-call branch. The label
returns to “Thinking…” or “Ready” as the tool completes.
19. Status label shows error message — truncated at 40 characters
When the harness enters the Error(...) lifecycle state, the status label shows the truncated error.
Steps:
- Initialize a workspace.
- Simulate the agent status transitioning to
Error("Something went wrong with the LLM provider endpoint")by dispatching theagent:status-changedevent with that payload (or by using a mock provider that sets this state). - Observe
data-testid="status-label".
Expected: The status label shows “Something went wrong with the LLM provider” (truncated at 40 characters with ”…”
appended). The full message is available in the title attribute of the status dot element. The status dot is red
(bg-status-danger).
Note: Testable via page.evaluate() to set store state directly, or via the bridge agent status route.
20. Panel closed — Escape key dismisses it
Pressing Escape while the conversation panel is open closes it.
Steps:
- Initialize a workspace and open the agent conversation panel.
- Press the Escape key.
- Observe whether the panel is visible.
Expected: The data-testid="conversation-panel" element is no longer rendered in the DOM
(if (!isOpen) return null in ConversationPanel). The panel state in dialogStore.isAgentPanelOpen is set to
false. The backdrop is also removed. No other dialogs are closed unintentionally.
21. Panel closed — clicking backdrop dismisses it
Clicking the semi-transparent backdrop outside the panel closes it.
Steps:
- Initialize a workspace and open the agent conversation panel.
- Click the backdrop element (the
div.absolute.inset-0.bg-black/50behind the panel). - Observe whether the panel is visible.
Expected: The panel closes (same result as pressing Escape). The closeAgentPanel function is called via the
backdrop’s onClick handler. The data-testid="conversation-panel" element is removed from the DOM.
22. Panel closed — close button dismisses it
Clicking the X button in the panel header closes it.
Steps:
- Initialize a workspace and open the agent conversation panel.
- Click
data-testid="close-panel-button". - Observe whether the panel is visible.
Expected: The panel closes. data-testid="conversation-panel" is no longer in the DOM.
23. Clear conversation button — clears message thread
Clicking the trash-icon button in the panel header clears all messages.
Steps:
- Initialize a workspace and start the agent.
- Open the agent conversation panel.
- Via
page.evaluate(), inject several messages into theagentStore(user and assistant messages). - Click
data-testid="clear-conversation-button". - Observe the message thread.
Expected: All messages are removed from the thread. The empty state (“Start a conversation with your agent”)
reappears. clearMessages() was called on the store. The clear_conversation backend command returns Ok(()). A new
op_id is assigned to the session for subsequent log correlation.
24. Auto-scroll — new message brings thread to bottom
When a new message is added (user, assistant, or system), the thread scrolls to the bottom.
Steps:
- Initialize a workspace and start the agent.
- Open the agent conversation panel.
- Via
page.evaluate(), inject 20+ messages into theagentStoreto create a scrollable thread. - Observe whether the bottom of the thread is visible (the
div ref={bottomRef}sentinel). - Inject one more message.
- Observe the scroll position.
Expected: After each message injection, the thread scrolls so the bottom sentinel is in view. This is driven by the
useEffect on messages.length in MessageThread that calls
bottomRef.current.scrollIntoView({ behavior: "smooth" }). The scroll happens smoothly, not instantly.
25. Conversation persists through panel close and reopen
Messages in the agentStore are not cleared when the panel is closed and reopened.
Steps:
- Initialize a workspace and start the agent.
- Open the agent conversation panel.
- Via
page.evaluate(), inject user and assistant messages into the store. - Close the panel (Escape or close button).
- Reopen the panel.
- Observe the message thread.
Expected: The messages from before closing are still visible in the thread when the panel reopens. The Zustand store
persists in memory for the lifetime of the app session. Opening the panel a second time triggers
get_conversation_history (which currently returns []), but the existing in-memory messages are preserved via the
loadHistory function that merges the backend response — an empty backend response does not clear existing in-memory
messages.
Note: Verify this behavior against the loadHistory action, which replaces messages with the result of
get_conversation_history. If the backend returns [], any in-memory messages will be overwritten. This is a known
limitation of the current implementation. The test should assert the actual behavior, not the ideal behavior.
26. Long message content — wraps within bubble
Assistant messages with long lines of text wrap within the bubble without overflowing.
Steps:
- Initialize a workspace and start the agent.
- Open the agent conversation panel.
- Via
page.evaluate(), inject an assistant message with a very long continuous string (no spaces, 200+ characters) into theagentStore. - Observe the assistant message bubble.
Expected: The long string wraps within the max-w-[85%] bubble boundary. The break-words CSS class ensures no
horizontal overflow occurs. The bubble does not extend outside the panel boundaries. The panel scrollbar is not
triggered by horizontal overflow.
27. Multiple messages in sequence — ordered correctly
Messages are rendered in the order they were added to the store.
Steps:
- Initialize a workspace and start the agent.
- Open the agent conversation panel.
- Via
page.evaluate(), inject a sequence: User(“Hello”), Assistant(“Hi”), User(“How are you?”), Assistant(“I am well.”). - Observe the message thread.
Expected: Messages are rendered top-to-bottom in insertion order: User → Assistant → User → Assistant. User messages
are right-aligned, assistant messages left-aligned. No messages are swapped, duplicated, or dropped. Message id fields
(from crypto.randomUUID()) ensure stable key props in the React list.
Test Data
| Key | Value | Notes |
|---|---|---|
| panel_testid | ”conversation-panel” | Root panel element |
| user_message_testid | ”user-message” | Right-aligned user bubble |
| assistant_message_testid | ”assistant-message” | Left-aligned assistant bubble wrapping StreamingMessage |
| system_message_testid | ”system-message” | Centered system/error notices |
| message_input_testid | ”message-input” | Textarea for user input |
| send_button_testid | ”send-button” | Arrow icon button; disabled when text.trim() is empty |
| stop_button_testid | ”stop-button” | Square icon button; shown only when isStreaming: true |
| close_panel_testid | ”close-panel-button” | X button in panel header |
| clear_conversation_testid | ”clear-conversation-button” | Trash icon button in panel header |
| start_agent_testid | ”start-agent-button” | Shown only when lifecycle: "Stopped" |
| permission_dialog_testid | ”permission-request-dialog” | role="alertdialog" inline card in thread |
| permission_allow_once_testid | ”permission-allow-once” | Calls respond_permission_request with "AllowOnce" |
| permission_always_allow_testid | ”permission-always-allow” | Calls respond_permission_request with "AlwaysAllow" |
| permission_deny_testid | ”permission-deny” | Calls respond_permission_request with "Deny" |
| status_label_testid | ”status-label” | Span in panel header; reads “Stopped”, “Ready”, “Thinking…”, “Using |
| streaming_cursor_class | ”animate-pulse” | CSS class on the blinking cursor span inside StreamingMessage |
| interrupted_system_msg | ”Session interrupted.” | Text content of system message after agent:session-interrupted |
| tool_call_timeout_ms | 2000 | Tool call badge auto-clears after this many milliseconds |
| error_truncation_chars | 40 | Max length of error string in status label before ”…” is appended |
| empty_state_text | ”Start a conversation with your agent” | Text shown in MessageThread when messages.length === 0 |
| stopped_state_text | ”The agent is not running.” | Text shown in panel body when lifecycle === "Stopped" |
Notes
- Event delivery: The
useAgentEventListenershook registers listeners foragent:status-changed,agent:session-started,agent:message-chunk,agent:session-completed,agent:session-interrupted,agent:tool-call,agent:error,agent:permission-request, andagent:settings-changed. Scenarios that assert on streaming, tool calls, or permission dialogs may use the HTTP bridge agent routes or a mock event injection mechanism (page.evaluate(() => window.__TAURI_INTERNALS__.emit(...))). - Store manipulation via
page.evaluate(): For UI rendering tests (scenarios 23–27), inject messages directly into the Zustand store using Playwright’spage.evaluate()to callwindow.__agentStore.getState().addUserMessage(...)or equivalent. This requires the store to be exposed on the window object in the test build — verify thatapps/desktop/src-react/stores/agentStore.tsis accessible via the global scope in the Playwright test environment. loadHistoryoverwrites on panel open: When theConversationPanelmounts (i.e.,isOpentransitions totrue), it callsget_conversation_historyand passes the result toloadHistory. Currentlyget_conversation_historyreturns[], soloadHistoryreplaces the in-memory message list with an empty array. Any messages injected viapage.evaluate()before the panel opens will be lost when the panel opens. Inject messages after opening the panel to work around this.StreamingMessagecursor: The blinking cursor is anaria-hidden="true"span with CSS classanimate-pulse. It is shown only whenisStreamingprop istrue. The cursor is removed whenfinalizeAssistantMessage()is called (triggered byagent:session-completedoragent:session-interrupted). Tests should look for the absence ofanimate-pulseon the last assistant message to confirm finalization.- Tool call badge timeout: The
setTimeout(..., 2000)is tracked intimeoutIdsand cleared on component unmount (INK-521 fix). Tests that close the panel while a tool call badge is visible should not see residual state in a subsequent panel open. - Permission dialog is not a modal: The
PermissionRequestDialogrenders as an inline card inside theMessageThreadscroll container, not as a separate modal overlay. It usesrole="alertdialog"for accessibility but does not trap focus or block interaction with the rest of the thread. isStreamingdisables textarea: Thedisabled={isStreaming}attribute on the textarea prevents input while the agent is generating. Tests can verify thedisabledattribute is set/cleared around streaming transitions using a mock streaming provider or store injection.- Suggestion cards are a separate component: The
SuggestionListcomponent (apps/desktop/src-react/components/agent/SuggestionList.tsx) listens foragent:proactive-suggestionevents and rendersSuggestionCardentries. This is a separate surface from theConversationPaneland is not covered in this spec. Coverage for proactive suggestions belongs in a futureagent-proactive.mdspec.
Was this page helpful?
Thanks for your feedback!