blob: 07c355df9beccffe71b9ffe2841616ca1e62d60e [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
Philip Zeyliger16098932025-06-04 11:02:55 -07001018 // Show confirmation dialog
1019 const confirmed = window.confirm(
1020 "Ending the session will shut down the underlying container. Are you sure?",
1021 );
1022 if (!confirmed) return;
Pokey Rule397871d2025-05-19 15:02:45 +01001023
1024 try {
1025 const response = await fetch("end", {
1026 method: "POST",
1027 headers: {
1028 "Content-Type": "application/json",
1029 },
Philip Zeyliger16098932025-06-04 11:02:55 -07001030 body: JSON.stringify({ reason: "user requested end of session" }),
Pokey Rule397871d2025-05-19 15:02:45 +01001031 });
1032
1033 if (!response.ok) {
1034 const errorData = await response.text();
1035 throw new Error(
1036 `Failed to end session: ${response.status} - ${errorData}`,
1037 );
1038 }
1039
1040 // After successful response, redirect to messages view
1041 // Extract the session ID from the URL
1042 const currentUrl = window.location.href;
1043 // The URL pattern should be like https://sketch.dev/s/cs71-8qa6-1124-aw79/
1044 const urlParts = currentUrl.split("/");
1045 let sessionId = "";
1046
1047 // Find the session ID in the URL (should be after /s/)
1048 for (let i = 0; i < urlParts.length; i++) {
1049 if (urlParts[i] === "s" && i + 1 < urlParts.length) {
1050 sessionId = urlParts[i + 1];
1051 break;
1052 }
1053 }
1054
1055 if (sessionId) {
1056 // Create the messages URL
1057 const messagesUrl = `/messages/${sessionId}`;
1058 // Redirect to messages view
1059 window.location.href = messagesUrl;
1060 }
1061
1062 // End request sent - connection will be closed by server
1063 } catch (error) {
1064 console.error("Error ending session:", error);
1065 }
1066 }
1067
Sean McCullough485afc62025-04-28 14:28:39 -07001068 async _handleMutlipleChoiceSelected(e: CustomEvent) {
1069 const chatInput = this.shadowRoot?.querySelector(
1070 "sketch-chat-input",
1071 ) as SketchChatInput;
1072 if (chatInput) {
Josh Bleecher Snyder6cad8612025-05-30 19:25:39 +00001073 if (chatInput.content && chatInput.content.trim() !== "") {
1074 chatInput.content += "\n\n";
1075 }
1076 chatInput.content += e.detail.responseText;
Sean McCullough485afc62025-04-28 14:28:39 -07001077 chatInput.focus();
Josh Bleecher Snyder6cad8612025-05-30 19:25:39 +00001078 // Adjust textarea height to accommodate new content
1079 requestAnimationFrame(() => {
1080 if (chatInput.adjustChatSpacing) {
1081 chatInput.adjustChatSpacing();
1082 }
1083 });
Sean McCullough485afc62025-04-28 14:28:39 -07001084 }
1085 }
1086
Sean McCullough86b56862025-04-18 13:04:03 -07001087 async _sendChat(e: CustomEvent) {
1088 console.log("app shell: _sendChat", e);
Sean McCullough485afc62025-04-28 14:28:39 -07001089 e.preventDefault();
1090 e.stopPropagation();
Sean McCullough86b56862025-04-18 13:04:03 -07001091 const message = e.detail.message?.trim();
1092 if (message == "") {
1093 return;
1094 }
1095 try {
Josh Bleecher Snyder98b64d12025-05-12 19:42:43 +00001096 // Always switch to chat view when sending a message so user can see processing
1097 if (this.viewMode !== "chat") {
1098 this.toggleViewMode("chat", true);
1099 }
Autoformatter5c7f9572025-05-13 01:17:31 +00001100
Sean McCullough86b56862025-04-18 13:04:03 -07001101 // Send the message to the server
1102 const response = await fetch("chat", {
1103 method: "POST",
1104 headers: {
1105 "Content-Type": "application/json",
1106 },
1107 body: JSON.stringify({ message }),
1108 });
1109
1110 if (!response.ok) {
1111 const errorData = await response.text();
1112 throw new Error(`Server error: ${response.status} - ${errorData}`);
1113 }
Sean McCullough86b56862025-04-18 13:04:03 -07001114 } catch (error) {
1115 console.error("Error sending chat message:", error);
1116 const statusText = document.getElementById("statusText");
1117 if (statusText) {
1118 statusText.textContent = "Error sending message";
1119 }
1120 }
1121 }
1122
Pokey Rule4097e532025-04-24 18:55:28 +01001123 private scrollContainerRef = createRef<HTMLElement>();
1124
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001125 /**
1126 * Set up ResizeObserver to monitor chat input height changes
1127 */
1128 private setupChatInputObserver(): void {
1129 // Wait for DOM to be ready
1130 this.updateComplete.then(() => {
1131 const chatInputElement = this.shadowRoot?.querySelector("#chat-input");
1132 if (chatInputElement && !this.chatInputResizeObserver) {
1133 this.chatInputResizeObserver = new ResizeObserver((entries) => {
1134 for (const entry of entries) {
1135 this.updateTodoPanelHeight(entry.contentRect.height);
1136 }
1137 });
1138
1139 this.chatInputResizeObserver.observe(chatInputElement);
1140
1141 // Initial height calculation
1142 const rect = chatInputElement.getBoundingClientRect();
1143 this.updateTodoPanelHeight(rect.height);
1144 }
1145 });
1146 }
1147
1148 /**
1149 * Update the CSS custom property that controls todo panel bottom position
1150 */
1151 private updateTodoPanelHeight(chatInputHeight: number): void {
1152 // Add some padding (20px) between todo panel and chat input
1153 const bottomOffset = chatInputHeight;
1154
1155 // Update the CSS custom property on the host element
1156 this.style.setProperty("--chat-input-height", `${bottomOffset}px`);
1157 }
1158
Sean McCullough86b56862025-04-18 13:04:03 -07001159 render() {
1160 return html`
Pokey Rule4097e532025-04-24 18:55:28 +01001161 <div id="top-banner">
Sean McCullough86b56862025-04-18 13:04:03 -07001162 <div class="title-container">
1163 <h1 class="banner-title">sketch</h1>
1164 <h2 id="chatTitle" class="chat-title">${this.title}</h2>
1165 </div>
1166
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001167 <!-- Container status info moved above tabs -->
Sean McCullough86b56862025-04-18 13:04:03 -07001168 <sketch-container-status
1169 .state=${this.containerState}
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001170 id="container-status"
Sean McCullough86b56862025-04-18 13:04:03 -07001171 ></sketch-container-status>
Autoformattercf570962025-04-30 17:27:39 +00001172
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001173 <!-- Last Commit section moved to sketch-container-status -->
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001174
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001175 <!-- Views section with tabs -->
1176 <sketch-view-mode-select></sketch-view-mode-select>
Sean McCullough86b56862025-04-18 13:04:03 -07001177
1178 <div class="refresh-control">
Sean McCulloughd3906e22025-04-29 17:32:14 +00001179 <button
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001180 id="stopButton"
1181 class="stop-button"
1182 ?disabled=${(this.containerState?.outstanding_llm_calls || 0) ===
1183 0 &&
1184 (this.containerState?.outstanding_tool_calls || []).length === 0}
1185 >
1186 <svg
1187 class="button-icon"
1188 xmlns="http://www.w3.org/2000/svg"
1189 viewBox="0 0 24 24"
1190 fill="none"
1191 stroke="currentColor"
1192 stroke-width="2"
1193 stroke-linecap="round"
1194 stroke-linejoin="round"
1195 >
1196 <rect x="6" y="6" width="12" height="12" />
1197 </svg>
1198 <span class="button-text">Stop</span>
Sean McCullough86b56862025-04-18 13:04:03 -07001199 </button>
Pokey Rule397871d2025-05-19 15:02:45 +01001200 <button
1201 id="endButton"
1202 class="end-button"
1203 @click=${this._handleEndClick}
1204 >
1205 <svg
1206 class="button-icon"
1207 xmlns="http://www.w3.org/2000/svg"
1208 viewBox="0 0 24 24"
1209 fill="none"
1210 stroke="currentColor"
1211 stroke-width="2"
1212 stroke-linecap="round"
1213 stroke-linejoin="round"
1214 >
1215 <path d="M18 6L6 18" />
1216 <path d="M6 6l12 12" />
1217 </svg>
1218 <span class="button-text">End</span>
1219 </button>
Sean McCullough86b56862025-04-18 13:04:03 -07001220
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +00001221 <div
1222 class="notifications-toggle"
1223 @click=${this._handleNotificationsToggle}
1224 title="${this.notificationsEnabled
1225 ? "Disable"
Philip Zeyligerbce3a132025-04-30 22:03:39 +00001226 : "Enable"} notifications when the agent completes its turn"
Philip Zeyligerdb5e9b42025-04-30 19:58:13 +00001227 >
1228 <div
1229 class="bell-icon ${!this.notificationsEnabled
1230 ? "bell-disabled"
1231 : ""}"
1232 >
1233 <!-- Bell SVG icon -->
1234 <svg
1235 xmlns="http://www.w3.org/2000/svg"
1236 width="16"
1237 height="16"
1238 fill="currentColor"
1239 viewBox="0 0 16 16"
1240 >
1241 <path
1242 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"
1243 />
1244 </svg>
1245 </div>
Philip Zeyligerbc6b6292025-04-30 18:00:15 +00001246 </div>
1247
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001248 <sketch-call-status
Sean McCulloughd9d45812025-04-30 16:53:41 -07001249 .agentState=${this.containerState?.agent_state}
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001250 .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
1251 .toolCalls=${this.containerState?.outstanding_tool_calls || []}
Philip Zeyliger72318392025-05-14 02:56:07 +00001252 .isIdle=${this.messages.length > 0
1253 ? this.messages[this.messages.length - 1]?.end_of_turn &&
1254 !this.messages[this.messages.length - 1]?.parent_conversation_id
1255 : true}
Philip Zeyliger5e357022025-05-16 04:50:34 +00001256 .isDisconnected=${this.connectionStatus === "disconnected"}
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001257 ></sketch-call-status>
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001258
1259 <sketch-network-status
1260 connection=${this.connectionStatus}
1261 error=${this.connectionErrorMessage}
1262 ></sketch-network-status>
Sean McCullough86b56862025-04-18 13:04:03 -07001263 </div>
1264 </div>
1265
Pokey Rule4097e532025-04-24 18:55:28 +01001266 <div id="view-container" ${ref(this.scrollContainerRef)}>
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001267 <div
1268 id="view-container-inner"
1269 class="${this._todoPanelVisible && this.viewMode === "chat"
1270 ? "with-todo-panel"
1271 : ""}"
1272 >
Pokey Rule4097e532025-04-24 18:55:28 +01001273 <div
1274 class="chat-view ${this.viewMode === "chat" ? "view-active" : ""}"
1275 >
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001276 <div
1277 class="chat-timeline-container ${this._todoPanelVisible &&
1278 this.viewMode === "chat"
1279 ? "with-todo-panel"
1280 : ""}"
1281 >
1282 <sketch-timeline
1283 .messages=${this.messages}
1284 .scrollContainer=${this.scrollContainerRef}
1285 .agentState=${this.containerState?.agent_state}
1286 .llmCalls=${this.containerState?.outstanding_llm_calls || 0}
1287 .toolCalls=${this.containerState?.outstanding_tool_calls || []}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001288 .firstMessageIndex=${this.containerState?.first_message_index ||
1289 0}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001290 ></sketch-timeline>
1291 </div>
1292 </div>
1293
1294 <!-- Todo panel positioned outside the main flow - only visible in chat view -->
1295 <div
1296 class="todo-panel-container ${this._todoPanelVisible &&
1297 this.viewMode === "chat"
1298 ? "visible"
1299 : ""}"
1300 >
1301 <sketch-todo-panel
1302 .visible=${this._todoPanelVisible && this.viewMode === "chat"}
1303 ></sketch-todo-panel>
Pokey Rule4097e532025-04-24 18:55:28 +01001304 </div>
1305 <div
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001306 class="diff2-view ${this.viewMode === "diff2" ? "view-active" : ""}"
1307 >
1308 <sketch-diff2-view
1309 .commit=${this.currentCommitHash}
1310 .gitService=${new DefaultGitDataService()}
1311 @diff-comment="${this._handleDiffComment}"
1312 ></sketch-diff2-view>
1313 </div>
Philip Zeyliger2d4c48f2025-05-02 23:35:03 +00001314
Pokey Rule4097e532025-04-24 18:55:28 +01001315 <div
1316 class="terminal-view ${this.viewMode === "terminal"
1317 ? "view-active"
1318 : ""}"
1319 >
1320 <sketch-terminal></sketch-terminal>
1321 </div>
Sean McCullough86b56862025-04-18 13:04:03 -07001322 </div>
1323 </div>
1324
Pokey Rule4097e532025-04-24 18:55:28 +01001325 <div id="chat-input">
1326 <sketch-chat-input @send-chat="${this._sendChat}"></sketch-chat-input>
1327 </div>
Sean McCullough86b56862025-04-18 13:04:03 -07001328 `;
1329 }
1330
1331 /**
Sean McCullough86b56862025-04-18 13:04:03 -07001332 * Lifecycle callback when component is first connected to DOM
1333 */
1334 firstUpdated(): void {
1335 if (this.viewMode !== "chat") {
1336 return;
1337 }
1338
1339 // Initial scroll to bottom when component is first rendered
1340 setTimeout(
1341 () => this.scrollTo({ top: this.scrollHeight, behavior: "smooth" }),
Philip Zeyliger72682df2025-04-23 13:09:46 -07001342 50,
Sean McCullough86b56862025-04-18 13:04:03 -07001343 );
1344
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001345 // Setup stop button
1346 const stopButton = this.renderRoot?.querySelector(
1347 "#stopButton",
1348 ) as HTMLButtonElement;
1349 stopButton?.addEventListener("click", async () => {
1350 try {
Sean McCullough495cb962025-05-01 16:25:53 -07001351 const response = await fetch("cancel", {
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001352 method: "POST",
1353 headers: {
1354 "Content-Type": "application/json",
1355 },
1356 body: JSON.stringify({ reason: "User clicked stop button" }),
1357 });
1358 if (!response.ok) {
1359 console.error("Failed to cancel:", await response.text());
1360 }
1361 } catch (error) {
1362 console.error("Error cancelling operation:", error);
1363 }
1364 });
1365
Pokey Rule397871d2025-05-19 15:02:45 +01001366 // Setup end button
1367 const endButton = this.renderRoot?.querySelector(
1368 "#endButton",
1369 ) as HTMLButtonElement;
1370 // We're already using the @click binding in the HTML, so manual event listener not needed here
1371
Philip Zeyliger47b71c92025-04-30 15:43:39 +00001372 // Process any existing messages to find commit information
1373 if (this.messages && this.messages.length > 0) {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001374 // Update last commit info via container status component
1375 if (this.containerStatusElement) {
1376 this.containerStatusElement.updateLastCommitInfo(this.messages);
1377 }
Philip Zeyliger47b71c92025-04-30 15:43:39 +00001378 }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001379
1380 // Set up chat input height observer for todo panel
1381 this.setupChatInputObserver();
Sean McCullough86b56862025-04-18 13:04:03 -07001382 }
1383}
1384
1385declare global {
1386 interface HTMLElementTagNameMap {
1387 "sketch-app-shell": SketchAppShell;
1388 }
1389}