blob: c612dd80968e75e0686cf1c57f3780eb809a677b [file] [log] [blame]
import { css, html, LitElement } from "lit";
import { PropertyValues } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { customElement, property, state } from "lit/decorators.js";
import { AgentMessage } from "../types";
import "./sketch-timeline-message";
import { Ref } from "lit/directives/ref";
@customElement("sketch-timeline")
export class SketchTimeline extends LitElement {
@property({ attribute: false })
messages: AgentMessage[] = [];
// Track if we should scroll to the bottom
@state()
private scrollingState: "pinToLatest" | "floating" = "pinToLatest";
@property({ attribute: false })
scrollContainer: Ref<HTMLElement>;
static styles = css`
/* Hide views initially to prevent flash of content */
.timeline-container .timeline,
.timeline-container .diff-view,
.timeline-container .chart-view,
.timeline-container .terminal-view {
visibility: hidden;
}
/* Will be set by JavaScript once we know which view to display */
.timeline-container.view-initialized .timeline,
.timeline-container.view-initialized .diff-view,
.timeline-container.view-initialized .chart-view,
.timeline-container.view-initialized .terminal-view {
visibility: visible;
}
.timeline-container {
width: 100%;
position: relative;
}
/* Timeline styles that should remain unchanged */
.timeline {
position: relative;
margin: 10px 0;
scroll-behavior: smooth;
}
.timeline::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 15px;
width: 2px;
background: #e0e0e0;
border-radius: 1px;
}
/* Hide the timeline vertical line when there are no messages */
.timeline.empty::before {
display: none;
}
#scroll-container {
overflow: auto;
padding-left: 1em;
}
#jump-to-latest {
display: none;
position: fixed;
bottom: 100px;
right: 0;
background: rgb(33, 150, 243);
color: white;
border-radius: 8px;
padding: 0.5em;
margin: 0.5em;
font-size: x-large;
opacity: 0.5;
cursor: pointer;
}
#jump-to-latest:hover {
opacity: 1;
}
#jump-to-latest.floating {
display: block;
}
/* Welcome box styles for the empty chat state */
.welcome-box {
margin: 2rem auto;
max-width: 80%;
padding: 2rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
background-color: #ffffff;
text-align: center;
}
.welcome-box-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
text-align: center;
color: #333;
}
.welcome-box-content {
color: #666; /* Slightly grey font color */
line-height: 1.6;
font-size: 1rem;
text-align: left;
}
`;
constructor() {
super();
// Binding methods
this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
this._handleScroll = this._handleScroll.bind(this);
}
/**
* Scroll to the bottom of the timeline
*/
private scrollToBottom(): void {
this.scrollContainer.value?.scrollTo({
top: this.scrollContainer.value?.scrollHeight,
behavior: "smooth",
});
}
/**
* Called after the component's properties have been updated
*/
updated(changedProperties: PropertyValues): void {
// If messages have changed, scroll to bottom if needed
if (changedProperties.has("messages") && this.messages.length > 0) {
if (this.scrollingState == "pinToLatest") {
setTimeout(() => this.scrollToBottom(), 50);
}
}
if (changedProperties.has("scrollContainer")) {
this.scrollContainer.value?.addEventListener(
"scroll",
this._handleScroll,
);
}
}
/**
* 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) {
const isAtBottom =
Math.abs(
this.scrollContainer.value.scrollHeight -
this.scrollContainer.value.clientHeight -
this.scrollContainer.value.scrollTop,
) <= 1;
if (isAtBottom) {
this.scrollingState = "pinToLatest";
} else {
// TODO: does scroll direction matter here?
this.scrollingState = "floating";
}
}
// See https://lit.dev/docs/components/lifecycle/
connectedCallback() {
super.connectedCallback();
// Listen for showCommitDiff events from the renderer
document.addEventListener(
"showCommitDiff",
this._handleShowCommitDiff as EventListener,
);
this.scrollContainer.value?.addEventListener("scroll", this._handleScroll);
}
// See https://lit.dev/docs/components/lifecycle/
disconnectedCallback() {
super.disconnectedCallback();
// Remove event listeners
document.removeEventListener(
"showCommitDiff",
this._handleShowCommitDiff as EventListener,
);
this.scrollContainer.value?.removeEventListener(
"scroll",
this._handleScroll,
);
}
// 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
if (this.messages.length === 0) {
return html`
<div id="scroll-container">
<div class="welcome-box">
<h2 class="welcome-box-title">How to use Sketch</h2>
<p class="welcome-box-content">
Sketch is an agentic coding assistant.
</p>
<p class="welcome-box-content">
Sketch has created a container with your repo.
</p>
<p class="welcome-box-content">
Ask it to implement a task or answer a question in the chat box
below. Sketch has created a container with your repo, and it can
edit and run your code in the container. Sketch will create
commits, which you can look at and review with comments in the
Diff tab. Once you're done, you'll find the changes ready to go in
a
<code>sketch/*</code> branch.
</p>
<p class="welcome-box-content">
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>
`;
}
// Otherwise render the regular timeline with messages
return html`
<div id="scroll-container">
<div class="timeline-container">
${repeat(this.messages, this.messageKey, (message, index) => {
let previousMessage: AgentMessage;
if (index > 0) {
previousMessage = this.messages[index - 1];
}
return html`<sketch-timeline-message
.message=${message}
.previousMessage=${previousMessage}
.open=${false}
></sketch-timeline-message>`;
})}
</div>
</div>
<div
id="jump-to-latest"
class="${this.scrollingState}"
@click=${this.scrollToBottom}
>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"sketch-timeline": SketchTimeline;
}
}