feat(commands): /background, /btw slash commands + undo button + reasoning chip

Rebased onto master after #931 (aux title routing) to resolve streaming.py conflict.
All changes from both PRs are cleanly integrated.

2088 tests passing (2065 master + 23 from #931).

Co-authored-by: bergeouss <bergeouss@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-24 01:24:51 +00:00
parent 57222c70e7
commit 9c69b646ff
10 changed files with 568 additions and 12 deletions

View File

@@ -29,10 +29,18 @@
workspace subtree) and never enumerate blocked system roots. (`api/routes.py`,
`api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616)
## [v0.50.182] — 2026-04-24
## [v0.50.183] — 2026-04-24
### Added
- **`/btw` slash command** — ask an ephemeral side question using current session context without adding to history. Creates a hidden session, streams the answer in a visually distinct bubble, then discards the session. Includes `attachBtwStream()` SSE consumer and `POST /api/btw` route. (`api/routes.py`, `api/background.py`, `static/commands.js`, `static/messages.js`, `static/style.css`)
- **`/background` slash command** — run a prompt in a parallel background agent without blocking the active conversation. Frontend polls `GET /api/background/status` for results and displays completed answers inline. Includes badge indicator in composer footer. (`api/routes.py`, `api/background.py`, `static/commands.js`, `static/messages.js`, `static/index.html`)
- **Undo button on last assistant message** — surfaced as an ↩ icon on the last assistant message, calling the existing `/undo` command for discoverability. (`static/ui.js`)
- **Reasoning effort chip in composer** — visual chip to set reasoning effort level from the composer footer without typing a command. (`static/ui.js`, `static/index.html`, `static/style.css`)
### Fixed
- **Auxiliary title model now respected** — when `auxiliary.title_generation` is explicitly configured in `config.yaml`, the WebUI now routes title generation through that dedicated model instead of silently using the chat session's model. Adds `_aux_title_configured()` to detect meaningful auxiliary config, `_aux_title_timeout()` to respect the configured per-task timeout (was hardcoded to 15.0 s), and adds the missing `llm_invalid_aux` fallback path so invalid auxiliary outputs trigger the agent-model fallback. (`api/streaming.py`, `tests/test_title_aux_routing.py`) Co-authored by @starship-s.
- **Ephemeral sessions correctly skip persistence** — added `return` after the ephemeral `done` event in `_run_agent_streaming()`, preventing ephemeral session state from being written to disk after stream completion. (`api/streaming.py`)
Co-authored by @bergeouss.
## [v0.50.181] — 2026-04-24

87
api/background.py Normal file
View File

@@ -0,0 +1,87 @@
"""Background and ephemeral task tracking for /background and /btw commands."""
from __future__ import annotations
import logging
import threading
import time
from typing import Any
logger = logging.getLogger(__name__)
_lock = threading.Lock()
# parent_session_id -> list of task dicts
_BACKGROUND_TASKS: dict[str, list[dict[str, Any]]] = {}
# btw ephemeral session tracking: parent_sid -> {ephemeral_sid, stream_id, question}
_BTW_TRACKING: dict[str, dict[str, Any]] = {}
def track_background(parent_sid: str, bg_sid: str, stream_id: str,
task_id: str, prompt: str) -> None:
with _lock:
_BACKGROUND_TASKS.setdefault(parent_sid, []).append({
"task_id": task_id,
"bg_session_id": bg_sid,
"stream_id": stream_id,
"prompt": prompt,
"status": "running",
"started_at": time.time(),
"answer": None,
"completed_at": None,
})
def track_btw(parent_sid: str, ephemeral_sid: str, stream_id: str,
question: str) -> None:
with _lock:
_BTW_TRACKING[parent_sid] = {
"ephemeral_session_id": ephemeral_sid,
"stream_id": stream_id,
"question": question,
}
def complete_background(parent_sid: str, task_id: str, answer: str) -> None:
with _lock:
for t in _BACKGROUND_TASKS.get(parent_sid, []):
if t["task_id"] == task_id and t["status"] == "running":
t["status"] = "done"
t["answer"] = answer
t["completed_at"] = time.time()
break
def get_results(parent_sid: str) -> list[dict[str, Any]]:
"""Return completed background task results and remove only the done ones
from tracking. Tasks still in ``status="running"`` MUST stay in the list
so that ``complete_background()`` can still find them when the worker
thread finishes — otherwise the first poll during a long-running task
silently drops it and the result is lost forever.
"""
with _lock:
tasks = _BACKGROUND_TASKS.get(parent_sid, [])
done = [t for t in tasks if t["status"] == "done"]
still_running = [t for t in tasks if t["status"] != "done"]
if still_running:
_BACKGROUND_TASKS[parent_sid] = still_running
else:
_BACKGROUND_TASKS.pop(parent_sid, None)
return [{
"task_id": t["task_id"],
"prompt": t["prompt"],
"answer": t["answer"],
"completed_at": t["completed_at"],
} for t in done]
def get_background_tasks(parent_sid: str) -> list[dict[str, Any]]:
"""Return all background tasks (running and done) for a parent session."""
with _lock:
return list(_BACKGROUND_TASKS.get(parent_sid, []))
def cleanup_btw(parent_sid: str) -> dict[str, Any] | None:
"""Remove and return btw tracking for a parent session."""
with _lock:
return _BTW_TRACKING.pop(parent_sid, None)

View File

@@ -741,6 +741,13 @@ def handle_get(handler, parsed) -> bool:
except KeyError:
return bad(handler, "Session not found", 404)
if parsed.path == "/api/background/status":
sid = parse_qs(parsed.query).get("session_id", [""])[0]
if not sid:
return bad(handler, "Missing session_id")
from api.background import get_results
return j(handler, {"results": get_results(sid)})
if parsed.path == "/api/sessions":
webui_sessions = all_sessions()
settings = load_settings()
@@ -1258,6 +1265,12 @@ def handle_post(handler, parsed) -> bool:
except ValueError as e:
return j(handler, {"error": str(e)})
if parsed.path == "/api/btw":
return _handle_btw(handler, body)
if parsed.path == "/api/background":
return _handle_background(handler, body)
if parsed.path == "/api/chat/start":
return _handle_chat_start(handler, body)
@@ -2457,6 +2470,133 @@ def _handle_sessions_cleanup(handler, body, zero_only=False):
return j(handler, {"ok": True, "cleaned": cleaned})
def _handle_btw(handler, body):
"""POST /api/btw — ephemeral side question using session context.
Creates a temporary hidden session, streams the answer via SSE, then
discards the session. The parent session is not modified.
"""
try:
require(body, "session_id")
require(body, "question")
except ValueError as e:
return bad(handler, str(e))
try:
s = get_session(body["session_id"])
except KeyError:
return bad(handler, "Session not found", 404)
question = str(body["question"]).strip()
if not question:
return bad(handler, "question is required")
# Duplicate-stream guard (same pattern as chat/start)
current_stream_id = getattr(s, "active_stream_id", None)
if current_stream_id:
with STREAMS_LOCK:
if current_stream_id in STREAMS:
return j(handler, {"error": "session already has an active stream"}, status=409)
s.active_stream_id = None
# Create ephemeral hidden session inheriting context
from api.models import new_session as _new_session
ephemeral = _new_session(workspace=s.workspace, model=s.model, profile=getattr(s, 'profile', None))
# Copy conversation history for context (agent reads from messages)
ephemeral.messages = list(s.messages or [])
ephemeral.title = f"btw: {question[:60]}"
ephemeral.save()
stream_id = uuid.uuid4().hex
ephemeral.active_stream_id = stream_id
ephemeral.save()
q = queue.Queue()
with STREAMS_LOCK:
STREAMS[stream_id] = q
from api.background import track_btw
track_btw(body["session_id"], ephemeral.session_id, stream_id, question)
thr = threading.Thread(
target=_run_agent_streaming,
args=(ephemeral.session_id, question, s.model, s.workspace, stream_id, None),
kwargs={"ephemeral": True},
daemon=True,
)
thr.start()
return j(handler, {"stream_id": stream_id, "session_id": ephemeral.session_id, "parent_session_id": body["session_id"]})
def _handle_background(handler, body):
"""POST /api/background — run prompt in parallel background agent.
Creates a hidden session, starts streaming in a daemon thread.
Frontend polls /api/background/status for completed results.
"""
try:
require(body, "session_id")
require(body, "prompt")
except ValueError as e:
return bad(handler, str(e))
try:
s = get_session(body["session_id"])
except KeyError:
return bad(handler, "Session not found", 404)
prompt = str(body["prompt"]).strip()
if not prompt:
return bad(handler, "prompt is required")
from api.models import new_session as _new_session
bg = _new_session(workspace=s.workspace, model=s.model, profile=getattr(s, 'profile', None))
bg.title = f"bg: {prompt[:60]}"
bg.save()
stream_id = uuid.uuid4().hex
bg.active_stream_id = stream_id
bg.save()
q = queue.Queue()
with STREAMS_LOCK:
STREAMS[stream_id] = q
task_id = uuid.uuid4().hex[:8]
from api.background import track_background, complete_background
parent_sid = body["session_id"]
bg_sid = bg.session_id
track_background(parent_sid, bg_sid, stream_id, task_id, prompt)
def _run_bg_and_notify():
"""Run the background agent, then mark the tracked task `done` with the
last assistant reply so `/api/background/status` can surface it. Without
this, `complete_background()` is never called and the result is lost —
`get_results()` would see a forever-`running` task and return nothing.
"""
try:
_run_agent_streaming(bg_sid, prompt, s.model, s.workspace, stream_id, None)
# Reload the bg session from disk and extract the final assistant reply.
try:
from api.models import Session as _Session
reloaded = _Session.load(bg_sid)
_answer = ""
for _m in reversed((reloaded.messages if reloaded else None) or []):
if not isinstance(_m, dict) or _m.get("role") != "assistant":
continue
if _m.get("_error"):
continue
_content = str(_m.get("content") or "").strip()
if _content:
_answer = _content
break
complete_background(parent_sid, task_id, _answer or "(no answer produced)")
except Exception:
complete_background(parent_sid, task_id, "(background task failed)")
# Best-effort cleanup of the hidden bg session file so it doesn't
# clutter the sidebar or SESSION_DIR. The index is pruned on the
# next rebuild via _index_entry_exists().
try:
(SESSION_DIR / f"{bg_sid}.json").unlink(missing_ok=True)
except Exception:
pass
except Exception:
try:
complete_background(parent_sid, task_id, "(background task failed)")
except Exception:
pass
thr = threading.Thread(target=_run_bg_and_notify, daemon=True)
thr.start()
return j(handler, {"task_id": task_id, "stream_id": stream_id, "session_id": bg.session_id})
def _handle_chat_start(handler, body):
try:
require(body, "session_id")

View File

@@ -866,8 +866,12 @@ def _sse(handler, event, data):
handler.wfile.flush()
def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None):
"""Run agent in background thread, writing SSE events to STREAMS[stream_id]."""
def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, attachments=None, *, ephemeral=False):
"""Run agent in background thread, writing SSE events to STREAMS[stream_id].
When ephemeral=True, session mutations are skipped — used by /btw to get
a streaming answer without persisting to the parent session.
"""
q = STREAMS.get(stream_id)
if q is None:
return
@@ -1336,6 +1340,27 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
task_id=session_id,
persist_user_message=msg_text,
)
# ── Ephemeral mode (/btw): deliver answer, skip persistence, cleanup ──
if ephemeral:
_answer = ''
for _m in reversed(result.get('messages') or []):
if isinstance(_m, dict) and _m.get('role') == 'assistant':
_answer = str(_m.get('content', ''))
break
put('done', {
'session': {'session_id': session_id, 'messages': result.get('messages', [])},
'usage': {'input_tokens': 0, 'output_tokens': 0},
'ephemeral': True,
'answer': _answer,
})
if _checkpoint_stop is not None:
_checkpoint_stop.set()
try:
import pathlib
pathlib.Path(s.path).unlink(missing_ok=True)
except Exception:
pass
return # skip all normal persistence for ephemeral sessions
if _checkpoint_stop is not None:
_checkpoint_stop.set()
if _ckpt_thread is not None:

View File

@@ -20,6 +20,8 @@ const COMMANDS=[
{name:'title', desc:t('cmd_title'), fn:cmdTitle, arg:'[title]'},
{name:'retry', desc:t('cmd_retry'), fn:cmdRetry, noEcho:true},
{name:'undo', desc:t('cmd_undo'), fn:cmdUndo, noEcho:true},
{name:'btw', desc:t('cmd_btw'), fn:cmdBtw, arg:'question', noEcho:true},
{name:'background',desc:t('cmd_background'),fn:cmdBackground,arg:'prompt', noEcho:true},
{name:'status', desc:t('cmd_status'), fn:cmdStatus},
{name:'voice', desc:t('cmd_voice'), fn:cmdVoice, noEcho:true},
{name:'reasoning', desc:t('cmd_reasoning'), fn:cmdReasoning, arg:'show|hide|none|minimal|low|medium|high|xhigh', subArgs:['show','hide','none','minimal','low','medium','high','xhigh'], noEcho:true},
@@ -573,6 +575,36 @@ async function cmdUndo(){
showToast(`${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
}catch(e){showToast(t('undo_failed')+e.message);}
}
async function undoLastExchange(){await cmdUndo();}
async function cmdBtw(args){
if(!S.session){showToast(t('no_active_session'));return;}
const question=(args||'').trim();
if(!question){showToast(t('cmd_btw_usage'));return;}
showToast(t('btw_asking'));
const activeSid=S.session.session_id;
try{
const r=await api('/api/btw',{method:'POST',body:JSON.stringify({session_id:activeSid,question})});
if(r&&r.error){showToast(r.error);return;}
// Connect to the ephemeral SSE stream
const streamId=r.stream_id;
const parentSid=r.parent_session_id;
if(typeof attachBtwStream==='function') attachBtwStream(parentSid,streamId,question);
}catch(e){showToast(t('btw_failed')+e.message);}
}
async function cmdBackground(args){
if(!S.session){showToast(t('no_active_session'));return;}
const prompt=(args||'').trim();
if(!prompt){showToast(t('cmd_background_usage'));return;}
showToast(t('bg_running'));
const activeSid=S.session.session_id;
try{
const r=await api('/api/background',{method:'POST',body:JSON.stringify({session_id:activeSid,prompt})});
if(r&&r.error){showToast(r.error);return;}
// Show background badge and start polling
if(typeof showBackgroundBadge==='function') showBackgroundBadge(r.task_id);
if(typeof startBackgroundPolling==='function') startBackgroundPolling(activeSid,r.task_id,prompt);
}catch(e){showToast(t('bg_failed')+e.message);}
}
async function cmdStatus(){
if(!S.session){showToast(t('no_active_session'));return;}
try{
@@ -623,6 +655,7 @@ function cmdReasoning(args){
.then(function(st){
const eff=(st && st.reasoning_effort)||arg;
showToast(BRAIN+' Reasoning effort set to '+eff+' (saved; applies to next turn)');
if(typeof syncReasoningChip==='function') syncReasoningChip();
})
.catch(function(e){
showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg));

View File

@@ -102,6 +102,21 @@ const LOCALES = {
cmd_title:'Get or set the session title',
cmd_retry:'Resend the last message',
cmd_undo:'Remove the last exchange',
cmd_btw:'Ask a side question (ephemeral)',
cmd_btw_usage:'/btw <question> — ask a side question using session context',
cmd_background:'Run a prompt in background',
cmd_background_usage:'/background <prompt> — run in parallel without blocking',
btw_asking:'Asking side question...',
btw_label:'Side question — not in history',
btw_done:'Side question answered',
btw_no_answer:'No answer received.',
btw_failed:'Side question failed: ',
bg_running:'Running in background...',
bg_complete:'Background task complete',
bg_label:'Background result:',
bg_no_answer:'(no answer)',
bg_failed:'Background task failed: ',
undo_exchange:'Undo last exchange',
cmd_status:'Show session info',
cmd_voice:'Toggle microphone input',
stream_stopped:'Response stopped.',
@@ -199,7 +214,7 @@ const LOCALES = {
settings_label_language: 'Language',
settings_label_token_usage: 'Show token usage',
settings_label_sidebar_density: 'Sidebar density',
cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level',
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
settings_label_cli_sessions: 'Show agent sessions',
settings_label_sync_insights: 'Sync to insights',
settings_label_check_updates: 'Check for updates',
@@ -655,7 +670,7 @@ const LOCALES = {
settings_label_language: 'Язык',
settings_label_token_usage: 'Показывать использование токенов',
settings_label_sidebar_density: 'Плотность боковой панели',
cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level',
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
settings_label_cli_sessions: 'Показывать сеансы агента',
settings_label_sync_insights: 'Синхронизировать с Insights',
settings_label_check_updates: 'Проверять обновления',
@@ -1139,7 +1154,7 @@ const LOCALES = {
settings_label_language: 'Idioma',
settings_label_token_usage: 'Mostrar uso de tokens',
settings_label_sidebar_density: 'Densidad de la barra lateral',
cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level',
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
settings_label_cli_sessions: 'Mostrar sesiones de CLI',
settings_label_sync_insights: 'Sincronizar con insights',
settings_label_check_updates: 'Buscar actualizaciones',
@@ -1595,7 +1610,7 @@ const LOCALES = {
settings_label_language: 'Sprache',
settings_label_token_usage: 'Token-Verbrauch anzeigen',
settings_label_sidebar_density: 'Seitenleistendichte',
cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level',
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
settings_label_cli_sessions: 'Agent-Sitzungen anzeigen',
settings_label_sync_insights: 'Mit Insights synchronisieren',
settings_label_check_updates: 'Nach Updates suchen',
@@ -1852,7 +1867,7 @@ const LOCALES = {
settings_label_language: '\u8bed\u8a00',
settings_label_token_usage: '\u663e\u793a token \u7528\u91cf',
settings_label_sidebar_density: '侧边栏密度',
cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level',
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
settings_label_cli_sessions: '\u663e\u793a CLI \u4f1a\u8bdd',
settings_label_sync_insights: '\u540c\u6b65\u5230 insights',
settings_label_check_updates: '\u68c0\u67e5\u66f4\u65b0',
@@ -2292,7 +2307,7 @@ const LOCALES = {
settings_label_language: '\u8a9d\u8a00',
settings_label_token_usage: '\u986f\u793a token \u7528\u91cf',
settings_label_sidebar_density: '側邊欄密度',
cmd_reasoning: 'Toggle thinking block visibility (show/hide) or set effort level',
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
settings_label_cli_sessions: '\u986f\u793a CLI \u6703\u8a71',
settings_label_sync_insights: '\u540c\u6b65\u5230 insights',
settings_label_check_updates: '\u6aa2\u67e5\u66f4\u65b0',

View File

@@ -346,6 +346,21 @@
<span class="composer-workspace-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
</div>
<div class="composer-reasoning-wrap" id="composerReasoningWrap" style="display:none">
<button class="composer-reasoning-chip" id="composerReasoningChip" type="button" onclick="toggleReasoningDropdown()" title="Reasoning effort level">
<span class="composer-reasoning-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7z"/><line x1="9" y1="21" x2="15" y2="21"/></svg></span>
<span class="composer-reasoning-label" id="composerReasoningLabel"></span>
<span class="composer-reasoning-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="composer-reasoning-dropdown" id="composerReasoningDropdown">
<div class="reasoning-option" data-effort="none">None</div>
<div class="reasoning-option" data-effort="minimal">Minimal</div>
<div class="reasoning-option" data-effort="low">Low</div>
<div class="reasoning-option" data-effort="medium">Medium</div>
<div class="reasoning-option" data-effort="high">High</div>
<div class="reasoning-option" data-effort="xhigh">Extra High</div>
</div>
</div>
<div class="composer-model-wrap">
<button class="composer-model-chip" id="composerModelChip" type="button" onclick="toggleModelDropdown()" title="Conversation model">
<span class="composer-model-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span>
@@ -396,6 +411,7 @@
<button class="cancel-btn" id="btnCancel" onclick="cancelStream()" style="display:none" title="Stop generation" aria-label="Stop generation">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2"></rect></svg>
</button>
<span class="bg-badge" id="bgBadge" style="display:none" title="Background tasks running">0</span>
<button class="send-btn" id="btnSend" title="Send message" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
</button>

View File

@@ -1323,4 +1323,111 @@ function sendBrowserNotification(title,body){
}
}
// ── /btw ephemeral stream ────────────────────────────────────────────────────
// Connects to the ephemeral SSE stream from /api/btw and renders the answer
// in a visually distinct bubble that is NOT persisted to session history.
function attachBtwStream(parentSid, streamId, question){
if(!parentSid||!streamId) return;
const src=new EventSource('/api/stream?stream_id='+encodeURIComponent(streamId));
let answer='';
let btwRow=null;
function _ensureBtwRow(){
if(btwRow&&btwRow.isConnected) return;
const inner=$('msgInner');
if(!inner) return;
btwRow=document.createElement('div');
btwRow.className='msg-row msg-row-btw';
btwRow.dataset.role='assistant';
btwRow.dataset.btw='1';
const labelEl=document.createElement('div');
labelEl.className='msg-btw-label';
labelEl.textContent=t('btw_label');
const qEl=document.createElement('div');
qEl.className='msg-body';
qEl.textContent=question;
const ansEl=document.createElement('div');
ansEl.className='msg-body msg-btw-answer';
ansEl.textContent='...';
btwRow.appendChild(labelEl);
btwRow.appendChild(qEl);
btwRow.appendChild(ansEl);
inner.appendChild(btwRow);
btwRow.scrollIntoView({behavior:'smooth',block:'end'});
}
src.addEventListener('token',e=>{
try{answer+=JSON.parse(e.data).text||'';}catch(_){}
_ensureBtwRow();
const ansEl=btwRow&&btwRow.querySelector('.msg-btw-answer');
if(ansEl) ansEl.innerHTML=renderMd(answer);
});
src.addEventListener('done',e=>{
src.close();
try{
const d=JSON.parse(e.data);
if(d.answer&&!answer) answer=d.answer;
}catch(_){}
if(btwRow&&btwRow.isConnected){
const ansEl=btwRow.querySelector('.msg-btw-answer');
if(ansEl) ansEl.innerHTML=renderMd(answer||t('btw_no_answer'));
}
showToast(t('btw_done'));
});
src.addEventListener('apperror',e=>{
src.close();
try{
const d=JSON.parse(e.data);
showToast(t('btw_failed')+(d.message||''));
}catch(_){showToast(t('btw_failed'));}
if(btwRow&&btwRow.isConnected) btwRow.remove();
});
src.addEventListener('stream_end',()=>{src.close();});
src.onerror=()=>{src.close();if(btwRow&&btwRow.isConnected) btwRow.remove();};
}
// ── /background task tracking ────────────────────────────────────────────────
let _bgPollTimers={};
let _bgActiveTasks=new Set();
function showBackgroundBadge(taskId){
_bgActiveTasks.add(taskId);
const badge=$('bgBadge');
if(badge){
badge.textContent=String(_bgActiveTasks.size);
badge.style.display=_bgActiveTasks.size?'':'none';
}
}
function hideBackgroundBadge(taskId){
_bgActiveTasks.delete(taskId);
const badge=$('bgBadge');
if(badge){
badge.textContent=String(_bgActiveTasks.size);
badge.style.display=_bgActiveTasks.size?'':'none';
}
}
function startBackgroundPolling(parentSid, taskId, prompt){
if(_bgPollTimers[taskId]) return;
async function _poll(){
try{
const r=await api('/api/background/status?session_id='+encodeURIComponent(parentSid));
if(r&&r.results){
for(const res of r.results){
if(res.task_id===taskId){
hideBackgroundBadge(taskId);
delete _bgPollTimers[taskId];
const msg={role:'assistant',content:`**${t('bg_label')}** ${prompt.slice(0,80)}\n\n${res.answer||t('bg_no_answer')}`,'_background':true,_ts:Date.now()/1000};
S.messages.push(msg);
renderMessages();
showToast(t('bg_complete'));
return;
}
}
}
}catch(_){}
_bgPollTimers[taskId]=setTimeout(_poll,3000);
}
_poll();
}
// ── Panel navigation (Chat / Tasks / Skills / Memory) ──

View File

@@ -593,6 +593,17 @@
.composer-workspace-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);}
.composer-workspace-icon,.composer-workspace-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-workspace-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.composer-reasoning-wrap{position:relative;flex:0 1 auto;min-width:0;}
.composer-reasoning-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
.composer-reasoning-chip:hover{color:var(--text);background-color:var(--hover-bg);}
.composer-reasoning-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);}
.composer-reasoning-icon,.composer-reasoning-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-reasoning-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.composer-reasoning-dropdown{display:none;position:absolute;bottom:calc(100% + 6px);left:0;background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.15);min-width:140px;z-index:100;padding:4px;animation:dropdown-in .12s ease-out;}
.composer-reasoning-dropdown.open{display:block;}
.reasoning-option{padding:8px 14px;border-radius:6px;cursor:pointer;font-size:13px;color:var(--text);white-space:nowrap;transition:background-color .1s;}
.reasoning-option:hover{background:var(--hover-bg);}
.reasoning-option.selected{color:var(--accent);font-weight:600;}
.composer-model-wrap{position:relative;flex:0 1 auto;min-width:0;}
.composer-model-chip{display:inline-flex;align-items:center;gap:8px;max-width:220px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
.composer-model-chip:hover{color:var(--text);background-color:var(--hover-bg);}
@@ -769,12 +780,15 @@
.composer-profile-label,
.composer-workspace-label,
.composer-model-label,
.composer-reasoning-label,
.composer-profile-chevron,
.composer-workspace-chevron,
.composer-model-chevron{display:none;}
.composer-model-chevron,
.composer-reasoning-chevron{display:none;}
.composer-profile-chip,
.composer-workspace-chip,
.composer-model-chip{max-width:44px;min-width:44px;min-height:44px;padding:6px;justify-content:center;gap:0;font-size:11px;}
.composer-model-chip,
.composer-reasoning-chip{max-width:44px;min-width:44px;min-height:44px;padding:6px;justify-content:center;gap:0;font-size:11px;}
.composer-divider{display:none;}
.composer-status{max-width:96px;font-size:10px;}
.send-btn{width:32px;height:32px;}
@@ -1909,3 +1923,40 @@ body.resizing{user-select:none;cursor:col-resize;}
.assistant-turn .msg-foot{opacity:1;}
.msg-actions{opacity:1;}
}
/* ── /btw ephemeral side-question bubble ── */
.msg-row-btw {
border-left: 3px solid var(--accent-bg-strong);
border-radius: 8px;
background: var(--accent-bg);
margin: 6px 0 6px var(--msg-rail);
padding: 8px 12px;
}
.msg-row-btw .msg-body { font-size: 14px; line-height: 1.75; }
.msg-btw-label {
font-size: 11px;
font-style: italic;
color: var(--muted);
margin-bottom: 4px;
opacity: .8;
}
.msg-btw-answer {
margin-top: 6px;
border-top: 1px solid var(--accent-bg-strong);
padding-top: 6px;
}
/* ── /background badge in composer footer ── */
.bg-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--accent-bg-strong);
color: var(--accent-text);
font-size: 10px;
font-weight: 600;
}

View File

@@ -386,6 +386,7 @@ function toggleModelDropdown(){
if(open){closeModelDropdown(); return;}
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
if(typeof closeWsDropdown==='function') closeWsDropdown();
if(typeof closeReasoningDropdown==='function') closeReasoningDropdown();
renderModelDropdown();
dd.classList.add('open');
_positionModelDropdown();
@@ -407,6 +408,77 @@ window.addEventListener('resize',()=>{
if(dd&&dd.classList.contains('open')) _positionModelDropdown();
});
// ── Reasoning effort chip ────────────────────────────────────────────────────
let _currentReasoningEffort=null;
function _applyReasoningChip(eff){
_currentReasoningEffort=eff;
const wrap=$('composerReasoningWrap');
const label=$('composerReasoningLabel');
if(!wrap||!label) return;
if(!eff||eff==='none'){wrap.style.display='none';return;}
wrap.style.display='';
label.textContent='🧠 '+eff;
_highlightReasoningOption(eff);
}
function fetchReasoningChip(){
api('/api/reasoning').then(function(st){
_applyReasoningChip((st&&st.reasoning_effort)||'');
}).catch(function(){_applyReasoningChip('');});
}
function syncReasoningChip(){
if(_currentReasoningEffort===null){fetchReasoningChip();return;}
_applyReasoningChip(_currentReasoningEffort);
}
function _highlightReasoningOption(effort){
const dd=$('composerReasoningDropdown');
if(!dd) return;
dd.querySelectorAll('.reasoning-option').forEach(function(opt){
opt.classList.toggle('selected',opt.dataset.effort===effort);
});
}
function toggleReasoningDropdown(){
const dd=$('composerReasoningDropdown');
const chip=$('composerReasoningChip');
if(!dd||!chip) return;
const open=dd.classList.contains('open');
if(open){closeReasoningDropdown();return;}
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
if(typeof closeWsDropdown==='function') closeWsDropdown();
closeModelDropdown();
_highlightReasoningOption(_currentReasoningEffort);
dd.classList.add('open');
chip.classList.add('active');
}
function closeReasoningDropdown(){
const dd=$('composerReasoningDropdown');
const chip=$('composerReasoningChip');
if(dd) dd.classList.remove('open');
if(chip) chip.classList.remove('active');
}
document.addEventListener('click',function(e){
if(!e.target.closest('#composerReasoningChip')&&!e.target.closest('#composerReasoningDropdown')) closeReasoningDropdown();
if(e.target.closest('.reasoning-option')){
const opt=e.target.closest('.reasoning-option');
const effort=opt&&opt.dataset.effort;
if(effort){
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:effort})})
.then(function(st){
_applyReasoningChip((st&&st.reasoning_effort)||effort);
showToast('🧠 Reasoning effort set to '+((st&&st.reasoning_effort)||effort));
})
.catch(function(){showToast('🧠 Failed to set effort');});
closeReasoningDropdown();
}
}
});
// ── Scroll pinning ──────────────────────────────────────────────────────────
// When streaming, auto-scroll only if the user hasn't manually scrolled up.
// Once the user scrolls back to within 150px of the bottom, re-pin.
@@ -1330,6 +1402,7 @@ function syncTopbar(){
}
}
if(typeof syncModelChip==='function') syncModelChip();
if(typeof syncReasoningChip==='function') syncReasoningChip();
// Show Clear button only when session has messages
const clearBtn=$('btnClearConv');
if(clearBtn) clearBtn.style.display=(S.messages&&S.messages.filter(msg=>msg.role!=='tool').length>0)?'':'none';
@@ -1693,6 +1766,7 @@ function renderMessages(){
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(_stripXmlToolCallsDisplay(String(content)));
const isEditableUser=isUser&&rawIdx===lastUserRawIdx;
const editBtn = isEditableUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">${li('pencil',13)}</button>` : '';
const undoBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('undo_exchange')}" onclick="undoLastExchange()">${li('undo-2',13)}</button>` : '';
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">${li('rotate-ccw',13)}</button>` : '';
const copyBtn = `<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>`;
const tsVal=m._ts||m.timestamp;