blob: 3dd17a227aae63f2f0fce6d84956a5664dc1b14b [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
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070031 @property({ attribute: false })
32 firstMessageIndex: number = 0;
33
Sean McCullough86b56862025-04-18 13:04:03 -070034 static styles = css`
Sean McCullough71941bd2025-04-18 13:31:48 -070035 /* Hide views initially to prevent flash of content */
36 .timeline-container .timeline,
37 .timeline-container .diff-view,
38 .timeline-container .chart-view,
39 .timeline-container .terminal-view {
40 visibility: hidden;
41 }
Sean McCullough86b56862025-04-18 13:04:03 -070042
Sean McCullough71941bd2025-04-18 13:31:48 -070043 /* Will be set by JavaScript once we know which view to display */
44 .timeline-container.view-initialized .timeline,
45 .timeline-container.view-initialized .diff-view,
46 .timeline-container.view-initialized .chart-view,
47 .timeline-container.view-initialized .terminal-view {
48 visibility: visible;
49 }
50
51 .timeline-container {
52 width: 100%;
53 position: relative;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000054 max-width: 100%;
55 margin: 0 auto;
56 padding: 0 15px;
57 box-sizing: border-box;
Philip Zeyligere31d2a92025-05-11 15:22:35 -070058 overflow-x: hidden;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070059 flex: 1;
Sean McCullough71941bd2025-04-18 13:31:48 -070060 }
61
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000062 /* Chat-like timeline styles */
Sean McCullough71941bd2025-04-18 13:31:48 -070063 .timeline {
64 position: relative;
65 margin: 10px 0;
66 scroll-behavior: smooth;
67 }
68
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000069 /* Remove the vertical timeline line */
Sean McCullough2c5bba42025-04-20 19:33:17 -070070
71 #scroll-container {
Philip Zeyligere31d2a92025-05-11 15:22:35 -070072 overflow-y: auto;
73 overflow-x: hidden;
Sean McCullough2c5bba42025-04-20 19:33:17 -070074 padding-left: 1em;
Philip Zeyligere31d2a92025-05-11 15:22:35 -070075 max-width: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070076 width: 100%;
Sean McCullough2c5bba42025-04-20 19:33:17 -070077 }
78 #jump-to-latest {
79 display: none;
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070080 position: absolute;
81 bottom: 20px;
82 right: 20px;
Sean McCullough2c5bba42025-04-20 19:33:17 -070083 background: rgb(33, 150, 243);
84 color: white;
85 border-radius: 8px;
86 padding: 0.5em;
87 margin: 0.5em;
88 font-size: x-large;
89 opacity: 0.5;
90 cursor: pointer;
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070091 z-index: 50;
Sean McCullough2c5bba42025-04-20 19:33:17 -070092 }
93 #jump-to-latest:hover {
94 opacity: 1;
95 }
96 #jump-to-latest.floating {
97 display: block;
98 }
Philip Zeyliger5cf49262025-04-29 18:35:55 +000099
100 /* Welcome box styles for the empty chat state */
101 .welcome-box {
102 margin: 2rem auto;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700103 max-width: 90%;
104 width: 90%;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000105 padding: 2rem;
106 border: 2px solid #e0e0e0;
107 border-radius: 8px;
108 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
109 background-color: #ffffff;
110 text-align: center;
111 }
112
113 .welcome-box-title {
114 font-size: 1.5rem;
115 font-weight: 600;
116 margin-bottom: 1.5rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700117 text-align: center;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000118 color: #333;
119 }
120
121 .welcome-box-content {
122 color: #666; /* Slightly grey font color */
123 line-height: 1.6;
124 font-size: 1rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700125 text-align: left;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000126 }
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000127
128 /* Thinking indicator styles */
129 .thinking-indicator {
130 padding-left: 85px;
131 margin-top: 5px;
132 margin-bottom: 15px;
133 display: flex;
134 }
135
136 .thinking-bubble {
137 background-color: #f1f1f1;
138 border-radius: 15px;
139 padding: 10px 15px;
140 max-width: 80px;
141 color: black;
142 position: relative;
143 border-bottom-left-radius: 5px;
144 }
145
146 .thinking-dots {
147 display: flex;
148 align-items: center;
149 justify-content: center;
150 gap: 4px;
151 height: 14px;
152 }
153
154 .dot {
155 width: 6px;
156 height: 6px;
157 background-color: #888;
158 border-radius: 50%;
159 opacity: 0.6;
160 }
161
162 .dot:nth-child(1) {
163 animation: pulse 1.5s infinite ease-in-out;
164 }
165
166 .dot:nth-child(2) {
167 animation: pulse 1.5s infinite ease-in-out 0.3s;
168 }
169
170 .dot:nth-child(3) {
171 animation: pulse 1.5s infinite ease-in-out 0.6s;
172 }
173
174 @keyframes pulse {
175 0%,
176 100% {
177 opacity: 0.4;
178 transform: scale(1);
179 }
180 50% {
181 opacity: 1;
182 transform: scale(1.2);
183 }
184 }
Sean McCullough86b56862025-04-18 13:04:03 -0700185 `;
186
187 constructor() {
188 super();
Sean McCullough71941bd2025-04-18 13:31:48 -0700189
Sean McCullough86b56862025-04-18 13:04:03 -0700190 // Binding methods
191 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700192 this._handleScroll = this._handleScroll.bind(this);
193 }
194
195 /**
196 * Scroll to the bottom of the timeline
197 */
198 private scrollToBottom(): void {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000199 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000200
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000201 // Use instant scroll to ensure we reach the exact bottom
202 this.scrollContainer.value.scrollTo({
203 top: this.scrollContainer.value.scrollHeight,
204 behavior: "instant",
Sean McCullough2c5bba42025-04-20 19:33:17 -0700205 });
206 }
Autoformatter71c73b52025-05-29 20:18:43 +0000207
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000208 /**
209 * Scroll to bottom with retry logic to handle dynamic content
210 */
211 private scrollToBottomWithRetry(): void {
212 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000213
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000214 let attempts = 0;
215 const maxAttempts = 5;
216 const retryInterval = 50;
Autoformatter71c73b52025-05-29 20:18:43 +0000217
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000218 const tryScroll = () => {
219 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000220
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000221 const container = this.scrollContainer.value;
222 const targetScrollTop = container.scrollHeight - container.clientHeight;
Autoformatter71c73b52025-05-29 20:18:43 +0000223
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000224 // Scroll to the calculated position
225 container.scrollTo({
226 top: targetScrollTop,
227 behavior: "instant",
228 });
Autoformatter71c73b52025-05-29 20:18:43 +0000229
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000230 attempts++;
Autoformatter71c73b52025-05-29 20:18:43 +0000231
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000232 // Check if we're actually at the bottom
233 const actualScrollTop = container.scrollTop;
234 const isAtBottom = Math.abs(targetScrollTop - actualScrollTop) <= 1;
Autoformatter71c73b52025-05-29 20:18:43 +0000235
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000236 if (!isAtBottom && attempts < maxAttempts) {
237 // Still not at bottom and we have attempts left, try again
238 setTimeout(tryScroll, retryInterval);
239 }
240 };
Autoformatter71c73b52025-05-29 20:18:43 +0000241
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000242 tryScroll();
243 }
Sean McCullough2c5bba42025-04-20 19:33:17 -0700244
245 /**
246 * Called after the component's properties have been updated
247 */
248 updated(changedProperties: PropertyValues): void {
249 // If messages have changed, scroll to bottom if needed
250 if (changedProperties.has("messages") && this.messages.length > 0) {
251 if (this.scrollingState == "pinToLatest") {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000252 // Use longer timeout and retry logic to handle dynamic content
253 setTimeout(() => this.scrollToBottomWithRetry(), 100);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700254 }
255 }
256 if (changedProperties.has("scrollContainer")) {
Pokey Rule4097e532025-04-24 18:55:28 +0100257 this.scrollContainer.value?.addEventListener(
258 "scroll",
259 this._handleScroll,
260 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700261 }
Sean McCullough86b56862025-04-18 13:04:03 -0700262 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700263
Sean McCullough86b56862025-04-18 13:04:03 -0700264 /**
265 * Handle showCommitDiff event
266 */
267 private _handleShowCommitDiff(event: CustomEvent) {
268 const { commitHash } = event.detail;
269 if (commitHash) {
270 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700271 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700272 detail: { commitHash },
273 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700274 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700275 });
276 this.dispatchEvent(newEvent);
277 }
278 }
279
Sean McCullough2c5bba42025-04-20 19:33:17 -0700280 private _handleScroll(event) {
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000281 if (!this.scrollContainer.value) return;
Autoformatter71c73b52025-05-29 20:18:43 +0000282
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000283 const container = this.scrollContainer.value;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700284 const isAtBottom =
285 Math.abs(
Autoformatter71c73b52025-05-29 20:18:43 +0000286 container.scrollHeight - container.clientHeight - container.scrollTop,
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000287 ) <= 3; // Increased tolerance to 3px for better detection
Autoformatter71c73b52025-05-29 20:18:43 +0000288
Sean McCullough2c5bba42025-04-20 19:33:17 -0700289 if (isAtBottom) {
290 this.scrollingState = "pinToLatest";
291 } else {
292 // TODO: does scroll direction matter here?
293 this.scrollingState = "floating";
294 }
295 }
296
Sean McCullough86b56862025-04-18 13:04:03 -0700297 // See https://lit.dev/docs/components/lifecycle/
298 connectedCallback() {
299 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700300
Sean McCullough86b56862025-04-18 13:04:03 -0700301 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700302 document.addEventListener(
303 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700304 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700305 );
Pokey Rule4097e532025-04-24 18:55:28 +0100306
307 this.scrollContainer.value?.addEventListener("scroll", this._handleScroll);
Sean McCullough86b56862025-04-18 13:04:03 -0700308 }
309
310 // See https://lit.dev/docs/components/lifecycle/
311 disconnectedCallback() {
312 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700313
Sean McCullough86b56862025-04-18 13:04:03 -0700314 // Remove event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -0700315 document.removeEventListener(
316 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700317 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700318 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700319
Pokey Rule4097e532025-04-24 18:55:28 +0100320 this.scrollContainer.value?.removeEventListener(
321 "scroll",
322 this._handleScroll,
323 );
Sean McCullough86b56862025-04-18 13:04:03 -0700324 }
325
Sean McCulloughd9f13372025-04-21 15:08:49 -0700326 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700327 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700328 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700329 // If the message has tool calls, and any of the tool_calls get a response, we need to
330 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700331 const toolCallResponses = message.tool_calls
332 ?.filter((tc) => tc.result_message)
333 .map((tc) => tc.tool_call_id)
334 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700335 return `message-${message.idx}-${toolCallResponses}`;
336 }
337
338 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000339 // Check if messages array is empty and render welcome box if it is
340 if (this.messages.length === 0) {
341 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700342 <div style="position: relative; height: 100%;">
343 <div id="scroll-container">
344 <div class="welcome-box">
345 <h2 class="welcome-box-title">How to use Sketch</h2>
346 <p class="welcome-box-content">
347 Sketch is an agentic coding assistant.
348 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700349
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700350 <p class="welcome-box-content">
351 Sketch has created a container with your repo.
352 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700353
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700354 <p class="welcome-box-content">
355 Ask it to implement a task or answer a question in the chat box
Autoformatter71c73b52025-05-29 20:18:43 +0000356 below. It can edit and run your code, all in the container.
357 Sketch will create commits in a newly created git branch, which
358 you can look at and comment on in the Diff tab. Once you're
359 done, you'll find that branch available in your (original) repo.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700360 </p>
361 <p class="welcome-box-content">
362 Because Sketch operates a container per session, you can run
Autoformatter71c73b52025-05-29 20:18:43 +0000363 Sketch in parallel to work on multiple ideas or even the same
364 idea with different approaches.
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700365 </p>
366 </div>
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000367 </div>
368 </div>
369 `;
370 }
371
372 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000373 const isThinking =
374 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
375
Sean McCullough86b56862025-04-18 13:04:03 -0700376 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700377 <div style="position: relative; height: 100%;">
378 <div id="scroll-container">
379 <div class="timeline-container">
380 ${repeat(
381 this.messages.filter((msg) => !msg.hide_output),
382 this.messageKey,
383 (message, index) => {
384 let previousMessageIndex =
385 this.messages.findIndex((m) => m === message) - 1;
386 let previousMessage =
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000387 previousMessageIndex >= 0
388 ? this.messages[previousMessageIndex]
389 : undefined;
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000390
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700391 // Skip hidden messages when determining previous message
392 while (previousMessage && previousMessage.hide_output) {
393 previousMessageIndex--;
394 previousMessage =
395 previousMessageIndex >= 0
396 ? this.messages[previousMessageIndex]
397 : undefined;
398 }
399
400 return html`<sketch-timeline-message
401 .message=${message}
402 .previousMessage=${previousMessage}
403 .open=${false}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700404 .firstMessageIndex=${this.firstMessageIndex}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700405 ></sketch-timeline-message>`;
406 },
407 )}
408 ${isThinking
409 ? html`
410 <div class="thinking-indicator">
411 <div class="thinking-bubble">
412 <div class="thinking-dots">
413 <div class="dot"></div>
414 <div class="dot"></div>
415 <div class="dot"></div>
416 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000417 </div>
418 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700419 `
420 : ""}
421 </div>
Sean McCullough2c5bba42025-04-20 19:33:17 -0700422 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700423 <div
424 id="jump-to-latest"
425 class="${this.scrollingState}"
Josh Bleecher Snyderdee39e02025-05-29 14:25:08 +0000426 @click=${this.scrollToBottomWithRetry}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700427 >
428
429 </div>
Sean McCullough71941bd2025-04-18 13:31:48 -0700430 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700431 `;
432 }
433}
434
435declare global {
436 interface HTMLElementTagNameMap {
437 "sketch-timeline": SketchTimeline;
438 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700439}