blob: cc16115079f6a4906ceea5b9afb10eca65ce2e49 [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";
Pokey Rule4097e532025-04-24 18:55:28 +01007import { Ref } from "lit/directives/ref";
Sean McCullough86b56862025-04-18 13:04:03 -07008
Sean McCullough71941bd2025-04-18 13:31:48 -07009@customElement("sketch-timeline")
Sean McCullough86b56862025-04-18 13:04:03 -070010export class SketchTimeline extends LitElement {
Pokey Rulee2a8c2f2025-04-23 15:09:25 +010011 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -070012 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -070013
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000014 // Active state properties to show thinking indicator
15 @property({ attribute: false })
16 agentState: string | null = null;
17
18 @property({ attribute: false })
19 llmCalls: number = 0;
20
21 @property({ attribute: false })
22 toolCalls: string[] = [];
23
Sean McCullough2c5bba42025-04-20 19:33:17 -070024 // Track if we should scroll to the bottom
25 @state()
26 private scrollingState: "pinToLatest" | "floating" = "pinToLatest";
27
Pokey Rulee2a8c2f2025-04-23 15:09:25 +010028 @property({ attribute: false })
Pokey Rule4097e532025-04-24 18:55:28 +010029 scrollContainer: Ref<HTMLElement>;
Sean McCullough2c5bba42025-04-20 19:33:17 -070030
Sean McCullough86b56862025-04-18 13:04:03 -070031 static styles = css`
Sean McCullough71941bd2025-04-18 13:31:48 -070032 /* Hide views initially to prevent flash of content */
33 .timeline-container .timeline,
34 .timeline-container .diff-view,
35 .timeline-container .chart-view,
36 .timeline-container .terminal-view {
37 visibility: hidden;
38 }
Sean McCullough86b56862025-04-18 13:04:03 -070039
Sean McCullough71941bd2025-04-18 13:31:48 -070040 /* Will be set by JavaScript once we know which view to display */
41 .timeline-container.view-initialized .timeline,
42 .timeline-container.view-initialized .diff-view,
43 .timeline-container.view-initialized .chart-view,
44 .timeline-container.view-initialized .terminal-view {
45 visibility: visible;
46 }
47
48 .timeline-container {
49 width: 100%;
50 position: relative;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000051 max-width: 100%;
52 margin: 0 auto;
53 padding: 0 15px;
54 box-sizing: border-box;
Philip Zeyligere31d2a92025-05-11 15:22:35 -070055 overflow-x: hidden;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070056 flex: 1;
Sean McCullough71941bd2025-04-18 13:31:48 -070057 }
58
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000059 /* Chat-like timeline styles */
Sean McCullough71941bd2025-04-18 13:31:48 -070060 .timeline {
61 position: relative;
62 margin: 10px 0;
63 scroll-behavior: smooth;
64 }
65
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000066 /* Remove the vertical timeline line */
Sean McCullough2c5bba42025-04-20 19:33:17 -070067
68 #scroll-container {
Philip Zeyligere31d2a92025-05-11 15:22:35 -070069 overflow-y: auto;
70 overflow-x: hidden;
Sean McCullough2c5bba42025-04-20 19:33:17 -070071 padding-left: 1em;
Philip Zeyligere31d2a92025-05-11 15:22:35 -070072 max-width: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070073 width: 100%;
Sean McCullough2c5bba42025-04-20 19:33:17 -070074 }
75 #jump-to-latest {
76 display: none;
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070077 position: absolute;
78 bottom: 20px;
79 right: 20px;
Sean McCullough2c5bba42025-04-20 19:33:17 -070080 background: rgb(33, 150, 243);
81 color: white;
82 border-radius: 8px;
83 padding: 0.5em;
84 margin: 0.5em;
85 font-size: x-large;
86 opacity: 0.5;
87 cursor: pointer;
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070088 z-index: 50;
Sean McCullough2c5bba42025-04-20 19:33:17 -070089 }
90 #jump-to-latest:hover {
91 opacity: 1;
92 }
93 #jump-to-latest.floating {
94 display: block;
95 }
Philip Zeyliger5cf49262025-04-29 18:35:55 +000096
97 /* Welcome box styles for the empty chat state */
98 .welcome-box {
99 margin: 2rem auto;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700100 max-width: 90%;
101 width: 90%;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000102 padding: 2rem;
103 border: 2px solid #e0e0e0;
104 border-radius: 8px;
105 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
106 background-color: #ffffff;
107 text-align: center;
108 }
109
110 .welcome-box-title {
111 font-size: 1.5rem;
112 font-weight: 600;
113 margin-bottom: 1.5rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700114 text-align: center;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000115 color: #333;
116 }
117
118 .welcome-box-content {
119 color: #666; /* Slightly grey font color */
120 line-height: 1.6;
121 font-size: 1rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700122 text-align: left;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000123 }
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000124
125 /* Thinking indicator styles */
126 .thinking-indicator {
127 padding-left: 85px;
128 margin-top: 5px;
129 margin-bottom: 15px;
130 display: flex;
131 }
132
133 .thinking-bubble {
134 background-color: #f1f1f1;
135 border-radius: 15px;
136 padding: 10px 15px;
137 max-width: 80px;
138 color: black;
139 position: relative;
140 border-bottom-left-radius: 5px;
141 }
142
143 .thinking-dots {
144 display: flex;
145 align-items: center;
146 justify-content: center;
147 gap: 4px;
148 height: 14px;
149 }
150
151 .dot {
152 width: 6px;
153 height: 6px;
154 background-color: #888;
155 border-radius: 50%;
156 opacity: 0.6;
157 }
158
159 .dot:nth-child(1) {
160 animation: pulse 1.5s infinite ease-in-out;
161 }
162
163 .dot:nth-child(2) {
164 animation: pulse 1.5s infinite ease-in-out 0.3s;
165 }
166
167 .dot:nth-child(3) {
168 animation: pulse 1.5s infinite ease-in-out 0.6s;
169 }
170
171 @keyframes pulse {
172 0%,
173 100% {
174 opacity: 0.4;
175 transform: scale(1);
176 }
177 50% {
178 opacity: 1;
179 transform: scale(1.2);
180 }
181 }
Sean McCullough86b56862025-04-18 13:04:03 -0700182 `;
183
184 constructor() {
185 super();
Sean McCullough71941bd2025-04-18 13:31:48 -0700186
Sean McCullough86b56862025-04-18 13:04:03 -0700187 // Binding methods
188 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700189 this._handleScroll = this._handleScroll.bind(this);
190 }
191
192 /**
193 * Scroll to the bottom of the timeline
194 */
195 private scrollToBottom(): void {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000196 if (!this.scrollContainer.value) return;
197
198 // Use instant scroll to ensure we reach the exact bottom
199 this.scrollContainer.value.scrollTo({
200 top: this.scrollContainer.value.scrollHeight,
201 behavior: "instant",
Sean McCullough2c5bba42025-04-20 19:33:17 -0700202 });
203 }
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000204
205 /**
206 * Scroll to bottom with retry logic to handle dynamic content
207 */
208 private scrollToBottomWithRetry(): void {
209 if (!this.scrollContainer.value) return;
210
211 let attempts = 0;
212 const maxAttempts = 5;
213 const retryInterval = 50;
214
215 const tryScroll = () => {
216 if (!this.scrollContainer.value) return;
217
218 const container = this.scrollContainer.value;
219 const targetScrollTop = container.scrollHeight - container.clientHeight;
220
221 // Scroll to the calculated position
222 container.scrollTo({
223 top: targetScrollTop,
224 behavior: "instant",
225 });
226
227 attempts++;
228
229 // Check if we're actually at the bottom
230 const actualScrollTop = container.scrollTop;
231 const isAtBottom = Math.abs(targetScrollTop - actualScrollTop) <= 1;
232
233 if (!isAtBottom && attempts < maxAttempts) {
234 // Still not at bottom and we have attempts left, try again
235 setTimeout(tryScroll, retryInterval);
236 }
237 };
238
239 tryScroll();
240 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700241
242 /**
243 * Called after the component's properties have been updated
244 */
245 updated(changedProperties: PropertyValues): void {
246 // If messages have changed, scroll to bottom if needed
247 if (changedProperties.has("messages") && this.messages.length > 0) {
248 if (this.scrollingState == "pinToLatest") {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000249 // Use longer timeout and retry logic to handle dynamic content
250 setTimeout(() => this.scrollToBottomWithRetry(), 100);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700251 }
252 }
253 if (changedProperties.has("scrollContainer")) {
Pokey Rule4097e532025-04-24 18:55:28 +0100254 this.scrollContainer.value?.addEventListener(
255 "scroll",
256 this._handleScroll,
257 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700258 }
Sean McCullough86b56862025-04-18 13:04:03 -0700259 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700260
Sean McCullough86b56862025-04-18 13:04:03 -0700261 /**
262 * Handle showCommitDiff event
263 */
264 private _handleShowCommitDiff(event: CustomEvent) {
265 const { commitHash } = event.detail;
266 if (commitHash) {
267 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700268 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700269 detail: { commitHash },
270 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700271 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700272 });
273 this.dispatchEvent(newEvent);
274 }
275 }
276
Sean McCullough2c5bba42025-04-20 19:33:17 -0700277 private _handleScroll(event) {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000278 if (!this.scrollContainer.value) return;
279
280 const container = this.scrollContainer.value;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700281 const isAtBottom =
282 Math.abs(
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000283 container.scrollHeight - container.clientHeight - container.scrollTop
284 ) <= 3; // Increased tolerance to 3px for better detection
285
Sean McCullough2c5bba42025-04-20 19:33:17 -0700286 if (isAtBottom) {
287 this.scrollingState = "pinToLatest";
288 } else {
289 // TODO: does scroll direction matter here?
290 this.scrollingState = "floating";
291 }
292 }
293
Sean McCullough86b56862025-04-18 13:04:03 -0700294 // See https://lit.dev/docs/components/lifecycle/
295 connectedCallback() {
296 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700297
Sean McCullough86b56862025-04-18 13:04:03 -0700298 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700299 document.addEventListener(
300 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700301 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700302 );
Pokey Rule4097e532025-04-24 18:55:28 +0100303
304 this.scrollContainer.value?.addEventListener("scroll", this._handleScroll);
Sean McCullough86b56862025-04-18 13:04:03 -0700305 }
306
307 // See https://lit.dev/docs/components/lifecycle/
308 disconnectedCallback() {
309 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700310
Sean McCullough86b56862025-04-18 13:04:03 -0700311 // Remove event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -0700312 document.removeEventListener(
313 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700314 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700315 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700316
Pokey Rule4097e532025-04-24 18:55:28 +0100317 this.scrollContainer.value?.removeEventListener(
318 "scroll",
319 this._handleScroll,
320 );
Sean McCullough86b56862025-04-18 13:04:03 -0700321 }
322
Sean McCulloughd9f13372025-04-21 15:08:49 -0700323 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700324 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700325 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700326 // If the message has tool calls, and any of the tool_calls get a response, we need to
327 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700328 const toolCallResponses = message.tool_calls
329 ?.filter((tc) => tc.result_message)
330 .map((tc) => tc.tool_call_id)
331 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700332 return `message-${message.idx}-${toolCallResponses}`;
333 }
334
335 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000336 // Check if messages array is empty and render welcome box if it is
337 if (this.messages.length === 0) {
338 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700339 <div style="position: relative; height: 100%;">
340 <div id="scroll-container">
341 <div class="welcome-box">
342 <h2 class="welcome-box-title">How to use Sketch</h2>
343 <p class="welcome-box-content">
344 Sketch is an agentic coding assistant.
345 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700346
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700347 <p class="welcome-box-content">
348 Sketch has created a container with your repo.
349 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700350
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700351 <p class="welcome-box-content">
352 Ask it to implement a task or answer a question in the chat box
353 below. It can edit and run your code, all in the container. Sketch
354 will create commits in a newly created git branch, which you can
355 look at and comment on in the Diff tab. Once you're done, you'll
356 find that branch available in your (original) repo.
357 </p>
358 <p class="welcome-box-content">
359 Because Sketch operates a container per session, you can run
360 Sketch in parallel to work on multiple ideas or even the same idea
361 with different approaches.
362 </p>
363 </div>
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000364 </div>
365 </div>
366 `;
367 }
368
369 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000370 const isThinking =
371 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
372
Sean McCullough86b56862025-04-18 13:04:03 -0700373 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700374 <div style="position: relative; height: 100%;">
375 <div id="scroll-container">
376 <div class="timeline-container">
377 ${repeat(
378 this.messages.filter((msg) => !msg.hide_output),
379 this.messageKey,
380 (message, index) => {
381 let previousMessageIndex =
382 this.messages.findIndex((m) => m === message) - 1;
383 let previousMessage =
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000384 previousMessageIndex >= 0
385 ? this.messages[previousMessageIndex]
386 : undefined;
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000387
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700388 // Skip hidden messages when determining previous message
389 while (previousMessage && previousMessage.hide_output) {
390 previousMessageIndex--;
391 previousMessage =
392 previousMessageIndex >= 0
393 ? this.messages[previousMessageIndex]
394 : undefined;
395 }
396
397 return html`<sketch-timeline-message
398 .message=${message}
399 .previousMessage=${previousMessage}
400 .open=${false}
401 ></sketch-timeline-message>`;
402 },
403 )}
404 ${isThinking
405 ? html`
406 <div class="thinking-indicator">
407 <div class="thinking-bubble">
408 <div class="thinking-dots">
409 <div class="dot"></div>
410 <div class="dot"></div>
411 <div class="dot"></div>
412 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000413 </div>
414 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700415 `
416 : ""}
417 </div>
Sean McCullough2c5bba42025-04-20 19:33:17 -0700418 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700419 <div
420 id="jump-to-latest"
421 class="${this.scrollingState}"
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000422 @click=${this.scrollToBottomWithRetry}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700423 >
424
425 </div>
Sean McCullough71941bd2025-04-18 13:31:48 -0700426 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700427 `;
428 }
429}
430
431declare global {
432 interface HTMLElementTagNameMap {
433 "sketch-timeline": SketchTimeline;
434 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700435}