mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat(web): slash command autocomplete + /status /list + fix chat input locking (#404)
* feat(web): slash command autocomplete, /status /list /cancel, fix input locking Backend: - Add JobStatus, JobList, JobCancel Submission variants to submission.rs - Parse /status [id], /progress [id], /list, /cancel <id> as control commands - Dispatch to existing handle_check_status/handle_list_jobs/handle_cancel_job handlers via new process_job_status/process_job_list/process_job_cancel methods - Add 4 parser tests (34 total, all passing) Web UI: - Add slash command autocomplete: type / in chat input to see all 18 commands with descriptions; arrow-key navigation, Tab/Enter to select, Escape to close - Remove chat input locking: drop textarea.disabled + sendBtn.disabled so users can always type and send (including /interrupt while agent is processing) - Remove quick-action toolbar buttons (↩↪⏸⊖🗑📋) added in previous session - Remove dead #chat-status bar (min-height 28px black bar always visible when empty) Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * refactor: address PR review comments - Remove Submission::JobList variant; parse /list directly as JobStatus { job_id: None } (simpler, eliminates redundant enum variant, match arm, is_control branch, and wrapper function) - Cache autocomplete matches in _slashMatches to avoid re-filtering SLASH_COMMANDS on every keydown while autocomplete is open Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --------- Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: Pierre LE GUEN <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
Pierre LE GUEN
parent
601d73d16b
commit
9ce09f71b0
Generated
+13
-1
@@ -955,6 +955,18 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.15.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
@@ -1389,7 +1401,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -717,6 +717,13 @@ impl Agent {
|
||||
Submission::Heartbeat => self.process_heartbeat().await,
|
||||
Submission::Summarize => self.process_summarize(session, thread_id).await,
|
||||
Submission::Suggest => self.process_suggest(session, thread_id).await,
|
||||
Submission::JobStatus { job_id } => {
|
||||
self.process_job_status(&message.user_id, job_id.as_deref())
|
||||
.await
|
||||
}
|
||||
Submission::JobCancel { job_id } => {
|
||||
self.process_job_cancel(&message.user_id, &job_id).await
|
||||
}
|
||||
Submission::Quit => return Ok(None),
|
||||
Submission::SwitchThread { thread_id: target } => {
|
||||
self.process_switch_thread(message, target).await
|
||||
|
||||
@@ -220,6 +220,33 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show job status inline — either all jobs (no id) or a specific job.
|
||||
pub(super) async fn process_job_status(
|
||||
&self,
|
||||
user_id: &str,
|
||||
job_id: Option<&str>,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
match self
|
||||
.handle_check_status(user_id, job_id.map(|s| s.to_string()))
|
||||
.await
|
||||
{
|
||||
Ok(text) => Ok(SubmissionResult::response(text)),
|
||||
Err(e) => Ok(SubmissionResult::error(format!("Job status error: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a job by ID.
|
||||
pub(super) async fn process_job_cancel(
|
||||
&self,
|
||||
user_id: &str,
|
||||
job_id: &str,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
match self.handle_cancel_job(user_id, job_id).await {
|
||||
Ok(text) => Ok(SubmissionResult::response(text)),
|
||||
Err(e) => Ok(SubmissionResult::error(format!("Cancel error: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a manual heartbeat check.
|
||||
pub(super) async fn process_heartbeat(&self) -> Result<SubmissionResult, Error> {
|
||||
let Some(workspace) = self.workspace() else {
|
||||
|
||||
@@ -107,6 +107,29 @@ impl SubmissionParser {
|
||||
return Submission::Quit;
|
||||
}
|
||||
|
||||
// Job commands
|
||||
if lower == "/status" || lower == "/progress" {
|
||||
return Submission::JobStatus { job_id: None };
|
||||
}
|
||||
if let Some(rest) = lower
|
||||
.strip_prefix("/status ")
|
||||
.or_else(|| lower.strip_prefix("/progress "))
|
||||
{
|
||||
let id = rest.trim().to_string();
|
||||
if !id.is_empty() {
|
||||
return Submission::JobStatus { job_id: Some(id) };
|
||||
}
|
||||
}
|
||||
if lower == "/list" {
|
||||
return Submission::JobStatus { job_id: None };
|
||||
}
|
||||
if let Some(rest) = lower.strip_prefix("/cancel ") {
|
||||
let id = rest.trim().to_string();
|
||||
if !id.is_empty() {
|
||||
return Submission::JobCancel { job_id: id };
|
||||
}
|
||||
}
|
||||
|
||||
// /thread <uuid> - switch thread
|
||||
if let Some(rest) = lower.strip_prefix("/thread ") {
|
||||
let rest = rest.trim();
|
||||
@@ -229,6 +252,18 @@ pub enum Submission {
|
||||
/// Suggest next steps based on the current thread.
|
||||
Suggest,
|
||||
|
||||
/// Check job status. No job_id shows all jobs; with job_id shows a specific job.
|
||||
JobStatus {
|
||||
/// Optional job ID (UUID or short prefix). If None, shows all jobs.
|
||||
job_id: Option<String>,
|
||||
},
|
||||
|
||||
/// Cancel a running job.
|
||||
JobCancel {
|
||||
/// Job ID (UUID or short prefix).
|
||||
job_id: String,
|
||||
},
|
||||
|
||||
/// Quit the agent. Bypasses thread-state checks.
|
||||
Quit,
|
||||
|
||||
@@ -313,6 +348,8 @@ impl Submission {
|
||||
| Self::Heartbeat
|
||||
| Self::Summarize
|
||||
| Self::Suggest
|
||||
| Self::JobStatus { .. }
|
||||
| Self::JobCancel { .. }
|
||||
| Self::SystemCommand { .. }
|
||||
)
|
||||
}
|
||||
@@ -740,6 +777,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_job_status() {
|
||||
// /status with no id → all jobs
|
||||
let s = SubmissionParser::parse("/status");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: None }));
|
||||
|
||||
// /progress alias
|
||||
let s = SubmissionParser::parse("/progress");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: None }));
|
||||
|
||||
// /status with id
|
||||
let s = SubmissionParser::parse("/status abc123");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: Some(id) } if id == "abc123"));
|
||||
|
||||
// /progress with id
|
||||
let s = SubmissionParser::parse("/progress abc123");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: Some(id) } if id == "abc123"));
|
||||
|
||||
// case insensitive
|
||||
let s = SubmissionParser::parse("/STATUS");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: None }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_job_list() {
|
||||
// /list is an alias for /status with no job_id
|
||||
let s = SubmissionParser::parse("/list");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: None }));
|
||||
|
||||
let s = SubmissionParser::parse("/LIST");
|
||||
assert!(matches!(s, Submission::JobStatus { job_id: None }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_job_cancel() {
|
||||
let s = SubmissionParser::parse("/cancel abc123");
|
||||
assert!(matches!(s, Submission::JobCancel { job_id } if job_id == "abc123"));
|
||||
|
||||
// /cancel with no id → falls through to UserInput
|
||||
let s = SubmissionParser::parse("/cancel");
|
||||
assert!(matches!(s, Submission::UserInput { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_job_commands_are_control() {
|
||||
assert!(SubmissionParser::parse("/status").is_control());
|
||||
assert!(SubmissionParser::parse("/list").is_control());
|
||||
assert!(SubmissionParser::parse("/cancel abc").is_control());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser_quit() {
|
||||
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
|
||||
|
||||
+125
-27
@@ -16,6 +16,31 @@ let pairingPollInterval = null;
|
||||
const JOB_EVENTS_CAP = 500;
|
||||
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
|
||||
|
||||
// --- Slash Commands ---
|
||||
|
||||
const SLASH_COMMANDS = [
|
||||
{ cmd: '/status', desc: 'Show all jobs, or /status <id> for one job' },
|
||||
{ cmd: '/list', desc: 'List all jobs' },
|
||||
{ cmd: '/cancel', desc: '/cancel <job-id> — cancel a running job' },
|
||||
{ cmd: '/undo', desc: 'Revert the last turn' },
|
||||
{ cmd: '/redo', desc: 'Re-apply an undone turn' },
|
||||
{ cmd: '/compact', desc: 'Compress the context window' },
|
||||
{ cmd: '/clear', desc: 'Clear thread and start fresh' },
|
||||
{ cmd: '/interrupt', desc: 'Stop the current turn' },
|
||||
{ cmd: '/heartbeat', desc: 'Trigger manual heartbeat check' },
|
||||
{ cmd: '/summarize', desc: 'Summarize the current thread' },
|
||||
{ cmd: '/suggest', desc: 'Suggest next steps' },
|
||||
{ cmd: '/help', desc: 'Show help' },
|
||||
{ cmd: '/version', desc: 'Show version info' },
|
||||
{ cmd: '/tools', desc: 'List available tools' },
|
||||
{ cmd: '/skills', desc: 'List installed skills' },
|
||||
{ cmd: '/model', desc: 'Show or switch the LLM model' },
|
||||
{ cmd: '/thread new', desc: 'Create a new conversation thread' },
|
||||
];
|
||||
|
||||
let _slashSelected = -1;
|
||||
let _slashMatches = [];
|
||||
|
||||
// --- Tool Activity State ---
|
||||
let _activeGroup = null;
|
||||
let _activeToolCards = {};
|
||||
@@ -263,10 +288,8 @@ function isCurrentThread(threadId) {
|
||||
|
||||
function sendMessage() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
if (!currentThreadId) {
|
||||
console.warn('sendMessage: no thread selected, ignoring');
|
||||
setStatus('Waiting for thread to load...');
|
||||
return;
|
||||
}
|
||||
const content = input.value.trim();
|
||||
@@ -275,27 +298,73 @@ function sendMessage() {
|
||||
addMessage('user', content);
|
||||
input.value = '';
|
||||
autoResizeTextarea(input);
|
||||
sendBtn.disabled = true;
|
||||
input.disabled = true;
|
||||
input.focus();
|
||||
|
||||
apiFetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
body: { content, thread_id: currentThreadId || undefined },
|
||||
}).catch((err) => {
|
||||
addMessage('system', 'Failed to send: ' + err.message);
|
||||
setStatus('');
|
||||
enableChatInput();
|
||||
});
|
||||
}
|
||||
|
||||
function enableChatInput() {
|
||||
// Don't re-enable until a thread is selected (prevents orphan messages)
|
||||
if (!currentThreadId) return;
|
||||
// no-op: input and send button are always enabled
|
||||
}
|
||||
|
||||
// --- Slash Autocomplete ---
|
||||
|
||||
function showSlashAutocomplete(matches) {
|
||||
const el = document.getElementById('slash-autocomplete');
|
||||
if (!el || matches.length === 0) { hideSlashAutocomplete(); return; }
|
||||
_slashMatches = matches;
|
||||
_slashSelected = -1;
|
||||
el.innerHTML = '';
|
||||
matches.forEach((item, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'slash-ac-item';
|
||||
row.dataset.index = i;
|
||||
row.innerHTML = '<span class="slash-ac-cmd">' + escapeHtml(item.cmd) + '</span>'
|
||||
+ '<span class="slash-ac-desc">' + escapeHtml(item.desc) + '</span>';
|
||||
row.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault(); // prevent blur
|
||||
selectSlashItem(item.cmd);
|
||||
});
|
||||
el.appendChild(row);
|
||||
});
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideSlashAutocomplete() {
|
||||
const el = document.getElementById('slash-autocomplete');
|
||||
if (el) el.style.display = 'none';
|
||||
_slashSelected = -1;
|
||||
_slashMatches = [];
|
||||
}
|
||||
|
||||
function selectSlashItem(cmd) {
|
||||
const input = document.getElementById('chat-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
sendBtn.disabled = false;
|
||||
input.disabled = false;
|
||||
input.value = cmd + ' ';
|
||||
input.focus();
|
||||
hideSlashAutocomplete();
|
||||
autoResizeTextarea(input);
|
||||
}
|
||||
|
||||
function updateSlashHighlight() {
|
||||
const items = document.querySelectorAll('#slash-autocomplete .slash-ac-item');
|
||||
items.forEach((el, i) => el.classList.toggle('selected', i === _slashSelected));
|
||||
}
|
||||
|
||||
function filterSlashCommands(value) {
|
||||
if (!value.startsWith('/')) { hideSlashAutocomplete(); return; }
|
||||
// Only show autocomplete when the input is just a slash command prefix (no spaces except /thread new)
|
||||
const lower = value.toLowerCase();
|
||||
const matches = SLASH_COMMANDS.filter((c) => c.cmd.startsWith(lower));
|
||||
if (matches.length === 0 || (matches.length === 1 && matches[0].cmd === lower.trimEnd())) {
|
||||
hideSlashAutocomplete();
|
||||
} else {
|
||||
showSlashAutocomplete(matches);
|
||||
}
|
||||
}
|
||||
|
||||
function sendApprovalAction(requestId, action) {
|
||||
@@ -396,15 +465,6 @@ function appendToLastAssistant(chunk) {
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
const el = document.getElementById('chat-status');
|
||||
if (!text) {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = escapeHtml(text);
|
||||
}
|
||||
|
||||
// --- Inline Tool Activity Cards ---
|
||||
|
||||
function getOrCreateActivityGroup() {
|
||||
@@ -1097,7 +1157,6 @@ function createNewThread() {
|
||||
apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
|
||||
currentThreadId = data.id || null;
|
||||
document.getElementById('chat-messages').innerHTML = '';
|
||||
setStatus('');
|
||||
loadThreads();
|
||||
}).catch((err) => {
|
||||
showToast('Failed to create thread: ' + err.message, 'error');
|
||||
@@ -1114,16 +1173,50 @@ function toggleThreadSidebar() {
|
||||
// Chat input auto-resize and keyboard handling
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
chatInput.addEventListener('keydown', (e) => {
|
||||
const acEl = document.getElementById('slash-autocomplete');
|
||||
const acVisible = acEl && acEl.style.display !== 'none';
|
||||
|
||||
if (acVisible) {
|
||||
const items = acEl.querySelectorAll('.slash-ac-item');
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
_slashSelected = Math.min(_slashSelected + 1, items.length - 1);
|
||||
updateSlashHighlight();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
_slashSelected = Math.max(_slashSelected - 1, -1);
|
||||
updateSlashHighlight();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab' || (e.key === 'Enter' && _slashSelected >= 0)) {
|
||||
e.preventDefault();
|
||||
const pick = _slashSelected >= 0 ? _slashMatches[_slashSelected] : _slashMatches[0];
|
||||
if (pick) selectSlashItem(pick.cmd);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
hideSlashAutocomplete();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
hideSlashAutocomplete();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
chatInput.addEventListener('input', () => autoResizeTextarea(chatInput));
|
||||
|
||||
// Disable send until a thread is selected (loadThreads will enable it)
|
||||
chatInput.disabled = true;
|
||||
document.getElementById('send-btn').disabled = true;
|
||||
chatInput.addEventListener('input', () => {
|
||||
autoResizeTextarea(chatInput);
|
||||
filterSlashCommands(chatInput.value);
|
||||
});
|
||||
chatInput.addEventListener('blur', () => {
|
||||
// Small delay so mousedown on autocomplete item fires first
|
||||
setTimeout(hideSlashAutocomplete, 150);
|
||||
});
|
||||
|
||||
// Infinite scroll: load older messages when scrolled near the top
|
||||
document.getElementById('chat-messages').addEventListener('scroll', function () {
|
||||
@@ -3612,8 +3705,13 @@ document.addEventListener('keydown', (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: close job detail or blur input
|
||||
// Escape: close autocomplete, job detail, or blur input
|
||||
if (e.key === 'Escape') {
|
||||
const acEl = document.getElementById('slash-autocomplete');
|
||||
if (acEl && acEl.style.display !== 'none') {
|
||||
hideSlashAutocomplete();
|
||||
return;
|
||||
}
|
||||
if (currentJobId) {
|
||||
closeJobDetail();
|
||||
} else if (inInput) {
|
||||
|
||||
@@ -78,9 +78,9 @@
|
||||
</div>
|
||||
<div class="chat-container">
|
||||
<div class="chat-messages" id="chat-messages"></div>
|
||||
<div class="chat-status" id="chat-status"></div>
|
||||
<div id="slash-autocomplete" class="slash-autocomplete" style="display:none"></div>
|
||||
<div class="chat-input">
|
||||
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
|
||||
<textarea id="chat-input" placeholder="Message or / for commands..." rows="1"></textarea>
|
||||
<button id="send-btn" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -458,31 +458,6 @@ body {
|
||||
.message th { background: var(--bg-tertiary); }
|
||||
|
||||
/* Status bar */
|
||||
.chat-status {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-status .spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
.scroll-load-spinner {
|
||||
display: flex;
|
||||
@@ -3451,3 +3426,44 @@ mark {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Slash command autocomplete dropdown */
|
||||
.slash-autocomplete {
|
||||
position: relative;
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.slash-ac-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 7px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.slash-ac-item:hover,
|
||||
.slash-ac-item.selected {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.slash-ac-cmd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.slash-ac-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user