blob: 64f3db0f0880401f2f3a1805a55ed7bfe3da0c92 [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;
Sean McCullough71941bd2025-04-18 13:31:48 -070055 }
56
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000057 /* Chat-like timeline styles */
Sean McCullough71941bd2025-04-18 13:31:48 -070058 .timeline {
59 position: relative;
60 margin: 10px 0;
61 scroll-behavior: smooth;
62 }
63
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000064 /* Remove the vertical timeline line */
Sean McCullough2c5bba42025-04-20 19:33:17 -070065
66 #scroll-container {
67 overflow: auto;
68 padding-left: 1em;
69 }
70 #jump-to-latest {
71 display: none;
72 position: fixed;
73 bottom: 100px;
74 right: 0;
75 background: rgb(33, 150, 243);
76 color: white;
77 border-radius: 8px;
78 padding: 0.5em;
79 margin: 0.5em;
80 font-size: x-large;
81 opacity: 0.5;
82 cursor: pointer;
83 }
84 #jump-to-latest:hover {
85 opacity: 1;
86 }
87 #jump-to-latest.floating {
88 display: block;
89 }
Philip Zeyliger5cf49262025-04-29 18:35:55 +000090
91 /* Welcome box styles for the empty chat state */
92 .welcome-box {
93 margin: 2rem auto;
94 max-width: 80%;
95 padding: 2rem;
96 border: 2px solid #e0e0e0;
97 border-radius: 8px;
98 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
99 background-color: #ffffff;
100 text-align: center;
101 }
102
103 .welcome-box-title {
104 font-size: 1.5rem;
105 font-weight: 600;
106 margin-bottom: 1.5rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700107 text-align: center;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000108 color: #333;
109 }
110
111 .welcome-box-content {
112 color: #666; /* Slightly grey font color */
113 line-height: 1.6;
114 font-size: 1rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700115 text-align: left;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000116 }
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000117
118 /* Thinking indicator styles */
119 .thinking-indicator {
120 padding-left: 85px;
121 margin-top: 5px;
122 margin-bottom: 15px;
123 display: flex;
124 }
125
126 .thinking-bubble {
127 background-color: #f1f1f1;
128 border-radius: 15px;
129 padding: 10px 15px;
130 max-width: 80px;
131 color: black;
132 position: relative;
133 border-bottom-left-radius: 5px;
134 }
135
136 .thinking-dots {
137 display: flex;
138 align-items: center;
139 justify-content: center;
140 gap: 4px;
141 height: 14px;
142 }
143
144 .dot {
145 width: 6px;
146 height: 6px;
147 background-color: #888;
148 border-radius: 50%;
149 opacity: 0.6;
150 }
151
152 .dot:nth-child(1) {
153 animation: pulse 1.5s infinite ease-in-out;
154 }
155
156 .dot:nth-child(2) {
157 animation: pulse 1.5s infinite ease-in-out 0.3s;
158 }
159
160 .dot:nth-child(3) {
161 animation: pulse 1.5s infinite ease-in-out 0.6s;
162 }
163
164 @keyframes pulse {
165 0%,
166 100% {
167 opacity: 0.4;
168 transform: scale(1);
169 }
170 50% {
171 opacity: 1;
172 transform: scale(1.2);
173 }
174 }
Sean McCullough86b56862025-04-18 13:04:03 -0700175 `;
176
177 constructor() {
178 super();
Sean McCullough71941bd2025-04-18 13:31:48 -0700179
Sean McCullough86b56862025-04-18 13:04:03 -0700180 // Binding methods
181 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700182 this._handleScroll = this._handleScroll.bind(this);
183 }
184
185 /**
186 * Scroll to the bottom of the timeline
187 */
188 private scrollToBottom(): void {
Pokey Rule4097e532025-04-24 18:55:28 +0100189 this.scrollContainer.value?.scrollTo({
190 top: this.scrollContainer.value?.scrollHeight,
Sean McCullough2c5bba42025-04-20 19:33:17 -0700191 behavior: "smooth",
192 });
193 }
194
195 /**
196 * Called after the component's properties have been updated
197 */
198 updated(changedProperties: PropertyValues): void {
199 // If messages have changed, scroll to bottom if needed
200 if (changedProperties.has("messages") && this.messages.length > 0) {
201 if (this.scrollingState == "pinToLatest") {
202 setTimeout(() => this.scrollToBottom(), 50);
203 }
204 }
205 if (changedProperties.has("scrollContainer")) {
Pokey Rule4097e532025-04-24 18:55:28 +0100206 this.scrollContainer.value?.addEventListener(
207 "scroll",
208 this._handleScroll,
209 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700210 }
Sean McCullough86b56862025-04-18 13:04:03 -0700211 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700212
Sean McCullough86b56862025-04-18 13:04:03 -0700213 /**
214 * Handle showCommitDiff event
215 */
216 private _handleShowCommitDiff(event: CustomEvent) {
217 const { commitHash } = event.detail;
218 if (commitHash) {
219 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700220 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700221 detail: { commitHash },
222 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700223 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700224 });
225 this.dispatchEvent(newEvent);
226 }
227 }
228
Sean McCullough2c5bba42025-04-20 19:33:17 -0700229 private _handleScroll(event) {
230 const isAtBottom =
231 Math.abs(
Pokey Rule4097e532025-04-24 18:55:28 +0100232 this.scrollContainer.value.scrollHeight -
233 this.scrollContainer.value.clientHeight -
234 this.scrollContainer.value.scrollTop,
Sean McCullough2c5bba42025-04-20 19:33:17 -0700235 ) <= 1;
236 if (isAtBottom) {
237 this.scrollingState = "pinToLatest";
238 } else {
239 // TODO: does scroll direction matter here?
240 this.scrollingState = "floating";
241 }
242 }
243
Sean McCullough86b56862025-04-18 13:04:03 -0700244 // See https://lit.dev/docs/components/lifecycle/
245 connectedCallback() {
246 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700247
Sean McCullough86b56862025-04-18 13:04:03 -0700248 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700249 document.addEventListener(
250 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700251 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700252 );
Pokey Rule4097e532025-04-24 18:55:28 +0100253
254 this.scrollContainer.value?.addEventListener("scroll", this._handleScroll);
Sean McCullough86b56862025-04-18 13:04:03 -0700255 }
256
257 // See https://lit.dev/docs/components/lifecycle/
258 disconnectedCallback() {
259 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700260
Sean McCullough86b56862025-04-18 13:04:03 -0700261 // Remove event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -0700262 document.removeEventListener(
263 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700264 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700265 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700266
Pokey Rule4097e532025-04-24 18:55:28 +0100267 this.scrollContainer.value?.removeEventListener(
268 "scroll",
269 this._handleScroll,
270 );
Sean McCullough86b56862025-04-18 13:04:03 -0700271 }
272
Sean McCulloughd9f13372025-04-21 15:08:49 -0700273 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700274 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700275 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700276 // If the message has tool calls, and any of the tool_calls get a response, we need to
277 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700278 const toolCallResponses = message.tool_calls
279 ?.filter((tc) => tc.result_message)
280 .map((tc) => tc.tool_call_id)
281 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700282 return `message-${message.idx}-${toolCallResponses}`;
283 }
284
285 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000286 // Check if messages array is empty and render welcome box if it is
287 if (this.messages.length === 0) {
288 return html`
289 <div id="scroll-container">
290 <div class="welcome-box">
291 <h2 class="welcome-box-title">How to use Sketch</h2>
292 <p class="welcome-box-content">
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700293 Sketch is an agentic coding assistant.
294 </p>
295
296 <p class="welcome-box-content">
297 Sketch has created a container with your repo.
298 </p>
299
300 <p class="welcome-box-content">
301 Ask it to implement a task or answer a question in the chat box
Josh Bleecher Snyderd33ee132025-04-30 16:26:10 -0700302 below. It can edit and run your code, all in the container. Sketch
303 will create commits in a newly created git branch, which you can
304 look at and comment on in the Diff tab. Once you're done, you'll
305 find that branch available in your (original) repo.
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700306 </p>
307 <p class="welcome-box-content">
308 Because Sketch operates a container per session, you can run
309 Sketch in parallel to work on multiple ideas or even the same idea
310 with different approaches.
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000311 </p>
312 </div>
313 </div>
314 `;
315 }
316
317 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000318 const isThinking =
319 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
320
Sean McCullough86b56862025-04-18 13:04:03 -0700321 return html`
Sean McCullough2c5bba42025-04-20 19:33:17 -0700322 <div id="scroll-container">
323 <div class="timeline-container">
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000324 ${repeat(
325 this.messages.filter((msg) => !msg.hide_output),
326 this.messageKey,
327 (message, index) => {
328 let previousMessageIndex =
329 this.messages.findIndex((m) => m === message) - 1;
330 let previousMessage =
331 previousMessageIndex >= 0
332 ? this.messages[previousMessageIndex]
333 : undefined;
334
335 // Skip hidden messages when determining previous message
336 while (previousMessage && previousMessage.hide_output) {
337 previousMessageIndex--;
338 previousMessage =
339 previousMessageIndex >= 0
340 ? this.messages[previousMessageIndex]
341 : undefined;
342 }
343
344 return html`<sketch-timeline-message
345 .message=${message}
346 .previousMessage=${previousMessage}
347 .open=${false}
348 ></sketch-timeline-message>`;
349 },
350 )}
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000351 ${isThinking
352 ? html`
353 <div class="thinking-indicator">
354 <div class="thinking-bubble">
355 <div class="thinking-dots">
356 <div class="dot"></div>
357 <div class="dot"></div>
358 <div class="dot"></div>
359 </div>
360 </div>
361 </div>
362 `
363 : ""}
Sean McCullough2c5bba42025-04-20 19:33:17 -0700364 </div>
365 </div>
366 <div
367 id="jump-to-latest"
368 class="${this.scrollingState}"
369 @click=${this.scrollToBottom}
370 >
371
Sean McCullough71941bd2025-04-18 13:31:48 -0700372 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700373 `;
374 }
375}
376
377declare global {
378 interface HTMLElementTagNameMap {
379 "sketch-timeline": SketchTimeline;
380 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700381}