blob: 7c618aa172ef0080d4c27c0822135322c86e79e6 [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 Zeyligerdb5e9b42025-04-30 19:58:13 +0000269 .poll-updates {
270 display: flex;
271 align-items: center;
272 font-size: 12px;
273 margin-right: 10px;
274 }
275
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000276 .notifications-toggle {
Sean McCullough86b56862025-04-18 13:04:03 -0700277 display: flex;
278 align-items: center;
Sean McCullough86b56862025-04-18 13:04:03 -0700279 font-size: 12px;
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000280 margin-right: 10px;
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000281 cursor: pointer;
282 }
283
284 .bell-icon {
285 width: 20px;
286 height: 20px;
287 position: relative;
288 display: inline-flex;
289 align-items: center;
290 justify-content: center;
291 }
292
293 .bell-disabled::before {
294 content: "";
295 position: absolute;
296 width: 2px;
297 height: 24px;
298 background-color: #dc3545;
299 transform: rotate(45deg);
300 transform-origin: center center;
Sean McCullough86b56862025-04-18 13:04:03 -0700301 }
302 `;
303
304 // Header bar: Network connection status details
305 @property()
306 connectionStatus: ConnectionStatus = "disconnected";
Autoformattercf570962025-04-30 17:27:39 +0000307
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000308 // Track if the last commit info has been copied
309 @state()
310 lastCommitCopied: boolean = false;
Sean McCullough86b56862025-04-18 13:04:03 -0700311
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000312 // Track notification preferences
313 @state()
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000314 notificationsEnabled: boolean = false;
315
316 // Track if the window is focused to control notifications
317 @state()
318 private _windowFocused: boolean = document.hasFocus();
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000319
Sean McCullough86b56862025-04-18 13:04:03 -0700320 @property()
321 connectionErrorMessage: string = "";
322
323 @property()
324 messageStatus: string = "";
325
326 // Chat messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100327 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700328 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -0700329
330 @property()
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000331 set title(value: string) {
332 const oldValue = this._title;
333 this._title = value;
334 this.requestUpdate("title", oldValue);
335 // Update document title when title property changes
336 this.updateDocumentTitle();
337 }
338
339 get title(): string {
340 return this._title;
341 }
342
343 private _title: string = "";
Sean McCullough86b56862025-04-18 13:04:03 -0700344
345 private dataManager = new DataManager();
346
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100347 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700348 containerState: State = {
349 title: "",
350 os: "",
351 message_count: 0,
352 hostname: "",
353 working_dir: "",
354 initial_commit: "",
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000355 outstanding_llm_calls: 0,
356 outstanding_tool_calls: [],
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000357 session_id: "",
358 ssh_available: false,
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700359 ssh_error: "",
360 in_container: false,
361 first_message_index: 0,
Sean McCulloughd9f13372025-04-21 15:08:49 -0700362 };
Sean McCullough86b56862025-04-18 13:04:03 -0700363
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700364 @state()
365 private restartModalOpen = false;
366
Sean McCullough86b56862025-04-18 13:04:03 -0700367 // Mutation observer to detect when new messages are added
368 private mutationObserver: MutationObserver | null = null;
369
370 constructor() {
371 super();
372
373 // Binding methods to this
374 this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
Sean McCullough86b56862025-04-18 13:04:03 -0700375 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
376 this._handlePopState = this._handlePopState.bind(this);
Sean McCulloughd3906e22025-04-29 17:32:14 +0000377 this._handleStopClick = this._handleStopClick.bind(this);
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000378 this._handleNotificationsToggle =
379 this._handleNotificationsToggle.bind(this);
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000380 this._handleWindowFocus = this._handleWindowFocus.bind(this);
381 this._handleWindowBlur = this._handleWindowBlur.bind(this);
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000382
383 // Load notification preference from localStorage
384 try {
385 const savedPref = localStorage.getItem("sketch-notifications-enabled");
386 if (savedPref !== null) {
387 this.notificationsEnabled = savedPref === "true";
388 }
389 } catch (error) {
390 console.error("Error loading notification preference:", error);
391 }
Sean McCullough86b56862025-04-18 13:04:03 -0700392 }
393
394 // See https://lit.dev/docs/components/lifecycle/
395 connectedCallback() {
396 super.connectedCallback();
397
398 // Initialize client-side nav history.
399 const url = new URL(window.location.href);
400 const mode = url.searchParams.get("view") || "chat";
401 window.history.replaceState({ mode }, "", url.toString());
402
403 this.toggleViewMode(mode as ViewMode, false);
404 // Add popstate event listener to handle browser back/forward navigation
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100405 window.addEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700406
407 // Add event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100408 window.addEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100409 window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
Sean McCullough86b56862025-04-18 13:04:03 -0700410
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000411 // Add window focus/blur listeners for controlling notifications
412 window.addEventListener("focus", this._handleWindowFocus);
413 window.addEventListener("blur", this._handleWindowBlur);
414
Sean McCullough86b56862025-04-18 13:04:03 -0700415 // register event listeners
416 this.dataManager.addEventListener(
417 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700418 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700419 );
420 this.dataManager.addEventListener(
421 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700422 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700423 );
424
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000425 // Set initial document title
426 this.updateDocumentTitle();
427
Sean McCullough86b56862025-04-18 13:04:03 -0700428 // Initialize the data manager
429 this.dataManager.initialize();
Autoformattercf570962025-04-30 17:27:39 +0000430
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000431 // Process existing messages for commit info
432 if (this.messages && this.messages.length > 0) {
433 this.updateLastCommitInfo(this.messages);
434 }
Sean McCullough86b56862025-04-18 13:04:03 -0700435 }
436
437 // See https://lit.dev/docs/components/lifecycle/
438 disconnectedCallback() {
439 super.disconnectedCallback();
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100440 window.removeEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700441
442 // Remove event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100443 window.removeEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100444 window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000445 window.removeEventListener("focus", this._handleWindowFocus);
446 window.removeEventListener("blur", this._handleWindowBlur);
Sean McCullough86b56862025-04-18 13:04:03 -0700447
448 // unregister data manager event listeners
449 this.dataManager.removeEventListener(
450 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700451 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700452 );
453 this.dataManager.removeEventListener(
454 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700455 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700456 );
457
458 // Disconnect mutation observer if it exists
459 if (this.mutationObserver) {
Sean McCullough86b56862025-04-18 13:04:03 -0700460 this.mutationObserver.disconnect();
461 this.mutationObserver = null;
462 }
463 }
464
Sean McCullough71941bd2025-04-18 13:31:48 -0700465 updateUrlForViewMode(mode: "chat" | "diff" | "charts" | "terminal"): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700466 // Get the current URL without search parameters
467 const url = new URL(window.location.href);
468
469 // Clear existing parameters
470 url.search = "";
471
472 // Only add view parameter if not in default chat view
473 if (mode !== "chat") {
474 url.searchParams.set("view", mode);
Sean McCullough71941bd2025-04-18 13:31:48 -0700475 const diffView = this.shadowRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700476 ".diff-view",
Sean McCullough71941bd2025-04-18 13:31:48 -0700477 ) as SketchDiffView;
Sean McCullough86b56862025-04-18 13:04:03 -0700478
479 // If in diff view and there's a commit hash, include that too
480 if (mode === "diff" && diffView.commitHash) {
481 url.searchParams.set("commit", diffView.commitHash);
482 }
483 }
484
485 // Update the browser history without reloading the page
486 window.history.pushState({ mode }, "", url.toString());
487 }
488
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100489 private _handlePopState(event: PopStateEvent) {
Sean McCullough86b56862025-04-18 13:04:03 -0700490 if (event.state && event.state.mode) {
491 this.toggleViewMode(event.state.mode, false);
492 } else {
493 this.toggleViewMode("chat", false);
494 }
495 }
496
497 /**
498 * Handle view mode selection event
499 */
500 private _handleViewModeSelect(event: CustomEvent) {
501 const mode = event.detail.mode as "chat" | "diff" | "charts" | "terminal";
502 this.toggleViewMode(mode, true);
503 }
504
505 /**
506 * Handle show commit diff event
507 */
508 private _handleShowCommitDiff(event: CustomEvent) {
509 const { commitHash } = event.detail;
510 if (commitHash) {
511 this.showCommitDiff(commitHash);
512 }
513 }
514
515 /**
Sean McCullough86b56862025-04-18 13:04:03 -0700516 * Listen for commit diff event
517 * @param commitHash The commit hash to show diff for
518 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100519 private showCommitDiff(commitHash: string): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700520 // Store the commit hash
521 this.currentCommitHash = commitHash;
522
523 // Switch to diff view
Sean McCullough71941bd2025-04-18 13:31:48 -0700524 this.toggleViewMode("diff", true);
Sean McCullough86b56862025-04-18 13:04:03 -0700525
526 // Wait for DOM update to complete
527 this.updateComplete.then(() => {
528 // Get the diff view component
529 const diffView = this.shadowRoot?.querySelector("sketch-diff-view");
530 if (diffView) {
531 // Call the showCommitDiff method
532 (diffView as any).showCommitDiff(commitHash);
533 }
534 });
535 }
536
537 /**
538 * Toggle between different view modes: chat, diff, charts, terminal
539 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100540 private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700541 // Don't do anything if the mode is already active
542 if (this.viewMode === mode) return;
543
544 // Update the view mode
545 this.viewMode = mode;
546
547 if (updateHistory) {
548 // Update URL with the current view mode
549 this.updateUrlForViewMode(mode);
550 }
551
552 // Wait for DOM update to complete
553 this.updateComplete.then(() => {
554 // Update active view
Pokey Rule46fff972025-04-25 14:57:44 +0100555 const viewContainerInner = this.shadowRoot?.querySelector(
556 "#view-container-inner",
557 );
Sean McCullough86b56862025-04-18 13:04:03 -0700558 const chatView = this.shadowRoot?.querySelector(".chat-view");
559 const diffView = this.shadowRoot?.querySelector(".diff-view");
560 const chartView = this.shadowRoot?.querySelector(".chart-view");
561 const terminalView = this.shadowRoot?.querySelector(".terminal-view");
562
563 // Remove active class from all views
564 chatView?.classList.remove("view-active");
565 diffView?.classList.remove("view-active");
566 chartView?.classList.remove("view-active");
567 terminalView?.classList.remove("view-active");
568
569 // Add/remove diff-active class on view container
570 if (mode === "diff") {
Pokey Rule46fff972025-04-25 14:57:44 +0100571 viewContainerInner?.classList.add("diff-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700572 } else {
Pokey Rule46fff972025-04-25 14:57:44 +0100573 viewContainerInner?.classList.remove("diff-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700574 }
575
576 // Add active class to the selected view
577 switch (mode) {
578 case "chat":
579 chatView?.classList.add("view-active");
580 break;
581 case "diff":
582 diffView?.classList.add("view-active");
583 // Load diff content if we have a diff view
584 const diffViewComp =
585 this.shadowRoot?.querySelector("sketch-diff-view");
586 if (diffViewComp && this.currentCommitHash) {
587 (diffViewComp as any).showCommitDiff(this.currentCommitHash);
588 } else if (diffViewComp) {
589 (diffViewComp as any).loadDiffContent();
590 }
591 break;
592 case "charts":
593 chartView?.classList.add("view-active");
594 break;
595 case "terminal":
596 terminalView?.classList.add("view-active");
597 break;
598 }
599
600 // Update view mode buttons
601 const viewModeSelect = this.shadowRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700602 "sketch-view-mode-select",
Sean McCullough86b56862025-04-18 13:04:03 -0700603 );
604 if (viewModeSelect) {
605 const event = new CustomEvent("update-active-mode", {
606 detail: { mode },
607 bubbles: true,
608 composed: true,
609 });
610 viewModeSelect.dispatchEvent(event);
611 }
612
613 // FIXME: This is a hack to get vega chart in sketch-charts.ts to work properly
614 // When the chart is in the background, its container has a width of 0, so vega
615 // renders width 0 and only changes that width on a resize event.
616 // See https://github.com/vega/react-vega/issues/85#issuecomment-1826421132
617 window.dispatchEvent(new Event("resize"));
618 });
619 }
620
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000621 /**
622 * Updates the document title based on current title and connection status
623 */
624 private updateDocumentTitle(): void {
625 let docTitle = `sk: ${this.title || "untitled"}`;
626
627 // Add red circle emoji if disconnected
628 if (this.connectionStatus === "disconnected") {
629 docTitle += " 🔴";
630 }
631
632 document.title = docTitle;
633 }
634
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000635 // Check and request notification permission if needed
636 private async checkNotificationPermission(): Promise<boolean> {
637 // Check if the Notification API is supported
638 if (!("Notification" in window)) {
639 console.log("This browser does not support notifications");
640 return false;
641 }
642
643 // Check if permission is already granted
644 if (Notification.permission === "granted") {
645 return true;
646 }
647
648 // If permission is not denied, request it
649 if (Notification.permission !== "denied") {
650 const permission = await Notification.requestPermission();
651 return permission === "granted";
652 }
653
654 return false;
655 }
656
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000657 // Handle notifications toggle click
658 private _handleNotificationsToggle(): void {
659 this.notificationsEnabled = !this.notificationsEnabled;
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000660
661 // If enabling notifications, check permissions
662 if (this.notificationsEnabled) {
663 this.checkNotificationPermission();
664 }
665
666 // Save preference to localStorage
667 try {
668 localStorage.setItem(
669 "sketch-notifications-enabled",
670 String(this.notificationsEnabled),
671 );
672 } catch (error) {
673 console.error("Error saving notification preference:", error);
674 }
675 }
676
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000677 // Handle window focus event
678 private _handleWindowFocus(): void {
679 this._windowFocused = true;
680 }
681
682 // Handle window blur event
683 private _handleWindowBlur(): void {
684 this._windowFocused = false;
685 }
686
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000687 // Show notification for message with EndOfTurn=true
688 private async showEndOfTurnNotification(
689 message: AgentMessage,
690 ): Promise<void> {
691 // Don't show notifications if they're disabled
692 if (!this.notificationsEnabled) return;
693
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000694 // Don't show notifications if the window is focused
695 if (this._windowFocused) return;
696
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000697 // Check if we have permission to show notifications
698 const hasPermission = await this.checkNotificationPermission();
699 if (!hasPermission) return;
700
Philip Zeyliger32011332025-04-30 20:59:40 +0000701 // Only show notifications for agent messages with end_of_turn=true and no parent_conversation_id
702 if (
703 message.type !== "agent" ||
704 !message.end_of_turn ||
705 message.parent_conversation_id
706 )
707 return;
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000708
709 // Create a title that includes the sketch title
710 const notificationTitle = `Sketch: ${this.title || "untitled"}`;
711
712 // Extract the beginning of the message content (first 100 chars)
713 const messagePreview = message.content
714 ? message.content.substring(0, 100) +
715 (message.content.length > 100 ? "..." : "")
716 : "Agent has completed its turn";
717
718 // Create and show the notification
719 try {
720 new Notification(notificationTitle, {
721 body: messagePreview,
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000722 icon: "https://sketch.dev/favicon.ico", // Use sketch.dev favicon for notification
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000723 });
724 } catch (error) {
725 console.error("Error showing notification:", error);
726 }
727 }
728
Sean McCullough86b56862025-04-18 13:04:03 -0700729 private handleDataChanged(eventData: {
730 state: State;
Sean McCulloughd9f13372025-04-21 15:08:49 -0700731 newMessages: AgentMessage[];
Sean McCullough86b56862025-04-18 13:04:03 -0700732 isFirstFetch?: boolean;
733 }): void {
734 const { state, newMessages, isFirstFetch } = eventData;
735
736 // Check if this is the first data fetch or if there are new messages
737 if (isFirstFetch) {
Sean McCullough86b56862025-04-18 13:04:03 -0700738 this.messageStatus = "Initial messages loaded";
739 } else if (newMessages && newMessages.length > 0) {
Sean McCullough86b56862025-04-18 13:04:03 -0700740 this.messageStatus = "Updated just now";
Sean McCullough86b56862025-04-18 13:04:03 -0700741 } else {
742 this.messageStatus = "No new messages";
743 }
744
745 // Update state if we received it
746 if (state) {
Josh Bleecher Snydere81233f2025-04-30 04:05:41 +0000747 // Ensure we're using the latest call status to prevent indicators from being stuck
Autoformatterf830c9d2025-04-30 18:16:01 +0000748 if (
749 state.outstanding_llm_calls === 0 &&
750 state.outstanding_tool_calls.length === 0
751 ) {
Josh Bleecher Snydere81233f2025-04-30 04:05:41 +0000752 // Force reset containerState calls when nothing is reported as in progress
753 state.outstanding_llm_calls = 0;
754 state.outstanding_tool_calls = [];
755 }
Autoformatterf830c9d2025-04-30 18:16:01 +0000756
Sean McCullough86b56862025-04-18 13:04:03 -0700757 this.containerState = state;
758 this.title = state.title;
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000759
760 // Update document title when sketch title changes
761 this.updateDocumentTitle();
Sean McCullough86b56862025-04-18 13:04:03 -0700762 }
763
Sean McCullough86b56862025-04-18 13:04:03 -0700764 // Update messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100765 this.messages = aggregateAgentMessages(this.messages, newMessages);
Autoformattercf570962025-04-30 17:27:39 +0000766
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000767 // Process new messages to find commit messages
768 this.updateLastCommitInfo(newMessages);
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000769
770 // Check for agent messages with end_of_turn=true and show notifications
771 if (newMessages && newMessages.length > 0 && !isFirstFetch) {
772 for (const message of newMessages) {
Philip Zeyliger32011332025-04-30 20:59:40 +0000773 if (
774 message.type === "agent" &&
775 message.end_of_turn &&
776 !message.parent_conversation_id
777 ) {
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000778 this.showEndOfTurnNotification(message);
779 break; // Only show one notification per batch of messages
780 }
781 }
782 }
Sean McCullough86b56862025-04-18 13:04:03 -0700783 }
784
785 private handleConnectionStatusChanged(
786 status: ConnectionStatus,
Philip Zeyliger72682df2025-04-23 13:09:46 -0700787 errorMessage?: string,
Sean McCullough86b56862025-04-18 13:04:03 -0700788 ): void {
789 this.connectionStatus = status;
790 this.connectionErrorMessage = errorMessage || "";
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000791
792 // Update document title when connection status changes
793 this.updateDocumentTitle();
Sean McCullough86b56862025-04-18 13:04:03 -0700794 }
795
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000796 // Update last commit information when new messages arrive
797 private updateLastCommitInfo(newMessages: AgentMessage[]): void {
798 if (!newMessages || newMessages.length === 0) return;
Autoformattercf570962025-04-30 17:27:39 +0000799
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000800 // Process messages in chronological order (latest last)
801 for (const message of newMessages) {
Autoformattercf570962025-04-30 17:27:39 +0000802 if (
803 message.type === "commit" &&
804 message.commits &&
805 message.commits.length > 0
806 ) {
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000807 // Get the first commit from the list
808 const commit = message.commits[0];
809 if (commit) {
810 this.lastCommit = {
811 hash: commit.hash,
Autoformattercf570962025-04-30 17:27:39 +0000812 pushedBranch: commit.pushed_branch,
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000813 };
814 this.lastCommitCopied = false;
815 }
816 }
817 }
818 }
819
820 // Copy commit info to clipboard
821 private copyCommitInfo(event: MouseEvent): void {
822 event.preventDefault();
823 event.stopPropagation();
Autoformattercf570962025-04-30 17:27:39 +0000824
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000825 if (!this.lastCommit) return;
Autoformattercf570962025-04-30 17:27:39 +0000826
827 const textToCopy =
828 this.lastCommit.pushedBranch || this.lastCommit.hash.substring(0, 8);
829
830 navigator.clipboard
831 .writeText(textToCopy)
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000832 .then(() => {
833 this.lastCommitCopied = true;
834 // Reset the copied state after 2 seconds
835 setTimeout(() => {
836 this.lastCommitCopied = false;
837 }, 2000);
838 })
Autoformattercf570962025-04-30 17:27:39 +0000839 .catch((err) => {
840 console.error("Failed to copy commit info:", err);
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000841 });
842 }
Autoformattercf570962025-04-30 17:27:39 +0000843
Sean McCulloughd3906e22025-04-29 17:32:14 +0000844 private async _handleStopClick(): Promise<void> {
845 try {
846 const response = await fetch("cancel", {
847 method: "POST",
848 headers: {
849 "Content-Type": "application/json",
850 },
851 body: JSON.stringify({ reason: "user requested cancellation" }),
852 });
853
854 if (!response.ok) {
855 const errorData = await response.text();
856 throw new Error(
857 `Failed to stop operation: ${response.status} - ${errorData}`,
858 );
859 }
860
861 this.messageStatus = "Stop request sent";
862 } catch (error) {
863 console.error("Error stopping operation:", error);
864 this.messageStatus = "Failed to stop operation";
865 }
866 }
867
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700868 openRestartModal() {
869 this.restartModalOpen = true;
870 }
871
872 handleRestartModalClose() {
873 this.restartModalOpen = false;
874 }
875
Sean McCullough86b56862025-04-18 13:04:03 -0700876 async _sendChat(e: CustomEvent) {
877 console.log("app shell: _sendChat", e);
878 const message = e.detail.message?.trim();
879 if (message == "") {
880 return;
881 }
882 try {
883 // Send the message to the server
884 const response = await fetch("chat", {
885 method: "POST",
886 headers: {
887 "Content-Type": "application/json",
888 },
889 body: JSON.stringify({ message }),
890 });
891
892 if (!response.ok) {
893 const errorData = await response.text();
894 throw new Error(`Server error: ${response.status} - ${errorData}`);
895 }
Sean McCullough86b56862025-04-18 13:04:03 -0700896
Philip Zeyliger73db6052025-04-23 13:09:07 -0700897 // TOOD(philip): If the data manager is getting messages out of order, there's a bug?
Sean McCullough86b56862025-04-18 13:04:03 -0700898 // Reset data manager state to force a full refresh after sending a message
899 // This ensures we get all messages in the correct order
900 // Use private API for now - TODO: add a resetState() method to DataManager
901 (this.dataManager as any).nextFetchIndex = 0;
902 (this.dataManager as any).currentFetchStartIndex = 0;
903
Sean McCullough86b56862025-04-18 13:04:03 -0700904 // // If in diff view, switch to conversation view
905 // if (this.viewMode === "diff") {
906 // await this.toggleViewMode("chat");
907 // }
908
909 // Refresh the timeline data to show the new message
910 await this.dataManager.fetchData();
Sean McCullough86b56862025-04-18 13:04:03 -0700911 } catch (error) {
912 console.error("Error sending chat message:", error);
913 const statusText = document.getElementById("statusText");
914 if (statusText) {
915 statusText.textContent = "Error sending message";
916 }
917 }
918 }
919
Pokey Rule4097e532025-04-24 18:55:28 +0100920 private scrollContainerRef = createRef<HTMLElement>();
921
Sean McCullough86b56862025-04-18 13:04:03 -0700922 render() {
923 return html`
Pokey Rule4097e532025-04-24 18:55:28 +0100924 <div id="top-banner">
Sean McCullough86b56862025-04-18 13:04:03 -0700925 <div class="title-container">
926 <h1 class="banner-title">sketch</h1>
927 <h2 id="chatTitle" class="chat-title">${this.title}</h2>
928 </div>
929
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000930 <!-- Views section with tabs -->
931 <sketch-view-mode-select></sketch-view-mode-select>
932
933 <!-- Container status info -->
Sean McCullough86b56862025-04-18 13:04:03 -0700934 <sketch-container-status
935 .state=${this.containerState}
936 ></sketch-container-status>
Autoformattercf570962025-04-30 17:27:39 +0000937
938 ${this.lastCommit
939 ? html`
940 <div
941 class="last-commit"
942 @click=${(e: MouseEvent) => this.copyCommitInfo(e)}
943 title="Click to copy"
944 >
945 ${this.lastCommitCopied
946 ? html`<span class="copied-indicator">Copied!</span>`
947 : ""}
948 ${this.lastCommit.pushedBranch
949 ? html`<span class="commit-branch-indicator"
950 >${this.lastCommit.pushedBranch}</span
951 >`
952 : html`<span class="commit-hash-indicator"
953 >${this.lastCommit.hash.substring(0, 8)}</span
954 >`}
955 </div>
956 `
957 : ""}
Sean McCullough86b56862025-04-18 13:04:03 -0700958
959 <div class="refresh-control">
Sean McCulloughd3906e22025-04-29 17:32:14 +0000960 <button
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700961 id="restartButton"
962 class="restart-button"
963 ?disabled=${this.containerState.message_count === 0}
964 @click=${this.openRestartModal}
Sean McCulloughd3906e22025-04-29 17:32:14 +0000965 >
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700966 Restart
967 </button>
968 <button id="stopButton" class="refresh-button stop-button">
Sean McCullough86b56862025-04-18 13:04:03 -0700969 Stop
970 </button>
971
972 <div class="poll-updates">
973 <input type="checkbox" id="pollToggle" checked />
974 <label for="pollToggle">Poll</label>
975 </div>
976
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000977 <div
978 class="notifications-toggle"
979 @click=${this._handleNotificationsToggle}
980 title="${this.notificationsEnabled
981 ? "Disable"
982 : "Enable"} notifications"
983 >
984 <div
985 class="bell-icon ${!this.notificationsEnabled
986 ? "bell-disabled"
987 : ""}"
988 >
989 <!-- Bell SVG icon -->
990 <svg
991 xmlns="http://www.w3.org/2000/svg"
992 width="16"
993 height="16"
994 fill="currentColor"
995 viewBox="0 0 16 16"
996 >
997 <path
998 d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zM8 1.918l-.797.161A4.002 4.002 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4.002 4.002 0 0 0-3.203-3.92L8 1.917zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5.002 5.002 0 0 1 13 6c0 .88.32 4.2 1.22 6z"
999 />
1000 </svg>
1001 </div>
Philip Zeyligerbc6b6292025-04-30 18:00:15 +00001002 </div>
1003
Sean McCullough86b56862025-04-18 13:04:03 -07001004 <sketch-network-status
1005 message=${this.messageStatus}
1006 connection=${this.connectionStatus}
1007 error=${this.connectionErrorMessage}
1008 ></sketch-network-status>
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001009
1010 <sketch-call-status
1011 .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
1012 .toolCalls=${this.containerState?.outstanding_tool_calls || []}
1013 ></sketch-call-status>
Sean McCullough86b56862025-04-18 13:04:03 -07001014 </div>
1015 </div>
1016
Pokey Rule4097e532025-04-24 18:55:28 +01001017 <div id="view-container" ${ref(this.scrollContainerRef)}>
1018 <div id="view-container-inner">
1019 <div
1020 class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}"
1021 >
1022 <sketch-timeline
1023 .messages=${this.messages}
1024 .scrollContainer=${this.scrollContainerRef}
1025 ></sketch-timeline>
1026 </div>
1027 <div
1028 class="diff-view ${this.viewMode === "diff" ? "view-active" : ""}"
1029 >
1030 <sketch-diff-view
1031 .commitHash=${this.currentCommitHash}
1032 ></sketch-diff-view>
1033 </div>
1034 <div
1035 class="chart-view ${this.viewMode === "charts"
1036 ? "view-active"
1037 : ""}"
1038 >
1039 <sketch-charts .messages=${this.messages}></sketch-charts>
1040 </div>
1041 <div
1042 class="terminal-view ${this.viewMode === "terminal"
1043 ? "view-active"
1044 : ""}"
1045 >
1046 <sketch-terminal></sketch-terminal>
1047 </div>
Sean McCullough86b56862025-04-18 13:04:03 -07001048 </div>
1049 </div>
1050
Pokey Rule4097e532025-04-24 18:55:28 +01001051 <div id="chat-input">
1052 <sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
1053 </div>
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001054
1055 <sketch-restart-modal
1056 ?open=${this.restartModalOpen}
1057 @close=${this.handleRestartModalClose}
1058 .containerState=${this.containerState}
1059 .messages=${this.messages}
1060 ></sketch-restart-modal>
Sean McCullough86b56862025-04-18 13:04:03 -07001061 `;
1062 }
1063
1064 /**
Sean McCullough86b56862025-04-18 13:04:03 -07001065 * Lifecycle callback when component is first connected to DOM
1066 */
1067 firstUpdated(): void {
1068 if (this.viewMode !== "chat") {
1069 return;
1070 }
1071
1072 // Initial scroll to bottom when component is first rendered
1073 setTimeout(
1074 () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
Philip Zeyliger72682df2025-04-23 13:09:46 -07001075 50,
Sean McCullough86b56862025-04-18 13:04:03 -07001076 );
1077
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001078 // Setup stop button
1079 const stopButton = this.renderRoot?.querySelector(
1080 "#stopButton",
1081 ) as HTMLButtonElement;
1082 stopButton?.addEventListener("click", async () => {
1083 try {
1084 const response = await fetch("/cancel", {
1085 method: "POST",
1086 headers: {
1087 "Content-Type": "application/json",
1088 },
1089 body: JSON.stringify({ reason: "User clicked stop button" }),
1090 });
1091 if (!response.ok) {
1092 console.error("Failed to cancel:", await response.text());
1093 }
1094 } catch (error) {
1095 console.error("Error cancelling operation:", error);
1096 }
1097 });
1098
Sean McCullough71941bd2025-04-18 13:31:48 -07001099 const pollToggleCheckbox = this.renderRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -07001100 "#pollToggle",
Sean McCullough71941bd2025-04-18 13:31:48 -07001101 ) as HTMLInputElement;
Sean McCullough86b56862025-04-18 13:04:03 -07001102 pollToggleCheckbox?.addEventListener("change", () => {
1103 this.dataManager.setPollingEnabled(pollToggleCheckbox.checked);
1104 if (!pollToggleCheckbox.checked) {
1105 this.connectionStatus = "disabled";
1106 this.messageStatus = "Polling stopped";
1107 } else {
1108 this.messageStatus = "Polling for updates...";
1109 }
1110 });
Autoformattercf570962025-04-30 17:27:39 +00001111
Philip Zeyliger47b71c92025-04-30 15:43:39 +00001112 // Process any existing messages to find commit information
1113 if (this.messages && this.messages.length > 0) {
1114 this.updateLastCommitInfo(this.messages);
1115 }
Sean McCullough86b56862025-04-18 13:04:03 -07001116 }
1117}
1118
1119declare global {
1120 interface HTMLElementTagNameMap {
1121 "sketch-app-shell": SketchAppShell;
1122 }
1123}