Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
7 changed files with 140 additions and 19 deletions
Showing only changes of commit db6e395c11 - Show all commits
+11 -5
View File
@@ -162,11 +162,8 @@ export class AppCopilot extends I18nMixin(ChatSession) {
return this.querySelector('.copilot-textarea'); return this.querySelector('.copilot-textarea');
} }
_scrollToBottom() { _messagesContainer() {
this.updateComplete.then(() => { return this.querySelector('.copilot-messages');
const el = this.querySelector('.copilot-messages');
if (el) el.scrollTop = el.scrollHeight;
});
} }
_onMessagePushed(item) { _onMessagePushed(item) {
@@ -389,6 +386,15 @@ export class AppCopilot extends I18nMixin(ChatSession) {
${t('chat.thinking')} ${t('chat.thinking')}
</div> </div>
` : nothing} ` : nothing}
${this._showJump ? html`
<button class="copilot-jump-btn" type="button"
title=${t('chat.scroll_to_latest')}
aria-label=${t('chat.scroll_to_latest')}
@click=${() => this._jumpToBottom()}>
<i class="bi bi-arrow-down-circle-fill"></i>
</button>
` : nothing}
</div> </div>
<div class="copilot-input-area"> <div class="copilot-input-area">
+12 -6
View File
@@ -43,7 +43,7 @@ export class ChatPage extends ChatSession {
updated(changed) { updated(changed) {
if (changed.has('visible') && this.visible) { if (changed.has('visible') && this.visible) {
this._scrollToBottom(); this._forceScrollToBottom();
} }
// The owner (mobile-app) re-points this chat by changing `source`. Switch the // The owner (mobile-app) re-points this chat by changing `source`. Switch the
// live connection — base `_switchSource` tears down the WS, reloads that // live connection — base `_switchSource` tears down the WS, reloads that
@@ -72,11 +72,8 @@ export class ChatPage extends ChatSession {
return this.querySelector('.chat-page-textarea'); return this.querySelector('.chat-page-textarea');
} }
_scrollToBottom() { _messagesContainer() {
this.updateComplete.then(() => { return this.querySelector('.chat-page-messages');
const el = this.querySelector('.chat-page-messages');
if (el) el.scrollTop = el.scrollHeight;
});
} }
_onMessagePushed(item) { _onMessagePushed(item) {
@@ -184,6 +181,15 @@ export class ChatPage extends ChatSession {
${t('chat.thinking')} ${t('chat.thinking')}
</div> </div>
` : nothing} ` : nothing}
${this._showJump ? html`
<button class="copilot-jump-btn" type="button"
title=${t('chat.scroll_to_latest')}
aria-label=${t('chat.scroll_to_latest')}
@click=${() => this._jumpToBottom()}>
<i class="bi bi-arrow-down-circle-fill"></i>
</button>
` : nothing}
</div> </div>
<div class="chat-page-input-area"> <div class="chat-page-input-area">
+33
View File
@@ -9,6 +9,39 @@
gap: 0.7rem; gap: 0.7rem;
} }
/* ── "Jump to latest" floating affordance ──────────────────────────────────── */
/* Shown only while the reader is scrolled away from the bottom (auto-scroll has
yielded). Sticky to the scrollport bottom so it stays in view as they read;
removed from the DOM entirely while stuck to the bottom. */
.copilot-jump-btn {
position: sticky;
bottom: 0.6rem;
align-self: flex-end;
width: 38px;
height: 38px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid var(--card-border);
border-radius: 50%;
background: rgba(var(--accent-rgb), 0.92);
color: #fff;
box-shadow: var(--card-shadow);
cursor: pointer;
z-index: 5;
font-size: 1.1rem;
line-height: 1;
transition: transform 0.12s ease, background-color 0.12s ease;
}
.copilot-jump-btn:hover { background: var(--accent); transform: translateY(-1px); }
.copilot-jump-btn:active { transform: translateY(0); }
.copilot-jump-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.copilot-jump-btn { transition: none; }
.copilot-jump-btn:hover { transform: none; }
}
/* ── Message bubbles ───────────────────────────────────────────────────────── */ /* ── Message bubbles ───────────────────────────────────────────────────────── */
.copilot-msg { .copilot-msg {
+1
View File
@@ -68,6 +68,7 @@ export default {
'chat.reasoning': 'Reasoning…', 'chat.reasoning': 'Reasoning…',
'chat.attach': 'Attach files', 'chat.attach': 'Attach files',
'chat.new_session': 'New conversation', 'chat.new_session': 'New conversation',
'chat.scroll_to_latest': 'Scroll to latest',
'chat.security_group': 'Security group', 'chat.security_group': 'Security group',
'chat.collapse': 'Hide chat', 'chat.collapse': 'Hide chat',
'chat.close_tab': 'Close tab', 'chat.close_tab': 'Close tab',
+1
View File
@@ -68,6 +68,7 @@ export default {
'chat.reasoning': 'Raisonnement…', 'chat.reasoning': 'Raisonnement…',
'chat.attach': 'Joindre des fichiers', 'chat.attach': 'Joindre des fichiers',
'chat.new_session': 'Nouvelle conversation', 'chat.new_session': 'Nouvelle conversation',
'chat.scroll_to_latest': 'Aller aux derniers messages',
'chat.security_group': 'Groupe de sécurité', 'chat.security_group': 'Groupe de sécurité',
'chat.collapse': 'Masquer la discussion', 'chat.collapse': 'Masquer la discussion',
'chat.close_tab': 'Fermer l\'onglet', 'chat.close_tab': 'Fermer l\'onglet',
+1
View File
@@ -68,6 +68,7 @@ export default {
'chat.reasoning': 'Ragionamento…', 'chat.reasoning': 'Ragionamento…',
'chat.attach': 'Allega file', 'chat.attach': 'Allega file',
'chat.new_session': 'Nuova conversazione', 'chat.new_session': 'Nuova conversazione',
'chat.scroll_to_latest': 'Vai agli ultimi messaggi',
'chat.security_group': 'Gruppo di sicurezza', 'chat.security_group': 'Gruppo di sicurezza',
'chat.collapse': 'Nascondi la chat', 'chat.collapse': 'Nascondi la chat',
'chat.close_tab': 'Chiudi scheda', 'chat.close_tab': 'Chiudi scheda',
+79 -6
View File
@@ -13,15 +13,24 @@ const SYSTEM_SLASH_COMMANDS = new Set([
'/compact', '/resettools', '/sethome', '/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). * Base class for chat UI components (desktop copilot, mobile chat page).
* *
* Contains all WebSocket logic, message state, and approval handling. * Contains all WebSocket logic, message state, and approval handling.
* Subclasses implement the render() method and override the DOM hooks: * Subclasses implement the render() method and override the DOM hooks:
* - _scrollToBottom() * - _messagesContainer() → returns the scrollable message-list element
* - _getInputContent() → returns current input value * - _getInputContent() → returns current input value
* - _clearInput() → empties the input * - _clearInput() → empties the input
* - _onMessagePushed(item) → called after each push (scroll, focus, etc.) * - _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 LightElement { export class ChatSession extends LightElement {
static properties = { static properties = {
@@ -42,6 +51,10 @@ export class ChatSession extends LightElement {
// Voice recording state (shared by every chat surface). // Voice recording state (shared by every chat surface).
_hasTranscribe: { state: true }, _hasTranscribe: { state: true },
_recording: { 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 // Pending attachments for the message being composed (shown as chips above
// the textarea; uploaded to disk on selection, sent with the next message). // the textarea; uploaded to disk on selection, sent with the next message).
_attachments: { state: true }, _attachments: { state: true },
@@ -85,6 +98,14 @@ export class ChatSession extends LightElement {
this._shortcutRecording = false; this._shortcutRecording = false;
this._mediaRecorder = null; this._mediaRecorder = null;
this._audioChunks = []; 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 // Each entry: { name, path, mimetype, filesize, uploading? }. While an upload
// is in flight the entry has `uploading: true` and no `path` yet. // is in flight the entry has `uploading: true` and no `path` yet.
this._attachments = []; this._attachments = [];
@@ -168,7 +189,7 @@ export class ChatSession extends LightElement {
if (m.kind === 'tool' && m.status === 'pending') expanded.add(m.tool_call_id); if (m.kind === 'tool' && m.status === 'pending') expanded.add(m.tool_call_id);
} }
this._expanded = expanded; this._expanded = expanded;
this._scrollToBottom(); this._forceScrollToBottom();
// Set flag so ws.onopen sends a resume if there are pending tools // Set flag so ws.onopen sends a resume if there are pending tools
// (approval/clarification waiting) or interrupted tools (status=error+Interrupted). // (approval/clarification waiting) or interrupted tools (status=error+Interrupted).
this._hasPendingTools = items.some( this._hasPendingTools = items.some(
@@ -400,7 +421,7 @@ export class ChatSession extends LightElement {
case 'approval_required': case 'approval_required':
this._updateTool(msg.tool_call_id, { status: 'pending', request_id: msg.request_id }); this._updateTool(msg.tool_call_id, { status: 'pending', request_id: msg.request_id });
this._expanded = new Set([...this._expanded, msg.tool_call_id]); this._expanded = new Set([...this._expanded, msg.tool_call_id]);
this.updateComplete.then(() => this._scrollToBottom()); this._forceScrollToBottom();
break; break;
case 'approval_resolved': { case 'approval_resolved': {
@@ -441,7 +462,7 @@ export class ChatSession extends LightElement {
suggested_answers: msg.suggested_answers ?? [], suggested_answers: msg.suggested_answers ?? [],
}); });
this._expanded = new Set([...this._expanded, msg.tool_call_id]); this._expanded = new Set([...this._expanded, msg.tool_call_id]);
this.updateComplete.then(() => this._scrollToBottom()); this._forceScrollToBottom();
break; break;
case 'agent_start': case 'agent_start':
@@ -657,6 +678,10 @@ export class ChatSession extends LightElement {
({ name, path, mimetype, filesize })); ({ name, path, mimetype, filesize }));
this._attachments = []; 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 // System slash commands reply with a `Done` and never echo back as a
// `user_message`, so render them optimistically. Regular messages and custom // `user_message`, so render them optimistically. Regular messages and custom
// slash commands use telnet-style echo: no local push — the bubble appears only // slash commands use telnet-style echo: no local push — the bubble appears only
@@ -910,8 +935,56 @@ export class ChatSession extends LightElement {
el.style.height = el.scrollHeight + 'px'; el.style.height = el.scrollHeight + 'px';
} }
/** Scrolls the message list to the bottom. */ /** Returns the scrollable message-list element. Subclasses must override. */
_scrollToBottom() {} _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) ──────────────────────────── // ── Voice recording (shared by every chat surface) ────────────────────────────