blob: 6a0f68311bdb3daacb27aff6cb9946e381f01582 [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 {
Pokey Rule4097e532025-04-24 18:55:28 +0100196 this.scrollContainer.value?.scrollTo({
197 top: this.scrollContainer.value?.scrollHeight,
Sean McCullough2c5bba42025-04-20 19:33:17 -0700198 behavior: "smooth",
199 });
200 }
201
202 /**
203 * Called after the component's properties have been updated
204 */
205 updated(changedProperties: PropertyValues): void {
206 // If messages have changed, scroll to bottom if needed
207 if (changedProperties.has("messages") && this.messages.length > 0) {
208 if (this.scrollingState == "pinToLatest") {
209 setTimeout(() => this.scrollToBottom(), 50);
210 }
211 }
212 if (changedProperties.has("scrollContainer")) {
Pokey Rule4097e532025-04-24 18:55:28 +0100213 this.scrollContainer.value?.addEventListener(
214 "scroll",
215 this._handleScroll,
216 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700217 }
Sean McCullough86b56862025-04-18 13:04:03 -0700218 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700219
Sean McCullough86b56862025-04-18 13:04:03 -0700220 /**
221 * Handle showCommitDiff event
222 */
223 private _handleShowCommitDiff(event: CustomEvent) {
224 const { commitHash } = event.detail;
225 if (commitHash) {
226 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700227 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700228 detail: { commitHash },
229 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700230 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700231 });
232 this.dispatchEvent(newEvent);
233 }
234 }
235
Sean McCullough2c5bba42025-04-20 19:33:17 -0700236 private _handleScroll(event) {
237 const isAtBottom =
238 Math.abs(
Pokey Rule4097e532025-04-24 18:55:28 +0100239 this.scrollContainer.value.scrollHeight -
240 this.scrollContainer.value.clientHeight -
241 this.scrollContainer.value.scrollTop,
Sean McCullough2c5bba42025-04-20 19:33:17 -0700242 ) <= 1;
243 if (isAtBottom) {
244 this.scrollingState = "pinToLatest";
245 } else {
246 // TODO: does scroll direction matter here?
247 this.scrollingState = "floating";
248 }
249 }
250
Sean McCullough86b56862025-04-18 13:04:03 -0700251 // See https://lit.dev/docs/components/lifecycle/
252 connectedCallback() {
253 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700254
Sean McCullough86b56862025-04-18 13:04:03 -0700255 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700256 document.addEventListener(
257 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700258 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700259 );
Pokey Rule4097e532025-04-24 18:55:28 +0100260
261 this.scrollContainer.value?.addEventListener("scroll", this._handleScroll);
Sean McCullough86b56862025-04-18 13:04:03 -0700262 }
263
264 // See https://lit.dev/docs/components/lifecycle/
265 disconnectedCallback() {
266 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700267
Sean McCullough86b56862025-04-18 13:04:03 -0700268 // Remove event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -0700269 document.removeEventListener(
270 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700271 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700272 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700273
Pokey Rule4097e532025-04-24 18:55:28 +0100274 this.scrollContainer.value?.removeEventListener(
275 "scroll",
276 this._handleScroll,
277 );
Sean McCullough86b56862025-04-18 13:04:03 -0700278 }
279
Sean McCulloughd9f13372025-04-21 15:08:49 -0700280 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700281 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700282 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700283 // If the message has tool calls, and any of the tool_calls get a response, we need to
284 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700285 const toolCallResponses = message.tool_calls
286 ?.filter((tc) => tc.result_message)
287 .map((tc) => tc.tool_call_id)
288 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700289 return `message-${message.idx}-${toolCallResponses}`;
290 }
291
292 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000293 // Check if messages array is empty and render welcome box if it is
294 if (this.messages.length === 0) {
295 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700296 <div style="position: relative; height: 100%;">
297 <div id="scroll-container">
298 <div class="welcome-box">
299 <h2 class="welcome-box-title">How to use Sketch</h2>
300 <p class="welcome-box-content">
301 Sketch is an agentic coding assistant.
302 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700303
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700304 <p class="welcome-box-content">
305 Sketch has created a container with your repo.
306 </p>
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700307
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700308 <p class="welcome-box-content">
309 Ask it to implement a task or answer a question in the chat box
310 below. It can edit and run your code, all in the container. Sketch
311 will create commits in a newly created git branch, which you can
312 look at and comment on in the Diff tab. Once you're done, you'll
313 find that branch available in your (original) repo.
314 </p>
315 <p class="welcome-box-content">
316 Because Sketch operates a container per session, you can run
317 Sketch in parallel to work on multiple ideas or even the same idea
318 with different approaches.
319 </p>
320 </div>
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000321 </div>
322 </div>
323 `;
324 }
325
326 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000327 const isThinking =
328 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
329
Sean McCullough86b56862025-04-18 13:04:03 -0700330 return html`
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700331 <div style="position: relative; height: 100%;">
332 <div id="scroll-container">
333 <div class="timeline-container">
334 ${repeat(
335 this.messages.filter((msg) => !msg.hide_output),
336 this.messageKey,
337 (message, index) => {
338 let previousMessageIndex =
339 this.messages.findIndex((m) => m === message) - 1;
340 let previousMessage =
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000341 previousMessageIndex >= 0
342 ? this.messages[previousMessageIndex]
343 : undefined;
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000344
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700345 // Skip hidden messages when determining previous message
346 while (previousMessage && previousMessage.hide_output) {
347 previousMessageIndex--;
348 previousMessage =
349 previousMessageIndex >= 0
350 ? this.messages[previousMessageIndex]
351 : undefined;
352 }
353
354 return html`<sketch-timeline-message
355 .message=${message}
356 .previousMessage=${previousMessage}
357 .open=${false}
358 ></sketch-timeline-message>`;
359 },
360 )}
361 ${isThinking
362 ? html`
363 <div class="thinking-indicator">
364 <div class="thinking-bubble">
365 <div class="thinking-dots">
366 <div class="dot"></div>
367 <div class="dot"></div>
368 <div class="dot"></div>
369 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000370 </div>
371 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700372 `
373 : ""}
374 </div>
Sean McCullough2c5bba42025-04-20 19:33:17 -0700375 </div>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700376 <div
377 id="jump-to-latest"
378 class="${this.scrollingState}"
379 @click=${this.scrollToBottom}
380 >
381
382 </div>
Sean McCullough71941bd2025-04-18 13:31:48 -0700383 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700384 `;
385 }
386}
387
388declare global {
389 interface HTMLElementTagNameMap {
390 "sketch-timeline": SketchTimeline;
391 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700392}