Release 0.2.0 #4
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
+81
-8
@@ -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) ────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user