Skip to content

feat(web): add follow-up suggestion chips and ghost text#1156

Merged
ilblackdragon merged 2 commits intostagingfrom
feat/suggestion-chips
Mar 14, 2026
Merged

feat(web): add follow-up suggestion chips and ghost text#1156
ilblackdragon merged 2 commits intostagingfrom
feat/suggestion-chips

Conversation

@ilblackdragon
Copy link
Copy Markdown
Member

Summary

  • LLM system prompt now requires 1-3 follow-up command suggestions (as <suggestions> tags) in every response
  • Server-side extraction strips the tags, broadcasts suggestions as SSE events to the web gateway
  • Frontend renders suggestions as clickable chip buttons above the chat input
  • First suggestion also shows as ghost text in the input field (Tab to accept, hidden behind CSS when placeholder would conflict)
  • Added debug logging for LLM responses in the agentic loop (text length, has_suggestions flag, full response)
  • Removed noisy "NEAR AI Chat response status: 200 OK" debug log

Test plan

  • Send a message via web UI and verify suggestion chips appear after the response
  • Click a chip — it should fill the input and send immediately
  • Press Tab with empty input — first suggestion should be accepted as input text
  • Verify ghost text is visible (placeholder hidden) when suggestions are active
  • Type in the input — ghost text hides, chips remain; clear input — ghost text reappears
  • Switch threads — chips and ghost text clear
  • Verify RUST_LOG=ironclaw::agent::agentic_loop=debug shows LLM response details

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings March 13, 2026 23:26
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@github-actions github-actions bot added scope: agent Agent core (agent loop, router, scheduler) scope: channel Channel infrastructure scope: channel/web Web gateway channel scope: channel/wasm WASM channel runtime scope: llm LLM integration size: L 200-499 changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Mar 13, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +906 to +911
- 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> \
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

},
StatusUpdate::Suggestions { .. } => wit_channel::StatusUpdate {
status: wit_channel::StatusType::Status,
message: String::new(),
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6411abestatus_to_wit() now returns Option and returns None for Suggestions, so WASM channels skip the callback entirely.

Comment on lines +1724 to +1731
// Accept first suggestion with Tab
if (e.key === 'Tab' && !acVisible && _ghostSuggestion && chatInput.value === '') {
e.preventDefault();
chatInput.value = _ghostSuggestion;
clearSuggestionChips();
autoResizeTextarea(chatInput);
return;
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +1094 to +1095
.into_iter()
.filter(|s| !s.is_empty() && s.len() <= 80)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6411abe — added .take(3) after filtering and also trims whitespace-only entries.

Comment on lines 423 to +490
@@ -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;
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6411abe — the approval-resume path now applies extract_suggestions() + send_status(Suggestions) the same as the main response path.

Comment on lines +438 to +467
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');
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6411abeshowSuggestionChips() now removes .has-ghost class during its reset phase before re-adding it conditionally.

Comment on lines +906 to +911
- 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> \
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Copy link
Copy Markdown
Collaborator

@zmanian zmanian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. The nearai_chat.rs code 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 -- Suggestions variant maps to empty Status in wasm/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.

Copy link
Copy Markdown
Collaborator

@zmanian zmanian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: Blocking items resolved

All 4 blocking items from my previous review have been addressed:

  1. Formatting -- Fixed in commit 6411abe.
  2. .expect() in production code -- Fixed. Regex::new(...).expect("valid regex") now has // safety: constant pattern inline suppression, which is the project's standard exception mechanism for infallible panics.
  3. Token cost on every LLM call -- Mitigated. extract_suggestions moved from dispatcher.rs to thread_ops.rs, so extraction only happens on the final AgenticLoopResult::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.
  4. Auto-send unsanitized suggestions -- Partially mitigated. Added .trim() filter and .take(3) cap. The broader prompt-injection-via-suggestions concern remains -- consider SafetyLayer filtering in a follow-up, especially if this feature is enabled for multi-user deployments.

Also addressed from non-blocking:

  • WASM channels now return None from status_to_wit for Suggestions variant with a clear comment explaining it's web-gateway-only.
  • Tab key now checks !e.shiftKey to 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.rs still logs full LLM response at DEBUG level (vs the prior TRACE convention in nearai_chat.rs). Worth revisiting if response content is sensitive.
  • Ghost text aria-hidden for screen readers is still missing.

Full CI is green. LGTM.

@ilblackdragon
Copy link
Copy Markdown
Member Author

Debug logging in agentic_loop.rs still logs full LLM response at DEBUG level (vs the prior TRACE convention in nearai_chat.rs). Worth revisiting if response content is sensitive.

Regarding this - it is massively helpful to see requests and responses in debug mode.
Currently requests already contain previous responses so filtering doesn't really improve much.

@ilblackdragon ilblackdragon merged commit 757d24b into staging Mar 14, 2026
19 checks passed
@ilblackdragon ilblackdragon deleted the feat/suggestion-chips branch March 14, 2026 18:57
ilblackdragon added a commit that referenced this pull request Mar 14, 2026
* 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>
@ironclaw-ci ironclaw-ci bot mentioned this pull request Mar 17, 2026
bkutasi pushed a commit to bkutasi/ironclaw that referenced this pull request Mar 28, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/wasm WASM channel runtime scope: channel/web Web gateway channel scope: channel Channel infrastructure scope: llm LLM integration size: L 200-499 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants