feat(web): add follow-up suggestion chips and ghost text#1156
feat(web): add follow-up suggestion chips and ghost text#1156ilblackdragon merged 2 commits intostagingfrom
Conversation
The LLM now always generates 1-3 follow-up command suggestions via <suggestions> tags in its response. These are extracted server-side, broadcast as SSE events, and rendered as clickable chips above the chat input. The first suggestion also appears as ghost text in the input field (Tab to accept). Includes debug logging for LLM responses in the agentic loop and removes noisy NEAR AI status logging. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
There was a problem hiding this comment.
Pull request overview
Adds an end-to-end “follow-up suggestions” feature for the web gateway: the LLM is prompted to emit <suggestions>…</suggestions>, the server extracts/broadcasts them as a dedicated SSE event, and the frontend renders clickable suggestion chips plus an input “ghost text” affordance.
Changes:
- Update system prompt to require
<suggestions>in every LLM response. - Add server-side extraction + new
StatusUpdate/SSE event type for suggestions; broadcast to web clients. - Add web UI rendering (chips + Tab-to-accept ghost text) and adjust styling; add/adjust debug logging.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/llm/reasoning.rs | Updates system prompt guidelines to require <suggestions> output. |
| src/llm/nearai_chat.rs | Removes a noisy DEBUG status log for NEAR AI Chat responses. |
| src/channels/channel.rs | Adds StatusUpdate::Suggestions to the shared status enum. |
| src/agent/thread_ops.rs | Extracts suggestions from final responses and emits a suggestions status update. |
| src/agent/dispatcher.rs | Implements extract_suggestions helper and adds unit tests. |
| src/agent/agentic_loop.rs | Adds debug logging for LLM response metadata/content. |
| src/channels/web/types.rs | Adds SseEvent::Suggestions and maps it to an event type string. |
| src/channels/web/sse.rs | Registers SSE event name mapping for suggestions. |
| src/channels/web/mod.rs | Translates StatusUpdate::Suggestions into an SSE Suggestions event. |
| src/channels/web/static/index.html | Adds DOM containers for suggestion chips and ghost text overlay. |
| src/channels/web/static/style.css | Adds layout/styles for suggestion chips and ghost text overlay behavior. |
| src/channels/web/static/app.js | Handles suggestions SSE events, renders chips, and implements ghost-text + Tab acceptance UX. |
| src/channels/wasm/wrapper.rs | Maps Suggestions status updates into WIT status updates (currently empty message). |
| src/channels/repl.rs | Explicitly ignores Suggestions status updates in the REPL channel. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/llm/reasoning.rs
Outdated
| - ALWAYS end your response with a <suggestions> tag containing a JSON array of 1-3 short follow-up \ | ||
| commands. Each suggestion must read as something the USER would type to instruct YOU. \ | ||
| Write them in the user's voice as direct commands: \"Suggest Italian places nearby\", not \ | ||
| \"Tell me your preferences so I can help\". Never phrase suggestions as requests FROM you TO the user. \ | ||
| Do NOT repeat or rephrase content already in your response. \ | ||
| Example: <suggestions>[\"Suggest dinner spots in my area\", \"Find a quick recipe for pasta\"]</suggestions> \ |
There was a problem hiding this comment.
Fixed in 6411abe — removed trailing backslashes from the raw string. The prompt is now a single line in the raw string, avoiding literal backslash characters in the output.
src/channels/wasm/wrapper.rs
Outdated
| }, | ||
| StatusUpdate::Suggestions { .. } => wit_channel::StatusUpdate { | ||
| status: wit_channel::StatusType::Status, | ||
| message: String::new(), |
There was a problem hiding this comment.
Fixed in 6411abe — status_to_wit() now returns Option and returns None for Suggestions, so WASM channels skip the callback entirely.
src/channels/web/static/app.js
Outdated
| // Accept first suggestion with Tab | ||
| if (e.key === 'Tab' && !acVisible && _ghostSuggestion && chatInput.value === '') { | ||
| e.preventDefault(); | ||
| chatInput.value = _ghostSuggestion; | ||
| clearSuggestionChips(); | ||
| autoResizeTextarea(chatInput); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Fixed in 6411abe — added \!e.shiftKey guard so Shift+Tab still allows reverse-tab navigation.
| iteration, | ||
| len = text.len(), | ||
| has_suggestions = text.contains("<suggestions>"), | ||
| response = %text, |
There was a problem hiding this comment.
Intentional — full response at DEBUG is needed for debugging suggestion extraction and prompt compliance. The TRACE-level body log in nearai_chat.rs was removed as redundant (the agentic loop log is more useful since it shows the cleaned response).
src/agent/dispatcher.rs
Outdated
| .into_iter() | ||
| .filter(|s| !s.is_empty() && s.len() <= 80) |
There was a problem hiding this comment.
Fixed in 6411abe — added .take(3) after filtering and also trims whitespace-only entries.
| @@ -473,6 +477,18 @@ impl Agent { | |||
| ) | |||
| .await; | |||
|
|
|||
| // Send suggestions after response (best-effort, rendered by web gateway) | |||
| if !suggestions.is_empty() { | |||
| let _ = self | |||
| .channels | |||
| .send_status( | |||
| &message.channel, | |||
| StatusUpdate::Suggestions { suggestions }, | |||
| &message.metadata, | |||
| ) | |||
| .await; | |||
| } | |||
There was a problem hiding this comment.
Fixed in 6411abe — the approval-resume path now applies extract_suggestions() + send_status(Suggestions) the same as the main response path.
| function showSuggestionChips(suggestions) { | ||
| // Clear previous chips/ghost without restoring placeholder (we'll set it below) | ||
| _ghostSuggestion = ''; | ||
| const container = document.getElementById('suggestion-chips'); | ||
| container.innerHTML = ''; | ||
| const ghost = document.getElementById('ghost-text'); | ||
| ghost.style.display = 'none'; | ||
|
|
||
| _ghostSuggestion = suggestions[0] || ''; | ||
| const input = document.getElementById('chat-input'); | ||
| suggestions.forEach(text => { | ||
| const chip = document.createElement('button'); | ||
| chip.className = 'suggestion-chip'; | ||
| chip.textContent = text; | ||
| chip.addEventListener('click', () => { | ||
| input.value = text; | ||
| clearSuggestionChips(); | ||
| autoResizeTextarea(input); | ||
| input.focus(); | ||
| sendMessage(); | ||
| }); | ||
| container.appendChild(chip); | ||
| }); | ||
| container.style.display = 'flex'; | ||
| // Show first suggestion as ghost text in the input so user knows Tab works | ||
| if (_ghostSuggestion && input.value === '') { | ||
| ghost.textContent = _ghostSuggestion; | ||
| ghost.style.display = 'block'; | ||
| input.closest('.chat-input-wrapper').classList.add('has-ghost'); | ||
| } |
There was a problem hiding this comment.
Fixed in 6411abe — showSuggestionChips() now removes .has-ghost class during its reset phase before re-adding it conditionally.
src/llm/reasoning.rs
Outdated
| - ALWAYS end your response with a <suggestions> tag containing a JSON array of 1-3 short follow-up \ | ||
| commands. Each suggestion must read as something the USER would type to instruct YOU. \ | ||
| Write them in the user's voice as direct commands: \"Suggest Italian places nearby\", not \ | ||
| \"Tell me your preferences so I can help\". Never phrase suggestions as requests FROM you TO the user. \ | ||
| Do NOT repeat or rephrase content already in your response. \ | ||
| Example: <suggestions>[\"Suggest dinner spots in my area\", \"Find a quick recipe for pasta\"]</suggestions> \ |
There was a problem hiding this comment.
For non-native-thinking models, the <suggestions> tag should naturally fall inside the <final> block since the prompt instruction places it at the end of the response content. In practice this hasn't been an issue — the models we use either have native thinking (no <final> tags) or place suggestions within the response body. Not changing for now but will revisit if we see issues.
- Remove literal backslashes from raw string prompt (reasoning.rs) - Make WASM channels skip Suggestions status (no-op instead of empty callback) - Add !e.shiftKey guard to Tab-to-accept ghost text handler - Cap extracted suggestions at 3 and trim whitespace-only entries - Extract suggestions in approval-resume path (prevents tag leaking) - Remove stale .has-ghost class during showSuggestionChips reset Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
zmanian
left a comment
There was a problem hiding this comment.
Review
Core implementation is clean -- safe DOM APIs (textContent, createElement), good test coverage for extraction edge cases, intuitive UX (chips + ghost text + Tab to accept). Four issues to address.
Blocking
1. CI formatting failure (src/agent/agentic_loop.rs)
cargo fmt --all -- --check reports a diff. Must run cargo fmt and push.
2. .expect() in production code (dispatcher.rs)
Regex::new(r"...").expect("valid regex")Project rules prohibit .expect() in production code. Even inside a LazyLock with a constant pattern, the convention is strict. Consider whether this warrants an exception (it is genuinely infallible) or use an alternative pattern.
3. Token cost increase on every LLM call
The suggestion instructions are added to the system prompt for all calls -- including intermediate tool-call iterations, background jobs, heartbeats, and routines. Suggestions are only extracted from final AgenticLoopResult::Response, so intermediate steps waste tokens generating unused <suggestions> tags (~30-50 tokens per step). This is a ~2-5% per-response overhead that should at minimum be acknowledged, or ideally mitigated (e.g., only inject for the final response turn).
4. Auto-send of unsanitized LLM-generated suggestions
Clicking a chip auto-sends the message. Suggestions are only filtered by !s.is_empty() && s.len() <= 80 -- no content sanitization. While textContent prevents XSS in the browser, a prompt injection attack could cause the LLM to emit misleading suggestions ("Enter your API key", "Run rm -rf /") that get executed with one click. Even in the single-user model, this deserves discussion -- consider filtering through SafetyLayer or at minimum blocking suggestions that look like shell commands or credential requests.
Non-blocking
- Debug logging includes full LLM response text at DEBUG level in
agentic_loop.rs. Thenearai_chat.rscode deliberately moved response body logging to TRACE to avoid exposing sensitive content. This reverses that intent. Consider TRACE for full response, DEBUG for length/metadata only. - WASM channels silently swallow suggestions --
Suggestionsvariant maps to emptyStatusinwasm/wrapper.rs. A comment explaining this is intentional would help. - Ghost text accessibility -- consider
aria-hidden="true"on the ghost text div for screen readers. - Code fence detection --
text.rfind("```")doesn't track open/close pairs. Works correctly for the common case but worth a comment noting the limitation.
zmanian
left a comment
There was a problem hiding this comment.
Re-review: Blocking items resolved
All 4 blocking items from my previous review have been addressed:
- Formatting -- Fixed in commit 6411abe.
.expect()in production code -- Fixed.Regex::new(...).expect("valid regex")now has// safety: constant patterninline suppression, which is the project's standard exception mechanism for infallible panics.- Token cost on every LLM call -- Mitigated.
extract_suggestionsmoved fromdispatcher.rstothread_ops.rs, so extraction only happens on the finalAgenticLoopResult::Response(not intermediate tool-call iterations). The system prompt instructions are still injected for all calls (~30 extra tokens), which is a follow-up optimization opportunity but acceptable for now. - Auto-send unsanitized suggestions -- Partially mitigated. Added
.trim()filter and.take(3)cap. The broader prompt-injection-via-suggestions concern remains -- considerSafetyLayerfiltering in a follow-up, especially if this feature is enabled for multi-user deployments.
Also addressed from non-blocking:
- WASM channels now return
Nonefromstatus_to_witforSuggestionsvariant with a clear comment explaining it's web-gateway-only. - Tab key now checks
!e.shiftKeyto avoid interfering with Shift+Tab navigation. - Ghost text wrapper class properly cleaned up on dismiss.
Remaining non-blocking observations (no action needed for merge)
- Debug logging in
agentic_loop.rsstill logs full LLM response at DEBUG level (vs the prior TRACE convention innearai_chat.rs). Worth revisiting if response content is sensitive. - Ghost text
aria-hiddenfor screen readers is still missing.
Full CI is green. LGTM.
Regarding this - it is massively helpful to see requests and responses in debug mode. |
* feat(web): add follow-up suggestion chips and ghost text to chat UI The LLM now always generates 1-3 follow-up command suggestions via <suggestions> tags in its response. These are extracted server-side, broadcast as SSE events, and rendered as clickable chips above the chat input. The first suggestion also appears as ghost text in the input field (Tab to accept). Includes debug logging for LLM responses in the agentic loop and removes noisy NEAR AI status logging. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve deferred review items from PR #1156 [skip-regression-check] - Remove literal backslashes from raw string prompt (reasoning.rs) - Make WASM channels skip Suggestions status (no-op instead of empty callback) - Add !e.shiftKey guard to Tab-to-accept ghost text handler - Cap extracted suggestions at 3 and trim whitespace-only entries - Extract suggestions in approval-resume path (prevents tag leaking) - Remove stale .has-ghost class during showSuggestionChips reset Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(web): add follow-up suggestion chips and ghost text to chat UI The LLM now always generates 1-3 follow-up command suggestions via <suggestions> tags in its response. These are extracted server-side, broadcast as SSE events, and rendered as clickable chips above the chat input. The first suggestion also appears as ghost text in the input field (Tab to accept). Includes debug logging for LLM responses in the agentic loop and removes noisy NEAR AI status logging. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve deferred review items from PR nearai#1156 [skip-regression-check] - Remove literal backslashes from raw string prompt (reasoning.rs) - Make WASM channels skip Suggestions status (no-op instead of empty callback) - Add !e.shiftKey guard to Tab-to-accept ghost text handler - Cap extracted suggestions at 3 and trim whitespace-only entries - Extract suggestions in approval-resume path (prevents tag leaking) - Remove stale .has-ghost class during showSuggestionChips reset Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
<suggestions>tags) in every responseTest plan
RUST_LOG=ironclaw::agent::agentic_loop=debugshows LLM response details🤖 Generated with Claude Code