blob: 1e63f325c9ee00b7f12e77bac35fd7443cc59534 [file] [log] [blame]
Sean McCullough71941bd2025-04-18 13:31:48 -07001import { css, html, LitElement } from "lit";
Sean McCullough2c5bba42025-04-20 19:33:17 -07002import { PropertyValues } from "lit";
Sean McCullough71941bd2025-04-18 13:31:48 -07003import { repeat } from "lit/directives/repeat.js";
Sean McCullough2c5bba42025-04-20 19:33:17 -07004import { customElement, property, state } from "lit/decorators.js";
Sean McCulloughd9f13372025-04-21 15:08:49 -07005import { AgentMessage } from "../types";
Sean McCullough71941bd2025-04-18 13:31:48 -07006import "./sketch-timeline-message";
Sean McCullough86b56862025-04-18 13:04:03 -07007
Sean McCullough71941bd2025-04-18 13:31:48 -07008@customElement("sketch-timeline")
Sean McCullough86b56862025-04-18 13:04:03 -07009export class SketchTimeline extends LitElement {
Pokey Rulee2a8c2f2025-04-23 15:09:25 +010010 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -070011 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -070012
Sean McCullough2c5bba42025-04-20 19:33:17 -070013 // Track if we should scroll to the bottom
14 @state()
15 private scrollingState: "pinToLatest" | "floating" = "pinToLatest";
16
Pokey Rulee2a8c2f2025-04-23 15:09:25 +010017 @property({ attribute: false })
18 scrollContainer: HTMLElement;
Sean McCullough2c5bba42025-04-20 19:33:17 -070019
Sean McCullough86b56862025-04-18 13:04:03 -070020 static styles = css`
Sean McCullough71941bd2025-04-18 13:31:48 -070021 /* Hide views initially to prevent flash of content */
22 .timeline-container .timeline,
23 .timeline-container .diff-view,
24 .timeline-container .chart-view,
25 .timeline-container .terminal-view {
26 visibility: hidden;
27 }
Sean McCullough86b56862025-04-18 13:04:03 -070028
Sean McCullough71941bd2025-04-18 13:31:48 -070029 /* Will be set by JavaScript once we know which view to display */
30 .timeline-container.view-initialized .timeline,
31 .timeline-container.view-initialized .diff-view,
32 .timeline-container.view-initialized .chart-view,
33 .timeline-container.view-initialized .terminal-view {
34 visibility: visible;
35 }
36
37 .timeline-container {
38 width: 100%;
39 position: relative;
40 }
41
42 /* Timeline styles that should remain unchanged */
43 .timeline {
44 position: relative;
45 margin: 10px 0;
46 scroll-behavior: smooth;
47 }
48
49 .timeline::before {
50 content: "";
51 position: absolute;
52 top: 0;
53 bottom: 0;
54 left: 15px;
55 width: 2px;
56 background: #e0e0e0;
57 border-radius: 1px;
58 }
59
60 /* Hide the timeline vertical line when there are no messages */
61 .timeline.empty::before {
62 display: none;
63 }
Sean McCullough2c5bba42025-04-20 19:33:17 -070064
65 #scroll-container {
66 overflow: auto;
67 padding-left: 1em;
68 }
69 #jump-to-latest {
70 display: none;
71 position: fixed;
72 bottom: 100px;
73 right: 0;
74 background: rgb(33, 150, 243);
75 color: white;
76 border-radius: 8px;
77 padding: 0.5em;
78 margin: 0.5em;
79 font-size: x-large;
80 opacity: 0.5;
81 cursor: pointer;
82 }
83 #jump-to-latest:hover {
84 opacity: 1;
85 }
86 #jump-to-latest.floating {
87 display: block;
88 }
Sean McCullough86b56862025-04-18 13:04:03 -070089 `;
90
91 constructor() {
92 super();
Sean McCullough71941bd2025-04-18 13:31:48 -070093
Sean McCullough86b56862025-04-18 13:04:03 -070094 // Binding methods
95 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -070096 this._handleScroll = this._handleScroll.bind(this);
97 }
98
99 /**
100 * Scroll to the bottom of the timeline
101 */
102 private scrollToBottom(): void {
103 this.scrollContainer?.scrollTo({
104 top: this.scrollContainer?.scrollHeight,
105 behavior: "smooth",
106 });
107 }
108
109 /**
110 * Called after the component's properties have been updated
111 */
112 updated(changedProperties: PropertyValues): void {
113 // If messages have changed, scroll to bottom if needed
114 if (changedProperties.has("messages") && this.messages.length > 0) {
115 if (this.scrollingState == "pinToLatest") {
116 setTimeout(() => this.scrollToBottom(), 50);
117 }
118 }
119 if (changedProperties.has("scrollContainer")) {
120 this.scrollContainer?.addEventListener("scroll", this._handleScroll);
121 }
Sean McCullough86b56862025-04-18 13:04:03 -0700122 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700123
Sean McCullough86b56862025-04-18 13:04:03 -0700124 /**
125 * Handle showCommitDiff event
126 */
127 private _handleShowCommitDiff(event: CustomEvent) {
128 const { commitHash } = event.detail;
129 if (commitHash) {
130 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700131 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700132 detail: { commitHash },
133 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700134 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700135 });
136 this.dispatchEvent(newEvent);
137 }
138 }
139
Sean McCullough2c5bba42025-04-20 19:33:17 -0700140 private _handleScroll(event) {
141 const isAtBottom =
142 Math.abs(
143 this.scrollContainer.scrollHeight -
144 this.scrollContainer.clientHeight -
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100145 this.scrollContainer.scrollTop
Sean McCullough2c5bba42025-04-20 19:33:17 -0700146 ) <= 1;
147 if (isAtBottom) {
148 this.scrollingState = "pinToLatest";
149 } else {
150 // TODO: does scroll direction matter here?
151 this.scrollingState = "floating";
152 }
153 }
154
Sean McCullough86b56862025-04-18 13:04:03 -0700155 // See https://lit.dev/docs/components/lifecycle/
156 connectedCallback() {
157 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700158
Sean McCullough86b56862025-04-18 13:04:03 -0700159 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700160 document.addEventListener(
161 "showCommitDiff",
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100162 this._handleShowCommitDiff as EventListener
Sean McCullough71941bd2025-04-18 13:31:48 -0700163 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700164 this.scrollContainer?.addEventListener("scroll", this._handleScroll);
Sean McCullough86b56862025-04-18 13:04:03 -0700165 }
166
167 // See https://lit.dev/docs/components/lifecycle/
168 disconnectedCallback() {
169 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700170
Sean McCullough86b56862025-04-18 13:04:03 -0700171 // Remove event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -0700172 document.removeEventListener(
173 "showCommitDiff",
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100174 this._handleShowCommitDiff as EventListener
Sean McCullough71941bd2025-04-18 13:31:48 -0700175 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700176
177 this.scrollContainer?.removeEventListener("scroll", this._handleScroll);
Sean McCullough86b56862025-04-18 13:04:03 -0700178 }
179
Sean McCulloughd9f13372025-04-21 15:08:49 -0700180 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700181 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700182 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700183 // If the message has tool calls, and any of the tool_calls get a response, we need to
184 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700185 const toolCallResponses = message.tool_calls
186 ?.filter((tc) => tc.result_message)
187 .map((tc) => tc.tool_call_id)
188 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700189 return `message-${message.idx}-${toolCallResponses}`;
190 }
191
192 render() {
193 return html`
Sean McCullough2c5bba42025-04-20 19:33:17 -0700194 <div id="scroll-container">
195 <div class="timeline-container">
196 ${repeat(this.messages, this.messageKey, (message, index) => {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700197 let previousMessage: AgentMessage;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700198 if (index > 0) {
199 previousMessage = this.messages[index - 1];
200 }
201 return html`<sketch-timeline-message
202 .message=${message}
203 .previousMessage=${previousMessage}
Sean McCullough2deac842025-04-21 18:17:57 -0700204 .open=${index == this.messages.length - 1}
Sean McCullough2c5bba42025-04-20 19:33:17 -0700205 ></sketch-timeline-message>`;
206 })}
207 </div>
208 </div>
209 <div
210 id="jump-to-latest"
211 class="${this.scrollingState}"
212 @click=${this.scrollToBottom}
213 >
214
Sean McCullough71941bd2025-04-18 13:31:48 -0700215 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700216 `;
217 }
218}
219
220declare global {
221 interface HTMLElementTagNameMap {
222 "sketch-timeline": SketchTimeline;
223 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700224}