Release 0.2.0 #4
@@ -162,11 +162,8 @@ export class AppCopilot extends I18nMixin(ChatSession) {
|
||||
return this.querySelector('.copilot-textarea');
|
||||
}
|
||||
|
||||
_scrollToBottom() {
|
||||
this.updateComplete.then(() => {
|
||||
const el = this.querySelector('.copilot-messages');
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
_messagesContainer() {
|
||||
return this.querySelector('.copilot-messages');
|
||||
}
|
||||
|
||||
_onMessagePushed(item) {
|
||||
@@ -389,6 +386,15 @@ export class AppCopilot extends I18nMixin(ChatSession) {
|
||||
${t('chat.thinking')}
|
||||
</div>
|
||||
` : 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 class="copilot-input-area">
|
||||
|
||||
@@ -43,7 +43,7 @@ export class ChatPage extends ChatSession {
|
||||
|
||||
updated(changed) {
|
||||
if (changed.has('visible') && this.visible) {
|
||||
this._scrollToBottom();
|
||||
this._forceScrollToBottom();
|
||||
}
|
||||
// The owner (mobile-app) re-points this chat by changing `source`. Switch the
|
||||
// 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');
|
||||
}
|
||||
|
||||
_scrollToBottom() {
|
||||
this.updateComplete.then(() => {
|
||||
const el = this.querySelector('.chat-page-messages');
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
_messagesContainer() {
|
||||
return this.querySelector('.chat-page-messages');
|
||||
}
|
||||
|
||||
_onMessagePushed(item) {
|
||||
@@ -184,6 +181,15 @@ export class ChatPage extends ChatSession {
|
||||
${t('chat.thinking')}
|
||||
</div>
|
||||
` : 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 class="chat-page-input-area">
|
||||
|
||||
@@ -9,6 +9,39 @@
|
||||
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 ───────────────────────────────────────────────────────── */
|
||||
|
||||
.copilot-msg {
|
||||
|
||||
@@ -68,6 +68,7 @@ export default {
|
||||
'chat.reasoning': 'Reasoning…',
|
||||
'chat.attach': 'Attach files',
|
||||
'chat.new_session': 'New conversation',
|
||||
'chat.scroll_to_latest': 'Scroll to latest',
|
||||
'chat.security_group': 'Security group',
|
||||
'chat.collapse': 'Hide chat',
|
||||
'chat.close_tab': 'Close tab',
|
||||
|
||||
@@ -68,6 +68,7 @@ export default {
|
||||
'chat.reasoning': 'Raisonnement…',
|
||||
'chat.attach': 'Joindre des fichiers',
|
||||
'chat.new_session': 'Nouvelle conversation',
|
||||
'chat.scroll_to_latest': 'Aller aux derniers messages',
|
||||
'chat.security_group': 'Groupe de sécurité',
|
||||
'chat.collapse': 'Masquer la discussion',
|
||||
'chat.close_tab': 'Fermer l\'onglet',
|
||||
|
||||
@@ -68,6 +68,7 @@ export default {
|
||||
'chat.reasoning': 'Ragionamento…',
|
||||
'chat.attach': 'Allega file',
|
||||
'chat.new_session': 'Nuova conversazione',
|
||||
'chat.scroll_to_latest': 'Vai agli ultimi messaggi',
|
||||
'chat.security_group': 'Gruppo di sicurezza',
|
||||
'chat.collapse': 'Nascondi la chat',
|
||||
'chat.close_tab': 'Chiudi scheda',
|
||||
|
||||
+79
-6
@@ -13,15 +13,24 @@ const SYSTEM_SLASH_COMMANDS = new Set([
|
||||
'/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:
|
||||
* - _scrollToBottom()
|
||||
* - _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 LightElement {
|
||||
static properties = {
|
||||
@@ -42,6 +51,10 @@ export class ChatSession extends LightElement {
|
||||
// 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 },
|
||||
@@ -85,6 +98,14 @@ export class ChatSession extends LightElement {
|
||||
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 = [];
|
||||
@@ -168,7 +189,7 @@ export class ChatSession extends LightElement {
|
||||
if (m.kind === 'tool' && m.status === 'pending') expanded.add(m.tool_call_id);
|
||||
}
|
||||
this._expanded = expanded;
|
||||
this._scrollToBottom();
|
||||
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(
|
||||
@@ -400,7 +421,7 @@ export class ChatSession extends LightElement {
|
||||
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.updateComplete.then(() => this._scrollToBottom());
|
||||
this._forceScrollToBottom();
|
||||
break;
|
||||
|
||||
case 'approval_resolved': {
|
||||
@@ -441,7 +462,7 @@ export class ChatSession extends LightElement {
|
||||
suggested_answers: msg.suggested_answers ?? [],
|
||||
});
|
||||
this._expanded = new Set([...this._expanded, msg.tool_call_id]);
|
||||
this.updateComplete.then(() => this._scrollToBottom());
|
||||
this._forceScrollToBottom();
|
||||
break;
|
||||
|
||||
case 'agent_start':
|
||||
@@ -657,6 +678,10 @@ export class ChatSession extends LightElement {
|
||||
({ 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
|
||||
@@ -910,8 +935,56 @@ export class ChatSession extends LightElement {
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
}
|
||||
|
||||
/** Scrolls the message list to the bottom. */
|
||||
_scrollToBottom() {}
|
||||
/** 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) ────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user