| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027 |
- import { escapeHtml } from '../utils/escape-html';
- 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';
- // ─── 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; }
- .spec-block-card {
- background: rgba(255,255,255,0.01);
- border: 1px solid rgba(255,255,255,0.05);
- border-radius: 8px;
- padding: 24px;
- margin-bottom: 20px;
- text-align: left;
- transition: all 0.2s ease;
- }
- .spec-block-card:hover {
- border-color: rgba(255,255,255,0.15);
- background: rgba(255,255,255,0.02);
- }
- .spec-block-card h3 {
- margin-top: 0;
- font-size: 18px;
- color: #ffffff;
- border-bottom: 1px solid rgba(255,255,255,0.08);
- padding-bottom: 8px;
- margin-bottom: 14px;
- }
- .spec-block-content {
- font-size: 15px;
- line-height: 1.6;
- color: rgba(255,255,255,0.8);
- }
- .spec-block-comment-box {
- margin-top: 16px;
- padding-top: 16px;
- border-top: 1px dashed rgba(255,255,255,0.08);
- display: flex;
- flex-direction: column;
- gap: 10px;
- }
- .spec-block-comment-input {
- min-height: 60px !important;
- font-size: 14px !important;
- padding: 10px !important;
- }
- .comment-submit-btn {
- align-self: flex-end;
- background: transparent;
- border: 1px solid rgba(255,255,255,0.2);
- color: rgba(255,255,255,0.8);
- padding: 8px 16px;
- border-radius: 6px;
- font-size: 13px;
- cursor: pointer;
- transition: all 0.2s ease;
- }
- .comment-submit-btn:hover:not(:disabled) {
- border-color: #34d399;
- color: #34d399;
- background: rgba(52,211,153,0.05);
- }
- .comment-submit-btn:disabled {
- opacity: 0.3;
- cursor: not-allowed;
- }
- 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; }
- /* ── TOC Sidebar ──────────────────────────────────────────────── */
- .toc-sidebar {
- position: fixed;
- top: 0; left: 0; bottom: 0;
- width: 240px;
- background: rgba(255,255,255,0.02);
- border-right: 1px solid rgba(255,255,255,0.06);
- padding: 24px 0 80px;
- overflow-y: auto;
- z-index: 50;
- display: none;
- }
- .toc-sidebar.visible { display: block; }
- .toc-header {
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: rgba(255,255,255,0.28);
- padding: 0 20px 14px;
- border-bottom: 1px solid rgba(255,255,255,0.05);
- margin-bottom: 8px;
- }
- .toc-item {
- display: block;
- padding: 7px 20px;
- font-size: 13px;
- color: rgba(255,255,255,0.5);
- text-decoration: none;
- cursor: pointer;
- transition: all 0.15s ease;
- border-left: 2px solid transparent;
- line-height: 1.4;
- }
- .toc-item:hover {
- color: rgba(255,255,255,0.85);
- background: rgba(255,255,255,0.03);
- }
- .toc-item.active {
- color: #ffffff;
- border-left-color: #ffffff;
- background: rgba(255,255,255,0.04);
- }
- body.toc-visible .wrap {
- margin-left: 240px;
- }
- /* ── Chat Panel ───────────────────────────────────────────────── */
- .chat-panel {
- position: fixed;
- bottom: 0; left: 0; right: 0;
- background: rgba(0,0,0,0.92);
- backdrop-filter: blur(12px);
- border-top: 1px solid rgba(255,255,255,0.08);
- padding: 12px 20px;
- z-index: 60;
- display: none;
- gap: 10px;
- align-items: center;
- }
- .chat-panel.visible { display: flex; }
- body.chat-visible .wrap { padding-bottom: 72px; }
- body.toc-visible .chat-panel { left: 240px; }
- .chat-input {
- flex: 1;
- background: rgba(255,255,255,0.06);
- border: 1px solid rgba(255,255,255,0.1);
- border-radius: 8px;
- color: #ffffff;
- font-family: inherit;
- font-size: 14px;
- padding: 10px 14px;
- outline: none;
- transition: border-color 0.2s ease;
- }
- .chat-input:focus { border-color: rgba(255,255,255,0.35); }
- .chat-input::placeholder { color: rgba(255,255,255,0.3); }
- .chat-send {
- flex-shrink: 0;
- background: #ffffff;
- color: #000000;
- border: 0;
- border-radius: 8px;
- padding: 10px 18px;
- font-size: 14px;
- font-weight: 600;
- cursor: pointer;
- transition: opacity 0.2s ease;
- }
- .chat-send:hover:not(:disabled) { opacity: 0.85; }
- .chat-send:disabled { opacity: 0.3; cursor: not-allowed; }
- </style>
- </head>
- <body>
- <nav id="tocSidebar" class="toc-sidebar"></nav>
- <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>
- <div class="chat-panel" id="chatPanel">
- <input type="text" id="chatInput" class="chat-input" placeholder="Send a message to the agent - add a section, revise content, ask questions..." autocomplete="off" />
- <button class="chat-send" id="chatSendBtn" type="button" disabled>Send</button>
- </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('');
- }
- async function submitBlockComment(sectionTitle, commentText, button) {
- if (!commentText.trim()) return;
- button.disabled = true;
- const submitStatus = document.getElementById('submitStatus');
- submitStatus.textContent = '';
-
- try {
- const res = await fetch('/api/interviews/' + encodeURIComponent(interviewId) + '/block-comment', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ section: sectionTitle, comment: commentText }),
- });
- const payload = await res.json();
- if (res.ok) {
- submitStatus.textContent = 'Feedback queued for block: ' + sectionTitle;
- const input = button.previousElementSibling;
- if (input) input.value = '';
- refresh().catch(() => {});
- } else {
- submitStatus.textContent = payload.error || 'Failed to submit comment.';
- }
- } catch (err) {
- submitStatus.textContent = 'Error submitting block connection feedback.';
- } finally {
- button.disabled = false;
- }
- }
- function renderCompletedView(data) {
- const container = document.getElementById('questions');
- const frag = document.createDocumentFragment();
- const blocks = data.blocks || [];
- if (blocks.length > 0) {
- const specLabel = document.createElement('div');
- specLabel.className = 'section-label';
- specLabel.textContent = 'Interactive Specifications (11 Sections)';
- frag.appendChild(specLabel);
- for (const block of blocks) {
- const card = document.createElement('div');
- card.className = 'spec-block-card';
- const header = document.createElement('h3');
- header.textContent = block.title;
- card.appendChild(header);
- const content = document.createElement('div');
- content.className = 'spec-block-content';
- content.innerHTML = simpleMarkdown(block.content);
- card.appendChild(content);
- // Per-block comment integration for micro annotations
- const commentBox = document.createElement('div');
- commentBox.className = 'spec-block-comment-box';
- const textarea = document.createElement('textarea');
- textarea.className = 'spec-block-comment-input';
- textarea.placeholder = 'Provide review comment/revision for ' + block.title + '...';
- commentBox.appendChild(textarea);
- const sendBtn = document.createElement('button');
- sendBtn.className = 'comment-submit-btn';
- sendBtn.type = 'button';
- sendBtn.textContent = 'Revise Section';
- sendBtn.addEventListener('click', () => {
- submitBlockComment(block.title, textarea.value, sendBtn);
- });
- commentBox.appendChild(sendBtn);
- card.appendChild(commentBox);
- frag.appendChild(card);
- }
- } else {
- // Fallback legacy parse if blocks array isn't populated
- const { spec, qaPairs } = parseDocument(data.document);
- // 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);
- const { qaPairs } = parseDocument(data.document);
- 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);
- }
- // ── TOC Sidebar ──────────────────────────────────────────────
- function updateTocSidebar(data) {
- const sidebar = document.getElementById('tocSidebar');
- const blocks = data.blocks || [];
- const isDone = ['completed', 'session-disconnected'].includes(data.mode);
- if (!isDone || !blocks.length) {
- sidebar.classList.remove('visible');
- document.body.classList.remove('toc-visible');
- return;
- }
- sidebar.classList.add('visible');
- document.body.classList.add('toc-visible');
- sidebar.innerHTML = '';
- const header = document.createElement('div');
- header.className = 'toc-header';
- header.textContent = 'Sections';
- sidebar.appendChild(header);
- blocks.forEach((block) => {
- const item = document.createElement('a');
- item.className = 'toc-item';
- item.textContent = block.title;
- item.addEventListener('click', () => {
- const cards = document.querySelectorAll('.spec-block-card h3');
- for (const h3 of cards) {
- if (h3.textContent === block.title) {
- h3.closest('.spec-block-card').scrollIntoView({ behavior: 'smooth', block: 'start' });
- break;
- }
- }
- document.querySelectorAll('.toc-item').forEach((el) => el.classList.remove('active'));
- item.classList.add('active');
- });
- sidebar.appendChild(item);
- });
- }
- // ── Chat Panel ───────────────────────────────────────────────
- function updateChatPanel(data) {
- const panel = document.getElementById('chatPanel');
- const input = document.getElementById('chatInput');
- const sendBtn = document.getElementById('chatSendBtn');
- const isDone = ['completed', 'session-disconnected'].includes(data.mode);
- if (!isDone) {
- panel.classList.remove('visible');
- document.body.classList.remove('chat-visible');
- return;
- }
- panel.classList.add('visible');
- document.body.classList.add('chat-visible');
- const busy = data.isBusy || false;
- sendBtn.disabled = busy;
- input.disabled = busy;
- if (busy) {
- input.placeholder = 'Agent is processing...';
- } else {
- input.placeholder = 'Send a message to the agent - add a section, revise content, ask questions...';
- }
- }
- async function sendChatMessage() {
- const input = document.getElementById('chatInput');
- const sendBtn = document.getElementById('chatSendBtn');
- const message = input.value.trim();
- if (!message) return;
- sendBtn.disabled = true;
- input.value = '';
- const submitStatus = document.getElementById('submitStatus');
- submitStatus.textContent = '';
- try {
- const res = await fetch('/api/interviews/' + encodeURIComponent(interviewId) + '/chat', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ message }),
- });
- const payload = await res.json();
- if (res.ok) {
- submitStatus.textContent = 'Chat message sent to agent.';
- refresh().catch(() => {});
- schedulePoll();
- } else {
- submitStatus.textContent = payload.message || payload.error || 'Failed to send chat message.';
- sendBtn.disabled = false;
- }
- } catch (_err) {
- submitStatus.textContent = 'Network error sending chat message.';
- sendBtn.disabled = false;
- }
- }
- document.getElementById('chatSendBtn').addEventListener('click', sendChatMessage);
- document.getElementById('chatInput').addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- sendChatMessage();
- }
- });
- 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();
- updateTocSidebar(data);
- updateChatPanel(data);
- // If we transitioned to a non-terminal state and connection/polling is stopped, restart them
- const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
- if (!terminalModes.includes(data.mode)) {
- if (!sseConnected && !activeEs) {
- connectSse();
- }
- if (!sseConnected && !pollFallbackTimer) {
- schedulePoll();
- }
- }
- }
- 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'));
- // ── Real-time updates via SSE ─────────────────────────────────
- let sseConnected = false;
- let pollFallbackTimer = null;
- let activeEs = null;
- function connectSse() {
- if (activeEs) {
- activeEs.close();
- }
- const sseUrl = '/api/interviews/' + encodeURIComponent(interviewId) + '/events';
- const es = new EventSource(sseUrl);
- activeEs = es;
- es.addEventListener('state', (e) => {
- sseConnected = true;
- if (pollFallbackTimer) {
- clearTimeout(pollFallbackTimer);
- pollFallbackTimer = null;
- }
- try {
- const data = JSON.parse(e.data);
- render(data);
- } catch (_) {}
- });
- es.onerror = () => {
- sseConnected = false;
- es.close();
- activeEs = null;
- const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
- if (state.data && terminalModes.includes(state.data.mode)) {
- return;
- }
- // Retry SSE after 3s, fall back to polling in the meantime
- if (!pollFallbackTimer) schedulePoll();
- setTimeout(() => connectSse(), 3000);
- };
- }
- function schedulePoll() {
- // Only poll if SSE is not connected
- if (sseConnected) return;
- const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
- if (state.data && terminalModes.includes(state.data.mode)) {
- return;
- }
- pollFallbackTimer = setTimeout(async () => {
- try { await refresh(); } catch (_) {}
- pollFallbackTimer = null;
- if (!sseConnected) {
- schedulePoll();
- }
- }, 2500);
- }
- refresh().catch((error) => {
- document.getElementById('submitStatus').textContent = error.message || 'Failed to load interview.';
- });
- connectSse();
- </script>
- </body>
- </html>`;
- }
|