blob: 3870e39d2ea8bf47757a4f2a3caf264af229245f [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;
Sean McCullough71941bd2025-04-18 13:31:48 -070056 }
57
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000058 /* Chat-like timeline styles */
Sean McCullough71941bd2025-04-18 13:31:48 -070059 .timeline {
60 position: relative;
61 margin: 10px 0;
62 scroll-behavior: smooth;
63 }
64
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000065 /* Remove the vertical timeline line */
Sean McCullough2c5bba42025-04-20 19:33:17 -070066
67 #scroll-container {
Philip Zeyligere31d2a92025-05-11 15:22:35 -070068 overflow-y: auto;
69 overflow-x: hidden;
Sean McCullough2c5bba42025-04-20 19:33:17 -070070 padding-left: 1em;
Philip Zeyligere31d2a92025-05-11 15:22:35 -070071 max-width: 100%;
Sean McCullough2c5bba42025-04-20 19:33:17 -070072 }
73 #jump-to-latest {
74 display: none;
75 position: fixed;
76 bottom: 100px;
77 right: 0;
78 background: rgb(33, 150, 243);
79 color: white;
80 border-radius: 8px;
81 padding: 0.5em;
82 margin: 0.5em;
83 font-size: x-large;
84 opacity: 0.5;
85 cursor: pointer;
86 }
87 #jump-to-latest:hover {
88 opacity: 1;
89 }
90 #jump-to-latest.floating {
91 display: block;
92 }
Philip Zeyliger5cf49262025-04-29 18:35:55 +000093
94 /* Welcome box styles for the empty chat state */
95 .welcome-box {
96 margin: 2rem auto;
97 max-width: 80%;
98 padding: 2rem;
99 border: 2px solid #e0e0e0;
100 border-radius: 8px;
101 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
102 background-color: #ffffff;
103 text-align: center;
104 }
105
106 .welcome-box-title {
107 font-size: 1.5rem;
108 font-weight: 600;
109 margin-bottom: 1.5rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700110 text-align: center;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000111 color: #333;
112 }
113
114 .welcome-box-content {
115 color: #666; /* Slightly grey font color */
116 line-height: 1.6;
117 font-size: 1rem;
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700118 text-align: left;
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000119 }
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000120
121 /* Thinking indicator styles */
122 .thinking-indicator {
123 padding-left: 85px;
124 margin-top: 5px;
125 margin-bottom: 15px;
126 display: flex;
127 }
128
129 .thinking-bubble {
130 background-color: #f1f1f1;
131 border-radius: 15px;
132 padding: 10px 15px;
133 max-width: 80px;
134 color: black;
135 position: relative;
136 border-bottom-left-radius: 5px;
137 }
138
139 .thinking-dots {
140 display: flex;
141 align-items: center;
142 justify-content: center;
143 gap: 4px;
144 height: 14px;
145 }
146
147 .dot {
148 width: 6px;
149 height: 6px;
150 background-color: #888;
151 border-radius: 50%;
152 opacity: 0.6;
153 }
154
155 .dot:nth-child(1) {
156 animation: pulse 1.5s infinite ease-in-out;
157 }
158
159 .dot:nth-child(2) {
160 animation: pulse 1.5s infinite ease-in-out 0.3s;
161 }
162
163 .dot:nth-child(3) {
164 animation: pulse 1.5s infinite ease-in-out 0.6s;
165 }
166
167 @keyframes pulse {
168 0%,
169 100% {
170 opacity: 0.4;
171 transform: scale(1);
172 }
173 50% {
174 opacity: 1;
175 transform: scale(1.2);
176 }
177 }
Sean McCullough86b56862025-04-18 13:04:03 -0700178 `;
179
180 constructor() {
181 super();
Sean McCullough71941bd2025-04-18 13:31:48 -0700182
Sean McCullough86b56862025-04-18 13:04:03 -0700183 // Binding methods
184 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700185 this._handleScroll = this._handleScroll.bind(this);
186 }
187
188 /**
189 * Scroll to the bottom of the timeline
190 */
191 private scrollToBottom(): void {
Pokey Rule4097e532025-04-24 18:55:28 +0100192 this.scrollContainer.value?.scrollTo({
193 top: this.scrollContainer.value?.scrollHeight,
Sean McCullough2c5bba42025-04-20 19:33:17 -0700194 behavior: "smooth",
195 });
196 }
197
198 /**
199 * Called after the component's properties have been updated
200 */
201 updated(changedProperties: PropertyValues): void {
202 // If messages have changed, scroll to bottom if needed
203 if (changedProperties.has("messages") && this.messages.length > 0) {
204 if (this.scrollingState == "pinToLatest") {
205 setTimeout(() => this.scrollToBottom(), 50);
206 }
207 }
208 if (changedProperties.has("scrollContainer")) {
Pokey Rule4097e532025-04-24 18:55:28 +0100209 this.scrollContainer.value?.addEventListener(
210 "scroll",
211 this._handleScroll,
212 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700213 }
Sean McCullough86b56862025-04-18 13:04:03 -0700214 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700215
Sean McCullough86b56862025-04-18 13:04:03 -0700216 /**
217 * Handle showCommitDiff event
218 */
219 private _handleShowCommitDiff(event: CustomEvent) {
220 const { commitHash } = event.detail;
221 if (commitHash) {
222 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700223 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700224 detail: { commitHash },
225 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700226 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700227 });
228 this.dispatchEvent(newEvent);
229 }
230 }
231
Sean McCullough2c5bba42025-04-20 19:33:17 -0700232 private _handleScroll(event) {
233 const isAtBottom =
234 Math.abs(
Pokey Rule4097e532025-04-24 18:55:28 +0100235 this.scrollContainer.value.scrollHeight -
236 this.scrollContainer.value.clientHeight -
237 this.scrollContainer.value.scrollTop,
Sean McCullough2c5bba42025-04-20 19:33:17 -0700238 ) <= 1;
239 if (isAtBottom) {
240 this.scrollingState = "pinToLatest";
241 } else {
242 // TODO: does scroll direction matter here?
243 this.scrollingState = "floating";
244 }
245 }
246
Sean McCullough86b56862025-04-18 13:04:03 -0700247 // See https://lit.dev/docs/components/lifecycle/
248 connectedCallback() {
249 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700250
Sean McCullough86b56862025-04-18 13:04:03 -0700251 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700252 document.addEventListener(
253 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700254 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700255 );
Pokey Rule4097e532025-04-24 18:55:28 +0100256
257 this.scrollContainer.value?.addEventListener("scroll", this._handleScroll);
Sean McCullough86b56862025-04-18 13:04:03 -0700258 }
259
260 // See https://lit.dev/docs/components/lifecycle/
261 disconnectedCallback() {
262 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700263
Sean McCullough86b56862025-04-18 13:04:03 -0700264 // Remove event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -0700265 document.removeEventListener(
266 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700267 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700268 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700269
Pokey Rule4097e532025-04-24 18:55:28 +0100270 this.scrollContainer.value?.removeEventListener(
271 "scroll",
272 this._handleScroll,
273 );
Sean McCullough86b56862025-04-18 13:04:03 -0700274 }
275
Sean McCulloughd9f13372025-04-21 15:08:49 -0700276 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700277 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700278 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700279 // If the message has tool calls, and any of the tool_calls get a response, we need to
280 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700281 const toolCallResponses = message.tool_calls
282 ?.filter((tc) => tc.result_message)
283 .map((tc) => tc.tool_call_id)
284 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700285 return `message-${message.idx}-${toolCallResponses}`;
286 }
287
288 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000289 // Check if messages array is empty and render welcome box if it is
290 if (this.messages.length === 0) {
291 return html`
292 <div id="scroll-container">
293 <div class="welcome-box">
294 <h2 class="welcome-box-title">How to use Sketch</h2>
295 <p class="welcome-box-content">
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700296 Sketch is an agentic coding assistant.
297 </p>
298
299 <p class="welcome-box-content">
300 Sketch has created a container with your repo.
301 </p>
302
303 <p class="welcome-box-content">
304 Ask it to implement a task or answer a question in the chat box
Josh Bleecher Snyderd33ee132025-04-30 16:26:10 -0700305 below. It can edit and run your code, all in the container. Sketch
306 will create commits in a newly created git branch, which you can
307 look at and comment on in the Diff tab. Once you're done, you'll
308 find that branch available in your (original) repo.
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700309 </p>
310 <p class="welcome-box-content">
311 Because Sketch operates a container per session, you can run
312 Sketch in parallel to work on multiple ideas or even the same idea
313 with different approaches.
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000314 </p>
315 </div>
316 </div>
317 `;
318 }
319
320 // Otherwise render the regular timeline with messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000321 const isThinking =
322 this.llmCalls > 0 || (this.toolCalls && this.toolCalls.length > 0);
323
Sean McCullough86b56862025-04-18 13:04:03 -0700324 return html`
Sean McCullough2c5bba42025-04-20 19:33:17 -0700325 <div id="scroll-container">
326 <div class="timeline-container">
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000327 ${repeat(
328 this.messages.filter((msg) => !msg.hide_output),
329 this.messageKey,
330 (message, index) => {
331 let previousMessageIndex =
332 this.messages.findIndex((m) => m === message) - 1;
333 let previousMessage =
334 previousMessageIndex >= 0
335 ? this.messages[previousMessageIndex]
336 : undefined;
337
338 // Skip hidden messages when determining previous message
339 while (previousMessage && previousMessage.hide_output) {
340 previousMessageIndex--;
341 previousMessage =
342 previousMessageIndex >= 0
343 ? this.messages[previousMessageIndex]
344 : undefined;
345 }
346
347 return html`<sketch-timeline-message
348 .message=${message}
349 .previousMessage=${previousMessage}
350 .open=${false}
351 ></sketch-timeline-message>`;
352 },
353 )}
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000354 ${isThinking
355 ? html`
356 <div class="thinking-indicator">
357 <div class="thinking-bubble">
358 <div class="thinking-dots">
359 <div class="dot"></div>
360 <div class="dot"></div>
361 <div class="dot"></div>
362 </div>
363 </div>
364 </div>
365 `
366 : ""}
Sean McCullough2c5bba42025-04-20 19:33:17 -0700367 </div>
368 </div>
369 <div
370 id="jump-to-latest"
371 class="${this.scrollingState}"
372 @click=${this.scrollToBottom}
373 >
374
Sean McCullough71941bd2025-04-18 13:31:48 -0700375 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700376 `;
377 }
378}
379
380declare global {
381 interface HTMLElementTagNameMap {
382 "sketch-timeline": SketchTimeline;
383 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700384}