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

* 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) <[email protected]>

* 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) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-14 18:57:24 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent f9b880c2e9
commit 757d24bd90
14 changed files with 368 additions and 35 deletions
+2
View File
@@ -238,6 +238,8 @@ pub enum StatusUpdate {
/// Optional workspace path where the image was saved.
path: Option<String>,
},
/// Suggested follow-up messages for the user.
Suggestions { suggestions: Vec<String> },
}
impl StatusUpdate {
+3
View File
@@ -607,6 +607,9 @@ impl Channel for ReplChannel {
eprintln!("\x1b[36m [image generated]\x1b[0m");
}
}
StatusUpdate::Suggestions { .. } => {
// Suggestions are only rendered by the web gateway
}
}
Ok(())
}
+52 -24
View File
@@ -1664,7 +1664,9 @@ impl WasmChannel {
.await;
let pairing_store = self.pairing_store.clone();
let wit_update = status_to_wit(status, metadata);
let Some(wit_update) = status_to_wit(status, metadata) else {
return Ok(());
};
let result = tokio::time::timeout(timeout, async move {
tokio::task::spawn_blocking(move || {
@@ -1833,7 +1835,9 @@ impl WasmChannel {
.await;
let pairing_store = self.pairing_store.clone();
let callback_timeout = self.runtime.config().callback_timeout;
let wit_update = status_to_wit(&status, metadata);
let Some(wit_update) = status_to_wit(&status, metadata) else {
return Ok(());
};
let handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(4));
@@ -2704,10 +2708,13 @@ fn truncate_status_text(input: &str, max_chars: usize) -> String {
}
}
fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_channel::StatusUpdate {
fn status_to_wit(
status: &StatusUpdate,
metadata: &serde_json::Value,
) -> Option<wit_channel::StatusUpdate> {
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
match status {
Some(match status {
StatusUpdate::Thinking(msg) => wit_channel::StatusUpdate {
status: wit_channel::StatusType::Thinking,
message: msg.clone(),
@@ -2827,7 +2834,9 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha
},
metadata_json,
},
}
// Suggestions are web-gateway-only; skip for WASM channels
StatusUpdate::Suggestions { .. } => return None,
})
}
/// Clone a WIT StatusUpdate (the generated type doesn't derive Clone).
@@ -3556,7 +3565,8 @@ mod tests {
let wit = status_to_wit(
&crate::channels::StatusUpdate::Thinking("Processing...".into()),
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
@@ -3574,7 +3584,8 @@ mod tests {
let wit = status_to_wit(
&crate::channels::StatusUpdate::Status("Done".into()),
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
}
@@ -3589,14 +3600,16 @@ mod tests {
let wit = status_to_wit(
&crate::channels::StatusUpdate::Status("done".into()),
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
// with whitespace
let wit = status_to_wit(
&crate::channels::StatusUpdate::Status(" Done ".into()),
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
}
@@ -3608,7 +3621,8 @@ mod tests {
let wit = status_to_wit(
&crate::channels::StatusUpdate::Status("Interrupted".into()),
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
@@ -3626,7 +3640,8 @@ mod tests {
let wit = status_to_wit(
&crate::channels::StatusUpdate::Status("interrupted".into()),
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
super::wit_channel::StatusType::Interrupted
@@ -3636,7 +3651,8 @@ mod tests {
let wit = status_to_wit(
&crate::channels::StatusUpdate::Status(" Interrupted ".into()),
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
super::wit_channel::StatusType::Interrupted
@@ -3651,7 +3667,8 @@ mod tests {
let wit = status_to_wit(
&crate::channels::StatusUpdate::Status("Awaiting approval".into()),
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(wit.status, super::wit_channel::StatusType::Status));
assert_eq!(wit.message, "Awaiting approval");
@@ -3670,7 +3687,8 @@ mod tests {
setup_url: None,
},
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
@@ -3690,7 +3708,8 @@ mod tests {
name: "http_request".to_string(),
},
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
@@ -3712,7 +3731,8 @@ mod tests {
parameters: None,
},
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
@@ -3734,7 +3754,8 @@ mod tests {
parameters: None,
},
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
@@ -3754,7 +3775,8 @@ mod tests {
preview: "{".to_string() + "\"temperature\": 22}",
},
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
@@ -3775,7 +3797,8 @@ mod tests {
preview: long_preview,
},
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
@@ -3796,7 +3819,8 @@ mod tests {
browse_url: "https://example.com/jobs/job-1".to_string(),
},
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
@@ -3818,7 +3842,8 @@ mod tests {
message: "Token saved".to_string(),
},
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
@@ -3840,7 +3865,8 @@ mod tests {
message: "Invalid token".to_string(),
},
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
@@ -3863,7 +3889,8 @@ mod tests {
parameters: serde_json::json!({"url": "https://api.weather.test"}),
},
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
@@ -3887,7 +3914,8 @@ mod tests {
parameters: serde_json::json!({"url": "https://api.weather.test"}),
},
&metadata,
);
)
.unwrap(); // safety: test
assert!(matches!(
wit.status,
+4
View File
@@ -397,6 +397,10 @@ impl Channel for GatewayChannel {
StatusUpdate::ImageGenerated { data_url, path } => SseEvent::ImageGenerated {
data_url,
path,
thread_id: thread_id.clone(),
},
StatusUpdate::Suggestions { suggestions } => SseEvent::Suggestions {
suggestions,
thread_id,
},
};
+1
View File
@@ -143,6 +143,7 @@ impl SseManager {
SseEvent::JobResult { .. } => "job_result",
SseEvent::Heartbeat => "heartbeat",
SseEvent::ImageGenerated { .. } => "image_generated",
SseEvent::Suggestions { .. } => "suggestions",
SseEvent::ExtensionStatus { .. } => "extension_status",
};
Ok(Event::default().event(event_type).data(data))
+81
View File
@@ -19,6 +19,7 @@ let _loadThreadsTimer = null;
const JOB_EVENTS_CAP = 500;
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
let stagedImages = [];
let _ghostSuggestion = '';
// --- Slash Commands ---
@@ -286,9 +287,18 @@ function connectSSE() {
if (data.thread_id) debouncedLoadThreads();
return;
}
clearSuggestionChips();
showActivityThinking(data.message);
});
eventSource.addEventListener('suggestions', (e) => {
const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return;
if (data.suggestions && data.suggestions.length > 0) {
showSuggestionChips(data.suggestions);
}
});
eventSource.addEventListener('tool_started', (e) => {
const data = JSON.parse(e.data);
if (!isCurrentThread(data.thread_id)) return;
@@ -423,9 +433,59 @@ function isCurrentThread(threadId) {
return threadId === currentThreadId;
}
// --- Suggestion Chips ---
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';
const wrapper = document.querySelector('.chat-input-wrapper');
if (wrapper) wrapper.classList.remove('has-ghost');
_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');
}
}
function clearSuggestionChips() {
_ghostSuggestion = '';
const container = document.getElementById('suggestion-chips');
if (container) {
container.innerHTML = '';
container.style.display = 'none';
}
const ghost = document.getElementById('ghost-text');
if (ghost) ghost.style.display = 'none';
const wrapper = document.querySelector('.chat-input-wrapper');
if (wrapper) wrapper.classList.remove('has-ghost');
}
// --- Chat ---
function sendMessage() {
clearSuggestionChips();
const input = document.getElementById('chat-input');
if (!currentThreadId) {
console.warn('sendMessage: no thread selected, ignoring');
@@ -1334,6 +1394,7 @@ function showAuthCardError(extensionName, message) {
}
function loadHistory(before) {
clearSuggestionChips();
let historyUrl = '/api/chat/history?limit=50';
if (currentThreadId) {
historyUrl += '&thread_id=' + encodeURIComponent(currentThreadId);
@@ -1629,6 +1690,7 @@ function switchToAssistant() {
}
function switchThread(threadId) {
clearSuggestionChips();
finalizeActivityGroup();
currentThreadId = threadId;
unreadThreads.delete(threadId);
@@ -1661,6 +1723,15 @@ chatInput.addEventListener('keydown', (e) => {
const acEl = document.getElementById('slash-autocomplete');
const acVisible = acEl && acEl.style.display !== 'none';
// Accept first suggestion with Tab (plain Tab only, not Shift+Tab)
if (e.key === 'Tab' && !e.shiftKey && !acVisible && _ghostSuggestion && chatInput.value === '') {
e.preventDefault();
chatInput.value = _ghostSuggestion;
clearSuggestionChips();
autoResizeTextarea(chatInput);
return;
}
if (acVisible) {
const items = acEl.querySelectorAll('.slash-ac-item');
if (e.key === 'ArrowDown') {
@@ -1697,6 +1768,16 @@ chatInput.addEventListener('keydown', (e) => {
chatInput.addEventListener('input', () => {
autoResizeTextarea(chatInput);
filterSlashCommands(chatInput.value);
const ghost = document.getElementById('ghost-text');
const wrapper = chatInput.closest('.chat-input-wrapper');
if (chatInput.value !== '') {
ghost.style.display = 'none';
wrapper.classList.remove('has-ghost');
} else if (_ghostSuggestion) {
ghost.textContent = _ghostSuggestion;
ghost.style.display = 'block';
wrapper.classList.add('has-ghost');
}
});
chatInput.addEventListener('blur', () => {
// Small delay so mousedown on autocomplete item fires first
+5 -1
View File
@@ -155,9 +155,13 @@
<div class="chat-container">
<div class="chat-messages" id="chat-messages"></div>
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
<div id="suggestion-chips" class="suggestion-chips" style="display:none"></div>
<div class="chat-input">
<div id="image-preview-strip" class="image-preview-strip"></div>
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
<div class="chat-input-wrapper">
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" placeholder="Message or / for commands..." rows="1"></textarea>
<div id="ghost-text" class="ghost-text"></div>
</div>
<input type="file" id="image-file-input" accept="image/*" multiple style="display:none">
<button id="attach-btn" class="attach-btn" data-i18n="chat.attachImages" data-i18n-attr="title" title="Attach images"
aria-label="Attach images">&#x1F4CE;</button>
+60 -5
View File
@@ -1362,8 +1362,14 @@ body {
min-height: 56px;
}
.chat-input textarea {
.chat-input-wrapper {
position: relative;
flex: 1;
display: flex;
}
.chat-input-wrapper textarea {
width: 100%;
padding: 8px 12px;
background: var(--bg);
border: 1px solid var(--border);
@@ -1376,17 +1382,66 @@ body {
max-height: 120px;
}
.chat-input textarea:focus {
.ghost-text {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 8px 12px;
font-size: 14px;
font-family: inherit;
color: var(--text-secondary);
opacity: 0.5;
pointer-events: none;
white-space: pre-wrap;
overflow: hidden;
display: none;
z-index: 1;
}
/* Hide native placeholder when ghost text is visible */
.chat-input-wrapper.has-ghost textarea::placeholder {
color: transparent;
}
.chat-input-wrapper textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
}
.chat-input textarea:disabled {
.chat-input-wrapper textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.suggestion-chips {
display: none;
flex-wrap: wrap;
gap: 8px;
padding: 8px 16px;
border-top: 1px solid var(--border);
}
.suggestion-chip {
padding: 6px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 16px;
color: var(--text-secondary);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.suggestion-chip:hover {
background: var(--accent);
color: #09090b;
border-color: var(--accent);
}
.chat-input button {
padding: 8px 20px;
background: var(--accent);
@@ -1416,7 +1471,7 @@ body {
}
/* Keyboard accessibility focus rings */
.chat-input textarea:focus-visible,
.chat-input-wrapper textarea:focus-visible,
.chat-input button:focus-visible,
.tab-bar button:focus-visible,
.tree-row:focus-visible {
@@ -3824,7 +3879,7 @@ mark {
min-height: 52px;
}
.chat-input textarea {
.chat-input-wrapper textarea {
min-height: 36px;
max-height: 100px;
}
+9
View File
@@ -242,6 +242,14 @@ pub enum SseEvent {
thread_id: Option<String>,
},
/// Suggested follow-up messages for the user.
#[serde(rename = "suggestions")]
Suggestions {
suggestions: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
/// Extension activation status change (WASM channels).
#[serde(rename = "extension_status")]
ExtensionStatus {
@@ -707,6 +715,7 @@ impl WsServerMessage {
SseEvent::JobStatus { .. } => "job_status",
SseEvent::JobResult { .. } => "job_result",
SseEvent::ImageGenerated { .. } => "image_generated",
SseEvent::Suggestions { .. } => "suggestions",
SseEvent::ExtensionStatus { .. } => "extension_status",
};
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);