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",
|
"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]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -1389,7 +1401,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.116",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -717,6 +717,13 @@ impl Agent {
|
|||||||
Submission::Heartbeat => self.process_heartbeat().await,
|
Submission::Heartbeat => self.process_heartbeat().await,
|
||||||
Submission::Summarize => self.process_summarize(session, thread_id).await,
|
Submission::Summarize => self.process_summarize(session, thread_id).await,
|
||||||
Submission::Suggest => self.process_suggest(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::Quit => return Ok(None),
|
||||||
Submission::SwitchThread { thread_id: target } => {
|
Submission::SwitchThread { thread_id: target } => {
|
||||||
self.process_switch_thread(message, target).await
|
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.
|
/// Trigger a manual heartbeat check.
|
||||||
pub(super) async fn process_heartbeat(&self) -> Result<SubmissionResult, Error> {
|
pub(super) async fn process_heartbeat(&self) -> Result<SubmissionResult, Error> {
|
||||||
let Some(workspace) = self.workspace() else {
|
let Some(workspace) = self.workspace() else {
|
||||||
|
|||||||
@@ -107,6 +107,29 @@ impl SubmissionParser {
|
|||||||
return Submission::Quit;
|
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
|
// /thread <uuid> - switch thread
|
||||||
if let Some(rest) = lower.strip_prefix("/thread ") {
|
if let Some(rest) = lower.strip_prefix("/thread ") {
|
||||||
let rest = rest.trim();
|
let rest = rest.trim();
|
||||||
@@ -229,6 +252,18 @@ pub enum Submission {
|
|||||||
/// Suggest next steps based on the current thread.
|
/// Suggest next steps based on the current thread.
|
||||||
Suggest,
|
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 the agent. Bypasses thread-state checks.
|
||||||
Quit,
|
Quit,
|
||||||
|
|
||||||
@@ -313,6 +348,8 @@ impl Submission {
|
|||||||
| Self::Heartbeat
|
| Self::Heartbeat
|
||||||
| Self::Summarize
|
| Self::Summarize
|
||||||
| Self::Suggest
|
| Self::Suggest
|
||||||
|
| Self::JobStatus { .. }
|
||||||
|
| Self::JobCancel { .. }
|
||||||
| Self::SystemCommand { .. }
|
| 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]
|
#[test]
|
||||||
fn test_parser_quit() {
|
fn test_parser_quit() {
|
||||||
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
|
assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit));
|
||||||
|
|||||||
+125
-27
@@ -16,6 +16,31 @@ let pairingPollInterval = null;
|
|||||||
const JOB_EVENTS_CAP = 500;
|
const JOB_EVENTS_CAP = 500;
|
||||||
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
|
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 ---
|
// --- Tool Activity State ---
|
||||||
let _activeGroup = null;
|
let _activeGroup = null;
|
||||||
let _activeToolCards = {};
|
let _activeToolCards = {};
|
||||||
@@ -263,10 +288,8 @@ function isCurrentThread(threadId) {
|
|||||||
|
|
||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
const input = document.getElementById('chat-input');
|
const input = document.getElementById('chat-input');
|
||||||
const sendBtn = document.getElementById('send-btn');
|
|
||||||
if (!currentThreadId) {
|
if (!currentThreadId) {
|
||||||
console.warn('sendMessage: no thread selected, ignoring');
|
console.warn('sendMessage: no thread selected, ignoring');
|
||||||
setStatus('Waiting for thread to load...');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = input.value.trim();
|
const content = input.value.trim();
|
||||||
@@ -275,27 +298,73 @@ function sendMessage() {
|
|||||||
addMessage('user', content);
|
addMessage('user', content);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
autoResizeTextarea(input);
|
autoResizeTextarea(input);
|
||||||
sendBtn.disabled = true;
|
input.focus();
|
||||||
input.disabled = true;
|
|
||||||
|
|
||||||
apiFetch('/api/chat/send', {
|
apiFetch('/api/chat/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { content, thread_id: currentThreadId || undefined },
|
body: { content, thread_id: currentThreadId || undefined },
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
addMessage('system', 'Failed to send: ' + err.message);
|
addMessage('system', 'Failed to send: ' + err.message);
|
||||||
setStatus('');
|
|
||||||
enableChatInput();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function enableChatInput() {
|
function enableChatInput() {
|
||||||
// Don't re-enable until a thread is selected (prevents orphan messages)
|
// no-op: input and send button are always enabled
|
||||||
if (!currentThreadId) return;
|
}
|
||||||
|
|
||||||
|
// --- 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 input = document.getElementById('chat-input');
|
||||||
const sendBtn = document.getElementById('send-btn');
|
input.value = cmd + ' ';
|
||||||
sendBtn.disabled = false;
|
|
||||||
input.disabled = false;
|
|
||||||
input.focus();
|
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) {
|
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 ---
|
// --- Inline Tool Activity Cards ---
|
||||||
|
|
||||||
function getOrCreateActivityGroup() {
|
function getOrCreateActivityGroup() {
|
||||||
@@ -1097,7 +1157,6 @@ function createNewThread() {
|
|||||||
apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
|
apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
|
||||||
currentThreadId = data.id || null;
|
currentThreadId = data.id || null;
|
||||||
document.getElementById('chat-messages').innerHTML = '';
|
document.getElementById('chat-messages').innerHTML = '';
|
||||||
setStatus('');
|
|
||||||
loadThreads();
|
loadThreads();
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
showToast('Failed to create thread: ' + err.message, 'error');
|
showToast('Failed to create thread: ' + err.message, 'error');
|
||||||
@@ -1114,16 +1173,50 @@ function toggleThreadSidebar() {
|
|||||||
// Chat input auto-resize and keyboard handling
|
// Chat input auto-resize and keyboard handling
|
||||||
const chatInput = document.getElementById('chat-input');
|
const chatInput = document.getElementById('chat-input');
|
||||||
chatInput.addEventListener('keydown', (e) => {
|
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) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
hideSlashAutocomplete();
|
||||||
sendMessage();
|
sendMessage();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
chatInput.addEventListener('input', () => autoResizeTextarea(chatInput));
|
chatInput.addEventListener('input', () => {
|
||||||
|
autoResizeTextarea(chatInput);
|
||||||
// Disable send until a thread is selected (loadThreads will enable it)
|
filterSlashCommands(chatInput.value);
|
||||||
chatInput.disabled = true;
|
});
|
||||||
document.getElementById('send-btn').disabled = true;
|
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
|
// Infinite scroll: load older messages when scrolled near the top
|
||||||
document.getElementById('chat-messages').addEventListener('scroll', function () {
|
document.getElementById('chat-messages').addEventListener('scroll', function () {
|
||||||
@@ -3612,8 +3705,13 @@ document.addEventListener('keydown', (e) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape: close job detail or blur input
|
// Escape: close autocomplete, job detail, or blur input
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
const acEl = document.getElementById('slash-autocomplete');
|
||||||
|
if (acEl && acEl.style.display !== 'none') {
|
||||||
|
hideSlashAutocomplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (currentJobId) {
|
if (currentJobId) {
|
||||||
closeJobDetail();
|
closeJobDetail();
|
||||||
} else if (inInput) {
|
} else if (inInput) {
|
||||||
|
|||||||
@@ -78,9 +78,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="chat-container">
|
<div class="chat-container">
|
||||||
<div class="chat-messages" id="chat-messages"></div>
|
<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">
|
<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>
|
<button id="send-btn" onclick="sendMessage()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -458,31 +458,6 @@ body {
|
|||||||
.message th { background: var(--bg-tertiary); }
|
.message th { background: var(--bg-tertiary); }
|
||||||
|
|
||||||
/* Status bar */
|
/* 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 {
|
.scroll-load-spinner {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3451,3 +3426,44 @@ mark {
|
|||||||
width: 100%;
|
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