Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ notes. Entries describe what users experience, not internal refactors.
- **Keep writing while the assistant responds.** Prepare the next request without
sending it automatically. Reading older messages keeps your place, and activity
hints distinguish reading, responding and waiting for your review.
The draft adapts when you resize the sidebar, and long drafts scroll inside the
input. Copy feedback stays compact; code and tables can be navigated by keyboard.

- **Ask the assistant about the current PDF page and review notes before adding them.**
It can read selectable page text and add page notes that you can undo. Scanned
Expand Down
302 changes: 302 additions & 0 deletions docs/assistant-format-regression.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion lib/agent-plugin/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,11 @@ const DEFAULT_MAX_CHARS = 8000;
export const getDocumentTextTool: AgentTool<GetDocumentTextParams, { text: string; truncated: boolean }> = {
name: 'get_document_text',
description:
'Word only. Read current main-body text without moving the caret or selection. Long text is truncated; unsupported structures are reported as unavailable.',
'Read source text from the current Word document so the application can answer factual questions about it. ' +
'Use this when a question refers to the open document, including when no text is selected. ' +
'This tool returns text, not a final answer; the application answers from the result. ' +
'Reading does not modify the document or move the caret. Main body only; long text is truncated ' +
'and unsupported structures are reported as unavailable.',
inputSchema: {
type: 'object',
properties: {
Expand Down
3 changes: 3 additions & 0 deletions lib/agent-plugin/ui/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,9 @@ export function createAgentPanel(
labels: chatLabels(),
});
const readiness = document.createElement('p');
window.addEventListener('pagehide', (event) => {
if (!event.persisted) chat.dispose();
});
readiness.className = 'agent-readiness';
readiness.setAttribute('role', 'status');
chat.el.append(readiness);
Expand Down
4 changes: 4 additions & 0 deletions packages/chat-ui/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ container.appendChild(chat.el); // mount the root element
| `clear()` | Remove all messages, restore empty state. |
| `getInput()` / `setInput(text)` / `focus()` | Input helpers (e.g. prepend a quote). |
| `setLabels(labels)` | Re-apply labels live (e.g. language change). |
| `dispose()` | Release resize observers and copy-feedback timers when unmounting. |

`chat.actionsEl` is a host-populated slot directly above the input (an IM-style
compose toolbar) — append your own controls; it collapses when empty.
Expand Down Expand Up @@ -78,6 +79,9 @@ onSend: async (text) => {
- Streaming: call `appendDelta` repeatedly, then exactly one `endStream`. Don't `append` an agent message during a stream.
- The send button is an **icon** (up-arrow ⇄ stop square); `labels.send`/`labels.stop` become its `title`/`aria-label`. It auto-disables when the input is empty (and idle).
- Modern look: assistant messages are bubble-less/full-width, the user gets an accent bubble, tool/error render as subtle chips; a jump-to-latest button appears when scrolled up and new content only auto-scrolls when already near the bottom.
- Width changes reflow the draft. Layout changes keep following the latest reply only while the reader is attached to the end; browsing history preserves its position.
- Message arrivals consume the current following state. They must not recompute it from post-reflow geometry: scroll handling owns reader attachment, while jumping to the end and clearing reset it.
- Copy feedback uses fixed-size icons and a separate polite announcement. Clipboard failure leaves the action available to retry; feedback never replaces running activity.
- Restyle via CSS custom properties on `.cui-root` (`--cui-accent`, `--cui-user-bg`, …) — don't depend on internal `cui-*` class names.
- DOM is built with the ranui `builder` (View/Div/Span/ButtonBuilder) and the scroll handler is throttled via ranuts; the package depends on `ranui` + `ranuts`.

Expand Down
152 changes: 134 additions & 18 deletions packages/chat-ui/src/chat-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const ICON_DOWN =
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M19 12l-7 7-7-7"/></svg>';
const ICON_COPY =
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V4H4v12h4"/></svg>';
const ICON_COPIED =
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 4 4L19 6"/></svg>';
const ICON_COPY_ERROR =
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v6m0 4h.01"/></svg>';

/** Distance (px) from the bottom within which we keep auto-scrolling on new content. */
const STICK_THRESHOLD = 60;
Expand Down Expand Up @@ -50,6 +54,18 @@ export class ChatView {
private composing = false;
private lastUserText = '';
private readonly statusEl = Div().class('cui-status').attr('role', 'status').build();
private readonly feedbackEl = Div().class('cui-feedback').attr('role', 'status').attr('aria-atomic', 'true').build();
private readonly resizeObserver?: ResizeObserver;
private inputWidth = 0;
private inputNeedsResize = false;
private layoutFrame?: number;
// Content mutations consume this state; only scrolling, jumping to the end,
// or clearing a conversation may change it. Reflow is not reader intent.
private following = true;
private scrollHeight = 0;
private viewportHeight = 0;
private disposed = false;
private readonly copyTimers = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>();
/** The agent bubble currently receiving streamed deltas, if any. */
private liveMsg: HTMLDivElement | null = null;
private liveText = '';
Expand All @@ -64,15 +80,21 @@ export class ChatView {
this.emptyActionsEl = Div().class('cui-empty-actions').build();
this.emptyEl = Div().class('cui-empty').children(this.emptyTitle, this.emptyActionsEl).build();

const updateScroll = throttle(() => this.updateScrollBtn(), 100);
this.messagesEl = Div()
.class('cui-messages')
.attr('role', 'log')
.attr('aria-busy', 'false')
.attr('tabindex', '-1')
.on(
'scroll',
throttle(() => this.updateScrollBtn(), 100),
)
.on('scroll', () => {
// Reflow can emit scroll before ResizeObserver. Changed geometry does
// not establish that the reader intentionally scrolled away.
const unchanged =
this.scrollHeight === this.messagesEl.scrollHeight && this.viewportHeight === this.messagesEl.clientHeight;
if (!this.scrollHeight || unchanged || this.nearBottom()) this.following = this.nearBottom();
this.recordScrollGeometry();
updateScroll();
})
.children(this.emptyEl)
.build();

Expand Down Expand Up @@ -135,7 +157,39 @@ export class ChatView {
// slot, and the composer.
const footer = Div().class('cui-footer').children(this.scrollBtn, composer).build();

this.el = Div().class('cui-root').children(this.messagesEl, this.statusEl, footer).build();
this.el = Div().class('cui-root').children(this.messagesEl, this.statusEl, footer, this.feedbackEl).build();
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver((entries) => {
if (this.disposed) return;
for (const entry of entries) {
if (
entry.target === this.input &&
entry.contentRect.width > 0 &&
entry.contentRect.width !== this.inputWidth
) {
this.inputWidth = entry.contentRect.width;
this.inputNeedsResize = true;
}
}
// Write in the next frame, outside observer delivery. Resizing a sibling
// viewport here can otherwise produce undelivered-notification errors.
if (this.layoutFrame !== undefined) return;
this.layoutFrame = requestAnimationFrame(() => {
this.layoutFrame = undefined;
if (this.disposed) return;
if (this.inputNeedsResize) {
this.inputNeedsResize = false;
this.autoGrow();
}
if (this.following) this.scrollToEnd();
else {
this.recordScrollGeometry();
this.updateScrollBtn();
}
});
});
this.observeLayout();
}
this.setLabels(this.labels);
this.updateSendState();
}
Expand Down Expand Up @@ -163,7 +217,7 @@ export class ChatView {
this.endStream();
}
if (message.role === 'user') this.lastUserText = message.text;
const stick = this.nearBottom();
const stick = this.following;
this.emptyEl.remove();
if (message.role === 'tool') {
const last = this.messagesEl.lastElementChild;
Expand All @@ -180,6 +234,7 @@ export class ChatView {
const details = document.createElement('details');
details.className = 'cui-activity';
details.append(document.createElement('summary'), document.createElement('ul'));
this.resizeObserver?.unobserve(group);
group.replaceWith(details);
group = details;
} else {
Expand All @@ -192,6 +247,7 @@ export class ChatView {
}
if (stick) this.scrollToEnd();
else this.updateScrollBtn();
this.resizeObserver?.observe(group);
return item;
}

Expand All @@ -202,19 +258,34 @@ export class ChatView {
const actions = Div().class('cui-message-actions').build();
if (message.role === 'agent') {
const copy = ButtonBuilder().class('cui-copy').attr('type', 'button').build();
copy.innerHTML = ICON_COPY;
copy.title = this.labels.copy ?? 'Copy';
copy.setAttribute('aria-label', copy.title);
this.updateCopyButton(copy);
copy.addEventListener('click', async () => {
if (this.disposed || copy.disabled) return;
clearTimeout(this.copyTimers.get(copy));
this.copyTimers.delete(copy);
this.feedbackEl.textContent = '';
copy.disabled = true;
let state: 'copied' | 'failed';
try {
await navigator.clipboard.writeText(row.dataset.source ?? message.text);
copy.textContent = this.labels.copied ?? 'Copied';
state = 'copied';
} catch {
copy.textContent = this.labels.copyFailed ?? 'Could not copy';
state = 'failed';
} finally {
copy.disabled = false;
}
if (this.disposed || !this.messagesEl.contains(row)) return;
copy.dataset.state = state;
this.updateCopyButton(copy);
this.feedbackEl.textContent = copy.title;
this.copyTimers.set(
copy,
setTimeout(() => {
this.copyTimers.delete(copy);
delete copy.dataset.state;
this.updateCopyButton(copy);
}, 1800),
);
});
actions.append(copy);
if (
Expand Down Expand Up @@ -268,6 +339,7 @@ export class ChatView {
if (message.interrupted) row.dataset.interrupted = 'true';

this.messagesEl.appendChild(row);
this.resizeObserver?.observe(row);
this.updateRestoreState();
if (stick) this.scrollToEnd();
else this.updateScrollBtn();
Expand All @@ -280,7 +352,7 @@ export class ChatView {
*/
appendDelta(delta: string): void {
if (!delta) return;
const stick = this.nearBottom();
const stick = this.following;
if (!this.liveMsg) {
this.liveMsg = this.append({ role: 'agent', text: '' }).parentElement as HTMLDivElement;
this.liveMsg.classList.add('cui-streaming');
Expand All @@ -294,7 +366,7 @@ export class ChatView {

/** Finalise the current streaming bubble (removes the caret). */
endStream(): void {
const stick = this.nearBottom();
const stick = this.following;
const bubble = this.liveMsg?.querySelector<HTMLElement>('.cui-bubble');
if (bubble) renderMarkdown(bubble, this.liveText);
this.liveMsg?.classList.remove('cui-streaming');
Expand All @@ -306,8 +378,9 @@ export class ChatView {
/** Mount a host-owned review card in chronological conversation order. */
appendContent(content: HTMLElement): void {
this.emptyEl.remove();
const stick = this.nearBottom();
const stick = this.following;
this.messagesEl.append(content);
this.resizeObserver?.observe(content);
if (stick) this.scrollToEnd();
else this.updateScrollBtn();
}
Expand Down Expand Up @@ -342,11 +415,15 @@ export class ChatView {

/** Remove all messages and restore the empty state. */
clear(): void {
this.clearCopyFeedback();
this.resizeObserver?.disconnect();
this.messagesEl.replaceChildren(this.emptyEl);
this.emptyTitle.textContent = this.labels.empty ?? '';
this.liveMsg = null;
this.liveText = '';
this.lastUserText = '';
this.following = true;
this.observeLayout();
this.updateScrollBtn();
}

Expand Down Expand Up @@ -379,10 +456,8 @@ export class ChatView {
this.input.setAttribute('aria-label', labels.placeholder ?? 'Message');
this.scrollBtn.setAttribute('aria-label', labels.scrollLatest ?? 'Scroll to latest');
this.updateActivity();
for (const button of this.messagesEl.querySelectorAll<HTMLElement>('.cui-copy')) {
button.title = labels.copy ?? 'Copy';
button.setAttribute('aria-label', button.title);
}
for (const button of this.messagesEl.querySelectorAll<HTMLButtonElement>('.cui-copy'))
this.updateCopyButton(button);
for (const button of this.messagesEl.querySelectorAll<HTMLElement>('.cui-apply')) {
const state = button.dataset.state;
button.title = labels.applyTip ?? 'Insert at the cursor or replace the selected text';
Expand Down Expand Up @@ -434,21 +509,62 @@ export class ChatView {
this.input.style.height = `${Math.min(this.input.scrollHeight, 160)}px`;
}

/** Release layout observers and transient feedback when the host unmounts. */
dispose(): void {
if (this.disposed) return;
this.disposed = true;
if (this.layoutFrame !== undefined) cancelAnimationFrame(this.layoutFrame);
this.layoutFrame = undefined;
this.resizeObserver?.disconnect();
this.clearCopyFeedback();
}

private observeLayout(): void {
if (this.disposed) return;
for (const element of [this.messagesEl, this.input, this.emptyEl]) this.resizeObserver?.observe(element);
}

private clearCopyFeedback(): void {
for (const timer of this.copyTimers.values()) clearTimeout(timer);
this.copyTimers.clear();
this.feedbackEl.textContent = '';
}

private updateCopyButton(button: HTMLButtonElement): void {
const state = button.dataset.state;
button.innerHTML = state === 'copied' ? ICON_COPIED : state === 'failed' ? ICON_COPY_ERROR : ICON_COPY;
button.title =
state === 'copied'
? (this.labels.copied ?? 'Copied')
: state === 'failed'
? (this.labels.copyFailed ?? 'Could not copy')
: (this.labels.copy ?? 'Copy');
button.setAttribute('aria-label', button.title);
}

private nearBottom(): boolean {
const el = this.messagesEl;
return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
}

private recordScrollGeometry(): void {
this.scrollHeight = this.messagesEl.scrollHeight;
this.viewportHeight = this.messagesEl.clientHeight;
}

private scrollToEnd(smooth = false): void {
this.following = true;
const reduceMotion = smooth && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
this.messagesEl.scrollTo({
top: this.messagesEl.scrollHeight,
behavior: smooth && !reduceMotion ? 'smooth' : 'auto',
});
this.recordScrollGeometry();
this.updateScrollBtn();
}

private updateScrollBtn(): void {
if (this.disposed) return;
const hidden = this.emptyEl.parentElement === this.messagesEl || this.nearBottom();
// Keep focus in the conversation when its jump control disappears.
if (hidden && document.activeElement === this.scrollBtn) this.messagesEl.focus({ preventScroll: true });
Expand Down
3 changes: 3 additions & 0 deletions packages/chat-ui/src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ function appendTokens(parent: Node, tokens: Token[]): void {
const table = token as Tokens.Table;
const wrapper = document.createElement('div');
wrapper.className = 'cui-table';
wrapper.tabIndex = 0;
const el = document.createElement('table');
const head = el.createTHead().insertRow();
for (const cell of table.header) {
const th = document.createElement('th');
th.scope = 'col';
appendTokens(th, cell.tokens);
head.append(th);
}
Expand All @@ -48,6 +50,7 @@ function appendTokens(parent: Node, tokens: Token[]): void {
}
if (token.type === 'code') {
const pre = document.createElement('pre');
pre.tabIndex = 0;
const code = document.createElement('code');
code.textContent = (token as Tokens.Code).text;
pre.append(code);
Expand Down
Loading
Loading