Archived
Stabilize chat prepend scrolling
This commit is contained in:
@@ -40,8 +40,13 @@ describe("chat history loading decisions", () => {
|
|||||||
expect(doesNotFillViewport({ scrollHeight: 502, clientHeight: 500 })).toBe(false);
|
expect(doesNotFillViewport({ scrollHeight: 502, clientHeight: 500 })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the larger of the default threshold and viewport height", () => {
|
||||||
|
expect(isNearTop({ scrollTop: 700, clientHeight: 800 })).toBe(true);
|
||||||
|
expect(isNearTop({ scrollTop: 800, clientHeight: 800 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("allows a custom top threshold", () => {
|
it("allows a custom top threshold", () => {
|
||||||
expect(isNearTop({ scrollTop: 80, topThreshold: 100 })).toBe(true);
|
expect(isNearTop({ scrollTop: 80, clientHeight: 500, topThreshold: 100 })).toBe(true);
|
||||||
expect(isNearTop({ scrollTop: 100, topThreshold: 100 })).toBe(false);
|
expect(isNearTop({ scrollTop: 100, clientHeight: 500, topThreshold: 100 })).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface ChatHistoryLoadState {
|
|||||||
topThreshold?: number;
|
topThreshold?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TOP_THRESHOLD = 64;
|
const DEFAULT_TOP_THRESHOLD = 600;
|
||||||
const VIEWPORT_FILL_TOLERANCE = 1;
|
const VIEWPORT_FILL_TOLERANCE = 1;
|
||||||
|
|
||||||
export function shouldRequestEarlierMessages(state: ChatHistoryLoadState): boolean {
|
export function shouldRequestEarlierMessages(state: ChatHistoryLoadState): boolean {
|
||||||
@@ -16,8 +16,8 @@ export function shouldRequestEarlierMessages(state: ChatHistoryLoadState): boole
|
|||||||
return isNearTop(state) || doesNotFillViewport(state);
|
return isNearTop(state) || doesNotFillViewport(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isNearTop(state: Pick<ChatHistoryLoadState, "scrollTop" | "topThreshold">): boolean {
|
export function isNearTop(state: Pick<ChatHistoryLoadState, "scrollTop" | "clientHeight" | "topThreshold">): boolean {
|
||||||
return state.scrollTop < (state.topThreshold ?? DEFAULT_TOP_THRESHOLD);
|
return state.scrollTop < (state.topThreshold ?? Math.max(DEFAULT_TOP_THRESHOLD, state.clientHeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function doesNotFillViewport(state: Pick<ChatHistoryLoadState, "scrollHeight" | "clientHeight">): boolean {
|
export function doesNotFillViewport(state: Pick<ChatHistoryLoadState, "scrollHeight" | "clientHeight">): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { scrollDeltaForMarker, scrollTopForBottomDistance, selectPrependMarker } from "./chatScrollAnchoring";
|
||||||
|
|
||||||
|
describe("chat scroll anchoring", () => {
|
||||||
|
it("selects the nearest marker at or above the viewport top", () => {
|
||||||
|
expect(selectPrependMarker([
|
||||||
|
{ id: "below", offset: 20 },
|
||||||
|
{ id: "above-far", offset: -50 },
|
||||||
|
{ id: "above-near", offset: -2 },
|
||||||
|
])).toEqual({ id: "above-near", offset: -2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the nearest marker below the viewport top", () => {
|
||||||
|
expect(selectPrependMarker([
|
||||||
|
{ id: "below-far", offset: 80 },
|
||||||
|
{ id: "below-near", offset: 12 },
|
||||||
|
])).toEqual({ id: "below-near", offset: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when there are no markers", () => {
|
||||||
|
expect(selectPrependMarker([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes the scroll delta needed to keep a marker at the same offset", () => {
|
||||||
|
expect(scrollDeltaForMarker(150, 40)).toBe(110);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes fallback scrollTop from bottom distance", () => {
|
||||||
|
expect(scrollTopForBottomDistance(1000, 250)).toBe(750);
|
||||||
|
expect(scrollTopForBottomDistance(100, 250)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export interface PrependScrollAnchor {
|
||||||
|
distanceFromBottom: number;
|
||||||
|
markerId?: string;
|
||||||
|
markerOffset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkerMeasurement {
|
||||||
|
id: string;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PREPEND_RESTORE_SETTLE_FRAMES = 30;
|
||||||
|
|
||||||
|
export function capturePrependScrollAnchor(scroller: HTMLElement, markers: HTMLElement[]): PrependScrollAnchor {
|
||||||
|
const marker = selectPrependMarker(measureMarkers(scroller, markers));
|
||||||
|
const base = { distanceFromBottom: scroller.scrollHeight - scroller.scrollTop };
|
||||||
|
return marker === undefined ? base : { ...base, markerId: marker.id, markerOffset: marker.offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restorePrependScrollAnchor(scroller: HTMLElement, anchor: PrependScrollAnchor, marker: HTMLElement | undefined): void {
|
||||||
|
if (marker !== undefined && anchor.markerOffset !== undefined) {
|
||||||
|
const markerOffset = marker.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||||
|
scroller.scrollTop += scrollDeltaForMarker(markerOffset, anchor.markerOffset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scroller.scrollTop = scrollTopForBottomDistance(scroller.scrollHeight, anchor.distanceFromBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectPrependMarker(markers: MarkerMeasurement[]): MarkerMeasurement | undefined {
|
||||||
|
let nearestAbove: MarkerMeasurement | undefined;
|
||||||
|
let nearestAboveOffset = Number.NEGATIVE_INFINITY;
|
||||||
|
let nearestBelow: MarkerMeasurement | undefined;
|
||||||
|
let nearestBelowOffset = Number.POSITIVE_INFINITY;
|
||||||
|
for (const marker of markers) {
|
||||||
|
if (marker.offset <= 0 && marker.offset >= nearestAboveOffset) {
|
||||||
|
nearestAbove = marker;
|
||||||
|
nearestAboveOffset = marker.offset;
|
||||||
|
} else if (marker.offset > 0 && marker.offset < nearestBelowOffset) {
|
||||||
|
nearestBelow = marker;
|
||||||
|
nearestBelowOffset = marker.offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearestAbove ?? nearestBelow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scrollDeltaForMarker(currentOffset: number, previousOffset: number): number {
|
||||||
|
return currentOffset - previousOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scrollTopForBottomDistance(scrollHeight: number, distanceFromBottom: number): number {
|
||||||
|
return Math.max(0, scrollHeight - distanceFromBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureMarkers(scroller: HTMLElement, markers: HTMLElement[]): MarkerMeasurement[] {
|
||||||
|
const scrollerTop = scroller.getBoundingClientRect().top;
|
||||||
|
return markers.flatMap((marker) => {
|
||||||
|
const id = marker.dataset["markerId"];
|
||||||
|
return id === undefined ? [] : [{ id, offset: marker.getBoundingClientRect().top - scrollerTop }];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,19 +2,13 @@ import { LitElement, html } from "lit";
|
|||||||
import { customElement, property, query, state } from "lit/decorators.js";
|
import { customElement, property, query, state } from "lit/decorators.js";
|
||||||
import { repeat } from "lit/directives/repeat.js";
|
import { repeat } from "lit/directives/repeat.js";
|
||||||
import { groupChatMessages, summarizeChatGroup } from "../chatGroups";
|
import { groupChatMessages, summarizeChatGroup } from "../chatGroups";
|
||||||
|
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
|
||||||
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
||||||
import type { SessionActivity, SessionStatus } from "../api";
|
import type { SessionActivity, SessionStatus } from "../api";
|
||||||
import type { ChatLine, ChatPart } from "./shared";
|
import type { ChatLine, ChatPart } from "./shared";
|
||||||
import { chatStyles } from "./shared";
|
import { chatStyles } from "./shared";
|
||||||
import "./FormattedText";
|
import "./FormattedText";
|
||||||
|
|
||||||
interface PrependScrollAnchor {
|
|
||||||
scrollTop: number;
|
|
||||||
scrollHeight: number;
|
|
||||||
key?: string;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isScrollPosition(value: unknown): value is { index?: number; key?: string; offset: number } {
|
function isScrollPosition(value: unknown): value is { index?: number; key?: string; offset: number } {
|
||||||
return typeof value === "object"
|
return typeof value === "object"
|
||||||
&& value !== null
|
&& value !== null
|
||||||
@@ -43,6 +37,7 @@ export class ChatView extends LitElement {
|
|||||||
@state() private expandedMetaKey: string | undefined;
|
@state() private expandedMetaKey: string | undefined;
|
||||||
@state() private copiedMessageKey: string | undefined;
|
@state() private copiedMessageKey: string | undefined;
|
||||||
private suppressScrollSave = false;
|
private suppressScrollSave = false;
|
||||||
|
private suppressLoadMoreRequests = false;
|
||||||
private saveScrollTimer?: number;
|
private saveScrollTimer?: number;
|
||||||
private lastScrollTop = 0;
|
private lastScrollTop = 0;
|
||||||
private lastClientHeight = 0;
|
private lastClientHeight = 0;
|
||||||
@@ -75,6 +70,12 @@ export class ChatView extends LitElement {
|
|||||||
if (changed.has("messages")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom());
|
if (changed.has("messages")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override update(changed: Map<string, unknown>): void {
|
||||||
|
const prependAnchor = this.isPrependingMessages(changed) ? this.capturePrependScrollAnchor() : undefined;
|
||||||
|
super.update(changed);
|
||||||
|
if (prependAnchor !== undefined) this.restorePrependScrollAnchor(prependAnchor);
|
||||||
|
}
|
||||||
|
|
||||||
protected override updated(changed: Map<string, unknown>): void {
|
protected override updated(changed: Map<string, unknown>): void {
|
||||||
if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false;
|
if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false;
|
||||||
if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false;
|
if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false;
|
||||||
@@ -206,6 +207,7 @@ export class ChatView extends LitElement {
|
|||||||
|
|
||||||
private renderMessage(message: ChatLine, index: number) {
|
private renderMessage(message: ChatLine, index: number) {
|
||||||
return html`
|
return html`
|
||||||
|
${this.renderScrollMarker(this.messageScrollMarkerId(index))}
|
||||||
<article class="msg ${message.role}" data-index=${index} data-anchor-key=${this.messageAnchorKey(index)}>
|
<article class="msg ${message.role}" data-index=${index} data-anchor-key=${this.messageAnchorKey(index)}>
|
||||||
${this.renderMessageHeader(message, String(index))}
|
${this.renderMessageHeader(message, String(index))}
|
||||||
${message.parts.map((part) => this.renderPart(part, message))}
|
${message.parts.map((part) => this.renderPart(part, message))}
|
||||||
@@ -216,6 +218,7 @@ export class ChatView extends LitElement {
|
|||||||
private renderMessageGroup(messages: ChatLine[], startIndex: number, endIndex: number) {
|
private renderMessageGroup(messages: ChatLine[], startIndex: number, endIndex: number) {
|
||||||
const key = this.groupKey(endIndex);
|
const key = this.groupKey(endIndex);
|
||||||
return html`
|
return html`
|
||||||
|
${this.renderScrollMarker(this.groupScrollMarkerId(endIndex))}
|
||||||
<details class="msg event-group" data-index=${startIndex} data-anchor-key=${this.groupAnchorKey(endIndex)} ?open=${this.openGroupKeys.has(key)} @toggle=${(event: Event) => { this.onGroupToggle(key, event); }}>
|
<details class="msg event-group" data-index=${startIndex} data-anchor-key=${this.groupAnchorKey(endIndex)} ?open=${this.openGroupKeys.has(key)} @toggle=${(event: Event) => { this.onGroupToggle(key, event); }}>
|
||||||
<summary>
|
<summary>
|
||||||
<b class="label">events</b>
|
<b class="label">events</b>
|
||||||
@@ -233,6 +236,10 @@ export class ChatView extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderScrollMarker(markerId: string) {
|
||||||
|
return html`<span class="scroll-marker" data-marker-id=${markerId} aria-hidden="true"></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
private renderMessageHeader(message: ChatLine, key: string) {
|
private renderMessageHeader(message: ChatLine, key: string) {
|
||||||
const meta = this.messageMetaLabel(message);
|
const meta = this.messageMetaLabel(message);
|
||||||
const expanded = this.expandedMetaKey === key;
|
const expanded = this.expandedMetaKey === key;
|
||||||
@@ -402,8 +409,14 @@ export class ChatView extends LitElement {
|
|||||||
return chat !== undefined && this.lastClientHeight !== 0 && chat.clientHeight !== this.lastClientHeight;
|
return chat !== undefined && this.lastClientHeight !== 0 && chat.clientHeight !== this.lastClientHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isPrependingMessages(changed: Map<string, unknown>): boolean {
|
||||||
|
const oldMessageStart = changed.get("messageStart");
|
||||||
|
return typeof oldMessageStart === "number" && this.messageStart < oldMessageStart;
|
||||||
|
}
|
||||||
|
|
||||||
private requestLoadMoreIfNeeded(): void {
|
private requestLoadMoreIfNeeded(): void {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
if (this.suppressLoadMoreRequests) return;
|
||||||
const chat = this.chat;
|
const chat = this.chat;
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
if (shouldRequestEarlierMessages({
|
if (shouldRequestEarlierMessages({
|
||||||
@@ -491,31 +504,33 @@ export class ChatView extends LitElement {
|
|||||||
capturePrependScrollAnchor(): PrependScrollAnchor | undefined {
|
capturePrependScrollAnchor(): PrependScrollAnchor | undefined {
|
||||||
const chat = this.chat;
|
const chat = this.chat;
|
||||||
if (!chat) return undefined;
|
if (!chat) return undefined;
|
||||||
const firstVisible = this.firstVisibleArticle();
|
return capturePrependScrollAnchor(chat, this.scrollMarkers());
|
||||||
if (!firstVisible) return { scrollTop: chat.scrollTop, scrollHeight: chat.scrollHeight };
|
|
||||||
const chatTop = chat.getBoundingClientRect().top;
|
|
||||||
const key = firstVisible.dataset["anchorKey"];
|
|
||||||
const anchor = { scrollTop: chat.scrollTop, scrollHeight: chat.scrollHeight };
|
|
||||||
return key === undefined
|
|
||||||
? anchor
|
|
||||||
: { ...anchor, key, offset: firstVisible.getBoundingClientRect().top - chatTop };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
restorePrependScrollAnchor(anchor: PrependScrollAnchor | undefined): void {
|
restorePrependScrollAnchor(anchor: PrependScrollAnchor | undefined): void {
|
||||||
|
if (!this.chat || !anchor) return;
|
||||||
|
this.suppressLoadMoreRequests = true;
|
||||||
|
this.suppressScrollSave = true;
|
||||||
|
let frames = 0;
|
||||||
|
const settle = () => {
|
||||||
const chat = this.chat;
|
const chat = this.chat;
|
||||||
if (!chat || !anchor) return;
|
if (!chat) return;
|
||||||
this.withSuppressedScrollSave(() => {
|
restorePrependScrollAnchor(chat, anchor, anchor.markerId === undefined ? undefined : this.scrollMarkerAt(anchor.markerId));
|
||||||
const article = anchor.key === undefined ? undefined : this.articleAt({ key: anchor.key });
|
|
||||||
if (article !== undefined && anchor.offset !== undefined) {
|
|
||||||
const chatTop = chat.getBoundingClientRect().top;
|
|
||||||
const currentOffset = article.getBoundingClientRect().top - chatTop;
|
|
||||||
chat.scrollTop += currentOffset - anchor.offset;
|
|
||||||
} else {
|
|
||||||
chat.scrollTop = anchor.scrollTop + (chat.scrollHeight - anchor.scrollHeight);
|
|
||||||
}
|
|
||||||
this.lastScrollTop = chat.scrollTop;
|
this.lastScrollTop = chat.scrollTop;
|
||||||
|
frames += 1;
|
||||||
|
// Formatted markdown/code layout can settle after Lit's first render. Re-apply
|
||||||
|
// the marker anchor briefly so late height changes above the viewport do not
|
||||||
|
// move the user's reading position.
|
||||||
|
if (frames < PREPEND_RESTORE_SETTLE_FRAMES) {
|
||||||
|
requestAnimationFrame(settle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.suppressScrollSave = false;
|
||||||
|
this.suppressLoadMoreRequests = false;
|
||||||
});
|
});
|
||||||
this.requestLoadMoreIfNeeded();
|
};
|
||||||
|
settle();
|
||||||
}
|
}
|
||||||
|
|
||||||
saveScrollPosition(sessionId = this.sessionId) {
|
saveScrollPosition(sessionId = this.sessionId) {
|
||||||
@@ -561,14 +576,25 @@ export class ChatView extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scrollMarkers(): HTMLElement[] {
|
||||||
|
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>(".scroll-marker"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollMarkerAt(markerId: string): HTMLElement | undefined {
|
||||||
|
return this.scrollMarkers().find((marker) => marker.dataset["markerId"] === markerId);
|
||||||
|
}
|
||||||
|
|
||||||
private firstVisibleArticle(): HTMLElement | undefined {
|
private firstVisibleArticle(): HTMLElement | undefined {
|
||||||
const chat = this.chat;
|
const chat = this.chat;
|
||||||
if (!chat) return undefined;
|
if (!chat) return undefined;
|
||||||
|
const firstVisible = (selector: string) => {
|
||||||
const chatRect = chat.getBoundingClientRect();
|
const chatRect = chat.getBoundingClientRect();
|
||||||
return this.articles().find((article) => {
|
return Array.from(this.renderRoot.querySelectorAll<HTMLElement>(selector)).find((article) => {
|
||||||
const rect = article.getBoundingClientRect();
|
const rect = article.getBoundingClientRect();
|
||||||
return rect.bottom >= chatRect.top && rect.top <= chatRect.bottom;
|
return rect.bottom >= chatRect.top && rect.top <= chatRect.bottom;
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
return firstVisible("article.msg") ?? firstVisible("article.msg, details.msg");
|
||||||
}
|
}
|
||||||
|
|
||||||
private articleAt(position: { index?: number; key?: string }): HTMLElement | undefined {
|
private articleAt(position: { index?: number; key?: string }): HTMLElement | undefined {
|
||||||
@@ -592,6 +618,16 @@ export class ChatView extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private withSuppressedLoadMoreRequests(callback: () => void) {
|
||||||
|
this.suppressLoadMoreRequests = true;
|
||||||
|
callback();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.suppressLoadMoreRequests = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private storageKey(sessionId = this.sessionId): string {
|
private storageKey(sessionId = this.sessionId): string {
|
||||||
return `pi-web:chat-scroll:${sessionId}`;
|
return `pi-web:chat-scroll:${sessionId}`;
|
||||||
}
|
}
|
||||||
@@ -612,6 +648,14 @@ export class ChatView extends LitElement {
|
|||||||
return `g:${String(endIndex)}`;
|
return `g:${String(endIndex)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private messageScrollMarkerId(index: number): string {
|
||||||
|
return `m:${String(index)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupScrollMarkerId(endIndex: number): string {
|
||||||
|
return `g:${String(endIndex)}`;
|
||||||
|
}
|
||||||
|
|
||||||
private readOpenGroupKeys(): Set<string> {
|
private readOpenGroupKeys(): Set<string> {
|
||||||
if (this.sessionId === "") return new Set();
|
if (this.sessionId === "") return new Set();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -141,12 +141,9 @@ export class PiWebApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async withChatPrependTransition(action: () => Promise<void>) {
|
private async withChatPrependTransition(action: () => Promise<void>) {
|
||||||
const anchor = this.chatView?.capturePrependScrollAnchor();
|
|
||||||
await action();
|
await action();
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
await this.chatView?.updateComplete;
|
await this.chatView?.updateComplete;
|
||||||
await nextFrame();
|
|
||||||
this.chatView?.restorePrependScrollAnchor(anchor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateUrl(options?: { replace?: boolean | undefined }) {
|
private updateUrl(options?: { replace?: boolean | undefined }) {
|
||||||
|
|||||||
@@ -145,7 +145,8 @@ export const listStyles = css`
|
|||||||
export const chatStyles = css`
|
export const chatStyles = css`
|
||||||
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: #e6edf3; font: 14px system-ui, sans-serif; }
|
:host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: #e6edf3; font: 14px system-ui, sans-serif; }
|
||||||
.chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
.chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
||||||
.chat { height: 100%; min-height: 0; overflow: auto; padding: 16px 16px 64px; box-sizing: border-box; }
|
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 16px 16px 64px; box-sizing: border-box; }
|
||||||
|
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
|
||||||
.history-indicator { position: absolute; top: 10px; right: 18px; z-index: 2; display: grid; gap: 2px; max-width: min(320px, calc(100% - 36px)); border: 1px solid #30363d; border-radius: 8px; background: #0d1117dd; color: #8b949e; padding: 6px 8px; font-size: 12px; text-align: right; pointer-events: none; box-shadow: 0 8px 24px #0006; }
|
.history-indicator { position: absolute; top: 10px; right: 18px; z-index: 2; display: grid; gap: 2px; max-width: min(320px, calc(100% - 36px)); border: 1px solid #30363d; border-radius: 8px; background: #0d1117dd; color: #8b949e; padding: 6px 8px; font-size: 12px; text-align: right; pointer-events: none; box-shadow: 0 8px 24px #0006; }
|
||||||
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 3; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid #30363d; border-radius: 999px; background: #0d1117e6; color: #8b949e; padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px #0008; backdrop-filter: blur(6px); }
|
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 3; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid #30363d; border-radius: 999px; background: #0d1117e6; color: #8b949e; padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px #0008; backdrop-filter: blur(6px); }
|
||||||
.activity-dock.active { border-color: #238636; color: #3fb950; background: #0f1b12ee; }
|
.activity-dock.active { border-color: #238636; color: #3fb950; background: #0f1b12ee; }
|
||||||
|
|||||||
Reference in New Issue
Block a user