Nightly Build / build (push) Successful in 7m39s
Reopening the app closed every project tab: the copilot's tab bar lived in
RAM, so a reload dropped it and each conversation had to be found again from
its project board.
The set of open tabs is now a column on the session row, `chat_sessions.is_open`
(additive, `ensure_column`), restored by `GET /api/sessions/open` and written by
`PUT /api/sessions/{id}/open`. Not localStorage: that store is per-origin, so on
a shared laptop one member's tabs would greet the next, whereas the owner table
sits in their own encrypted file and follows them to another device. Which tab
is *selected* stays in sessionStorage — that one is genuinely per window, and a
shared value would have two windows fighting over it.
`is_open` defaults to 0 and `chat_sessions::create` never sets it: every `/new`
leaves its predecessor behind and every system-agent pass mints a row, so the
opposite default would restore a bar full of conversations nobody opened. Only
the copilot writes the column, at the moment it opens the tab. A reset moves the
flag rather than copying it — `POST /api/sessions` now returns the new id and
`new_session` carries it, and the old row is closed as the new one opens, or the
source would restore twice and a later close would clear the stale row.
Closing a tab clears the flag and nothing else: the conversation is kept and
comes back with its history when the project is reopened.
1409 lines
57 KiB
JavaScript
1409 lines
57 KiB
JavaScript
import { html, nothing } from 'lit';
|
||
import { LightElement } from './base.js';
|
||
import { InboxCardsMixin } from './inbox-cards.js';
|
||
import { t } from './i18n.js';
|
||
import { isSessionExpired, notifySessionExpired, probeSession } from './session-expiry.js';
|
||
|
||
// Slash commands handled entirely server-side: they reply with a `Done` and never
|
||
// echo back as a `user_message`, so they are the only commands rendered
|
||
// optimistically on send. Everything else — custom commands and unknown ones — is
|
||
// telnet-style: the bubble appears only when the backend persists it and re-emits a
|
||
// `user_message` (custom commands carry their typed form as the echoed content).
|
||
// (`/new` and `/clear` are intercepted earlier and never reach the echo.)
|
||
const SYSTEM_SLASH_COMMANDS = new Set([
|
||
'/help', '/models', '/model', '/context', '/cost',
|
||
'/compact', '/resettools', '/sethome',
|
||
]);
|
||
|
||
// Pixels from the bottom within which auto-scroll still "sticks". Scrolling
|
||
// further up during streaming makes auto-scroll yield so the reader is left in
|
||
// peace; scrolling back within this band re-arms it.
|
||
const SCROLL_STICKY_PX = 80;
|
||
|
||
/**
|
||
* Base class for chat UI components (desktop copilot, mobile chat page).
|
||
*
|
||
* Contains all WebSocket logic, message state, and approval handling.
|
||
* Subclasses implement the render() method and override the DOM hooks:
|
||
* - _messagesContainer() → returns the scrollable message-list element
|
||
* - _getInputContent() → returns current input value
|
||
* - _clearInput() → empties the input
|
||
* - _onMessagePushed(item) → called after each push (scroll, focus, etc.)
|
||
*
|
||
* Auto-scroll is stick-to-bottom: it follows the stream only while the reader is
|
||
* within SCROLL_STICKY_PX of the bottom. Scrolling up to read pauses it, and a
|
||
* "jump to latest" affordance (driven by the `_showJump` state) is shown then.
|
||
*/
|
||
export class ChatSession extends InboxCardsMixin(LightElement) {
|
||
static properties = {
|
||
_messages: { state: true },
|
||
_waiting: { state: true },
|
||
_expanded: { state: true },
|
||
_providers: { state: true },
|
||
_selectedClient: { state: true },
|
||
_providersLoaded: { state: true },
|
||
// Session security-group (permission group) picker — the twin of the model
|
||
// pill. `_securityGroups` is the caller's selectable set; `_selectedGroup` is
|
||
// the session's current group (backend is the source of truth).
|
||
_securityGroups: { state: true },
|
||
_selectedGroup: { state: true },
|
||
_rejectingId: { state: true },
|
||
_rejectNote: { state: true },
|
||
_clarificationAnswer: { state: true },
|
||
// Voice recording state (shared by every chat surface).
|
||
_hasTranscribe: { state: true },
|
||
_recording: { state: true },
|
||
// Whether to show the "jump to latest" affordance: true only while the reader
|
||
// is scrolled away from the bottom (i.e. auto-scroll has yielded). Driven by
|
||
// the stickiness sensor in `_scrollToBottom`.
|
||
_showJump: { state: true },
|
||
// Pending attachments for the message being composed (shown as chips above
|
||
// the textarea; uploaded to disk on selection, sent with the next message).
|
||
_attachments: { state: true },
|
||
// Background tasks (`execute_task mode=async`) this conversation started.
|
||
// Each entry: { job_id, title, agent_id, session_id, state, error, started_at }.
|
||
_tasks: { state: true },
|
||
// Bumped once a second while a task is running, so the elapsed counters move.
|
||
_taskTick: { state: true },
|
||
// Approvals and questions raised by those tasks: `{ approvals, clarifications }`,
|
||
// each item carrying the `job_id` / `job_title` that asked.
|
||
_taskInbox: { state: true },
|
||
};
|
||
|
||
// Live events whose arrival implies a turn is in flight (used to restore the
|
||
// STOP button when reconnecting mid-turn).
|
||
static _STREAMING_EVENTS = new Set([
|
||
'thinking', 'tool_start', 'agent_start', 'pending_write', 'approval_required',
|
||
'token_delta',
|
||
]);
|
||
|
||
constructor() {
|
||
super();
|
||
this._messages = [];
|
||
this._waiting = false;
|
||
this._expanded = new Set();
|
||
this._ws = null;
|
||
// True only for an auto-reconnect after an unexpected socket close (set in
|
||
// `onclose`), so the next `onopen` reconciles tool state that may have advanced
|
||
// while we were disconnected. A deliberate teardown (source switch / new session)
|
||
// nulls `onclose` first, so it never sets this.
|
||
this._reconnecting = false;
|
||
this._providers = [];
|
||
this._selectedClient = null;
|
||
this._providersLoaded = false;
|
||
this._securityGroups = [];
|
||
this._selectedGroup = 'default';
|
||
this._rejectingId = null;
|
||
this._rejectNote = '';
|
||
this._clarificationAnswer = '';
|
||
// Runtime-selected source. When null, falls back to the static `_wsSource`.
|
||
// Lets a single chat component switch between sessions (e.g. copilot tabs).
|
||
this._activeSource = null;
|
||
// Voice recording state. Shared so every surface (desktop copilot + mobile
|
||
// chat) can expose the same mic button. The desktop-only Ctrl+Space push-
|
||
// to-talk shortcut is wired in `app-copilot`; `_shortcutRecording` tracks
|
||
// whether a recording session was started by that shortcut.
|
||
this._hasTranscribe = false;
|
||
this._recording = false;
|
||
this._shortcutRecording = false;
|
||
this._mediaRecorder = null;
|
||
this._audioChunks = [];
|
||
// Stick-to-bottom auto-scroll: `_stickToBottom` is `true` while the reader is
|
||
// within SCROLL_STICKY_PX of the bottom. A manual scroll-up flips it false so
|
||
// live streaming stops fighting the reader; scrolling back within the band
|
||
// re-arms it. `_scrollEl` tracks the bound container so the scroll sensor is
|
||
// attached exactly once (and re-attached if the element is recreated).
|
||
this._stickToBottom = true;
|
||
this._scrollEl = null;
|
||
this._showJump = false;
|
||
// Each entry: { name, path, mimetype, filesize, uploading? }. While an upload
|
||
// is in flight the entry has `uploading: true` and no `path` yet.
|
||
this._attachments = [];
|
||
this._tasks = [];
|
||
this._taskInbox = { approvals: [], clarifications: [] };
|
||
this._taskTick = 0;
|
||
this._taskTimer = null;
|
||
// Timers that drop a finished task from the strip after a grace period.
|
||
this._taskDropTimers = new Map();
|
||
this._onAuthRestored = this._onAuthRestored.bind(this);
|
||
}
|
||
|
||
async connectedCallback() {
|
||
super.connectedCallback();
|
||
window.addEventListener('auth-restored', this._onAuthRestored);
|
||
// Fire-and-forget: availability of a transcription provider determines
|
||
// whether the mic button is rendered at all.
|
||
this._checkTranscribe();
|
||
await Promise.all([
|
||
this._loadProviders(), this._loadHistory(), this._loadTasks(), this._loadTaskInbox(),
|
||
]);
|
||
this._connectWS();
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
super.disconnectedCallback?.();
|
||
window.removeEventListener('auth-restored', this._onAuthRestored);
|
||
this._stopTaskClock();
|
||
for (const timer of this._taskDropTimers.values()) clearTimeout(timer);
|
||
this._taskDropTimers.clear();
|
||
}
|
||
|
||
// ── Source identity — override in subclass ────────────────────────────────────
|
||
|
||
// Static default source for this component. Subclasses override (e.g. 'mobile').
|
||
get _wsSource() { return 'web'; }
|
||
|
||
// Effective source: the runtime-selected one, or the static default.
|
||
get _source() { return this._activeSource ?? this._wsSource; }
|
||
|
||
/**
|
||
* Switch the live connection to a different source: tear down the current WS,
|
||
* swap source, reload that source's history, and reconnect. Used to move
|
||
* between sessions (e.g. General ↔ a project chat) without remounting.
|
||
*/
|
||
async _switchSource(source) {
|
||
if (this._ws) { this._ws.onclose = null; this._ws.close(); this._ws = null; }
|
||
this._activeSource = source;
|
||
this._messages = [];
|
||
this._waiting = false;
|
||
this._tasks = [];
|
||
this._taskInbox = { approvals: [], clarifications: [] };
|
||
await Promise.all([this._loadHistory(), this._loadTasks(), this._loadTaskInbox()]);
|
||
this._connectWS();
|
||
}
|
||
|
||
// ── Data loading ──────────────────────────────────────────────────────────────
|
||
|
||
async _loadProviders() {
|
||
try {
|
||
const res = await fetch('/api/llm/models/selector');
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const { models, default: def } = await res.json();
|
||
this._providers = models;
|
||
this._selectedClient = def;
|
||
} catch (e) {
|
||
console.error('Failed to load LLM models:', e);
|
||
} finally {
|
||
this._providersLoaded = true;
|
||
}
|
||
}
|
||
|
||
get _noModels() {
|
||
return this._providersLoaded
|
||
&& this._providers.filter(p => p !== 'auto').length === 0;
|
||
}
|
||
|
||
_renderNoModelsBanner() {
|
||
if (!this._noModels) return nothing;
|
||
return html`
|
||
<div class="home-banner home-banner--error chat-no-models">
|
||
<div class="home-banner-icon"><i class="bi bi-cpu-fill"></i></div>
|
||
<div class="home-banner-body">
|
||
<strong>${t('dashboard.banner.no_models.title')}</strong>
|
||
${t('dashboard.banner.no_models.desc')}
|
||
<a href="#llm-providers">${t('dashboard.banner.no_models.action')}</a>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async _loadHistory() {
|
||
try {
|
||
const res = await fetch(`/api/${this._source}/messages`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const items = await res.json();
|
||
if (items.length > 0) {
|
||
this._messages = items;
|
||
const expanded = new Set(this._expanded);
|
||
for (const m of items) {
|
||
if (m.kind === 'tool' && m.status === 'pending') expanded.add(m.tool_call_id);
|
||
}
|
||
this._expanded = expanded;
|
||
this._forceScrollToBottom();
|
||
// Set flag so ws.onopen sends a resume if there are pending tools
|
||
// (approval/clarification waiting) or interrupted tools (status=error+Interrupted).
|
||
this._hasPendingTools = items.some(
|
||
m => m.kind === 'tool' && (m.status === 'pending' || (m.status === 'error' && m.error === 'Interrupted.'))
|
||
);
|
||
}
|
||
} catch (e) {
|
||
console.warn('Could not load history:', e.message);
|
||
}
|
||
}
|
||
|
||
// ── Background tasks ──────────────────────────────────────────────────────────
|
||
//
|
||
// The agent can hand work to a background task (`execute_task mode="async"`)
|
||
// and keep talking. Until now the only trace of one was the receipt in the
|
||
// transcript and a row on the Tasks page, so "is it still going?" had no
|
||
// answer in the chat itself. `_tasks` is that answer: a small live list of
|
||
// the tasks *this* conversation started.
|
||
//
|
||
// Live updates arrive as `task_update` over the WebSocket — a broadcast with
|
||
// no replay, which is why `_loadTasks` runs on every load and reconnect: it
|
||
// is what makes the strip survive a browser refresh.
|
||
|
||
// A finished task lingers this long before disappearing on its own. Its result
|
||
// is already in the conversation by then; the chip is just the hand-off.
|
||
static TASK_DROP_MS = 20000;
|
||
|
||
/**
|
||
* Whether this surface can open a task's own session page (`#session/{id}`).
|
||
* The mobile shell routes a fixed set of sections and silently falls back to
|
||
* the chat for anything else, so there the row is shown without a link rather
|
||
* than with one that quietly navigates somewhere wrong.
|
||
*/
|
||
get _canOpenTaskSession() { return false; }
|
||
|
||
// Failures the user has dismissed with the ✕. Kept across reloads (the server
|
||
// keeps reporting a recent failure, and dismissing it should mean dismissed).
|
||
static _DISMISSED_KEY = 'skald.dismissedTasks';
|
||
|
||
_dismissedTasks() {
|
||
try {
|
||
return new Set(JSON.parse(localStorage.getItem(ChatSession._DISMISSED_KEY) ?? '[]'));
|
||
} catch { return new Set(); }
|
||
}
|
||
|
||
_rememberDismissed(jobId) {
|
||
const ids = [...this._dismissedTasks(), jobId].slice(-50);
|
||
try { localStorage.setItem(ChatSession._DISMISSED_KEY, JSON.stringify(ids)); } catch { /* private mode */ }
|
||
}
|
||
|
||
async _loadTasks() {
|
||
try {
|
||
const res = await fetch(`/api/${this._source}/tasks`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const dismissed = this._dismissedTasks();
|
||
this._tasks = (await res.json()).filter(t => !dismissed.has(t.job_id));
|
||
this._syncTaskClock();
|
||
} catch (e) {
|
||
console.warn('Could not load background tasks:', e.message);
|
||
}
|
||
}
|
||
|
||
/** Insert or advance one task from a `task_update` event. */
|
||
_upsertTask(task) {
|
||
if (this._dismissedTasks().has(task.job_id)) return;
|
||
const idx = this._tasks.findIndex(t => t.job_id === task.job_id);
|
||
const prev = idx >= 0 ? this._tasks[idx] : null;
|
||
// Keep the fields the event does not carry (a terminal update has no
|
||
// `started_at`, and the elapsed counter should not reset at the finish line).
|
||
const next = { ...prev, ...task, started_at: task.started_at ?? prev?.started_at ?? null };
|
||
this._tasks = idx >= 0
|
||
? this._tasks.map((t, i) => (i === idx ? next : t))
|
||
: [...this._tasks, next];
|
||
|
||
// A failure stays until dismissed — it is the only place the reason is
|
||
// readable at a glance. Everything else clears itself.
|
||
if (next.state === 'completed' || next.state === 'cancelled') {
|
||
this._scheduleTaskDrop(next.job_id);
|
||
}
|
||
this._syncTaskClock();
|
||
}
|
||
|
||
_scheduleTaskDrop(jobId) {
|
||
clearTimeout(this._taskDropTimers.get(jobId));
|
||
this._taskDropTimers.set(jobId, setTimeout(() => {
|
||
this._taskDropTimers.delete(jobId);
|
||
this._tasks = this._tasks.filter(t => t.job_id !== jobId);
|
||
this._syncTaskClock();
|
||
}, ChatSession.TASK_DROP_MS));
|
||
}
|
||
|
||
_dismissTask(jobId) {
|
||
this._rememberDismissed(jobId);
|
||
clearTimeout(this._taskDropTimers.get(jobId));
|
||
this._taskDropTimers.delete(jobId);
|
||
this._tasks = this._tasks.filter(t => t.job_id !== jobId);
|
||
this._syncTaskClock();
|
||
}
|
||
|
||
/** Stop a running task. The kill lands as a `task_update` like any other end. */
|
||
async _stopTask(jobId) {
|
||
try {
|
||
const res = await fetch(`/api/cron/jobs/${jobId}/kill`, { method: 'POST' });
|
||
if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
|
||
} catch (e) {
|
||
console.warn('Could not stop task:', e.message);
|
||
}
|
||
}
|
||
|
||
/** The 1 s clock runs only while something is actually running. */
|
||
_syncTaskClock() {
|
||
const running = this._tasks.some(t => t.state === 'running');
|
||
if (running && !this._taskTimer) {
|
||
this._taskTimer = setInterval(() => { this._taskTick++; }, 1000);
|
||
} else if (!running) {
|
||
this._stopTaskClock();
|
||
}
|
||
}
|
||
|
||
_stopTaskClock() {
|
||
clearInterval(this._taskTimer);
|
||
this._taskTimer = null;
|
||
}
|
||
|
||
// ── What those tasks are waiting on ───────────────────────────────────────────
|
||
//
|
||
// An async sub-agent runs in a session of its own, so the rich per-session
|
||
// events that draw the inline approval card (`approval_required`,
|
||
// `agent_question`) never reach this socket — only the id-only inbox lifecycle
|
||
// events do, which is why this is a fetch and not an event payload. The chat
|
||
// renders the first pending item above the task strip: outside the transcript,
|
||
// in a fixed position, so the conversation stays readable underneath it.
|
||
//
|
||
// The queue needs no state of its own. Resolving an item broadcasts
|
||
// `approval_resolved` / `clarification_resolved`, this list is re-read, and the
|
||
// next item becomes the first.
|
||
|
||
async _loadTaskInbox() {
|
||
try {
|
||
const res = await fetch(`/api/${this._source}/inbox`);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const data = await res.json();
|
||
this._taskInbox = {
|
||
approvals: data.approvals ?? [],
|
||
clarifications: data.clarifications ?? [],
|
||
};
|
||
} catch (e) {
|
||
console.warn('Could not load background-task inbox:', e.message);
|
||
}
|
||
}
|
||
|
||
// Cards the user has waved away with the ✕. Dismissing is *not* answering:
|
||
// the item stays pending, the task stays blocked, and the Inbox stays the
|
||
// place to deal with it — this only says "not in front of my chat". Persisted
|
||
// for the same reason the strip persists dismissed tasks: the endpoint keeps
|
||
// reporting the item until it is resolved, so an in-memory set would put the
|
||
// card back on the next reload.
|
||
static _DISMISSED_ASKS_KEY = 'skald.dismissedAsks';
|
||
|
||
/** Identity for dismissal. Post-restart items carry `request_id: 0`, so they
|
||
* key on the durable `tool_call_id` instead — otherwise every one of them
|
||
* would share a single key and dismissing one would hide them all. */
|
||
static askKey(item) {
|
||
return item.request_id
|
||
? `${item.kind}-${item.request_id}`
|
||
: `${item.kind}-tc${item.tool_call_id}`;
|
||
}
|
||
|
||
_dismissedAsks() {
|
||
try {
|
||
return new Set(JSON.parse(localStorage.getItem(ChatSession._DISMISSED_ASKS_KEY) ?? '[]'));
|
||
} catch { return new Set(); }
|
||
}
|
||
|
||
_dismissAsk(item) {
|
||
const keys = [...this._dismissedAsks(), ChatSession.askKey(item)].slice(-50);
|
||
try { localStorage.setItem(ChatSession._DISMISSED_ASKS_KEY, JSON.stringify(keys)); } catch { /* private mode */ }
|
||
this.requestUpdate();
|
||
}
|
||
|
||
/** Approvals first: a blocked tool call is more urgent than an open question. */
|
||
get _taskPendingAll() {
|
||
return [
|
||
...this._taskInbox.approvals.map(x => ({ ...x, kind: 'approval' })),
|
||
...this._taskInbox.clarifications.map(x => ({ ...x, kind: 'clarification' })),
|
||
];
|
||
}
|
||
|
||
/** What the chat still offers to show. */
|
||
get _taskPending() {
|
||
const dismissed = this._dismissedAsks();
|
||
return this._taskPendingAll.filter(x => !dismissed.has(ChatSession.askKey(x)));
|
||
}
|
||
|
||
/** Waved away but still waiting — the strip keeps a pointer to the Inbox for these. */
|
||
get _taskPendingHidden() {
|
||
return this._taskPendingAll.length - this._taskPending.length;
|
||
}
|
||
|
||
/** `InboxCardsMixin` hook: a resolved item leaves this list, and may reveal the next. */
|
||
async _afterInboxResolve() {
|
||
await this._loadTaskInbox();
|
||
}
|
||
|
||
// ── WebSocket ─────────────────────────────────────────────────────────────────
|
||
|
||
_connectWS() {
|
||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||
const ws = new WebSocket(`${proto}://${location.host}/api/ws?source=${this._source}`);
|
||
this._ws = ws;
|
||
ws.onopen = () => {
|
||
// After an auto-reconnect, reconcile tool state: a terminal event
|
||
// (tool_done / tool_error) delivered while the socket was down is lost —
|
||
// the server bus is a broadcast with no replay — so a card could otherwise
|
||
// stay 'running' forever until a manual reload. Re-fetch history and advance
|
||
// any locally-unfinished card that has since reached a terminal state.
|
||
if (this._reconnecting) {
|
||
this._reconnecting = false;
|
||
this._resyncOnReconnect();
|
||
// A task that ended while the socket was down emitted its `task_update`
|
||
// into the void: re-read the authoritative list — and with it whatever
|
||
// those tasks started asking for in the meantime.
|
||
this._loadTasks();
|
||
this._loadTaskInbox();
|
||
}
|
||
if (this._hasPendingTools) {
|
||
ws.send(JSON.stringify({ type: 'resume' }));
|
||
this._hasPendingTools = false;
|
||
}
|
||
};
|
||
ws.onmessage = (ev) => this._handleServerMsg(JSON.parse(ev.data));
|
||
ws.onclose = () => { this._reconnecting = true; this._scheduleReconnect(); };
|
||
}
|
||
|
||
/**
|
||
* Reconnect after an unexpected close — unless we were dropped because the
|
||
* server no longer knows this browser. Sessions live in the server's RAM
|
||
* (blueprint §9), so a restart refuses the WS upgrade, and a refused upgrade
|
||
* reaches `onclose` looking exactly like a flaky network: the loop used to
|
||
* retry every 2 s forever behind "Not connected", against a server that would
|
||
* never accept it again. So ask first, and let the re-login dialog take it
|
||
* from there — the socket comes back on `auth-restored`.
|
||
*/
|
||
async _scheduleReconnect() {
|
||
if (isSessionExpired()) return; // the dialog is already up
|
||
if ((await probeSession()) === 'expired') {
|
||
notifySessionExpired();
|
||
// A shell that handles auth itself (native mobile) ignores the report;
|
||
// there is no dialog coming, so keep retrying as before.
|
||
if (isSessionExpired()) return;
|
||
}
|
||
setTimeout(() => this._connectWS(), 2000);
|
||
}
|
||
|
||
/**
|
||
* A new session was obtained without leaving the page: reconnect and reconcile
|
||
* like any other unexpected disconnection (the `_reconnecting` flag is what
|
||
* makes `onopen` re-sync tool state that advanced while we were away).
|
||
*/
|
||
_onAuthRestored() {
|
||
if (this._ws && this._ws.readyState !== WebSocket.CLOSED) return;
|
||
this._reconnecting = true;
|
||
this._connectWS();
|
||
}
|
||
|
||
/**
|
||
* Reconcile tool cards after an unexpected reconnect. Re-fetches the server's
|
||
* message history and advances any locally-unfinished tool card (`running` or
|
||
* `pending`) whose server row has reached a **terminal** state while the socket
|
||
* was down. Only ever moves a card *forward* to a terminal state: a tool still
|
||
* executing reads as `pending`/Interrupted in history and is deliberately left
|
||
* untouched, so this never re-shows a spinner or an approval form for live work.
|
||
*/
|
||
async _resyncOnReconnect() {
|
||
let items;
|
||
try {
|
||
const res = await fetch(`/api/${this._source}/messages`);
|
||
if (!res.ok) return;
|
||
items = await res.json();
|
||
} catch { return; }
|
||
// Terminal from the history projection: 'done', or a genuine 'error' (a tool
|
||
// that was merely interrupted mid-run surfaces as error 'Interrupted.' and is
|
||
// NOT terminal — it may still be executing).
|
||
const isTerminal = (it) =>
|
||
it.kind === 'tool' &&
|
||
(it.status === 'done' || (it.status === 'error' && it.error !== 'Interrupted.'));
|
||
for (const it of items) {
|
||
if (!isTerminal(it)) continue;
|
||
const local = this._messages.find(
|
||
m => m.kind === 'tool' && m.tool_call_id === it.tool_call_id
|
||
);
|
||
if (!local || (local.status !== 'running' && local.status !== 'pending')) continue;
|
||
this._updateTool(it.tool_call_id, {
|
||
status: it.status,
|
||
result: it.result,
|
||
result_type: it.result_type,
|
||
error: it.error,
|
||
request_id: null,
|
||
});
|
||
// Collapse a resolved approval form.
|
||
const expanded = new Set(this._expanded);
|
||
expanded.delete(it.tool_call_id);
|
||
this._expanded = expanded;
|
||
}
|
||
}
|
||
|
||
async _startNewSession() {
|
||
if (this._ws) {
|
||
this._ws.onclose = null;
|
||
this._ws.close();
|
||
this._ws = null;
|
||
}
|
||
this._cancelStreamFlush();
|
||
this._messages = [];
|
||
this._waiting = false;
|
||
// Tasks belong to the conversation that started them; this is a new one.
|
||
this._tasks = [];
|
||
this._stopTaskClock();
|
||
try {
|
||
const res = await fetch(`/api/sessions?source=${this._source}`, { method: 'POST' });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const { session_id } = await res.json();
|
||
if (session_id) this._onSessionReplaced(this._source, session_id);
|
||
} catch (e) {
|
||
this._pushError('Could not clear session: ' + e.message);
|
||
}
|
||
this._connectWS();
|
||
}
|
||
|
||
/**
|
||
* A reset replaced this source's session with a fresh one. Anything anchored to
|
||
* the session id (the copilot's tab bar) has to follow it across. No-op here.
|
||
*/
|
||
_onSessionReplaced(_source, _sessionId) {}
|
||
|
||
// ── Message handling ──────────────────────────────────────────────────────────
|
||
|
||
_handleServerMsg(msg) {
|
||
console.debug('[WS ←]', msg.type, msg);
|
||
// Receiving a live streaming event means a turn is active — restore the STOP
|
||
// button even if we reconnected mid-turn and missed the start. `done`/`error`
|
||
// reset it below.
|
||
if (!this._waiting && ChatSession._STREAMING_EVENTS.has(msg.type)) {
|
||
this._waiting = true;
|
||
}
|
||
switch (msg.type) {
|
||
// Sent on (re)connect: authoritative running state for this session.
|
||
case 'turn_running':
|
||
this._waiting = msg.running;
|
||
break;
|
||
|
||
case 'pending_write':
|
||
this._push({
|
||
kind: 'pending_write',
|
||
request_id: msg.request_id,
|
||
tool_call_id: msg.tool_call_id,
|
||
path: msg.path,
|
||
old_content: msg.old_content ?? '',
|
||
new_content: msg.new_content,
|
||
status: 'pending',
|
||
});
|
||
break;
|
||
|
||
case 'thinking': {
|
||
// A tool-call round's text. When the round streamed, its pending bubble
|
||
// becomes the thinking item in place. Reasoning comes from the event
|
||
// (buffered providers) or the streamed accumulation.
|
||
const last = this._messages[this._messages.length - 1];
|
||
const streaming = (last?.kind === 'assistant' && last.streaming) ? last : null;
|
||
const item = { kind: 'thinking', message_id: msg.message_id, content: msg.content,
|
||
reasoning: msg.reasoning_content ?? streaming?.reasoning ?? null,
|
||
input_tokens: msg.input_tokens, output_tokens: msg.output_tokens };
|
||
if (streaming) this._replaceLast(item); else this._push(item);
|
||
break;
|
||
}
|
||
|
||
case 'token_delta': {
|
||
// Best-effort live tokens. Accumulate into a pending assistant bubble;
|
||
// the final `done` (or `thinking`) event replaces it with authoritative
|
||
// content. Mutate in place + throttled flush: deltas can arrive at a
|
||
// high rate and a full Lit update per token would be wasteful.
|
||
let last = this._messages[this._messages.length - 1];
|
||
if (last?.kind !== 'assistant' || !last.streaming) {
|
||
last = { kind: 'assistant', content: '', reasoning: '', streaming: true };
|
||
this._messages = [...this._messages, last];
|
||
this._onMessagePushed(last);
|
||
}
|
||
if (msg.kind === 'reasoning') last.reasoning += msg.delta;
|
||
else last.content += msg.delta;
|
||
this._scheduleStreamFlush();
|
||
break;
|
||
}
|
||
|
||
case 'done': {
|
||
this._waiting = false;
|
||
const last = this._messages[this._messages.length - 1];
|
||
if (last?.kind === 'assistant' && last.streaming) {
|
||
// Finalize the streamed bubble with the authoritative content.
|
||
this._replaceLast({ kind: 'assistant', content: msg.content,
|
||
reasoning: msg.reasoning_content ?? last.reasoning ?? null,
|
||
input_tokens: msg.input_tokens, output_tokens: msg.output_tokens });
|
||
} else {
|
||
this._push({ kind: 'assistant', content: msg.content,
|
||
reasoning: msg.reasoning_content ?? null,
|
||
input_tokens: msg.input_tokens, output_tokens: msg.output_tokens });
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'tool_start': {
|
||
// A round with tool calls but no Thinking event (no usage/text) leaves a
|
||
// reasoning-only streaming bubble behind: finalize it in place so its
|
||
// content isn't swallowed by the next round's deltas.
|
||
const last = this._messages[this._messages.length - 1];
|
||
if (last?.kind === 'assistant' && last.streaming) {
|
||
this._replaceLast({ kind: 'thinking', content: last.content,
|
||
reasoning: last.reasoning || null,
|
||
input_tokens: null, output_tokens: null });
|
||
}
|
||
// On resume, the server re-emits ToolStart for tools already in history.
|
||
// Update in place rather than pushing a duplicate card.
|
||
const existingIdx = this._messages.findIndex(
|
||
m => (m.kind === 'tool') && m.tool_call_id === msg.tool_call_id
|
||
);
|
||
if (existingIdx >= 0) {
|
||
this._updateTool(msg.tool_call_id, { status: 'running', result: null, error: null });
|
||
} else {
|
||
this._push({
|
||
kind: 'tool',
|
||
tool_call_id: msg.tool_call_id,
|
||
name: msg.name,
|
||
display_name: msg.display_name,
|
||
icon: msg.icon,
|
||
label_short: msg.label_short,
|
||
label_full: msg.label_full,
|
||
path: msg.path,
|
||
arguments: msg.arguments,
|
||
status: 'running',
|
||
result: null,
|
||
error: null,
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'tool_done':
|
||
// `preview_old`/`preview_new` are present only for a file-write; they let the
|
||
// card render the diff inline even for an auto-allowed write (no PendingWrite).
|
||
this._updateTool(msg.tool_call_id, {
|
||
status: 'done', result: msg.result, result_type: msg.result_type,
|
||
preview_old: msg.preview_old, preview_new: msg.preview_new,
|
||
});
|
||
break;
|
||
|
||
case 'tool_error':
|
||
this._updateTool(msg.tool_call_id, { status: 'error', error: msg.error });
|
||
break;
|
||
|
||
case 'tool_cancelled':
|
||
// Stopped by the user via /stop — distinct from an error.
|
||
this._updateTool(msg.tool_call_id, { status: 'cancelled' });
|
||
break;
|
||
|
||
case 'tool_rejected':
|
||
// Denied by an approval policy or a human — distinct from an error.
|
||
this._updateTool(msg.tool_call_id, { status: 'rejected', error: msg.reason });
|
||
break;
|
||
|
||
case 'approval_required':
|
||
this._updateTool(msg.tool_call_id, { status: 'pending', request_id: msg.request_id });
|
||
this._expanded = new Set([...this._expanded, msg.tool_call_id]);
|
||
this._forceScrollToBottom();
|
||
break;
|
||
|
||
case 'approval_resolved': {
|
||
const { request_id, tool_call_id, approved } = msg;
|
||
window.dispatchEvent(new CustomEvent('inbox-changed'));
|
||
// Resolved elsewhere (the Inbox page, the phone) or by us — either way
|
||
// the card above the strip has to go, and the next one to appear.
|
||
this._loadTaskInbox();
|
||
this._updatePendingWrite(request_id, { status: approved ? 'approved' : 'rejected' });
|
||
if (tool_call_id != null) {
|
||
if (approved) {
|
||
this._updateTool(tool_call_id, { status: 'running', request_id: null });
|
||
} else {
|
||
this._updateTool(tool_call_id, { status: 'rejected', error: t('chat.rejected') });
|
||
}
|
||
const expanded = new Set(this._expanded);
|
||
expanded.delete(tool_call_id);
|
||
this._expanded = expanded;
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'approval_requested':
|
||
case 'clarification_requested':
|
||
case 'clarification_resolved':
|
||
// Inbox lifecycle from any of this user's sessions (chat, cron,
|
||
// background): nudge listeners (sidebar badge, inbox page) to refresh
|
||
// immediately instead of waiting for the next poll.
|
||
window.dispatchEvent(new CustomEvent('inbox-changed'));
|
||
// These carry ids only, so they cannot say whether the item belongs to
|
||
// one of *our* background tasks — the endpoint answers that. An item
|
||
// raised by this chat's own turn is filtered out server-side and stays
|
||
// where it belongs, inline in the transcript.
|
||
this._loadTaskInbox();
|
||
break;
|
||
|
||
case 'elicitation_requested':
|
||
case 'elicitation_resolved':
|
||
// Not attributable to a task: `PendingElicitationInfo` carries no
|
||
// session_id, so the strip cannot claim one. Inbox only, for now.
|
||
window.dispatchEvent(new CustomEvent('inbox-changed'));
|
||
break;
|
||
|
||
case 'agent_question':
|
||
// Link the question form to the tool card by updating status + storing request_id.
|
||
this._updateTool(msg.tool_call_id, {
|
||
status: 'pending',
|
||
request_id: msg.request_id,
|
||
question: msg.question,
|
||
question_title: msg.title,
|
||
suggested_answers: msg.suggested_answers ?? [],
|
||
});
|
||
this._expanded = new Set([...this._expanded, msg.tool_call_id]);
|
||
this._forceScrollToBottom();
|
||
break;
|
||
|
||
case 'agent_start':
|
||
this._push({
|
||
kind: 'agent',
|
||
stack_id: msg.stack_id,
|
||
agent_id: msg.agent_id,
|
||
parent_agent_id: msg.parent_agent_id,
|
||
prompt_preview: msg.prompt_preview,
|
||
depth: msg.depth,
|
||
done: false,
|
||
});
|
||
break;
|
||
|
||
case 'agent_done': {
|
||
// A sub-agent's final round emits no Done: its streamed bubble would
|
||
// stay pending forever — finalize it with the accumulated content.
|
||
const last = this._messages[this._messages.length - 1];
|
||
if (last?.kind === 'assistant' && last.streaming) {
|
||
this._replaceLast({ kind: 'assistant', content: last.content,
|
||
reasoning: last.reasoning || null,
|
||
input_tokens: null, output_tokens: null });
|
||
}
|
||
this._updateAgent(msg.stack_id, { done: true });
|
||
const agentMsg = this._messages.find(m => m.kind === 'agent' && m.stack_id === msg.stack_id);
|
||
if (agentMsg) {
|
||
this._push({
|
||
kind: 'agent_end',
|
||
agent_id: msg.agent_id,
|
||
parent_agent_id: msg.parent_agent_id,
|
||
result_preview: msg.result_preview,
|
||
depth: agentMsg.depth,
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'truncated':
|
||
this._pushError(`${t('chat.truncated', { tokens: msg.output_tokens?.toLocaleString() ?? '?' })}`);
|
||
break;
|
||
|
||
case 'error':
|
||
this._waiting = false;
|
||
this._dropStreaming();
|
||
this._pushError(msg.message);
|
||
break;
|
||
|
||
case 'file_changed':
|
||
window.dispatchEvent(new CustomEvent('file-changed', { detail: { path: msg.path } }));
|
||
break;
|
||
|
||
case 'open_file': {
|
||
// Agent-driven file open. Every kind — HTML included — routes through the
|
||
// file-viewer page, which renders HTML live in an origin-isolated iframe.
|
||
const p = msg.path ?? '';
|
||
if (p && typeof window.openFile === 'function') window.openFile(p);
|
||
break;
|
||
}
|
||
|
||
case 'model_fallback':
|
||
// A fallback mid-stream means the previous attempt's deltas are orphaned:
|
||
// drop the pending bubble — the replacement model streams a fresh one.
|
||
this._dropStreaming();
|
||
this._push({ kind: 'info', content: `⚡ Model fallback: ${msg.from} → ${msg.to}` });
|
||
break;
|
||
|
||
case 'user_message':
|
||
// Telnet-style echo: the backend emits this when the message is persisted
|
||
// to history, so we render the bubble here — for the sending client and
|
||
// every other client alike. No dedup needed: regular messages are never
|
||
// rendered optimistically (only slash commands are, and those are never
|
||
// echoed). `message_id` is the real chat_history row id.
|
||
this._push({
|
||
kind: 'user',
|
||
content: msg.content,
|
||
attachments: msg.attachments ?? [],
|
||
message_id: msg.message_id,
|
||
});
|
||
break;
|
||
|
||
case 'new_session':
|
||
this._cancelStreamFlush();
|
||
this._messages = [];
|
||
this._waiting = false;
|
||
// A reset from another client of this source: follow the new session id.
|
||
if (msg.session_id) this._onSessionReplaced(this._source, msg.session_id);
|
||
break;
|
||
|
||
case 'client_selected':
|
||
// Backend is the single source of truth for the pinned model. Updates
|
||
// arrive here regardless of which client (dropdown, /model command,
|
||
// another tab) originated the change — so the dropdown/select stays
|
||
// in sync. We set the field directly; Lit re-renders because
|
||
// `_selectedClient` is `state: true`.
|
||
this._selectedClient = msg.client;
|
||
break;
|
||
|
||
case 'security_group_selected':
|
||
// Twin of `client_selected`: the backend is the source of truth for the
|
||
// session's security-group. Arrives on connect (initial state) and on
|
||
// every change (this tab, another tab, or a role-default), so the picker
|
||
// stays in sync. Direct set — Lit re-renders (`_selectedGroup` is state).
|
||
this._selectedGroup = msg.group;
|
||
break;
|
||
|
||
case 'task_update':
|
||
// A background task this conversation started changed state.
|
||
this._upsertTask({
|
||
job_id: msg.job_id,
|
||
title: msg.title,
|
||
agent_id: msg.agent_id,
|
||
session_id: msg.session_id ?? null,
|
||
state: msg.state,
|
||
error: msg.error ?? null,
|
||
started_at: msg.state === 'running' ? new Date().toISOString() : null,
|
||
});
|
||
break;
|
||
|
||
case 'llm_failed':
|
||
this._waiting = false;
|
||
this._dropStreaming();
|
||
this._pushError(`LLM unavailable. Tried: ${msg.tried.join(', ')}. ${msg.last_error}`);
|
||
break;
|
||
}
|
||
}
|
||
|
||
_push(item) {
|
||
console.debug('[push]', item.kind, item);
|
||
this._messages = [...this._messages, item];
|
||
this._onMessagePushed(item);
|
||
}
|
||
|
||
// ── Live token streaming ────────────────────────────────────────────────────
|
||
// A pending assistant bubble (`streaming: true`) is mutated in place by
|
||
// `token_delta` events and flushed to Lit at most ~15×/s; turn-ending events
|
||
// (`done`/`thinking`) finalize it via `_replaceLast`, failures drop it.
|
||
|
||
_scheduleStreamFlush() {
|
||
if (this._streamFlushTimer) return;
|
||
this._streamFlushTimer = setTimeout(() => {
|
||
this._streamFlushTimer = null;
|
||
this._messages = [...this._messages];
|
||
this._scrollToBottom();
|
||
}, 66);
|
||
}
|
||
|
||
_cancelStreamFlush() {
|
||
if (!this._streamFlushTimer) return;
|
||
clearTimeout(this._streamFlushTimer);
|
||
this._streamFlushTimer = null;
|
||
}
|
||
|
||
_replaceLast(item) {
|
||
this._cancelStreamFlush();
|
||
const updated = [...this._messages];
|
||
updated[updated.length - 1] = item;
|
||
this._messages = updated;
|
||
this._scrollToBottom();
|
||
}
|
||
|
||
_dropStreaming() {
|
||
this._cancelStreamFlush();
|
||
const last = this._messages[this._messages.length - 1];
|
||
if (last?.kind === 'assistant' && last.streaming) {
|
||
this._messages = this._messages.slice(0, -1);
|
||
}
|
||
}
|
||
|
||
_pushError(text) {
|
||
this._push({ kind: 'error', content: text });
|
||
}
|
||
|
||
_updateAgent(stack_id, patch) {
|
||
const idx = this._messages.findIndex(m => m.kind === 'agent' && m.stack_id === stack_id);
|
||
if (idx < 0) return;
|
||
const updated = [...this._messages];
|
||
updated[idx] = { ...updated[idx], ...patch };
|
||
this._messages = updated;
|
||
}
|
||
|
||
_updateTool(tool_call_id, patch) {
|
||
const idx = this._messages.findIndex(
|
||
m => (m.kind === 'tool' || m.kind === 'pending_write') && m.tool_call_id === tool_call_id
|
||
);
|
||
if (idx < 0) return;
|
||
const updated = [...this._messages];
|
||
updated[idx] = { ...updated[idx], ...patch };
|
||
this._messages = updated;
|
||
}
|
||
|
||
_updatePendingWrite(request_id, patch) {
|
||
const idx = this._messages.findIndex(
|
||
m => m.kind === 'pending_write' && m.request_id === request_id
|
||
);
|
||
if (idx < 0) return;
|
||
// Once resolved (approved or rejected) remove the block entirely —
|
||
// the tool card already shows the outcome.
|
||
if (patch.status === 'approved' || patch.status === 'rejected') {
|
||
this._messages = this._messages.filter((_, i) => i !== idx);
|
||
return;
|
||
}
|
||
const updated = [...this._messages];
|
||
updated[idx] = { ...updated[idx], ...patch };
|
||
this._messages = updated;
|
||
}
|
||
|
||
// ── User input ────────────────────────────────────────────────────────────────
|
||
|
||
async _send() {
|
||
const content = this._getInputContent();
|
||
// Text is required; attachments are a complement, never sent on their own.
|
||
// Sending is allowed while a turn is in flight: the message is queued and
|
||
// injected into the running turn at its next round boundary.
|
||
if (!content) return;
|
||
// Don't send while an attachment is still streaming to disk, or its path
|
||
// would be missing from the message.
|
||
if (this._attachments.some(a => a.uploading)) return;
|
||
// Handled entirely over HTTP (and it reconnects the socket itself), so it must
|
||
// stay available precisely when the socket is down.
|
||
if (content === '/new' || content === '/clear') {
|
||
this._clearInput();
|
||
this._attachments = [];
|
||
await this._startNewSession();
|
||
return;
|
||
}
|
||
|
||
// Nothing below this point may run while the socket is down: everything from
|
||
// here on is destructive to what the user typed (the input is cleared, the
|
||
// attachment chips are dropped). Bailing first is what keeps a long message
|
||
// recoverable — the composer still holds it, so retrying after the automatic
|
||
// reconnect is one Enter, not a retype.
|
||
if (this._ws?.readyState !== WebSocket.OPEN) {
|
||
this._pushError(t('chat.not_connected'));
|
||
return;
|
||
}
|
||
this._clearInput();
|
||
|
||
// Strip client-only fields; the server persists these as message metadata.
|
||
const attachments = this._attachments.map(({ name, path, mimetype, filesize }) =>
|
||
({ name, path, mimetype, filesize }));
|
||
this._attachments = [];
|
||
|
||
// Sending implies the reader wants to follow the conversation: always land at
|
||
// the latest, even if they had scrolled up to read before sending.
|
||
this._forceScrollToBottom();
|
||
|
||
// System slash commands reply with a `Done` and never echo back as a
|
||
// `user_message`, so render them optimistically. Regular messages and custom
|
||
// slash commands use telnet-style echo: no local push — the bubble appears only
|
||
// when the backend persists the message and sends it back as a `user_message`
|
||
// event (for a custom command, carrying the typed form as its content), placing
|
||
// it correctly (e.g. after the current round's tools when injected mid-turn).
|
||
if (SYSTEM_SLASH_COMMANDS.has(content.split(/\s+/)[0])) {
|
||
this._push({ kind: 'user', content, attachments });
|
||
}
|
||
this._waiting = true;
|
||
this._ws.send(JSON.stringify({ content, attachments }));
|
||
}
|
||
|
||
// ── Attachments ────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Upload the given files to `data/uploads/{session}/` and add them as chips.
|
||
* Each file is streamed to disk server-side; while in flight its chip shows a
|
||
* spinner. Accepts a FileList or array of File.
|
||
*/
|
||
async _addFiles(files) {
|
||
const list = Array.from(files || []).filter(Boolean);
|
||
if (list.length === 0) return;
|
||
|
||
// Optimistic placeholders so the chips appear immediately.
|
||
const pending = list.map(f => ({ name: f.name, filesize: f.size, mimetype: f.type, uploading: true }));
|
||
this._attachments = [...this._attachments, ...pending];
|
||
|
||
const form = new FormData();
|
||
for (const f of list) form.append('files', f, f.name);
|
||
|
||
try {
|
||
const res = await fetch(`/api/${this._source}/uploads`, { method: 'POST', body: form });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const saved = await res.json(); // [{ name, path, mimetype, filesize }]
|
||
// Replace the placeholders with the saved entries (preserve other chips).
|
||
this._attachments = this._attachments.filter(a => !pending.includes(a)).concat(saved);
|
||
} catch (e) {
|
||
console.error('upload failed:', e);
|
||
// Drop the failed placeholders and surface the error.
|
||
this._attachments = this._attachments.filter(a => !pending.includes(a));
|
||
this._pushError('Upload failed: ' + e.message);
|
||
}
|
||
}
|
||
|
||
_removeAttachment(i) {
|
||
this._attachments = this._attachments.filter((_, idx) => idx !== i);
|
||
}
|
||
|
||
/** Handler for a paste event: uploads any files on the clipboard. */
|
||
_onPaste(e) {
|
||
const files = e.clipboardData?.files;
|
||
if (files && files.length) {
|
||
e.preventDefault();
|
||
this._addFiles(files);
|
||
}
|
||
}
|
||
|
||
/** Handler for a drop event on the composer: uploads the dropped files. */
|
||
_onDrop(e) {
|
||
const files = e.dataTransfer?.files;
|
||
if (files && files.length) {
|
||
e.preventDefault();
|
||
this._addFiles(files);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Pin a client (model) for the current source. Mirrors the state locally for
|
||
* instant feedback, then notifies the backend, which is the single source of
|
||
* truth — it broadcasts `client_selected` back to every client of the source
|
||
* (this tab included), so the dropdown/select re-syncs from authoritative
|
||
* state. Pass `'auto'` to clear the pin.
|
||
*/
|
||
_selectClient(client) {
|
||
this._selectedClient = client;
|
||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||
this._ws.send(JSON.stringify({ type: 'select_client', client }));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load the caller's selectable security-groups (the role's effective set). One
|
||
* fetch; the current selection arrives over the WS (`security_group_selected`),
|
||
* so this only feeds the dropdown's options.
|
||
*/
|
||
async _loadSecurityGroups() {
|
||
try {
|
||
const res = await fetch('/api/my/security-groups');
|
||
if (!res.ok) return;
|
||
const list = await res.json();
|
||
if (Array.isArray(list)) this._securityGroups = list;
|
||
} catch { /* the picker just stays hidden if this fails */ }
|
||
}
|
||
|
||
/**
|
||
* Pick a security-group for the current session. Mirrors [`_selectClient`]: set
|
||
* locally for instant feedback, then notify the backend, which validates against
|
||
* the role, persists on the session, and broadcasts `security_group_selected`
|
||
* back to every client (this tab included) so the picker re-syncs from truth.
|
||
*/
|
||
_selectGroup(group) {
|
||
this._selectedGroup = group;
|
||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||
this._ws.send(JSON.stringify({ type: 'select_security_group', group }));
|
||
}
|
||
}
|
||
|
||
_cancel() {
|
||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||
this._ws.send(JSON.stringify({ type: 'cancel' }));
|
||
}
|
||
this._waiting = false;
|
||
}
|
||
|
||
// ── Approval — pending_write (WS) ─────────────────────────────────────────────
|
||
|
||
_approve(msg) {
|
||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||
this._ws.send(JSON.stringify({ type: 'approve_write', request_id: msg.request_id }));
|
||
}
|
||
this._updatePendingWrite(msg.request_id, { status: 'approved' });
|
||
}
|
||
|
||
_approveWriteBypass(msg, bypassSecs) {
|
||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||
this._ws.send(JSON.stringify({ type: 'approve_write', request_id: msg.request_id, bypass_secs: bypassSecs }));
|
||
}
|
||
this._updatePendingWrite(msg.request_id, { status: 'approved' });
|
||
}
|
||
|
||
_startReject(msg) {
|
||
this._rejectingId = msg.request_id;
|
||
this._rejectNote = '';
|
||
}
|
||
|
||
_confirmReject(msg) {
|
||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||
this._ws.send(JSON.stringify({ type: 'reject_write', request_id: msg.request_id, note: this._rejectNote }));
|
||
}
|
||
this._updatePendingWrite(msg.request_id, { status: 'rejected' });
|
||
this._rejectingId = null;
|
||
}
|
||
|
||
// ── Approval — tool (WS, live) ────────────────────────────────────────────────
|
||
|
||
_approveWsTool(msg) {
|
||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||
this._ws.send(JSON.stringify({ type: 'approve_tool', request_id: msg.request_id }));
|
||
}
|
||
this._updateTool(msg.tool_call_id, { status: 'running', request_id: null });
|
||
this._rejectingId = null;
|
||
}
|
||
|
||
_approveWsToolBypass(msg, bypassSecs) {
|
||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||
this._ws.send(JSON.stringify({ type: 'approve_tool', request_id: msg.request_id, bypass_secs: bypassSecs }));
|
||
}
|
||
this._updateTool(msg.tool_call_id, { status: 'running', request_id: null });
|
||
this._rejectingId = null;
|
||
}
|
||
|
||
_rejectWsTool(msg) {
|
||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||
this._ws.send(JSON.stringify({ type: 'reject_tool', request_id: msg.request_id, note: this._rejectNote }));
|
||
}
|
||
this._updateTool(msg.tool_call_id, { status: 'rejected', error: t('chat.rejected_by_user') });
|
||
this._rejectingId = null;
|
||
}
|
||
|
||
// ── Approval — tool (REST, from history) ─────────────────────────────────────
|
||
|
||
async _approveTool(msg) {
|
||
this._updateTool(msg.tool_call_id, { status: 'running' });
|
||
try {
|
||
const res = await fetch(`/api/tools/${msg.tool_call_id}/resolve`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action: 'approve' }),
|
||
});
|
||
if (!res.ok) {
|
||
this._updateTool(msg.tool_call_id, { status: 'error', error: `Approval failed: ${await res.text()}` });
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
this._updateTool(msg.tool_call_id, { status: data.status, result: data.result, result_type: data.result_type });
|
||
if (this._ws?.readyState === WebSocket.OPEN) this._ws.send(JSON.stringify({ type: 'resume' }));
|
||
} catch (e) {
|
||
this._updateTool(msg.tool_call_id, { status: 'error', error: String(e) });
|
||
}
|
||
}
|
||
|
||
async _rejectTool(msg) {
|
||
this._updateTool(msg.tool_call_id, { status: 'running' });
|
||
try {
|
||
const res = await fetch(`/api/tools/${msg.tool_call_id}/resolve`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action: 'reject', note: this._rejectNote }),
|
||
});
|
||
if (!res.ok) {
|
||
this._updateTool(msg.tool_call_id, { status: 'error', error: `Rejection failed: ${await res.text()}` });
|
||
return;
|
||
}
|
||
this._updateTool(msg.tool_call_id, { status: 'error', error: 'Rejected by user.' });
|
||
this._rejectingId = null;
|
||
if (this._ws?.readyState === WebSocket.OPEN) this._ws.send(JSON.stringify({ type: 'resume' }));
|
||
} catch (e) {
|
||
this._updateTool(msg.tool_call_id, { status: 'error', error: String(e) });
|
||
}
|
||
}
|
||
|
||
// ── Clarification ─────────────────────────────────────────────────────────────
|
||
|
||
_answerQuestion(msg) {
|
||
const answer = this._clarificationAnswer.trim();
|
||
if (!answer) return;
|
||
if (this._ws?.readyState === WebSocket.OPEN) {
|
||
this._ws.send(JSON.stringify({ type: 'answer_question', request_id: msg.request_id, answer }));
|
||
}
|
||
this._updateTool(msg.tool_call_id, { status: 'running', request_id: null });
|
||
this._clarificationAnswer = '';
|
||
}
|
||
|
||
// ── DOM hooks (override in subclass) ─────────────────────────────────────────
|
||
|
||
/** Called after every _push(). Override to handle scrolling, focus, etc. */
|
||
_onMessagePushed(_item) {}
|
||
|
||
/** Returns the chat input textarea element. Subclasses must override. */
|
||
_inputEl() { return null; }
|
||
|
||
/** Returns the current value of the chat input. */
|
||
_getInputContent() { return this._inputEl()?.value.trim() ?? ''; }
|
||
|
||
/** Clears the chat input and resets its auto-resize height. */
|
||
_clearInput() {
|
||
const el = this._inputEl();
|
||
if (!el) return;
|
||
el.value = '';
|
||
el.style.height = 'auto';
|
||
}
|
||
|
||
/** Auto-resizes a textarea to fit its content (capped by CSS max-height). */
|
||
_autoResize(el) {
|
||
el.style.height = 'auto';
|
||
el.style.height = el.scrollHeight + 'px';
|
||
}
|
||
|
||
/** Returns the scrollable message-list element. Subclasses must override. */
|
||
_messagesContainer() { return null; }
|
||
|
||
/**
|
||
* Scrolls the message list to the bottom — unless the reader has scrolled away
|
||
* from the bottom, in which case streaming is left to flow without yanking them
|
||
* back. Pass `force=true` for cases that must always land at the latest content
|
||
* (history load, the chat becoming visible, interactive approval/clarification
|
||
* prompts).
|
||
*
|
||
* Stickiness is tracked as a flag updated by a passive `scroll` listener on the
|
||
* container (attached lazily), NOT by re-measuring the distance on every call:
|
||
* a single fast flush can add more than the threshold of new content, which
|
||
* would otherwise make a stuck reader look "far from the bottom" and kill the
|
||
* auto-scroll. The listener recomputes the flag from the live scroll position,
|
||
* so scrolling back within the band re-arms it.
|
||
*/
|
||
_scrollToBottom(force = false) {
|
||
this.updateComplete.then(() => {
|
||
const el = this._messagesContainer();
|
||
if (!el) return;
|
||
// Lazily bind the stickiness sensor to the (possibly recreated) container.
|
||
if (el !== this._scrollEl) {
|
||
this._scrollEl = el;
|
||
el.addEventListener('scroll', () => {
|
||
if (this._scrollEl !== el) return;
|
||
this._stickToBottom =
|
||
(el.scrollHeight - el.scrollTop - el.clientHeight) <= SCROLL_STICKY_PX;
|
||
this._updateJumpButton();
|
||
}, { passive: true });
|
||
}
|
||
if (force || this._stickToBottom) {
|
||
el.scrollTop = el.scrollHeight;
|
||
this._stickToBottom = true;
|
||
this._updateJumpButton();
|
||
}
|
||
});
|
||
}
|
||
|
||
/** Always scrolls to the bottom regardless of the reader's position. */
|
||
_forceScrollToBottom() { this._scrollToBottom(true); }
|
||
|
||
/** "Jump to latest" handler: force-scroll and re-stick. */
|
||
_jumpToBottom() { this._forceScrollToBottom(); }
|
||
|
||
/** Toggles the `_showJump` state from the current stickiness (only on change). */
|
||
_updateJumpButton() {
|
||
const show = !this._stickToBottom && this._messages.length > 0;
|
||
if (show !== this._showJump) this._showJump = show;
|
||
}
|
||
|
||
// ── Voice recording (shared by every chat surface) ────────────────────────────
|
||
|
||
async _checkTranscribe() {
|
||
try {
|
||
const r = await fetch('/api/transcribe/has');
|
||
this._hasTranscribe = r.status === 204;
|
||
} catch {
|
||
this._hasTranscribe = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Why the microphone can be missing even when the server has a transcribe
|
||
* model: `navigator.mediaDevices` is only exposed in a **secure context**
|
||
* (HTTPS, or localhost/127.0.0.1) — over http on a LAN address the property
|
||
* is `undefined`, not a denied permission. That used to die in the `catch`
|
||
* below as a bare console line, leaving the button frozen with no
|
||
* explanation, so the unavailable cases are named here instead of guessed at.
|
||
*/
|
||
_micUnavailableReason() {
|
||
if (!navigator.mediaDevices?.getUserMedia) {
|
||
return window.isSecureContext === false
|
||
? t('chat.mic.insecure')
|
||
: t('chat.mic.unsupported');
|
||
}
|
||
if (typeof MediaRecorder === 'undefined') return t('chat.mic.unsupported');
|
||
return null;
|
||
}
|
||
|
||
async _startRecording(fromShortcut = false) {
|
||
if (this._recording) return;
|
||
const unavailable = this._micUnavailableReason();
|
||
if (unavailable) {
|
||
this._pushError(unavailable);
|
||
return;
|
||
}
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
this._audioChunks = [];
|
||
this._shortcutRecording = fromShortcut;
|
||
|
||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||
? 'audio/webm;codecs=opus'
|
||
: MediaRecorder.isTypeSupported('audio/webm')
|
||
? 'audio/webm'
|
||
: '';
|
||
|
||
this._mediaRecorder = mimeType
|
||
? new MediaRecorder(stream, { mimeType })
|
||
: new MediaRecorder(stream);
|
||
|
||
this._mediaRecorder.addEventListener('dataavailable', e => {
|
||
if (e.data.size > 0) this._audioChunks.push(e.data);
|
||
});
|
||
|
||
this._mediaRecorder.addEventListener('stop', () => {
|
||
stream.getTracks().forEach(t => t.stop());
|
||
this._submitAudio();
|
||
});
|
||
|
||
this._mediaRecorder.start();
|
||
this._recording = true;
|
||
} catch (err) {
|
||
console.error('mic error:', err);
|
||
this._pushError(
|
||
err?.name === 'NotAllowedError' || err?.name === 'SecurityError'
|
||
? t('chat.mic.denied')
|
||
: t('chat.mic.failed', { error: err?.message || err?.name || 'unknown' }),
|
||
);
|
||
}
|
||
}
|
||
|
||
_stopRecording() {
|
||
if (!this._recording || !this._mediaRecorder) return;
|
||
this._mediaRecorder.stop();
|
||
this._recording = false;
|
||
}
|
||
|
||
/** Toggle button handler: start or stop a recording (button-initiated). */
|
||
_toggleRecording() {
|
||
if (this._recording) {
|
||
this._shortcutRecording = false;
|
||
this._stopRecording();
|
||
} else {
|
||
this._startRecording(false);
|
||
}
|
||
}
|
||
|
||
async _submitAudio() {
|
||
if (this._audioChunks.length === 0) return;
|
||
|
||
const mimeType = this._mediaRecorder?.mimeType ?? 'audio/webm';
|
||
const blob = new Blob(this._audioChunks, { type: mimeType });
|
||
// Derive file extension from mimeType, e.g. "audio/webm;codecs=opus" → "webm"
|
||
const ext = mimeType.split('/')[1]?.split(';')[0] ?? 'webm';
|
||
|
||
const form = new FormData();
|
||
form.append('audio', blob, `recording.${ext}`);
|
||
|
||
try {
|
||
const resp = await fetch('/api/transcribe/audio', { method: 'POST', body: form });
|
||
if (!resp.ok) throw new Error(await resp.text());
|
||
const { text } = await resp.json();
|
||
if (text) {
|
||
const ta = this._inputEl();
|
||
if (ta) {
|
||
ta.value = (ta.value ? ta.value + ' ' : '') + text;
|
||
this._autoResize(ta);
|
||
ta.focus();
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('transcription error:', err);
|
||
}
|
||
}
|
||
}
|