blob: 3ee27f6dc78363145e1837e1c04d58caa83fa763 [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 }
Autoformattercf570962025-04-30 17:27:39 +000060
Philip Zeyliger47b71c92025-04-30 15:43:39 +000061 .last-commit:hover {
62 background-color: #dbedff;
63 }
Autoformattercf570962025-04-30 17:27:39 +000064
Philip Zeyliger47b71c92025-04-30 15:43:39 +000065 .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 }
Autoformattercf570962025-04-30 17:27:39 +000072
Philip Zeyliger47b71c92025-04-30 15:43:39 +000073 .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 }
Autoformattercf570962025-04-30 17:27:39 +000087
Philip Zeyliger47b71c92025-04-30 15:43:39 +000088 @keyframes fadeInOut {
Autoformattercf570962025-04-30 17:27:39 +000089 0% {
90 opacity: 0;
91 }
92 20% {
93 opacity: 1;
94 }
95 80% {
96 opacity: 1;
97 }
98 100% {
99 opacity: 0;
100 }
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000101 }
Autoformattercf570962025-04-30 17:27:39 +0000102
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000103 .commit-branch-indicator {
104 color: #28a745;
105 }
Autoformattercf570962025-04-30 17:27:39 +0000106
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000107 .commit-hash-indicator {
108 color: #0366d6;
109 }
Sean McCullough86b56862025-04-18 13:04:03 -0700110 :host {
111 display: block;
Sean McCullough71941bd2025-04-18 13:31:48 -0700112 font-family:
113 system-ui,
114 -apple-system,
115 BlinkMacSystemFont,
116 "Segoe UI",
117 Roboto,
118 sans-serif;
Sean McCullough86b56862025-04-18 13:04:03 -0700119 color: #333;
120 line-height: 1.4;
Pokey Rule4097e532025-04-24 18:55:28 +0100121 height: 100vh;
Sean McCullough86b56862025-04-18 13:04:03 -0700122 width: 100%;
123 position: relative;
124 overflow-x: hidden;
Pokey Rule4097e532025-04-24 18:55:28 +0100125 display: flex;
126 flex-direction: column;
Sean McCullough86b56862025-04-18 13:04:03 -0700127 }
128
129 /* Top banner with combined elements */
Pokey Rule4097e532025-04-24 18:55:28 +0100130 #top-banner {
Sean McCullough86b56862025-04-18 13:04:03 -0700131 display: flex;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000132 align-self: stretch;
Sean McCullough86b56862025-04-18 13:04:03 -0700133 justify-content: space-between;
134 align-items: center;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000135 padding: 0 20px;
Sean McCullough86b56862025-04-18 13:04:03 -0700136 margin-bottom: 0;
137 border-bottom: 1px solid #eee;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000138 gap: 20px;
Sean McCullough86b56862025-04-18 13:04:03 -0700139 background: white;
Sean McCullough86b56862025-04-18 13:04:03 -0700140 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000141 width: 100%;
142 height: 48px;
143 padding-right: 30px; /* Extra padding on the right to prevent elements from hitting the edge */
Sean McCullough86b56862025-04-18 13:04:03 -0700144 }
145
Pokey Rule4097e532025-04-24 18:55:28 +0100146 /* View mode container styles - mirroring timeline.css structure */
147 #view-container {
148 align-self: stretch;
149 overflow-y: auto;
150 flex: 1;
151 }
152
153 #view-container-inner {
154 max-width: 1200px;
155 margin: 0 auto;
156 position: relative;
157 padding-bottom: 10px;
158 padding-top: 10px;
159 }
160
161 #chat-input {
162 align-self: flex-end;
163 width: 100%;
164 box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
165 }
166
Sean McCullough86b56862025-04-18 13:04:03 -0700167 .banner-title {
168 font-size: 18px;
169 font-weight: 600;
170 margin: 0;
171 min-width: 6em;
172 white-space: nowrap;
173 overflow: hidden;
174 text-overflow: ellipsis;
175 }
176
177 .chat-title {
178 margin: 0;
179 padding: 0;
180 color: rgba(82, 82, 82, 0.85);
Josh Bleecher Snydereb5166a2025-04-30 17:04:20 +0000181 font-size: 14px;
Sean McCullough86b56862025-04-18 13:04:03 -0700182 font-weight: normal;
183 font-style: italic;
184 white-space: nowrap;
185 overflow: hidden;
186 text-overflow: ellipsis;
187 }
188
Sean McCullough86b56862025-04-18 13:04:03 -0700189 /* Allow the container to expand to full width in diff mode */
Pokey Rule46fff972025-04-25 14:57:44 +0100190 #view-container-inner.diff-active {
Sean McCullough86b56862025-04-18 13:04:03 -0700191 max-width: 100%;
Pokey Rule46fff972025-04-25 14:57:44 +0100192 width: 100%;
Sean McCullough86b56862025-04-18 13:04:03 -0700193 }
194
195 /* Individual view styles */
196 .chat-view,
197 .diff-view,
198 .chart-view,
199 .terminal-view {
200 display: none; /* Hidden by default */
201 width: 100%;
202 }
203
204 /* Active view styles - these will be applied via JavaScript */
205 .view-active {
206 display: flex;
207 flex-direction: column;
208 }
209
210 .title-container {
211 display: flex;
212 flex-direction: column;
213 white-space: nowrap;
214 overflow: hidden;
215 text-overflow: ellipsis;
Josh Bleecher Snydereb5166a2025-04-30 17:04:20 +0000216 max-width: 30%;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000217 padding: 6px 0;
Sean McCullough86b56862025-04-18 13:04:03 -0700218 }
219
220 .refresh-control {
221 display: flex;
222 align-items: center;
223 margin-bottom: 0;
224 flex-wrap: nowrap;
225 white-space: nowrap;
226 flex-shrink: 0;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000227 gap: 15px;
228 padding-left: 15px;
229 margin-right: 50px;
Sean McCullough86b56862025-04-18 13:04:03 -0700230 }
231
232 .refresh-button {
233 background: #4caf50;
234 color: white;
235 border: none;
236 padding: 4px 10px;
237 border-radius: 4px;
238 cursor: pointer;
239 font-size: 12px;
240 margin-right: 5px;
241 }
242
243 .stop-button:hover {
244 background-color: #c82333 !important;
245 }
246
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000247 .poll-updates,
248 .notifications-toggle {
Sean McCullough86b56862025-04-18 13:04:03 -0700249 display: flex;
250 align-items: center;
Sean McCullough86b56862025-04-18 13:04:03 -0700251 font-size: 12px;
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000252 margin-right: 10px;
Sean McCullough86b56862025-04-18 13:04:03 -0700253 }
254 `;
255
256 // Header bar: Network connection status details
257 @property()
258 connectionStatus: ConnectionStatus = "disconnected";
Autoformattercf570962025-04-30 17:27:39 +0000259
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000260 // Track if the last commit info has been copied
261 @state()
262 lastCommitCopied: boolean = false;
Sean McCullough86b56862025-04-18 13:04:03 -0700263
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000264 // Track notification preferences
265 @state()
266 notificationsEnabled: boolean = true;
267
Sean McCullough86b56862025-04-18 13:04:03 -0700268 @property()
269 connectionErrorMessage: string = "";
270
271 @property()
272 messageStatus: string = "";
273
274 // Chat messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100275 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700276 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -0700277
278 @property()
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000279 set title(value: string) {
280 const oldValue = this._title;
281 this._title = value;
282 this.requestUpdate("title", oldValue);
283 // Update document title when title property changes
284 this.updateDocumentTitle();
285 }
286
287 get title(): string {
288 return this._title;
289 }
290
291 private _title: string = "";
Sean McCullough86b56862025-04-18 13:04:03 -0700292
293 private dataManager = new DataManager();
294
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100295 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700296 containerState: State = {
297 title: "",
298 os: "",
299 message_count: 0,
300 hostname: "",
301 working_dir: "",
302 initial_commit: "",
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000303 outstanding_llm_calls: 0,
304 outstanding_tool_calls: [],
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000305 session_id: "",
306 ssh_available: false,
Sean McCulloughd9f13372025-04-21 15:08:49 -0700307 };
Sean McCullough86b56862025-04-18 13:04:03 -0700308
Sean McCullough86b56862025-04-18 13:04:03 -0700309 // Mutation observer to detect when new messages are added
310 private mutationObserver: MutationObserver | null = null;
311
312 constructor() {
313 super();
314
315 // Binding methods to this
316 this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
Sean McCullough86b56862025-04-18 13:04:03 -0700317 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
318 this._handlePopState = this._handlePopState.bind(this);
Sean McCulloughd3906e22025-04-29 17:32:14 +0000319 this._handleStopClick = this._handleStopClick.bind(this);
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000320 this._handleNotificationsToggle =
321 this._handleNotificationsToggle.bind(this);
322
323 // Load notification preference from localStorage
324 try {
325 const savedPref = localStorage.getItem("sketch-notifications-enabled");
326 if (savedPref !== null) {
327 this.notificationsEnabled = savedPref === "true";
328 }
329 } catch (error) {
330 console.error("Error loading notification preference:", error);
331 }
Sean McCullough86b56862025-04-18 13:04:03 -0700332 }
333
334 // See https://lit.dev/docs/components/lifecycle/
335 connectedCallback() {
336 super.connectedCallback();
337
338 // Initialize client-side nav history.
339 const url = new URL(window.location.href);
340 const mode = url.searchParams.get("view") || "chat";
341 window.history.replaceState({ mode }, "", url.toString());
342
343 this.toggleViewMode(mode as ViewMode, false);
344 // Add popstate event listener to handle browser back/forward navigation
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100345 window.addEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700346
347 // Add event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100348 window.addEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100349 window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
Sean McCullough86b56862025-04-18 13:04:03 -0700350
351 // register event listeners
352 this.dataManager.addEventListener(
353 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700354 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700355 );
356 this.dataManager.addEventListener(
357 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700358 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700359 );
360
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000361 // Set initial document title
362 this.updateDocumentTitle();
363
Sean McCullough86b56862025-04-18 13:04:03 -0700364 // Initialize the data manager
365 this.dataManager.initialize();
Autoformattercf570962025-04-30 17:27:39 +0000366
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000367 // Process existing messages for commit info
368 if (this.messages && this.messages.length > 0) {
369 this.updateLastCommitInfo(this.messages);
370 }
Sean McCullough86b56862025-04-18 13:04:03 -0700371 }
372
373 // See https://lit.dev/docs/components/lifecycle/
374 disconnectedCallback() {
375 super.disconnectedCallback();
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100376 window.removeEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700377
378 // Remove event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100379 window.removeEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100380 window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
Sean McCullough86b56862025-04-18 13:04:03 -0700381
382 // unregister data manager event listeners
383 this.dataManager.removeEventListener(
384 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700385 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700386 );
387 this.dataManager.removeEventListener(
388 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700389 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700390 );
391
392 // Disconnect mutation observer if it exists
393 if (this.mutationObserver) {
Sean McCullough86b56862025-04-18 13:04:03 -0700394 this.mutationObserver.disconnect();
395 this.mutationObserver = null;
396 }
397 }
398
Sean McCullough71941bd2025-04-18 13:31:48 -0700399 updateUrlForViewMode(mode: "chat" | "diff" | "charts" | "terminal"): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700400 // Get the current URL without search parameters
401 const url = new URL(window.location.href);
402
403 // Clear existing parameters
404 url.search = "";
405
406 // Only add view parameter if not in default chat view
407 if (mode !== "chat") {
408 url.searchParams.set("view", mode);
Sean McCullough71941bd2025-04-18 13:31:48 -0700409 const diffView = this.shadowRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700410 ".diff-view",
Sean McCullough71941bd2025-04-18 13:31:48 -0700411 ) as SketchDiffView;
Sean McCullough86b56862025-04-18 13:04:03 -0700412
413 // If in diff view and there's a commit hash, include that too
414 if (mode === "diff" && diffView.commitHash) {
415 url.searchParams.set("commit", diffView.commitHash);
416 }
417 }
418
419 // Update the browser history without reloading the page
420 window.history.pushState({ mode }, "", url.toString());
421 }
422
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100423 private _handlePopState(event: PopStateEvent) {
Sean McCullough86b56862025-04-18 13:04:03 -0700424 if (event.state && event.state.mode) {
425 this.toggleViewMode(event.state.mode, false);
426 } else {
427 this.toggleViewMode("chat", false);
428 }
429 }
430
431 /**
432 * Handle view mode selection event
433 */
434 private _handleViewModeSelect(event: CustomEvent) {
435 const mode = event.detail.mode as "chat" | "diff" | "charts" | "terminal";
436 this.toggleViewMode(mode, true);
437 }
438
439 /**
440 * Handle show commit diff event
441 */
442 private _handleShowCommitDiff(event: CustomEvent) {
443 const { commitHash } = event.detail;
444 if (commitHash) {
445 this.showCommitDiff(commitHash);
446 }
447 }
448
449 /**
Sean McCullough86b56862025-04-18 13:04:03 -0700450 * Listen for commit diff event
451 * @param commitHash The commit hash to show diff for
452 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100453 private showCommitDiff(commitHash: string): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700454 // Store the commit hash
455 this.currentCommitHash = commitHash;
456
457 // Switch to diff view
Sean McCullough71941bd2025-04-18 13:31:48 -0700458 this.toggleViewMode("diff", true);
Sean McCullough86b56862025-04-18 13:04:03 -0700459
460 // Wait for DOM update to complete
461 this.updateComplete.then(() => {
462 // Get the diff view component
463 const diffView = this.shadowRoot?.querySelector("sketch-diff-view");
464 if (diffView) {
465 // Call the showCommitDiff method
466 (diffView as any).showCommitDiff(commitHash);
467 }
468 });
469 }
470
471 /**
472 * Toggle between different view modes: chat, diff, charts, terminal
473 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100474 private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700475 // Don't do anything if the mode is already active
476 if (this.viewMode === mode) return;
477
478 // Update the view mode
479 this.viewMode = mode;
480
481 if (updateHistory) {
482 // Update URL with the current view mode
483 this.updateUrlForViewMode(mode);
484 }
485
486 // Wait for DOM update to complete
487 this.updateComplete.then(() => {
488 // Update active view
Pokey Rule46fff972025-04-25 14:57:44 +0100489 const viewContainerInner = this.shadowRoot?.querySelector(
490 "#view-container-inner",
491 );
Sean McCullough86b56862025-04-18 13:04:03 -0700492 const chatView = this.shadowRoot?.querySelector(".chat-view");
493 const diffView = this.shadowRoot?.querySelector(".diff-view");
494 const chartView = this.shadowRoot?.querySelector(".chart-view");
495 const terminalView = this.shadowRoot?.querySelector(".terminal-view");
496
497 // Remove active class from all views
498 chatView?.classList.remove("view-active");
499 diffView?.classList.remove("view-active");
500 chartView?.classList.remove("view-active");
501 terminalView?.classList.remove("view-active");
502
503 // Add/remove diff-active class on view container
504 if (mode === "diff") {
Pokey Rule46fff972025-04-25 14:57:44 +0100505 viewContainerInner?.classList.add("diff-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700506 } else {
Pokey Rule46fff972025-04-25 14:57:44 +0100507 viewContainerInner?.classList.remove("diff-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700508 }
509
510 // Add active class to the selected view
511 switch (mode) {
512 case "chat":
513 chatView?.classList.add("view-active");
514 break;
515 case "diff":
516 diffView?.classList.add("view-active");
517 // Load diff content if we have a diff view
518 const diffViewComp =
519 this.shadowRoot?.querySelector("sketch-diff-view");
520 if (diffViewComp && this.currentCommitHash) {
521 (diffViewComp as any).showCommitDiff(this.currentCommitHash);
522 } else if (diffViewComp) {
523 (diffViewComp as any).loadDiffContent();
524 }
525 break;
526 case "charts":
527 chartView?.classList.add("view-active");
528 break;
529 case "terminal":
530 terminalView?.classList.add("view-active");
531 break;
532 }
533
534 // Update view mode buttons
535 const viewModeSelect = this.shadowRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700536 "sketch-view-mode-select",
Sean McCullough86b56862025-04-18 13:04:03 -0700537 );
538 if (viewModeSelect) {
539 const event = new CustomEvent("update-active-mode", {
540 detail: { mode },
541 bubbles: true,
542 composed: true,
543 });
544 viewModeSelect.dispatchEvent(event);
545 }
546
547 // FIXME: This is a hack to get vega chart in sketch-charts.ts to work properly
548 // When the chart is in the background, its container has a width of 0, so vega
549 // renders width 0 and only changes that width on a resize event.
550 // See https://github.com/vega/react-vega/issues/85#issuecomment-1826421132
551 window.dispatchEvent(new Event("resize"));
552 });
553 }
554
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000555 /**
556 * Updates the document title based on current title and connection status
557 */
558 private updateDocumentTitle(): void {
559 let docTitle = `sk: ${this.title || "untitled"}`;
560
561 // Add red circle emoji if disconnected
562 if (this.connectionStatus === "disconnected") {
563 docTitle += " 🔴";
564 }
565
566 document.title = docTitle;
567 }
568
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000569 // Check and request notification permission if needed
570 private async checkNotificationPermission(): Promise<boolean> {
571 // Check if the Notification API is supported
572 if (!("Notification" in window)) {
573 console.log("This browser does not support notifications");
574 return false;
575 }
576
577 // Check if permission is already granted
578 if (Notification.permission === "granted") {
579 return true;
580 }
581
582 // If permission is not denied, request it
583 if (Notification.permission !== "denied") {
584 const permission = await Notification.requestPermission();
585 return permission === "granted";
586 }
587
588 return false;
589 }
590
591 // Handle notifications toggle change
592 private _handleNotificationsToggle(event: Event): void {
593 const toggleCheckbox = event.target as HTMLInputElement;
594 this.notificationsEnabled = toggleCheckbox.checked;
595
596 // If enabling notifications, check permissions
597 if (this.notificationsEnabled) {
598 this.checkNotificationPermission();
599 }
600
601 // Save preference to localStorage
602 try {
603 localStorage.setItem(
604 "sketch-notifications-enabled",
605 String(this.notificationsEnabled),
606 );
607 } catch (error) {
608 console.error("Error saving notification preference:", error);
609 }
610 }
611
612 // Show notification for message with EndOfTurn=true
613 private async showEndOfTurnNotification(
614 message: AgentMessage,
615 ): Promise<void> {
616 // Don't show notifications if they're disabled
617 if (!this.notificationsEnabled) return;
618
619 // Check if we have permission to show notifications
620 const hasPermission = await this.checkNotificationPermission();
621 if (!hasPermission) return;
622
623 // Only show notifications for agent messages with end_of_turn=true
624 if (message.type !== "agent" || !message.end_of_turn) return;
625
626 // Create a title that includes the sketch title
627 const notificationTitle = `Sketch: ${this.title || "untitled"}`;
628
629 // Extract the beginning of the message content (first 100 chars)
630 const messagePreview = message.content
631 ? message.content.substring(0, 100) +
632 (message.content.length > 100 ? "..." : "")
633 : "Agent has completed its turn";
634
635 // Create and show the notification
636 try {
637 new Notification(notificationTitle, {
638 body: messagePreview,
639 icon: "/static/favicon.ico", // Use sketch favicon if available
640 });
641 } catch (error) {
642 console.error("Error showing notification:", error);
643 }
644 }
645
Sean McCullough86b56862025-04-18 13:04:03 -0700646 private handleDataChanged(eventData: {
647 state: State;
Sean McCulloughd9f13372025-04-21 15:08:49 -0700648 newMessages: AgentMessage[];
Sean McCullough86b56862025-04-18 13:04:03 -0700649 isFirstFetch?: boolean;
650 }): void {
651 const { state, newMessages, isFirstFetch } = eventData;
652
653 // Check if this is the first data fetch or if there are new messages
654 if (isFirstFetch) {
Sean McCullough86b56862025-04-18 13:04:03 -0700655 this.messageStatus = "Initial messages loaded";
656 } else if (newMessages && newMessages.length > 0) {
Sean McCullough86b56862025-04-18 13:04:03 -0700657 this.messageStatus = "Updated just now";
Sean McCullough86b56862025-04-18 13:04:03 -0700658 } else {
659 this.messageStatus = "No new messages";
660 }
661
662 // Update state if we received it
663 if (state) {
Josh Bleecher Snydere81233f2025-04-30 04:05:41 +0000664 // Ensure we're using the latest call status to prevent indicators from being stuck
Autoformatterf830c9d2025-04-30 18:16:01 +0000665 if (
666 state.outstanding_llm_calls === 0 &&
667 state.outstanding_tool_calls.length === 0
668 ) {
Josh Bleecher Snydere81233f2025-04-30 04:05:41 +0000669 // Force reset containerState calls when nothing is reported as in progress
670 state.outstanding_llm_calls = 0;
671 state.outstanding_tool_calls = [];
672 }
Autoformatterf830c9d2025-04-30 18:16:01 +0000673
Sean McCullough86b56862025-04-18 13:04:03 -0700674 this.containerState = state;
675 this.title = state.title;
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000676
677 // Update document title when sketch title changes
678 this.updateDocumentTitle();
Sean McCullough86b56862025-04-18 13:04:03 -0700679 }
680
Sean McCullough86b56862025-04-18 13:04:03 -0700681 // Update messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100682 this.messages = aggregateAgentMessages(this.messages, newMessages);
Autoformattercf570962025-04-30 17:27:39 +0000683
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000684 // Process new messages to find commit messages
685 this.updateLastCommitInfo(newMessages);
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000686
687 // Check for agent messages with end_of_turn=true and show notifications
688 if (newMessages && newMessages.length > 0 && !isFirstFetch) {
689 for (const message of newMessages) {
690 if (message.type === "agent" && message.end_of_turn) {
691 this.showEndOfTurnNotification(message);
692 break; // Only show one notification per batch of messages
693 }
694 }
695 }
Sean McCullough86b56862025-04-18 13:04:03 -0700696 }
697
698 private handleConnectionStatusChanged(
699 status: ConnectionStatus,
Philip Zeyliger72682df2025-04-23 13:09:46 -0700700 errorMessage?: string,
Sean McCullough86b56862025-04-18 13:04:03 -0700701 ): void {
702 this.connectionStatus = status;
703 this.connectionErrorMessage = errorMessage || "";
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000704
705 // Update document title when connection status changes
706 this.updateDocumentTitle();
Sean McCullough86b56862025-04-18 13:04:03 -0700707 }
708
Sean McCulloughd3906e22025-04-29 17:32:14 +0000709 /**
710 * Handle stop button click
711 * Sends a request to the server to stop the current operation
712 */
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000713 // Update last commit information when new messages arrive
714 private updateLastCommitInfo(newMessages: AgentMessage[]): void {
715 if (!newMessages || newMessages.length === 0) return;
Autoformattercf570962025-04-30 17:27:39 +0000716
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000717 // Process messages in chronological order (latest last)
718 for (const message of newMessages) {
Autoformattercf570962025-04-30 17:27:39 +0000719 if (
720 message.type === "commit" &&
721 message.commits &&
722 message.commits.length > 0
723 ) {
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000724 // Get the first commit from the list
725 const commit = message.commits[0];
726 if (commit) {
727 this.lastCommit = {
728 hash: commit.hash,
Autoformattercf570962025-04-30 17:27:39 +0000729 pushedBranch: commit.pushed_branch,
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000730 };
731 this.lastCommitCopied = false;
732 }
733 }
734 }
735 }
736
737 // Copy commit info to clipboard
738 private copyCommitInfo(event: MouseEvent): void {
739 event.preventDefault();
740 event.stopPropagation();
Autoformattercf570962025-04-30 17:27:39 +0000741
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000742 if (!this.lastCommit) return;
Autoformattercf570962025-04-30 17:27:39 +0000743
744 const textToCopy =
745 this.lastCommit.pushedBranch || this.lastCommit.hash.substring(0, 8);
746
747 navigator.clipboard
748 .writeText(textToCopy)
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000749 .then(() => {
750 this.lastCommitCopied = true;
751 // Reset the copied state after 2 seconds
752 setTimeout(() => {
753 this.lastCommitCopied = false;
754 }, 2000);
755 })
Autoformattercf570962025-04-30 17:27:39 +0000756 .catch((err) => {
757 console.error("Failed to copy commit info:", err);
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000758 });
759 }
Autoformattercf570962025-04-30 17:27:39 +0000760
Sean McCulloughd3906e22025-04-29 17:32:14 +0000761 private async _handleStopClick(): Promise<void> {
762 try {
763 const response = await fetch("cancel", {
764 method: "POST",
765 headers: {
766 "Content-Type": "application/json",
767 },
768 body: JSON.stringify({ reason: "user requested cancellation" }),
769 });
770
771 if (!response.ok) {
772 const errorData = await response.text();
773 throw new Error(
774 `Failed to stop operation: ${response.status} - ${errorData}`,
775 );
776 }
777
778 this.messageStatus = "Stop request sent";
779 } catch (error) {
780 console.error("Error stopping operation:", error);
781 this.messageStatus = "Failed to stop operation";
782 }
783 }
784
Sean McCullough86b56862025-04-18 13:04:03 -0700785 async _sendChat(e: CustomEvent) {
786 console.log("app shell: _sendChat", e);
787 const message = e.detail.message?.trim();
788 if (message == "") {
789 return;
790 }
791 try {
792 // Send the message to the server
793 const response = await fetch("chat", {
794 method: "POST",
795 headers: {
796 "Content-Type": "application/json",
797 },
798 body: JSON.stringify({ message }),
799 });
800
801 if (!response.ok) {
802 const errorData = await response.text();
803 throw new Error(`Server error: ${response.status} - ${errorData}`);
804 }
Sean McCullough86b56862025-04-18 13:04:03 -0700805
Philip Zeyliger73db6052025-04-23 13:09:07 -0700806 // TOOD(philip): If the data manager is getting messages out of order, there's a bug?
Sean McCullough86b56862025-04-18 13:04:03 -0700807 // Reset data manager state to force a full refresh after sending a message
808 // This ensures we get all messages in the correct order
809 // Use private API for now - TODO: add a resetState() method to DataManager
810 (this.dataManager as any).nextFetchIndex = 0;
811 (this.dataManager as any).currentFetchStartIndex = 0;
812
Sean McCullough86b56862025-04-18 13:04:03 -0700813 // // If in diff view, switch to conversation view
814 // if (this.viewMode === "diff") {
815 // await this.toggleViewMode("chat");
816 // }
817
818 // Refresh the timeline data to show the new message
819 await this.dataManager.fetchData();
Sean McCullough86b56862025-04-18 13:04:03 -0700820 } catch (error) {
821 console.error("Error sending chat message:", error);
822 const statusText = document.getElementById("statusText");
823 if (statusText) {
824 statusText.textContent = "Error sending message";
825 }
826 }
827 }
828
Pokey Rule4097e532025-04-24 18:55:28 +0100829 private scrollContainerRef = createRef<HTMLElement>();
830
Sean McCullough86b56862025-04-18 13:04:03 -0700831 render() {
832 return html`
Pokey Rule4097e532025-04-24 18:55:28 +0100833 <div id="top-banner">
Sean McCullough86b56862025-04-18 13:04:03 -0700834 <div class="title-container">
835 <h1 class="banner-title">sketch</h1>
836 <h2 id="chatTitle" class="chat-title">${this.title}</h2>
837 </div>
838
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000839 <!-- Views section with tabs -->
840 <sketch-view-mode-select></sketch-view-mode-select>
841
842 <!-- Container status info -->
Sean McCullough86b56862025-04-18 13:04:03 -0700843 <sketch-container-status
844 .state=${this.containerState}
845 ></sketch-container-status>
Autoformattercf570962025-04-30 17:27:39 +0000846
847 ${this.lastCommit
848 ? html`
849 <div
850 class="last-commit"
851 @click=${(e: MouseEvent) => this.copyCommitInfo(e)}
852 title="Click to copy"
853 >
854 ${this.lastCommitCopied
855 ? html`<span class="copied-indicator">Copied!</span>`
856 : ""}
857 ${this.lastCommit.pushedBranch
858 ? html`<span class="commit-branch-indicator"
859 >${this.lastCommit.pushedBranch}</span
860 >`
861 : html`<span class="commit-hash-indicator"
862 >${this.lastCommit.hash.substring(0, 8)}</span
863 >`}
864 </div>
865 `
866 : ""}
Sean McCullough86b56862025-04-18 13:04:03 -0700867
868 <div class="refresh-control">
Sean McCulloughd3906e22025-04-29 17:32:14 +0000869 <button
870 id="stopButton"
871 class="refresh-button stop-button"
872 @click="${this._handleStopClick}"
873 >
Sean McCullough86b56862025-04-18 13:04:03 -0700874 Stop
875 </button>
876
877 <div class="poll-updates">
878 <input type="checkbox" id="pollToggle" checked />
879 <label for="pollToggle">Poll</label>
880 </div>
881
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000882 <div class="notifications-toggle">
883 <input
884 type="checkbox"
885 id="notificationsToggle"
886 ?checked=${this.notificationsEnabled}
887 @change=${this._handleNotificationsToggle}
888 />
889 <label for="notificationsToggle">Notifications</label>
890 </div>
891
Sean McCullough86b56862025-04-18 13:04:03 -0700892 <sketch-network-status
893 message=${this.messageStatus}
894 connection=${this.connectionStatus}
895 error=${this.connectionErrorMessage}
896 ></sketch-network-status>
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000897
898 <sketch-call-status
899 .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
900 .toolCalls=${this.containerState?.outstanding_tool_calls || []}
901 ></sketch-call-status>
Sean McCullough86b56862025-04-18 13:04:03 -0700902 </div>
903 </div>
904
Pokey Rule4097e532025-04-24 18:55:28 +0100905 <div id="view-container" ${ref(this.scrollContainerRef)}>
906 <div id="view-container-inner">
907 <div
908 class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}"
909 >
910 <sketch-timeline
911 .messages=${this.messages}
912 .scrollContainer=${this.scrollContainerRef}
913 ></sketch-timeline>
914 </div>
915 <div
916 class="diff-view ${this.viewMode === "diff" ? "view-active" : ""}"
917 >
918 <sketch-diff-view
919 .commitHash=${this.currentCommitHash}
920 ></sketch-diff-view>
921 </div>
922 <div
923 class="chart-view ${this.viewMode === "charts"
924 ? "view-active"
925 : ""}"
926 >
927 <sketch-charts .messages=${this.messages}></sketch-charts>
928 </div>
929 <div
930 class="terminal-view ${this.viewMode === "terminal"
931 ? "view-active"
932 : ""}"
933 >
934 <sketch-terminal></sketch-terminal>
935 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700936 </div>
937 </div>
938
Pokey Rule4097e532025-04-24 18:55:28 +0100939 <div id="chat-input">
940 <sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
941 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700942 `;
943 }
944
945 /**
Sean McCullough86b56862025-04-18 13:04:03 -0700946 * Lifecycle callback when component is first connected to DOM
947 */
948 firstUpdated(): void {
949 if (this.viewMode !== "chat") {
950 return;
951 }
952
953 // Initial scroll to bottom when component is first rendered
954 setTimeout(
955 () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
Philip Zeyliger72682df2025-04-23 13:09:46 -0700956 50,
Sean McCullough86b56862025-04-18 13:04:03 -0700957 );
958
Sean McCullough71941bd2025-04-18 13:31:48 -0700959 const pollToggleCheckbox = this.renderRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700960 "#pollToggle",
Sean McCullough71941bd2025-04-18 13:31:48 -0700961 ) as HTMLInputElement;
Sean McCullough86b56862025-04-18 13:04:03 -0700962 pollToggleCheckbox?.addEventListener("change", () => {
963 this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
964 if (!pollToggleCheckbox.checked) {
965 this.connectionStatus = "disabled";
966 this.messageStatus = "Polling stopped";
967 } else {
968 this.messageStatus = "Polling for updates...";
969 }
970 });
Autoformattercf570962025-04-30 17:27:39 +0000971
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000972 // Process any existing messages to find commit information
973 if (this.messages && this.messages.length > 0) {
974 this.updateLastCommitInfo(this.messages);
975 }
Sean McCullough86b56862025-04-18 13:04:03 -0700976 }
977}
978
979declare global {
980 interface HTMLElementTagNameMap {
981 "sketch-app-shell": SketchAppShell;
982 }
983}