blob: 1cd1a0b34ba87c5ea05badef7509af71235d45d5 [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";
Philip Zeyliger2c4db092025-04-28 16:57:50 -070016import "./sketch-restart-modal";
Pokey Rule4097e532025-04-24 18:55:28 +010017
18import { createRef, ref } from "lit/directives/ref.js";
Sean McCullough86b56862025-04-18 13:04:03 -070019
20type ViewMode = "chat" | "diff" | "charts" | "terminal";
21
22@customElement("sketch-app-shell")
23export class SketchAppShell extends LitElement {
24 // Current view mode (chat, diff, charts, terminal)
25 @state()
26 viewMode: "chat" | "diff" | "charts" | "terminal" = "chat";
27
28 // Current commit hash for diff view
29 @state()
30 currentCommitHash: string = "";
31
Philip Zeyliger47b71c92025-04-30 15:43:39 +000032 // Last commit information
33 @state()
34 lastCommit: { hash: string; pushedBranch?: string } | null = null;
35
Sean McCullough86b56862025-04-18 13:04:03 -070036 // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
37 // Note that these styles only apply to the scope of this web component's
38 // shadow DOM node, so they won't leak out or collide with CSS declared in
39 // other components or the containing web page (...unless you want it to do that).
40 static styles = css`
Philip Zeyliger47b71c92025-04-30 15:43:39 +000041 /* Last commit display styling */
42 .last-commit {
43 display: flex;
44 align-items: center;
45 padding: 3px 8px;
46 background: #f0f7ff;
47 border: 1px solid #c8e1ff;
48 border-radius: 4px;
49 font-family: monospace;
50 font-size: 12px;
51 color: #0366d6;
52 cursor: pointer;
53 position: relative;
54 margin: 0 10px;
55 white-space: nowrap;
56 overflow: hidden;
57 text-overflow: ellipsis;
58 max-width: 180px;
59 transition: background-color 0.2s ease;
60 }
Autoformattercf570962025-04-30 17:27:39 +000061
Philip Zeyliger47b71c92025-04-30 15:43:39 +000062 .last-commit:hover {
63 background-color: #dbedff;
64 }
Autoformattercf570962025-04-30 17:27:39 +000065
Philip Zeyliger47b71c92025-04-30 15:43:39 +000066 .last-commit::before {
67 content: "Last Commit: ";
68 color: #666;
69 margin-right: 4px;
70 font-family: system-ui, sans-serif;
71 font-size: 11px;
72 }
Autoformattercf570962025-04-30 17:27:39 +000073
Philip Zeyliger47b71c92025-04-30 15:43:39 +000074 .copied-indicator {
75 position: absolute;
76 top: -20px;
77 left: 50%;
78 transform: translateX(-50%);
79 background: rgba(40, 167, 69, 0.9);
80 color: white;
81 padding: 2px 6px;
82 border-radius: 3px;
83 font-size: 10px;
84 font-family: system-ui, sans-serif;
85 animation: fadeInOut 2s ease;
86 pointer-events: none;
87 }
Autoformattercf570962025-04-30 17:27:39 +000088
Philip Zeyliger47b71c92025-04-30 15:43:39 +000089 @keyframes fadeInOut {
Autoformattercf570962025-04-30 17:27:39 +000090 0% {
91 opacity: 0;
92 }
93 20% {
94 opacity: 1;
95 }
96 80% {
97 opacity: 1;
98 }
99 100% {
100 opacity: 0;
101 }
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000102 }
Autoformattercf570962025-04-30 17:27:39 +0000103
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000104 .commit-branch-indicator {
105 color: #28a745;
106 }
Autoformattercf570962025-04-30 17:27:39 +0000107
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000108 .commit-hash-indicator {
109 color: #0366d6;
110 }
Sean McCullough86b56862025-04-18 13:04:03 -0700111 :host {
112 display: block;
Sean McCullough71941bd2025-04-18 13:31:48 -0700113 font-family:
114 system-ui,
115 -apple-system,
116 BlinkMacSystemFont,
117 "Segoe UI",
118 Roboto,
119 sans-serif;
Sean McCullough86b56862025-04-18 13:04:03 -0700120 color: #333;
121 line-height: 1.4;
Pokey Rule4097e532025-04-24 18:55:28 +0100122 height: 100vh;
Sean McCullough86b56862025-04-18 13:04:03 -0700123 width: 100%;
124 position: relative;
125 overflow-x: hidden;
Pokey Rule4097e532025-04-24 18:55:28 +0100126 display: flex;
127 flex-direction: column;
Sean McCullough86b56862025-04-18 13:04:03 -0700128 }
129
130 /* Top banner with combined elements */
Pokey Rule4097e532025-04-24 18:55:28 +0100131 #top-banner {
Sean McCullough86b56862025-04-18 13:04:03 -0700132 display: flex;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000133 align-self: stretch;
Sean McCullough86b56862025-04-18 13:04:03 -0700134 justify-content: space-between;
135 align-items: center;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000136 padding: 0 20px;
Sean McCullough86b56862025-04-18 13:04:03 -0700137 margin-bottom: 0;
138 border-bottom: 1px solid #eee;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000139 gap: 20px;
Sean McCullough86b56862025-04-18 13:04:03 -0700140 background: white;
Sean McCullough86b56862025-04-18 13:04:03 -0700141 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000142 width: 100%;
143 height: 48px;
144 padding-right: 30px; /* Extra padding on the right to prevent elements from hitting the edge */
Sean McCullough86b56862025-04-18 13:04:03 -0700145 }
146
Pokey Rule4097e532025-04-24 18:55:28 +0100147 /* View mode container styles - mirroring timeline.css structure */
148 #view-container {
149 align-self: stretch;
150 overflow-y: auto;
151 flex: 1;
152 }
153
154 #view-container-inner {
155 max-width: 1200px;
156 margin: 0 auto;
157 position: relative;
158 padding-bottom: 10px;
159 padding-top: 10px;
160 }
161
162 #chat-input {
163 align-self: flex-end;
164 width: 100%;
165 box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
166 }
167
Sean McCullough86b56862025-04-18 13:04:03 -0700168 .banner-title {
169 font-size: 18px;
170 font-weight: 600;
171 margin: 0;
172 min-width: 6em;
173 white-space: nowrap;
174 overflow: hidden;
175 text-overflow: ellipsis;
176 }
177
178 .chat-title {
179 margin: 0;
180 padding: 0;
181 color: rgba(82, 82, 82, 0.85);
Josh Bleecher Snydereb5166a2025-04-30 17:04:20 +0000182 font-size: 14px;
Sean McCullough86b56862025-04-18 13:04:03 -0700183 font-weight: normal;
184 font-style: italic;
185 white-space: nowrap;
186 overflow: hidden;
187 text-overflow: ellipsis;
188 }
189
Sean McCullough86b56862025-04-18 13:04:03 -0700190 /* Allow the container to expand to full width in diff mode */
Pokey Rule46fff972025-04-25 14:57:44 +0100191 #view-container-inner.diff-active {
Sean McCullough86b56862025-04-18 13:04:03 -0700192 max-width: 100%;
Pokey Rule46fff972025-04-25 14:57:44 +0100193 width: 100%;
Sean McCullough86b56862025-04-18 13:04:03 -0700194 }
195
196 /* Individual view styles */
197 .chat-view,
198 .diff-view,
199 .chart-view,
200 .terminal-view {
201 display: none; /* Hidden by default */
202 width: 100%;
203 }
204
205 /* Active view styles - these will be applied via JavaScript */
206 .view-active {
207 display: flex;
208 flex-direction: column;
209 }
210
211 .title-container {
212 display: flex;
213 flex-direction: column;
214 white-space: nowrap;
215 overflow: hidden;
216 text-overflow: ellipsis;
Josh Bleecher Snydereb5166a2025-04-30 17:04:20 +0000217 max-width: 30%;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000218 padding: 6px 0;
Sean McCullough86b56862025-04-18 13:04:03 -0700219 }
220
221 .refresh-control {
222 display: flex;
223 align-items: center;
224 margin-bottom: 0;
225 flex-wrap: nowrap;
226 white-space: nowrap;
227 flex-shrink: 0;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000228 gap: 15px;
229 padding-left: 15px;
230 margin-right: 50px;
Sean McCullough86b56862025-04-18 13:04:03 -0700231 }
232
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700233 .restart-button {
234 background: #2196f3;
235 color: white;
236 border: none;
237 padding: 4px 10px;
238 border-radius: 4px;
239 cursor: pointer;
240 font-size: 12px;
241 margin-right: 5px;
242 }
243
244 .restart-button:hover {
245 background-color: #0b7dda;
246 }
247
248 .restart-button:disabled {
249 background-color: #ccc;
250 cursor: not-allowed;
251 opacity: 0.6;
252 }
253
Sean McCullough86b56862025-04-18 13:04:03 -0700254 .refresh-button {
255 background: #4caf50;
256 color: white;
257 border: none;
258 padding: 4px 10px;
259 border-radius: 4px;
260 cursor: pointer;
261 font-size: 12px;
262 margin-right: 5px;
263 }
264
265 .stop-button:hover {
266 background-color: #c82333 !important;
267 }
268
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000269 .poll-updates,
270 .notifications-toggle {
Sean McCullough86b56862025-04-18 13:04:03 -0700271 display: flex;
272 align-items: center;
Sean McCullough86b56862025-04-18 13:04:03 -0700273 font-size: 12px;
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000274 margin-right: 10px;
Sean McCullough86b56862025-04-18 13:04:03 -0700275 }
276 `;
277
278 // Header bar: Network connection status details
279 @property()
280 connectionStatus: ConnectionStatus = "disconnected";
Autoformattercf570962025-04-30 17:27:39 +0000281
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000282 // Track if the last commit info has been copied
283 @state()
284 lastCommitCopied: boolean = false;
Sean McCullough86b56862025-04-18 13:04:03 -0700285
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000286 // Track notification preferences
287 @state()
288 notificationsEnabled: boolean = true;
289
Sean McCullough86b56862025-04-18 13:04:03 -0700290 @property()
291 connectionErrorMessage: string = "";
292
293 @property()
294 messageStatus: string = "";
295
296 // Chat messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100297 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700298 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -0700299
300 @property()
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000301 set title(value: string) {
302 const oldValue = this._title;
303 this._title = value;
304 this.requestUpdate("title", oldValue);
305 // Update document title when title property changes
306 this.updateDocumentTitle();
307 }
308
309 get title(): string {
310 return this._title;
311 }
312
313 private _title: string = "";
Sean McCullough86b56862025-04-18 13:04:03 -0700314
315 private dataManager = new DataManager();
316
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100317 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700318 containerState: State = {
319 title: "",
320 os: "",
321 message_count: 0,
322 hostname: "",
323 working_dir: "",
324 initial_commit: "",
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000325 outstanding_llm_calls: 0,
326 outstanding_tool_calls: [],
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000327 session_id: "",
328 ssh_available: false,
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700329 ssh_error: "",
330 in_container: false,
331 first_message_index: 0,
Sean McCulloughd9f13372025-04-21 15:08:49 -0700332 };
Sean McCullough86b56862025-04-18 13:04:03 -0700333
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700334 @state()
335 private restartModalOpen = false;
336
Sean McCullough86b56862025-04-18 13:04:03 -0700337 // Mutation observer to detect when new messages are added
338 private mutationObserver: MutationObserver | null = null;
339
340 constructor() {
341 super();
342
343 // Binding methods to this
344 this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
Sean McCullough86b56862025-04-18 13:04:03 -0700345 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
346 this._handlePopState = this._handlePopState.bind(this);
Sean McCulloughd3906e22025-04-29 17:32:14 +0000347 this._handleStopClick = this._handleStopClick.bind(this);
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000348 this._handleNotificationsToggle =
349 this._handleNotificationsToggle.bind(this);
350
351 // Load notification preference from localStorage
352 try {
353 const savedPref = localStorage.getItem("sketch-notifications-enabled");
354 if (savedPref !== null) {
355 this.notificationsEnabled = savedPref === "true";
356 }
357 } catch (error) {
358 console.error("Error loading notification preference:", error);
359 }
Sean McCullough86b56862025-04-18 13:04:03 -0700360 }
361
362 // See https://lit.dev/docs/components/lifecycle/
363 connectedCallback() {
364 super.connectedCallback();
365
366 // Initialize client-side nav history.
367 const url = new URL(window.location.href);
368 const mode = url.searchParams.get("view") || "chat";
369 window.history.replaceState({ mode }, "", url.toString());
370
371 this.toggleViewMode(mode as ViewMode, false);
372 // Add popstate event listener to handle browser back/forward navigation
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100373 window.addEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700374
375 // Add event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100376 window.addEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100377 window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
Sean McCullough86b56862025-04-18 13:04:03 -0700378
379 // register event listeners
380 this.dataManager.addEventListener(
381 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700382 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700383 );
384 this.dataManager.addEventListener(
385 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700386 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700387 );
388
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000389 // Set initial document title
390 this.updateDocumentTitle();
391
Sean McCullough86b56862025-04-18 13:04:03 -0700392 // Initialize the data manager
393 this.dataManager.initialize();
Autoformattercf570962025-04-30 17:27:39 +0000394
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000395 // Process existing messages for commit info
396 if (this.messages && this.messages.length > 0) {
397 this.updateLastCommitInfo(this.messages);
398 }
Sean McCullough86b56862025-04-18 13:04:03 -0700399 }
400
401 // See https://lit.dev/docs/components/lifecycle/
402 disconnectedCallback() {
403 super.disconnectedCallback();
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100404 window.removeEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700405
406 // Remove event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100407 window.removeEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100408 window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
Sean McCullough86b56862025-04-18 13:04:03 -0700409
410 // unregister data manager event listeners
411 this.dataManager.removeEventListener(
412 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700413 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700414 );
415 this.dataManager.removeEventListener(
416 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700417 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700418 );
419
420 // Disconnect mutation observer if it exists
421 if (this.mutationObserver) {
Sean McCullough86b56862025-04-18 13:04:03 -0700422 this.mutationObserver.disconnect();
423 this.mutationObserver = null;
424 }
425 }
426
Sean McCullough71941bd2025-04-18 13:31:48 -0700427 updateUrlForViewMode(mode: "chat" | "diff" | "charts" | "terminal"): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700428 // Get the current URL without search parameters
429 const url = new URL(window.location.href);
430
431 // Clear existing parameters
432 url.search = "";
433
434 // Only add view parameter if not in default chat view
435 if (mode !== "chat") {
436 url.searchParams.set("view", mode);
Sean McCullough71941bd2025-04-18 13:31:48 -0700437 const diffView = this.shadowRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700438 ".diff-view",
Sean McCullough71941bd2025-04-18 13:31:48 -0700439 ) as SketchDiffView;
Sean McCullough86b56862025-04-18 13:04:03 -0700440
441 // If in diff view and there's a commit hash, include that too
442 if (mode === "diff" && diffView.commitHash) {
443 url.searchParams.set("commit", diffView.commitHash);
444 }
445 }
446
447 // Update the browser history without reloading the page
448 window.history.pushState({ mode }, "", url.toString());
449 }
450
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100451 private _handlePopState(event: PopStateEvent) {
Sean McCullough86b56862025-04-18 13:04:03 -0700452 if (event.state && event.state.mode) {
453 this.toggleViewMode(event.state.mode, false);
454 } else {
455 this.toggleViewMode("chat", false);
456 }
457 }
458
459 /**
460 * Handle view mode selection event
461 */
462 private _handleViewModeSelect(event: CustomEvent) {
463 const mode = event.detail.mode as "chat" | "diff" | "charts" | "terminal";
464 this.toggleViewMode(mode, true);
465 }
466
467 /**
468 * Handle show commit diff event
469 */
470 private _handleShowCommitDiff(event: CustomEvent) {
471 const { commitHash } = event.detail;
472 if (commitHash) {
473 this.showCommitDiff(commitHash);
474 }
475 }
476
477 /**
Sean McCullough86b56862025-04-18 13:04:03 -0700478 * Listen for commit diff event
479 * @param commitHash The commit hash to show diff for
480 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100481 private showCommitDiff(commitHash: string): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700482 // Store the commit hash
483 this.currentCommitHash = commitHash;
484
485 // Switch to diff view
Sean McCullough71941bd2025-04-18 13:31:48 -0700486 this.toggleViewMode("diff", true);
Sean McCullough86b56862025-04-18 13:04:03 -0700487
488 // Wait for DOM update to complete
489 this.updateComplete.then(() => {
490 // Get the diff view component
491 const diffView = this.shadowRoot?.querySelector("sketch-diff-view");
492 if (diffView) {
493 // Call the showCommitDiff method
494 (diffView as any).showCommitDiff(commitHash);
495 }
496 });
497 }
498
499 /**
500 * Toggle between different view modes: chat, diff, charts, terminal
501 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100502 private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700503 // Don't do anything if the mode is already active
504 if (this.viewMode === mode) return;
505
506 // Update the view mode
507 this.viewMode = mode;
508
509 if (updateHistory) {
510 // Update URL with the current view mode
511 this.updateUrlForViewMode(mode);
512 }
513
514 // Wait for DOM update to complete
515 this.updateComplete.then(() => {
516 // Update active view
Pokey Rule46fff972025-04-25 14:57:44 +0100517 const viewContainerInner = this.shadowRoot?.querySelector(
518 "#view-container-inner",
519 );
Sean McCullough86b56862025-04-18 13:04:03 -0700520 const chatView = this.shadowRoot?.querySelector(".chat-view");
521 const diffView = this.shadowRoot?.querySelector(".diff-view");
522 const chartView = this.shadowRoot?.querySelector(".chart-view");
523 const terminalView = this.shadowRoot?.querySelector(".terminal-view");
524
525 // Remove active class from all views
526 chatView?.classList.remove("view-active");
527 diffView?.classList.remove("view-active");
528 chartView?.classList.remove("view-active");
529 terminalView?.classList.remove("view-active");
530
531 // Add/remove diff-active class on view container
532 if (mode === "diff") {
Pokey Rule46fff972025-04-25 14:57:44 +0100533 viewContainerInner?.classList.add("diff-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700534 } else {
Pokey Rule46fff972025-04-25 14:57:44 +0100535 viewContainerInner?.classList.remove("diff-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700536 }
537
538 // Add active class to the selected view
539 switch (mode) {
540 case "chat":
541 chatView?.classList.add("view-active");
542 break;
543 case "diff":
544 diffView?.classList.add("view-active");
545 // Load diff content if we have a diff view
546 const diffViewComp =
547 this.shadowRoot?.querySelector("sketch-diff-view");
548 if (diffViewComp && this.currentCommitHash) {
549 (diffViewComp as any).showCommitDiff(this.currentCommitHash);
550 } else if (diffViewComp) {
551 (diffViewComp as any).loadDiffContent();
552 }
553 break;
554 case "charts":
555 chartView?.classList.add("view-active");
556 break;
557 case "terminal":
558 terminalView?.classList.add("view-active");
559 break;
560 }
561
562 // Update view mode buttons
563 const viewModeSelect = this.shadowRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700564 "sketch-view-mode-select",
Sean McCullough86b56862025-04-18 13:04:03 -0700565 );
566 if (viewModeSelect) {
567 const event = new CustomEvent("update-active-mode", {
568 detail: { mode },
569 bubbles: true,
570 composed: true,
571 });
572 viewModeSelect.dispatchEvent(event);
573 }
574
575 // FIXME: This is a hack to get vega chart in sketch-charts.ts to work properly
576 // When the chart is in the background, its container has a width of 0, so vega
577 // renders width 0 and only changes that width on a resize event.
578 // See https://github.com/vega/react-vega/issues/85#issuecomment-1826421132
579 window.dispatchEvent(new Event("resize"));
580 });
581 }
582
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000583 /**
584 * Updates the document title based on current title and connection status
585 */
586 private updateDocumentTitle(): void {
587 let docTitle = `sk: ${this.title || "untitled"}`;
588
589 // Add red circle emoji if disconnected
590 if (this.connectionStatus === "disconnected") {
591 docTitle += " 🔴";
592 }
593
594 document.title = docTitle;
595 }
596
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000597 // Check and request notification permission if needed
598 private async checkNotificationPermission(): Promise<boolean> {
599 // Check if the Notification API is supported
600 if (!("Notification" in window)) {
601 console.log("This browser does not support notifications");
602 return false;
603 }
604
605 // Check if permission is already granted
606 if (Notification.permission === "granted") {
607 return true;
608 }
609
610 // If permission is not denied, request it
611 if (Notification.permission !== "denied") {
612 const permission = await Notification.requestPermission();
613 return permission === "granted";
614 }
615
616 return false;
617 }
618
619 // Handle notifications toggle change
620 private _handleNotificationsToggle(event: Event): void {
621 const toggleCheckbox = event.target as HTMLInputElement;
622 this.notificationsEnabled = toggleCheckbox.checked;
623
624 // If enabling notifications, check permissions
625 if (this.notificationsEnabled) {
626 this.checkNotificationPermission();
627 }
628
629 // Save preference to localStorage
630 try {
631 localStorage.setItem(
632 "sketch-notifications-enabled",
633 String(this.notificationsEnabled),
634 );
635 } catch (error) {
636 console.error("Error saving notification preference:", error);
637 }
638 }
639
640 // Show notification for message with EndOfTurn=true
641 private async showEndOfTurnNotification(
642 message: AgentMessage,
643 ): Promise<void> {
644 // Don't show notifications if they're disabled
645 if (!this.notificationsEnabled) return;
646
647 // Check if we have permission to show notifications
648 const hasPermission = await this.checkNotificationPermission();
649 if (!hasPermission) return;
650
651 // Only show notifications for agent messages with end_of_turn=true
652 if (message.type !== "agent" || !message.end_of_turn) return;
653
654 // Create a title that includes the sketch title
655 const notificationTitle = `Sketch: ${this.title || "untitled"}`;
656
657 // Extract the beginning of the message content (first 100 chars)
658 const messagePreview = message.content
659 ? message.content.substring(0, 100) +
660 (message.content.length > 100 ? "..." : "")
661 : "Agent has completed its turn";
662
663 // Create and show the notification
664 try {
665 new Notification(notificationTitle, {
666 body: messagePreview,
667 icon: "/static/favicon.ico", // Use sketch favicon if available
668 });
669 } catch (error) {
670 console.error("Error showing notification:", error);
671 }
672 }
673
Sean McCullough86b56862025-04-18 13:04:03 -0700674 private handleDataChanged(eventData: {
675 state: State;
Sean McCulloughd9f13372025-04-21 15:08:49 -0700676 newMessages: AgentMessage[];
Sean McCullough86b56862025-04-18 13:04:03 -0700677 isFirstFetch?: boolean;
678 }): void {
679 const { state, newMessages, isFirstFetch } = eventData;
680
681 // Check if this is the first data fetch or if there are new messages
682 if (isFirstFetch) {
Sean McCullough86b56862025-04-18 13:04:03 -0700683 this.messageStatus = "Initial messages loaded";
684 } else if (newMessages && newMessages.length > 0) {
Sean McCullough86b56862025-04-18 13:04:03 -0700685 this.messageStatus = "Updated just now";
Sean McCullough86b56862025-04-18 13:04:03 -0700686 } else {
687 this.messageStatus = "No new messages";
688 }
689
690 // Update state if we received it
691 if (state) {
Josh Bleecher Snydere81233f2025-04-30 04:05:41 +0000692 // Ensure we're using the latest call status to prevent indicators from being stuck
Autoformatterf830c9d2025-04-30 18:16:01 +0000693 if (
694 state.outstanding_llm_calls === 0 &&
695 state.outstanding_tool_calls.length === 0
696 ) {
Josh Bleecher Snydere81233f2025-04-30 04:05:41 +0000697 // Force reset containerState calls when nothing is reported as in progress
698 state.outstanding_llm_calls = 0;
699 state.outstanding_tool_calls = [];
700 }
Autoformatterf830c9d2025-04-30 18:16:01 +0000701
Sean McCullough86b56862025-04-18 13:04:03 -0700702 this.containerState = state;
703 this.title = state.title;
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000704
705 // Update document title when sketch title changes
706 this.updateDocumentTitle();
Sean McCullough86b56862025-04-18 13:04:03 -0700707 }
708
Sean McCullough86b56862025-04-18 13:04:03 -0700709 // Update messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100710 this.messages = aggregateAgentMessages(this.messages, newMessages);
Autoformattercf570962025-04-30 17:27:39 +0000711
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000712 // Process new messages to find commit messages
713 this.updateLastCommitInfo(newMessages);
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000714
715 // Check for agent messages with end_of_turn=true and show notifications
716 if (newMessages && newMessages.length > 0 && !isFirstFetch) {
717 for (const message of newMessages) {
718 if (message.type === "agent" && message.end_of_turn) {
719 this.showEndOfTurnNotification(message);
720 break; // Only show one notification per batch of messages
721 }
722 }
723 }
Sean McCullough86b56862025-04-18 13:04:03 -0700724 }
725
726 private handleConnectionStatusChanged(
727 status: ConnectionStatus,
Philip Zeyliger72682df2025-04-23 13:09:46 -0700728 errorMessage?: string,
Sean McCullough86b56862025-04-18 13:04:03 -0700729 ): void {
730 this.connectionStatus = status;
731 this.connectionErrorMessage = errorMessage || "";
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000732
733 // Update document title when connection status changes
734 this.updateDocumentTitle();
Sean McCullough86b56862025-04-18 13:04:03 -0700735 }
736
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000737 // Update last commit information when new messages arrive
738 private updateLastCommitInfo(newMessages: AgentMessage[]): void {
739 if (!newMessages || newMessages.length === 0) return;
Autoformattercf570962025-04-30 17:27:39 +0000740
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000741 // Process messages in chronological order (latest last)
742 for (const message of newMessages) {
Autoformattercf570962025-04-30 17:27:39 +0000743 if (
744 message.type === "commit" &&
745 message.commits &&
746 message.commits.length > 0
747 ) {
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000748 // Get the first commit from the list
749 const commit = message.commits[0];
750 if (commit) {
751 this.lastCommit = {
752 hash: commit.hash,
Autoformattercf570962025-04-30 17:27:39 +0000753 pushedBranch: commit.pushed_branch,
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000754 };
755 this.lastCommitCopied = false;
756 }
757 }
758 }
759 }
760
761 // Copy commit info to clipboard
762 private copyCommitInfo(event: MouseEvent): void {
763 event.preventDefault();
764 event.stopPropagation();
Autoformattercf570962025-04-30 17:27:39 +0000765
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000766 if (!this.lastCommit) return;
Autoformattercf570962025-04-30 17:27:39 +0000767
768 const textToCopy =
769 this.lastCommit.pushedBranch || this.lastCommit.hash.substring(0, 8);
770
771 navigator.clipboard
772 .writeText(textToCopy)
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000773 .then(() => {
774 this.lastCommitCopied = true;
775 // Reset the copied state after 2 seconds
776 setTimeout(() => {
777 this.lastCommitCopied = false;
778 }, 2000);
779 })
Autoformattercf570962025-04-30 17:27:39 +0000780 .catch((err) => {
781 console.error("Failed to copy commit info:", err);
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000782 });
783 }
Autoformattercf570962025-04-30 17:27:39 +0000784
Sean McCulloughd3906e22025-04-29 17:32:14 +0000785 private async _handleStopClick(): Promise<void> {
786 try {
787 const response = await fetch("cancel", {
788 method: "POST",
789 headers: {
790 "Content-Type": "application/json",
791 },
792 body: JSON.stringify({ reason: "user requested cancellation" }),
793 });
794
795 if (!response.ok) {
796 const errorData = await response.text();
797 throw new Error(
798 `Failed to stop operation: ${response.status} - ${errorData}`,
799 );
800 }
801
802 this.messageStatus = "Stop request sent";
803 } catch (error) {
804 console.error("Error stopping operation:", error);
805 this.messageStatus = "Failed to stop operation";
806 }
807 }
808
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700809 openRestartModal() {
810 this.restartModalOpen = true;
811 }
812
813 handleRestartModalClose() {
814 this.restartModalOpen = false;
815 }
816
Sean McCullough86b56862025-04-18 13:04:03 -0700817 async _sendChat(e: CustomEvent) {
818 console.log("app shell: _sendChat", e);
819 const message = e.detail.message?.trim();
820 if (message == "") {
821 return;
822 }
823 try {
824 // Send the message to the server
825 const response = await fetch("chat", {
826 method: "POST",
827 headers: {
828 "Content-Type": "application/json",
829 },
830 body: JSON.stringify({ message }),
831 });
832
833 if (!response.ok) {
834 const errorData = await response.text();
835 throw new Error(`Server error: ${response.status} - ${errorData}`);
836 }
Sean McCullough86b56862025-04-18 13:04:03 -0700837
Philip Zeyliger73db6052025-04-23 13:09:07 -0700838 // TOOD(philip): If the data manager is getting messages out of order, there's a bug?
Sean McCullough86b56862025-04-18 13:04:03 -0700839 // Reset data manager state to force a full refresh after sending a message
840 // This ensures we get all messages in the correct order
841 // Use private API for now - TODO: add a resetState() method to DataManager
842 (this.dataManager as any).nextFetchIndex = 0;
843 (this.dataManager as any).currentFetchStartIndex = 0;
844
Sean McCullough86b56862025-04-18 13:04:03 -0700845 // // If in diff view, switch to conversation view
846 // if (this.viewMode === "diff") {
847 // await this.toggleViewMode("chat");
848 // }
849
850 // Refresh the timeline data to show the new message
851 await this.dataManager.fetchData();
Sean McCullough86b56862025-04-18 13:04:03 -0700852 } catch (error) {
853 console.error("Error sending chat message:", error);
854 const statusText = document.getElementById("statusText");
855 if (statusText) {
856 statusText.textContent = "Error sending message";
857 }
858 }
859 }
860
Pokey Rule4097e532025-04-24 18:55:28 +0100861 private scrollContainerRef = createRef<HTMLElement>();
862
Sean McCullough86b56862025-04-18 13:04:03 -0700863 render() {
864 return html`
Pokey Rule4097e532025-04-24 18:55:28 +0100865 <div id="top-banner">
Sean McCullough86b56862025-04-18 13:04:03 -0700866 <div class="title-container">
867 <h1 class="banner-title">sketch</h1>
868 <h2 id="chatTitle" class="chat-title">${this.title}</h2>
869 </div>
870
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000871 <!-- Views section with tabs -->
872 <sketch-view-mode-select></sketch-view-mode-select>
873
874 <!-- Container status info -->
Sean McCullough86b56862025-04-18 13:04:03 -0700875 <sketch-container-status
876 .state=${this.containerState}
877 ></sketch-container-status>
Autoformattercf570962025-04-30 17:27:39 +0000878
879 ${this.lastCommit
880 ? html`
881 <div
882 class="last-commit"
883 @click=${(e: MouseEvent) => this.copyCommitInfo(e)}
884 title="Click to copy"
885 >
886 ${this.lastCommitCopied
887 ? html`<span class="copied-indicator">Copied!</span>`
888 : ""}
889 ${this.lastCommit.pushedBranch
890 ? html`<span class="commit-branch-indicator"
891 >${this.lastCommit.pushedBranch}</span
892 >`
893 : html`<span class="commit-hash-indicator"
894 >${this.lastCommit.hash.substring(0, 8)}</span
895 >`}
896 </div>
897 `
898 : ""}
Sean McCullough86b56862025-04-18 13:04:03 -0700899
900 <div class="refresh-control">
Sean McCulloughd3906e22025-04-29 17:32:14 +0000901 <button
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700902 id="restartButton"
903 class="restart-button"
904 ?disabled=${this.containerState.message_count === 0}
905 @click=${this.openRestartModal}
Sean McCulloughd3906e22025-04-29 17:32:14 +0000906 >
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700907 Restart
908 </button>
909 <button id="stopButton" class="refresh-button stop-button">
Sean McCullough86b56862025-04-18 13:04:03 -0700910 Stop
911 </button>
912
913 <div class="poll-updates">
914 <input type="checkbox" id="pollToggle" checked />
915 <label for="pollToggle">Poll</label>
916 </div>
917
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000918 <div class="notifications-toggle">
919 <input
920 type="checkbox"
921 id="notificationsToggle"
922 ?checked=${this.notificationsEnabled}
923 @change=${this._handleNotificationsToggle}
924 />
925 <label for="notificationsToggle">Notifications</label>
926 </div>
927
Sean McCullough86b56862025-04-18 13:04:03 -0700928 <sketch-network-status
929 message=${this.messageStatus}
930 connection=${this.connectionStatus}
931 error=${this.connectionErrorMessage}
932 ></sketch-network-status>
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000933
934 <sketch-call-status
935 .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
936 .toolCalls=${this.containerState?.outstanding_tool_calls || []}
937 ></sketch-call-status>
Sean McCullough86b56862025-04-18 13:04:03 -0700938 </div>
939 </div>
940
Pokey Rule4097e532025-04-24 18:55:28 +0100941 <div id="view-container" ${ref(this.scrollContainerRef)}>
942 <div id="view-container-inner">
943 <div
944 class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}"
945 >
946 <sketch-timeline
947 .messages=${this.messages}
948 .scrollContainer=${this.scrollContainerRef}
949 ></sketch-timeline>
950 </div>
951 <div
952 class="diff-view ${this.viewMode === "diff" ? "view-active" : ""}"
953 >
954 <sketch-diff-view
955 .commitHash=${this.currentCommitHash}
956 ></sketch-diff-view>
957 </div>
958 <div
959 class="chart-view ${this.viewMode === "charts"
960 ? "view-active"
961 : ""}"
962 >
963 <sketch-charts .messages=${this.messages}></sketch-charts>
964 </div>
965 <div
966 class="terminal-view ${this.viewMode === "terminal"
967 ? "view-active"
968 : ""}"
969 >
970 <sketch-terminal></sketch-terminal>
971 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700972 </div>
973 </div>
974
Pokey Rule4097e532025-04-24 18:55:28 +0100975 <div id="chat-input">
976 <sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
977 </div>
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700978
979 <sketch-restart-modal
980 ?open=${this.restartModalOpen}
981 @close=${this.handleRestartModalClose}
982 .containerState=${this.containerState}
983 .messages=${this.messages}
984 ></sketch-restart-modal>
Sean McCullough86b56862025-04-18 13:04:03 -0700985 `;
986 }
987
988 /**
Sean McCullough86b56862025-04-18 13:04:03 -0700989 * Lifecycle callback when component is first connected to DOM
990 */
991 firstUpdated(): void {
992 if (this.viewMode !== "chat") {
993 return;
994 }
995
996 // Initial scroll to bottom when component is first rendered
997 setTimeout(
998 () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
Philip Zeyliger72682df2025-04-23 13:09:46 -0700999 50,
Sean McCullough86b56862025-04-18 13:04:03 -07001000 );
1001
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001002 // Setup stop button
1003 const stopButton = this.renderRoot?.querySelector(
1004 "#stopButton",
1005 ) as HTMLButtonElement;
1006 stopButton?.addEventListener("click", async () => {
1007 try {
1008 const response = await fetch("/cancel", {
1009 method: "POST",
1010 headers: {
1011 "Content-Type": "application/json",
1012 },
1013 body: JSON.stringify({ reason: "User clicked stop button" }),
1014 });
1015 if (!response.ok) {
1016 console.error("Failed to cancel:", await response.text());
1017 }
1018 } catch (error) {
1019 console.error("Error cancelling operation:", error);
1020 }
1021 });
1022
Sean McCullough71941bd2025-04-18 13:31:48 -07001023 const pollToggleCheckbox = this.renderRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -07001024 "#pollToggle",
Sean McCullough71941bd2025-04-18 13:31:48 -07001025 ) as HTMLInputElement;
Sean McCullough86b56862025-04-18 13:04:03 -07001026 pollToggleCheckbox?.addEventListener("change", () => {
1027 this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
1028 if (!pollToggleCheckbox.checked) {
1029 this.connectionStatus = "disabled";
1030 this.messageStatus = "Polling stopped";
1031 } else {
1032 this.messageStatus = "Polling for updates...";
1033 }
1034 });
Autoformattercf570962025-04-30 17:27:39 +00001035
Philip Zeyliger47b71c92025-04-30 15:43:39 +00001036 // Process any existing messages to find commit information
1037 if (this.messages && this.messages.length > 0) {
1038 this.updateLastCommitInfo(this.messages);
1039 }
Sean McCullough86b56862025-04-18 13:04:03 -07001040 }
1041}
1042
1043declare global {
1044 interface HTMLElementTagNameMap {
1045 "sketch-app-shell": SketchAppShell;
1046 }
1047}