blob: c612dd80968e75e0686cf1c57f3780eb809a677b [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
Sean McCullough2c5bba42025-04-20 19:33:17 -070014 // Track if we should scroll to the bottom
15 @state()
16 private scrollingState: "pinToLatest" | "floating" = "pinToLatest";
17
Pokey Rulee2a8c2f2025-04-23 15:09:25 +010018 @property({ attribute: false })
Pokey Rule4097e532025-04-24 18:55:28 +010019 scrollContainer: Ref<HTMLElement>;
Sean McCullough2c5bba42025-04-20 19:33:17 -070020
Sean McCullough86b56862025-04-18 13:04:03 -070021 static styles = css`
Sean McCullough71941bd2025-04-18 13:31:48 -070022 /* Hide views initially to prevent flash of content */
23 .timeline-container .timeline,
24 .timeline-container .diff-view,
25 .timeline-container .chart-view,
26 .timeline-container .terminal-view {
27 visibility: hidden;
28 }
Sean McCullough86b56862025-04-18 13:04:03 -070029
Sean McCullough71941bd2025-04-18 13:31:48 -070030 /* Will be set by JavaScript once we know which view to display */
31 .timeline-container.view-initialized .timeline,
32 .timeline-container.view-initialized .diff-view,
33 .timeline-container.view-initialized .chart-view,
34 .timeline-container.view-initialized .terminal-view {
35 visibility: visible;
36 }
37
38 .timeline-container {
39 width: 100%;
40 position: relative;
41 }
42
43 /* Timeline styles that should remain unchanged */
44 .timeline {
45 position: relative;
46 margin: 10px 0;
47 scroll-behavior: smooth;
48 }
49
50 .timeline::before {
51 content: "";
52 position: absolute;
53 top: 0;
54 bottom: 0;
55 left: 15px;
56 width: 2px;
57 background: #e0e0e0;
58 border-radius: 1px;
59 }
60
61 /* Hide the timeline vertical line when there are no messages */
62 .timeline.empty::before {
63 display: none;
64 }
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 }
Sean McCullough86b56862025-04-18 13:04:03 -0700117 `;
118
119 constructor() {
120 super();
Sean McCullough71941bd2025-04-18 13:31:48 -0700121
Sean McCullough86b56862025-04-18 13:04:03 -0700122 // Binding methods
123 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -0700124 this._handleScroll = this._handleScroll.bind(this);
125 }
126
127 /**
128 * Scroll to the bottom of the timeline
129 */
130 private scrollToBottom(): void {
Pokey Rule4097e532025-04-24 18:55:28 +0100131 this.scrollContainer.value?.scrollTo({
132 top: this.scrollContainer.value?.scrollHeight,
Sean McCullough2c5bba42025-04-20 19:33:17 -0700133 behavior: "smooth",
134 });
135 }
136
137 /**
138 * Called after the component's properties have been updated
139 */
140 updated(changedProperties: PropertyValues): void {
141 // If messages have changed, scroll to bottom if needed
142 if (changedProperties.has("messages") && this.messages.length > 0) {
143 if (this.scrollingState == "pinToLatest") {
144 setTimeout(() => this.scrollToBottom(), 50);
145 }
146 }
147 if (changedProperties.has("scrollContainer")) {
Pokey Rule4097e532025-04-24 18:55:28 +0100148 this.scrollContainer.value?.addEventListener(
149 "scroll",
150 this._handleScroll,
151 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700152 }
Sean McCullough86b56862025-04-18 13:04:03 -0700153 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700154
Sean McCullough86b56862025-04-18 13:04:03 -0700155 /**
156 * Handle showCommitDiff event
157 */
158 private _handleShowCommitDiff(event: CustomEvent) {
159 const { commitHash } = event.detail;
160 if (commitHash) {
161 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700162 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700163 detail: { commitHash },
164 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700165 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700166 });
167 this.dispatchEvent(newEvent);
168 }
169 }
170
Sean McCullough2c5bba42025-04-20 19:33:17 -0700171 private _handleScroll(event) {
172 const isAtBottom =
173 Math.abs(
Pokey Rule4097e532025-04-24 18:55:28 +0100174 this.scrollContainer.value.scrollHeight -
175 this.scrollContainer.value.clientHeight -
176 this.scrollContainer.value.scrollTop,
Sean McCullough2c5bba42025-04-20 19:33:17 -0700177 ) <= 1;
178 if (isAtBottom) {
179 this.scrollingState = "pinToLatest";
180 } else {
181 // TODO: does scroll direction matter here?
182 this.scrollingState = "floating";
183 }
184 }
185
Sean McCullough86b56862025-04-18 13:04:03 -0700186 // See https://lit.dev/docs/components/lifecycle/
187 connectedCallback() {
188 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700189
Sean McCullough86b56862025-04-18 13:04:03 -0700190 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700191 document.addEventListener(
192 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700193 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700194 );
Pokey Rule4097e532025-04-24 18:55:28 +0100195
196 this.scrollContainer.value?.addEventListener("scroll", this._handleScroll);
Sean McCullough86b56862025-04-18 13:04:03 -0700197 }
198
199 // See https://lit.dev/docs/components/lifecycle/
200 disconnectedCallback() {
201 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700202
Sean McCullough86b56862025-04-18 13:04:03 -0700203 // Remove event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -0700204 document.removeEventListener(
205 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700206 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700207 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700208
Pokey Rule4097e532025-04-24 18:55:28 +0100209 this.scrollContainer.value?.removeEventListener(
210 "scroll",
211 this._handleScroll,
212 );
Sean McCullough86b56862025-04-18 13:04:03 -0700213 }
214
Sean McCulloughd9f13372025-04-21 15:08:49 -0700215 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700216 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700217 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700218 // If the message has tool calls, and any of the tool_calls get a response, we need to
219 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700220 const toolCallResponses = message.tool_calls
221 ?.filter((tc) => tc.result_message)
222 .map((tc) => tc.tool_call_id)
223 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700224 return `message-${message.idx}-${toolCallResponses}`;
225 }
226
227 render() {
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000228 // Check if messages array is empty and render welcome box if it is
229 if (this.messages.length === 0) {
230 return html`
231 <div id="scroll-container">
232 <div class="welcome-box">
233 <h2 class="welcome-box-title">How to use Sketch</h2>
234 <p class="welcome-box-content">
Philip Zeyligerabc093c2025-04-30 10:46:27 -0700235 Sketch is an agentic coding assistant.
236 </p>
237
238 <p class="welcome-box-content">
239 Sketch has created a container with your repo.
240 </p>
241
242 <p class="welcome-box-content">
243 Ask it to implement a task or answer a question in the chat box
244 below. Sketch has created a container with your repo, and it can
245 edit and run your code in the container. Sketch will create
246 commits, which you can look at and review with comments in the
247 Diff tab. Once you're done, you'll find the changes ready to go in
248 a
249 <code>sketch/*</code> branch.
250 </p>
251 <p class="welcome-box-content">
252 Because Sketch operates a container per session, you can run
253 Sketch in parallel to work on multiple ideas or even the same idea
254 with different approaches.
Philip Zeyliger5cf49262025-04-29 18:35:55 +0000255 </p>
256 </div>
257 </div>
258 `;
259 }
260
261 // Otherwise render the regular timeline with messages
Sean McCullough86b56862025-04-18 13:04:03 -0700262 return html`
Sean McCullough2c5bba42025-04-20 19:33:17 -0700263 <div id="scroll-container">
264 <div class="timeline-container">
265 ${repeat(this.messages, this.messageKey, (message, index) => {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700266 let previousMessage: AgentMessage;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700267 if (index > 0) {
268 previousMessage = this.messages[index - 1];
269 }
270 return html`<sketch-timeline-message
271 .message=${message}
272 .previousMessage=${previousMessage}
Philip Zeyligerd20ded52025-04-30 17:38:36 +0000273 .open=${false}
Sean McCullough2c5bba42025-04-20 19:33:17 -0700274 ></sketch-timeline-message>`;
275 })}
276 </div>
277 </div>
278 <div
279 id="jump-to-latest"
280 class="${this.scrollingState}"
281 @click=${this.scrollToBottom}
282 >
283
Sean McCullough71941bd2025-04-18 13:31:48 -0700284 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700285 `;
286 }
287}
288
289declare global {
290 interface HTMLElementTagNameMap {
291 "sketch-timeline": SketchTimeline;
292 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700293}