blob: c15f6ee03b1bc08edcd2f8ff512758335ef73b97 [file] [log] [blame]
import { html } from "lit";
import { PropertyValues } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { customElement, property, state } from "lit/decorators.js";
import { AgentMessage, State } from "../types";
import "./sketch-timeline-message";
import { SketchTailwindElement } from "./sketch-tailwind-element";
import { Ref } from "lit/directives/ref";
@customElement("sketch-timeline")
export class SketchTimeline extends SketchTailwindElement {
@property({ attribute: false })
messages: AgentMessage[] = [];
// Active state properties to show thinking indicator
@property({ attribute: false })
agentState: string | null = null;
@property({ attribute: false })
llmCalls: number = 0;
@property({ attribute: false })
toolCalls: string[] = [];
// Track if we should scroll to the bottom
@state()
private scrollingState: "pinToLatest" | "floating" = "pinToLatest";
@property({ attribute: false })
scrollContainer: Ref<HTMLElement>;
// Keep track of current scroll container for cleanup
private currentScrollContainer: HTMLElement | null = null;
// Event-driven scroll handling without setTimeout
private scrollDebounceFrame: number | null = null;
// Loading operation management with proper cancellation
private loadingAbortController: AbortController | null = null;
private pendingScrollRestoration: (() => void) | null = null;
// Track current loading operation for cancellation
private currentLoadingOperation: Promise<void> | null = null;
// Observers for event-driven DOM updates
private resizeObserver: ResizeObserver | null = null;
private mutationObserver: MutationObserver | null = null;
@property({ attribute: false })
firstMessageIndex: number = 0;
@property({ attribute: false })
state: State | null = null;
@property({ attribute: false })
compactPadding: boolean = false;
// Track initial load completion for better rendering control
@state()
private isInitialLoadComplete: boolean = false;
@property({ attribute: false })
dataManager: any = null; // Reference to DataManager for event listening
// Viewport rendering properties
@property({ attribute: false })
initialMessageCount: number = 30;
@property({ attribute: false })
loadChunkSize: number = 20;
@state()
private visibleMessageStartIndex: number = 0;
@state()
private isLoadingOlderMessages: boolean = false;
// Threshold for triggering load more (pixels from top)
private loadMoreThreshold: number = 100;
// Timeout ID for loading operations
private loadingTimeoutId: number | null = null;
constructor() {
super();
// Binding methods
this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
this._handleScroll = this._handleScroll.bind(this);
// Add custom animations and styles that can't be easily done with Tailwind
this.addCustomStyles();
}
private addCustomStyles() {
const styleId = "sketch-timeline-custom-styles";
if (document.getElementById(styleId)) {
return; // Already added
}
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
/* Hide message content initially to prevent flash of incomplete content */
.timeline-not-initialized sketch-timeline-message {
opacity: 0;
transition: opacity 0.2s ease-in;
}
/* Show content once initial load is complete */
.timeline-initialized sketch-timeline-message {
opacity: 1;
}
/* Custom animations for thinking dots */
@keyframes thinking-pulse {
0%, 100% {
opacity: 0.4;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
.thinking-dot-1 {
animation: thinking-pulse 1.5s infinite ease-in-out;
}
.thinking-dot-2 {
animation: thinking-pulse 1.5s infinite ease-in-out 0.3s;
}
.thinking-dot-3 {
animation: thinking-pulse 1.5s infinite ease-in-out 0.6s;
}
/* Custom spinner animation */
@keyframes loading-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-spinner {
animation: loading-spin 1s linear infinite;
}
/* Custom compact padding styling */
.compact-padding .scroll-container {
padding-left: 0;
}
`;
document.head.appendChild(style);
}
/**
* Safely add scroll event listener with proper cleanup tracking
*/
private addScrollListener(container: HTMLElement): void {
// Remove any existing listener first
this.removeScrollListener();
// Add new listener and track the container
container.addEventListener("scroll", this._handleScroll);
this.currentScrollContainer = container;
}
/**
* Safely remove scroll event listener
*/
private removeScrollListener(): void {
if (this.currentScrollContainer) {
this.currentScrollContainer.removeEventListener(
"scroll",
this._handleScroll,
);
this.currentScrollContainer = null;
}
// Clear any pending timeouts and operations
this.clearAllPendingOperations();
}
/**
* Clear all pending operations and observers to prevent race conditions
*/
private clearAllPendingOperations(): void {
// Clear scroll debounce frame
if (this.scrollDebounceFrame) {
cancelAnimationFrame(this.scrollDebounceFrame);
this.scrollDebounceFrame = null;
}
// Abort loading operations
if (this.loadingAbortController) {
this.loadingAbortController.abort();
this.loadingAbortController = null;
}
// Cancel pending scroll restoration
if (this.pendingScrollRestoration) {
this.pendingScrollRestoration = null;
}
// Clean up observers
this.disconnectObservers();
}
/**
* Disconnect all observers
*/
private disconnectObservers(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.mutationObserver) {
this.mutationObserver.disconnect();
this.mutationObserver = null;
}
}
/**
* Force a viewport reset to show the most recent messages
* Useful when loading a new session or when messages change significantly
*/
public resetViewport(): void {
// Cancel any pending loading operations to prevent race conditions
this.cancelCurrentLoadingOperation();
// Reset viewport state
this.visibleMessageStartIndex = 0;
this.isLoadingOlderMessages = false;
// Clear all pending operations
this.clearAllPendingOperations();
this.requestUpdate();
}
/**
* Cancel current loading operation if in progress
*/
private cancelCurrentLoadingOperation(): void {
if (this.isLoadingOlderMessages) {
this.isLoadingOlderMessages = false;
// Abort the loading operation
if (this.loadingAbortController) {
this.loadingAbortController.abort();
this.loadingAbortController = null;
}
// Cancel pending scroll restoration
this.pendingScrollRestoration = null;
}
}
/**
* Get the filtered messages (excluding hidden ones)
*/
private get filteredMessages(): AgentMessage[] {
return this.messages.filter((msg) => !msg.hide_output);
}
/**
* Get the currently visible messages based on viewport rendering
* Race-condition safe implementation
*/
private get visibleMessages(): AgentMessage[] {
const filtered = this.filteredMessages;
if (filtered.length === 0) return [];
// Always show the most recent messages first
// visibleMessageStartIndex represents how many additional older messages to show
const totalVisible =
this.initialMessageCount + this.visibleMessageStartIndex;
const startIndex = Math.max(0, filtered.length - totalVisible);
// Ensure we don't return an invalid slice during loading operations
const endIndex = filtered.length;
if (startIndex >= endIndex) {
return [];
}
return filtered.slice(startIndex, endIndex);
}
/**
* Check if the component is in a stable state for loading operations
*/
private isStableForLoading(): boolean {
return (
this.scrollContainer.value !== null &&
this.scrollContainer.value === this.currentScrollContainer &&
this.scrollContainer.value.isConnected &&
!this.isLoadingOlderMessages &&
!this.currentLoadingOperation
);
}
/**
* Load more older messages by expanding the visible window
* Race-condition safe implementation
*/
private async loadOlderMessages(): Promise<void> {
// Prevent concurrent loading operations
if (this.isLoadingOlderMessages || this.currentLoadingOperation) {
return;
}
const filtered = this.filteredMessages;
const currentVisibleCount = this.visibleMessages.length;
const totalAvailable = filtered.length;
// Check if there are more messages to load
if (currentVisibleCount >= totalAvailable) {
return;
}
// Start loading operation with proper state management
this.isLoadingOlderMessages = true;
// Store current scroll position for restoration
const container = this.scrollContainer.value;
const previousScrollHeight = container?.scrollHeight || 0;
const previousScrollTop = container?.scrollTop || 0;
// Validate scroll container hasn't changed during setup
if (!container || container !== this.currentScrollContainer) {
this.isLoadingOlderMessages = false;
return;
}
// Expand the visible window with bounds checking
const additionalMessages = Math.min(
this.loadChunkSize,
totalAvailable - currentVisibleCount,
);
const newStartIndex = this.visibleMessageStartIndex + additionalMessages;
// Ensure we don't exceed available messages
const boundedStartIndex = Math.min(
newStartIndex,
totalAvailable - this.initialMessageCount,
);
this.visibleMessageStartIndex = Math.max(0, boundedStartIndex);
// Create the loading operation with proper error handling and cleanup
const loadingOperation = this.executeScrollPositionRestoration(
container,
previousScrollHeight,
previousScrollTop,
);
this.currentLoadingOperation = loadingOperation;
try {
await loadingOperation;
} catch (error) {
console.warn("Loading operation failed:", error);
} finally {
// Ensure loading state is always cleared
this.isLoadingOlderMessages = false;
this.currentLoadingOperation = null;
// Clear the loading timeout if it exists
if (this.loadingTimeoutId) {
clearTimeout(this.loadingTimeoutId);
this.loadingTimeoutId = null;
}
}
}
/**
* Execute scroll position restoration with event-driven approach
*/
private async executeScrollPositionRestoration(
container: HTMLElement,
previousScrollHeight: number,
previousScrollTop: number,
): Promise<void> {
// Set up AbortController for proper cancellation
this.loadingAbortController = new AbortController();
const { signal } = this.loadingAbortController;
// Create scroll restoration function
const restoreScrollPosition = () => {
// Check if operation was aborted
if (signal.aborted) {
return;
}
// Double-check container is still valid and connected
if (
!container ||
!container.isConnected ||
container !== this.currentScrollContainer
) {
return;
}
try {
const newScrollHeight = container.scrollHeight;
const scrollDifference = newScrollHeight - previousScrollHeight;
const newScrollTop = previousScrollTop + scrollDifference;
// Validate all scroll calculations before applying
const isValidRestoration =
scrollDifference > 0 && // Content was added
newScrollTop >= 0 && // New position is valid
newScrollTop <= newScrollHeight && // Don't exceed max scroll
previousScrollHeight > 0 && // Had valid previous height
newScrollHeight > previousScrollHeight; // Height actually increased
if (isValidRestoration) {
container.scrollTop = newScrollTop;
} else {
// Log invalid restoration attempts for debugging
console.debug("Skipped scroll restoration:", {
scrollDifference,
newScrollTop,
newScrollHeight,
previousScrollHeight,
previousScrollTop,
});
}
} catch (error) {
console.warn("Scroll position restoration failed:", error);
}
};
// Store the restoration function for potential cancellation
this.pendingScrollRestoration = restoreScrollPosition;
// Wait for DOM update and then restore scroll position
await this.updateComplete;
// Check if operation was cancelled during await
if (
!signal.aborted &&
this.pendingScrollRestoration === restoreScrollPosition
) {
// Use ResizeObserver to detect when content is actually ready
await this.waitForContentReady(container, signal);
if (!signal.aborted) {
restoreScrollPosition();
this.pendingScrollRestoration = null;
}
}
}
/**
* Wait for content to be ready using ResizeObserver instead of setTimeout
*/
private async waitForContentReady(
container: HTMLElement,
signal: AbortSignal,
): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new Error("Operation aborted"));
return;
}
// Resolve immediately if container already has content
if (container.scrollHeight > 0) {
resolve();
return;
}
// Set up ResizeObserver to detect content changes
const observer = new ResizeObserver((entries) => {
if (signal.aborted) {
observer.disconnect();
reject(new Error("Operation aborted"));
return;
}
// Content is ready when height increases
const entry = entries[0];
if (entry && entry.contentRect.height > 0) {
observer.disconnect();
resolve();
}
});
// Start observing
observer.observe(container);
// Clean up on abort
signal.addEventListener("abort", () => {
observer.disconnect();
reject(new Error("Operation aborted"));
});
});
}
/**
* Scroll to the bottom of the timeline
*/
private scrollToBottom(): void {
if (!this.scrollContainer.value) return;
// Use instant scroll to ensure we reach the exact bottom
this.scrollContainer.value.scrollTo({
top: this.scrollContainer.value.scrollHeight,
behavior: "instant",
});
}
/**
* Scroll to bottom with event-driven approach using MutationObserver
*/
private async scrollToBottomWithRetry(): Promise<void> {
if (!this.scrollContainer.value) return;
const container = this.scrollContainer.value;
// Try immediate scroll first
this.scrollToBottom();
// Check if we're at the bottom
const isAtBottom = () => {
const targetScrollTop = container.scrollHeight - container.clientHeight;
const actualScrollTop = container.scrollTop;
return Math.abs(targetScrollTop - actualScrollTop) <= 1;
};
// If already at bottom, we're done
if (isAtBottom()) {
return;
}
// Use MutationObserver to detect content changes and retry
return new Promise((resolve) => {
let scrollAttempted = false;
const observer = new MutationObserver(() => {
if (!scrollAttempted) {
scrollAttempted = true;
// Use requestAnimationFrame to ensure DOM is painted
requestAnimationFrame(() => {
this.scrollToBottom();
// Check if successful
if (isAtBottom()) {
observer.disconnect();
resolve();
} else {
// Try one more time after another frame
requestAnimationFrame(() => {
this.scrollToBottom();
observer.disconnect();
resolve();
});
}
});
}
});
// Observe changes to the timeline container
observer.observe(container, {
childList: true,
subtree: true,
attributes: false,
});
// Clean up after a reasonable time if no changes detected
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!scrollAttempted) {
observer.disconnect();
resolve();
}
});
});
});
}
/**
* Called after the component's properties have been updated
*/
updated(changedProperties: PropertyValues): void {
// Handle DataManager changes to set up event listeners
if (changedProperties.has("dataManager")) {
const oldDataManager = changedProperties.get("dataManager");
// Remove old event listener if it exists
if (oldDataManager) {
oldDataManager.removeEventListener(
"initialLoadComplete",
this.handleInitialLoadComplete,
);
}
// Add new event listener if dataManager is available
if (this.dataManager) {
this.dataManager.addEventListener(
"initialLoadComplete",
this.handleInitialLoadComplete,
);
// Check if initial load is already complete
if (
this.dataManager.getIsInitialLoadComplete &&
this.dataManager.getIsInitialLoadComplete()
) {
this.isInitialLoadComplete = true;
}
}
}
// Handle scroll container changes first to prevent race conditions
if (changedProperties.has("scrollContainer")) {
// Cancel any ongoing loading operations since container is changing
this.cancelCurrentLoadingOperation();
if (this.scrollContainer.value) {
this.addScrollListener(this.scrollContainer.value);
} else {
this.removeScrollListener();
}
}
// If messages have changed, handle viewport updates
if (changedProperties.has("messages")) {
const oldMessages =
(changedProperties.get("messages") as AgentMessage[]) || [];
const newMessages = this.messages || [];
// Cancel loading operations if messages changed significantly
const significantChange =
oldMessages.length === 0 ||
newMessages.length < oldMessages.length ||
Math.abs(newMessages.length - oldMessages.length) > 20;
if (significantChange) {
// Cancel any ongoing operations and reset viewport
this.cancelCurrentLoadingOperation();
this.visibleMessageStartIndex = 0;
}
// Scroll to bottom if needed (only if not loading to prevent race conditions)
if (
this.messages.length > 0 &&
this.scrollingState === "pinToLatest" &&
!this.isLoadingOlderMessages
) {
// Use async scroll without setTimeout
this.scrollToBottomWithRetry().catch((error) => {
console.warn("Scroll to bottom failed:", error);
});
}
}
}
/**
* Handle showCommitDiff event
*/
private _handleShowCommitDiff(event: CustomEvent) {
const { commitHash } = event.detail;
if (commitHash) {
// Bubble up the event to the app shell
const newEvent = new CustomEvent("show-commit-diff", {
detail: { commitHash },
bubbles: true,
composed: true,
});
this.dispatchEvent(newEvent);
}
}
private _handleScroll(_event) {
if (!this.scrollContainer.value) return;
const container = this.scrollContainer.value;
// Verify this is still our tracked container to prevent race conditions
if (container !== this.currentScrollContainer) {
return;
}
const isAtBottom =
Math.abs(
container.scrollHeight - container.clientHeight - container.scrollTop,
) <= 3; // Increased tolerance to 3px for better detection
const isNearTop = container.scrollTop <= this.loadMoreThreshold;
// Update scroll state immediately for responsive UI
if (isAtBottom) {
this.scrollingState = "pinToLatest";
} else {
this.scrollingState = "floating";
}
// Use requestAnimationFrame for smooth debouncing instead of setTimeout
if (this.scrollDebounceFrame) {
cancelAnimationFrame(this.scrollDebounceFrame);
}
this.scrollDebounceFrame = requestAnimationFrame(() => {
// Use stability check to ensure safe loading conditions
if (isNearTop && this.isStableForLoading()) {
this.loadOlderMessages().catch((error) => {
console.warn("Async loadOlderMessages failed:", error);
});
}
this.scrollDebounceFrame = null;
});
}
// See https://lit.dev/docs/components/lifecycle/
connectedCallback() {
super.connectedCallback();
// Listen for showCommitDiff events from the renderer
document.addEventListener(
"showCommitDiff",
this._handleShowCommitDiff as EventListener,
);
// Set up scroll listener if container is available
if (this.scrollContainer?.value) {
this.addScrollListener(this.scrollContainer.value);
}
// Initialize observers for event-driven behavior
this.setupObservers();
}
/**
* Handle initial load completion from DataManager
*/
private handleInitialLoadComplete = (eventData: {
messageCount: number;
expectedCount: number;
}): void => {
console.log(
`Timeline: Initial load complete - ${eventData.messageCount}/${eventData.expectedCount} messages`,
);
this.isInitialLoadComplete = true;
this.requestUpdate();
};
/**
* Set up observers for event-driven DOM monitoring
*/
private setupObservers(): void {
// ResizeObserver will be created on-demand in loading operations
// MutationObserver will be created on-demand in scroll operations
// This avoids creating observers that may not be needed
}
// See https://lit.dev/docs/component/lifecycle/
disconnectedCallback() {
super.disconnectedCallback();
// Cancel any ongoing loading operations before cleanup
this.cancelCurrentLoadingOperation();
// Remove event listeners with guaranteed cleanup
document.removeEventListener(
"showCommitDiff",
this._handleShowCommitDiff as EventListener,
);
// Remove DataManager event listener if connected
if (this.dataManager) {
this.dataManager.removeEventListener(
"initialLoadComplete",
this.handleInitialLoadComplete,
);
}
// Use our safe cleanup method
this.removeScrollListener();
}
// messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
// that we only re-render <sketch-message> elements that we need to re-render.
messageKey(message: AgentMessage): string {
// If the message has tool calls, and any of the tool_calls get a response, we need to
// re-render that message.
const toolCallResponses = message.tool_calls
?.filter((tc) => tc.result_message)
.map((tc) => tc.tool_call_id)
.join("-");
return `message-${message.idx}-${toolCallResponses}`;
}
render() {
// Check if messages array is empty and render welcome box if it is
// Skip welcome box in newsessions (compactPadding) context
if (this.messages.length === 0 && !this.compactPadding) {
const compactClass = this.compactPadding ? "compact-padding" : "";
return html`
<div class="relative h-full">
<div
id="scroll-container"
class="overflow-y-auto overflow-x-hidden pl-4 max-w-full w-full h-full ${compactClass} scroll-container print:h-auto print:max-h-none print:overflow-visible"
>
<div
class="my-8 mx-auto max-w-[90%] w-[90%] p-8 border-2 border-gray-300 dark:border-gray-600 rounded-lg shadow-sm bg-white dark:bg-gray-800 text-center print:break-inside-avoid"
data-testid="welcome-box"
>
<h2
class="text-2xl font-semibold mb-6 text-center text-gray-800 dark:text-gray-100"
data-testid="welcome-box-title"
>
How to use Sketch
</h2>
<p
class="text-gray-600 dark:text-gray-300 leading-relaxed text-base text-left"
>
Sketch is an agentic coding assistant.
</p>
<p
class="text-gray-600 dark:text-gray-300 leading-relaxed text-base text-left"
>
Sketch has created a container with your repo.
</p>
<p
class="text-gray-600 dark:text-gray-300 leading-relaxed text-base text-left"
>
Ask it to implement a task or answer a question in the chat box
below. It can edit and run your code, all in the container.
Sketch will create commits in a newly created git branch, which
you can look at and comment on in the Diff tab. Once you're
done, you'll find that branch available in your (original) repo.
</p>
<p
class="text-gray-600 dark:text-gray-300 leading-relaxed text-base text-left"
>
Because Sketch operates a container per session, you can run
Sketch in parallel to work on multiple ideas or even the same
idea with different approaches.
</p>
</div>
</div>
</div>
`;
}
// Otherwise render the regular timeline with messages
const isThinking =
this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
// Apply view-initialized class when initial load is complete
const timelineStateClass = this.isInitialLoadComplete
? "timeline-initialized"
: "timeline-not-initialized";
// Compact padding class
const compactClass = this.compactPadding ? "compact-padding" : "";
return html`
<div class="relative h-full">
<div
id="scroll-container"
class="overflow-y-auto overflow-x-hidden pl-4 max-w-full w-full h-full ${compactClass} scroll-container print:h-auto print:max-h-none print:overflow-visible"
>
<div
class="w-full relative max-w-full mx-auto px-[15px] box-border overflow-x-hidden flex-1 min-h-[100px] ${timelineStateClass} print:h-auto print:max-h-none print:overflow-visible print:break-inside-avoid"
data-testid="timeline-container"
>
${!this.isInitialLoadComplete
? html`
<div
class="flex items-center justify-center p-5 text-gray-600 dark:text-gray-400 text-sm gap-2.5 opacity-100 print:hidden"
data-testid="loading-indicator"
>
<div
class="w-5 h-5 border-2 border-gray-300 dark:border-gray-600 border-t-gray-600 dark:border-t-gray-300 rounded-full loading-spinner"
data-testid="loading-spinner"
></div>
<span>Loading conversation...</span>
</div>
`
: ""}
${this.isLoadingOlderMessages
? html`
<div
class="flex items-center justify-center p-5 text-gray-600 dark:text-gray-400 text-sm gap-2.5 opacity-100 print:hidden"
data-testid="loading-indicator"
>
<div
class="w-5 h-5 border-2 border-gray-300 dark:border-gray-600 border-t-gray-600 dark:border-t-gray-300 rounded-full loading-spinner"
data-testid="loading-spinner"
></div>
<span>Loading older messages...</span>
</div>
`
: ""}
${this.isInitialLoadComplete
? repeat(
this.visibleMessages,
this.messageKey,
(message, _index) => {
// Find the previous message in the full filtered messages array
const filteredMessages = this.filteredMessages;
const messageIndex = filteredMessages.findIndex(
(m) => m === message,
);
const previousMessage =
messageIndex > 0
? filteredMessages[messageIndex - 1]
: undefined;
return html`<sketch-timeline-message
.message=${message}
.previousMessage=${previousMessage}
.open=${false}
.firstMessageIndex=${this.firstMessageIndex}
.state=${this.state}
.compactPadding=${this.compactPadding}
></sketch-timeline-message>`;
},
)
: ""}
${isThinking && this.isInitialLoadComplete
? html`
<div
class="pl-[85px] mt-1.5 mb-4 flex"
data-testid="thinking-indicator"
style="display: flex; padding-left: 85px; margin-top: 6px; margin-bottom: 16px;"
>
<div
class="bg-gray-100 dark:bg-gray-700 rounded-2xl px-4 py-2.5 max-w-20 text-black dark:text-white relative rounded-bl-[5px]"
data-testid="thinking-bubble"
>
<div
class="flex items-center justify-center gap-1 h-3.5"
data-testid="thinking-dots"
>
<div
class="w-1.5 h-1.5 bg-gray-500 dark:bg-gray-300 rounded-full opacity-60 thinking-dot-1"
data-testid="thinking-dot"
></div>
<div
class="w-1.5 h-1.5 bg-gray-500 dark:bg-gray-300 rounded-full opacity-60 thinking-dot-2"
data-testid="thinking-dot"
></div>
<div
class="w-1.5 h-1.5 bg-gray-500 dark:bg-gray-300 rounded-full opacity-60 thinking-dot-3"
data-testid="thinking-dot"
></div>
</div>
</div>
</div>
`
: ""}
</div>
</div>
<div
id="jump-to-latest"
class="${this.scrollingState === "floating"
? "block floating"
: "hidden"} fixed bottom-20 left-1/2 -translate-x-1/2 bg-black/60 dark:bg-gray-700/80 text-white border-none rounded-xl px-2 py-1 text-xs font-normal cursor-pointer shadow-md z-[1000] transition-all duration-150 ease-out whitespace-nowrap opacity-80 hover:bg-black/80 dark:hover:bg-gray-600/90 hover:-translate-y-0.5 hover:opacity-100 hover:shadow-lg active:translate-y-0 print:hidden"
@click=${this.scrollToBottomWithRetry}
>
↓ Jump to bottom
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"sketch-timeline": SketchTimeline;
}
}