blob: f4c0f257f967b28d4fb92aabc96a4a30cb23fe20 [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] {
77 cursor: help;
78 text-decoration: underline dotted;
79 }
80
Sean McCullough86b56862025-04-18 13:04:03 -070081 .cost {
82 color: #2e7d32;
83 }
84
85 .info-item a {
86 --tw-text-opacity: 1;
87 color: rgb(37 99 235 / var(--tw-text-opacity, 1));
88 text-decoration: inherit;
89 }
Philip Zeyligere66db3e2025-04-27 15:40:39 +000090
91 .info-toggle {
92 margin-left: 8px;
93 width: 24px;
94 height: 24px;
95 border-radius: 50%;
96 display: flex;
97 align-items: center;
98 justify-content: center;
99 background: #f0f0f0;
100 border: 1px solid #ddd;
101 cursor: pointer;
102 font-weight: bold;
103 font-style: italic;
104 color: #555;
105 transition: all 0.2s ease;
106 }
107
108 .info-toggle:hover {
109 background: #e0e0e0;
110 }
111
112 .info-toggle.active {
113 background: #4a90e2;
114 color: white;
115 border-color: #3a80d2;
116 }
117
118 .main-info-grid {
119 display: flex;
120 gap: 20px;
121 }
122
123 .info-column {
124 display: flex;
125 flex-direction: column;
126 gap: 2px;
127 }
128
129 .detailed-info-grid {
130 display: grid;
131 grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
132 gap: 8px;
133 margin-top: 10px;
134 }
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000135
136 .ssh-section {
137 margin-top: 10px;
138 padding-top: 10px;
139 border-top: 1px solid #eee;
140 }
141
142 .ssh-command {
143 display: flex;
144 align-items: center;
145 margin-bottom: 8px;
146 gap: 10px;
147 }
148
149 .ssh-command-text {
150 font-family: monospace;
151 font-size: 12px;
152 background: #f5f5f5;
153 padding: 4px 8px;
154 border-radius: 4px;
155 border: 1px solid #e0e0e0;
156 flex-grow: 1;
157 }
158
159 .copy-button {
160 background: #f0f0f0;
161 border: 1px solid #ddd;
162 border-radius: 4px;
163 padding: 3px 6px;
164 font-size: 11px;
165 cursor: pointer;
166 transition: all 0.2s;
167 }
168
169 .copy-button:hover {
170 background: #e0e0e0;
171 }
172
173 .ssh-warning {
174 background: #fff3e0;
175 border-left: 3px solid #ff9800;
176 padding: 8px 12px;
177 margin-top: 8px;
178 font-size: 12px;
179 color: #e65100;
180 }
181
182 .vscode-link {
183 color: #2962ff;
184 text-decoration: none;
185 }
186
187 .vscode-link:hover {
188 text-decoration: underline;
189 }
Sean McCullough86b56862025-04-18 13:04:03 -0700190 `;
191
192 constructor() {
193 super();
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000194 this._toggleInfoDetails = this._toggleInfoDetails.bind(this);
195
196 // Close the info panel when clicking outside of it
197 document.addEventListener("click", (event) => {
198 if (this.showDetails && !this.contains(event.target as Node)) {
199 this.showDetails = false;
200 this.requestUpdate();
201 }
202 });
203 }
204
205 /**
206 * Toggle the display of detailed information
207 */
208 private _toggleInfoDetails(event: Event) {
209 event.stopPropagation();
210 this.showDetails = !this.showDetails;
211 this.requestUpdate();
Sean McCullough86b56862025-04-18 13:04:03 -0700212 }
213
Philip Zeyligerd1402952025-04-23 03:54:37 +0000214 formatHostname() {
Philip Zeyliger18532b22025-04-23 21:11:46 +0000215 const outsideHostname = this.state?.outside_hostname;
216 const insideHostname = this.state?.inside_hostname;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000217
Philip Zeyliger18532b22025-04-23 21:11:46 +0000218 if (!outsideHostname || !insideHostname) {
Philip Zeyligerd1402952025-04-23 03:54:37 +0000219 return this.state?.hostname;
220 }
221
Philip Zeyliger18532b22025-04-23 21:11:46 +0000222 if (outsideHostname === insideHostname) {
223 return outsideHostname;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000224 }
225
Philip Zeyliger18532b22025-04-23 21:11:46 +0000226 return `${outsideHostname}:${insideHostname}`;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000227 }
228
229 formatWorkingDir() {
Philip Zeyliger18532b22025-04-23 21:11:46 +0000230 const outsideWorkingDir = this.state?.outside_working_dir;
231 const insideWorkingDir = this.state?.inside_working_dir;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000232
Philip Zeyliger18532b22025-04-23 21:11:46 +0000233 if (!outsideWorkingDir || !insideWorkingDir) {
Philip Zeyligerd1402952025-04-23 03:54:37 +0000234 return this.state?.working_dir;
235 }
236
Philip Zeyliger18532b22025-04-23 21:11:46 +0000237 if (outsideWorkingDir === insideWorkingDir) {
238 return outsideWorkingDir;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000239 }
240
Philip Zeyliger18532b22025-04-23 21:11:46 +0000241 return `${outsideWorkingDir}:${insideWorkingDir}`;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000242 }
243
244 getHostnameTooltip() {
Philip Zeyliger18532b22025-04-23 21:11:46 +0000245 const outsideHostname = this.state?.outside_hostname;
246 const insideHostname = this.state?.inside_hostname;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000247
248 if (
Philip Zeyliger18532b22025-04-23 21:11:46 +0000249 !outsideHostname ||
250 !insideHostname ||
251 outsideHostname === insideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000252 ) {
253 return "";
254 }
255
Philip Zeyliger18532b22025-04-23 21:11:46 +0000256 return `Outside: ${outsideHostname}, Inside: ${insideHostname}`;
257 }
258
259 getWorkingDirTooltip() {
260 const outsideWorkingDir = this.state?.outside_working_dir;
261 const insideWorkingDir = this.state?.inside_working_dir;
262
263 if (
264 !outsideWorkingDir ||
265 !insideWorkingDir ||
266 outsideWorkingDir === insideWorkingDir
267 ) {
268 return "";
269 }
270
271 return `Outside: ${outsideWorkingDir}, Inside: ${insideWorkingDir}`;
Philip Zeyligerd1402952025-04-23 03:54:37 +0000272 }
273
Sean McCullough86b56862025-04-18 13:04:03 -0700274 // See https://lit.dev/docs/components/lifecycle/
275 connectedCallback() {
276 super.connectedCallback();
277 // register event listeners
278 }
279
280 // See https://lit.dev/docs/components/lifecycle/
281 disconnectedCallback() {
282 super.disconnectedCallback();
283 // unregister event listeners
284 }
285
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000286 copyToClipboard(text: string) {
287 navigator.clipboard
288 .writeText(text)
289 .then(() => {
290 // Could add a temporary success indicator here
291 })
292 .catch((err) => {
293 console.error("Could not copy text: ", err);
294 });
295 }
296
297 getSSHHostname() {
298 return `sketch-${this.state?.session_id}`;
299 }
300
301 renderSSHSection() {
302 // Only show SSH section if we're in a Docker container and have session ID
303 if (!this.state?.session_id) {
304 return html``;
305 }
306
307 const sshHost = this.getSSHHostname();
308 const sshCommand = `ssh ${sshHost}`;
309 const vscodeCommand = `code --remote ssh-remote+root@${sshHost} /app -n`;
310 const vscodeURL = `vscode://vscode-remote/ssh-remote+root@${sshHost}/app?windowId=_blank`;
311
312 if (!this.state?.ssh_available) {
313 return html`
314 <div class="ssh-section">
315 <h3>SSH Connection</h3>
316 <div class="ssh-warning">
317 SSH connections are not available:
318 ${this.state?.ssh_error || "SSH configuration is missing"}
319 </div>
320 </div>
321 `;
322 }
323
324 return html`
325 <div class="ssh-section">
326 <h3>SSH Connection</h3>
327 <div class="ssh-command">
328 <div class="ssh-command-text">${sshCommand}</div>
329 <button
330 class="copy-button"
331 @click=${() => this.copyToClipboard(sshCommand)}
332 >
333 Copy
334 </button>
335 </div>
336 <div class="ssh-command">
337 <div class="ssh-command-text">${vscodeCommand}</div>
338 <button
339 class="copy-button"
340 @click=${() => this.copyToClipboard(vscodeCommand)}
341 >
342 Copy
343 </button>
344 </div>
345 <div class="ssh-command">
346 <a href="${vscodeURL}" class="vscode-link">${vscodeURL}</a>
347 </div>
348 </div>
349 `;
350 }
351
Sean McCullough86b56862025-04-18 13:04:03 -0700352 render() {
353 return html`
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000354 <div class="info-container">
355 <!-- Main visible info in two columns - hostname/dir and repo/cost -->
356 <div class="main-info-grid">
357 <!-- First column: hostname and working dir -->
358 <div class="info-column">
359 <div class="info-item">
360 <span
361 id="hostname"
362 class="info-value"
363 title="${this.getHostnameTooltip()}"
364 >
365 ${this.formatHostname()}
366 </span>
367 </div>
368 <div class="info-item">
369 <span
370 id="workingDir"
371 class="info-value"
372 title="${this.getWorkingDirTooltip()}"
373 >
374 ${this.formatWorkingDir()}
375 </span>
376 </div>
377 </div>
378
379 <!-- Second column: git repo and cost -->
380 <div class="info-column">
381 ${this.state?.git_origin
382 ? html`
383 <div class="info-item">
384 <span id="gitOrigin" class="info-value"
385 >${this.state?.git_origin}</span
386 >
387 </div>
388 `
389 : ""}
390 <div class="info-item">
391 <span id="totalCost" class="info-value cost"
392 >$${(this.state?.total_usage?.total_cost_usd || 0).toFixed(
393 2,
394 )}</span
395 >
396 </div>
397 </div>
Sean McCullough86b56862025-04-18 13:04:03 -0700398 </div>
Philip Zeyligere66db3e2025-04-27 15:40:39 +0000399
400 <!-- Info toggle button -->
401 <button
402 class="info-toggle ${this.showDetails ? "active" : ""}"
403 @click=${this._toggleInfoDetails}
404 title="Show/hide details"
405 >
406 i
407 </button>
408
409 <!-- Expanded info panel -->
410 <div class="info-expanded ${this.showDetails ? "active" : ""}">
411 <div class="detailed-info-grid">
412 <div class="info-item">
413 <span class="info-label">Commit:</span>
414 <span id="initialCommit" class="info-value"
415 >${this.state?.initial_commit?.substring(0, 8)}</span
416 >
417 </div>
418 <div class="info-item">
419 <span class="info-label">Msgs:</span>
420 <span id="messageCount" class="info-value"
421 >${this.state?.message_count}</span
422 >
423 </div>
424 <div class="info-item">
425 <span class="info-label">Input tokens:</span>
426 <span id="inputTokens" class="info-value"
427 >${formatNumber(
428 (this.state?.total_usage?.input_tokens || 0) +
429 (this.state?.total_usage?.cache_read_input_tokens || 0) +
430 (this.state?.total_usage?.cache_creation_input_tokens || 0),
431 )}</span
432 >
433 </div>
434 <div class="info-item">
435 <span class="info-label">Output tokens:</span>
436 <span id="outputTokens" class="info-value"
437 >${formatNumber(this.state?.total_usage?.output_tokens)}</span
438 >
439 </div>
440 <div
441 class="info-item"
442 style="grid-column: 1 / -1; margin-top: 5px; border-top: 1px solid #eee; padding-top: 5px;"
443 >
444 <a href="logs">Logs</a> (<a href="download">Download</a>)
445 </div>
446 </div>
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000447
448 <!-- SSH Connection Information -->
449 ${this.renderSSHSection()}
Sean McCullough86b56862025-04-18 13:04:03 -0700450 </div>
451 </div>
452 `;
453 }
454}
455
456declare global {
457 interface HTMLElementTagNameMap {
458 "sketch-container-status": SketchContainerStatus;
459 }
460}