personal memory agent
1<style>
2 .support-nav {
3 display: flex;
4 gap: 0.25rem;
5 margin-bottom: 1.5rem;
6 border-bottom: 1px solid var(--border, #e0e0e0);
7 padding-bottom: 0.5rem;
8 }
9 .support-nav button {
10 background: none;
11 border: none;
12 padding: 0.5rem 1rem;
13 cursor: pointer;
14 font-size: 0.9rem;
15 color: var(--muted, #888);
16 border-bottom: 2px solid transparent;
17 transition: all 0.15s;
18 }
19 .support-nav button.active {
20 color: var(--text, #222);
21 border-bottom-color: var(--facet-color, #3b82f6);
22 font-weight: 600;
23 }
24 .support-section { display: none; }
25 .support-section.active { display: block; }
26
27 /* Tickets */
28 .support-ticket {
29 border: 1px solid var(--border, #e0e0e0);
30 border-radius: 8px;
31 padding: 1rem;
32 margin-bottom: 0.75rem;
33 cursor: pointer;
34 transition: background 0.1s;
35 }
36 .support-ticket:hover { background: var(--card-bg, #fafafa); }
37 .support-ticket-header {
38 display: flex;
39 justify-content: space-between;
40 align-items: center;
41 margin-bottom: 0.25rem;
42 }
43 .support-ticket-id { font-size: 0.8rem; color: var(--muted, #888); }
44 .support-ticket-subject { font-weight: 600; }
45 .support-ticket-meta { font-size: 0.8rem; color: var(--muted, #888); margin-top: 0.25rem; }
46 .support-status {
47 font-size: 0.75rem;
48 padding: 0.15rem 0.5rem;
49 border-radius: 4px;
50 font-weight: 600;
51 }
52 .support-status-open { background: #e3f2fd; color: #1565c0; }
53 .support-status-in-progress { background: #fff3e0; color: #e65100; }
54 .support-status-waiting { background: #f3e5f5; color: #7b1fa2; }
55 .support-status-resolved { background: #e8f5e9; color: #2e7d32; }
56
57 /* Ticket detail */
58 .support-detail { display: none; }
59 .support-detail.active { display: block; }
60 .support-detail-back {
61 background: none;
62 border: none;
63 cursor: pointer;
64 font-size: 0.9rem;
65 color: var(--muted, #888);
66 padding: 0;
67 margin-bottom: 1rem;
68 }
69 .support-detail-back:hover { color: var(--text, #222); }
70 .support-message {
71 border-left: 3px solid var(--border, #e0e0e0);
72 padding: 0.75rem 1rem;
73 margin-bottom: 0.75rem;
74 }
75 .support-message-meta { font-size: 0.8rem; color: var(--muted, #888); margin-bottom: 0.25rem; }
76 .support-reply-form { margin-top: 1rem; }
77 .support-reply-form textarea {
78 width: 100%;
79 min-height: 80px;
80 padding: 0.75rem;
81 border: 1px solid var(--border, #e0e0e0);
82 border-radius: 6px;
83 font: inherit;
84 resize: vertical;
85 box-sizing: border-box;
86 }
87 .support-reply-form textarea:focus {
88 outline: none;
89 border-color: var(--facet-color, #3b82f6);
90 }
91
92 /* Feedback */
93 .support-feedback-form textarea {
94 width: 100%;
95 min-height: 100px;
96 padding: 0.75rem;
97 border: 1px solid var(--border, #e0e0e0);
98 border-radius: 6px;
99 font: inherit;
100 resize: vertical;
101 margin-bottom: 0.5rem;
102 box-sizing: border-box;
103 }
104 .support-feedback-form textarea:focus {
105 outline: none;
106 border-color: var(--facet-color, #3b82f6);
107 }
108 .support-feedback-options {
109 display: flex;
110 gap: 1rem;
111 align-items: center;
112 margin-bottom: 0.5rem;
113 font-size: 0.85rem;
114 }
115
116 /* Help */
117 .support-help-card {
118 border: 1px solid var(--border, #e0e0e0);
119 border-radius: 8px;
120 padding: 1rem;
121 margin-bottom: 0.75rem;
122 }
123 .support-help-card h3 { margin: 0 0 0.5rem 0; font-size: 1rem; }
124 .support-help-card p { margin: 0; font-size: 0.9rem; color: var(--muted, #888); }
125
126 /* Buttons */
127 .support-btn {
128 background: var(--facet-color, #3b82f6);
129 color: #fff;
130 border: none;
131 padding: 0.5rem 1.25rem;
132 border-radius: 6px;
133 cursor: pointer;
134 font-size: 0.9rem;
135 font-weight: 500;
136 }
137 .support-btn:hover { opacity: 0.9; }
138 .support-btn:disabled { opacity: 0.5; cursor: not-allowed; }
139 .support-btn-secondary {
140 background: transparent;
141 color: var(--text, #222);
142 border: 1px solid var(--border, #e0e0e0);
143 }
144
145 .support-empty {
146 text-align: center;
147 padding: 3rem 1rem;
148 color: var(--muted, #888);
149 }
150 .support-disabled {
151 text-align: center;
152 padding: 3rem 1rem;
153 color: var(--muted, #888);
154 }
155
156 .support-status-msg {
157 font-size: 0.85rem;
158 margin-top: 0.5rem;
159 min-height: 1.2em;
160 }
161 .support-status-msg.success { color: #2e7d32; }
162 .support-status-msg.error { color: #c62828; }
163
164 /* Attachments */
165 .support-attachments {
166 margin-top: 0.5rem;
167 font-size: 0.85rem;
168 }
169 .support-attachment-item {
170 display: flex;
171 align-items: center;
172 gap: 0.35rem;
173 color: var(--muted, #888);
174 margin-top: 0.15rem;
175 }
176 .support-drop-zone {
177 border: 2px dashed var(--border, #e0e0e0);
178 border-radius: 8px;
179 padding: 1rem;
180 text-align: center;
181 color: var(--muted, #888);
182 font-size: 0.85rem;
183 margin-top: 0.75rem;
184 transition: all 0.15s;
185 cursor: pointer;
186 }
187 .support-drop-zone.dragover {
188 border-color: var(--facet-color, #3b82f6);
189 background: rgba(59, 130, 246, 0.04);
190 color: var(--text, #222);
191 }
192 .support-drop-zone input[type="file"] { display: none; }
193 .support-file-list {
194 margin-top: 0.5rem;
195 font-size: 0.85rem;
196 }
197 .support-file-entry {
198 display: flex;
199 align-items: center;
200 justify-content: space-between;
201 padding: 0.25rem 0;
202 }
203 .support-file-entry .remove-file {
204 background: none;
205 border: none;
206 color: var(--muted, #888);
207 cursor: pointer;
208 font-size: 0.9rem;
209 padding: 0 0.25rem;
210 }
211 .support-file-entry .remove-file:hover { color: #c62828; }
212
213 /* Announcements banner */
214 .support-announcements {
215 background: #fff8e1;
216 border: 1px solid #ffe082;
217 border-radius: 8px;
218 padding: 0.75rem 1rem;
219 margin-bottom: 1.5rem;
220 font-size: 0.9rem;
221 }
222 .support-announcements h4 { margin: 0 0 0.25rem 0; font-size: 0.9rem; }
223</style>
224
225<div class="workspace-content" id="support-root">
226 <div id="support-disabled" class="support-disabled" style="display:none;">
227 <p>Support agent is disabled. Enable it in Settings to file tickets and give feedback.</p>
228 <p style="font-size:0.85rem;">Local help and diagnostics are still available via <code>sol call support diagnose</code>.</p>
229 </div>
230
231 <div id="support-main">
232 <!-- Announcements banner (hidden if none) -->
233 <div id="support-announcements-banner" class="support-announcements" style="display:none;"></div>
234
235 <!-- Navigation tabs -->
236 <nav class="support-nav">
237 <button class="active" data-section="tickets">Active Tickets</button>
238 <button data-section="feedback">Feedback</button>
239 <button data-section="help">Help & Guidance</button>
240 </nav>
241
242 <!-- Section: Active Tickets -->
243 <div id="section-tickets" class="support-section active">
244 <div id="tickets-list"></div>
245 <div id="ticket-detail" class="support-detail"></div>
246 </div>
247
248 <!-- Section: Feedback -->
249 <div id="section-feedback" class="support-section">
250 <p style="margin-bottom:1rem;">Share your impressions, ideas, or anything on your mind. Your feedback shapes the product.</p>
251 <div class="support-feedback-form">
252 <textarea id="feedback-text" placeholder="What's on your mind?"></textarea>
253 <div class="support-feedback-options">
254 <label><input type="checkbox" id="feedback-anonymous"> Submit anonymously</label>
255 </div>
256 <button class="support-btn" id="feedback-submit">Send Feedback</button>
257 <div id="feedback-status" class="support-status-msg"></div>
258 </div>
259 </div>
260
261 <!-- Section: Help & Guidance -->
262 <div id="section-help" class="support-section">
263 <div class="support-help-card">
264 <h3>🛟 Getting Help</h3>
265 <p>Just say "I need help" or "something's not working" in the chat bar. Your sol will handle everything — searching for answers, running diagnostics, and filing a ticket if needed.</p>
266 </div>
267 <div class="support-help-card">
268 <h3>🔍 Search the Knowledge Base</h3>
269 <p>Run <code>sol call support search "your question"</code> to find answers in our knowledge base before filing a ticket.</p>
270 </div>
271 <div class="support-help-card">
272 <h3>🩺 Run Diagnostics</h3>
273 <p>Run <code>sol call support diagnose</code> to check your system health locally — no data is sent anywhere.</p>
274 </div>
275 <div class="support-help-card">
276 <h3>📢 Announcements</h3>
277 <p>Run <code>sol call support announcements</code> to check for product updates and known issues.</p>
278 </div>
279 <div class="support-help-card">
280 <h3>🔒 Privacy</h3>
281 <p>Nothing leaves your device without your explicit approval. You review every ticket before it's sent and can edit or redact anything. Journal content is never included unless you attach it yourself.</p>
282 </div>
283 </div>
284 </div>
285</div>
286
287<script>
288(function() {
289 // Tab navigation
290 document.querySelectorAll('.support-nav button').forEach(btn => {
291 btn.addEventListener('click', function() {
292 document.querySelectorAll('.support-nav button').forEach(b => b.classList.remove('active'));
293 document.querySelectorAll('.support-section').forEach(s => s.classList.remove('active'));
294 this.classList.add('active');
295 document.getElementById('section-' + this.dataset.section).classList.add('active');
296 // Reset ticket detail when switching to tickets
297 if (this.dataset.section === 'tickets') {
298 document.getElementById('ticket-detail').classList.remove('active');
299 document.getElementById('tickets-list').style.display = '';
300 }
301 });
302 });
303
304 // Load tickets
305 async function loadTickets() {
306 const list = document.getElementById('tickets-list');
307 try {
308 const resp = await fetch('/app/support/api/tickets');
309 if (!resp.ok) {
310 if (resp.status === 403) {
311 document.getElementById('support-main').style.display = 'none';
312 document.getElementById('support-disabled').style.display = '';
313 return;
314 }
315 throw new Error('Failed to load tickets');
316 }
317 const tickets = await resp.json();
318
319 if (!tickets.length) {
320 list.innerHTML = '<div class="support-empty"><p>No tickets yet.</p><p style="font-size:0.85rem;">File one by saying "I need help" in the chat bar, or use <code>sol call support create</code>.</p></div>';
321 return;
322 }
323
324 list.innerHTML = tickets.map(t => {
325 const statusClass = 'support-status-' + (t.status || 'open').replace(/[^a-z-]/g, '');
326 return `<div class="support-ticket" data-id="${t.id}">
327 <div class="support-ticket-header">
328 <span class="support-ticket-subject">${esc(t.subject || 'Untitled')}</span>
329 <span class="support-status ${statusClass}">${esc(t.status || 'open')}</span>
330 </div>
331 <div class="support-ticket-meta">
332 <span class="support-ticket-id">#${t.id}</span> ·
333 ${esc(t.product || '')} ·
334 ${timeAgo(t.created_at)}
335 </div>
336 </div>`;
337 }).join('');
338
339 // Click to open detail
340 list.querySelectorAll('.support-ticket').forEach(el => {
341 el.addEventListener('click', () => openTicket(parseInt(el.dataset.id)));
342 });
343 } catch (e) {
344 list.innerHTML = '<div class="support-empty"><p>Unable to load tickets.</p></div>';
345 }
346 }
347
348 // Open ticket detail
349 async function openTicket(id) {
350 const detail = document.getElementById('ticket-detail');
351 const list = document.getElementById('tickets-list');
352 list.style.display = 'none';
353 detail.classList.add('active');
354 detail.innerHTML = '<p>Loading...</p>';
355
356 try {
357 const resp = await fetch('/app/support/api/tickets/' + id);
358 const t = await resp.json();
359 const statusClass = 'support-status-' + (t.status || 'open').replace(/[^a-z-]/g, '');
360
361 let html = `
362 <button class="support-detail-back" id="back-to-list">← Back to tickets</button>
363 <h2>${esc(t.subject || 'Untitled')} <span class="support-status ${statusClass}">${esc(t.status || 'open')}</span></h2>
364 <div class="support-ticket-meta">#${t.id} · ${esc(t.product || '')} · ${esc(t.severity || '')} · ${timeAgo(t.created_at)}</div>
365 <div class="support-message" style="margin-top:1rem;">
366 <p>${esc(t.description || '')}</p>
367 </div>`;
368
369 const msgs = t.messages || [];
370 if (msgs.length) {
371 html += '<h3 style="margin-top:1.5rem;">Thread</h3>';
372 msgs.forEach(m => {
373 let attachHtml = '';
374 const atts = m.attachments || [];
375 if (atts.length) {
376 attachHtml = '<div class="support-attachments">';
377 atts.forEach(a => {
378 attachHtml += `<div class="support-attachment-item">\u{1F4CE} ${esc(a.filename || '?')} (${formatSize(a.size_bytes || 0)})</div>`;
379 });
380 attachHtml += '</div>';
381 }
382 html += `<div class="support-message">
383 <div class="support-message-meta">${esc(m.handle || 'unknown')} · ${timeAgo(m.created_at)}</div>
384 <p>${esc(m.content || '')}</p>
385 ${attachHtml}
386 </div>`;
387 });
388 }
389
390 if (t.status !== 'resolved') {
391 html += `<div class="support-reply-form">
392 <textarea id="reply-text" placeholder="Write a reply..."></textarea>
393 <div class="support-drop-zone" id="attach-zone">
394 <input type="file" id="attach-input" multiple accept=".png,.jpg,.jpeg,.gif,.webp,.svg,.pdf,.txt,.csv,.html,.md,.xml,.json">
395 Drop files here or click to attach (max 10 MB each, up to 5 files)
396 </div>
397 <div class="support-file-list" id="attach-file-list"></div>
398 <div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
399 <button class="support-btn" id="reply-submit">Send Reply</button>
400 <button class="support-btn support-btn-secondary" id="attach-only-submit" style="display:none;">Upload Files Only</button>
401 </div>
402 <div id="reply-status" class="support-status-msg"></div>
403 </div>`;
404 }
405
406 detail.innerHTML = html;
407
408 document.getElementById('back-to-list').addEventListener('click', () => {
409 detail.classList.remove('active');
410 detail.innerHTML = '';
411 list.style.display = '';
412 });
413
414 // -- Attachment handling --
415 let pendingFiles = [];
416 const zone = document.getElementById('attach-zone');
417 const fileInput = document.getElementById('attach-input');
418 const fileList = document.getElementById('attach-file-list');
419 const attachOnlyBtn = document.getElementById('attach-only-submit');
420
421 function addFiles(newFiles) {
422 for (const f of newFiles) {
423 if (f.size > 10 * 1024 * 1024) {
424 showStatus('reply-status', f.name + ' exceeds 10 MB limit.', 'error');
425 continue;
426 }
427 if (pendingFiles.length >= 5) {
428 showStatus('reply-status', 'Max 5 files per upload.', 'error');
429 break;
430 }
431 if (!pendingFiles.some(p => p.name === f.name && p.size === f.size)) {
432 pendingFiles.push(f);
433 }
434 }
435 renderFileList();
436 }
437
438 function renderFileList() {
439 if (!fileList) return;
440 if (!pendingFiles.length) {
441 fileList.innerHTML = '';
442 if (attachOnlyBtn) attachOnlyBtn.style.display = 'none';
443 return;
444 }
445 if (attachOnlyBtn) attachOnlyBtn.style.display = '';
446 fileList.innerHTML = pendingFiles.map((f, i) =>
447 `<div class="support-file-entry">
448 <span>\u{1F4CE} ${esc(f.name)} (${formatSize(f.size)})</span>
449 <button class="remove-file" data-idx="${i}">\u00D7</button>
450 </div>`
451 ).join('');
452 fileList.querySelectorAll('.remove-file').forEach(btn => {
453 btn.addEventListener('click', () => {
454 pendingFiles.splice(parseInt(btn.dataset.idx), 1);
455 renderFileList();
456 });
457 });
458 }
459
460 if (zone) {
461 zone.addEventListener('click', () => fileInput && fileInput.click());
462 zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
463 zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
464 zone.addEventListener('drop', e => {
465 e.preventDefault();
466 zone.classList.remove('dragover');
467 if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
468 });
469 }
470 if (fileInput) {
471 fileInput.addEventListener('change', () => {
472 if (fileInput.files.length) addFiles(fileInput.files);
473 fileInput.value = '';
474 });
475 }
476
477 async function uploadPendingFiles(ticketId) {
478 let uploaded = 0;
479 for (const f of pendingFiles) {
480 const form = new FormData();
481 form.append('file', f);
482 const r = await fetch('/app/support/api/tickets/' + ticketId + '/attachments', {
483 method: 'POST',
484 body: form
485 });
486 if (!r.ok) {
487 const err = await r.json().catch(() => ({}));
488 throw new Error(err.error || 'Upload failed for ' + f.name);
489 }
490 uploaded++;
491 }
492 return uploaded;
493 }
494
495 const replyBtn = document.getElementById('reply-submit');
496 if (replyBtn) {
497 replyBtn.addEventListener('click', async () => {
498 const text = document.getElementById('reply-text').value.trim();
499 if (!text && !pendingFiles.length) return;
500 replyBtn.disabled = true;
501 if (attachOnlyBtn) attachOnlyBtn.disabled = true;
502 const status = document.getElementById('reply-status');
503 try {
504 // Send reply text if present
505 if (text) {
506 const r = await fetch('/app/support/api/tickets/' + id + '/reply', {
507 method: 'POST',
508 headers: {'Content-Type': 'application/json'},
509 body: JSON.stringify({content: text})
510 });
511 if (!r.ok) throw new Error('Failed to send reply');
512 }
513 // Upload attachments if any
514 if (pendingFiles.length) {
515 await uploadPendingFiles(id);
516 pendingFiles = [];
517 renderFileList();
518 }
519 status.textContent = text ? 'Reply sent.' : 'Files uploaded.';
520 status.className = 'support-status-msg success';
521 document.getElementById('reply-text').value = '';
522 setTimeout(() => openTicket(id), 500);
523 } catch (e) {
524 status.textContent = e.message || 'Failed to send.';
525 status.className = 'support-status-msg error';
526 }
527 replyBtn.disabled = false;
528 if (attachOnlyBtn) attachOnlyBtn.disabled = false;
529 });
530 }
531
532 // Upload files only (no reply text)
533 if (attachOnlyBtn) {
534 attachOnlyBtn.addEventListener('click', async () => {
535 if (!pendingFiles.length) return;
536 replyBtn.disabled = true;
537 attachOnlyBtn.disabled = true;
538 const status = document.getElementById('reply-status');
539 try {
540 await uploadPendingFiles(id);
541 pendingFiles = [];
542 renderFileList();
543 status.textContent = 'Files uploaded.';
544 status.className = 'support-status-msg success';
545 setTimeout(() => openTicket(id), 500);
546 } catch (e) {
547 status.textContent = e.message || 'Failed to upload.';
548 status.className = 'support-status-msg error';
549 }
550 replyBtn.disabled = false;
551 attachOnlyBtn.disabled = false;
552 });
553 }
554 } catch (e) {
555 detail.innerHTML = '<p>Failed to load ticket.</p>';
556 }
557 }
558
559 // Feedback submission
560 document.getElementById('feedback-submit').addEventListener('click', async function() {
561 const text = document.getElementById('feedback-text').value.trim();
562 if (!text) return;
563 const anon = document.getElementById('feedback-anonymous').checked;
564 const status = document.getElementById('feedback-status');
565 this.disabled = true;
566
567 try {
568 const resp = await fetch('/app/support/api/feedback', {
569 method: 'POST',
570 headers: {'Content-Type': 'application/json'},
571 body: JSON.stringify({body: text, anonymous: anon})
572 });
573 if (resp.ok) {
574 status.textContent = 'Thanks for your feedback!';
575 status.className = 'support-status-msg success';
576 document.getElementById('feedback-text').value = '';
577 } else {
578 throw new Error('Failed');
579 }
580 } catch (e) {
581 status.textContent = 'Failed to submit feedback.';
582 status.className = 'support-status-msg error';
583 }
584 this.disabled = false;
585 });
586
587 // Load announcements
588 async function loadAnnouncements() {
589 try {
590 const resp = await fetch('/app/support/api/announcements');
591 if (!resp.ok) return;
592 const items = await resp.json();
593 if (!items.length) return;
594 const banner = document.getElementById('support-announcements-banner');
595 banner.style.display = '';
596 const icons = {'known-issue': '⚠️', 'maintenance': '🔧', 'info': '📢'};
597 banner.innerHTML = items.map(a =>
598 `<div><h4>${icons[a.type] || '📢'} ${esc(a.title || '')}</h4><p style="margin:0;font-size:0.85rem;">${esc((a.content || '').slice(0, 200))}</p></div>`
599 ).join('');
600 } catch (e) { /* ignore */ }
601 }
602
603 // Helpers
604 function esc(s) {
605 const el = document.createElement('span');
606 el.textContent = s;
607 return el.innerHTML;
608 }
609
610 function formatSize(bytes) {
611 if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
612 if (bytes >= 1024) return Math.round(bytes / 1024) + ' KB';
613 return bytes + ' bytes';
614 }
615
616 function showStatus(elId, msg, type) {
617 const el = document.getElementById(elId);
618 if (el) {
619 el.textContent = msg;
620 el.className = 'support-status-msg ' + type;
621 }
622 }
623
624 function timeAgo(dateStr) {
625 if (!dateStr) return '';
626 const now = Date.now();
627 const then = new Date(dateStr + (dateStr.includes('Z') ? '' : 'Z')).getTime();
628 const s = Math.floor((now - then) / 1000);
629 if (s < 60) return 'just now';
630 if (s < 3600) return Math.floor(s / 60) + 'm ago';
631 if (s < 86400) return Math.floor(s / 3600) + 'h ago';
632 return Math.floor(s / 86400) + 'd ago';
633 }
634
635 // Init
636 loadTickets();
637 loadAnnouncements();
638})();
639</script>