blob: c450330076f715a63c5cc1fd30cd02b3df0510d3 [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";
philip.zeyliger6d3de482025-06-10 19:38:14 -07005import { AgentMessage, State } 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
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070031 @property({ attribute: false })
32 firstMessageIndex: number = 0;
33
philip.zeyliger6d3de482025-06-10 19:38:14 -070034 @property({ attribute: false })
35 state: State | null = null;
36
Sean McCullough86b56862025-04-18 13:04:03 -070037 static styles = css`
Sean McCullough71941bd2025-04-18 13:31:48 -070038 /* Hide views initially to prevent flash of content */
39 .timeline-container .timeline,
40 .timeline-container .diff-view,
41 .timeline-container .chart-view,
42 .timeline-container .terminal-view {
43 visibility: hidden;
44 }
Sean McCullough86b56862025-04-18 13:04:03 -070045
Sean McCullough71941bd2025-04-18 13:31:48 -070046 /* Will be set by JavaScript once we know which view to display */
47 .timeline-container.view-initialized .timeline,
48 .timeline-container.view-initialized .diff-view,
49 .timeline-container.view-initialized .chart-view,
50 .timeline-container.view-initialized .terminal-view {
51 visibility: visible;
52 }
53
54 .timeline-container {
55 width: 100%;
56 position: relative;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000057 max-width: 100%;
58 margin: 0 auto;
59 padding: 0 15px;
60 box-sizing: border-box;
Philip Zeyligere31d2a92025-05-11 15:22:35 -070061 overflow-x: hidden;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070062 flex: 1;
Sean McCullough71941bd2025-04-18 13:31:48 -070063 }
64
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000065 /* Chat-like timeline styles */
Sean McCullough71941bd2025-04-18 13:31:48 -070066 .timeline {
67 position: relative;
68 margin: 10px 0;
69 scroll-behavior: smooth;
70 }
71
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000072 /* Remove the vertical timeline line */
Sean McCullough2c5bba42025-04-20 19:33:17 -070073
74 #scroll-container {
Philip Zeyligere31d2a92025-05-11 15:22:35 -070075 overflow-y: auto;
76 overflow-x: hidden;
Sean McCullough2c5bba42025-04-20 19:33:17 -070077 padding-left: 1em;
Philip Zeyligere31d2a92025-05-11 15:22:35 -070078 max-width: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070079 width: 100%;
Sean McCullough2c5bba42025-04-20 19:33:17 -070080 }
81 #jump-to-latest {
82 display: none;
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070083 position: absolute;
84 bottom: 20px;
85 right: 20px;
Sean McCullough2c5bba42025-04-20 19:33:17 -070086 background: rgb(33, 150, 243);
87 color: white;
88 border-radius: 8px;
89 padding: 0.5em;
90 margin: 0.5em;
91 font-size: x-large;
92 opacity: 0.5;
93 cursor: pointer;
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070094 z-index: 50;
Sean McCullough2c5bba42025-04-20 19:33:17 -070095 }
96 #jump-to-latest:hover {
97 opacity: 1;
98 }
99 #jump-to-latest.floating {
100 display: block;
101 }
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000102
103 /* Welcome box styles for the empty chat state */
104 .welcome-box {
105 margin: 2rem auto;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700106 max-width: 90%;
107 width: 90%;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000108 padding: 2rem;
109 border: 2px solid #e0e0e0;
110 border-radius: 8px;
111 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
112 background-color: #ffffff;
113 text-align: center;
114 }
115
116 .welcome-box-title {
117 font-size: 1.5rem;
118 font-weight: 600;
119 margin-bottom: 1.5rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700120 text-align: center;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000121 color: #333;
122 }
123
124 .welcome-box-content {
125 color: #666; /* Slightly grey font color */
126 line-height: 1.6;
127 font-size: 1rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700128 text-align: left;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000129 }
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000130
131 /* Thinking indicator styles */
132 .thinking-indicator {
133 padding-left: 85px;
134 margin-top: 5px;
135 margin-bottom: 15px;
136 display: flex;
137 }
138
139 .thinking-bubble {
140 background-color: #f1f1f1;
141 border-radius: 15px;
142 padding: 10px 15px;
143 max-width: 80px;
144 color: black;
145 position: relative;
146 border-bottom-left-radius: 5px;
147 }
148
149 .thinking-dots {
150 display: flex;
151 align-items: center;
152 justify-content: center;
153 gap: 4px;
154 height: 14px;
155 }
156
157 .dot {
158 width: 6px;
159 height: 6px;
160 background-color: #888;
161 border-radius: 50%;
162 opacity: 0.6;
163 }
164
165 .dot:nth-child(1) {
166 animation: pulse 1.5s infinite ease-in-out;
167 }
168
169 .dot:nth-child(2) {
170 animation: pulse 1.5s infinite ease-in-out 0.3s;
171 }
172
173 .dot:nth-child(3) {
174 animation: pulse 1.5s infinite ease-in-out 0.6s;
175 }
176
177 @keyframes pulse {
178 0%,
179 100% {
180 opacity: 0.4;
181 transform: scale(1);
182 }
183 50% {
184 opacity: 1;
185 transform: scale(1.2);
186 }
187 }
Sean McCullough86b56862025-04-18 13:04:03 -0700188 `;
189
190 constructor() {
191 super();
Sean McCullough71941bd2025-04-18 13:31:48 -0700192
Sean McCullough86b56862025-04-18 13:04:03 -0700193 // Binding methods
194 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700195 this._handleScroll = this._handleScroll.bind(this);
196 }
197
198 /**
199 * Scroll to the bottom of the timeline
200 */
201 private scrollToBottom(): void {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000202 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000203
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000204 // Use instant scroll to ensure we reach the exact bottom
205 this.scrollContainer.value.scrollTo({
206 top: this.scrollContainer.value.scrollHeight,
207 behavior: "instant",
Sean McCullough2c5bba42025-04-20 19:33:17 -0700208 });
209 }
Autoformatter71c73b52025-05-29 20:18:43 +0000210
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000211 /**
212 * Scroll to bottom with retry logic to handle dynamic content
213 */
214 private scrollToBottomWithRetry(): void {
215 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000216
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000217 let attempts = 0;
218 const maxAttempts = 5;
219 const retryInterval = 50;
Autoformatter71c73b52025-05-29 20:18:43 +0000220
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000221 const tryScroll = () => {
222 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000223
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000224 const container = this.scrollContainer.value;
225 const targetScrollTop = container.scrollHeight - container.clientHeight;
Autoformatter71c73b52025-05-29 20:18:43 +0000226
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000227 // Scroll to the calculated position
228 container.scrollTo({
229 top: targetScrollTop,
230 behavior: "instant",
231 });
Autoformatter71c73b52025-05-29 20:18:43 +0000232
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000233 attempts++;
Autoformatter71c73b52025-05-29 20:18:43 +0000234
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000235 // Check if we're actually at the bottom
236 const actualScrollTop = container.scrollTop;
237 const isAtBottom = Math.abs(targetScrollTop - actualScrollTop) <= 1;
Autoformatter71c73b52025-05-29 20:18:43 +0000238
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000239 if (!isAtBottom && attempts < maxAttempts) {
240 // Still not at bottom and we have attempts left, try again
241 setTimeout(tryScroll, retryInterval);
242 }
243 };
Autoformatter71c73b52025-05-29 20:18:43 +0000244
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000245 tryScroll();
246 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700247
248 /**
249 * Called after the component's properties have been updated
250 */
251 updated(changedProperties: PropertyValues): void {
252 // If messages have changed, scroll to bottom if needed
253 if (changedProperties.has("messages") && this.messages.length > 0) {
254 if (this.scrollingState == "pinToLatest") {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000255 // Use longer timeout and retry logic to handle dynamic content
256 setTimeout(() => this.scrollToBottomWithRetry(), 100);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700257 }
258 }
259 if (changedProperties.has("scrollContainer")) {
Pokey Rule4097e532025-04-24 18:55:28 +0100260 this.scrollContainer.value?.addEventListener(
261 "scroll",
262 this._handleScroll,
263 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700264 }
Sean McCullough86b56862025-04-18 13:04:03 -0700265 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700266
Sean McCullough86b56862025-04-18 13:04:03 -0700267 /**
268 * Handle showCommitDiff event
269 */
270 private _handleShowCommitDiff(event: CustomEvent) {
271 const { commitHash } = event.detail;
272 if (commitHash) {
273 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700274 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700275 detail: { commitHash },
276 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700277 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700278 });
279 this.dispatchEvent(newEvent);
280 }
281 }
282
Sean McCullough2c5bba42025-04-20 19:33:17 -0700283 private _handleScroll(event) {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000284 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000285
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000286 const container = this.scrollContainer.value;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700287 const isAtBottom =
288 Math.abs(
Autoformatter71c73b52025-05-29 20:18:43 +0000289 container.scrollHeight - container.clientHeight - container.scrollTop,
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000290 ) <= 3; // Increased tolerance to 3px for better detection
Autoformatter71c73b52025-05-29 20:18:43 +0000291
Sean McCullough2c5bba42025-04-20 19:33:17 -0700292 if (isAtBottom) {
293 this.scrollingState = "pinToLatest";
294 } else {
295 // TODO: does scroll direction matter here?
296 this.scrollingState = "floating";
297 }
298 }
299
Sean McCullough86b56862025-04-18 13:04:03 -0700300 // See https://lit.dev/docs/components/lifecycle/
301 connectedCallback() {
302 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700303
Sean McCullough86b56862025-04-18 13:04:03 -0700304 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700305 document.addEventListener(
306 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700307 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700308 );
Pokey Rule4097e532025-04-24 18:55:28 +0100309
310 this.scrollContainer.value?.addEventListener("scroll", this._handleScroll);
Sean McCullough86b56862025-04-18 13:04:03 -0700311 }
312
313 // See https://lit.dev/docs/components/lifecycle/
314 disconnectedCallback() {
315 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700316
Sean McCullough86b56862025-04-18 13:04:03 -0700317 // Remove event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -0700318 document.removeEventListener(
319 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700320 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700321 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700322
Pokey Rule4097e532025-04-24 18:55:28 +0100323 this.scrollContainer.value?.removeEventListener(
324 "scroll",
325 this._handleScroll,
326 );
Sean McCullough86b56862025-04-18 13:04:03 -0700327 }
328
Sean McCulloughd9f13372025-04-21 15:08:49 -0700329 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700330 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700331 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700332 // If the message has tool calls, and any of the tool_calls get a response, we need to
333 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700334 const toolCallResponses = message.tool_calls
335 ?.filter((tc) => tc.result_message)
336 .map((tc) => tc.tool_call_id)
337 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700338 return `message-${message.idx}-${toolCallResponses}`;
339 }
340
341 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000342 // Check if messages array is empty and render welcome box if it is
343 if (this.messages.length === 0) {
344 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700345 <div style="position: relative; height: 100%;">
346 <div id="scroll-container">
347 <div class="welcome-box">
348 <h2 class="welcome-box-title">How to use Sketch</h2>
349 <p class="welcome-box-content">
350 Sketch is an agentic coding assistant.
351 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700352
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700353 <p class="welcome-box-content">
354 Sketch has created a container with your repo.
355 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700356
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700357 <p class="welcome-box-content">
358 Ask it to implement a task or answer a question in the chat box
Autoformatter71c73b52025-05-29 20:18:43 +0000359 below. It can edit and run your code, all in the container.
360 Sketch will create commits in a newly created git branch, which
361 you can look at and comment on in the Diff tab. Once you're
362 done, you'll find that branch available in your (original) repo.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700363 </p>
364 <p class="welcome-box-content">
365 Because Sketch operates a container per session, you can run
Autoformatter71c73b52025-05-29 20:18:43 +0000366 Sketch in parallel to work on multiple ideas or even the same
367 idea with different approaches.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700368 </p>
369 </div>
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000370 </div>
371 </div>
372 `;
373 }
374
375 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000376 const isThinking =
377 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
378
Sean McCullough86b56862025-04-18 13:04:03 -0700379 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700380 <div style="position: relative; height: 100%;">
381 <div id="scroll-container">
382 <div class="timeline-container">
383 ${repeat(
384 this.messages.filter((msg) => !msg.hide_output),
385 this.messageKey,
386 (message, index) => {
387 let previousMessageIndex =
388 this.messages.findIndex((m) => m === message) - 1;
389 let previousMessage =
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000390 previousMessageIndex >= 0
391 ? this.messages[previousMessageIndex]
392 : undefined;
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000393
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700394 // Skip hidden messages when determining previous message
395 while (previousMessage && previousMessage.hide_output) {
396 previousMessageIndex--;
397 previousMessage =
398 previousMessageIndex >= 0
399 ? this.messages[previousMessageIndex]
400 : undefined;
401 }
402
403 return html`<sketch-timeline-message
404 .message=${message}
405 .previousMessage=${previousMessage}
406 .open=${false}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700407 .firstMessageIndex=${this.firstMessageIndex}
philip.zeyliger6d3de482025-06-10 19:38:14 -0700408 .state=${this.state}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700409 ></sketch-timeline-message>`;
410 },
411 )}
412 ${isThinking
413 ? html`
414 <div class="thinking-indicator">
415 <div class="thinking-bubble">
416 <div class="thinking-dots">
417 <div class="dot"></div>
418 <div class="dot"></div>
419 <div class="dot"></div>
420 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000421 </div>
422 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700423 `
424 : ""}
425 </div>
Sean McCullough2c5bba42025-04-20 19:33:17 -0700426 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700427 <div
428 id="jump-to-latest"
429 class="${this.scrollingState}"
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000430 @click=${this.scrollToBottomWithRetry}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700431 >
432
433 </div>
Sean McCullough71941bd2025-04-18 13:31:48 -0700434 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700435 `;
436 }
437}
438
439declare global {
440 interface HTMLElementTagNameMap {
441 "sketch-timeline": SketchTimeline;
442 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700443}