blob: 59fb9214ba615fd3e5acea9005e3cf08ca909957 [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
167 .chat-title {
168 margin: 0;
169 padding: 0;
170 color: rgba(82, 82, 82, 0.85);
Josh Bleecher Snydereb5166a2025-04-30 17:04:20 +0000171 font-size: 14px;
Sean McCullough86b56862025-04-18 13:04:03 -0700172 font-weight: normal;
173 font-style: italic;
174 white-space: nowrap;
175 overflow: hidden;
176 text-overflow: ellipsis;
177 }
178
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700179 /* Allow the container to expand to full width and height in diff mode */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700180 #view-container-inner.diff2-active {
Sean McCullough86b56862025-04-18 13:04:03 -0700181 max-width: 100%;
Pokey Rule46fff972025-04-25 14:57:44 +0100182 width: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700183 height: 100%;
184 padding: 0; /* Remove padding for more space */
185 display: flex;
186 flex-direction: column;
187 flex: 1;
188 min-height: 0; /* Critical for flex behavior */
Sean McCullough86b56862025-04-18 13:04:03 -0700189 }
190
191 /* Individual view styles */
192 .chat-view,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700193 .diff2-view,
Sean McCullough86b56862025-04-18 13:04:03 -0700194 .terminal-view {
195 display: none; /* Hidden by default */
196 width: 100%;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700197 height: 100%;
198 }
Autoformatter8c463622025-05-16 21:54:17 +0000199
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700200 /* Make chat view take full width available */
201 .chat-view.view-active {
202 display: flex;
203 flex-direction: column;
204 width: 100%;
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700205 height: 100%;
206 }
207
208 /* Chat timeline container - takes full width, memory panel will be positioned separately */
209 .chat-timeline-container {
210 flex: 1;
211 display: flex;
212 flex-direction: column;
213 width: 100%;
214 height: 100%;
215 margin-right: 0; /* Default - no memory panel */
216 transition: margin-right 0.2s ease; /* Smooth transition */
217 }
218
219 /* Adjust chat timeline container when todo panel is visible */
220 .chat-timeline-container.with-todo-panel {
221 margin-right: 400px; /* Make space for fixed todo panel */
222 width: calc(100% - 400px); /* Explicitly set width to prevent overlap */
223 }
224
225 /* Todo panel container - fixed to right side */
226 .todo-panel-container {
227 position: fixed;
228 top: 48px; /* Below top banner */
229 right: 15px; /* Leave space for scroll bar */
230 width: 400px;
231 bottom: var(
232 --chat-input-height,
233 90px
234 ); /* Dynamic height based on chat input size */
235 background-color: #fafafa;
236 border-left: 1px solid #e0e0e0;
237 z-index: 100;
238 display: none; /* Hidden by default */
239 transition: bottom 0.2s ease; /* Smooth transition when height changes */
240 /* Add fuzzy gradient at bottom to blend with text entry */
241 background: linear-gradient(
242 to bottom,
243 #fafafa 0%,
244 #fafafa 90%,
245 rgba(250, 250, 250, 0.5) 95%,
246 rgba(250, 250, 250, 0.2) 100%
247 );
248 }
249
250 .todo-panel-container.visible {
251 display: block;
252 }
253
254 /* Responsive adjustments for todo panel */
255 @media (max-width: 1200px) {
256 .todo-panel-container {
257 width: 350px;
258 /* bottom is still controlled by --chat-input-height CSS variable */
259 }
260 .chat-timeline-container.with-todo-panel {
261 margin-right: 350px;
262 width: calc(100% - 350px);
263 }
264 }
265
266 @media (max-width: 900px) {
267 .todo-panel-container {
268 width: 300px;
269 /* bottom is still controlled by --chat-input-height CSS variable */
270 }
271 .chat-timeline-container.with-todo-panel {
272 margin-right: 300px;
273 width: calc(100% - 300px);
274 }
275 }
276
277 /* On very small screens, hide todo panel or make it overlay */
278 @media (max-width: 768px) {
279 .todo-panel-container.visible {
280 display: none; /* Hide on mobile */
281 }
282 .chat-timeline-container.with-todo-panel {
283 margin-right: 0;
284 width: 100%;
285 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700286 }
Autoformatter8c463622025-05-16 21:54:17 +0000287
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700288 /* Monaco diff2 view needs to take all available space */
289 .diff2-view.view-active {
290 flex: 1;
291 overflow: hidden;
292 min-height: 0; /* Required for proper flex child behavior */
293 display: flex;
294 flex-direction: column;
295 height: 100%;
Sean McCullough86b56862025-04-18 13:04:03 -0700296 }
297
298 /* Active view styles - these will be applied via JavaScript */
299 .view-active {
300 display: flex;
301 flex-direction: column;
302 }
303
304 .title-container {
305 display: flex;
306 flex-direction: column;
307 white-space: nowrap;
308 overflow: hidden;
309 text-overflow: ellipsis;
Josh Bleecher Snydereb5166a2025-04-30 17:04:20 +0000310 max-width: 30%;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000311 padding: 6px 0;
Sean McCullough86b56862025-04-18 13:04:03 -0700312 }
313
314 .refresh-control {
315 display: flex;
316 align-items: center;
317 margin-bottom: 0;
318 flex-wrap: nowrap;
319 white-space: nowrap;
320 flex-shrink: 0;
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000321 gap: 15px;
322 padding-left: 15px;
323 margin-right: 50px;
Sean McCullough86b56862025-04-18 13:04:03 -0700324 }
325
Pokey Rule397871d2025-05-19 15:02:45 +0100326 .stop-button,
327 .end-button {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700328 background: #2196f3;
329 color: white;
330 border: none;
331 padding: 4px 10px;
332 border-radius: 4px;
333 cursor: pointer;
334 font-size: 12px;
335 margin-right: 5px;
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000336 display: flex;
337 align-items: center;
338 gap: 6px;
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700339 }
340
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000341 .stop-button {
342 background: #dc3545;
Sean McCullough86b56862025-04-18 13:04:03 -0700343 color: white;
Sean McCullough86b56862025-04-18 13:04:03 -0700344 }
345
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000346 .stop-button:hover:not(:disabled) {
347 background-color: #c82333;
Sean McCullough86b56862025-04-18 13:04:03 -0700348 }
349
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000350 .stop-button:disabled {
351 background-color: #e9a8ad;
352 cursor: not-allowed;
353 opacity: 0.7;
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000354 }
355
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000356 .stop-button:disabled:hover {
357 background-color: #e9a8ad;
358 }
359
Pokey Rule397871d2025-05-19 15:02:45 +0100360 .end-button {
361 background: #6c757d;
362 color: white;
363 }
364
365 .end-button:hover:not(:disabled) {
366 background-color: #5a6268;
367 }
368
369 .end-button:disabled {
370 background-color: #a9acaf;
371 cursor: not-allowed;
372 opacity: 0.7;
373 }
374
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000375 .button-icon {
376 width: 16px;
377 height: 16px;
378 }
379
380 @media (max-width: 1400px) {
381 .button-text {
382 display: none;
383 }
384
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000385 .stop-button {
386 padding: 6px;
387 }
388 }
389
390 /* Removed poll-updates class */
391
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000392 .notifications-toggle {
Sean McCullough86b56862025-04-18 13:04:03 -0700393 display: flex;
394 align-items: center;
Sean McCullough86b56862025-04-18 13:04:03 -0700395 font-size: 12px;
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000396 margin-right: 10px;
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000397 cursor: pointer;
398 }
399
400 .bell-icon {
401 width: 20px;
402 height: 20px;
403 position: relative;
404 display: inline-flex;
405 align-items: center;
406 justify-content: center;
407 }
408
409 .bell-disabled::before {
410 content: "";
411 position: absolute;
412 width: 2px;
413 height: 24px;
414 background-color: #dc3545;
415 transform: rotate(45deg);
416 transform-origin: center center;
Sean McCullough86b56862025-04-18 13:04:03 -0700417 }
418 `;
419
420 // Header bar: Network connection status details
421 @property()
422 connectionStatus: ConnectionStatus = "disconnected";
Autoformattercf570962025-04-30 17:27:39 +0000423
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000424 // Track if the last commit info has been copied
425 @state()
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000426 // lastCommitCopied moved to sketch-container-status
Sean McCullough86b56862025-04-18 13:04:03 -0700427
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000428 // Track notification preferences
429 @state()
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000430 notificationsEnabled: boolean = false;
431
432 // Track if the window is focused to control notifications
433 @state()
434 private _windowFocused: boolean = document.hasFocus();
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000435
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700436 // Track if the todo panel should be visible
437 @state()
438 private _todoPanelVisible: boolean = false;
439
440 // ResizeObserver for tracking chat input height changes
441 private chatInputResizeObserver: ResizeObserver | null = null;
442
Sean McCullough86b56862025-04-18 13:04:03 -0700443 @property()
444 connectionErrorMessage: string = "";
445
Sean McCullough86b56862025-04-18 13:04:03 -0700446 // Chat messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100447 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700448 messages: AgentMessage[] = [];
Sean McCullough86b56862025-04-18 13:04:03 -0700449
450 @property()
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000451 set title(value: string) {
452 const oldValue = this._title;
453 this._title = value;
454 this.requestUpdate("title", oldValue);
455 // Update document title when title property changes
456 this.updateDocumentTitle();
457 }
458
459 get title(): string {
460 return this._title;
461 }
462
463 private _title: string = "";
Sean McCullough86b56862025-04-18 13:04:03 -0700464
465 private dataManager = new DataManager();
466
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100467 @property({ attribute: false })
Sean McCulloughd9f13372025-04-21 15:08:49 -0700468 containerState: State = {
Philip Zeyligerd03318d2025-05-08 13:09:12 -0700469 state_version: 2,
Sean McCulloughd9f13372025-04-21 15:08:49 -0700470 title: "",
471 os: "",
472 message_count: 0,
473 hostname: "",
474 working_dir: "",
475 initial_commit: "",
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000476 outstanding_llm_calls: 0,
477 outstanding_tool_calls: [],
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000478 session_id: "",
479 ssh_available: false,
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700480 ssh_error: "",
481 in_container: false,
482 first_message_index: 0,
Sean McCulloughd9f13372025-04-21 15:08:49 -0700483 };
Sean McCullough86b56862025-04-18 13:04:03 -0700484
Sean McCullough86b56862025-04-18 13:04:03 -0700485 // Mutation observer to detect when new messages are added
486 private mutationObserver: MutationObserver | null = null;
487
488 constructor() {
489 super();
490
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000491 // Reference to the container status element
492 this.containerStatusElement = null;
493
Sean McCullough86b56862025-04-18 13:04:03 -0700494 // Binding methods to this
495 this._handleViewModeSelect = this._handleViewModeSelect.bind(this);
Sean McCullough34bb09a2025-05-13 15:39:54 -0700496 this._handlePopState = this._handlePopState.bind(this);
Sean McCullough86b56862025-04-18 13:04:03 -0700497 this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
Sean McCullough485afc62025-04-28 14:28:39 -0700498 this._handleMutlipleChoiceSelected =
499 this._handleMutlipleChoiceSelected.bind(this);
Sean McCulloughd3906e22025-04-29 17:32:14 +0000500 this._handleStopClick = this._handleStopClick.bind(this);
Pokey Rule397871d2025-05-19 15:02:45 +0100501 this._handleEndClick = this._handleEndClick.bind(this);
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000502 this._handleNotificationsToggle =
503 this._handleNotificationsToggle.bind(this);
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000504 this._handleWindowFocus = this._handleWindowFocus.bind(this);
505 this._handleWindowBlur = this._handleWindowBlur.bind(this);
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000506
507 // Load notification preference from localStorage
508 try {
509 const savedPref = localStorage.getItem("sketch-notifications-enabled");
510 if (savedPref !== null) {
511 this.notificationsEnabled = savedPref === "true";
512 }
513 } catch (error) {
514 console.error("Error loading notification preference:", error);
515 }
Sean McCullough86b56862025-04-18 13:04:03 -0700516 }
517
518 // See https://lit.dev/docs/components/lifecycle/
519 connectedCallback() {
520 super.connectedCallback();
521
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000522 // Get reference to the container status element
523 setTimeout(() => {
524 this.containerStatusElement =
525 this.shadowRoot?.getElementById("container-status");
526 }, 0);
527
Sean McCullough86b56862025-04-18 13:04:03 -0700528 // Initialize client-side nav history.
529 const url = new URL(window.location.href);
530 const mode = url.searchParams.get("view") || "chat";
531 window.history.replaceState({ mode }, "", url.toString());
532
533 this.toggleViewMode(mode as ViewMode, false);
534 // Add popstate event listener to handle browser back/forward navigation
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100535 window.addEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700536
537 // Add event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100538 window.addEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100539 window.addEventListener("show-commit-diff", this._handleShowCommitDiff);
Sean McCullough86b56862025-04-18 13:04:03 -0700540
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000541 // Add window focus/blur listeners for controlling notifications
542 window.addEventListener("focus", this._handleWindowFocus);
543 window.addEventListener("blur", this._handleWindowBlur);
Sean McCullough485afc62025-04-28 14:28:39 -0700544 window.addEventListener(
545 "multiple-choice-selected",
546 this._handleMutlipleChoiceSelected,
547 );
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000548
Sean McCullough86b56862025-04-18 13:04:03 -0700549 // register event listeners
550 this.dataManager.addEventListener(
551 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700552 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700553 );
554 this.dataManager.addEventListener(
555 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700556 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700557 );
558
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000559 // Set initial document title
560 this.updateDocumentTitle();
561
Sean McCullough86b56862025-04-18 13:04:03 -0700562 // Initialize the data manager
563 this.dataManager.initialize();
Autoformattercf570962025-04-30 17:27:39 +0000564
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000565 // Process existing messages for commit info
566 if (this.messages && this.messages.length > 0) {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000567 // Update last commit info via container status component
568 setTimeout(() => {
569 if (this.containerStatusElement) {
570 this.containerStatusElement.updateLastCommitInfo(this.messages);
571 }
572 }, 100);
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000573 }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700574
575 // Check if todo panel should be visible on initial load
576 this.checkTodoPanelVisibility();
577
578 // Set up ResizeObserver for chat input to update todo panel height
579 this.setupChatInputObserver();
Sean McCullough86b56862025-04-18 13:04:03 -0700580 }
581
582 // See https://lit.dev/docs/components/lifecycle/
583 disconnectedCallback() {
584 super.disconnectedCallback();
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100585 window.removeEventListener("popstate", this._handlePopState);
Sean McCullough86b56862025-04-18 13:04:03 -0700586
587 // Remove event listeners
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100588 window.removeEventListener("view-mode-select", this._handleViewModeSelect);
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100589 window.removeEventListener("show-commit-diff", this._handleShowCommitDiff);
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000590 window.removeEventListener("focus", this._handleWindowFocus);
591 window.removeEventListener("blur", this._handleWindowBlur);
Sean McCullough485afc62025-04-28 14:28:39 -0700592 window.removeEventListener(
593 "multiple-choice-selected",
594 this._handleMutlipleChoiceSelected,
595 );
Sean McCullough86b56862025-04-18 13:04:03 -0700596
597 // unregister data manager event listeners
598 this.dataManager.removeEventListener(
599 "dataChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700600 this.handleDataChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700601 );
602 this.dataManager.removeEventListener(
603 "connectionStatusChanged",
Philip Zeyliger72682df2025-04-23 13:09:46 -0700604 this.handleConnectionStatusChanged.bind(this),
Sean McCullough86b56862025-04-18 13:04:03 -0700605 );
606
607 // Disconnect mutation observer if it exists
608 if (this.mutationObserver) {
Sean McCullough86b56862025-04-18 13:04:03 -0700609 this.mutationObserver.disconnect();
610 this.mutationObserver = null;
611 }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700612
613 // Disconnect chat input resize observer if it exists
614 if (this.chatInputResizeObserver) {
615 this.chatInputResizeObserver.disconnect();
616 this.chatInputResizeObserver = null;
617 }
Sean McCullough86b56862025-04-18 13:04:03 -0700618 }
619
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700620 updateUrlForViewMode(mode: ViewMode): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700621 // Get the current URL without search parameters
622 const url = new URL(window.location.href);
623
624 // Clear existing parameters
625 url.search = "";
626
627 // Only add view parameter if not in default chat view
628 if (mode !== "chat") {
629 url.searchParams.set("view", mode);
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000630 const diff2View = this.shadowRoot?.querySelector(
631 "sketch-diff2-view",
632 ) as SketchDiff2View;
Sean McCullough86b56862025-04-18 13:04:03 -0700633
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000634 // If in diff2 view and there's a commit hash, include that too
635 if (mode === "diff2" && diff2View?.commit) {
636 url.searchParams.set("commit", diff2View.commit);
Sean McCullough86b56862025-04-18 13:04:03 -0700637 }
638 }
639
640 // Update the browser history without reloading the page
641 window.history.pushState({ mode }, "", url.toString());
642 }
643
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100644 private _handlePopState(event: PopStateEvent) {
Sean McCullough86b56862025-04-18 13:04:03 -0700645 if (event.state && event.state.mode) {
646 this.toggleViewMode(event.state.mode, false);
647 } else {
648 this.toggleViewMode("chat", false);
649 }
650 }
651
652 /**
653 * Handle view mode selection event
654 */
655 private _handleViewModeSelect(event: CustomEvent) {
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000656 const mode = event.detail.mode as "chat" | "diff2" | "terminal";
Sean McCullough86b56862025-04-18 13:04:03 -0700657 this.toggleViewMode(mode, true);
658 }
659
660 /**
661 * Handle show commit diff event
662 */
663 private _handleShowCommitDiff(event: CustomEvent) {
664 const { commitHash } = event.detail;
665 if (commitHash) {
666 this.showCommitDiff(commitHash);
667 }
668 }
669
Sean McCullough485afc62025-04-28 14:28:39 -0700670 private _handleMultipleChoice(event: CustomEvent) {
671 window.console.log("_handleMultipleChoice", event);
672 this._sendChat;
673 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700674
675 private _handleDiffComment(event: CustomEvent) {
676 // Empty stub required by the event binding in the template
677 // Actual handling occurs at global level in sketch-chat-input component
678 }
Sean McCullough86b56862025-04-18 13:04:03 -0700679 /**
Sean McCullough86b56862025-04-18 13:04:03 -0700680 * Listen for commit diff event
681 * @param commitHash The commit hash to show diff for
682 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100683 private showCommitDiff(commitHash: string): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700684 // Store the commit hash
685 this.currentCommitHash = commitHash;
686
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700687 this.toggleViewMode("diff2", true);
Sean McCullough86b56862025-04-18 13:04:03 -0700688
Sean McCullough86b56862025-04-18 13:04:03 -0700689 this.updateComplete.then(() => {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700690 const diff2View = this.shadowRoot?.querySelector("sketch-diff2-view");
691 if (diff2View) {
692 (diff2View as SketchDiff2View).refreshDiffView();
Sean McCullough86b56862025-04-18 13:04:03 -0700693 }
694 });
695 }
696
697 /**
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000698 * Toggle between different view modes: chat, diff2, terminal
Sean McCullough86b56862025-04-18 13:04:03 -0700699 */
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100700 private toggleViewMode(mode: ViewMode, updateHistory: boolean): void {
Sean McCullough86b56862025-04-18 13:04:03 -0700701 // Don't do anything if the mode is already active
702 if (this.viewMode === mode) return;
703
704 // Update the view mode
705 this.viewMode = mode;
706
707 if (updateHistory) {
708 // Update URL with the current view mode
709 this.updateUrlForViewMode(mode);
710 }
711
712 // Wait for DOM update to complete
713 this.updateComplete.then(() => {
714 // Update active view
Pokey Rule46fff972025-04-25 14:57:44 +0100715 const viewContainerInner = this.shadowRoot?.querySelector(
716 "#view-container-inner",
717 );
Sean McCullough86b56862025-04-18 13:04:03 -0700718 const chatView = this.shadowRoot?.querySelector(".chat-view");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700719 const diff2View = this.shadowRoot?.querySelector(".diff2-view");
Sean McCullough86b56862025-04-18 13:04:03 -0700720 const terminalView = this.shadowRoot?.querySelector(".terminal-view");
721
722 // Remove active class from all views
723 chatView?.classList.remove("view-active");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700724 diff2View?.classList.remove("view-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700725 terminalView?.classList.remove("view-active");
726
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000727 // Add/remove diff2-active class on view container
728 if (mode === "diff2") {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700729 viewContainerInner?.classList.add("diff2-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700730 } else {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700731 viewContainerInner?.classList.remove("diff2-active");
Sean McCullough86b56862025-04-18 13:04:03 -0700732 }
733
734 // Add active class to the selected view
735 switch (mode) {
736 case "chat":
737 chatView?.classList.add("view-active");
738 break;
Autoformatter8c463622025-05-16 21:54:17 +0000739
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700740 case "diff2":
741 diff2View?.classList.add("view-active");
742 // Refresh git/recentlog when Monaco diff view is opened
743 // This ensures branch information is always up-to-date, as branches can change frequently
Autoformatter8c463622025-05-16 21:54:17 +0000744 const diff2ViewComp =
745 this.shadowRoot?.querySelector("sketch-diff2-view");
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700746 if (diff2ViewComp) {
747 (diff2ViewComp as SketchDiff2View).refreshDiffView();
748 }
749 break;
Philip Zeyliger2d4c48f2025-05-02 23:35:03 +0000750
Sean McCullough86b56862025-04-18 13:04:03 -0700751 case "terminal":
752 terminalView?.classList.add("view-active");
753 break;
754 }
755
756 // Update view mode buttons
757 const viewModeSelect = this.shadowRoot?.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700758 "sketch-view-mode-select",
Sean McCullough86b56862025-04-18 13:04:03 -0700759 );
760 if (viewModeSelect) {
761 const event = new CustomEvent("update-active-mode", {
762 detail: { mode },
763 bubbles: true,
764 composed: true,
765 });
766 viewModeSelect.dispatchEvent(event);
767 }
Sean McCullough86b56862025-04-18 13:04:03 -0700768 });
769 }
770
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000771 /**
772 * Updates the document title based on current title and connection status
773 */
774 private updateDocumentTitle(): void {
775 let docTitle = `sk: ${this.title || "untitled"}`;
776
777 // Add red circle emoji if disconnected
778 if (this.connectionStatus === "disconnected") {
779 docTitle += " 🔴";
780 }
781
782 document.title = docTitle;
783 }
784
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000785 // Check and request notification permission if needed
786 private async checkNotificationPermission(): Promise<boolean> {
787 // Check if the Notification API is supported
788 if (!("Notification" in window)) {
789 console.log("This browser does not support notifications");
790 return false;
791 }
792
793 // Check if permission is already granted
794 if (Notification.permission === "granted") {
795 return true;
796 }
797
798 // If permission is not denied, request it
799 if (Notification.permission !== "denied") {
800 const permission = await Notification.requestPermission();
801 return permission === "granted";
802 }
803
804 return false;
805 }
806
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000807 // Handle notifications toggle click
808 private _handleNotificationsToggle(): void {
809 this.notificationsEnabled = !this.notificationsEnabled;
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000810
811 // If enabling notifications, check permissions
812 if (this.notificationsEnabled) {
813 this.checkNotificationPermission();
814 }
815
816 // Save preference to localStorage
817 try {
818 localStorage.setItem(
819 "sketch-notifications-enabled",
820 String(this.notificationsEnabled),
821 );
822 } catch (error) {
823 console.error("Error saving notification preference:", error);
824 }
825 }
826
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000827 // Handle window focus event
828 private _handleWindowFocus(): void {
829 this._windowFocused = true;
830 }
831
832 // Handle window blur event
833 private _handleWindowBlur(): void {
834 this._windowFocused = false;
835 }
836
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000837 // Show notification for message with EndOfTurn=true
838 private async showEndOfTurnNotification(
839 message: AgentMessage,
840 ): Promise<void> {
841 // Don't show notifications if they're disabled
842 if (!this.notificationsEnabled) return;
843
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000844 // Don't show notifications if the window is focused
845 if (this._windowFocused) return;
846
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000847 // Check if we have permission to show notifications
848 const hasPermission = await this.checkNotificationPermission();
849 if (!hasPermission) return;
850
Philip Zeyliger32011332025-04-30 20:59:40 +0000851 // Only show notifications for agent messages with end_of_turn=true and no parent_conversation_id
852 if (
853 message.type !== "agent" ||
854 !message.end_of_turn ||
855 message.parent_conversation_id
856 )
857 return;
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000858
859 // Create a title that includes the sketch title
860 const notificationTitle = `Sketch: ${this.title || "untitled"}`;
861
862 // Extract the beginning of the message content (first 100 chars)
863 const messagePreview = message.content
864 ? message.content.substring(0, 100) +
865 (message.content.length > 100 ? "..." : "")
866 : "Agent has completed its turn";
867
868 // Create and show the notification
869 try {
870 new Notification(notificationTitle, {
871 body: messagePreview,
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +0000872 icon: "https://sketch.dev/favicon.ico", // Use sketch.dev favicon for notification
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000873 });
874 } catch (error) {
875 console.error("Error showing notification:", error);
876 }
877 }
878
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700879 // Check if todo panel should be visible based on latest todo content from messages or state
880 private checkTodoPanelVisibility(): void {
881 // Find the latest todo content from messages first
882 let latestTodoContent = "";
883 for (let i = this.messages.length - 1; i >= 0; i--) {
884 const message = this.messages[i];
885 if (message.todo_content !== undefined) {
886 latestTodoContent = message.todo_content || "";
887 break;
888 }
889 }
890
891 // If no todo content found in messages, check the current state
892 if (latestTodoContent === "" && this.containerState?.todo_content) {
893 latestTodoContent = this.containerState.todo_content;
894 }
895
896 // Parse the todo data to check if there are any actual todos
897 let hasTodos = false;
898 if (latestTodoContent.trim()) {
899 try {
900 const todoData = JSON.parse(latestTodoContent);
901 hasTodos = todoData.items && todoData.items.length > 0;
902 } catch (error) {
903 // Invalid JSON, treat as no todos
904 hasTodos = false;
905 }
906 }
907
908 this._todoPanelVisible = hasTodos;
909
910 // Update todo panel content if visible
911 if (hasTodos) {
912 const todoPanel = this.shadowRoot?.querySelector(
913 "sketch-todo-panel",
914 ) as any;
915 if (todoPanel && todoPanel.updateTodoContent) {
916 todoPanel.updateTodoContent(latestTodoContent);
917 }
918 }
919 }
920
Sean McCullough86b56862025-04-18 13:04:03 -0700921 private handleDataChanged(eventData: {
922 state: State;
Sean McCulloughd9f13372025-04-21 15:08:49 -0700923 newMessages: AgentMessage[];
Sean McCullough86b56862025-04-18 13:04:03 -0700924 }): void {
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000925 const { state, newMessages } = eventData;
Sean McCullough86b56862025-04-18 13:04:03 -0700926
927 // Update state if we received it
928 if (state) {
Josh Bleecher Snydere81233f2025-04-30 04:05:41 +0000929 // Ensure we're using the latest call status to prevent indicators from being stuck
Autoformatterf830c9d2025-04-30 18:16:01 +0000930 if (
931 state.outstanding_llm_calls === 0 &&
932 state.outstanding_tool_calls.length === 0
933 ) {
Josh Bleecher Snydere81233f2025-04-30 04:05:41 +0000934 // Force reset containerState calls when nothing is reported as in progress
935 state.outstanding_llm_calls = 0;
936 state.outstanding_tool_calls = [];
937 }
Autoformatterf830c9d2025-04-30 18:16:01 +0000938
Sean McCullough86b56862025-04-18 13:04:03 -0700939 this.containerState = state;
940 this.title = state.title;
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000941
942 // Update document title when sketch title changes
943 this.updateDocumentTitle();
Sean McCullough86b56862025-04-18 13:04:03 -0700944 }
945
Sean McCullough86b56862025-04-18 13:04:03 -0700946 // Update messages
Pokey Rulee2a8c2f2025-04-23 15:09:25 +0100947 this.messages = aggregateAgentMessages(this.messages, newMessages);
Autoformattercf570962025-04-30 17:27:39 +0000948
Philip Zeyliger47b71c92025-04-30 15:43:39 +0000949 // Process new messages to find commit messages
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000950 // Update last commit info via container status component
951 if (this.containerStatusElement) {
952 this.containerStatusElement.updateLastCommitInfo(newMessages);
953 }
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000954
955 // Check for agent messages with end_of_turn=true and show notifications
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000956 if (newMessages && newMessages.length > 0) {
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000957 for (const message of newMessages) {
Philip Zeyliger32011332025-04-30 20:59:40 +0000958 if (
959 message.type === "agent" &&
960 message.end_of_turn &&
961 !message.parent_conversation_id
962 ) {
Philip Zeyligerbc6b6292025-04-30 18:00:15 +0000963 this.showEndOfTurnNotification(message);
964 break; // Only show one notification per batch of messages
965 }
966 }
967 }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700968
969 // Check if todo panel should be visible after agent loop iteration
970 this.checkTodoPanelVisibility();
971
972 // Ensure chat input observer is set up when new data comes in
973 if (!this.chatInputResizeObserver) {
974 this.setupChatInputObserver();
975 }
Sean McCullough86b56862025-04-18 13:04:03 -0700976 }
977
978 private handleConnectionStatusChanged(
979 status: ConnectionStatus,
Philip Zeyliger72682df2025-04-23 13:09:46 -0700980 errorMessage?: string,
Sean McCullough86b56862025-04-18 13:04:03 -0700981 ): void {
982 this.connectionStatus = status;
983 this.connectionErrorMessage = errorMessage || "";
Philip Zeyliger9b999b02025-04-25 16:31:50 +0000984
985 // Update document title when connection status changes
986 this.updateDocumentTitle();
Sean McCullough86b56862025-04-18 13:04:03 -0700987 }
988
Sean McCulloughd3906e22025-04-29 17:32:14 +0000989 private async _handleStopClick(): Promise<void> {
990 try {
991 const response = await fetch("cancel", {
992 method: "POST",
993 headers: {
994 "Content-Type": "application/json",
995 },
996 body: JSON.stringify({ reason: "user requested cancellation" }),
997 });
998
999 if (!response.ok) {
1000 const errorData = await response.text();
1001 throw new Error(
1002 `Failed to stop operation: ${response.status} - ${errorData}`,
1003 );
1004 }
1005
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001006 // Stop request sent
Sean McCulloughd3906e22025-04-29 17:32:14 +00001007 } catch (error) {
1008 console.error("Error stopping operation:", error);
Sean McCulloughd3906e22025-04-29 17:32:14 +00001009 }
1010 }
1011
Pokey Rule397871d2025-05-19 15:02:45 +01001012 private async _handleEndClick(event?: Event): Promise<void> {
1013 if (event) {
1014 event.preventDefault();
1015 event.stopPropagation();
1016 }
Philip Zeyligerb5739402025-06-02 07:04:34 -07001017
1018 // Show custom dialog with survey
1019 const surveyResult = await this.showEndSessionSurvey();
1020 if (!surveyResult) return; // User cancelled
Pokey Rule397871d2025-05-19 15:02:45 +01001021
1022 try {
Philip Zeyligerb5739402025-06-02 07:04:34 -07001023 const requestBody: any = { reason: "user requested end of session" };
1024
1025 // Add survey data if provided
1026 if (surveyResult.happy !== null) {
1027 requestBody.happy = surveyResult.happy;
1028 requestBody.comment = surveyResult.comment;
1029 }
1030
Pokey Rule397871d2025-05-19 15:02:45 +01001031 const response = await fetch("end", {
1032 method: "POST",
1033 headers: {
1034 "Content-Type": "application/json",
1035 },
Philip Zeyligerb5739402025-06-02 07:04:34 -07001036 body: JSON.stringify(requestBody),
Pokey Rule397871d2025-05-19 15:02:45 +01001037 });
1038
1039 if (!response.ok) {
1040 const errorData = await response.text();
1041 throw new Error(
1042 `Failed to end session: ${response.status} - ${errorData}`,
1043 );
1044 }
1045
1046 // After successful response, redirect to messages view
1047 // Extract the session ID from the URL
1048 const currentUrl = window.location.href;
1049 // The URL pattern should be like https://sketch.dev/s/cs71-8qa6-1124-aw79/
1050 const urlParts = currentUrl.split("/");
1051 let sessionId = "";
1052
1053 // Find the session ID in the URL (should be after /s/)
1054 for (let i = 0; i < urlParts.length; i++) {
1055 if (urlParts[i] === "s" && i + 1 < urlParts.length) {
1056 sessionId = urlParts[i + 1];
1057 break;
1058 }
1059 }
1060
1061 if (sessionId) {
1062 // Create the messages URL
1063 const messagesUrl = `/messages/${sessionId}`;
1064 // Redirect to messages view
1065 window.location.href = messagesUrl;
1066 }
1067
1068 // End request sent - connection will be closed by server
1069 } catch (error) {
1070 console.error("Error ending session:", error);
1071 }
1072 }
1073
Sean McCullough485afc62025-04-28 14:28:39 -07001074 async _handleMutlipleChoiceSelected(e: CustomEvent) {
1075 const chatInput = this.shadowRoot?.querySelector(
1076 "sketch-chat-input",
1077 ) as SketchChatInput;
1078 if (chatInput) {
Josh Bleecher Snyder6cad8612025-05-30 19:25:39 +00001079 if (chatInput.content && chatInput.content.trim() !== "") {
1080 chatInput.content += "\n\n";
1081 }
1082 chatInput.content += e.detail.responseText;
Sean McCullough485afc62025-04-28 14:28:39 -07001083 chatInput.focus();
Josh Bleecher Snyder6cad8612025-05-30 19:25:39 +00001084 // Adjust textarea height to accommodate new content
1085 requestAnimationFrame(() => {
1086 if (chatInput.adjustChatSpacing) {
1087 chatInput.adjustChatSpacing();
1088 }
1089 });
Sean McCullough485afc62025-04-28 14:28:39 -07001090 }
1091 }
1092
Sean McCullough86b56862025-04-18 13:04:03 -07001093 async _sendChat(e: CustomEvent) {
1094 console.log("app shell: _sendChat", e);
Sean McCullough485afc62025-04-28 14:28:39 -07001095 e.preventDefault();
1096 e.stopPropagation();
Sean McCullough86b56862025-04-18 13:04:03 -07001097 const message = e.detail.message?.trim();
1098 if (message == "") {
1099 return;
1100 }
1101 try {
Josh Bleecher Snyder98b64d12025-05-12 19:42:43 +00001102 // Always switch to chat view when sending a message so user can see processing
1103 if (this.viewMode !== "chat") {
1104 this.toggleViewMode("chat", true);
1105 }
Autoformatter5c7f9572025-05-13 01:17:31 +00001106
Sean McCullough86b56862025-04-18 13:04:03 -07001107 // Send the message to the server
1108 const response = await fetch("chat", {
1109 method: "POST",
1110 headers: {
1111 "Content-Type": "application/json",
1112 },
1113 body: JSON.stringify({ message }),
1114 });
1115
1116 if (!response.ok) {
1117 const errorData = await response.text();
1118 throw new Error(`Server error: ${response.status} - ${errorData}`);
1119 }
Sean McCullough86b56862025-04-18 13:04:03 -07001120 } catch (error) {
1121 console.error("Error sending chat message:", error);
1122 const statusText = document.getElementById("statusText");
1123 if (statusText) {
1124 statusText.textContent = "Error sending message";
1125 }
1126 }
1127 }
1128
Pokey Rule4097e532025-04-24 18:55:28 +01001129 private scrollContainerRef = createRef<HTMLElement>();
1130
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001131 /**
1132 * Set up ResizeObserver to monitor chat input height changes
1133 */
1134 private setupChatInputObserver(): void {
1135 // Wait for DOM to be ready
1136 this.updateComplete.then(() => {
1137 const chatInputElement = this.shadowRoot?.querySelector("#chat-input");
1138 if (chatInputElement && !this.chatInputResizeObserver) {
1139 this.chatInputResizeObserver = new ResizeObserver((entries) => {
1140 for (const entry of entries) {
1141 this.updateTodoPanelHeight(entry.contentRect.height);
1142 }
1143 });
1144
1145 this.chatInputResizeObserver.observe(chatInputElement);
1146
1147 // Initial height calculation
1148 const rect = chatInputElement.getBoundingClientRect();
1149 this.updateTodoPanelHeight(rect.height);
1150 }
1151 });
1152 }
1153
1154 /**
1155 * Update the CSS custom property that controls todo panel bottom position
1156 */
1157 private updateTodoPanelHeight(chatInputHeight: number): void {
1158 // Add some padding (20px) between todo panel and chat input
1159 const bottomOffset = chatInputHeight;
1160
1161 // Update the CSS custom property on the host element
1162 this.style.setProperty("--chat-input-height", `${bottomOffset}px`);
1163 }
1164
Philip Zeyligerb5739402025-06-02 07:04:34 -07001165 /**
1166 * Show end session survey dialog
1167 */
1168 private async showEndSessionSurvey(): Promise<{
1169 happy: boolean | null;
1170 comment: string;
1171 } | null> {
1172 return new Promise((resolve) => {
1173 // Create modal overlay
1174 const overlay = document.createElement("div");
1175 overlay.style.cssText = `
1176 position: fixed;
1177 top: 0;
1178 left: 0;
1179 right: 0;
1180 bottom: 0;
1181 background: rgba(0, 0, 0, 0.5);
1182 display: flex;
1183 align-items: center;
1184 justify-content: center;
1185 z-index: 10000;
1186 `;
1187
1188 // Create modal content
1189 const modal = document.createElement("div");
1190 modal.style.cssText = `
1191 background: white;
1192 border-radius: 8px;
1193 padding: 24px;
1194 max-width: 500px;
1195 width: 90%;
1196 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1197 `;
1198
1199 modal.innerHTML = `
1200 <h3 style="margin: 0 0 16px 0; font-size: 18px; font-weight: 600;">End Session</h3>
1201 <p style="margin: 0 0 20px 0; color: #666;">Ending the session will shut down the underlying container. Are you sure?</p>
1202
1203 <div style="margin-bottom: 20px;">
1204 <p style="margin: 0 0 12px 0; font-weight: 500;">How was your experience?</p>
1205 <div style="display: flex; gap: 12px; margin-bottom: 16px;">
1206 <button id="thumbs-up" style="
1207 background: #f8f9fa;
1208 border: 2px solid #dee2e6;
1209 border-radius: 6px;
1210 padding: 8px 16px;
1211 cursor: pointer;
1212 display: flex;
1213 align-items: center;
1214 gap: 8px;
1215 font-size: 14px;
1216 ">
1217 👍 Good
1218 </button>
1219 <button id="thumbs-down" style="
1220 background: #f8f9fa;
1221 border: 2px solid #dee2e6;
1222 border-radius: 6px;
1223 padding: 8px 16px;
1224 cursor: pointer;
1225 display: flex;
1226 align-items: center;
1227 gap: 8px;
1228 font-size: 14px;
1229 ">
1230 👎 Not so good
1231 </button>
1232 </div>
1233
1234 <label style="display: block; margin-bottom: 8px; font-weight: 500;">Any additional feedback? (optional)</label>
1235 <textarea id="feedback-text" placeholder="Tell us what went well or what could be improved..." style="
1236 width: 100%;
1237 min-height: 80px;
1238 padding: 8px;
1239 border: 1px solid #ccc;
1240 border-radius: 4px;
1241 resize: vertical;
1242 font-family: inherit;
1243 font-size: 14px;
1244 box-sizing: border-box;
1245 "></textarea>
1246 </div>
1247
1248 <div style="display: flex; gap: 12px; justify-content: flex-end;">
1249 <button id="cancel-btn" style="
1250 background: #f8f9fa;
1251 border: 1px solid #dee2e6;
1252 color: #495057;
1253 padding: 8px 16px;
1254 border-radius: 4px;
1255 cursor: pointer;
1256 ">Cancel</button>
1257 <button id="end-btn" style="
1258 background: #dc3545;
1259 border: 1px solid #dc3545;
1260 color: white;
1261 padding: 8px 16px;
1262 border-radius: 4px;
1263 cursor: pointer;
1264 ">End Session</button>
1265 </div>
1266 `;
1267
1268 overlay.appendChild(modal);
1269 document.body.appendChild(overlay);
1270
1271 let selectedRating: boolean | null = null;
1272
1273 // Handle thumbs up/down selection
1274 const thumbsUp = modal.querySelector("#thumbs-up") as HTMLButtonElement;
1275 const thumbsDown = modal.querySelector(
1276 "#thumbs-down",
1277 ) as HTMLButtonElement;
1278 const feedbackText = modal.querySelector(
1279 "#feedback-text",
1280 ) as HTMLTextAreaElement;
1281 const cancelBtn = modal.querySelector("#cancel-btn") as HTMLButtonElement;
1282 const endBtn = modal.querySelector("#end-btn") as HTMLButtonElement;
1283
1284 const updateButtonStyles = () => {
1285 thumbsUp.style.background =
1286 selectedRating === true ? "#d4edda" : "#f8f9fa";
1287 thumbsUp.style.borderColor =
1288 selectedRating === true ? "#28a745" : "#dee2e6";
1289 thumbsDown.style.background =
1290 selectedRating === false ? "#f8d7da" : "#f8f9fa";
1291 thumbsDown.style.borderColor =
1292 selectedRating === false ? "#dc3545" : "#dee2e6";
1293 };
1294
1295 thumbsUp.addEventListener("click", () => {
1296 selectedRating = true;
1297 updateButtonStyles();
1298 });
1299
1300 thumbsDown.addEventListener("click", () => {
1301 selectedRating = false;
1302 updateButtonStyles();
1303 });
1304
1305 cancelBtn.addEventListener("click", () => {
1306 document.body.removeChild(overlay);
1307 resolve(null);
1308 });
1309
1310 endBtn.addEventListener("click", () => {
1311 const result = {
1312 happy: selectedRating,
1313 comment: feedbackText.value.trim(),
1314 };
1315 document.body.removeChild(overlay);
1316 resolve(result);
1317 });
1318
1319 // Close on overlay click
1320 overlay.addEventListener("click", (e) => {
1321 if (e.target === overlay) {
1322 document.body.removeChild(overlay);
1323 resolve(null);
1324 }
1325 });
1326
1327 // Focus the modal
1328 modal.focus();
1329 });
1330 }
1331
Sean McCullough86b56862025-04-18 13:04:03 -07001332 render() {
1333 return html`
Pokey Rule4097e532025-04-24 18:55:28 +01001334 <div id="top-banner">
Sean McCullough86b56862025-04-18 13:04:03 -07001335 <div class="title-container">
1336 <h1 class="banner-title">sketch</h1>
1337 <h2 id="chatTitle" class="chat-title">${this.title}</h2>
1338 </div>
1339
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001340 <!-- Container status info moved above tabs -->
Sean McCullough86b56862025-04-18 13:04:03 -07001341 <sketch-container-status
1342 .state=${this.containerState}
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001343 id="container-status"
Sean McCullough86b56862025-04-18 13:04:03 -07001344 ></sketch-container-status>
Autoformattercf570962025-04-30 17:27:39 +00001345
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001346 <!-- Last Commit section moved to sketch-container-status -->
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001347
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001348 <!-- Views section with tabs -->
1349 <sketch-view-mode-select></sketch-view-mode-select>
Sean McCullough86b56862025-04-18 13:04:03 -07001350
1351 <div class="refresh-control">
Sean McCulloughd3906e22025-04-29 17:32:14 +00001352 <button
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001353 id="stopButton"
1354 class="stop-button"
1355 ?disabled=${(this.containerState?.outstanding_llm_calls || 0) ===
1356 0 &&
1357 (this.containerState?.outstanding_tool_calls || []).length === 0}
1358 >
1359 <svg
1360 class="button-icon"
1361 xmlns="http://www.w3.org/2000/svg"
1362 viewBox="0 0 24 24"
1363 fill="none"
1364 stroke="currentColor"
1365 stroke-width="2"
1366 stroke-linecap="round"
1367 stroke-linejoin="round"
1368 >
1369 <rect x="6" y="6" width="12" height="12" />
1370 </svg>
1371 <span class="button-text">Stop</span>
Sean McCullough86b56862025-04-18 13:04:03 -07001372 </button>
Pokey Rule397871d2025-05-19 15:02:45 +01001373 <button
1374 id="endButton"
1375 class="end-button"
1376 @click=${this._handleEndClick}
1377 >
1378 <svg
1379 class="button-icon"
1380 xmlns="http://www.w3.org/2000/svg"
1381 viewBox="0 0 24 24"
1382 fill="none"
1383 stroke="currentColor"
1384 stroke-width="2"
1385 stroke-linecap="round"
1386 stroke-linejoin="round"
1387 >
1388 <path d="M18 6L6 18" />
1389 <path d="M6 6l12 12" />
1390 </svg>
1391 <span class="button-text">End</span>
1392 </button>
Sean McCullough86b56862025-04-18 13:04:03 -07001393
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +00001394 <div
1395 class="notifications-toggle"
1396 @click=${this._handleNotificationsToggle}
1397 title="${this.notificationsEnabled
1398 ? "Disable"
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001399 : "Enable"} notifications when the agent completes its turn"
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +00001400 >
1401 <div
1402 class="bell-icon ${!this.notificationsEnabled
1403 ? "bell-disabled"
1404 : ""}"
1405 >
1406 <!-- Bell SVG icon -->
1407 <svg
1408 xmlns="http://www.w3.org/2000/svg"
1409 width="16"
1410 height="16"
1411 fill="currentColor"
1412 viewBox="0 0 16 16"
1413 >
1414 <path
1415 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"
1416 />
1417 </svg>
1418 </div>
Philip Zeyligerbc6b6292025-04-30 18:00:15 +00001419 </div>
1420
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001421 <sketch-call-status
Sean McCulloughd9d45812025-04-30 16:53:41 -07001422 .agentState=${this.containerState?.agent_state}
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001423 .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
1424 .toolCalls=${this.containerState?.outstanding_tool_calls || []}
Philip Zeyliger72318392025-05-14 02:56:07 +00001425 .isIdle=${this.messages.length > 0
1426 ? this.messages[this.messages.length - 1]?.end_of_turn &&
1427 !this.messages[this.messages.length - 1]?.parent_conversation_id
1428 : true}
Philip Zeyliger5e357022025-05-16 04:50:34 +00001429 .isDisconnected=${this.connectionStatus === "disconnected"}
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001430 ></sketch-call-status>
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001431
1432 <sketch-network-status
1433 connection=${this.connectionStatus}
1434 error=${this.connectionErrorMessage}
1435 ></sketch-network-status>
Sean McCullough86b56862025-04-18 13:04:03 -07001436 </div>
1437 </div>
1438
Pokey Rule4097e532025-04-24 18:55:28 +01001439 <div id="view-container" ${ref(this.scrollContainerRef)}>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001440 <div
1441 id="view-container-inner"
1442 class="${this._todoPanelVisible && this.viewMode === "chat"
1443 ? "with-todo-panel"
1444 : ""}"
1445 >
Pokey Rule4097e532025-04-24 18:55:28 +01001446 <div
1447 class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}"
1448 >
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001449 <div
1450 class="chat-timeline-container ${this._todoPanelVisible &&
1451 this.viewMode === "chat"
1452 ? "with-todo-panel"
1453 : ""}"
1454 >
1455 <sketch-timeline
1456 .messages=${this.messages}
1457 .scrollContainer=${this.scrollContainerRef}
1458 .agentState=${this.containerState?.agent_state}
1459 .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
1460 .toolCalls=${this.containerState?.outstanding_tool_calls || []}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001461 .firstMessageIndex=${this.containerState?.first_message_index ||
1462 0}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001463 ></sketch-timeline>
1464 </div>
1465 </div>
1466
1467 <!-- Todo panel positioned outside the main flow - only visible in chat view -->
1468 <div
1469 class="todo-panel-container ${this._todoPanelVisible &&
1470 this.viewMode === "chat"
1471 ? "visible"
1472 : ""}"
1473 >
1474 <sketch-todo-panel
1475 .visible=${this._todoPanelVisible && this.viewMode === "chat"}
1476 ></sketch-todo-panel>
Pokey Rule4097e532025-04-24 18:55:28 +01001477 </div>
1478 <div
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001479 class="diff2-view ${this.viewMode === "diff2" ? "view-active" : ""}"
1480 >
1481 <sketch-diff2-view
1482 .commit=${this.currentCommitHash}
1483 .gitService=${new DefaultGitDataService()}
1484 @diff-comment="${this._handleDiffComment}"
1485 ></sketch-diff2-view>
1486 </div>
Philip Zeyliger2d4c48f2025-05-02 23:35:03 +00001487
Pokey Rule4097e532025-04-24 18:55:28 +01001488 <div
1489 class="terminal-view ${this.viewMode === "terminal"
1490 ? "view-active"
1491 : ""}"
1492 >
1493 <sketch-terminal></sketch-terminal>
1494 </div>
Sean McCullough86b56862025-04-18 13:04:03 -07001495 </div>
1496 </div>
1497
Pokey Rule4097e532025-04-24 18:55:28 +01001498 <div id="chat-input">
1499 <sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
1500 </div>
Sean McCullough86b56862025-04-18 13:04:03 -07001501 `;
1502 }
1503
1504 /**
Sean McCullough86b56862025-04-18 13:04:03 -07001505 * Lifecycle callback when component is first connected to DOM
1506 */
1507 firstUpdated(): void {
1508 if (this.viewMode !== "chat") {
1509 return;
1510 }
1511
1512 // Initial scroll to bottom when component is first rendered
1513 setTimeout(
1514 () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
Philip Zeyliger72682df2025-04-23 13:09:46 -07001515 50,
Sean McCullough86b56862025-04-18 13:04:03 -07001516 );
1517
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001518 // Setup stop button
1519 const stopButton = this.renderRoot?.querySelector(
1520 "#stopButton",
1521 ) as HTMLButtonElement;
1522 stopButton?.addEventListener("click", async () => {
1523 try {
Sean McCullough495cb962025-05-01 16:25:53 -07001524 const response = await fetch("cancel", {
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001525 method: "POST",
1526 headers: {
1527 "Content-Type": "application/json",
1528 },
1529 body: JSON.stringify({ reason: "User clicked stop button" }),
1530 });
1531 if (!response.ok) {
1532 console.error("Failed to cancel:", await response.text());
1533 }
1534 } catch (error) {
1535 console.error("Error cancelling operation:", error);
1536 }
1537 });
1538
Pokey Rule397871d2025-05-19 15:02:45 +01001539 // Setup end button
1540 const endButton = this.renderRoot?.querySelector(
1541 "#endButton",
1542 ) as HTMLButtonElement;
1543 // We're already using the @click binding in the HTML, so manual event listener not needed here
1544
Philip Zeyliger47b71c92025-04-30 15:43:39 +00001545 // Process any existing messages to find commit information
1546 if (this.messages && this.messages.length > 0) {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001547 // Update last commit info via container status component
1548 if (this.containerStatusElement) {
1549 this.containerStatusElement.updateLastCommitInfo(this.messages);
1550 }
Philip Zeyliger47b71c92025-04-30 15:43:39 +00001551 }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001552
1553 // Set up chat input height observer for todo panel
1554 this.setupChatInputObserver();
Sean McCullough86b56862025-04-18 13:04:03 -07001555 }
1556}
1557
1558declare global {
1559 interface HTMLElementTagNameMap {
1560 "sketch-app-shell": SketchAppShell;
1561 }
1562}