blob: 15df3832e8039b3af2e8909d67471580857455ff [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 }
Sean McCullough86b56862025-04-18 13:04:03 -070090 `;
91
92 constructor() {
93 super();
Sean McCullough71941bd2025-04-18 13:31:48 -070094
Sean McCullough86b56862025-04-18 13:04:03 -070095 // Binding methods
96 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough2c5bba42025-04-20 19:33:17 -070097 this._handleScroll = this._handleScroll.bind(this);
98 }
99
100 /**
101 * Scroll to the bottom of the timeline
102 */
103 private scrollToBottom(): void {
Pokey Rule4097e532025-04-24 18:55:28 +0100104 this.scrollContainer.value?.scrollTo({
105 top: this.scrollContainer.value?.scrollHeight,
Sean McCullough2c5bba42025-04-20 19:33:17 -0700106 behavior: "smooth",
107 });
108 }
109
110 /**
111 * Called after the component's properties have been updated
112 */
113 updated(changedProperties: PropertyValues): void {
114 // If messages have changed, scroll to bottom if needed
115 if (changedProperties.has("messages") && this.messages.length > 0) {
116 if (this.scrollingState == "pinToLatest") {
117 setTimeout(() => this.scrollToBottom(), 50);
118 }
119 }
120 if (changedProperties.has("scrollContainer")) {
Pokey Rule4097e532025-04-24 18:55:28 +0100121 this.scrollContainer.value?.addEventListener(
122 "scroll",
123 this._handleScroll,
124 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700125 }
Sean McCullough86b56862025-04-18 13:04:03 -0700126 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700127
Sean McCullough86b56862025-04-18 13:04:03 -0700128 /**
129 * Handle showCommitDiff event
130 */
131 private _handleShowCommitDiff(event: CustomEvent) {
132 const { commitHash } = event.detail;
133 if (commitHash) {
134 // Bubble up the event to the app shell
Sean McCullough71941bd2025-04-18 13:31:48 -0700135 const newEvent = new CustomEvent("show-commit-diff", {
Sean McCullough86b56862025-04-18 13:04:03 -0700136 detail: { commitHash },
137 bubbles: true,
Sean McCullough71941bd2025-04-18 13:31:48 -0700138 composed: true,
Sean McCullough86b56862025-04-18 13:04:03 -0700139 });
140 this.dispatchEvent(newEvent);
141 }
142 }
143
Sean McCullough2c5bba42025-04-20 19:33:17 -0700144 private _handleScroll(event) {
145 const isAtBottom =
146 Math.abs(
Pokey Rule4097e532025-04-24 18:55:28 +0100147 this.scrollContainer.value.scrollHeight -
148 this.scrollContainer.value.clientHeight -
149 this.scrollContainer.value.scrollTop,
Sean McCullough2c5bba42025-04-20 19:33:17 -0700150 ) <= 1;
151 if (isAtBottom) {
152 this.scrollingState = "pinToLatest";
153 } else {
154 // TODO: does scroll direction matter here?
155 this.scrollingState = "floating";
156 }
157 }
158
Sean McCullough86b56862025-04-18 13:04:03 -0700159 // See https://lit.dev/docs/components/lifecycle/
160 connectedCallback() {
161 super.connectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700162
Sean McCullough86b56862025-04-18 13:04:03 -0700163 // Listen for showCommitDiff events from the renderer
Sean McCullough71941bd2025-04-18 13:31:48 -0700164 document.addEventListener(
165 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700166 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700167 );
Pokey Rule4097e532025-04-24 18:55:28 +0100168
169 this.scrollContainer.value?.addEventListener("scroll", this._handleScroll);
Sean McCullough86b56862025-04-18 13:04:03 -0700170 }
171
172 // See https://lit.dev/docs/components/lifecycle/
173 disconnectedCallback() {
174 super.disconnectedCallback();
Sean McCullough71941bd2025-04-18 13:31:48 -0700175
Sean McCullough86b56862025-04-18 13:04:03 -0700176 // Remove event listeners
Sean McCullough71941bd2025-04-18 13:31:48 -0700177 document.removeEventListener(
178 "showCommitDiff",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700179 this._handleShowCommitDiff as EventListener,
Sean McCullough71941bd2025-04-18 13:31:48 -0700180 );
Sean McCullough2c5bba42025-04-20 19:33:17 -0700181
Pokey Rule4097e532025-04-24 18:55:28 +0100182 this.scrollContainer.value?.removeEventListener(
183 "scroll",
184 this._handleScroll,
185 );
Sean McCullough86b56862025-04-18 13:04:03 -0700186 }
187
Sean McCulloughd9f13372025-04-21 15:08:49 -0700188 // messageKey uniquely identifes a AgentMessage based on its ID and tool_calls, so
Sean McCullough2c5bba42025-04-20 19:33:17 -0700189 // that we only re-render <sketch-message> elements that we need to re-render.
Sean McCulloughd9f13372025-04-21 15:08:49 -0700190 messageKey(message: AgentMessage): string {
Sean McCullough86b56862025-04-18 13:04:03 -0700191 // If the message has tool calls, and any of the tool_calls get a response, we need to
192 // re-render that message.
Sean McCullough71941bd2025-04-18 13:31:48 -0700193 const toolCallResponses = message.tool_calls
194 ?.filter((tc) => tc.result_message)
195 .map((tc) => tc.tool_call_id)
196 .join("-");
Sean McCullough86b56862025-04-18 13:04:03 -0700197 return `message-${message.idx}-${toolCallResponses}`;
198 }
199
200 render() {
201 return html`
Sean McCullough2c5bba42025-04-20 19:33:17 -0700202 <div id="scroll-container">
203 <div class="timeline-container">
204 ${repeat(this.messages, this.messageKey, (message, index) => {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700205 let previousMessage: AgentMessage;
Sean McCullough2c5bba42025-04-20 19:33:17 -0700206 if (index > 0) {
207 previousMessage = this.messages[index - 1];
208 }
209 return html`<sketch-timeline-message
210 .message=${message}
211 .previousMessage=${previousMessage}
Sean McCullough2deac842025-04-21 18:17:57 -0700212 .open=${index == this.messages.length - 1}
Sean McCullough2c5bba42025-04-20 19:33:17 -0700213 ></sketch-timeline-message>`;
214 })}
215 </div>
216 </div>
217 <div
218 id="jump-to-latest"
219 class="${this.scrollingState}"
220 @click=${this.scrollToBottom}
221 >
222
Sean McCullough71941bd2025-04-18 13:31:48 -0700223 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700224 `;
225 }
226}
227
228declare global {
229 interface HTMLElementTagNameMap {
230 "sketch-timeline": SketchTimeline;
231 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700232}