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
48 changes: 39 additions & 9 deletions crates/chat/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::{
cmp::Ordering,
collections::{BTreeMap, HashMap, HashSet},
ffi::OsStr,
path::{Path, PathBuf},
Expand Down Expand Up @@ -1570,12 +1571,39 @@ impl LiveModelService {
) -> Vec<&'a moltis_providers::ModelInfo> {
let mut ordered: Vec<(usize, &'a moltis_providers::ModelInfo)> =
models.enumerate().collect();
ordered.sort_by_key(|(idx, model)| {
(
Self::priority_rank(order, model),
subscription_provider_rank(&model.provider),
*idx,
)
ordered.sort_by(|(idx_a, a), (idx_b, b)| {
let rank_a = Self::priority_rank(order, a);
let rank_b = Self::priority_rank(order, b);
// Preferred (rank != MAX) first, then non-preferred
let bucket_a = if rank_a == usize::MAX {
1u8
} else {
0
};
let bucket_b = if rank_b == usize::MAX {
1u8
} else {
0
};
bucket_a
.cmp(&bucket_b)
.then_with(|| {
if bucket_a == 0 {
rank_a.cmp(&rank_b)
} else {
Ordering::Equal
}
})
.then_with(|| {
a.display_name
.to_lowercase()
.cmp(&b.display_name.to_lowercase())
})
.then_with(|| {
subscription_provider_rank(&a.provider)
.cmp(&subscription_provider_rank(&b.provider))
})
.then_with(|| idx_a.cmp(idx_b))
});
ordered.into_iter().map(|(_, model)| model).collect()
}
Expand Down Expand Up @@ -9989,9 +10017,11 @@ mod tests {

let order = LiveModelService::build_priority_order(&[]);
let ordered = LiveModelService::prioritize_models(&order, vec![&m1, &m2, &m3].into_iter());
assert_eq!(ordered[0].id, m2.id);
assert_eq!(ordered[1].id, m1.id);
assert_eq!(ordered[2].id, m3.id);
// Alphabetical: "Claude Sonnet 4.5" < "GPT-5.2"; among GPT-5.2
// ties, subscription_provider_rank breaks the tie (codex > openai).
assert_eq!(ordered[0].id, m3.id);
assert_eq!(ordered[1].id, m2.id);
assert_eq!(ordered[2].id, m1.id);
}

#[test]
Expand Down
10 changes: 6 additions & 4 deletions crates/gateway/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ async fn run_mcp_scan(installed_dir: &Path) -> anyhow::Result<Value> {
Ok(parsed)
}

fn is_protected_discovered_skill(name: &str) -> bool {
/// Returns `true` for discovered skill names that are protected and cannot be
/// deleted from the UI (e.g. built-in template/tmux skills).
pub fn is_protected_discovered_skill(name: &str) -> bool {
matches!(name, "template-skill" | "template" | "tmux")
}

Expand Down Expand Up @@ -940,9 +942,9 @@ fn delete_discovered_skill(source_type: &str, params: &Value) -> ServiceResult {
.ok_or_else(|| "missing 'skill' parameter".to_string())?;

if is_protected_discovered_skill(skill_name) {
return Err(
format!("skill '{skill_name}' is protected and cannot be deleted from the UI").into(),
);
return Err(ServiceError::forbidden(format!(
"skill '{skill_name}' is protected and cannot be deleted from the UI"
)));
}

if !moltis_skills::parse::validate_name(skill_name) {
Expand Down
15 changes: 14 additions & 1 deletion crates/service-traits/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ use {async_trait::async_trait, serde_json::Value, tracing::warn};
pub enum ServiceError {
#[error("{message}")]
Message { message: String },
#[error("{message}")]
Forbidden { message: String },
#[error("{0}")]
Serde(#[from] serde_json::Error),
}
Expand All @@ -21,6 +23,13 @@ impl ServiceError {
message: message.to_string(),
}
}

#[must_use]
pub fn forbidden(message: impl std::fmt::Display) -> Self {
Self::Forbidden {
message: message.to_string(),
}
}
}

impl From<String> for ServiceError {
Expand All @@ -37,7 +46,11 @@ impl From<&str> for ServiceError {

impl From<ServiceError> for moltis_protocol::ErrorShape {
fn from(err: ServiceError) -> Self {
Self::new(moltis_protocol::error_codes::UNAVAILABLE, err.to_string())
let code = match &err {
ServiceError::Forbidden { .. } => moltis_protocol::error_codes::FORBIDDEN,
_ => moltis_protocol::error_codes::UNAVAILABLE,
};
Self::new(code, err.to_string())
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/web/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,11 +558,13 @@ pub async fn api_skills_handler(State(state): State<AppState>) -> impl IntoRespo
let discoverer = FsSkillDiscoverer::new(search_paths);
if let Ok(discovered) = discoverer.discover().await {
for s in discovered {
let protected = moltis_gateway::services::is_protected_discovered_skill(&s.name);
skills.push(serde_json::json!({
"name": s.name,
"description": s.description,
"source": s.source,
"enabled": true,
"protected": protected,
}));
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/web/src/assets/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -1284,7 +1284,7 @@
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
max-height: 240px;
max-height: 320px;
overflow-y: auto;
margin-top: 4px;
}
Expand Down
57 changes: 46 additions & 11 deletions crates/web/src/assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,18 +300,47 @@ function refreshAuthChrome() {
}

window.addEventListener("moltis:auth-status-changed", () => {
refreshAuthChrome().then((auth) => {
if (!auth) return;
if (auth.setup_required) {
window.location.assign("/onboarding");
return;
}
if (!auth.authenticated) {
window.location.assign("/login");
}
});
refreshAuthChrome()
.then((auth) => {
if (!auth) return;
if (auth.setup_required) {
clearSensitiveData();
window.location.assign("/onboarding");
return;
}
if (!auth.authenticated) {
clearSensitiveData();
window.location.assign("/login");
}
})
.finally(() => {
window.dispatchEvent(new CustomEvent("moltis:auth-status-sync-complete"));
});
});

/**
* Purge cached sensitive data so that a logged-out page cannot display
* session previews, identity info, or other user-scoped state.
*/
function clearSensitiveData() {
// Clear session store and legacy state
sessionStore.setAll([]);
S.setSessions([]);
renderSessionList();

// Clear model and project stores
modelStore.setAll([]);
S.setModels([]);
projectStore.setAll([]);
S.setProjects([]);

// Clear identity from gon so sidebar/header no longer shows it
gon.set("identity", null);
gon.set("sessions_recent", null);
// Signal vault sealed so SessionList shows the correct placeholder
gon.set("vault_status", "sealed");
}

// Seed sandbox info from gon so the settings page can render immediately
// without waiting for the auth-protected /api/bootstrap fetch.
try {
Expand Down Expand Up @@ -447,7 +476,13 @@ function fetchBootstrap() {
// Fetch bootstrap data asynchronously — populates sidebar, models, projects
// as soon as the data arrives, without blocking the initial page render.
fetch("/api/bootstrap?include_sessions=false")
.then((r) => r.json())
.then((r) => {
if (r.status === 401 || r.status === 403) {
window.dispatchEvent(new CustomEvent("moltis:auth-status-changed"));
return Promise.reject(new Error("auth"));
}
return r.json();
})
.then((boot) => {
if (boot.channels) S.setCachedChannels(boot.channels.channels || boot.channels || []);
if (boot.sessions) {
Expand Down
46 changes: 30 additions & 16 deletions crates/web/src/assets/js/components/session-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
// component that auto-rerenders from sessionStore signals.

import { html } from "htm/preact";
import { useEffect, useRef } from "preact/hooks";
import { useEffect, useRef, useState } from "preact/hooks";
import * as gon from "../gon.js";
import {
makeBranchIcon,
makeChatIcon,
Expand Down Expand Up @@ -220,6 +221,34 @@ export function SessionList() {
var filterId = projectStore.projectFilterId.value;
var tab = sessionStore.sessionListTab.value;

// Hide session list when the vault is sealed — session data is
// encrypted and cannot be displayed.
var [vaultStatus, setVaultStatus] = useState(gon.get("vault_status"));
useEffect(() => {
setVaultStatus(gon.get("vault_status"));
gon.onChange("vault_status", setVaultStatus);
return () => gon.offChange("vault_status", setVaultStatus);
}, []);

// Spinner animation via setInterval
var spinnersRef = useRef(null);
useEffect(() => {
var idx = 0;
var timer = setInterval(() => {
idx = (idx + 1) % spinnerFrames.length;
if (!spinnersRef.current) return;
var els = spinnersRef.current.querySelectorAll(
".session-item.replying .session-spinner, .session-item.loading .session-spinner",
);
for (var el of els) el.textContent = spinnerFrames[idx];
}, 80);
return () => clearInterval(timer);
}, []);

if (vaultStatus === "sealed") {
return html`<div class="text-xs text-[var(--muted)] p-3">Vault is sealed</div>`;
}

var filtered = filterId ? allSessions.filter((s) => s.projectId === filterId) : allSessions;
if (tab === "sessions") {
filtered = filtered.filter((s) => !(s.key || "").startsWith("cron:"));
Expand All @@ -239,21 +268,6 @@ export function SessionList() {
});
var roots = filtered.filter((s) => !(s.parentSessionKey && keyMap[s.parentSessionKey]));

// Spinner animation via setInterval
var spinnersRef = useRef(null);
useEffect(() => {
var idx = 0;
var timer = setInterval(() => {
idx = (idx + 1) % spinnerFrames.length;
if (!spinnersRef.current) return;
var els = spinnersRef.current.querySelectorAll(
".session-item.replying .session-spinner, .session-item.loading .session-spinner",
);
for (var el of els) el.textContent = spinnerFrames[idx];
}, 80);
return () => clearInterval(timer);
}, []);

function renderTree(session, depth) {
var children = childrenMap[session.key] || [];
return html`
Expand Down
7 changes: 7 additions & 0 deletions crates/web/src/assets/js/gon.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export function onChange(key, fn) {
listeners[key].push(fn);
}

export function offChange(key, fn) {
var arr = listeners[key];
if (!arr) return;
var idx = arr.indexOf(fn);
if (idx !== -1) arr.splice(idx, 1);
}

export function refresh() {
return fetch(`/api/gon?_=${Date.now()}`, {
cache: "no-store",
Expand Down
6 changes: 3 additions & 3 deletions crates/web/src/assets/js/onboarding-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -3208,13 +3208,13 @@ function SummaryStep({ onBack, onFinish }) {
${
data.tailscale !== null
? html`<${SummaryRow}
icon=${data.tailscale?.connected ? html`<${CheckIcon} />` : data.tailscale?.installed ? html`<${WarnIcon} />` : html`<${InfoIcon} />`}
icon=${data.tailscale?.tailscale_up ? html`<${CheckIcon} />` : data.tailscale?.installed ? html`<${WarnIcon} />` : html`<${InfoIcon} />`}
label="Tailscale">
${
data.tailscale?.connected
data.tailscale?.tailscale_up
? html`Connected`
: data.tailscale?.installed
? html`Installed but not connected`
? html`Installed but not connected — <a href="/settings/tailscale" class="text-[var(--accent)] underline">Configure in Settings</a>`
: html`Not installed. Install Tailscale for secure remote access.`
}
<//>`
Expand Down
21 changes: 14 additions & 7 deletions crates/web/src/assets/js/page-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -2703,6 +2703,19 @@ function VoiceSection() {
}
}

function humanizeMicError(err) {
if (err.name === "OverconstrainedError" || (err.message && /constraint/i.test(err.message))) {
return "No compatible microphone found. Check your audio input device.";
}
if (err.name === "NotFoundError" || err.name === "NotAllowedError") {
return "Microphone access denied or no microphone found. Check browser permissions.";
}
if (err.name === "NotReadableError") {
return "Microphone is in use by another application.";
}
return err.message || "STT test failed";
}

// Test a voice provider (TTS or STT)
async function testVoiceProvider(providerId, type) {
// If already recording for this provider, stop it
Expand Down Expand Up @@ -2831,13 +2844,7 @@ function VoiceSection() {
rerender();
};
} catch (err) {
if (err.name === "NotAllowedError") {
setVoiceErr("Microphone permission denied");
} else if (err.name === "NotFoundError") {
setVoiceErr("No microphone found");
} else {
setVoiceErr(err.message || "STT test failed");
}
setVoiceErr(humanizeMicError(err));
setVoiceTesting(null);
}
}
Expand Down
15 changes: 15 additions & 0 deletions crates/web/src/assets/js/ws-connect.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as S from "./state.js";

var reconnectTimer = null;
var lastOpts = null;
var authRedirectPending = false;

/** Registry of server-request handlers keyed by method name (v4 bidir RPC). */
var serverRequestHandlers = {};
Expand All @@ -13,6 +14,12 @@ function resolveLocale() {
return getPreferredLocale();
}

function resetAuthRedirectGuard() {
authRedirectPending = false;
}

window.addEventListener("moltis:auth-status-sync-complete", resetAuthRedirectGuard);

/**
* Register a handler for server-initiated RPC requests (v4 bidirectional RPC).
* @param {string} method — method name (e.g. "node.invoke")
Expand Down Expand Up @@ -87,6 +94,14 @@ export function connectWs(opts) {
}
if (frame?.type === "res" && frame.error) {
frame.error = localizeRpcError(frame.error);
// When an RPC response indicates auth failure, trigger the
// auth-status-changed flow so the UI redirects to login
// instead of showing stale/broken data. Use a flag to
// avoid dispatching multiple times when several RPCs fail.
if (frame.error.code === "UNAUTHORIZED" && !authRedirectPending) {
authRedirectPending = true;
window.dispatchEvent(new CustomEvent("moltis:auth-status-changed"));
}
}
if (frame.type === "res" && frame.id && S.pending[frame.id]) {
S.pending[frame.id](frame);
Expand Down
Loading
Loading