mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 16:19:21 +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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user