Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 84 additions & 38 deletions crates/ironclaw_gateway/static/js/core/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ function loadHistory(before) {

apiFetch(historyUrl).then((data) => {
const container = document.getElementById('chat-messages');
const pending = !isPaginating ? _pendingUserMessages.get(currentThreadId) : null;
let freshPending = [];
let pendingByContent = null;

if (!isPaginating && pending && pending.length > 0) {
const now = Date.now();
freshPending = pending.filter(p => now - p.timestamp < PENDING_MSG_TTL_MS);
if (freshPending.length > 0) {
pendingByContent = new Map();
freshPending.forEach((p) => {
const key = p.content;
if (!pendingByContent.has(key)) pendingByContent.set(key, []);
pendingByContent.get(key).push(p);
});
}
}

if (!isPaginating && currentThreadId && data.channel) {
threadChannelHints.set(currentThreadId, data.channel);
Expand All @@ -49,7 +65,33 @@ function loadHistory(before) {
container.innerHTML = '';
for (const turn of data.turns) {
if (turn.user_input) {
addMessage('user', turn.user_input);
let renderedPending = false;
const pendingQueue = pendingByContent && pendingByContent.get(turn.user_input);
const nextPending = pendingQueue && pendingQueue.length > 0 ? pendingQueue[0] : null;
if (nextPending) {
let persistedAttachments = [];
if (typeof parseUserMessageContent === 'function') {
persistedAttachments = parseUserMessageContent(turn.user_input).attachments;
}
const hasPendingVisuals = (
(Array.isArray(nextPending.attachments) && nextPending.attachments.length > 0)
|| (Array.isArray(nextPending.images) && nextPending.images.length > 0)
);
if (hasPendingVisuals && persistedAttachments.length === 0) {
const div = addMessage('user', nextPending.content, {
attachments: Array.isArray(nextPending.attachments) ? nextPending.attachments : [],
copyText: nextPending.copyText || nextPending.content,
});
if (nextPending.images && nextPending.images.length > 0) {
appendImagesToMessage(div, nextPending.images);
}
renderedPending = true;
}
pendingQueue.shift();
}
if (!renderedPending) {
addMessage('user', turn.user_input);
}
}
if (turn.tool_calls && turn.tool_calls.length > 0) {
addToolCallsSummary(turn.tool_calls);
Expand All @@ -75,52 +117,57 @@ function loadHistory(before) {
addMessage('assistant', turn.response);
}
}
// Re-inject pending user messages not yet in DB (#2409)
const pending = _pendingUserMessages.get(currentThreadId);
let freshPending = [];
if (pending && pending.length > 0) {
const now = Date.now();
freshPending = pending.filter(p => now - p.timestamp < PENDING_MSG_TTL_MS);
if (freshPending.length > 0) {
const dbContentsCounts = new Map();
data.turns
.map(t => t.user_input)
.filter(Boolean)
.forEach(content => {
dbContentsCounts.set(content, (dbContentsCounts.get(content) || 0) + 1);
});
for (const p of freshPending) {
const count = dbContentsCounts.get(p.content) || 0;
if (count > 0) {
dbContentsCounts.set(p.content, count - 1);
} else {
const div = addMessage('user', p.content);
if (p.images && p.images.length > 0) {
appendImagesToMessage(div, p.images);
}
}
}
_pendingUserMessages.set(currentThreadId, freshPending);
} else {
_pendingUserMessages.delete(currentThreadId);
}
}
container.scrollTop = container.scrollHeight;
// Show welcome card when history is empty
if (data.turns.length === 0 && !data.in_progress && freshPending.length === 0) {
showWelcomeCard();
}
// Show processing indicator if the last turn is still in-progress
var lastTurn = data.turns.length > 0 ? data.turns[data.turns.length - 1] : null;
if (data.in_progress) {
const sameLastTurn = isSameInProgressTurn(lastTurn, data.in_progress);
if (!sameLastTurn && data.in_progress.user_input) {
addMessage('user', data.in_progress.user_input);
const pendingQueue = pendingByContent && pendingByContent.get(data.in_progress.user_input);
const nextPending = pendingQueue && pendingQueue.length > 0 ? pendingQueue[0] : null;
const hasPendingVisuals = nextPending && (
(Array.isArray(nextPending.attachments) && nextPending.attachments.length > 0)
|| (Array.isArray(nextPending.images) && nextPending.images.length > 0)
);
if (hasPendingVisuals) {
const div = addMessage('user', nextPending.content, {
attachments: Array.isArray(nextPending.attachments) ? nextPending.attachments : [],
copyText: nextPending.copyText || nextPending.content,
});
if (nextPending.images && nextPending.images.length > 0) {
appendImagesToMessage(div, nextPending.images);
}
pendingQueue.shift();
} else {
addMessage('user', data.in_progress.user_input);
}
}
showActivityThinking(ActivityEntry.t('activity.processing', 'Processing...'));
} else if (lastTurn && !lastTurn.response && lastTurn.state === 'Processing') {
showActivityThinking(ActivityEntry.t('activity.processing', 'Processing...'));
}
// Re-inject pending user messages not yet in DB (#2409)
const remainingPending = freshPending.length > 0 && pendingByContent
? Array.from(pendingByContent.values()).flat()
: freshPending;
if (remainingPending.length > 0) {
for (const p of remainingPending) {
const div = addMessage('user', p.content, {
attachments: Array.isArray(p.attachments) ? p.attachments : [],
copyText: p.copyText || p.content,
});
if (p.images && p.images.length > 0) {
appendImagesToMessage(div, p.images);
}
}
_pendingUserMessages.set(currentThreadId, freshPending);
} else {
_pendingUserMessages.delete(currentThreadId);
}
container.scrollTop = container.scrollHeight;
// Show welcome card when history is empty
if (data.turns.length === 0 && !data.in_progress && freshPending.length === 0) {
showWelcomeCard();
}
const hintedChannel = currentThreadId
? (data.channel || threadChannelHints.get(currentThreadId) || 'gateway')
: 'gateway';
Expand Down Expand Up @@ -780,4 +827,3 @@ function updateTabIndicator() {
window.addEventListener('resize', updateTabIndicator);

// --- Memory (filesystem tree) ---

2 changes: 1 addition & 1 deletion crates/ironclaw_gateway/static/js/core/init-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function initApp() {
if (roleEl) roleEl.textContent = profile.role;
}).catch(function() {});
checkTeeStatus();
refreshSlashSkillEntries();
loadThreads();
loadMemoryTree();
loadJobs();
Expand Down Expand Up @@ -412,4 +413,3 @@ function updateRestartButtonVisibility() {
}

// --- SSE ---

71 changes: 66 additions & 5 deletions crates/ironclaw_gateway/static/js/surfaces/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,20 @@ async function sendMessage() {
}));
const displayContent = content
|| (pendingAttachmentsForDisplay.length > 0 ? '(files attached)' : '(images attached)');
const pendingCopyTextParts = [];
if (displayContent) pendingCopyTextParts.push(displayContent);
pendingAttachmentsForDisplay.forEach((att) => {
const suffix = [att.mime_type, att.size_label].filter(Boolean).join(' • ');
pendingCopyTextParts.push(
suffix
? `[Attachment] ${att.filename || 'attachment'} (${suffix})`
: `[Attachment] ${att.filename || 'attachment'}`
);
});
const pendingCopyText = pendingCopyTextParts.join('\n');
const userMsg = addMessage('user', displayContent, {
attachments: pendingAttachmentsForDisplay,
copyText: pendingCopyText,
});
if (attachedImageDataUrls.length > 0) {
appendImagesToMessage(userMsg, attachedImageDataUrls);
Expand All @@ -149,14 +161,15 @@ async function sendMessage() {
let pendingId = null;
const pendingThreadId = currentThreadId;
if (currentThreadId) {
const displayContent = content || '(images attached)';
if (!_pendingUserMessages.has(currentThreadId)) {
_pendingUserMessages.set(currentThreadId, []);
}
pendingId = _nextPendingId++;
_pendingUserMessages.get(currentThreadId).push({
id: pendingId,
content: displayContent,
copyText: pendingCopyText,
attachments: pendingAttachmentsForDisplay.map((att) => ({ ...att })),
images: attachedImageDataUrls,
timestamp: Date.now(),
});
Expand Down Expand Up @@ -310,8 +323,8 @@ document.getElementById('attach-btn').addEventListener('click', () => {
});

document.getElementById('image-file-input').addEventListener('change', (e) => {
handleImageFiles(e.target.files);
e.target.value = '';
const files = Array.from(e.target.files || []);
handleImageFiles(files);
});

document.getElementById('chat-input').addEventListener('paste', (e) => {
Expand Down Expand Up @@ -441,6 +454,8 @@ function resolveGeneratedImageForRender(threadId, image) {

// --- Slash Autocomplete ---

let _slashSkillEntries = [];

function showSlashAutocomplete(matches) {
const el = document.getElementById('slash-autocomplete');
if (!el || matches.length === 0) { hideSlashAutocomplete(); return; }
Expand Down Expand Up @@ -468,6 +483,51 @@ function showSlashAutocomplete(matches) {
el.style.display = 'block';
}

function setSlashSkillEntries(skills) {
if (!Array.isArray(skills)) {
_slashSkillEntries = [];
const input = document.getElementById('chat-input');
if (input && input.value.startsWith('/')) filterSlashCommands(input.value);
return;
}
_slashSkillEntries = skills
.filter((skill) => skill && typeof skill.name === 'string' && skill.name.trim() !== '')
.map((skill) => ({
cmd: '/' + skill.name.trim(),
desc: (skill.description || '').trim() || 'Skill',
kind: 'skill',
}))
.sort((a, b) => a.cmd.localeCompare(b.cmd));
const input = document.getElementById('chat-input');
if (input && input.value.startsWith('/')) filterSlashCommands(input.value);
}

function getSlashAutocompleteItems() {
const items = SLASH_COMMANDS.map((cmd) => ({
cmd: cmd.cmd,
desc: cmd.desc,
kind: 'command',
}));
const seen = new Set(items.map((item) => item.cmd.toLowerCase()));
_slashSkillEntries.forEach((item) => {
const key = item.cmd.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
items.push(item);
});
return items;
}

function refreshSlashSkillEntries() {
return apiFetch('/api/skills')
.then(function(data) {
setSlashSkillEntries((data && data.skills) || []);
})
.catch(function() {
// Preserve the last known skill list on transient fetch failures.
});
}

function hideSlashAutocomplete() {
const el = document.getElementById('slash-autocomplete');
if (el) el.style.display = 'none';
Expand Down Expand Up @@ -495,8 +555,9 @@ 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())) {
const exactLower = lower.trimEnd();
const matches = getSlashAutocompleteItems().filter((c) => c.cmd.toLowerCase().startsWith(lower));
if (matches.length === 0 || (matches.length === 1 && matches[0].cmd.toLowerCase() === exactLower)) {
hideSlashAutocomplete();
} else {
showSlashAutocomplete(matches);
Expand Down
2 changes: 1 addition & 1 deletion crates/ironclaw_gateway/static/js/surfaces/skills.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function loadSkills() {
var skillsList = document.getElementById('skills-list');
skillsList.innerHTML = renderCardsSkeleton(3);
apiFetch('/api/skills').then(function(data) {
setSlashSkillEntries((data && data.skills) || []);
if (!data.skills || data.skills.length === 0) {
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.noInstalled') + '</div>';
return;
Expand Down Expand Up @@ -390,4 +391,3 @@ document.getElementById('skill-search-input').addEventListener('keydown', functi
});

// --- Tool Permissions ---

14 changes: 5 additions & 9 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -474,18 +474,14 @@ impl AppBuilder {
// workspace. Even outside authenticated multi-tenant mode, some
// channels and test harnesses route non-owner users through
// per-user tenant workspaces seeded on demand.
let is_multi_tenant = db.has_any_users().await.unwrap_or(false);
//
// Whether the deployment is multi-tenant is configuration, not a
// property we should infer from the current DB contents. An admin
// may start in multi-tenant mode before creating any tenant users.
let is_multi_tenant = self.config.is_multi_tenant_deployment();

// In multi-tenant mode, enable admin system prompt on the owner
// workspace so the dispatcher reads SYSTEM.md from __admin__ scope.
//
// NOTE: `is_multi_tenant` is evaluated once at startup. If the
// server starts with no users (single-user mode) and users are
// added later, the owner workspace frozen in `Arc` will NOT have
// `admin_prompt_enabled`. A server restart is required after the
// first user is created to activate admin prompts on the owner
// workspace. Tenant workspaces created via `WorkspacePool` are
// unaffected — they always call `.with_admin_prompt()`.
if is_multi_tenant {
ws = ws.with_admin_prompt();
}
Expand Down
Loading
Loading