blob: f545b8256e27d056b9805fc4becc783b9387763d [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 Zeyliger272a90e2025-05-16 14:49:51 -07004import { AgentMessage, GitLogEntry, State } from "../types";
Pokey Rulee2a8c2f2025-04-23 15:09:25 +01005import { aggregateAgentMessages } from "./aggregateAgentMessages";
Philip Zeyliger2d4c48f2025-05-02 23:35:03 +00006
Pokey Rule4097e532025-04-24 18:55:28 +01007import "./sketch-chat-input";
8import "./sketch-container-status";
Philip Zeyliger00bcaef2025-05-30 04:21:15 +00009
Philip Zeyliger272a90e2025-05-16 14:49:51 -070010import "./sketch-diff2-view";
11import { SketchDiff2View } from "./sketch-diff2-view";
12import { DefaultGitDataService } from "./git-data-service";
13import "./sketch-monaco-view";
Pokey Rule4097e532025-04-24 18:55:28 +010014import "./sketch-network-status";
Philip Zeyliger99a9a022025-04-27 15:15:25 +000015import "./sketch-call-status";
Pokey Rule4097e532025-04-24 18:55:28 +010016import "./sketch-terminal";
17import "./sketch-timeline";
18import "./sketch-view-mode-select";
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070019import "./sketch-todo-panel";
Pokey Rule4097e532025-04-24 18:55:28 +010020
21import { createRef, ref } from "lit/directives/ref.js";
Sean McCullough485afc62025-04-28 14:28:39 -070022import { SketchChatInput } from "./sketch-chat-input";
Sean McCullough86b56862025-04-18 13:04:03 -070023
Philip Zeyliger00bcaef2025-05-30 04:21:15 +000024type ViewMode = "chat" | "diff2" | "terminal";
Sean McCullough86b56862025-04-18 13:04:03 -070025
26@customElement("sketch-app-shell")
27export class SketchAppShell extends LitElement {
Philip Zeyliger2d4c48f2025-05-02 23:35:03 +000028 // Current view mode (chat, diff, terminal)
Sean McCullough86b56862025-04-18 13:04:03 -070029 @state()
Philip Zeyliger272a90e2025-05-16 14:49:51 -070030 viewMode: ViewMode = "chat";
Sean McCullough86b56862025-04-18 13:04:03 -070031
32 // Current commit hash for diff view
33 @state()
34 currentCommitHash: string = "";
35
Philip Zeyliger47b71c92025-04-30 15:43:39 +000036 // Last commit information
37 @state()
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000038
39 // Reference to the container status element
40 containerStatusElement: any = null;
Philip Zeyliger47b71c92025-04-30 15:43:39 +000041
Sean McCullough86b56862025-04-18 13:04:03 -070042 // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
43 // Note that these styles only apply to the scope of this web component's
44 // shadow DOM node, so they won't leak out or collide with CSS declared in
45 // other components or the containing web page (...unless you want it to do that).
46 static styles = css`
Philip Zeyliger47b71c92025-04-30 15:43:39 +000047 .copied-indicator {
48 position: absolute;
49 top: -20px;
50 left: 50%;
51 transform: translateX(-50%);
52 background: rgba(40, 167, 69, 0.9);
53 color: white;
54 padding: 2px 6px;
55 border-radius: 3px;
56 font-size: 10px;
57 font-family: system-ui, sans-serif;
58 animation: fadeInOut 2s ease;
59 pointer-events: none;
60 }
Autoformattercf570962025-04-30 17:27:39 +000061
Philip Zeyliger47b71c92025-04-30 15:43:39 +000062 @keyframes fadeInOut {
Autoformattercf570962025-04-30 17:27:39 +000063 0% {
64 opacity: 0;
65 }
66 20% {
67 opacity: 1;
68 }
69 80% {
70 opacity: 1;
71 }
72 100% {
73 opacity: 0;
74 }
Philip Zeyliger47b71c92025-04-30 15:43:39 +000075 }
Autoformattercf570962025-04-30 17:27:39 +000076
Philip Zeyliger47b71c92025-04-30 15:43:39 +000077 .commit-branch-indicator {
78 color: #28a745;
79 }
Autoformattercf570962025-04-30 17:27:39 +000080
Philip Zeyliger47b71c92025-04-30 15:43:39 +000081 .commit-hash-indicator {
82 color: #0366d6;
83 }
Sean McCullough86b56862025-04-18 13:04:03 -070084 :host {
85 display: block;
Sean McCullough71941bd2025-04-18 13:31:48 -070086 font-family:
87 system-ui,
88 -apple-system,
89 BlinkMacSystemFont,
90 "Segoe UI",
91 Roboto,
92 sans-serif;
Sean McCullough86b56862025-04-18 13:04:03 -070093 color: #333;
94 line-height: 1.4;
Pokey Rule4097e532025-04-24 18:55:28 +010095 height: 100vh;
Sean McCullough86b56862025-04-18 13:04:03 -070096 width: 100%;
97 position: relative;
98 overflow-x: hidden;
Pokey Rule4097e532025-04-24 18:55:28 +010099 display: flex;
100 flex-direction: column;
Sean McCullough86b56862025-04-18 13:04:03 -0700101 }
102
103 /* Top banner with combined elements */
Pokey Rule4097e532025-04-24 18:55:28 +0100104 #top-banner {
Sean McCullough86b56862025-04-18 13:04:03 -0700105 display: flex;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000106 align-self: stretch;
Sean McCullough86b56862025-04-18 13:04:03 -0700107 justify-content: space-between;
108 align-items: center;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000109 padding: 0 20px;
Sean McCullough86b56862025-04-18 13:04:03 -0700110 margin-bottom: 0;
111 border-bottom: 1px solid #eee;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000112 gap: 20px;
Sean McCullough86b56862025-04-18 13:04:03 -0700113 background: white;
Sean McCullough86b56862025-04-18 13:04:03 -0700114 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000115 width: 100%;
116 height: 48px;
117 padding-right: 30px; /* Extra padding on the right to prevent elements from hitting the edge */
Sean McCullough86b56862025-04-18 13:04:03 -0700118 }
119
Pokey Rule4097e532025-04-24 18:55:28 +0100120 /* View mode container styles - mirroring timeline.css structure */
121 #view-container {
122 align-self: stretch;
123 overflow-y: auto;
124 flex: 1;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700125 display: flex;
126 flex-direction: column;
127 min-height: 0; /* Critical for proper flex child behavior */
Pokey Rule4097e532025-04-24 18:55:28 +0100128 }
129
130 #view-container-inner {
131 max-width: 1200px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700132 width: calc(100% - 40px);
Pokey Rule4097e532025-04-24 18:55:28 +0100133 margin: 0 auto;
134 position: relative;
135 padding-bottom: 10px;
136 padding-top: 10px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700137 display: flex;
138 flex-direction: column;
139 height: 100%; /* Ensure it takes full height of parent */
Pokey Rule4097e532025-04-24 18:55:28 +0100140 }
141
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700142 /* Adjust view container when todo panel is visible in chat mode */
143 #view-container-inner.with-todo-panel {
144 max-width: none;
145 width: 100%;
146 margin: 0;
147 padding-left: 20px;
148 padding-right: 20px;
149 }
150
Pokey Rule4097e532025-04-24 18:55:28 +0100151 #chat-input {
152 align-self: flex-end;
153 width: 100%;
154 box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
155 }
156
Sean McCullough86b56862025-04-18 13:04:03 -0700157 .banner-title {
158 font-size: 18px;
159 font-weight: 600;
160 margin: 0;
161 min-width: 6em;
162 white-space: nowrap;
163 overflow: hidden;
164 text-overflow: ellipsis;
165 }
166
Philip Zeyliger0113be52025-06-07 23:53:41 +0000167 .banner-title a {
168 color: inherit;
169 text-decoration: none;
170 transition: opacity 0.2s ease;
171 display: flex;
172 align-items: center;
173 gap: 8px;
174 }
175
176 .banner-title a:hover {
177 opacity: 0.8;
178 text-decoration: underline;
179 }
180
181 .banner-title img {
182 width: 20px;
183 height: 20px;
184 border-radius: 3px;
185 }
186
187 /* Mobile-specific styles for banner link */
188 @media (max-width: 768px) {
189 .title-container {
190 max-width: 50%; /* Allow more space on mobile */
191 }
192
193 .banner-title {
194 font-size: 16px; /* Slightly smaller on mobile */
195 }
196
197 .banner-title img {
198 width: 18px;
199 height: 18px;
200 }
201 }
202
203 @media (max-width: 480px) {
204 .title-container {
205 max-width: 60%; /* Even more space on very small screens */
206 }
207
208 .banner-title {
209 font-size: 14px;
210 }
211
212 .banner-title img {
213 width: 16px;
214 height: 16px;
215 }
216 }
217
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700218 .slug-title {
Sean McCullough86b56862025-04-18 13:04:03 -0700219 margin: 0;
220 padding: 0;
221 color: rgba(82, 82, 82, 0.85);
Josh Bleecher Snydereb5166a2025-04-30 17:04:20 +0000222 font-size: 14px;
Sean McCullough86b56862025-04-18 13:04:03 -0700223 font-weight: normal;
224 font-style: italic;
225 white-space: nowrap;
226 overflow: hidden;
227 text-overflow: ellipsis;
228 }
229
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700230 /* Allow the container to expand to full width and height in diff mode */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700231 #view-container-inner.diff2-active {
Sean McCullough86b56862025-04-18 13:04:03 -0700232 max-width: 100%;
Pokey Rule46fff972025-04-25 14:57:44 +0100233 width: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700234 height: 100%;
235 padding: 0; /* Remove padding for more space */
236 display: flex;
237 flex-direction: column;
238 flex: 1;
239 min-height: 0; /* Critical for flex behavior */
Sean McCullough86b56862025-04-18 13:04:03 -0700240 }
241
242 /* Individual view styles */
243 .chat-view,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700244 .diff2-view,
Sean McCullough86b56862025-04-18 13:04:03 -0700245 .terminal-view {
246 display: none; /* Hidden by default */
247 width: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700248 height: 100%;
249 }
Autoformatter8c463622025-05-16 21:54:17 +0000250
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700251 /* Make chat view take full width available */
252 .chat-view.view-active {
253 display: flex;
254 flex-direction: column;
255 width: 100%;
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700256 height: 100%;
257 }
258
259 /* Chat timeline container - takes full width, memory panel will be positioned separately */
260 .chat-timeline-container {
261 flex: 1;
262 display: flex;
263 flex-direction: column;
264 width: 100%;
265 height: 100%;
266 margin-right: 0; /* Default - no memory panel */
267 transition: margin-right 0.2s ease; /* Smooth transition */
268 }
269
270 /* Adjust chat timeline container when todo panel is visible */
271 .chat-timeline-container.with-todo-panel {
272 margin-right: 400px; /* Make space for fixed todo panel */
273 width: calc(100% - 400px); /* Explicitly set width to prevent overlap */
274 }
275
276 /* Todo panel container - fixed to right side */
277 .todo-panel-container {
278 position: fixed;
279 top: 48px; /* Below top banner */
280 right: 15px; /* Leave space for scroll bar */
281 width: 400px;
282 bottom: var(
283 --chat-input-height,
284 90px
285 ); /* Dynamic height based on chat input size */
286 background-color: #fafafa;
287 border-left: 1px solid #e0e0e0;
288 z-index: 100;
289 display: none; /* Hidden by default */
290 transition: bottom 0.2s ease; /* Smooth transition when height changes */
291 /* Add fuzzy gradient at bottom to blend with text entry */
292 background: linear-gradient(
293 to bottom,
294 #fafafa 0%,
295 #fafafa 90%,
296 rgba(250, 250, 250, 0.5) 95%,
297 rgba(250, 250, 250, 0.2) 100%
298 );
299 }
300
301 .todo-panel-container.visible {
302 display: block;
303 }
304
305 /* Responsive adjustments for todo panel */
306 @media (max-width: 1200px) {
307 .todo-panel-container {
308 width: 350px;
309 /* bottom is still controlled by --chat-input-height CSS variable */
310 }
311 .chat-timeline-container.with-todo-panel {
312 margin-right: 350px;
313 width: calc(100% - 350px);
314 }
315 }
316
317 @media (max-width: 900px) {
318 .todo-panel-container {
319 width: 300px;
320 /* bottom is still controlled by --chat-input-height CSS variable */
321 }
322 .chat-timeline-container.with-todo-panel {
323 margin-right: 300px;
324 width: calc(100% - 300px);
325 }
326 }
327
328 /* On very small screens, hide todo panel or make it overlay */
329 @media (max-width: 768px) {
330 .todo-panel-container.visible {
331 display: none; /* Hide on mobile */
332 }
333 .chat-timeline-container.with-todo-panel {
334 margin-right: 0;
335 width: 100%;
336 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700337 }
Autoformatter8c463622025-05-16 21:54:17 +0000338
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700339 /* Monaco diff2 view needs to take all available space */
340 .diff2-view.view-active {
341 flex: 1;
342 overflow: hidden;
343 min-height: 0; /* Required for proper flex child behavior */
344 display: flex;
345 flex-direction: column;
346 height: 100%;
Sean McCullough86b56862025-04-18 13:04:03 -0700347 }
348
349 /* Active view styles - these will be applied via JavaScript */
350 .view-active {
351 display: flex;
352 flex-direction: column;
353 }
354
355 .title-container {
356 display: flex;
357 flex-direction: column;
358 white-space: nowrap;
359 overflow: hidden;
360 text-overflow: ellipsis;
Josh Bleecher Snydereb5166a2025-04-30 17:04:20 +0000361 max-width: 30%;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000362 padding: 6px 0;
Sean McCullough86b56862025-04-18 13:04:03 -0700363 }
364
365 .refresh-control {
366 display: flex;
367 align-items: center;
368 margin-bottom: 0;
369 flex-wrap: nowrap;
370 white-space: nowrap;
371 flex-shrink: 0;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000372 gap: 15px;
373 padding-left: 15px;
374 margin-right: 50px;
Sean McCullough86b56862025-04-18 13:04:03 -0700375 }
376
Pokey Rule397871d2025-05-19 15:02:45 +0100377 .stop-button,
378 .end-button {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700379 background: #2196f3;
380 color: white;
381 border: none;
382 padding: 4px 10px;
383 border-radius: 4px;
384 cursor: pointer;
385 font-size: 12px;
386 margin-right: 5px;
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000387 display: flex;
388 align-items: center;
389 gap: 6px;
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700390 }
391
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000392 .stop-button {
393 background: #dc3545;
Sean McCullough86b56862025-04-18 13:04:03 -0700394 color: white;
Sean McCullough86b56862025-04-18 13:04:03 -0700395 }
396
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000397 .stop-button:hover:not(:disabled) {
398 background-color: #c82333;
Sean McCullough86b56862025-04-18 13:04:03 -0700399 }
400
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000401 .stop-button:disabled {
402 background-color: #e9a8ad;
403 cursor: not-allowed;
404 opacity: 0.7;
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000405 }
406
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000407 .stop-button:disabled:hover {
408 background-color: #e9a8ad;
409 }
410
Pokey Rule397871d2025-05-19 15:02:45 +0100411 .end-button {
412 background: #6c757d;
413 color: white;
414 }
415
416 .end-button:hover:not(:disabled) {
417 background-color: #5a6268;
418 }
419
420 .end-button:disabled {
421 background-color: #a9acaf;
422 cursor: not-allowed;
423 opacity: 0.7;
424 }
425
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000426 .button-icon {
427 width: 16px;
428 height: 16px;
429 }
430
431 @media (max-width: 1400px) {
432 .button-text {
433 display: none;
434 }
435
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000436 .stop-button {
437 padding: 6px;
438 }
439 }
440
441 /* Removed poll-updates class */
442
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000443 .notifications-toggle {
Sean McCullough86b56862025-04-18 13:04:03 -0700444 display: flex;
445 align-items: center;
Sean McCullough86b56862025-04-18 13:04:03 -0700446 font-size: 12px;
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000447 margin-right: 10px;
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000448 cursor: pointer;
449 }
450
451 .bell-icon {
452 width: 20px;
453 height: 20px;
454 position: relative;
455 display: inline-flex;
456 align-items: center;
457 justify-content: center;
458 }
459
460 .bell-disabled::before {
461 content: "";
462 position: absolute;
463 width: 2px;
464 height: 24px;
465 background-color: #dc3545;
466 transform: rotate(45deg);
467 transform-origin: center center;
Sean McCullough86b56862025-04-18 13:04:03 -0700468 }
philip.zeyligerffa94c62025-06-19 18:43:37 -0700469
470 /* Print styles for full chat printing */
471 @media print {
472 :host {
473 height: auto !important;
474 overflow: visible !important;
475 display: block !important;
476 }
477
478 /* Hide non-essential UI elements during printing */
479 #top-banner {
480 border-bottom: 1px solid #000;
481 box-shadow: none;
482 page-break-inside: avoid;
483 }
484
485 /* Hide interactive elements that aren't useful in print */
486 .stop-button,
487 .end-button,
488 .notifications-toggle,
489 sketch-call-status,
490 sketch-network-status,
491 sketch-view-mode-select {
492 display: none !important;
493 }
494
495 /* Ensure view container can expand to full content */
496 #view-container {
497 overflow: visible !important;
498 flex: none !important;
499 height: auto !important;
500 max-height: none !important;
501 }
502
503 #view-container-inner {
504 height: auto !important;
505 max-height: none !important;
506 overflow: visible !important;
507 }
508
509 /* Remove todo panel from print to avoid layout issues */
510 .todo-panel-container {
511 display: none !important;
512 }
513
514 /* Ensure chat timeline container takes full width in print */
515 .chat-timeline-container {
516 margin-right: 0 !important;
517 width: 100% !important;
518 height: auto !important;
519 overflow: visible !important;
520 }
521
522 /* Make chat view fully visible */
523 .chat-view {
524 height: auto !important;
525 overflow: visible !important;
526 }
527
528 /* Hide chat input during printing */
529 #chat-input {
530 display: none !important;
531 }
532
533 /* Adjust diff2 and terminal views for print */
534 .diff2-view,
535 .terminal-view {
536 height: auto !important;
537 overflow: visible !important;
538 }
539
540 /* Ensure only active view is shown in print */
541 .chat-view:not(.view-active),
542 .diff2-view:not(.view-active),
543 .terminal-view:not(.view-active) {
544 display: none !important;
545 }
546 }
Sean McCullough86b56862025-04-18 13:04:03 -0700547 `;
548
549 // Header bar: Network connection status details
550 @property()
551 connectionStatus: ConnectionStatus = "disconnected";
Autoformattercf570962025-04-30 17:27:39 +0000552
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000553 // Track if the last commit info has been copied
554 @state()
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000555 // lastCommitCopied moved to sketch-container-status
Sean McCullough86b56862025-04-18 13:04:03 -0700556
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000557 // Track notification preferences
558 @state()
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000559 notificationsEnabled: boolean = false;
560
561 // Track if the window is focused to control notifications
562 @state()
563 private _windowFocused: boolean = document.hasFocus();
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000564
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700565 // Track if the todo panel should be visible
566 @state()
567 private _todoPanelVisible: boolean = false;
568
banksean65ff9092025-06-19 00:36:25 +0000569 // Store scroll position for the chat view to preserve it when switching tabs
570 @state()
571 private _chatScrollPosition: number = 0;
572
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700573 // ResizeObserver for tracking chat input height changes
574 private chatInputResizeObserver: ResizeObserver | null = null;
575
Sean McCullough86b56862025-04-18 13:04:03 -0700576 @property()
577 connectionErrorMessage: string = "";
578
Sean McCullough86b56862025-04-18 13:04:03 -0700579 // Chat messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100580 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700581 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -0700582
583 @property()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700584 set slug(value: string) {
585 const oldValue = this._slug;
586 this._slug = value;
587 this.requestUpdate("slug", oldValue);
588 // Update document title when slug property changes
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000589 this.updateDocumentTitle();
590 }
591
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700592 get slug(): string {
593 return this._slug;
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000594 }
595
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700596 private _slug: string = "";
Sean McCullough86b56862025-04-18 13:04:03 -0700597
598 private dataManager = new DataManager();
599
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100600 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700601 containerState: State = {
Philip Zeyligerd03318d2025-05-08 13:09:12 -0700602 state_version: 2,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700603 slug: "",
Sean McCulloughd9f13372025-04-21 15:08:49 -0700604 os: "",
605 message_count: 0,
606 hostname: "",
607 working_dir: "",
608 initial_commit: "",
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000609 outstanding_llm_calls: 0,
610 outstanding_tool_calls: [],
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000611 session_id: "",
612 ssh_available: false,
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700613 ssh_error: "",
614 in_container: false,
615 first_message_index: 0,
Philip Zeyliger64f60462025-06-16 13:57:10 -0700616 diff_lines_added: 0,
617 diff_lines_removed: 0,
Sean McCulloughd9f13372025-04-21 15:08:49 -0700618 };
Sean McCullough86b56862025-04-18 13:04:03 -0700619
Sean McCullough86b56862025-04-18 13:04:03 -0700620 // Mutation observer to detect when new messages are added
621 private mutationObserver: MutationObserver | null = null;
622
623 constructor() {
624 super();
625
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000626 // Reference to the container status element
627 this.containerStatusElement = null;
628
Sean McCullough86b56862025-04-18 13:04:03 -0700629 // Binding methods to this
630 this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
Sean McCullough34bb09a2025-05-13 15:39:54 -0700631 this._handlePopState = this._handlePopState.bind(this);
Sean McCullough86b56862025-04-18 13:04:03 -0700632 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough485afc62025-04-28 14:28:39 -0700633 this._handleMutlipleChoiceSelected =
634 this._handleMutlipleChoiceSelected.bind(this);
Sean McCulloughd3906e22025-04-29 17:32:14 +0000635 this._handleStopClick = this._handleStopClick.bind(this);
Pokey Rule397871d2025-05-19 15:02:45 +0100636 this._handleEndClick = this._handleEndClick.bind(this);
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000637 this._handleNotificationsToggle =
638 this._handleNotificationsToggle.bind(this);
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000639 this._handleWindowFocus = this._handleWindowFocus.bind(this);
640 this._handleWindowBlur = this._handleWindowBlur.bind(this);
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000641
642 // Load notification preference from localStorage
643 try {
644 const savedPref = localStorage.getItem("sketch-notifications-enabled");
645 if (savedPref !== null) {
646 this.notificationsEnabled = savedPref === "true";
647 }
648 } catch (error) {
649 console.error("Error loading notification preference:", error);
650 }
Sean McCullough86b56862025-04-18 13:04:03 -0700651 }
652
653 // See https://lit.dev/docs/components/lifecycle/
654 connectedCallback() {
655 super.connectedCallback();
656
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000657 // Get reference to the container status element
658 setTimeout(() => {
659 this.containerStatusElement =
660 this.shadowRoot?.getElementById("container-status");
661 }, 0);
662
Sean McCullough86b56862025-04-18 13:04:03 -0700663 // Initialize client-side nav history.
664 const url = new URL(window.location.href);
665 const mode = url.searchParams.get("view") || "chat";
666 window.history.replaceState({ mode }, "", url.toString());
667
668 this.toggleViewMode(mode as ViewMode, false);
669 // Add popstate event listener to handle browser back/forward navigation
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100670 window.addEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700671
672 // Add event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100673 window.addEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100674 window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
Sean McCullough86b56862025-04-18 13:04:03 -0700675
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000676 // Add window focus/blur listeners for controlling notifications
677 window.addEventListener("focus", this._handleWindowFocus);
678 window.addEventListener("blur", this._handleWindowBlur);
Sean McCullough485afc62025-04-28 14:28:39 -0700679 window.addEventListener(
680 "multiple-choice-selected",
681 this._handleMutlipleChoiceSelected,
682 );
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000683
Sean McCullough86b56862025-04-18 13:04:03 -0700684 // register event listeners
685 this.dataManager.addEventListener(
686 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700687 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700688 );
689 this.dataManager.addEventListener(
690 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700691 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700692 );
693
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000694 // Set initial document title
695 this.updateDocumentTitle();
696
Sean McCullough86b56862025-04-18 13:04:03 -0700697 // Initialize the data manager
698 this.dataManager.initialize();
Autoformattercf570962025-04-30 17:27:39 +0000699
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000700 // Process existing messages for commit info
701 if (this.messages && this.messages.length > 0) {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000702 // Update last commit info via container status component
703 setTimeout(() => {
704 if (this.containerStatusElement) {
705 this.containerStatusElement.updateLastCommitInfo(this.messages);
706 }
707 }, 100);
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000708 }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700709
710 // Check if todo panel should be visible on initial load
711 this.checkTodoPanelVisibility();
712
713 // Set up ResizeObserver for chat input to update todo panel height
714 this.setupChatInputObserver();
Sean McCullough86b56862025-04-18 13:04:03 -0700715 }
716
717 // See https://lit.dev/docs/components/lifecycle/
718 disconnectedCallback() {
719 super.disconnectedCallback();
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100720 window.removeEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700721
722 // Remove event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100723 window.removeEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100724 window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000725 window.removeEventListener("focus", this._handleWindowFocus);
726 window.removeEventListener("blur", this._handleWindowBlur);
Sean McCullough485afc62025-04-28 14:28:39 -0700727 window.removeEventListener(
728 "multiple-choice-selected",
729 this._handleMutlipleChoiceSelected,
730 );
Sean McCullough86b56862025-04-18 13:04:03 -0700731
732 // unregister data manager event listeners
733 this.dataManager.removeEventListener(
734 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700735 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700736 );
737 this.dataManager.removeEventListener(
738 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700739 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700740 );
741
742 // Disconnect mutation observer if it exists
743 if (this.mutationObserver) {
Sean McCullough86b56862025-04-18 13:04:03 -0700744 this.mutationObserver.disconnect();
745 this.mutationObserver = null;
746 }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700747
748 // Disconnect chat input resize observer if it exists
749 if (this.chatInputResizeObserver) {
750 this.chatInputResizeObserver.disconnect();
751 this.chatInputResizeObserver = null;
752 }
Sean McCullough86b56862025-04-18 13:04:03 -0700753 }
754
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700755 updateUrlForViewMode(mode: ViewMode): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700756 // Get the current URL without search parameters
757 const url = new URL(window.location.href);
758
759 // Clear existing parameters
760 url.search = "";
761
762 // Only add view parameter if not in default chat view
763 if (mode !== "chat") {
764 url.searchParams.set("view", mode);
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000765 const diff2View = this.shadowRoot?.querySelector(
766 "sketch-diff2-view",
767 ) as SketchDiff2View;
Sean McCullough86b56862025-04-18 13:04:03 -0700768
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000769 // If in diff2 view and there's a commit hash, include that too
770 if (mode === "diff2" && diff2View?.commit) {
771 url.searchParams.set("commit", diff2View.commit);
Sean McCullough86b56862025-04-18 13:04:03 -0700772 }
773 }
774
775 // Update the browser history without reloading the page
776 window.history.pushState({ mode }, "", url.toString());
777 }
778
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100779 private _handlePopState(event: PopStateEvent) {
Sean McCullough86b56862025-04-18 13:04:03 -0700780 if (event.state && event.state.mode) {
781 this.toggleViewMode(event.state.mode, false);
782 } else {
783 this.toggleViewMode("chat", false);
784 }
785 }
786
787 /**
788 * Handle view mode selection event
789 */
790 private _handleViewModeSelect(event: CustomEvent) {
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000791 const mode = event.detail.mode as "chat" | "diff2" | "terminal";
Sean McCullough86b56862025-04-18 13:04:03 -0700792 this.toggleViewMode(mode, true);
793 }
794
795 /**
796 * Handle show commit diff event
797 */
798 private _handleShowCommitDiff(event: CustomEvent) {
799 const { commitHash } = event.detail;
800 if (commitHash) {
801 this.showCommitDiff(commitHash);
802 }
803 }
804
Sean McCullough485afc62025-04-28 14:28:39 -0700805 private _handleMultipleChoice(event: CustomEvent) {
806 window.console.log("_handleMultipleChoice", event);
807 this._sendChat;
808 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700809
810 private _handleDiffComment(event: CustomEvent) {
811 // Empty stub required by the event binding in the template
812 // Actual handling occurs at global level in sketch-chat-input component
813 }
Sean McCullough86b56862025-04-18 13:04:03 -0700814 /**
Sean McCullough86b56862025-04-18 13:04:03 -0700815 * Listen for commit diff event
816 * @param commitHash The commit hash to show diff for
817 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100818 private showCommitDiff(commitHash: string): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700819 // Store the commit hash
820 this.currentCommitHash = commitHash;
821
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700822 this.toggleViewMode("diff2", true);
Sean McCullough86b56862025-04-18 13:04:03 -0700823
Sean McCullough86b56862025-04-18 13:04:03 -0700824 this.updateComplete.then(() => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700825 const diff2View = this.shadowRoot?.querySelector("sketch-diff2-view");
826 if (diff2View) {
827 (diff2View as SketchDiff2View).refreshDiffView();
Sean McCullough86b56862025-04-18 13:04:03 -0700828 }
829 });
830 }
831
832 /**
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000833 * Toggle between different view modes: chat, diff2, terminal
Sean McCullough86b56862025-04-18 13:04:03 -0700834 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100835 private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700836 // Don't do anything if the mode is already active
837 if (this.viewMode === mode) return;
838
banksean65ff9092025-06-19 00:36:25 +0000839 // Store scroll position if we're leaving the chat view
840 if (this.viewMode === "chat" && this.scrollContainerRef.value) {
841 // Only store scroll position if we actually have meaningful content
842 const scrollTop = this.scrollContainerRef.value.scrollTop;
843 const scrollHeight = this.scrollContainerRef.value.scrollHeight;
844 const clientHeight = this.scrollContainerRef.value.clientHeight;
845
846 // Store position only if we have scrollable content and have actually scrolled
847 if (scrollHeight > clientHeight && scrollTop > 0) {
848 this._chatScrollPosition = scrollTop;
849 }
850 }
851
Sean McCullough86b56862025-04-18 13:04:03 -0700852 // Update the view mode
853 this.viewMode = mode;
854
855 if (updateHistory) {
856 // Update URL with the current view mode
857 this.updateUrlForViewMode(mode);
858 }
859
860 // Wait for DOM update to complete
861 this.updateComplete.then(() => {
862 // Update active view
Pokey Rule46fff972025-04-25 14:57:44 +0100863 const viewContainerInner = this.shadowRoot?.querySelector(
864 "#view-container-inner",
865 );
Sean McCullough86b56862025-04-18 13:04:03 -0700866 const chatView = this.shadowRoot?.querySelector(".chat-view");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700867 const diff2View = this.shadowRoot?.querySelector(".diff2-view");
Sean McCullough86b56862025-04-18 13:04:03 -0700868 const terminalView = this.shadowRoot?.querySelector(".terminal-view");
869
870 // Remove active class from all views
871 chatView?.classList.remove("view-active");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700872 diff2View?.classList.remove("view-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700873 terminalView?.classList.remove("view-active");
874
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000875 // Add/remove diff2-active class on view container
876 if (mode === "diff2") {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700877 viewContainerInner?.classList.add("diff2-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700878 } else {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700879 viewContainerInner?.classList.remove("diff2-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700880 }
881
882 // Add active class to the selected view
883 switch (mode) {
884 case "chat":
885 chatView?.classList.add("view-active");
banksean65ff9092025-06-19 00:36:25 +0000886 // Restore scroll position if we're switching back to chat
887 if (this.scrollContainerRef.value && this._chatScrollPosition > 0) {
888 // Use requestAnimationFrame to ensure DOM is ready
889 requestAnimationFrame(() => {
890 if (this.scrollContainerRef.value) {
891 // Double-check that we're still in chat mode and the container is available
892 if (
893 this.viewMode === "chat" &&
894 this.scrollContainerRef.value.isConnected
895 ) {
896 this.scrollContainerRef.value.scrollTop =
897 this._chatScrollPosition;
898 }
899 }
900 });
901 }
Sean McCullough86b56862025-04-18 13:04:03 -0700902 break;
Autoformatter8c463622025-05-16 21:54:17 +0000903
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700904 case "diff2":
905 diff2View?.classList.add("view-active");
906 // Refresh git/recentlog when Monaco diff view is opened
907 // This ensures branch information is always up-to-date, as branches can change frequently
Autoformatter8c463622025-05-16 21:54:17 +0000908 const diff2ViewComp =
909 this.shadowRoot?.querySelector("sketch-diff2-view");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700910 if (diff2ViewComp) {
911 (diff2ViewComp as SketchDiff2View).refreshDiffView();
912 }
913 break;
Philip Zeyliger2d4c48f2025-05-02 23:35:03 +0000914
Sean McCullough86b56862025-04-18 13:04:03 -0700915 case "terminal":
916 terminalView?.classList.add("view-active");
917 break;
918 }
919
920 // Update view mode buttons
921 const viewModeSelect = this.shadowRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700922 "sketch-view-mode-select",
Sean McCullough86b56862025-04-18 13:04:03 -0700923 );
924 if (viewModeSelect) {
925 const event = new CustomEvent("update-active-mode", {
926 detail: { mode },
927 bubbles: true,
928 composed: true,
929 });
930 viewModeSelect.dispatchEvent(event);
931 }
Sean McCullough86b56862025-04-18 13:04:03 -0700932 });
933 }
934
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000935 /**
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700936 * Updates the document title based on current slug and connection status
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000937 */
938 private updateDocumentTitle(): void {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700939 let docTitle = `sk: ${this.slug || "untitled"}`;
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000940
941 // Add red circle emoji if disconnected
942 if (this.connectionStatus === "disconnected") {
943 docTitle += " 🔴";
944 }
945
946 document.title = docTitle;
947 }
948
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000949 // Check and request notification permission if needed
950 private async checkNotificationPermission(): Promise<boolean> {
951 // Check if the Notification API is supported
952 if (!("Notification" in window)) {
953 console.log("This browser does not support notifications");
954 return false;
955 }
956
957 // Check if permission is already granted
958 if (Notification.permission === "granted") {
959 return true;
960 }
961
962 // If permission is not denied, request it
963 if (Notification.permission !== "denied") {
964 const permission = await Notification.requestPermission();
965 return permission === "granted";
966 }
967
968 return false;
969 }
970
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000971 // Handle notifications toggle click
972 private _handleNotificationsToggle(): void {
973 this.notificationsEnabled = !this.notificationsEnabled;
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000974
975 // If enabling notifications, check permissions
976 if (this.notificationsEnabled) {
977 this.checkNotificationPermission();
978 }
979
980 // Save preference to localStorage
981 try {
982 localStorage.setItem(
983 "sketch-notifications-enabled",
984 String(this.notificationsEnabled),
985 );
986 } catch (error) {
987 console.error("Error saving notification preference:", error);
988 }
989 }
990
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000991 // Handle window focus event
992 private _handleWindowFocus(): void {
993 this._windowFocused = true;
994 }
995
996 // Handle window blur event
997 private _handleWindowBlur(): void {
998 this._windowFocused = false;
999 }
1000
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -07001001 // Get the last user or agent message (ignore system messages like commit, error, etc.)
1002 // For example, when Sketch notices a new commit, it'll send a message,
1003 // but it's still idle!
1004 private getLastUserOrAgentMessage(): AgentMessage | null {
1005 for (let i = this.messages.length - 1; i >= 0; i--) {
1006 const message = this.messages[i];
1007 if (message.type === "user" || message.type === "agent") {
1008 return message;
1009 }
1010 }
1011 return null;
1012 }
1013
Philip Zeyligerbc6b6292025-04-30 18:00:15 +00001014 // Show notification for message with EndOfTurn=true
1015 private async showEndOfTurnNotification(
1016 message: AgentMessage,
1017 ): Promise<void> {
1018 // Don't show notifications if they're disabled
1019 if (!this.notificationsEnabled) return;
1020
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +00001021 // Don't show notifications if the window is focused
1022 if (this._windowFocused) return;
1023
Philip Zeyligerbc6b6292025-04-30 18:00:15 +00001024 // Check if we have permission to show notifications
1025 const hasPermission = await this.checkNotificationPermission();
1026 if (!hasPermission) return;
1027
Philip Zeyliger32011332025-04-30 20:59:40 +00001028 // Only show notifications for agent messages with end_of_turn=true and no parent_conversation_id
1029 if (
1030 message.type !== "agent" ||
1031 !message.end_of_turn ||
1032 message.parent_conversation_id
1033 )
1034 return;
Philip Zeyligerbc6b6292025-04-30 18:00:15 +00001035
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001036 // Create a title that includes the sketch slug
1037 const notificationTitle = `Sketch: ${this.slug || "untitled"}`;
Philip Zeyligerbc6b6292025-04-30 18:00:15 +00001038
1039 // Extract the beginning of the message content (first 100 chars)
1040 const messagePreview = message.content
1041 ? message.content.substring(0, 100) +
1042 (message.content.length > 100 ? "..." : "")
1043 : "Agent has completed its turn";
1044
1045 // Create and show the notification
1046 try {
1047 new Notification(notificationTitle, {
1048 body: messagePreview,
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +00001049 icon: "https://sketch.dev/favicon.ico", // Use sketch.dev favicon for notification
Philip Zeyligerbc6b6292025-04-30 18:00:15 +00001050 });
1051 } catch (error) {
1052 console.error("Error showing notification:", error);
1053 }
1054 }
1055
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001056 // Check if todo panel should be visible based on latest todo content from messages or state
1057 private checkTodoPanelVisibility(): void {
1058 // Find the latest todo content from messages first
1059 let latestTodoContent = "";
1060 for (let i = this.messages.length - 1; i >= 0; i--) {
1061 const message = this.messages[i];
1062 if (message.todo_content !== undefined) {
1063 latestTodoContent = message.todo_content || "";
1064 break;
1065 }
1066 }
1067
1068 // If no todo content found in messages, check the current state
1069 if (latestTodoContent === "" && this.containerState?.todo_content) {
1070 latestTodoContent = this.containerState.todo_content;
1071 }
1072
1073 // Parse the todo data to check if there are any actual todos
1074 let hasTodos = false;
1075 if (latestTodoContent.trim()) {
1076 try {
1077 const todoData = JSON.parse(latestTodoContent);
1078 hasTodos = todoData.items && todoData.items.length > 0;
1079 } catch (error) {
1080 // Invalid JSON, treat as no todos
1081 hasTodos = false;
1082 }
1083 }
1084
1085 this._todoPanelVisible = hasTodos;
1086
1087 // Update todo panel content if visible
1088 if (hasTodos) {
1089 const todoPanel = this.shadowRoot?.querySelector(
1090 "sketch-todo-panel",
1091 ) as any;
1092 if (todoPanel && todoPanel.updateTodoContent) {
1093 todoPanel.updateTodoContent(latestTodoContent);
1094 }
1095 }
1096 }
1097
Sean McCullough86b56862025-04-18 13:04:03 -07001098 private handleDataChanged(eventData: {
1099 state: State;
Sean McCulloughd9f13372025-04-21 15:08:49 -07001100 newMessages: AgentMessage[];
Sean McCullough86b56862025-04-18 13:04:03 -07001101 }): void {
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001102 const { state, newMessages } = eventData;
Sean McCullough86b56862025-04-18 13:04:03 -07001103
1104 // Update state if we received it
1105 if (state) {
Josh Bleecher Snydere81233f2025-04-30 04:05:41 +00001106 // Ensure we're using the latest call status to prevent indicators from being stuck
Autoformatterf830c9d2025-04-30 18:16:01 +00001107 if (
1108 state.outstanding_llm_calls === 0 &&
1109 state.outstanding_tool_calls.length === 0
1110 ) {
Josh Bleecher Snydere81233f2025-04-30 04:05:41 +00001111 // Force reset containerState calls when nothing is reported as in progress
1112 state.outstanding_llm_calls = 0;
1113 state.outstanding_tool_calls = [];
1114 }
Autoformatterf830c9d2025-04-30 18:16:01 +00001115
Sean McCullough86b56862025-04-18 13:04:03 -07001116 this.containerState = state;
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001117 this.slug = state.slug || "";
Philip Zeyliger9b999b02025-04-25 16:31:50 +00001118
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001119 // Update document title when sketch slug changes
Philip Zeyliger9b999b02025-04-25 16:31:50 +00001120 this.updateDocumentTitle();
Sean McCullough86b56862025-04-18 13:04:03 -07001121 }
1122
Sean McCullough86b56862025-04-18 13:04:03 -07001123 // Update messages
banksean65ff9092025-06-19 00:36:25 +00001124 const oldMessageCount = this.messages.length;
Pokey Rulee2a8c2f2025-04-23 15:09:25 +01001125 this.messages = aggregateAgentMessages(this.messages, newMessages);
Autoformattercf570962025-04-30 17:27:39 +00001126
banksean65ff9092025-06-19 00:36:25 +00001127 // If new messages were added and we're in chat view, reset stored scroll position
1128 // so the timeline can auto-scroll to bottom for new content
1129 if (this.messages.length > oldMessageCount && this.viewMode === "chat") {
1130 // Only reset if we were near the bottom (indicating user wants to follow new messages)
1131 if (this.scrollContainerRef.value) {
1132 const scrollTop = this.scrollContainerRef.value.scrollTop;
1133 const scrollHeight = this.scrollContainerRef.value.scrollHeight;
1134 const clientHeight = this.scrollContainerRef.value.clientHeight;
1135 const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px tolerance
1136
1137 if (isNearBottom) {
1138 this._chatScrollPosition = 0; // Reset stored position to allow auto-scroll
1139 }
1140 }
1141 }
1142
Philip Zeyliger47b71c92025-04-30 15:43:39 +00001143 // Process new messages to find commit messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001144 // Update last commit info via container status component
1145 if (this.containerStatusElement) {
1146 this.containerStatusElement.updateLastCommitInfo(newMessages);
1147 }
Philip Zeyligerbc6b6292025-04-30 18:00:15 +00001148
1149 // Check for agent messages with end_of_turn=true and show notifications
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001150 if (newMessages && newMessages.length > 0) {
Philip Zeyligerbc6b6292025-04-30 18:00:15 +00001151 for (const message of newMessages) {
Philip Zeyliger32011332025-04-30 20:59:40 +00001152 if (
1153 message.type === "agent" &&
1154 message.end_of_turn &&
1155 !message.parent_conversation_id
1156 ) {
Philip Zeyligerbc6b6292025-04-30 18:00:15 +00001157 this.showEndOfTurnNotification(message);
1158 break; // Only show one notification per batch of messages
1159 }
1160 }
1161 }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001162
1163 // Check if todo panel should be visible after agent loop iteration
1164 this.checkTodoPanelVisibility();
1165
1166 // Ensure chat input observer is set up when new data comes in
1167 if (!this.chatInputResizeObserver) {
1168 this.setupChatInputObserver();
1169 }
Sean McCullough86b56862025-04-18 13:04:03 -07001170 }
1171
1172 private handleConnectionStatusChanged(
1173 status: ConnectionStatus,
Philip Zeyliger72682df2025-04-23 13:09:46 -07001174 errorMessage?: string,
Sean McCullough86b56862025-04-18 13:04:03 -07001175 ): void {
1176 this.connectionStatus = status;
1177 this.connectionErrorMessage = errorMessage || "";
Philip Zeyliger9b999b02025-04-25 16:31:50 +00001178
1179 // Update document title when connection status changes
1180 this.updateDocumentTitle();
Sean McCullough86b56862025-04-18 13:04:03 -07001181 }
1182
Sean McCulloughd3906e22025-04-29 17:32:14 +00001183 private async _handleStopClick(): Promise<void> {
1184 try {
1185 const response = await fetch("cancel", {
1186 method: "POST",
1187 headers: {
1188 "Content-Type": "application/json",
1189 },
1190 body: JSON.stringify({ reason: "user requested cancellation" }),
1191 });
1192
1193 if (!response.ok) {
1194 const errorData = await response.text();
1195 throw new Error(
1196 `Failed to stop operation: ${response.status} - ${errorData}`,
1197 );
1198 }
1199
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001200 // Stop request sent
Sean McCulloughd3906e22025-04-29 17:32:14 +00001201 } catch (error) {
1202 console.error("Error stopping operation:", error);
Sean McCulloughd3906e22025-04-29 17:32:14 +00001203 }
1204 }
1205
Pokey Rule397871d2025-05-19 15:02:45 +01001206 private async _handleEndClick(event?: Event): Promise<void> {
1207 if (event) {
1208 event.preventDefault();
1209 event.stopPropagation();
1210 }
Philip Zeyligerb5739402025-06-02 07:04:34 -07001211
Philip Zeyliger16098932025-06-04 11:02:55 -07001212 // Show confirmation dialog
1213 const confirmed = window.confirm(
1214 "Ending the session will shut down the underlying container. Are you sure?",
1215 );
1216 if (!confirmed) return;
Pokey Rule397871d2025-05-19 15:02:45 +01001217
1218 try {
1219 const response = await fetch("end", {
1220 method: "POST",
1221 headers: {
1222 "Content-Type": "application/json",
1223 },
Philip Zeyliger16098932025-06-04 11:02:55 -07001224 body: JSON.stringify({ reason: "user requested end of session" }),
Pokey Rule397871d2025-05-19 15:02:45 +01001225 });
1226
1227 if (!response.ok) {
1228 const errorData = await response.text();
1229 throw new Error(
1230 `Failed to end session: ${response.status} - ${errorData}`,
1231 );
1232 }
1233
1234 // After successful response, redirect to messages view
1235 // Extract the session ID from the URL
1236 const currentUrl = window.location.href;
1237 // The URL pattern should be like https://sketch.dev/s/cs71-8qa6-1124-aw79/
1238 const urlParts = currentUrl.split("/");
1239 let sessionId = "";
1240
1241 // Find the session ID in the URL (should be after /s/)
1242 for (let i = 0; i < urlParts.length; i++) {
1243 if (urlParts[i] === "s" && i + 1 < urlParts.length) {
1244 sessionId = urlParts[i + 1];
1245 break;
1246 }
1247 }
1248
1249 if (sessionId) {
1250 // Create the messages URL
1251 const messagesUrl = `/messages/${sessionId}`;
1252 // Redirect to messages view
1253 window.location.href = messagesUrl;
1254 }
1255
1256 // End request sent - connection will be closed by server
1257 } catch (error) {
1258 console.error("Error ending session:", error);
1259 }
1260 }
1261
Sean McCullough485afc62025-04-28 14:28:39 -07001262 async _handleMutlipleChoiceSelected(e: CustomEvent) {
1263 const chatInput = this.shadowRoot?.querySelector(
1264 "sketch-chat-input",
1265 ) as SketchChatInput;
1266 if (chatInput) {
Josh Bleecher Snyder6cad8612025-05-30 19:25:39 +00001267 if (chatInput.content && chatInput.content.trim() !== "") {
1268 chatInput.content += "\n\n";
1269 }
1270 chatInput.content += e.detail.responseText;
Sean McCullough485afc62025-04-28 14:28:39 -07001271 chatInput.focus();
Josh Bleecher Snyder6cad8612025-05-30 19:25:39 +00001272 // Adjust textarea height to accommodate new content
1273 requestAnimationFrame(() => {
1274 if (chatInput.adjustChatSpacing) {
1275 chatInput.adjustChatSpacing();
1276 }
1277 });
Sean McCullough485afc62025-04-28 14:28:39 -07001278 }
1279 }
1280
Sean McCullough86b56862025-04-18 13:04:03 -07001281 async _sendChat(e: CustomEvent) {
1282 console.log("app shell: _sendChat", e);
Sean McCullough485afc62025-04-28 14:28:39 -07001283 e.preventDefault();
1284 e.stopPropagation();
Sean McCullough86b56862025-04-18 13:04:03 -07001285 const message = e.detail.message?.trim();
1286 if (message == "") {
1287 return;
1288 }
1289 try {
Josh Bleecher Snyder98b64d12025-05-12 19:42:43 +00001290 // Always switch to chat view when sending a message so user can see processing
1291 if (this.viewMode !== "chat") {
1292 this.toggleViewMode("chat", true);
1293 }
Autoformatter5c7f9572025-05-13 01:17:31 +00001294
Sean McCullough86b56862025-04-18 13:04:03 -07001295 // Send the message to the server
1296 const response = await fetch("chat", {
1297 method: "POST",
1298 headers: {
1299 "Content-Type": "application/json",
1300 },
1301 body: JSON.stringify({ message }),
1302 });
1303
1304 if (!response.ok) {
1305 const errorData = await response.text();
1306 throw new Error(`Server error: ${response.status} - ${errorData}`);
1307 }
Sean McCullough86b56862025-04-18 13:04:03 -07001308 } catch (error) {
1309 console.error("Error sending chat message:", error);
1310 const statusText = document.getElementById("statusText");
1311 if (statusText) {
1312 statusText.textContent = "Error sending message";
1313 }
1314 }
1315 }
1316
Pokey Rule4097e532025-04-24 18:55:28 +01001317 private scrollContainerRef = createRef<HTMLElement>();
1318
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001319 /**
1320 * Set up ResizeObserver to monitor chat input height changes
1321 */
1322 private setupChatInputObserver(): void {
1323 // Wait for DOM to be ready
1324 this.updateComplete.then(() => {
1325 const chatInputElement = this.shadowRoot?.querySelector("#chat-input");
1326 if (chatInputElement && !this.chatInputResizeObserver) {
1327 this.chatInputResizeObserver = new ResizeObserver((entries) => {
1328 for (const entry of entries) {
1329 this.updateTodoPanelHeight(entry.contentRect.height);
1330 }
1331 });
1332
1333 this.chatInputResizeObserver.observe(chatInputElement);
1334
1335 // Initial height calculation
1336 const rect = chatInputElement.getBoundingClientRect();
1337 this.updateTodoPanelHeight(rect.height);
1338 }
1339 });
1340 }
1341
1342 /**
1343 * Update the CSS custom property that controls todo panel bottom position
1344 */
1345 private updateTodoPanelHeight(chatInputHeight: number): void {
1346 // Add some padding (20px) between todo panel and chat input
1347 const bottomOffset = chatInputHeight;
1348
1349 // Update the CSS custom property on the host element
1350 this.style.setProperty("--chat-input-height", `${bottomOffset}px`);
1351 }
1352
Sean McCullough86b56862025-04-18 13:04:03 -07001353 render() {
1354 return html`
Pokey Rule4097e532025-04-24 18:55:28 +01001355 <div id="top-banner">
Sean McCullough86b56862025-04-18 13:04:03 -07001356 <div class="title-container">
Philip Zeyliger0113be52025-06-07 23:53:41 +00001357 <h1 class="banner-title">
1358 ${this.containerState?.skaband_addr
1359 ? html`<a
1360 href="${this.containerState.skaband_addr}"
1361 target="_blank"
1362 rel="noopener noreferrer"
1363 >
1364 <img
1365 src="${this.containerState.skaband_addr}/sketch.dev.png"
1366 alt="sketch"
1367 />
1368 sketch
1369 </a>`
1370 : html`sketch`}
1371 </h1>
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001372 <h2 class="slug-title">${this.slug}</h2>
Sean McCullough86b56862025-04-18 13:04:03 -07001373 </div>
1374
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001375 <!-- Container status info moved above tabs -->
Sean McCullough86b56862025-04-18 13:04:03 -07001376 <sketch-container-status
1377 .state=${this.containerState}
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001378 id="container-status"
Sean McCullough86b56862025-04-18 13:04:03 -07001379 ></sketch-container-status>
Autoformattercf570962025-04-30 17:27:39 +00001380
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001381 <!-- Last Commit section moved to sketch-container-status -->
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001382
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001383 <!-- Views section with tabs -->
Philip Zeyliger64f60462025-06-16 13:57:10 -07001384 <sketch-view-mode-select
1385 .diffLinesAdded=${this.containerState?.diff_lines_added || 0}
1386 .diffLinesRemoved=${this.containerState?.diff_lines_removed || 0}
1387 ></sketch-view-mode-select>
Sean McCullough86b56862025-04-18 13:04:03 -07001388
1389 <div class="refresh-control">
Sean McCulloughd3906e22025-04-29 17:32:14 +00001390 <button
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001391 id="stopButton"
1392 class="stop-button"
1393 ?disabled=${(this.containerState?.outstanding_llm_calls || 0) ===
1394 0 &&
1395 (this.containerState?.outstanding_tool_calls || []).length === 0}
1396 >
1397 <svg
1398 class="button-icon"
1399 xmlns="http://www.w3.org/2000/svg"
1400 viewBox="0 0 24 24"
1401 fill="none"
1402 stroke="currentColor"
1403 stroke-width="2"
1404 stroke-linecap="round"
1405 stroke-linejoin="round"
1406 >
1407 <rect x="6" y="6" width="12" height="12" />
1408 </svg>
1409 <span class="button-text">Stop</span>
Sean McCullough86b56862025-04-18 13:04:03 -07001410 </button>
Pokey Rule397871d2025-05-19 15:02:45 +01001411 <button
1412 id="endButton"
1413 class="end-button"
1414 @click=${this._handleEndClick}
1415 >
1416 <svg
1417 class="button-icon"
1418 xmlns="http://www.w3.org/2000/svg"
1419 viewBox="0 0 24 24"
1420 fill="none"
1421 stroke="currentColor"
1422 stroke-width="2"
1423 stroke-linecap="round"
1424 stroke-linejoin="round"
1425 >
1426 <path d="M18 6L6 18" />
1427 <path d="M6 6l12 12" />
1428 </svg>
1429 <span class="button-text">End</span>
1430 </button>
Sean McCullough86b56862025-04-18 13:04:03 -07001431
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +00001432 <div
1433 class="notifications-toggle"
1434 @click=${this._handleNotificationsToggle}
1435 title="${this.notificationsEnabled
1436 ? "Disable"
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001437 : "Enable"} notifications when the agent completes its turn"
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +00001438 >
1439 <div
1440 class="bell-icon ${!this.notificationsEnabled
1441 ? "bell-disabled"
1442 : ""}"
1443 >
1444 <!-- Bell SVG icon -->
1445 <svg
1446 xmlns="http://www.w3.org/2000/svg"
1447 width="16"
1448 height="16"
1449 fill="currentColor"
1450 viewBox="0 0 16 16"
1451 >
1452 <path
1453 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"
1454 />
1455 </svg>
1456 </div>
Philip Zeyligerbc6b6292025-04-30 18:00:15 +00001457 </div>
1458
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001459 <sketch-call-status
Sean McCulloughd9d45812025-04-30 16:53:41 -07001460 .agentState=${this.containerState?.agent_state}
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001461 .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
1462 .toolCalls=${this.containerState?.outstanding_tool_calls || []}
Philip Zeyliger5a85ffe2025-06-21 21:27:44 -07001463 .isIdle=${(() => {
1464 const lastUserOrAgentMessage = this.getLastUserOrAgentMessage();
1465 return lastUserOrAgentMessage
1466 ? lastUserOrAgentMessage.end_of_turn &&
1467 !lastUserOrAgentMessage.parent_conversation_id
1468 : true;
1469 })()}
Philip Zeyliger5e357022025-05-16 04:50:34 +00001470 .isDisconnected=${this.connectionStatus === "disconnected"}
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001471 ></sketch-call-status>
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001472
1473 <sketch-network-status
1474 connection=${this.connectionStatus}
1475 error=${this.connectionErrorMessage}
1476 ></sketch-network-status>
Sean McCullough86b56862025-04-18 13:04:03 -07001477 </div>
1478 </div>
1479
Pokey Rule4097e532025-04-24 18:55:28 +01001480 <div id="view-container" ${ref(this.scrollContainerRef)}>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001481 <div
1482 id="view-container-inner"
1483 class="${this._todoPanelVisible && this.viewMode === "chat"
1484 ? "with-todo-panel"
1485 : ""}"
1486 >
Pokey Rule4097e532025-04-24 18:55:28 +01001487 <div
1488 class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}"
1489 >
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001490 <div
1491 class="chat-timeline-container ${this._todoPanelVisible &&
1492 this.viewMode === "chat"
1493 ? "with-todo-panel"
1494 : ""}"
1495 >
1496 <sketch-timeline
1497 .messages=${this.messages}
1498 .scrollContainer=${this.scrollContainerRef}
1499 .agentState=${this.containerState?.agent_state}
1500 .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
1501 .toolCalls=${this.containerState?.outstanding_tool_calls || []}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001502 .firstMessageIndex=${this.containerState?.first_message_index ||
1503 0}
philip.zeyliger6d3de482025-06-10 19:38:14 -07001504 .state=${this.containerState}
banksean54777362025-06-19 16:38:30 +00001505 .dataManager=${this.dataManager}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001506 ></sketch-timeline>
1507 </div>
1508 </div>
1509
1510 <!-- Todo panel positioned outside the main flow - only visible in chat view -->
1511 <div
1512 class="todo-panel-container ${this._todoPanelVisible &&
1513 this.viewMode === "chat"
1514 ? "visible"
1515 : ""}"
1516 >
1517 <sketch-todo-panel
1518 .visible=${this._todoPanelVisible && this.viewMode === "chat"}
1519 ></sketch-todo-panel>
Pokey Rule4097e532025-04-24 18:55:28 +01001520 </div>
1521 <div
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001522 class="diff2-view ${this.viewMode === "diff2" ? "view-active" : ""}"
1523 >
1524 <sketch-diff2-view
1525 .commit=${this.currentCommitHash}
1526 .gitService=${new DefaultGitDataService()}
1527 @diff-comment="${this._handleDiffComment}"
1528 ></sketch-diff2-view>
1529 </div>
Philip Zeyliger2d4c48f2025-05-02 23:35:03 +00001530
Pokey Rule4097e532025-04-24 18:55:28 +01001531 <div
1532 class="terminal-view ${this.viewMode === "terminal"
1533 ? "view-active"
1534 : ""}"
1535 >
1536 <sketch-terminal></sketch-terminal>
1537 </div>
Sean McCullough86b56862025-04-18 13:04:03 -07001538 </div>
1539 </div>
1540
Pokey Rule4097e532025-04-24 18:55:28 +01001541 <div id="chat-input">
1542 <sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
1543 </div>
Sean McCullough86b56862025-04-18 13:04:03 -07001544 `;
1545 }
1546
1547 /**
Sean McCullough86b56862025-04-18 13:04:03 -07001548 * Lifecycle callback when component is first connected to DOM
1549 */
1550 firstUpdated(): void {
1551 if (this.viewMode !== "chat") {
1552 return;
1553 }
1554
1555 // Initial scroll to bottom when component is first rendered
1556 setTimeout(
1557 () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
Philip Zeyliger72682df2025-04-23 13:09:46 -07001558 50,
Sean McCullough86b56862025-04-18 13:04:03 -07001559 );
1560
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001561 // Setup stop button
1562 const stopButton = this.renderRoot?.querySelector(
1563 "#stopButton",
1564 ) as HTMLButtonElement;
1565 stopButton?.addEventListener("click", async () => {
1566 try {
Sean McCullough495cb962025-05-01 16:25:53 -07001567 const response = await fetch("cancel", {
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001568 method: "POST",
1569 headers: {
1570 "Content-Type": "application/json",
1571 },
1572 body: JSON.stringify({ reason: "User clicked stop button" }),
1573 });
1574 if (!response.ok) {
1575 console.error("Failed to cancel:", await response.text());
1576 }
1577 } catch (error) {
1578 console.error("Error cancelling operation:", error);
1579 }
1580 });
1581
Pokey Rule397871d2025-05-19 15:02:45 +01001582 // Setup end button
1583 const endButton = this.renderRoot?.querySelector(
1584 "#endButton",
1585 ) as HTMLButtonElement;
1586 // We're already using the @click binding in the HTML, so manual event listener not needed here
1587
Philip Zeyliger47b71c92025-04-30 15:43:39 +00001588 // Process any existing messages to find commit information
1589 if (this.messages && this.messages.length > 0) {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001590 // Update last commit info via container status component
1591 if (this.containerStatusElement) {
1592 this.containerStatusElement.updateLastCommitInfo(this.messages);
1593 }
Philip Zeyliger47b71c92025-04-30 15:43:39 +00001594 }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001595
1596 // Set up chat input height observer for todo panel
1597 this.setupChatInputObserver();
Sean McCullough86b56862025-04-18 13:04:03 -07001598 }
1599}
1600
1601declare global {
1602 interface HTMLElementTagNameMap {
1603 "sketch-app-shell": SketchAppShell;
1604 }
1605}