| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648 |
- import type { InterviewFileItem, InterviewListItem } from './types';
- interface DashboardInterviewItem extends InterviewListItem {
- url: string;
- mode: string;
- resumeSlug: string;
- sessionID?: string;
- directory?: string;
- }
- const BRAND_LOGO_URL =
- 'https://ohmyopencodeslim.com/android-chrome-512x512.png';
- export function escapeHtml(value: string): string {
- return value
- .replaceAll('&', '&')
- .replaceAll('<', '<')
- .replaceAll('>', '>')
- .replaceAll('"', '"')
- .replaceAll("'", ''');
- }
- // ─── Shared client-side helpers ────────────────────────────────────
- function clipboardHelperJs(): string {
- return `
- function copyCommand(text, btn) {
- var clip = navigator.clipboard && navigator.clipboard.writeText;
- var useFallback = function() {
- var ta = document.createElement('textarea');
- ta.value = text;
- ta.style.position = 'fixed';
- ta.style.left = '-9999px';
- document.body.appendChild(ta);
- ta.select();
- document.execCommand('copy');
- document.body.removeChild(ta);
- };
- if (clip) {
- clip.call(navigator.clipboard, text).catch(useFallback);
- } else {
- useFallback();
- }
- btn.textContent = 'Copied!';
- btn.classList.add('copied');
- setTimeout(function() {
- btn.textContent = 'Copy';
- btn.classList.remove('copied');
- }, 2000);
- }`;
- }
- // ─── Shared styles ─────────────────────────────────────────────────
- function sharedStyles(): string {
- return `
- :root { color-scheme: dark; }
- body {
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
- margin: 0;
- background: #000000;
- color: #ffffff;
- line-height: 1.6;
- -webkit-font-smoothing: antialiased;
- font-size: 16px;
- }
- .wrap { max-width: 680px; margin: 0 auto; padding: 56px 24px; }
- .brand-header {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 16px;
- margin-bottom: 32px;
- text-align: center;
- }
- .brand-mark {
- object-fit: contain;
- filter: drop-shadow(0 10px 30px rgba(255,255,255,0.1));
- }
- h1 { font-size: 28px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 8px; line-height: 1.2; }
- .muted { color: rgba(255,255,255,0.5); font-size: 16px; }
- code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 4px; font-size: 14px; }
- .resume-row {
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .resume-cmd {
- flex: 1;
- padding: 8px 12px;
- font-size: 13px;
- border-radius: 6px;
- background: rgba(255,255,255,0.05);
- border: 1px solid rgba(255,255,255,0.1);
- white-space: nowrap;
- overflow-x: auto;
- }
- .copy-btn {
- flex-shrink: 0;
- padding: 8px 16px;
- border: 1px solid rgba(255,255,255,0.2);
- border-radius: 6px;
- background: transparent;
- color: rgba(255,255,255,0.8);
- font-size: 13px;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.2s ease;
- }
- .copy-btn:hover {
- background: rgba(255,255,255,0.1);
- border-color: rgba(255,255,255,0.4);
- color: #ffffff;
- }
- .copy-btn.copied {
- background: rgba(52, 211, 153, 0.15);
- border-color: #34d399;
- color: #34d399;
- }
- .footer { margin-top: 32px; text-align: center; font-size: 13px; color: rgba(255,255,255,0.4); }`;
- }
- // ─── Dashboard brand image ─────────────────────────────────────────
- function brandImage(size: number): string {
- return `<img class="brand-mark" src="${BRAND_LOGO_URL}" alt="Oh My Opencode Slim" width="${size}" height="${size}" />`;
- }
- export function renderDashboardPage(
- interviews: DashboardInterviewItem[],
- files: InterviewFileItem[],
- outputFolder: string,
- ): string {
- const activeHtml =
- interviews.length === 0
- ? ''
- : interviews
- .map((item) => {
- const isDisconnected = item.mode === 'session-disconnected';
- const statusLabel = isDisconnected ? 'disconnected' : item.status;
- const statusClass = isDisconnected
- ? 'status-file'
- : `status-${item.status}`;
- const resumeHint = isDisconnected
- ? `<div class="resume-row" style="margin-top:8px"><code class="resume-cmd">/interview ${escapeHtml(item.resumeSlug)}</code><button class="copy-btn" data-cmd="/interview ${escapeHtml(item.resumeSlug)}" title="Copy command">Copy</button></div>`
- : '';
- return `<a class="interview-card" href="${escapeHtml(item.url)}">
- <div class="card-header">
- <span class="card-idea">${escapeHtml(item.idea)}</span>
- <span class="card-status ${statusClass}">${statusLabel}</span>
- </div>
- ${item.directory ? `<div class="card-dir">${escapeHtml(item.directory)}</div>` : ''}
- <div class="card-meta">
- <span>${escapeHtml(new Date(item.createdAt).toLocaleString())}</span>
- </div>
- ${resumeHint}
- </a>`;
- })
- .join('\n');
- // Dedup: skip files whose sessionID matches an active/recovered interview.
- // These are already visible in the "Interviews" section above.
- const activeSessionIDs = new Set(
- interviews
- .map((i) => i.sessionID)
- .filter((sid): sid is string => typeof sid === 'string'),
- );
- const unrecoveredFiles = files.filter((f) => {
- if (f.sessionID && activeSessionIDs.has(f.sessionID)) return false;
- return true;
- });
- const filesHtml =
- unrecoveredFiles.length === 0
- ? ''
- : unrecoveredFiles
- .map(
- (item) =>
- `<div class="interview-card file-card">
- <div class="card-header">
- <span class="card-idea">${escapeHtml(item.title)}</span>
- <span class="card-status status-file">saved</span>
- </div>
- ${item.directory ? `<div class="card-dir">${escapeHtml(item.directory)}</div>` : ''}
- <div class="card-summary">${escapeHtml(item.summary)}</div>
- <div class="resume-row">
- <code class="resume-cmd">${escapeHtml(item.resumeCommand)}</code>
- <button class="copy-btn" data-cmd="${escapeHtml(item.resumeCommand)}" title="Copy command">Copy</button>
- </div>
- </div>`,
- )
- .join('\n');
- const totalCount = interviews.length + unrecoveredFiles.length;
- const emptyState =
- totalCount === 0
- ? '<p class="muted" style="text-align:center;padding:48px 0">No interviews yet. Start one with <code>/interview</code> in your OpenCode session.</p>'
- : '';
- const activeSection =
- interviews.length > 0 ? `<h2>Interviews</h2>${activeHtml}` : '';
- const fileSection =
- unrecoveredFiles.length > 0
- ? `<h2>Files without session</h2><p class="muted file-hint">Resume with the command in any OpenCode session.</p>${filesHtml}`
- : '';
- return `<!doctype html>
- <html lang="en">
- <head>
- <meta charset="utf-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1" />
- <title>Interviews</title>
- <style>
- ${sharedStyles()}
- .brand-mark { width: 96px; height: 96px; }
- h2 { font-size: 18px; font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase; color: rgba(255,255,255,0.4); margin-bottom: 16px; margin-top: 40px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 12px; }
- h2:first-of-type { margin-top: 0; }
- .file-hint { font-size: 14px; margin-bottom: 16px; margin-top: -8px; }
- .interview-card {
- display: block;
- background: rgba(255,255,255,0.02);
- border: 1px solid rgba(255,255,255,0.1);
- border-radius: 8px;
- padding: 20px 24px;
- margin-bottom: 12px;
- text-decoration: none;
- color: inherit;
- transition: all 0.2s ease;
- }
- .interview-card:hover {
- background: rgba(255,255,255,0.06);
- border-color: rgba(255,255,255,0.3);
- transform: translateX(4px);
- }
- .file-card {
- cursor: default;
- }
- .file-card:hover {
- transform: none;
- }
- .card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
- .card-idea { font-size: 17px; font-weight: 500; line-height: 1.4; flex: 1; }
- .card-status {
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- padding: 4px 10px;
- border-radius: 999px;
- font-weight: 600;
- flex-shrink: 0;
- }
- .status-active { background: rgba(52, 211, 153, 0.15); color: #34d399; }
- .status-abandoned { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.4); }
- .status-file { background: rgba(96, 165, 250, 0.15); color: #60a5fa; }
- .card-meta { font-size: 13px; color: rgba(255,255,255,0.4); }
- .card-summary { font-size: 14px; color: rgba(255,255,255,0.5); margin-bottom: 12px; line-height: 1.5; }
- .card-dir { font-size: 12px; color: rgba(255,255,255,0.3); margin-bottom: 8px; font-family: monospace; word-break: break-all; }
- .update-banner {
- position: fixed;
- top: -56px;
- left: 0; right: 0;
- background: rgba(96, 165, 250, 0.95);
- color: #fff;
- text-align: center;
- padding: 14px 24px;
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- z-index: 200;
- transition: top 0.3s ease;
- backdrop-filter: blur(8px);
- }
- .update-banner.visible { top: 0; }
- .update-banner:hover { background: rgba(96, 165, 250, 1); }
- .info-box {
- font-size: 13px;
- color: rgba(255,255,255,0.45);
- background: rgba(255,255,255,0.03);
- border: 1px solid rgba(255,255,255,0.08);
- border-radius: 8px;
- padding: 16px 20px;
- margin-bottom: 40px;
- line-height: 1.7;
- }
- .info-box strong { color: rgba(255,255,255,0.65); font-weight: 500; }
- .info-box code {
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- background: rgba(255,255,255,0.08);
- padding: 2px 6px;
- border-radius: 4px;
- font-size: 12px;
- word-break: break-all;
- }
- .settings-row {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-top: 12px;
- flex-wrap: wrap;
- }
- .settings-row label {
- font-size: 13px;
- color: rgba(255,255,255,0.5);
- white-space: nowrap;
- }
- .settings-row select, .settings-row input[type="text"] {
- background: rgba(255,255,255,0.05);
- border: 1px solid rgba(255,255,255,0.15);
- color: #fff;
- border-radius: 4px;
- padding: 6px 10px;
- font-size: 13px;
- font-family: inherit;
- }
- .settings-row input[type="text"] {
- flex: 1;
- min-width: 200px;
- }
- .settings-row select:focus, .settings-row input[type="text"]:focus {
- border-color: rgba(255,255,255,0.4);
- outline: none;
- }
- .small-btn {
- padding: 6px 14px;
- border: 1px solid rgba(255,255,255,0.2);
- border-radius: 4px;
- background: transparent;
- color: rgba(255,255,255,0.8);
- font-size: 13px;
- cursor: pointer;
- transition: all 0.2s ease;
- }
- .small-btn:hover {
- background: rgba(255,255,255,0.1);
- border-color: rgba(255,255,255,0.4);
- color: #fff;
- }
- .folder-list {
- display: flex;
- flex-direction: column;
- gap: 4px;
- margin-top: 8px;
- }
- .folder-item {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 13px;
- color: rgba(255,255,255,0.5);
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- }
- .folder-item button {
- padding: 2px 8px;
- border: 1px solid rgba(255,255,255,0.15);
- border-radius: 3px;
- background: transparent;
- color: rgba(255,255,255,0.4);
- font-size: 11px;
- cursor: pointer;
- }
- .folder-item button:hover {
- border-color: rgba(255,100,100,0.5);
- color: rgba(255,100,100,0.8);
- }
- </style>
- </head>
- <body>
- <div class="wrap">
- <div class="brand-header">
- ${brandImage(96)}
- <h1>Interviews</h1>
- <p class="muted">${totalCount} item${totalCount === 1 ? '' : 's'}</p>
- </div>
- <div class="info-box">
- <strong>Interviews</strong> — live sessions and recovered files. State is pushed from OpenCode sessions to this dashboard.<br>
- <strong>Files without session</strong> — <code>.md</code> files in <code>${escapeHtml(outputFolder)}</code> with no frontmatter. Resume with <code>/interview <name></code>.
- </div>
- <details style="margin-bottom:24px">
- <summary style="cursor:pointer;font-size:13px;color:rgba(255,255,255,0.4);user-select:none">Settings</summary>
- <div style="margin-top:12px">
- <div class="settings-row">
- <label for="scanDays">Scan sessions from last</label>
- <select id="scanDays">
- <option value="1">1 day</option>
- <option value="7">7 days</option>
- <option value="30" selected>30 days</option>
- <option value="90">90 days</option>
- <option value="0">All time</option>
- </select>
- <button class="small-btn" id="rescanBtn">Rescan</button>
- </div>
- <div class="settings-row">
- <input type="text" id="addFolderInput" placeholder="/path/to/project" />
- <button class="small-btn" id="addFolderBtn">Add Folder</button>
- </div>
- <div class="folder-list" id="folderList"></div>
- </div>
- </details>
- ${emptyState}
- ${activeSection}
- ${fileSection}
- <div class="footer">OH MY OPENCODE SLIM</div>
- </div>
- <div class="update-banner" id="updateBanner">Dashboard updated — tap to refresh</div>
- <script>
- ${clipboardHelperJs()}
- document.querySelectorAll('.copy-btn').forEach(btn => {
- btn.addEventListener('click', (e) => {
- e.stopPropagation();
- e.preventDefault();
- copyCommand(btn.dataset.cmd, btn);
- });
- });
- let lastSig = '';
- let bannerTimer = null;
- // Use health endpoint for change detection (no auth needed, shows counts)
- (async () => { try { const r = await fetch('/api/health'); if (r.ok) lastSig = await r.text(); } catch {} })();
- setInterval(async () => {
- try {
- const res = await fetch('/api/health');
- if (!res.ok) return;
- const sig = await res.text();
- if (sig !== lastSig) {
- lastSig = sig;
- const banner = document.getElementById('updateBanner');
- banner.classList.add('visible');
- if (bannerTimer) clearTimeout(bannerTimer);
- bannerTimer = setTimeout(() => banner.classList.remove('visible'), 30000);
- }
- } catch {}
- }, 15000);
- document.getElementById('updateBanner').addEventListener('click', () => location.reload());
- // Settings: load current state
- (async () => {
- try {
- const r = await fetch('/api/settings');
- if (!r.ok) return;
- const s = await r.json();
- const sel = document.getElementById('scanDays');
- if (sel && s.scanDays !== undefined) sel.value = String(s.scanDays);
- renderFolderList(s.folders || []);
- } catch {}
- })();
- function renderFolderList(folders) {
- const list = document.getElementById('folderList');
- if (!list) return;
- list.innerHTML = '';
- folders.forEach(function(dir) {
- const item = document.createElement('div');
- item.className = 'folder-item';
- const span = document.createElement('span');
- span.textContent = dir;
- span.style.flex = '1';
- const btn = document.createElement('button');
- btn.textContent = 'Remove';
- btn.addEventListener('click', async function() {
- await fetch('/api/settings', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ removeFolder: dir })
- });
- location.reload();
- });
- item.appendChild(span);
- item.appendChild(btn);
- list.appendChild(item);
- });
- }
- document.getElementById('rescanBtn')?.addEventListener('click', async function() {
- const days = parseInt(document.getElementById('scanDays').value, 10);
- await fetch('/api/settings', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ scanDays: days, discover: true })
- });
- location.reload();
- });
- document.getElementById('addFolderBtn')?.addEventListener('click', async function() {
- const input = document.getElementById('addFolderInput');
- const dir = input.value.trim();
- if (!dir) return;
- await fetch('/api/settings', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ addFolder: dir })
- });
- input.value = '';
- location.reload();
- });
- </script>
- </body>
- </html>`;
- }
- export function renderInterviewPage(
- interviewId: string,
- resumeSlug: string,
- ): string {
- const safeTitle = escapeHtml(interviewId);
- return `<!doctype html>
- <html lang="en">
- <head>
- <meta charset="utf-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1" />
- <title>Interview ${safeTitle}</title>
- <style>
- ${sharedStyles()}
- .brand-mark { width: 144px; height: 144px; }
- h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 12px; line-height: 1.2; }
- h2 { font-size: 18px; font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase; color: rgba(255,255,255,0.4); margin-bottom: 24px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 12px; }
- h3 { font-size: 18px; font-weight: 500; margin-bottom: 16px; line-height: 1.4; }
- p { margin-top: 0; }
- .meta { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: rgba(255,255,255,0.4); margin-bottom: 16px; letter-spacing: 0.05em; text-transform: uppercase; }
-
- .file-path-container {
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 13px;
- color: rgba(255,255,255,0.6);
- background: rgba(255,255,255,0.05);
- padding: 8px 12px;
- border-radius: 6px;
- margin-bottom: 12px;
- display: flex;
- align-items: center;
- gap: 8px;
- border: 1px solid rgba(255,255,255,0.08);
- }
- .file-path-icon {
- opacity: 0.5;
- }
- .download-btn {
- margin-left: auto;
- background: transparent;
- border: 1px solid rgba(255,255,255,0.2);
- color: rgba(255,255,255,0.6);
- border-radius: 4px;
- padding: 4px 10px;
- font-size: 12px;
- cursor: pointer;
- transition: all 0.2s ease;
- }
- .download-btn:hover {
- border-color: rgba(255,255,255,0.5);
- color: #ffffff;
- }
- .question {
- background: rgba(255,255,255,0.02);
- border: 1px solid rgba(255,255,255,0.1);
- border-left: 1px solid rgba(255,255,255,0.1);
- border-radius: 6px;
- padding: 28px;
- margin-bottom: 32px;
- transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
- }
- .question:focus-within {
- border-color: rgba(255,255,255,0.3);
- }
-
- /* Make active question much clearer */
- .question.active-question {
- background: rgba(255,255,255,0.04);
- border-color: rgba(255,255,255,0.4);
- border-left: 4px solid #ffffff;
- box-shadow: 0 8px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.1);
- transform: translateX(4px);
- }
-
- .options { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }
- .question-hint {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin: -4px 0 16px;
- color: rgba(255,255,255,0.5);
- font-size: 13px;
- line-height: 1.5;
- transition: color 0.2s ease;
- }
- .active-question .question-hint {
- color: rgba(255,255,255,0.78);
- }
- .hint-chip {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 4px 10px;
- border-radius: 999px;
- background: rgba(255,255,255,0.06);
- border: 1px solid rgba(255,255,255,0.08);
- }
- .hint-chip kbd {
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 12px;
- padding: 2px 6px;
- border-radius: 6px;
- background: rgba(255,255,255,0.12);
- border: 1px solid rgba(255,255,255,0.08);
- color: rgba(255,255,255,0.95);
- }
-
- .option {
- border: 1px solid rgba(255,255,255,0.1);
- background: transparent;
- color: inherit;
- border-radius: 6px;
- padding: 14px 18px;
- cursor: pointer;
- text-align: left;
- font-size: 16px;
- transition: all 0.2s ease;
- display: flex;
- align-items: center;
- }
- .option:hover {
- background: rgba(255,255,255,0.06);
- border-color: rgba(255,255,255,0.3);
- }
- .option.selected {
- background: #ffffff;
- color: #000000;
- border-color: #ffffff;
- font-weight: 500;
- }
-
- .shortcut {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- background: rgba(255,255,255,0.1);
- color: rgba(255,255,255,0.8);
- border-radius: 4px;
- min-width: 20px;
- height: 20px;
- padding: 0 4px;
- font-size: 12px;
- margin-right: 12px;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- }
- .option.selected .shortcut {
- background: rgba(0,0,0,0.15);
- color: rgba(0,0,0,0.9);
- }
-
- .option-text {
- flex: 1;
- line-height: 1.4;
- }
- .recommended-badge {
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- background: rgba(255,255,255,0.15);
- color: rgba(255,255,255,0.9);
- padding: 4px 8px;
- border-radius: 999px;
- margin-left: 12px;
- font-weight: 600;
- }
- .option.selected .recommended-badge {
- background: rgba(0,0,0,0.15);
- color: rgba(0,0,0,0.8);
- }
- .submit-shortcut {
- display: inline-block;
- margin-left: 10px;
- padding: 3px 8px;
- border-radius: 999px;
- background: rgba(0,0,0,0.08);
- color: rgba(0,0,0,0.7);
- font-size: 12px;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- }
-
- textarea {
- width: 100%;
- box-sizing: border-box;
- min-height: 140px;
- border-radius: 6px;
- border: 1px solid rgba(255,255,255,0.15);
- background: rgba(0,0,0,0.6);
- color: inherit;
- padding: 16px;
- font-family: inherit;
- font-size: 16px;
- line-height: 1.5;
- resize: vertical;
- outline: none;
- transition: border-color 0.2s ease;
- }
- textarea:focus {
- border-color: rgba(255,255,255,0.5);
- box-shadow: 0 0 0 1px rgba(255,255,255,0.1);
- }
- .hidden-textarea {
- display: none;
- }
-
- button.primary {
- background: #ffffff;
- color: #000000;
- border: 0;
- border-radius: 6px;
- padding: 16px 24px;
- font-size: 16px;
- font-weight: 600;
- cursor: pointer;
- width: 100%;
- transition: opacity 0.2s ease, transform 0.1s ease;
- }
- button.primary:hover:not(:disabled) {
- opacity: 0.9;
- transform: translateY(-1px);
- }
- button.primary:active:not(:disabled) {
- transform: translateY(1px);
- }
- button.primary:disabled {
- opacity: 0.3;
- cursor: not-allowed;
- }
-
- .footer {
- margin-top: 32px;
- text-align: center;
- font-size: 13px;
- color: rgba(255,255,255,0.4);
- }
- .back-link {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- color: rgba(255,255,255,0.5);
- text-decoration: none;
- font-size: 14px;
- padding: 8px 0;
- transition: color 0.2s ease;
- position: relative;
- z-index: 200;
- }
- .back-link:hover { color: #ffffff; }
- .status-completed { color: #34d399; }
- /* Loading State Overlay */
- .loading-overlay {
- position: fixed;
- top: 0; left: 0; right: 0; bottom: 0;
- background: rgba(0, 0, 0, 0.85);
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- z-index: 100;
- opacity: 0;
- pointer-events: none;
- backdrop-filter: blur(8px);
- transition: opacity 0.3s ease;
- }
- .loading-overlay.active {
- opacity: 1;
- pointer-events: all;
- }
-
- .loading-overlay .status-text {
- font-size: 15px;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: #ffffff;
- font-weight: 500;
- }
- /* ── Completed Mode ─────────────────────────────────────────── */
- .section-label {
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: rgba(255,255,255,0.28);
- margin-bottom: 14px;
- }
- .spec-block {
- background: rgba(255,255,255,0.02);
- border: 1px solid rgba(255,255,255,0.08);
- border-radius: 6px;
- padding: 20px 24px;
- margin-bottom: 32px;
- color: rgba(255,255,255,0.72);
- font-size: 15px;
- line-height: 1.75;
- }
- .spec-block p { margin: 0 0 10px; }
- .spec-block p:last-child { margin-bottom: 0; }
- .spec-block strong { color: rgba(255,255,255,0.95); font-weight: 600; }
- .spec-block code {
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: 13px;
- background: rgba(255,255,255,0.08);
- padding: 1px 5px;
- border-radius: 3px;
- }
- .qa-list { margin-bottom: 12px; }
- .qa-card {
- background: rgba(255,255,255,0.02);
- border: 1px solid rgba(255,255,255,0.08);
- border-radius: 6px;
- padding: 18px 22px;
- margin-bottom: 10px;
- }
- .qa-row { display: flex; gap: 14px; align-items: flex-start; }
- .qa-row + .qa-row { margin-top: 12px; }
- .qa-badge {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 22px;
- height: 22px;
- border-radius: 50%;
- font-size: 10px;
- font-weight: 700;
- flex-shrink: 0;
- margin-top: 2px;
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- }
- .qa-badge-q { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.85); }
- .qa-badge-a { background: rgba(52,211,153,0.15); color: #34d399; }
- .qa-divider { height: 1px; background: rgba(255,255,255,0.05); margin: 12px 0; }
- .qa-text { font-size: 15px; line-height: 1.55; padding-top: 2px; }
- .qa-text-q { color: rgba(255,255,255,0.82); }
- .qa-text-a { color: rgba(255,255,255,0.52); }
- .qa-empty {
- color: rgba(255,255,255,0.25);
- font-size: 14px;
- text-align: center;
- padding: 20px 0;
- font-style: italic;
- }
- .nudge-actions {
- display: flex;
- gap: 12px;
- margin-top: 32px;
- margin-bottom: 8px;
- }
- .nudge-btn {
- flex: 1;
- background: transparent;
- border: 1px solid rgba(255,255,255,0.15);
- color: rgba(255,255,255,0.65);
- border-radius: 6px;
- padding: 13px 16px;
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.2s ease;
- letter-spacing: 0.01em;
- }
- .nudge-btn:hover:not(:disabled) {
- border-color: rgba(255,255,255,0.4);
- color: #ffffff;
- background: rgba(255,255,255,0.04);
- }
- .nudge-btn.nudge-confirm {
- border-color: rgba(52,211,153,0.25);
- color: rgba(52,211,153,0.8);
- }
- .nudge-btn.nudge-confirm:hover:not(:disabled) {
- border-color: rgba(52,211,153,0.6);
- color: #34d399;
- background: rgba(52,211,153,0.05);
- }
- .nudge-btn:disabled { opacity: 0.3; cursor: not-allowed; }
- </style>
- </head>
- <body>
- <div class="wrap">
- <a href="/" class="back-link">← All Interviews</a>
- <div class="brand-header">
- ${brandImage(144)}
- </div>
- <h1 id="idea">Connecting...</h1>
- <p class="muted" id="summary">Preparing interview session</p>
-
- <div class="meta">
- <span id="status">INITIALIZING</span>
- <span>OH MY OPENCODE SLIM</span>
- </div>
-
- <div id="filePathContainer" class="file-path-container" style="display: none;">
- <span class="file-path-icon">📄</span>
- <span id="markdownPath"></span>
- <button id="downloadBtn" class="download-btn" type="button">Download</button>
- </div>
- <div id="resumeRow" class="resume-row" style="display:none;">
- <code id="resumeCmd" class="resume-cmd"></code>
- <button class="copy-btn" id="copyResumeBtn" type="button">Copy</button>
- </div>
- <div id="questions"></div>
- <div id="nudgeActions" class="nudge-actions" style="display:none;">
- <button class="nudge-btn" id="moreQuestionsBtn" type="button">Ask more questions</button>
- <button class="nudge-btn nudge-confirm" id="confirmCompleteBtn" type="button">Confirm complete ✓</button>
- </div>
-
- <button class="primary" id="submitButton" disabled>Submit Answers <span class="submit-shortcut">⌘↵</span></button>
-
- <div class="footer" id="submitStatus"></div>
- </div>
-
- <div class="loading-overlay" id="loadingOverlay">
- <div class="status-text" id="loadingText">Processing...</div>
- </div>
- <script>
- ${clipboardHelperJs()}
- const interviewId = ${JSON.stringify(interviewId).replace(/</g, '\\u003c')};
- const resumeSlug = ${JSON.stringify(resumeSlug).replace(/</g, '\\u003c')};
- const state = {
- data: null,
- answers: {},
- activeQuestionIndex: 0,
- lastQuestionIds: [],
- lastSig: null,
- customMode: {},
- isSubmitting: false,
- };
- function updateSubmitButton() {
- const button = document.getElementById('submitButton');
- if (!state.data) {
- button.disabled = true;
- return;
- }
- const questions = state.data.questions || [];
- const allAnswered = questions.every((question) =>
- (state.answers[question.id] || '').trim().length > 0,
- );
- button.disabled =
- state.data.isBusy ||
- state.isSubmitting ||
- !questions.length ||
- !allAnswered;
- const hideSubmit = ['completed', 'session-disconnected'];
- button.style.display = hideSubmit.includes(state.data.mode) ? 'none' : '';
-
- const overlay = document.getElementById('loadingOverlay');
- const overlayText = document.getElementById('loadingText');
- if (state.data.isBusy) {
- overlay.classList.add('active');
- overlayText.textContent = "Agent Thinking...";
- } else {
- overlay.classList.remove('active');
- }
- }
- function getOptionButtonId(questionId, index) {
- return 'opt-' + questionId + '-' + index;
- }
- function createOption(question, option, index, isCustom) {
- const button = document.createElement('button');
- button.type = 'button';
- button.className = 'option';
- button.id = getOptionButtonId(question.id, index);
-
- const shortcut = index < 9 ? (index + 1) : '';
- if (shortcut) {
- const kbd = document.createElement('span');
- kbd.className = 'shortcut';
- kbd.textContent = shortcut;
- button.appendChild(kbd);
- }
-
- const text = document.createElement('span');
- text.className = 'option-text';
- text.textContent = isCustom ? 'Custom' : option;
- button.appendChild(text);
- // Visual marking for suggested/recommended answers
- if (!isCustom && question.suggested === option) {
- const badge = document.createElement('span');
- badge.className = 'recommended-badge';
- badge.textContent = 'Recommended';
- button.appendChild(badge);
- }
- button.addEventListener('click', () => {
- const questions = state.data?.questions || [];
- const qIdx = questions.findIndex(q => q.id === question.id);
- if (qIdx !== -1) {
- state.activeQuestionIndex = qIdx;
- updateActiveQuestionFocus();
- }
- handleOptionSelect(question, option, isCustom);
- });
-
- return button;
- }
- function handleOptionSelect(question, option, isCustom) {
- const textarea = document.getElementById('answer-' + question.id);
-
- if (isCustom) {
- state.customMode[question.id] = true;
- state.answers[question.id] = state.customMode[question.id]
- ? state.answers[question.id] || ''
- : '';
- updateTextareaVisibility(question.id);
- updateOptionsDOM(question.id);
- if (textarea) {
- textarea.focus();
- }
- } else {
- state.customMode[question.id] = false;
- state.answers[question.id] = option;
- updateTextareaVisibility(question.id);
- advanceToNextQuestion(question.id);
- }
-
- updateSubmitButton();
- updateOptionsDOM(question.id);
- }
- function updateTextareaVisibility(questionId) {
- const textarea = document.getElementById('answer-' + questionId);
- if (!textarea) return;
- if (state.customMode[questionId]) {
- textarea.classList.remove('hidden-textarea');
- } else {
- textarea.classList.add('hidden-textarea');
- }
- }
- function advanceToNextQuestion(currentQuestionId) {
- const questions = state.data?.questions || [];
- const currentIndex = questions.findIndex(q => q.id === currentQuestionId);
-
- if (currentIndex >= 0 && currentIndex < questions.length - 1) {
- state.activeQuestionIndex = currentIndex + 1;
- updateActiveQuestionFocus();
- const nextQuestion = questions[currentIndex + 1];
- const nextEl = document.getElementById('question-' + nextQuestion.id);
- if (nextEl) {
- nextEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
- }
- } else if (currentIndex === questions.length - 1) {
- const submitBtn = document.getElementById('submitButton');
- if (submitBtn && !submitBtn.disabled) {
- submitBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
- }
- }
- }
- function updateOptionsDOM(questionId) {
- const question = (state.data?.questions || []).find(q => q.id === questionId);
- if (!question) return;
-
- const predefined = question.options || [];
- const currentAnswer = state.answers[question.id];
-
- predefined.forEach((opt, idx) => {
- const btn = document.getElementById(getOptionButtonId(questionId, idx));
- if (btn) {
- if (currentAnswer === opt) btn.classList.add('selected');
- else btn.classList.remove('selected');
- }
- });
-
- const customBtn = document.getElementById(getOptionButtonId(questionId, predefined.length));
- if (customBtn) {
- if (state.customMode[questionId]) {
- customBtn.classList.add('selected');
- } else {
- customBtn.classList.remove('selected');
- }
- }
- }
- function updateActiveQuestionFocus() {
- const questions = state.data?.questions || [];
- questions.forEach((q, idx) => {
- const wrapper = document.getElementById('question-' + q.id);
- if (wrapper) {
- if (idx === state.activeQuestionIndex) {
- wrapper.classList.add('active-question');
- } else {
- wrapper.classList.remove('active-question');
- }
- }
- });
- }
- function scrollToActiveQuestion(behavior) {
- const questions = state.data?.questions || [];
- const activeQ = questions[state.activeQuestionIndex];
- if (!activeQ) return;
- const wrapper = document.getElementById('question-' + activeQ.id);
- if (wrapper) {
- wrapper.scrollIntoView({ behavior, block: 'center' });
- }
- }
- function syncActiveQuestionIndex(questions) {
- if (!questions.length) {
- state.activeQuestionIndex = 0;
- state.lastQuestionIds = [];
- return;
- }
- const nextQuestionIds = questions.map((question) => question.id);
- const previousQuestionIds = state.lastQuestionIds || [];
- const activeQuestionId = previousQuestionIds[state.activeQuestionIndex];
- const nextActiveIndex = activeQuestionId
- ? nextQuestionIds.indexOf(activeQuestionId)
- : -1;
- if (nextActiveIndex >= 0) {
- state.activeQuestionIndex = nextActiveIndex;
- } else {
- state.activeQuestionIndex = 0;
- }
- state.lastQuestionIds = nextQuestionIds;
- }
- function isTextEntryTarget(target) {
- return target &&
- (target.tagName === 'TEXTAREA' ||
- target.tagName === 'INPUT' ||
- target.isContentEditable);
- }
- function isShortcutBlockedTarget(target) {
- if (!target) return false;
- return !!target.closest(
- 'button, a, select, summary, textarea, input, [contenteditable="true"]',
- );
- }
- document.addEventListener('keydown', (e) => {
- const isSubmitShortcut =
- (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) ||
- (e.key === 's' && (e.metaKey || e.ctrlKey));
- if (isSubmitShortcut) {
- const submitBtn = document.getElementById('submitButton');
- if (submitBtn && !submitBtn.disabled) {
- submitBtn.click();
- e.preventDefault();
- }
- return;
- }
- if (isTextEntryTarget(e.target)) return;
- if (e.ctrlKey || e.metaKey || e.altKey) return;
- const questions = state.data?.questions || [];
- if (!questions.length) return;
- if (e.key === 'Enter') {
- if (e.repeat) {
- e.preventDefault();
- return;
- }
- if (isShortcutBlockedTarget(e.target)) return;
- if (state.data.isBusy || state.isSubmitting) return;
- const activeQ = questions[state.activeQuestionIndex];
- if (!activeQ) return;
- const answer = (state.answers[activeQ.id] || '').trim();
- if (!answer) return;
- const isLastQuestion =
- state.activeQuestionIndex === questions.length - 1;
- if (isLastQuestion) {
- const submitBtn = document.getElementById('submitButton');
- if (submitBtn && !submitBtn.disabled) {
- submitBtn.click();
- }
- } else {
- advanceToNextQuestion(activeQ.id);
- }
- e.preventDefault();
- return;
- }
- const num = parseInt(e.key, 10);
- if (num >= 1 && num <= 9) {
- const activeQ = questions[state.activeQuestionIndex];
- if (!activeQ) return;
-
- const options = activeQ.options || [];
- if (!options.length) return;
- const idx = num - 1;
-
- if (idx < options.length) {
- handleOptionSelect(activeQ, options[idx], false);
- e.preventDefault();
- } else if (idx === options.length) {
- handleOptionSelect(activeQ, 'Custom', true);
- e.preventDefault();
- }
- }
-
- if (e.key === 'ArrowDown') {
- if (state.activeQuestionIndex < questions.length - 1) {
- state.activeQuestionIndex++;
- updateActiveQuestionFocus();
- const wrapper = document.getElementById('question-' + questions[state.activeQuestionIndex].id);
- if (wrapper) wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
- e.preventDefault();
- }
- }
- if (e.key === 'ArrowUp') {
- if (state.activeQuestionIndex > 0) {
- state.activeQuestionIndex--;
- updateActiveQuestionFocus();
- const wrapper = document.getElementById('question-' + questions[state.activeQuestionIndex].id);
- if (wrapper) wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
- e.preventDefault();
- }
- }
- });
- // ── Completed-mode helpers ───────────────────────────────────
- function parseDocument(doc) {
- if (!doc) return { spec: '', qaPairs: [] };
- // Strip frontmatter (---...---) to prevent frontmatter lines from
- // matching section headers or being parsed as content
- let content = doc;
- const fmMatch = content.match(/^---\\n[\\s\\S]*?\\n---\\n/);
- if (fmMatch) content = content.slice(fmMatch[0].length);
- const lines = content.split('\\n');
- const specLines = [];
- const qaLines = [];
- let section = null;
- for (const line of lines) {
- if (/^## Current spec\\b/i.test(line)) { section = 'spec'; continue; }
- if (/^## Q&A history\\b/i.test(line)) { section = 'qa'; continue; }
- if (/^#{1,6} /.test(line)) { section = null; continue; }
- if (section === 'spec') specLines.push(line);
- else if (section === 'qa') qaLines.push(line);
- }
- const qaPairs = [];
- let current = null;
- for (const line of qaLines) {
- const trimmed = line.trim();
- if (trimmed.startsWith('Q: ')) {
- if (current) qaPairs.push(current);
- current = { q: trimmed.slice(3), a: '' };
- } else if (trimmed.startsWith('A: ') && current) {
- current.a = trimmed.slice(3);
- } else if (trimmed && current && current.a) {
- current.a += ' ' + trimmed;
- }
- }
- if (current) qaPairs.push(current);
- return { spec: specLines.join('\\n').trim(), qaPairs };
- }
- // simpleMarkdown: safe because escaping happens BEFORE markdown
- // processing. Adding raw HTML output from user content would break
- // this safety — always use textContent for user data.
- function simpleMarkdown(text) {
- if (!text) return '';
- const escaped = text
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>');
- const formatted = escaped
- .replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
- .replace(/\`([^\`]+)\`/g, '<code>$1</code>');
- const paragraphs = formatted.split(/\\n\\n+/).filter(p => p.trim());
- return paragraphs.map(p => \`<p>\${p.replace(/\\n/g, '<br>')}</p>\`).join('');
- }
- function renderCompletedView(data) {
- const container = document.getElementById('questions');
- const { spec, qaPairs } = parseDocument(data.document);
- const frag = document.createDocumentFragment();
- // Spec section
- if (spec) {
- const specLabel = document.createElement('div');
- specLabel.className = 'section-label';
- specLabel.textContent = 'Current Spec';
- frag.appendChild(specLabel);
- const specBlock = document.createElement('div');
- specBlock.className = 'spec-block';
- specBlock.innerHTML = simpleMarkdown(spec);
- frag.appendChild(specBlock);
- }
- // Q&A section
- const qaLabel = document.createElement('div');
- qaLabel.className = 'section-label';
- qaLabel.textContent = 'Q&A History';
- frag.appendChild(qaLabel);
- if (!qaPairs.length) {
- const empty = document.createElement('p');
- empty.className = 'qa-empty';
- empty.textContent = 'No Q&A recorded.';
- frag.appendChild(empty);
- } else {
- const qaList = document.createElement('div');
- qaList.className = 'qa-list';
- for (const pair of qaPairs) {
- const card = document.createElement('div');
- card.className = 'qa-card';
- const qRow = document.createElement('div');
- qRow.className = 'qa-row';
- const qBadge = document.createElement('span');
- qBadge.className = 'qa-badge qa-badge-q';
- qBadge.textContent = 'Q';
- const qText = document.createElement('span');
- qText.className = 'qa-text qa-text-q';
- qText.textContent = pair.q;
- qRow.appendChild(qBadge);
- qRow.appendChild(qText);
- card.appendChild(qRow);
- const divider = document.createElement('div');
- divider.className = 'qa-divider';
- card.appendChild(divider);
- const aRow = document.createElement('div');
- aRow.className = 'qa-row';
- const aBadge = document.createElement('span');
- aBadge.className = 'qa-badge qa-badge-a';
- aBadge.textContent = 'A';
- const aText = document.createElement('span');
- aText.className = 'qa-text qa-text-a';
- aText.textContent = pair.a || '—';
- aRow.appendChild(aBadge);
- aRow.appendChild(aText);
- card.appendChild(aRow);
- qaList.appendChild(card);
- }
- frag.appendChild(qaList);
- }
- container.replaceChildren(frag);
- }
- function renderQuestions(questions) {
- const sig = JSON.stringify([questions, state.data?.mode]);
- const container = document.getElementById('questions');
- const previousActiveQuestionId =
- state.lastQuestionIds[state.activeQuestionIndex];
- syncActiveQuestionIndex(questions);
- if (state.lastSig === sig) {
- questions.forEach((q) => updateOptionsDOM(q.id));
- updateActiveQuestionFocus();
- return;
- }
-
- state.lastSig = sig;
- container.replaceChildren();
- if (!questions.length && !state.data?.isBusy) {
- const doneModes = ['completed', 'session-disconnected'];
- if (doneModes.includes(state.data?.mode)) {
- renderCompletedView(state.data);
- } else {
- const empty = document.createElement('p');
- empty.className = 'muted';
- empty.style.textAlign = 'center';
- empty.style.padding = '48px 0';
- empty.textContent = 'No active questions right now.';
- container.appendChild(empty);
- }
- return;
- }
- questions.forEach((question, idx) => {
- const wrapper = document.createElement('div');
- wrapper.className = 'question';
- wrapper.id = 'question-' + question.id;
-
- if (question.suggested && !state.answers[question.id]) {
- state.answers[question.id] = question.suggested;
- state.customMode[question.id] = false;
- }
- const title = document.createElement('h3');
- title.textContent = question.question;
- wrapper.appendChild(title);
- const predefined = question.options || [];
- if (predefined.length) {
- const hint = document.createElement('div');
- hint.className = 'question-hint';
- hint.innerHTML =
- '<span class="hint-chip"><kbd>1-9</kbd><span>Choose an option</span></span>' +
- '<span class="hint-chip"><kbd>Enter</kbd><span>Accept selected answer</span></span>';
- wrapper.appendChild(hint);
- }
- if (predefined.length) {
- const options = document.createElement('div');
- options.className = 'options';
- predefined.forEach((option, optIdx) => {
- options.appendChild(createOption(question, option, optIdx, false));
- });
- options.appendChild(createOption(question, 'Custom', predefined.length, true));
- wrapper.appendChild(options);
- }
- const textarea = document.createElement('textarea');
- textarea.id = 'answer-' + question.id;
- textarea.placeholder = 'Type your answer here...';
- textarea.value = state.customMode[question.id] ? (state.answers[question.id] || '') : '';
- if (!state.customMode[question.id]) {
- textarea.classList.add('hidden-textarea');
- }
-
- textarea.addEventListener('focus', () => {
- state.activeQuestionIndex = idx;
- updateActiveQuestionFocus();
- });
-
- textarea.addEventListener('input', () => {
- state.answers[question.id] = textarea.value;
- updateSubmitButton();
- updateOptionsDOM(question.id);
- });
- wrapper.appendChild(textarea);
- container.appendChild(wrapper);
- });
-
- updateActiveQuestionFocus();
- questions.forEach(q => updateOptionsDOM(q.id));
- const currentActiveQuestionId = questions[state.activeQuestionIndex]?.id;
- if (
- questions.length > 0 &&
- previousActiveQuestionId !== currentActiveQuestionId
- ) {
- scrollToActiveQuestion('smooth');
- }
- }
- function render(data) {
- state.data = data;
- document.getElementById('idea').textContent = data.interview.idea || 'Interview';
- const doneModes = ['completed', 'session-disconnected'];
- const isDone = doneModes.includes(data.mode);
- const summaryEl = document.getElementById('summary');
- if (isDone) {
- summaryEl.style.display = 'none';
- } else {
- summaryEl.style.display = '';
- summaryEl.textContent = data.summary || 'Session in progress.';
- }
- const statusEl = document.getElementById('status');
- statusEl.textContent = data.mode.toUpperCase();
- statusEl.className = isDone ? 'status-completed' : '';
-
- // Render Markdown Path — always visible in completed mode
- const pathContainer = document.getElementById('filePathContainer');
- const pathElement = document.getElementById('markdownPath');
- const mdPath = data.markdownPath || (data.interview && data.interview.markdownPath);
- if (mdPath || isDone) {
- if (mdPath) {
- // Show just the filename for absolute paths
- pathElement.textContent = mdPath.startsWith('/')
- ? mdPath.split('/').pop() || mdPath
- : mdPath;
- } else {
- pathElement.textContent = 'interview.md';
- }
- pathContainer.style.display = 'flex';
- } else {
- pathContainer.style.display = 'none';
- }
- // Resume command — only in session-disconnected mode
- var resumeRow = document.getElementById('resumeRow');
- if (resumeRow) {
- var showResume = data.mode === 'session-disconnected';
- resumeRow.style.display = showResume ? 'flex' : 'none';
- if (showResume) {
- document.getElementById('resumeCmd').textContent = '/interview ' + resumeSlug;
- }
- }
- // Nudge actions — only for completed (live session)
- // session-disconnected can't nudge (no session to receive it)
- const nudgeActions = document.getElementById('nudgeActions');
- const moreBtn = document.getElementById('moreQuestionsBtn');
- const confirmBtn = document.getElementById('confirmCompleteBtn');
- const canNudge = data.mode === 'completed';
- if (nudgeActions) nudgeActions.style.display = canNudge ? 'flex' : 'none';
- if (moreBtn) moreBtn.disabled = data.isBusy;
- if (confirmBtn) confirmBtn.disabled = data.isBusy;
-
- renderQuestions(data.questions || []);
- updateSubmitButton();
- }
- async function refresh() {
- const url = '/api/interviews/' + encodeURIComponent(interviewId) + '/state';
- const response = await fetch(url);
- const data = await response.json();
- if (!response.ok) throw new Error(data.error || 'Failed to load state');
- render(data);
- }
- function scrollToTop() {
- window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
- }
- document.getElementById('submitButton').addEventListener('click', async () => {
- if (!state.data || state.isSubmitting) return;
- document.getElementById('submitButton').blur();
- const answers = (state.data.questions || []).map((question) => {
- return {
- questionId: question.id,
- answer: (state.answers[question.id] || '').trim(),
- };
- });
- state.isSubmitting = true;
- updateSubmitButton();
- const overlay = document.getElementById('loadingOverlay');
- const overlayText = document.getElementById('loadingText');
- overlay.classList.add('active');
- overlayText.textContent = "Submitting Answers...";
- scrollToTop();
- try {
- const response = await fetch('/api/interviews/' + encodeURIComponent(interviewId) + '/answers', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ answers }),
- });
- const payload = await response.json();
- document.getElementById('submitStatus').textContent = payload.message || (response.ok ? 'Answers submitted successfully.' : 'Submission failed.');
- } catch (err) {
- document.getElementById('submitStatus').textContent = 'Error submitting answers.';
- }
- state.isSubmitting = false;
- updateSubmitButton();
- try {
- await refresh();
- } catch (_error) {
- overlay.classList.remove('active');
- }
- });
- document.getElementById('downloadBtn').addEventListener('click', () => {
- if (!state.data || !state.data.document) return;
- const blob = new Blob([state.data.document], { type: 'text/markdown' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- const mdPath = state.data.markdownPath || 'interview.md';
- a.download = mdPath.split('/').pop() || 'interview.md';
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- });
- document.getElementById('copyResumeBtn').addEventListener('click', function(e) {
- e.stopPropagation();
- e.preventDefault();
- var cmd = document.getElementById('resumeCmd').textContent;
- if (cmd) copyCommand(cmd, this);
- });
- async function sendNudge(action) {
- const moreBtn = document.getElementById('moreQuestionsBtn');
- const confirmBtn = document.getElementById('confirmCompleteBtn');
- if (moreBtn) moreBtn.disabled = true;
- if (confirmBtn) confirmBtn.disabled = true;
- document.getElementById('submitStatus').textContent = '';
- try {
- const url =
- '/api/interviews/' +
- encodeURIComponent(interviewId) +
- '/nudge';
- const res = await fetch(url, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ action }),
- });
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- document.getElementById('submitStatus').textContent =
- err.error || 'Request failed.';
- if (moreBtn) moreBtn.disabled = false;
- if (confirmBtn) confirmBtn.disabled = false;
- } else {
- try { await refresh(); } catch (_) {}
- schedulePoll(); // Restart polling — nudge may reactivate interview
- }
- } catch (_err) {
- document.getElementById('submitStatus').textContent = 'Network error.';
- if (moreBtn) moreBtn.disabled = false;
- if (confirmBtn) confirmBtn.disabled = false;
- }
- }
- document.getElementById('moreQuestionsBtn').addEventListener('click', () => sendNudge('more-questions'));
- document.getElementById('confirmCompleteBtn').addEventListener('click', () => sendNudge('confirm-complete'));
- function schedulePoll() {
- setTimeout(async () => {
- try { await refresh(); } catch (_) {}
- // Stop polling for terminal states
- const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
- if (!terminalModes.includes(state.data?.mode)) schedulePoll();
- }, 2500);
- }
- refresh().catch((error) => {
- document.getElementById('submitStatus').textContent = error.message || 'Failed to load interview.';
- });
- schedulePoll();
- </script>
- </body>
- </html>`;
- }
|