blob: a6709a5d6c5fc183279db844322f3ee7c7e132a5 [file] [log] [blame]
Sean McCullough86b56862025-04-18 13:04:03 -07001import { css, html, LitElement } from "lit";
2import { customElement, property, state } from "lit/decorators.js";
Pokey Rule4097e532025-04-24 18:55:28 +01003import { ConnectionStatus, DataManager } from "../data";
Philip Zeyliger47b71c92025-04-30 15:43:39 +00004import { AgentMessage, GitCommit, State } from "../types";
Pokey Rulee2a8c2f2025-04-23 15:09:25 +01005import { aggregateAgentMessages } from "./aggregateAgentMessages";
Pokey Rule4097e532025-04-24 18:55:28 +01006import "./sketch-charts";
7import "./sketch-chat-input";
8import "./sketch-container-status";
9import "./sketch-diff-view";
10import { SketchDiffView } from "./sketch-diff-view";
11import "./sketch-network-status";
Philip Zeyliger99a9a022025-04-27 15:15:25 +000012import "./sketch-call-status";
Pokey Rule4097e532025-04-24 18:55:28 +010013import "./sketch-terminal";
14import "./sketch-timeline";
15import "./sketch-view-mode-select";
16
17import { createRef, ref } from "lit/directives/ref.js";
Sean McCullough86b56862025-04-18 13:04:03 -070018
19type ViewMode = "chat" | "diff" | "charts" | "terminal";
20
21@customElement("sketch-app-shell")
22export class SketchAppShell extends LitElement {
23 // Current view mode (chat, diff, charts, terminal)
24 @state()
25 viewMode: "chat" | "diff" | "charts" | "terminal" = "chat";
26
27 // Current commit hash for diff view
28 @state()
29 currentCommitHash: string = "";
30
Philip Zeyliger47b71c92025-04-30 15:43:39 +000031 // Last commit information
32 @state()
33 lastCommit: { hash: string; pushedBranch?: string } | null = null;
34
Sean McCullough86b56862025-04-18 13:04:03 -070035 // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
36 // Note that these styles only apply to the scope of this web component's
37 // shadow DOM node, so they won't leak out or collide with CSS declared in
38 // other components or the containing web page (...unless you want it to do that).
39 static styles = css`
Philip Zeyliger47b71c92025-04-30 15:43:39 +000040 /* Last commit display styling */
41 .last-commit {
42 display: flex;
43 align-items: center;
44 padding: 3px 8px;
45 background: #f0f7ff;
46 border: 1px solid #c8e1ff;
47 border-radius: 4px;
48 font-family: monospace;
49 font-size: 12px;
50 color: #0366d6;
51 cursor: pointer;
52 position: relative;
53 margin: 0 10px;
54 white-space: nowrap;
55 overflow: hidden;
56 text-overflow: ellipsis;
57 max-width: 180px;
58 transition: background-color 0.2s ease;
59 }
60
61 .last-commit:hover {
62 background-color: #dbedff;
63 }
64
65 .last-commit::before {
66 content: "Last Commit: ";
67 color: #666;
68 margin-right: 4px;
69 font-family: system-ui, sans-serif;
70 font-size: 11px;
71 }
72
73 .copied-indicator {
74 position: absolute;
75 top: -20px;
76 left: 50%;
77 transform: translateX(-50%);
78 background: rgba(40, 167, 69, 0.9);
79 color: white;
80 padding: 2px 6px;
81 border-radius: 3px;
82 font-size: 10px;
83 font-family: system-ui, sans-serif;
84 animation: fadeInOut 2s ease;
85 pointer-events: none;
86 }
87
88 @keyframes fadeInOut {
89 0% { opacity: 0; }
90 20% { opacity: 1; }
91 80% { opacity: 1; }
92 100% { opacity: 0; }
93 }
94
95 .commit-branch-indicator {
96 color: #28a745;
97 }
98
99 .commit-hash-indicator {
100 color: #0366d6;
101 }
Sean McCullough86b56862025-04-18 13:04:03 -0700102 :host {
103 display: block;
Sean McCullough71941bd2025-04-18 13:31:48 -0700104 font-family:
105 system-ui,
106 -apple-system,
107 BlinkMacSystemFont,
108 "Segoe UI",
109 Roboto,
110 sans-serif;
Sean McCullough86b56862025-04-18 13:04:03 -0700111 color: #333;
112 line-height: 1.4;
Pokey Rule4097e532025-04-24 18:55:28 +0100113 height: 100vh;
Sean McCullough86b56862025-04-18 13:04:03 -0700114 width: 100%;
115 position: relative;
116 overflow-x: hidden;
Pokey Rule4097e532025-04-24 18:55:28 +0100117 display: flex;
118 flex-direction: column;
Sean McCullough86b56862025-04-18 13:04:03 -0700119 }
120
121 /* Top banner with combined elements */
Pokey Rule4097e532025-04-24 18:55:28 +0100122 #top-banner {
Sean McCullough86b56862025-04-18 13:04:03 -0700123 display: flex;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000124 align-self: stretch;
Sean McCullough86b56862025-04-18 13:04:03 -0700125 justify-content: space-between;
126 align-items: center;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000127 padding: 0 20px;
Sean McCullough86b56862025-04-18 13:04:03 -0700128 margin-bottom: 0;
129 border-bottom: 1px solid #eee;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000130 gap: 20px;
Sean McCullough86b56862025-04-18 13:04:03 -0700131 background: white;
Sean McCullough86b56862025-04-18 13:04:03 -0700132 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000133 width: 100%;
134 height: 48px;
135 padding-right: 30px; /* Extra padding on the right to prevent elements from hitting the edge */
Sean McCullough86b56862025-04-18 13:04:03 -0700136 }
137
Pokey Rule4097e532025-04-24 18:55:28 +0100138 /* View mode container styles - mirroring timeline.css structure */
139 #view-container {
140 align-self: stretch;
141 overflow-y: auto;
142 flex: 1;
143 }
144
145 #view-container-inner {
146 max-width: 1200px;
147 margin: 0 auto;
148 position: relative;
149 padding-bottom: 10px;
150 padding-top: 10px;
151 }
152
153 #chat-input {
154 align-self: flex-end;
155 width: 100%;
156 box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
157 }
158
Sean McCullough86b56862025-04-18 13:04:03 -0700159 .banner-title {
160 font-size: 18px;
161 font-weight: 600;
162 margin: 0;
163 min-width: 6em;
164 white-space: nowrap;
165 overflow: hidden;
166 text-overflow: ellipsis;
167 }
168
169 .chat-title {
170 margin: 0;
171 padding: 0;
172 color: rgba(82, 82, 82, 0.85);
173 font-size: 16px;
174 font-weight: normal;
175 font-style: italic;
176 white-space: nowrap;
177 overflow: hidden;
178 text-overflow: ellipsis;
179 }
180
Sean McCullough86b56862025-04-18 13:04:03 -0700181 /* Allow the container to expand to full width in diff mode */
Pokey Rule46fff972025-04-25 14:57:44 +0100182 #view-container-inner.diff-active {
Sean McCullough86b56862025-04-18 13:04:03 -0700183 max-width: 100%;
Pokey Rule46fff972025-04-25 14:57:44 +0100184 width: 100%;
Sean McCullough86b56862025-04-18 13:04:03 -0700185 }
186
187 /* Individual view styles */
188 .chat-view,
189 .diff-view,
190 .chart-view,
191 .terminal-view {
192 display: none; /* Hidden by default */
193 width: 100%;
194 }
195
196 /* Active view styles - these will be applied via JavaScript */
197 .view-active {
198 display: flex;
199 flex-direction: column;
200 }
201
202 .title-container {
203 display: flex;
204 flex-direction: column;
205 white-space: nowrap;
206 overflow: hidden;
207 text-overflow: ellipsis;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000208 max-width: 25%;
209 padding: 6px 0;
Sean McCullough86b56862025-04-18 13:04:03 -0700210 }
211
212 .refresh-control {
213 display: flex;
214 align-items: center;
215 margin-bottom: 0;
216 flex-wrap: nowrap;
217 white-space: nowrap;
218 flex-shrink: 0;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000219 gap: 15px;
220 padding-left: 15px;
221 margin-right: 50px;
Sean McCullough86b56862025-04-18 13:04:03 -0700222 }
223
224 .refresh-button {
225 background: #4caf50;
226 color: white;
227 border: none;
228 padding: 4px 10px;
229 border-radius: 4px;
230 cursor: pointer;
231 font-size: 12px;
232 margin-right: 5px;
233 }
234
235 .stop-button:hover {
236 background-color: #c82333 !important;
237 }
238
239 .poll-updates {
240 display: flex;
241 align-items: center;
Sean McCullough86b56862025-04-18 13:04:03 -0700242 font-size: 12px;
243 }
244 `;
245
246 // Header bar: Network connection status details
247 @property()
248 connectionStatus: ConnectionStatus = "disconnected";
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000249
250 // Track if the last commit info has been copied
251 @state()
252 lastCommitCopied: boolean = false;
Sean McCullough86b56862025-04-18 13:04:03 -0700253
254 @property()
255 connectionErrorMessage: string = "";
256
257 @property()
258 messageStatus: string = "";
259
260 // Chat messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100261 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700262 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -0700263
264 @property()
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000265 set title(value: string) {
266 const oldValue = this._title;
267 this._title = value;
268 this.requestUpdate("title", oldValue);
269 // Update document title when title property changes
270 this.updateDocumentTitle();
271 }
272
273 get title(): string {
274 return this._title;
275 }
276
277 private _title: string = "";
Sean McCullough86b56862025-04-18 13:04:03 -0700278
279 private dataManager = new DataManager();
280
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100281 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700282 containerState: State = {
283 title: "",
284 os: "",
285 message_count: 0,
286 hostname: "",
287 working_dir: "",
288 initial_commit: "",
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000289 outstanding_llm_calls: 0,
290 outstanding_tool_calls: [],
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000291 session_id: "",
292 ssh_available: false,
Sean McCulloughd9f13372025-04-21 15:08:49 -0700293 };
Sean McCullough86b56862025-04-18 13:04:03 -0700294
Sean McCullough86b56862025-04-18 13:04:03 -0700295 // Mutation observer to detect when new messages are added
296 private mutationObserver: MutationObserver | null = null;
297
298 constructor() {
299 super();
300
301 // Binding methods to this
302 this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
Sean McCullough86b56862025-04-18 13:04:03 -0700303 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
304 this._handlePopState = this._handlePopState.bind(this);
Sean McCulloughd3906e22025-04-29 17:32:14 +0000305 this._handleStopClick = this._handleStopClick.bind(this);
Sean McCullough86b56862025-04-18 13:04:03 -0700306 }
307
308 // See https://lit.dev/docs/components/lifecycle/
309 connectedCallback() {
310 super.connectedCallback();
311
312 // Initialize client-side nav history.
313 const url = new URL(window.location.href);
314 const mode = url.searchParams.get("view") || "chat";
315 window.history.replaceState({ mode }, "", url.toString());
316
317 this.toggleViewMode(mode as ViewMode, false);
318 // Add popstate event listener to handle browser back/forward navigation
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100319 window.addEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700320
321 // Add event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100322 window.addEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100323 window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
Sean McCullough86b56862025-04-18 13:04:03 -0700324
325 // register event listeners
326 this.dataManager.addEventListener(
327 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700328 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700329 );
330 this.dataManager.addEventListener(
331 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700332 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700333 );
334
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000335 // Set initial document title
336 this.updateDocumentTitle();
337
Sean McCullough86b56862025-04-18 13:04:03 -0700338 // Initialize the data manager
339 this.dataManager.initialize();
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000340
341 // Process existing messages for commit info
342 if (this.messages && this.messages.length > 0) {
343 this.updateLastCommitInfo(this.messages);
344 }
Sean McCullough86b56862025-04-18 13:04:03 -0700345 }
346
347 // See https://lit.dev/docs/components/lifecycle/
348 disconnectedCallback() {
349 super.disconnectedCallback();
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100350 window.removeEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700351
352 // Remove event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100353 window.removeEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100354 window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
Sean McCullough86b56862025-04-18 13:04:03 -0700355
356 // unregister data manager event listeners
357 this.dataManager.removeEventListener(
358 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700359 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700360 );
361 this.dataManager.removeEventListener(
362 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700363 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700364 );
365
366 // Disconnect mutation observer if it exists
367 if (this.mutationObserver) {
Sean McCullough86b56862025-04-18 13:04:03 -0700368 this.mutationObserver.disconnect();
369 this.mutationObserver = null;
370 }
371 }
372
Sean McCullough71941bd2025-04-18 13:31:48 -0700373 updateUrlForViewMode(mode: "chat" | "diff" | "charts" | "terminal"): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700374 // Get the current URL without search parameters
375 const url = new URL(window.location.href);
376
377 // Clear existing parameters
378 url.search = "";
379
380 // Only add view parameter if not in default chat view
381 if (mode !== "chat") {
382 url.searchParams.set("view", mode);
Sean McCullough71941bd2025-04-18 13:31:48 -0700383 const diffView = this.shadowRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700384 ".diff-view",
Sean McCullough71941bd2025-04-18 13:31:48 -0700385 ) as SketchDiffView;
Sean McCullough86b56862025-04-18 13:04:03 -0700386
387 // If in diff view and there's a commit hash, include that too
388 if (mode === "diff" && diffView.commitHash) {
389 url.searchParams.set("commit", diffView.commitHash);
390 }
391 }
392
393 // Update the browser history without reloading the page
394 window.history.pushState({ mode }, "", url.toString());
395 }
396
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100397 private _handlePopState(event: PopStateEvent) {
Sean McCullough86b56862025-04-18 13:04:03 -0700398 if (event.state && event.state.mode) {
399 this.toggleViewMode(event.state.mode, false);
400 } else {
401 this.toggleViewMode("chat", false);
402 }
403 }
404
405 /**
406 * Handle view mode selection event
407 */
408 private _handleViewModeSelect(event: CustomEvent) {
409 const mode = event.detail.mode as "chat" | "diff" | "charts" | "terminal";
410 this.toggleViewMode(mode, true);
411 }
412
413 /**
414 * Handle show commit diff event
415 */
416 private _handleShowCommitDiff(event: CustomEvent) {
417 const { commitHash } = event.detail;
418 if (commitHash) {
419 this.showCommitDiff(commitHash);
420 }
421 }
422
423 /**
Sean McCullough86b56862025-04-18 13:04:03 -0700424 * Listen for commit diff event
425 * @param commitHash The commit hash to show diff for
426 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100427 private showCommitDiff(commitHash: string): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700428 // Store the commit hash
429 this.currentCommitHash = commitHash;
430
431 // Switch to diff view
Sean McCullough71941bd2025-04-18 13:31:48 -0700432 this.toggleViewMode("diff", true);
Sean McCullough86b56862025-04-18 13:04:03 -0700433
434 // Wait for DOM update to complete
435 this.updateComplete.then(() => {
436 // Get the diff view component
437 const diffView = this.shadowRoot?.querySelector("sketch-diff-view");
438 if (diffView) {
439 // Call the showCommitDiff method
440 (diffView as any).showCommitDiff(commitHash);
441 }
442 });
443 }
444
445 /**
446 * Toggle between different view modes: chat, diff, charts, terminal
447 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100448 private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700449 // Don't do anything if the mode is already active
450 if (this.viewMode === mode) return;
451
452 // Update the view mode
453 this.viewMode = mode;
454
455 if (updateHistory) {
456 // Update URL with the current view mode
457 this.updateUrlForViewMode(mode);
458 }
459
460 // Wait for DOM update to complete
461 this.updateComplete.then(() => {
462 // Update active view
Pokey Rule46fff972025-04-25 14:57:44 +0100463 const viewContainerInner = this.shadowRoot?.querySelector(
464 "#view-container-inner",
465 );
Sean McCullough86b56862025-04-18 13:04:03 -0700466 const chatView = this.shadowRoot?.querySelector(".chat-view");
467 const diffView = this.shadowRoot?.querySelector(".diff-view");
468 const chartView = this.shadowRoot?.querySelector(".chart-view");
469 const terminalView = this.shadowRoot?.querySelector(".terminal-view");
470
471 // Remove active class from all views
472 chatView?.classList.remove("view-active");
473 diffView?.classList.remove("view-active");
474 chartView?.classList.remove("view-active");
475 terminalView?.classList.remove("view-active");
476
477 // Add/remove diff-active class on view container
478 if (mode === "diff") {
Pokey Rule46fff972025-04-25 14:57:44 +0100479 viewContainerInner?.classList.add("diff-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700480 } else {
Pokey Rule46fff972025-04-25 14:57:44 +0100481 viewContainerInner?.classList.remove("diff-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700482 }
483
484 // Add active class to the selected view
485 switch (mode) {
486 case "chat":
487 chatView?.classList.add("view-active");
488 break;
489 case "diff":
490 diffView?.classList.add("view-active");
491 // Load diff content if we have a diff view
492 const diffViewComp =
493 this.shadowRoot?.querySelector("sketch-diff-view");
494 if (diffViewComp && this.currentCommitHash) {
495 (diffViewComp as any).showCommitDiff(this.currentCommitHash);
496 } else if (diffViewComp) {
497 (diffViewComp as any).loadDiffContent();
498 }
499 break;
500 case "charts":
501 chartView?.classList.add("view-active");
502 break;
503 case "terminal":
504 terminalView?.classList.add("view-active");
505 break;
506 }
507
508 // Update view mode buttons
509 const viewModeSelect = this.shadowRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700510 "sketch-view-mode-select",
Sean McCullough86b56862025-04-18 13:04:03 -0700511 );
512 if (viewModeSelect) {
513 const event = new CustomEvent("update-active-mode", {
514 detail: { mode },
515 bubbles: true,
516 composed: true,
517 });
518 viewModeSelect.dispatchEvent(event);
519 }
520
521 // FIXME: This is a hack to get vega chart in sketch-charts.ts to work properly
522 // When the chart is in the background, its container has a width of 0, so vega
523 // renders width 0 and only changes that width on a resize event.
524 // See https://github.com/vega/react-vega/issues/85#issuecomment-1826421132
525 window.dispatchEvent(new Event("resize"));
526 });
527 }
528
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000529 /**
530 * Updates the document title based on current title and connection status
531 */
532 private updateDocumentTitle(): void {
533 let docTitle = `sk: ${this.title || "untitled"}`;
534
535 // Add red circle emoji if disconnected
536 if (this.connectionStatus === "disconnected") {
537 docTitle += " 🔴";
538 }
539
540 document.title = docTitle;
541 }
542
Sean McCullough86b56862025-04-18 13:04:03 -0700543 private handleDataChanged(eventData: {
544 state: State;
Sean McCulloughd9f13372025-04-21 15:08:49 -0700545 newMessages: AgentMessage[];
Sean McCullough86b56862025-04-18 13:04:03 -0700546 isFirstFetch?: boolean;
547 }): void {
548 const { state, newMessages, isFirstFetch } = eventData;
549
550 // Check if this is the first data fetch or if there are new messages
551 if (isFirstFetch) {
Sean McCullough86b56862025-04-18 13:04:03 -0700552 this.messageStatus = "Initial messages loaded";
553 } else if (newMessages && newMessages.length > 0) {
Sean McCullough86b56862025-04-18 13:04:03 -0700554 this.messageStatus = "Updated just now";
Sean McCullough86b56862025-04-18 13:04:03 -0700555 } else {
556 this.messageStatus = "No new messages";
557 }
558
559 // Update state if we received it
560 if (state) {
561 this.containerState = state;
562 this.title = state.title;
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000563
564 // Update document title when sketch title changes
565 this.updateDocumentTitle();
Sean McCullough86b56862025-04-18 13:04:03 -0700566 }
567
Sean McCullough86b56862025-04-18 13:04:03 -0700568 // Update messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100569 this.messages = aggregateAgentMessages(this.messages, newMessages);
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000570
571 // Process new messages to find commit messages
572 this.updateLastCommitInfo(newMessages);
Sean McCullough86b56862025-04-18 13:04:03 -0700573 }
574
575 private handleConnectionStatusChanged(
576 status: ConnectionStatus,
Philip Zeyliger72682df2025-04-23 13:09:46 -0700577 errorMessage?: string,
Sean McCullough86b56862025-04-18 13:04:03 -0700578 ): void {
579 this.connectionStatus = status;
580 this.connectionErrorMessage = errorMessage || "";
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000581
582 // Update document title when connection status changes
583 this.updateDocumentTitle();
Sean McCullough86b56862025-04-18 13:04:03 -0700584 }
585
Sean McCulloughd3906e22025-04-29 17:32:14 +0000586 /**
587 * Handle stop button click
588 * Sends a request to the server to stop the current operation
589 */
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000590 // Update last commit information when new messages arrive
591 private updateLastCommitInfo(newMessages: AgentMessage[]): void {
592 if (!newMessages || newMessages.length === 0) return;
593
594 // Process messages in chronological order (latest last)
595 for (const message of newMessages) {
596 if (message.type === 'commit' && message.commits && message.commits.length > 0) {
597 // Get the first commit from the list
598 const commit = message.commits[0];
599 if (commit) {
600 this.lastCommit = {
601 hash: commit.hash,
602 pushedBranch: commit.pushed_branch
603 };
604 this.lastCommitCopied = false;
605 }
606 }
607 }
608 }
609
610 // Copy commit info to clipboard
611 private copyCommitInfo(event: MouseEvent): void {
612 event.preventDefault();
613 event.stopPropagation();
614
615 if (!this.lastCommit) return;
616
617 const textToCopy = this.lastCommit.pushedBranch || this.lastCommit.hash.substring(0, 8);
618
619 navigator.clipboard.writeText(textToCopy)
620 .then(() => {
621 this.lastCommitCopied = true;
622 // Reset the copied state after 2 seconds
623 setTimeout(() => {
624 this.lastCommitCopied = false;
625 }, 2000);
626 })
627 .catch(err => {
628 console.error('Failed to copy commit info:', err);
629 });
630 }
631
Sean McCulloughd3906e22025-04-29 17:32:14 +0000632 private async _handleStopClick(): Promise<void> {
633 try {
634 const response = await fetch("cancel", {
635 method: "POST",
636 headers: {
637 "Content-Type": "application/json",
638 },
639 body: JSON.stringify({ reason: "user requested cancellation" }),
640 });
641
642 if (!response.ok) {
643 const errorData = await response.text();
644 throw new Error(
645 `Failed to stop operation: ${response.status} - ${errorData}`,
646 );
647 }
648
649 this.messageStatus = "Stop request sent";
650 } catch (error) {
651 console.error("Error stopping operation:", error);
652 this.messageStatus = "Failed to stop operation";
653 }
654 }
655
Sean McCullough86b56862025-04-18 13:04:03 -0700656 async _sendChat(e: CustomEvent) {
657 console.log("app shell: _sendChat", e);
658 const message = e.detail.message?.trim();
659 if (message == "") {
660 return;
661 }
662 try {
663 // Send the message to the server
664 const response = await fetch("chat", {
665 method: "POST",
666 headers: {
667 "Content-Type": "application/json",
668 },
669 body: JSON.stringify({ message }),
670 });
671
672 if (!response.ok) {
673 const errorData = await response.text();
674 throw new Error(`Server error: ${response.status} - ${errorData}`);
675 }
Sean McCullough86b56862025-04-18 13:04:03 -0700676
Philip Zeyliger73db6052025-04-23 13:09:07 -0700677 // TOOD(philip): If the data manager is getting messages out of order, there's a bug?
Sean McCullough86b56862025-04-18 13:04:03 -0700678 // Reset data manager state to force a full refresh after sending a message
679 // This ensures we get all messages in the correct order
680 // Use private API for now - TODO: add a resetState() method to DataManager
681 (this.dataManager as any).nextFetchIndex = 0;
682 (this.dataManager as any).currentFetchStartIndex = 0;
683
Sean McCullough86b56862025-04-18 13:04:03 -0700684 // // If in diff view, switch to conversation view
685 // if (this.viewMode === "diff") {
686 // await this.toggleViewMode("chat");
687 // }
688
689 // Refresh the timeline data to show the new message
690 await this.dataManager.fetchData();
Sean McCullough86b56862025-04-18 13:04:03 -0700691 } catch (error) {
692 console.error("Error sending chat message:", error);
693 const statusText = document.getElementById("statusText");
694 if (statusText) {
695 statusText.textContent = "Error sending message";
696 }
697 }
698 }
699
Pokey Rule4097e532025-04-24 18:55:28 +0100700 private scrollContainerRef = createRef<HTMLElement>();
701
Sean McCullough86b56862025-04-18 13:04:03 -0700702 render() {
703 return html`
Pokey Rule4097e532025-04-24 18:55:28 +0100704 <div id="top-banner">
Sean McCullough86b56862025-04-18 13:04:03 -0700705 <div class="title-container">
706 <h1 class="banner-title">sketch</h1>
707 <h2 id="chatTitle" class="chat-title">${this.title}</h2>
708 </div>
709
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000710 <!-- Views section with tabs -->
711 <sketch-view-mode-select></sketch-view-mode-select>
712
713 <!-- Container status info -->
Sean McCullough86b56862025-04-18 13:04:03 -0700714 <sketch-container-status
715 .state=${this.containerState}
716 ></sketch-container-status>
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000717
718 ${this.lastCommit ? html`
719 <div class="last-commit" @click=${(e: MouseEvent) => this.copyCommitInfo(e)} title="Click to copy">
720 ${this.lastCommitCopied ? html`<span class="copied-indicator">Copied!</span>` : ''}
721 ${this.lastCommit.pushedBranch
722 ? html`<span class="commit-branch-indicator">${this.lastCommit.pushedBranch}</span>`
723 : html`<span class="commit-hash-indicator">${this.lastCommit.hash.substring(0, 8)}</span>`}
724 </div>
725 ` : ''}
Sean McCullough86b56862025-04-18 13:04:03 -0700726
727 <div class="refresh-control">
Sean McCulloughd3906e22025-04-29 17:32:14 +0000728 <button
729 id="stopButton"
730 class="refresh-button stop-button"
731 @click="${this._handleStopClick}"
732 >
Sean McCullough86b56862025-04-18 13:04:03 -0700733 Stop
734 </button>
735
736 <div class="poll-updates">
737 <input type="checkbox" id="pollToggle" checked />
738 <label for="pollToggle">Poll</label>
739 </div>
740
741 <sketch-network-status
742 message=${this.messageStatus}
743 connection=${this.connectionStatus}
744 error=${this.connectionErrorMessage}
745 ></sketch-network-status>
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000746
747 <sketch-call-status
748 .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
749 .toolCalls=${this.containerState?.outstanding_tool_calls || []}
750 ></sketch-call-status>
Sean McCullough86b56862025-04-18 13:04:03 -0700751 </div>
752 </div>
753
Pokey Rule4097e532025-04-24 18:55:28 +0100754 <div id="view-container" ${ref(this.scrollContainerRef)}>
755 <div id="view-container-inner">
756 <div
757 class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}"
758 >
759 <sketch-timeline
760 .messages=${this.messages}
761 .scrollContainer=${this.scrollContainerRef}
762 ></sketch-timeline>
763 </div>
764 <div
765 class="diff-view ${this.viewMode === "diff" ? "view-active" : ""}"
766 >
767 <sketch-diff-view
768 .commitHash=${this.currentCommitHash}
769 ></sketch-diff-view>
770 </div>
771 <div
772 class="chart-view ${this.viewMode === "charts"
773 ? "view-active"
774 : ""}"
775 >
776 <sketch-charts .messages=${this.messages}></sketch-charts>
777 </div>
778 <div
779 class="terminal-view ${this.viewMode === "terminal"
780 ? "view-active"
781 : ""}"
782 >
783 <sketch-terminal></sketch-terminal>
784 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700785 </div>
786 </div>
787
Pokey Rule4097e532025-04-24 18:55:28 +0100788 <div id="chat-input">
789 <sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
790 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700791 `;
792 }
793
794 /**
Sean McCullough86b56862025-04-18 13:04:03 -0700795 * Lifecycle callback when component is first connected to DOM
796 */
797 firstUpdated(): void {
798 if (this.viewMode !== "chat") {
799 return;
800 }
801
802 // Initial scroll to bottom when component is first rendered
803 setTimeout(
804 () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
Philip Zeyliger72682df2025-04-23 13:09:46 -0700805 50,
Sean McCullough86b56862025-04-18 13:04:03 -0700806 );
807
Sean McCullough71941bd2025-04-18 13:31:48 -0700808 const pollToggleCheckbox = this.renderRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700809 "#pollToggle",
Sean McCullough71941bd2025-04-18 13:31:48 -0700810 ) as HTMLInputElement;
Sean McCullough86b56862025-04-18 13:04:03 -0700811 pollToggleCheckbox?.addEventListener("change", () => {
812 this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
813 if (!pollToggleCheckbox.checked) {
814 this.connectionStatus = "disabled";
815 this.messageStatus = "Polling stopped";
816 } else {
817 this.messageStatus = "Polling for updates...";
818 }
819 });
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000820
821 // Process any existing messages to find commit information
822 if (this.messages && this.messages.length > 0) {
823 this.updateLastCommitInfo(this.messages);
824 }
Sean McCullough86b56862025-04-18 13:04:03 -0700825 }
826}
827
828declare global {
829 interface HTMLElementTagNameMap {
830 "sketch-app-shell": SketchAppShell;
831 }
832}