blob: 1b2757038225400b5288808016a52bbbd2a109ed [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;
77 position: fixed;
78 bottom: 100px;
79 right: 0;
80 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;
88 }
89 #jump-to-latest:hover {
90 opacity: 1;
91 }
92 #jump-to-latest.floating {
93 display: block;
94 }
Philip Zeyliger5cf49262025-04-29 18:35:55 +000095
96 /* Welcome box styles for the empty chat state */
97 .welcome-box {
98 margin: 2rem auto;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070099 max-width: 90%;
100 width: 90%;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000101 padding: 2rem;
102 border: 2px solid #e0e0e0;
103 border-radius: 8px;
104 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
105 background-color: #ffffff;
106 text-align: center;
107 }
108
109 .welcome-box-title {
110 font-size: 1.5rem;
111 font-weight: 600;
112 margin-bottom: 1.5rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700113 text-align: center;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000114 color: #333;
115 }
116
117 .welcome-box-content {
118 color: #666; /* Slightly grey font color */
119 line-height: 1.6;
120 font-size: 1rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700121 text-align: left;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000122 }
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000123
124 /* Thinking indicator styles */
125 .thinking-indicator {
126 padding-left: 85px;
127 margin-top: 5px;
128 margin-bottom: 15px;
129 display: flex;
130 }
131
132 .thinking-bubble {
133 background-color: #f1f1f1;
134 border-radius: 15px;
135 padding: 10px 15px;
136 max-width: 80px;
137 color: black;
138 position: relative;
139 border-bottom-left-radius: 5px;
140 }
141
142 .thinking-dots {
143 display: flex;
144 align-items: center;
145 justify-content: center;
146 gap: 4px;
147 height: 14px;
148 }
149
150 .dot {
151 width: 6px;
152 height: 6px;
153 background-color: #888;
154 border-radius: 50%;
155 opacity: 0.6;
156 }
157
158 .dot:nth-child(1) {
159 animation: pulse 1.5s infinite ease-in-out;
160 }
161
162 .dot:nth-child(2) {
163 animation: pulse 1.5s infinite ease-in-out 0.3s;
164 }
165
166 .dot:nth-child(3) {
167 animation: pulse 1.5s infinite ease-in-out 0.6s;
168 }
169
170 @keyframes pulse {
171 0%,
172 100% {
173 opacity: 0.4;
174 transform: scale(1);
175 }
176 50% {
177 opacity: 1;
178 transform: scale(1.2);
179 }
180 }
Sean McCullough86b56862025-04-18 13:04:03 -0700181 `;
182
183 constructor() {
184 super();
Sean McCullough71941bd2025-04-18 13:31:48 -0700185
Sean McCullough86b56862025-04-18 13:04:03 -0700186 // Binding methods
187 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700188 this._handleScroll = this._handleScroll.bind(this);
189 }
190
191 /**
192 * Scroll to the bottom of the timeline
193 */
194 private scrollToBottom(): void {
Pokey Rule4097e532025-04-24 18:55:28 +0100195 this.scrollContainer.value?.scrollTo({
196 top: this.scrollContainer.value?.scrollHeight,
Sean McCullough2c5bba42025-04-20 19:33:17 -0700197 behavior: "smooth",
198 });
199 }
200
201 /**
202 * Called after the component's properties have been updated
203 */
204 updated(changedProperties: PropertyValues): void {
205 // If messages have changed, scroll to bottom if needed
206 if (changedProperties.has("messages") && this.messages.length > 0) {
207 if (this.scrollingState == "pinToLatest") {
208 setTimeout(() => this.scrollToBottom(), 50);
209 }
210 }
211 if (changedProperties.has("scrollContainer")) {
Pokey Rule4097e532025-04-24 18:55:28 +0100212 this.scrollContainer.value?.addEventListener(
213 "scroll",
214 this._handleScroll,
215 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700216 }
Sean McCullough86b56862025-04-18 13:04:03 -0700217 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700218
Sean McCullough86b56862025-04-18 13:04:03 -0700219 /**
220 * Handle showCommitDiff event
221 */
222 private _handleShowCommitDiff(event: CustomEvent) {
223 const { commitHash } = event.detail;
224 if (commitHash) {
225 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700226 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700227 detail: { commitHash },
228 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700229 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700230 });
231 this.dispatchEvent(newEvent);
232 }
233 }
234
Sean McCullough2c5bba42025-04-20 19:33:17 -0700235 private _handleScroll(event) {
236 const isAtBottom =
237 Math.abs(
Pokey Rule4097e532025-04-24 18:55:28 +0100238 this.scrollContainer.value.scrollHeight -
239 this.scrollContainer.value.clientHeight -
240 this.scrollContainer.value.scrollTop,
Sean McCullough2c5bba42025-04-20 19:33:17 -0700241 ) <= 1;
242 if (isAtBottom) {
243 this.scrollingState = "pinToLatest";
244 } else {
245 // TODO: does scroll direction matter here?
246 this.scrollingState = "floating";
247 }
248 }
249
Sean McCullough86b56862025-04-18 13:04:03 -0700250 // See https://lit.dev/docs/components/lifecycle/
251 connectedCallback() {
252 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700253
Sean McCullough86b56862025-04-18 13:04:03 -0700254 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700255 document.addEventListener(
256 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700257 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700258 );
Pokey Rule4097e532025-04-24 18:55:28 +0100259
260 this.scrollContainer.value?.addEventListener("scroll", this._handleScroll);
Sean McCullough86b56862025-04-18 13:04:03 -0700261 }
262
263 // See https://lit.dev/docs/components/lifecycle/
264 disconnectedCallback() {
265 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700266
Sean McCullough86b56862025-04-18 13:04:03 -0700267 // Remove event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -0700268 document.removeEventListener(
269 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700270 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700271 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700272
Pokey Rule4097e532025-04-24 18:55:28 +0100273 this.scrollContainer.value?.removeEventListener(
274 "scroll",
275 this._handleScroll,
276 );
Sean McCullough86b56862025-04-18 13:04:03 -0700277 }
278
Sean McCulloughd9f13372025-04-21 15:08:49 -0700279 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700280 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700281 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700282 // If the message has tool calls, and any of the tool_calls get a response, we need to
283 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700284 const toolCallResponses = message.tool_calls
285 ?.filter((tc) => tc.result_message)
286 .map((tc) => tc.tool_call_id)
287 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700288 return `message-${message.idx}-${toolCallResponses}`;
289 }
290
291 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000292 // Check if messages array is empty and render welcome box if it is
293 if (this.messages.length === 0) {
294 return html`
295 <div id="scroll-container">
296 <div class="welcome-box">
297 <h2 class="welcome-box-title">How to use Sketch</h2>
298 <p class="welcome-box-content">
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700299 Sketch is an agentic coding assistant.
300 </p>
301
302 <p class="welcome-box-content">
303 Sketch has created a container with your repo.
304 </p>
305
306 <p class="welcome-box-content">
307 Ask it to implement a task or answer a question in the chat box
Josh Bleecher Snyderd33ee132025-04-30 16:26:10 -0700308 below. It can edit and run your code, all in the container. Sketch
309 will create commits in a newly created git branch, which you can
310 look at and comment on in the Diff tab. Once you're done, you'll
311 find that branch available in your (original) repo.
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700312 </p>
313 <p class="welcome-box-content">
314 Because Sketch operates a container per session, you can run
315 Sketch in parallel to work on multiple ideas or even the same idea
316 with different approaches.
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000317 </p>
318 </div>
319 </div>
320 `;
321 }
322
323 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000324 const isThinking =
325 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
326
Sean McCullough86b56862025-04-18 13:04:03 -0700327 return html`
Sean McCullough2c5bba42025-04-20 19:33:17 -0700328 <div id="scroll-container">
329 <div class="timeline-container">
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000330 ${repeat(
331 this.messages.filter((msg) => !msg.hide_output),
332 this.messageKey,
333 (message, index) => {
334 let previousMessageIndex =
335 this.messages.findIndex((m) => m === message) - 1;
336 let previousMessage =
337 previousMessageIndex >= 0
338 ? this.messages[previousMessageIndex]
339 : undefined;
340
341 // Skip hidden messages when determining previous message
342 while (previousMessage && previousMessage.hide_output) {
343 previousMessageIndex--;
344 previousMessage =
345 previousMessageIndex >= 0
346 ? this.messages[previousMessageIndex]
347 : undefined;
348 }
349
350 return html`<sketch-timeline-message
351 .message=${message}
352 .previousMessage=${previousMessage}
353 .open=${false}
354 ></sketch-timeline-message>`;
355 },
356 )}
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000357 ${isThinking
358 ? html`
359 <div class="thinking-indicator">
360 <div class="thinking-bubble">
361 <div class="thinking-dots">
362 <div class="dot"></div>
363 <div class="dot"></div>
364 <div class="dot"></div>
365 </div>
366 </div>
367 </div>
368 `
369 : ""}
Sean McCullough2c5bba42025-04-20 19:33:17 -0700370 </div>
371 </div>
372 <div
373 id="jump-to-latest"
374 class="${this.scrollingState}"
375 @click=${this.scrollToBottom}
376 >
377
Sean McCullough71941bd2025-04-18 13:31:48 -0700378 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700379 `;
380 }
381}
382
383declare global {
384 interface HTMLElementTagNameMap {
385 "sketch-timeline": SketchTimeline;
386 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700387}