diff --git a/cortex/routers/help.py b/cortex/routers/help.py
index e61b2d1..f0bfcc3 100644
--- a/cortex/routers/help.py
+++ b/cortex/routers/help.py
@@ -21,6 +21,9 @@ router = APIRouter()
_STATIC = Path(__file__).parent.parent / "static"
+_LAST_PERSONA_COOKIE = "cx_last_persona"
+
+
def _get_session_user(request: Request) -> str | None:
token = request.cookies.get(COOKIE_NAME)
if not token:
@@ -31,6 +34,16 @@ def _get_session_user(request: Request) -> str | None:
return None
+def _preferred_persona(request: Request, username: str) -> str:
+ names = list_user_personas(username)
+ if not names:
+ return ""
+ cookie_val = request.cookies.get(_LAST_PERSONA_COOKIE, "")
+ if cookie_val in names:
+ return cookie_val
+ return names[0]
+
+
@router.get("/help", include_in_schema=False)
async def help_page(request: Request, persona: str = ""):
username = _get_session_user(request)
@@ -38,11 +51,11 @@ async def help_page(request: Request, persona: str = ""):
return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
- # Use persona from query param if valid, else fall back to first
+ # Use persona from query param if valid, else prefer last-visited from cookie
if persona and persona in personas:
back_persona = persona
else:
- back_persona = personas[0] if personas else ""
+ back_persona = _preferred_persona(request, username)
back_href = f"/{username}/{back_persona}" if back_persona else "/"
html = (_STATIC / "help.html").read_text()
diff --git a/cortex/routers/settings.py b/cortex/routers/settings.py
index f3395d3..cd71901 100644
--- a/cortex/routers/settings.py
+++ b/cortex/routers/settings.py
@@ -28,6 +28,9 @@ router = APIRouter()
_STATIC = Path(__file__).parent.parent / "static"
+_LAST_PERSONA_COOKIE = "cx_last_persona"
+
+
def _get_session_user(request: Request) -> str | None:
token = request.cookies.get(COOKIE_NAME)
if not token:
@@ -38,7 +41,17 @@ def _get_session_user(request: Request) -> str | None:
return None
-def _settings_page(username: str, personas: list[str], success: str = "", error: str = "") -> str:
+def _preferred_persona(request: Request, username: str) -> str:
+ names = list_user_personas(username)
+ if not names:
+ return ""
+ cookie_val = request.cookies.get(_LAST_PERSONA_COOKIE, "")
+ if cookie_val in names:
+ return cookie_val
+ return names[0]
+
+
+def _settings_page(username: str, personas: list[str], back_persona: str = "", success: str = "", error: str = "") -> str:
html = (_STATIC / "settings.html").read_text()
html = html.replace("{{ username }}", username)
@@ -62,7 +75,8 @@ def _settings_page(username: str, personas: list[str], success: str = "", error:
''' for p in personas
)
html = html.replace("{{ persona_items }}", persona_items or "
No personas yet.
")
- back_persona = personas[0] if personas else ""
+ if not back_persona:
+ back_persona = personas[0] if personas else ""
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
html = html.replace("{{ help_href }}", f"/help?persona={back_persona}" if back_persona else "/help")
if success:
@@ -78,7 +92,8 @@ async def settings_page(request: Request):
if not username:
return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
- return HTMLResponse(_settings_page(username, personas))
+ back_persona = _preferred_persona(request, username)
+ return HTMLResponse(_settings_page(username, personas, back_persona=back_persona))
@router.post("/settings/password", include_in_schema=False)
@@ -93,19 +108,20 @@ async def change_password(
return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
+ back_persona = _preferred_persona(request, username)
if not check_credentials(username, current_password):
- return HTMLResponse(_settings_page(username, personas, error="Current password is incorrect."))
+ return HTMLResponse(_settings_page(username, personas, back_persona, error="Current password is incorrect."))
if len(new_password) < 8:
- return HTMLResponse(_settings_page(username, personas, error="New password must be at least 8 characters."))
+ return HTMLResponse(_settings_page(username, personas, back_persona, error="New password must be at least 8 characters."))
if new_password != confirm_password:
- return HTMLResponse(_settings_page(username, personas, error="New passwords do not match."))
+ return HTMLResponse(_settings_page(username, personas, back_persona, error="New passwords do not match."))
set_password(username, new_password)
logger.info("password changed: %s", username)
- return HTMLResponse(_settings_page(username, personas, success="Password updated successfully."))
+ return HTMLResponse(_settings_page(username, personas, back_persona, success="Password updated successfully."))
@router.post("/settings/username", include_in_schema=False)
@@ -118,11 +134,12 @@ async def rename_username(
return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
+ back_persona = _preferred_persona(request, username)
new_username = new_username.strip().lower()
if not _SLUG_RE.match(new_username):
return HTMLResponse(_settings_page(
- username, personas,
+ username, personas, back_persona,
error="Invalid username. Use lowercase letters, digits, _ or - only."))
if new_username == username:
@@ -134,7 +151,7 @@ async def rename_username(
if new_dir.exists():
return HTMLResponse(_settings_page(
- username, personas,
+ username, personas, back_persona,
error=f"Username '{new_username}' is already taken."))
old_dir.rename(new_dir)
@@ -156,6 +173,7 @@ async def save_gemini_key(
return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
+ back_persona = _preferred_persona(request, username)
gemini_api_key = gemini_api_key.strip()
data = _read_auth(username)
@@ -167,7 +185,7 @@ async def save_gemini_key(
msg = "Gemini API key removed — using server key."
_write_auth(username, data)
logger.info("gemini key updated: %s", username)
- return HTMLResponse(_settings_page(username, personas, success=msg))
+ return HTMLResponse(_settings_page(username, personas, back_persona, success=msg))
@router.post("/settings/persona/rename", include_in_schema=False)
@@ -181,11 +199,12 @@ async def rename_persona(
return RedirectResponse("/login", status_code=302)
personas = list_user_personas(username)
+ back_persona = _preferred_persona(request, username)
new_name = new_name.strip().lower()
if not _SLUG_RE.match(new_name):
return HTMLResponse(_settings_page(
- username, personas,
+ username, personas, back_persona,
error="Invalid name. Use lowercase letters, digits, _ or - only."))
if new_name == old_name:
@@ -196,11 +215,11 @@ async def rename_persona(
new_dir = persona_root / new_name
if not old_dir.exists():
- return HTMLResponse(_settings_page(username, personas, error=f"Persona '{old_name}' not found."))
+ return HTMLResponse(_settings_page(username, personas, back_persona, error=f"Persona '{old_name}' not found."))
if new_dir.exists():
return HTMLResponse(_settings_page(
- username, personas,
+ username, personas, back_persona,
error=f"A persona named '{new_name}' already exists."))
old_dir.rename(new_dir)
diff --git a/cortex/routers/ui.py b/cortex/routers/ui.py
index 3db4871..614b9a2 100644
--- a/cortex/routers/ui.py
+++ b/cortex/routers/ui.py
@@ -56,12 +56,26 @@ def _set_cookie(response: Response, username: str) -> None:
)
+_LAST_PERSONA_COOKIE = "cx_last_persona"
+
+
def _first_persona(username: str) -> str | None:
"""Return the first available persona for a user, or None."""
names = list_user_personas(username)
return names[0] if names else None
+def _preferred_persona(request: Request, username: str) -> str | None:
+ """Return the last-visited persona from cookie if valid, else the first available."""
+ names = list_user_personas(username)
+ if not names:
+ return None
+ cookie_val = request.cookies.get(_LAST_PERSONA_COOKIE, "")
+ if cookie_val in names:
+ return cookie_val
+ return names[0]
+
+
# ---------------------------------------------------------------------------
# Favicon — default sparkle; persona pages override via JS
# ---------------------------------------------------------------------------
@@ -85,7 +99,7 @@ async def root(request: Request):
user = _get_session_user(request)
if not user:
return RedirectResponse("/login", status_code=302)
- persona = _first_persona(user)
+ persona = _preferred_persona(request, user)
if not persona:
return HTMLResponse("
No personas configured for your account.
", status_code=500)
return RedirectResponse(f"/{user}/{persona}", status_code=302)
@@ -100,7 +114,7 @@ async def login_page(request: Request):
user = _get_session_user(request)
if user:
# Already logged in — redirect home
- persona = _first_persona(user)
+ persona = _preferred_persona(request, user)
if persona:
return RedirectResponse(f"/{user}/{persona}", status_code=302)
return HTMLResponse((_STATIC / "login.html").read_text())
@@ -254,7 +268,16 @@ async def api_personas(request: Request) -> dict:
if not user:
from fastapi import HTTPException
raise HTTPException(status_code=401, detail="Not authenticated")
- return {"user": user, "personas": list_user_personas(user)}
+ personas_with_emoji = []
+ for p in list_user_personas(user):
+ emoji = "✨"
+ identity_path = persona_path(user, p) / "IDENTITY.md"
+ if identity_path.exists():
+ m = re.search(r"\|\s*Emoji\s*\|\s*(.+?)\s*\|", identity_path.read_text())
+ if m:
+ emoji = m.group(1).strip()
+ personas_with_emoji.append({"name": p, "emoji": emoji})
+ return {"user": user, "personas": personas_with_emoji}
@router.get("/{username}/{persona}", include_in_schema=False)
@@ -288,4 +311,6 @@ async def serve_ui(username: str, persona: str, request: Request):
f'{{user: "{username}", persona: "{persona}", emoji: "{emoji}"}};'
)
html = html.replace("", f"{config_tag}\n", 1)
- return HTMLResponse(html)
+ resp = HTMLResponse(html)
+ resp.set_cookie(_LAST_PERSONA_COOKIE, persona, max_age=365 * 86400, httponly=False, samesite="lax")
+ return resp
diff --git a/cortex/static/app.js b/cortex/static/app.js
index d10f608..a701a2e 100644
--- a/cortex/static/app.js
+++ b/cortex/static/app.js
@@ -360,10 +360,18 @@
personaDropEl.innerHTML = '';
personas.forEach(p => {
+ const name = p.name || p;
+ const emoji = p.emoji || '✨';
const a = document.createElement('a');
- a.href = `/${CORTEX_USER}/${p}`;
- a.textContent = p.charAt(0).toUpperCase() + p.slice(1);
- if (p === CORTEX_PERSONA) a.classList.add('active');
+ a.href = `/${CORTEX_USER}/${name}`;
+ if (name === CORTEX_PERSONA) a.classList.add('active');
+ const emojiEl = document.createElement('span');
+ emojiEl.className = 'pd-emoji';
+ emojiEl.textContent = emoji;
+ a.appendChild(emojiEl);
+ const nameEl = document.createElement('span');
+ nameEl.textContent = name.charAt(0).toUpperCase() + name.slice(1);
+ a.appendChild(nameEl);
personaDropEl.appendChild(a);
});
@@ -1493,19 +1501,6 @@
try { data = JSON.parse(e.data); } catch { return; }
if (data.type === 'keepalive') return;
- if (data.type === 'claude_auth_expired') {
- let banner = document.getElementById('claude-auth-banner');
- if (!banner) {
- banner = document.createElement('div');
- banner.id = 'claude-auth-banner';
- banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#7c2d12;color:#fef2f2;padding:0.6rem 1rem;font-size:0.85rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;';
- banner.innerHTML = '⚠️ Claude authentication expired — run claude in your terminal to re-authenticate, then reload.'
- + '';
- document.body.prepend(banner);
- }
- return;
- }
-
if (data.type !== 'nct_message' && data.type !== 'nct_response') return;
if (sessionId === data.session_id) {
@@ -1704,57 +1699,6 @@
syncHeight();
addMessage('system', 'Session started');
- // ── Auth token warning banner ─────────────────────────────
- const authBanner = document.getElementById('auth-banner');
- const authBannerMsg = document.getElementById('auth-banner-msg');
- const authBannerHint = document.getElementById('auth-banner-hint');
- const authBannerClose = document.getElementById('auth-banner-close');
-
- async function checkAuthStatus() {
- try {
- const res = await fetch('/auth/status');
- if (!res.ok) return;
- const d = await res.json();
-
- const warnings = [];
- const fixes = [];
- let anyExpired = false;
-
- if (d.claude?.warning) {
- if (d.claude.expired) {
- warnings.push('✕ Claude CLI token has expired');
- anyExpired = true;
- } else {
- warnings.push(`⚠ Claude CLI token expires in ${d.claude.access_token_hours_remaining}h`);
- }
- fixes.push('claude');
- }
-
- if (d.gemini?.warning) {
- warnings.push('⚠ Gemini CLI not authenticated');
- fixes.push('gemini');
- }
-
- if (!warnings.length) {
- authBanner.classList.remove('show');
- return;
- }
-
- authBannerMsg.innerHTML = warnings.join(' ');
- authBannerHint.innerHTML =
- `To fix: SSH into the Cortex host and run ${fixes.join(' and/or ')} — `
- + 'follow the login prompt, then restart Cortex.';
- authBanner.classList.toggle('expired', anyExpired);
- authBanner.classList.add('show');
- } catch { /* silently ignore — don't break the UI */ }
- }
-
- authBannerClose.addEventListener('click', () => authBanner.classList.remove('show'));
-
- checkAuthStatus();
- // Re-check every 30 minutes
- setInterval(checkAuthStatus, 30 * 60 * 1000);
-
// ── Initial render ────────────────────────────────────────────
// Process all static Lucide SVGs in the header + stop button,
// and seed the mode UI (which also calls render_icons internally).
diff --git a/cortex/static/help.html b/cortex/static/help.html
index 954c306..e608fd6 100644
--- a/cortex/static/help.html
+++ b/cortex/static/help.html
@@ -8,17 +8,33 @@
+
diff --git a/cortex/static/index.html b/cortex/static/index.html
index e3608ae..ec20dee 100644
--- a/cortex/static/index.html
+++ b/cortex/static/index.html
@@ -148,15 +148,6 @@
-
-
-
-
-
-
-
-
-
diff --git a/cortex/static/local_llm.html b/cortex/static/local_llm.html
index 6b131fe..eb27f27 100644
--- a/cortex/static/local_llm.html
+++ b/cortex/static/local_llm.html
@@ -7,12 +7,28 @@
+
@@ -248,6 +287,9 @@
Claude models are accessed through the Claude CLI using your existing OAuth login.
Run claude auth login to authenticate.
+
+ Checking…
+
@@ -335,11 +377,10 @@
Models
{{ model_rows }}
-
-
-
-
Add Model
+
+ + Add model
+
@@ -427,6 +468,9 @@
+
+
+
@@ -625,6 +669,28 @@
// Hide fetch button initially if no hosts
if (!HAS_HOSTS) fetchBtn.style.display = 'none';
+
+ // ── Claude CLI auth status ─────────────────────────────────────────────
+ (async function() {
+ const el = document.getElementById('claude-auth-status');
+ const msg = document.getElementById('claude-auth-msg');
+ if (!el || !msg) return;
+ try {
+ const d = await fetch('/auth/status').then(r => r.json());
+ const c = d.claude;
+ if (!c) return;
+ if (c.expired) {
+ el.className = 'auth-status err';
+ msg.textContent = 'Token expired — run claude auth login on the Cortex host, then restart Cortex.';
+ } else if (c.warning) {
+ el.className = 'auth-status warn';
+ msg.textContent = `Token expires in ${c.access_token_hours_remaining}h — run claude auth login to refresh.`;
+ } else {
+ el.className = 'auth-status ok';
+ msg.textContent = `Authenticated — token valid for ${c.access_token_hours_remaining}h`;
+ }
+ } catch { msg.textContent = 'Status unavailable'; }
+ })();