blob: ba745ccbc2be29c081abb84213559d5ff70557fa [file] [log] [blame]
Sean McCullough86b56862025-04-18 13:04:03 -07001import { State } from "../types";
Sean McCulloughb29f8912025-04-20 15:39:11 -07002import { LitElement, css, html } from "lit";
Philip Zeyligere66db3e2025-04-27 15:40:39 +00003import { customElement, property, state } from "lit/decorators.js";
Josh Bleecher Snydera0801ad2025-04-25 19:34:53 +00004import { formatNumber } from "../utils";
Sean McCullough86b56862025-04-18 13:04:03 -07005
6@customElement("sketch-container-status")
7export class SketchContainerStatus extends LitElement {
8 // Header bar: Container status details
9
10 @property()
11 state: State;
12
Philip Zeyligere66db3e2025-04-27 15:40:39 +000013 @state()
14 showDetails: boolean = false;
15
Sean McCullough86b56862025-04-18 13:04:03 -070016 // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
17 // Note that these styles only apply to the scope of this web component's
18 // shadow DOM node, so they won't leak out or collide with CSS declared in
19 // other components or the containing web page (...unless you want it to do that).
20 static styles = css`
Philip Zeyligere66db3e2025-04-27 15:40:39 +000021 .info-container {
22 display: flex;
23 align-items: center;
24 position: relative;
Sean McCullough86b56862025-04-18 13:04:03 -070025 }
26
27 .info-grid {
28 display: flex;
29 flex-wrap: wrap;
30 gap: 8px;
31 background: #f9f9f9;
32 border-radius: 4px;
33 padding: 4px 10px;
34 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
35 flex: 1;
36 }
37
Philip Zeyligere66db3e2025-04-27 15:40:39 +000038 .info-expanded {
39 position: absolute;
40 top: 100%;
41 right: 0;
42 z-index: 10;
Philip Zeyligerc72fff52025-04-29 20:17:54 +000043 min-width: 400px;
Philip Zeyligere66db3e2025-04-27 15:40:39 +000044 background: white;
45 border-radius: 8px;
46 padding: 10px 15px;
47 box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
48 margin-top: 5px;
49 display: none;
50 }
51
52 .info-expanded.active {
53 display: block;
54 }
55
Sean McCullough86b56862025-04-18 13:04:03 -070056 .info-item {
57 display: flex;
58 align-items: center;
59 white-space: nowrap;
60 margin-right: 10px;
61 font-size: 13px;
62 }
63
64 .info-label {
65 font-size: 11px;
66 color: #555;
67 margin-right: 3px;
68 font-weight: 500;
69 }
70
71 .info-value {
72 font-size: 11px;
73 font-weight: 600;
74 }
75
Philip Zeyligerd1402952025-04-23 03:54:37 +000076 [title] {
Philip Zeyligerbce3a132025-04-30 22:03:39 +000077 cursor: default;
Philip Zeyligerd1402952025-04-23 03:54:37 +000078 }
79
Sean McCullough86b56862025-04-18 13:04:03 -070080 .cost {
81 color: #2e7d32;
82 }
83
84 .info-item a {
85 --tw-text-opacity: 1;
86 color: rgb(37 99 235 / var(--tw-text-opacity, 1));
87 text-decoration: inherit;
88 }
Philip Zeyligere66db3e2025-04-27 15:40:39 +000089
90 .info-toggle {
91 margin-left: 8px;
92 width: 24px;
93 height: 24px;
94 border-radius: 50%;
95 display: flex;
96 align-items: center;
97 justify-content: center;
98 background: #f0f0f0;
99 border: 1px solid #ddd;
100 cursor: pointer;
101 font-weight: bold;
102 font-style: italic;
103 color: #555;
104 transition: all 0.2s ease;
105 }
106
107 .info-toggle:hover {
108 background: #e0e0e0;
109 }
110
111 .info-toggle.active {
112 background: #4a90e2;
113 color: white;
114 border-color: #3a80d2;
115 }
116
117 .main-info-grid {
118 display: flex;
119 gap: 20px;
120 }
121
122 .info-column {
123 display: flex;
124 flex-direction: column;
125 gap: 2px;
126 }
127
128 .detailed-info-grid {
129 display: grid;
130 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
131 gap: 8px;
132 margin-top: 10px;
133 }
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000134
135 .ssh-section {
136 margin-top: 10px;
137 padding-top: 10px;
138 border-top: 1px solid #eee;
139 }
140
141 .ssh-command {
142 display: flex;
143 align-items: center;
144 margin-bottom: 8px;
145 gap: 10px;
146 }
147
148 .ssh-command-text {
149 font-family: monospace;
150 font-size: 12px;
151 background: #f5f5f5;
152 padding: 4px 8px;
153 border-radius: 4px;
154 border: 1px solid #e0e0e0;
155 flex-grow: 1;
156 }
157
158 .copy-button {
159 background: #f0f0f0;
160 border: 1px solid #ddd;
161 border-radius: 4px;
162 padding: 3px 6px;
163 font-size: 11px;
164 cursor: pointer;
165 transition: all 0.2s;
166 }
167
168 .copy-button:hover {
169 background: #e0e0e0;
170 }
171
172 .ssh-warning {
173 background: #fff3e0;
174 border-left: 3px solid #ff9800;
175 padding: 8px 12px;
176 margin-top: 8px;
177 font-size: 12px;
178 color: #e65100;
179 }
180
181 .vscode-link {
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000182 color: white;
183 text-decoration: none;
184 background-color: #0066b8;
185 padding: 4px 8px;
186 border-radius: 4px;
187 display: flex;
188 align-items: center;
189 gap: 6px;
190 font-size: 12px;
191 transition: all 0.2s ease;
192 }
193
194 .vscode-link:hover {
195 background-color: #005091;
196 }
197
198 .vscode-icon {
199 width: 16px;
200 height: 16px;
201 }
202
203 .github-link {
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000204 color: #2962ff;
205 text-decoration: none;
206 }
207
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000208 .github-link:hover {
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000209 text-decoration: underline;
210 }
Sean McCullough86b56862025-04-18 13:04:03 -0700211 `;
212
213 constructor() {
214 super();
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000215 this._toggleInfoDetails = this._toggleInfoDetails.bind(this);
216
217 // Close the info panel when clicking outside of it
218 document.addEventListener("click", (event) => {
219 if (this.showDetails && !this.contains(event.target as Node)) {
220 this.showDetails = false;
221 this.requestUpdate();
222 }
223 });
224 }
225
226 /**
227 * Toggle the display of detailed information
228 */
229 private _toggleInfoDetails(event: Event) {
230 event.stopPropagation();
231 this.showDetails = !this.showDetails;
232 this.requestUpdate();
Sean McCullough86b56862025-04-18 13:04:03 -0700233 }
234
Philip Zeyligerd1402952025-04-23 03:54:37 +0000235 formatHostname() {
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000236 // Only display outside hostname
Philip Zeyliger18532b22025-04-23 21:11:46 +0000237 const outsideHostname = this.state?.outside_hostname;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000238
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000239 if (!outsideHostname) {
Philip Zeyligerd1402952025-04-23 03:54:37 +0000240 return this.state?.hostname;
241 }
242
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000243 return outsideHostname;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000244 }
245
246 formatWorkingDir() {
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000247 // Only display outside working directory
Philip Zeyliger18532b22025-04-23 21:11:46 +0000248 const outsideWorkingDir = this.state?.outside_working_dir;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000249
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000250 if (!outsideWorkingDir) {
Philip Zeyligerd1402952025-04-23 03:54:37 +0000251 return this.state?.working_dir;
252 }
253
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000254 return outsideWorkingDir;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000255 }
256
257 getHostnameTooltip() {
Philip Zeyliger18532b22025-04-23 21:11:46 +0000258 const outsideHostname = this.state?.outside_hostname;
259 const insideHostname = this.state?.inside_hostname;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000260
261 if (
Philip Zeyliger18532b22025-04-23 21:11:46 +0000262 !outsideHostname ||
263 !insideHostname ||
264 outsideHostname === insideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000265 ) {
266 return "";
267 }
268
Philip Zeyliger18532b22025-04-23 21:11:46 +0000269 return `Outside: ${outsideHostname}, Inside: ${insideHostname}`;
270 }
271
272 getWorkingDirTooltip() {
273 const outsideWorkingDir = this.state?.outside_working_dir;
274 const insideWorkingDir = this.state?.inside_working_dir;
275
276 if (
277 !outsideWorkingDir ||
278 !insideWorkingDir ||
279 outsideWorkingDir === insideWorkingDir
280 ) {
281 return "";
282 }
283
284 return `Outside: ${outsideWorkingDir}, Inside: ${insideWorkingDir}`;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000285 }
286
Sean McCullough86b56862025-04-18 13:04:03 -0700287 // See https://lit.dev/docs/components/lifecycle/
288 connectedCallback() {
289 super.connectedCallback();
290 // register event listeners
291 }
292
293 // See https://lit.dev/docs/components/lifecycle/
294 disconnectedCallback() {
295 super.disconnectedCallback();
296 // unregister event listeners
297 }
298
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000299 copyToClipboard(text: string) {
300 navigator.clipboard
301 .writeText(text)
302 .then(() => {
303 // Could add a temporary success indicator here
304 })
305 .catch((err) => {
306 console.error("Could not copy text: ", err);
307 });
308 }
309
310 getSSHHostname() {
311 return `sketch-${this.state?.session_id}`;
312 }
313
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000314 // Format GitHub repository URL to org/repo format
315 formatGitHubRepo(url) {
316 if (!url) return null;
317
318 // Common GitHub URL patterns
319 const patterns = [
320 // HTTPS URLs
321 /https:\/\/github\.com\/([^/]+)\/([^/\s.]+)(?:\.git)?/,
322 // SSH URLs
323 /git@github\.com:([^/]+)\/([^/\s.]+)(?:\.git)?/,
324 // Git protocol
325 /git:\/\/github\.com\/([^/]+)\/([^/\s.]+)(?:\.git)?/,
326 ];
327
328 for (const pattern of patterns) {
329 const match = url.match(pattern);
330 if (match) {
331 return {
332 formatted: `${match[1]}/${match[2]}`,
333 url: `https://github.com/${match[1]}/${match[2]}`,
334 };
335 }
336 }
337
338 return null;
339 }
340
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000341 renderSSHSection() {
342 // Only show SSH section if we're in a Docker container and have session ID
343 if (!this.state?.session_id) {
344 return html``;
345 }
346
347 const sshHost = this.getSSHHostname();
348 const sshCommand = `ssh ${sshHost}`;
349 const vscodeCommand = `code --remote ssh-remote+root@${sshHost} /app -n`;
350 const vscodeURL = `vscode://vscode-remote/ssh-remote+root@${sshHost}/app?windowId=_blank`;
351
352 if (!this.state?.ssh_available) {
353 return html`
354 <div class="ssh-section">
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000355 <h3>Connect to Container</h3>
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000356 <div class="ssh-warning">
357 SSH connections are not available:
358 ${this.state?.ssh_error || "SSH configuration is missing"}
359 </div>
360 </div>
361 `;
362 }
363
364 return html`
365 <div class="ssh-section">
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000366 <h3>Connect to Container</h3>
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000367 <div class="ssh-command">
368 <div class="ssh-command-text">${sshCommand}</div>
369 <button
370 class="copy-button"
371 @click=${() => this.copyToClipboard(sshCommand)}
372 >
373 Copy
374 </button>
375 </div>
376 <div class="ssh-command">
377 <div class="ssh-command-text">${vscodeCommand}</div>
378 <button
379 class="copy-button"
380 @click=${() => this.copyToClipboard(vscodeCommand)}
381 >
382 Copy
383 </button>
384 </div>
385 <div class="ssh-command">
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000386 <a href="${vscodeURL}" class="vscode-link" title="${vscodeURL}">
387 <svg
388 class="vscode-icon"
389 xmlns="http://www.w3.org/2000/svg"
390 viewBox="0 0 24 24"
391 fill="none"
392 stroke="white"
393 stroke-width="2"
394 stroke-linecap="round"
395 stroke-linejoin="round"
396 >
397 <path
398 d="M16.5 9.4 7.55 4.24a.35.35 0 0 0-.41.01l-1.23.93a.35.35 0 0 0-.14.29v13.04c0 .12.07.23.17.29l1.24.93c.13.1.31.09.43-.01L16.5 14.6l-6.39 4.82c-.16.12-.38.12-.55.01l-1.33-1.01a.35.35 0 0 1-.14-.28V5.88c0-.12.07-.23.18-.29l1.23-.93c.14-.1.32-.1.46 0l6.54 4.92-6.54 4.92c-.14.1-.32.1-.46 0l-1.23-.93a.35.35 0 0 1-.18-.29V5.88c0-.12.07-.23.17-.29l1.33-1.01c.16-.12.39-.11.55.01l6.39 4.81z"
399 />
400 </svg>
401 <span>Open in VSCode</span>
402 </a>
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000403 </div>
404 </div>
405 `;
406 }
407
Sean McCullough86b56862025-04-18 13:04:03 -0700408 render() {
409 return html`
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000410 <div class="info-container">
411 <!-- Main visible info in two columns - hostname/dir and repo/cost -->
412 <div class="main-info-grid">
413 <!-- First column: hostname and working dir -->
414 <div class="info-column">
415 <div class="info-item">
416 <span
417 id="hostname"
418 class="info-value"
419 title="${this.getHostnameTooltip()}"
420 >
421 ${this.formatHostname()}
422 </span>
423 </div>
424 <div class="info-item">
425 <span
426 id="workingDir"
427 class="info-value"
428 title="${this.getWorkingDirTooltip()}"
429 >
430 ${this.formatWorkingDir()}
431 </span>
432 </div>
433 </div>
434
435 <!-- Second column: git repo and cost -->
436 <div class="info-column">
437 ${this.state?.git_origin
438 ? html`
439 <div class="info-item">
Philip Zeyligerbce3a132025-04-30 22:03:39 +0000440 ${(() => {
441 const github = this.formatGitHubRepo(
442 this.state?.git_origin,
443 );
444 if (github) {
445 return html`
446 <a
447 href="${github.url}"
448 target="_blank"
449 rel="noopener noreferrer"
450 class="github-link"
451 title="${this.state?.git_origin}"
452 >
453 ${github.formatted}
454 </a>
455 `;
456 } else {
457 return html`
458 <span id="gitOrigin" class="info-value"
459 >${this.state?.git_origin}</span
460 >
461 `;
462 }
463 })()}
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000464 </div>
465 `
466 : ""}
467 <div class="info-item">
468 <span id="totalCost" class="info-value cost"
469 >$${(this.state?.total_usage?.total_cost_usd || 0).toFixed(
470 2,
471 )}</span
472 >
473 </div>
474 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700475 </div>
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000476
477 <!-- Info toggle button -->
478 <button
479 class="info-toggle ${this.showDetails ? "active" : ""}"
480 @click=${this._toggleInfoDetails}
481 title="Show/hide details"
482 >
483 i
484 </button>
485
486 <!-- Expanded info panel -->
487 <div class="info-expanded ${this.showDetails ? "active" : ""}">
488 <div class="detailed-info-grid">
489 <div class="info-item">
490 <span class="info-label">Commit:</span>
491 <span id="initialCommit" class="info-value"
492 >${this.state?.initial_commit?.substring(0, 8)}</span
493 >
494 </div>
495 <div class="info-item">
496 <span class="info-label">Msgs:</span>
497 <span id="messageCount" class="info-value"
498 >${this.state?.message_count}</span
499 >
500 </div>
501 <div class="info-item">
502 <span class="info-label">Input tokens:</span>
503 <span id="inputTokens" class="info-value"
504 >${formatNumber(
505 (this.state?.total_usage?.input_tokens || 0) +
506 (this.state?.total_usage?.cache_read_input_tokens || 0) +
507 (this.state?.total_usage?.cache_creation_input_tokens || 0),
508 )}</span
509 >
510 </div>
511 <div class="info-item">
512 <span class="info-label">Output tokens:</span>
513 <span id="outputTokens" class="info-value"
514 >${formatNumber(this.state?.total_usage?.output_tokens)}</span
515 >
516 </div>
517 <div
518 class="info-item"
519 style="grid-column: 1 / -1; margin-top: 5px; border-top: 1px solid #eee; padding-top: 5px;"
520 >
521 <a href="logs">Logs</a> (<a href="download">Download</a>)
522 </div>
523 </div>
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000524
525 <!-- SSH Connection Information -->
526 ${this.renderSSHSection()}
Sean McCullough86b56862025-04-18 13:04:03 -0700527 </div>
528 </div>
529 `;
530 }
531}
532
533declare global {
534 interface HTMLElementTagNameMap {
535 "sketch-container-status": SketchContainerStatus;
536 }
537}