ui.ts 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648
  1. import type { InterviewFileItem, InterviewListItem } from './types';
  2. interface DashboardInterviewItem extends InterviewListItem {
  3. url: string;
  4. mode: string;
  5. resumeSlug: string;
  6. sessionID?: string;
  7. directory?: string;
  8. }
  9. const BRAND_LOGO_URL =
  10. 'https://ohmyopencodeslim.com/android-chrome-512x512.png';
  11. export function escapeHtml(value: string): string {
  12. return value
  13. .replaceAll('&', '&')
  14. .replaceAll('<', '&lt;')
  15. .replaceAll('>', '&gt;')
  16. .replaceAll('"', '&quot;')
  17. .replaceAll("'", '&#39;');
  18. }
  19. // ─── Shared client-side helpers ────────────────────────────────────
  20. function clipboardHelperJs(): string {
  21. return `
  22. function copyCommand(text, btn) {
  23. var clip = navigator.clipboard && navigator.clipboard.writeText;
  24. var useFallback = function() {
  25. var ta = document.createElement('textarea');
  26. ta.value = text;
  27. ta.style.position = 'fixed';
  28. ta.style.left = '-9999px';
  29. document.body.appendChild(ta);
  30. ta.select();
  31. document.execCommand('copy');
  32. document.body.removeChild(ta);
  33. };
  34. if (clip) {
  35. clip.call(navigator.clipboard, text).catch(useFallback);
  36. } else {
  37. useFallback();
  38. }
  39. btn.textContent = 'Copied!';
  40. btn.classList.add('copied');
  41. setTimeout(function() {
  42. btn.textContent = 'Copy';
  43. btn.classList.remove('copied');
  44. }, 2000);
  45. }`;
  46. }
  47. // ─── Shared styles ─────────────────────────────────────────────────
  48. function sharedStyles(): string {
  49. return `
  50. :root { color-scheme: dark; }
  51. body {
  52. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
  53. margin: 0;
  54. background: #000000;
  55. color: #ffffff;
  56. line-height: 1.6;
  57. -webkit-font-smoothing: antialiased;
  58. font-size: 16px;
  59. }
  60. .wrap { max-width: 680px; margin: 0 auto; padding: 56px 24px; }
  61. .brand-header {
  62. display: flex;
  63. flex-direction: column;
  64. align-items: center;
  65. gap: 16px;
  66. margin-bottom: 32px;
  67. text-align: center;
  68. }
  69. .brand-mark {
  70. object-fit: contain;
  71. filter: drop-shadow(0 10px 30px rgba(255,255,255,0.1));
  72. }
  73. h1 { font-size: 28px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 8px; line-height: 1.2; }
  74. .muted { color: rgba(255,255,255,0.5); font-size: 16px; }
  75. 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; }
  76. .resume-row {
  77. display: flex;
  78. align-items: center;
  79. gap: 8px;
  80. }
  81. .resume-cmd {
  82. flex: 1;
  83. padding: 8px 12px;
  84. font-size: 13px;
  85. border-radius: 6px;
  86. background: rgba(255,255,255,0.05);
  87. border: 1px solid rgba(255,255,255,0.1);
  88. white-space: nowrap;
  89. overflow-x: auto;
  90. }
  91. .copy-btn {
  92. flex-shrink: 0;
  93. padding: 8px 16px;
  94. border: 1px solid rgba(255,255,255,0.2);
  95. border-radius: 6px;
  96. background: transparent;
  97. color: rgba(255,255,255,0.8);
  98. font-size: 13px;
  99. font-weight: 500;
  100. cursor: pointer;
  101. transition: all 0.2s ease;
  102. }
  103. .copy-btn:hover {
  104. background: rgba(255,255,255,0.1);
  105. border-color: rgba(255,255,255,0.4);
  106. color: #ffffff;
  107. }
  108. .copy-btn.copied {
  109. background: rgba(52, 211, 153, 0.15);
  110. border-color: #34d399;
  111. color: #34d399;
  112. }
  113. .footer { margin-top: 32px; text-align: center; font-size: 13px; color: rgba(255,255,255,0.4); }`;
  114. }
  115. // ─── Dashboard brand image ─────────────────────────────────────────
  116. function brandImage(size: number): string {
  117. return `<img class="brand-mark" src="${BRAND_LOGO_URL}" alt="Oh My Opencode Slim" width="${size}" height="${size}" />`;
  118. }
  119. export function renderDashboardPage(
  120. interviews: DashboardInterviewItem[],
  121. files: InterviewFileItem[],
  122. outputFolder: string,
  123. ): string {
  124. const activeHtml =
  125. interviews.length === 0
  126. ? ''
  127. : interviews
  128. .map((item) => {
  129. const isDisconnected = item.mode === 'session-disconnected';
  130. const statusLabel = isDisconnected ? 'disconnected' : item.status;
  131. const statusClass = isDisconnected
  132. ? 'status-file'
  133. : `status-${item.status}`;
  134. const resumeHint = isDisconnected
  135. ? `<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>`
  136. : '';
  137. return `<a class="interview-card" href="${escapeHtml(item.url)}">
  138. <div class="card-header">
  139. <span class="card-idea">${escapeHtml(item.idea)}</span>
  140. <span class="card-status ${statusClass}">${statusLabel}</span>
  141. </div>
  142. ${item.directory ? `<div class="card-dir">${escapeHtml(item.directory)}</div>` : ''}
  143. <div class="card-meta">
  144. <span>${escapeHtml(new Date(item.createdAt).toLocaleString())}</span>
  145. </div>
  146. ${resumeHint}
  147. </a>`;
  148. })
  149. .join('\n');
  150. // Dedup: skip files whose sessionID matches an active/recovered interview.
  151. // These are already visible in the "Interviews" section above.
  152. const activeSessionIDs = new Set(
  153. interviews
  154. .map((i) => i.sessionID)
  155. .filter((sid): sid is string => typeof sid === 'string'),
  156. );
  157. const unrecoveredFiles = files.filter((f) => {
  158. if (f.sessionID && activeSessionIDs.has(f.sessionID)) return false;
  159. return true;
  160. });
  161. const filesHtml =
  162. unrecoveredFiles.length === 0
  163. ? ''
  164. : unrecoveredFiles
  165. .map(
  166. (item) =>
  167. `<div class="interview-card file-card">
  168. <div class="card-header">
  169. <span class="card-idea">${escapeHtml(item.title)}</span>
  170. <span class="card-status status-file">saved</span>
  171. </div>
  172. ${item.directory ? `<div class="card-dir">${escapeHtml(item.directory)}</div>` : ''}
  173. <div class="card-summary">${escapeHtml(item.summary)}</div>
  174. <div class="resume-row">
  175. <code class="resume-cmd">${escapeHtml(item.resumeCommand)}</code>
  176. <button class="copy-btn" data-cmd="${escapeHtml(item.resumeCommand)}" title="Copy command">Copy</button>
  177. </div>
  178. </div>`,
  179. )
  180. .join('\n');
  181. const totalCount = interviews.length + unrecoveredFiles.length;
  182. const emptyState =
  183. totalCount === 0
  184. ? '<p class="muted" style="text-align:center;padding:48px 0">No interviews yet. Start one with <code>/interview</code> in your OpenCode session.</p>'
  185. : '';
  186. const activeSection =
  187. interviews.length > 0 ? `<h2>Interviews</h2>${activeHtml}` : '';
  188. const fileSection =
  189. unrecoveredFiles.length > 0
  190. ? `<h2>Files without session</h2><p class="muted file-hint">Resume with the command in any OpenCode session.</p>${filesHtml}`
  191. : '';
  192. return `<!doctype html>
  193. <html lang="en">
  194. <head>
  195. <meta charset="utf-8" />
  196. <meta name="viewport" content="width=device-width, initial-scale=1" />
  197. <title>Interviews</title>
  198. <style>
  199. ${sharedStyles()}
  200. .brand-mark { width: 96px; height: 96px; }
  201. 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; }
  202. h2:first-of-type { margin-top: 0; }
  203. .file-hint { font-size: 14px; margin-bottom: 16px; margin-top: -8px; }
  204. .interview-card {
  205. display: block;
  206. background: rgba(255,255,255,0.02);
  207. border: 1px solid rgba(255,255,255,0.1);
  208. border-radius: 8px;
  209. padding: 20px 24px;
  210. margin-bottom: 12px;
  211. text-decoration: none;
  212. color: inherit;
  213. transition: all 0.2s ease;
  214. }
  215. .interview-card:hover {
  216. background: rgba(255,255,255,0.06);
  217. border-color: rgba(255,255,255,0.3);
  218. transform: translateX(4px);
  219. }
  220. .file-card {
  221. cursor: default;
  222. }
  223. .file-card:hover {
  224. transform: none;
  225. }
  226. .card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
  227. .card-idea { font-size: 17px; font-weight: 500; line-height: 1.4; flex: 1; }
  228. .card-status {
  229. font-size: 11px;
  230. text-transform: uppercase;
  231. letter-spacing: 0.05em;
  232. padding: 4px 10px;
  233. border-radius: 999px;
  234. font-weight: 600;
  235. flex-shrink: 0;
  236. }
  237. .status-active { background: rgba(52, 211, 153, 0.15); color: #34d399; }
  238. .status-abandoned { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.4); }
  239. .status-file { background: rgba(96, 165, 250, 0.15); color: #60a5fa; }
  240. .card-meta { font-size: 13px; color: rgba(255,255,255,0.4); }
  241. .card-summary { font-size: 14px; color: rgba(255,255,255,0.5); margin-bottom: 12px; line-height: 1.5; }
  242. .card-dir { font-size: 12px; color: rgba(255,255,255,0.3); margin-bottom: 8px; font-family: monospace; word-break: break-all; }
  243. .update-banner {
  244. position: fixed;
  245. top: -56px;
  246. left: 0; right: 0;
  247. background: rgba(96, 165, 250, 0.95);
  248. color: #fff;
  249. text-align: center;
  250. padding: 14px 24px;
  251. font-size: 14px;
  252. font-weight: 500;
  253. cursor: pointer;
  254. z-index: 200;
  255. transition: top 0.3s ease;
  256. backdrop-filter: blur(8px);
  257. }
  258. .update-banner.visible { top: 0; }
  259. .update-banner:hover { background: rgba(96, 165, 250, 1); }
  260. .info-box {
  261. font-size: 13px;
  262. color: rgba(255,255,255,0.45);
  263. background: rgba(255,255,255,0.03);
  264. border: 1px solid rgba(255,255,255,0.08);
  265. border-radius: 8px;
  266. padding: 16px 20px;
  267. margin-bottom: 40px;
  268. line-height: 1.7;
  269. }
  270. .info-box strong { color: rgba(255,255,255,0.65); font-weight: 500; }
  271. .info-box code {
  272. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  273. background: rgba(255,255,255,0.08);
  274. padding: 2px 6px;
  275. border-radius: 4px;
  276. font-size: 12px;
  277. word-break: break-all;
  278. }
  279. .settings-row {
  280. display: flex;
  281. align-items: center;
  282. gap: 12px;
  283. margin-top: 12px;
  284. flex-wrap: wrap;
  285. }
  286. .settings-row label {
  287. font-size: 13px;
  288. color: rgba(255,255,255,0.5);
  289. white-space: nowrap;
  290. }
  291. .settings-row select, .settings-row input[type="text"] {
  292. background: rgba(255,255,255,0.05);
  293. border: 1px solid rgba(255,255,255,0.15);
  294. color: #fff;
  295. border-radius: 4px;
  296. padding: 6px 10px;
  297. font-size: 13px;
  298. font-family: inherit;
  299. }
  300. .settings-row input[type="text"] {
  301. flex: 1;
  302. min-width: 200px;
  303. }
  304. .settings-row select:focus, .settings-row input[type="text"]:focus {
  305. border-color: rgba(255,255,255,0.4);
  306. outline: none;
  307. }
  308. .small-btn {
  309. padding: 6px 14px;
  310. border: 1px solid rgba(255,255,255,0.2);
  311. border-radius: 4px;
  312. background: transparent;
  313. color: rgba(255,255,255,0.8);
  314. font-size: 13px;
  315. cursor: pointer;
  316. transition: all 0.2s ease;
  317. }
  318. .small-btn:hover {
  319. background: rgba(255,255,255,0.1);
  320. border-color: rgba(255,255,255,0.4);
  321. color: #fff;
  322. }
  323. .folder-list {
  324. display: flex;
  325. flex-direction: column;
  326. gap: 4px;
  327. margin-top: 8px;
  328. }
  329. .folder-item {
  330. display: flex;
  331. align-items: center;
  332. gap: 8px;
  333. font-size: 13px;
  334. color: rgba(255,255,255,0.5);
  335. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  336. }
  337. .folder-item button {
  338. padding: 2px 8px;
  339. border: 1px solid rgba(255,255,255,0.15);
  340. border-radius: 3px;
  341. background: transparent;
  342. color: rgba(255,255,255,0.4);
  343. font-size: 11px;
  344. cursor: pointer;
  345. }
  346. .folder-item button:hover {
  347. border-color: rgba(255,100,100,0.5);
  348. color: rgba(255,100,100,0.8);
  349. }
  350. </style>
  351. </head>
  352. <body>
  353. <div class="wrap">
  354. <div class="brand-header">
  355. ${brandImage(96)}
  356. <h1>Interviews</h1>
  357. <p class="muted">${totalCount} item${totalCount === 1 ? '' : 's'}</p>
  358. </div>
  359. <div class="info-box">
  360. <strong>Interviews</strong> — live sessions and recovered files. State is pushed from OpenCode sessions to this dashboard.<br>
  361. <strong>Files without session</strong> — <code>.md</code> files in <code>${escapeHtml(outputFolder)}</code> with no frontmatter. Resume with <code>/interview &lt;name&gt;</code>.
  362. </div>
  363. <details style="margin-bottom:24px">
  364. <summary style="cursor:pointer;font-size:13px;color:rgba(255,255,255,0.4);user-select:none">Settings</summary>
  365. <div style="margin-top:12px">
  366. <div class="settings-row">
  367. <label for="scanDays">Scan sessions from last</label>
  368. <select id="scanDays">
  369. <option value="1">1 day</option>
  370. <option value="7">7 days</option>
  371. <option value="30" selected>30 days</option>
  372. <option value="90">90 days</option>
  373. <option value="0">All time</option>
  374. </select>
  375. <button class="small-btn" id="rescanBtn">Rescan</button>
  376. </div>
  377. <div class="settings-row">
  378. <input type="text" id="addFolderInput" placeholder="/path/to/project" />
  379. <button class="small-btn" id="addFolderBtn">Add Folder</button>
  380. </div>
  381. <div class="folder-list" id="folderList"></div>
  382. </div>
  383. </details>
  384. ${emptyState}
  385. ${activeSection}
  386. ${fileSection}
  387. <div class="footer">OH MY OPENCODE SLIM</div>
  388. </div>
  389. <div class="update-banner" id="updateBanner">Dashboard updated — tap to refresh</div>
  390. <script>
  391. ${clipboardHelperJs()}
  392. document.querySelectorAll('.copy-btn').forEach(btn => {
  393. btn.addEventListener('click', (e) => {
  394. e.stopPropagation();
  395. e.preventDefault();
  396. copyCommand(btn.dataset.cmd, btn);
  397. });
  398. });
  399. let lastSig = '';
  400. let bannerTimer = null;
  401. // Use health endpoint for change detection (no auth needed, shows counts)
  402. (async () => { try { const r = await fetch('/api/health'); if (r.ok) lastSig = await r.text(); } catch {} })();
  403. setInterval(async () => {
  404. try {
  405. const res = await fetch('/api/health');
  406. if (!res.ok) return;
  407. const sig = await res.text();
  408. if (sig !== lastSig) {
  409. lastSig = sig;
  410. const banner = document.getElementById('updateBanner');
  411. banner.classList.add('visible');
  412. if (bannerTimer) clearTimeout(bannerTimer);
  413. bannerTimer = setTimeout(() => banner.classList.remove('visible'), 30000);
  414. }
  415. } catch {}
  416. }, 15000);
  417. document.getElementById('updateBanner').addEventListener('click', () => location.reload());
  418. // Settings: load current state
  419. (async () => {
  420. try {
  421. const r = await fetch('/api/settings');
  422. if (!r.ok) return;
  423. const s = await r.json();
  424. const sel = document.getElementById('scanDays');
  425. if (sel && s.scanDays !== undefined) sel.value = String(s.scanDays);
  426. renderFolderList(s.folders || []);
  427. } catch {}
  428. })();
  429. function renderFolderList(folders) {
  430. const list = document.getElementById('folderList');
  431. if (!list) return;
  432. list.innerHTML = '';
  433. folders.forEach(function(dir) {
  434. const item = document.createElement('div');
  435. item.className = 'folder-item';
  436. const span = document.createElement('span');
  437. span.textContent = dir;
  438. span.style.flex = '1';
  439. const btn = document.createElement('button');
  440. btn.textContent = 'Remove';
  441. btn.addEventListener('click', async function() {
  442. await fetch('/api/settings', {
  443. method: 'POST',
  444. headers: { 'content-type': 'application/json' },
  445. body: JSON.stringify({ removeFolder: dir })
  446. });
  447. location.reload();
  448. });
  449. item.appendChild(span);
  450. item.appendChild(btn);
  451. list.appendChild(item);
  452. });
  453. }
  454. document.getElementById('rescanBtn')?.addEventListener('click', async function() {
  455. const days = parseInt(document.getElementById('scanDays').value, 10);
  456. await fetch('/api/settings', {
  457. method: 'POST',
  458. headers: { 'content-type': 'application/json' },
  459. body: JSON.stringify({ scanDays: days, discover: true })
  460. });
  461. location.reload();
  462. });
  463. document.getElementById('addFolderBtn')?.addEventListener('click', async function() {
  464. const input = document.getElementById('addFolderInput');
  465. const dir = input.value.trim();
  466. if (!dir) return;
  467. await fetch('/api/settings', {
  468. method: 'POST',
  469. headers: { 'content-type': 'application/json' },
  470. body: JSON.stringify({ addFolder: dir })
  471. });
  472. input.value = '';
  473. location.reload();
  474. });
  475. </script>
  476. </body>
  477. </html>`;
  478. }
  479. export function renderInterviewPage(
  480. interviewId: string,
  481. resumeSlug: string,
  482. ): string {
  483. const safeTitle = escapeHtml(interviewId);
  484. return `<!doctype html>
  485. <html lang="en">
  486. <head>
  487. <meta charset="utf-8" />
  488. <meta name="viewport" content="width=device-width, initial-scale=1" />
  489. <title>Interview ${safeTitle}</title>
  490. <style>
  491. ${sharedStyles()}
  492. .brand-mark { width: 144px; height: 144px; }
  493. h1 { font-size: 32px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 12px; line-height: 1.2; }
  494. 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; }
  495. h3 { font-size: 18px; font-weight: 500; margin-bottom: 16px; line-height: 1.4; }
  496. p { margin-top: 0; }
  497. .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; }
  498. .file-path-container {
  499. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  500. font-size: 13px;
  501. color: rgba(255,255,255,0.6);
  502. background: rgba(255,255,255,0.05);
  503. padding: 8px 12px;
  504. border-radius: 6px;
  505. margin-bottom: 12px;
  506. display: flex;
  507. align-items: center;
  508. gap: 8px;
  509. border: 1px solid rgba(255,255,255,0.08);
  510. }
  511. .file-path-icon {
  512. opacity: 0.5;
  513. }
  514. .download-btn {
  515. margin-left: auto;
  516. background: transparent;
  517. border: 1px solid rgba(255,255,255,0.2);
  518. color: rgba(255,255,255,0.6);
  519. border-radius: 4px;
  520. padding: 4px 10px;
  521. font-size: 12px;
  522. cursor: pointer;
  523. transition: all 0.2s ease;
  524. }
  525. .download-btn:hover {
  526. border-color: rgba(255,255,255,0.5);
  527. color: #ffffff;
  528. }
  529. .question {
  530. background: rgba(255,255,255,0.02);
  531. border: 1px solid rgba(255,255,255,0.1);
  532. border-left: 1px solid rgba(255,255,255,0.1);
  533. border-radius: 6px;
  534. padding: 28px;
  535. margin-bottom: 32px;
  536. transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
  537. }
  538. .question:focus-within {
  539. border-color: rgba(255,255,255,0.3);
  540. }
  541. /* Make active question much clearer */
  542. .question.active-question {
  543. background: rgba(255,255,255,0.04);
  544. border-color: rgba(255,255,255,0.4);
  545. border-left: 4px solid #ffffff;
  546. box-shadow: 0 8px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.1);
  547. transform: translateX(4px);
  548. }
  549. .options { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }
  550. .question-hint {
  551. display: flex;
  552. flex-wrap: wrap;
  553. gap: 8px;
  554. margin: -4px 0 16px;
  555. color: rgba(255,255,255,0.5);
  556. font-size: 13px;
  557. line-height: 1.5;
  558. transition: color 0.2s ease;
  559. }
  560. .active-question .question-hint {
  561. color: rgba(255,255,255,0.78);
  562. }
  563. .hint-chip {
  564. display: inline-flex;
  565. align-items: center;
  566. gap: 6px;
  567. padding: 4px 10px;
  568. border-radius: 999px;
  569. background: rgba(255,255,255,0.06);
  570. border: 1px solid rgba(255,255,255,0.08);
  571. }
  572. .hint-chip kbd {
  573. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  574. font-size: 12px;
  575. padding: 2px 6px;
  576. border-radius: 6px;
  577. background: rgba(255,255,255,0.12);
  578. border: 1px solid rgba(255,255,255,0.08);
  579. color: rgba(255,255,255,0.95);
  580. }
  581. .option {
  582. border: 1px solid rgba(255,255,255,0.1);
  583. background: transparent;
  584. color: inherit;
  585. border-radius: 6px;
  586. padding: 14px 18px;
  587. cursor: pointer;
  588. text-align: left;
  589. font-size: 16px;
  590. transition: all 0.2s ease;
  591. display: flex;
  592. align-items: center;
  593. }
  594. .option:hover {
  595. background: rgba(255,255,255,0.06);
  596. border-color: rgba(255,255,255,0.3);
  597. }
  598. .option.selected {
  599. background: #ffffff;
  600. color: #000000;
  601. border-color: #ffffff;
  602. font-weight: 500;
  603. }
  604. .shortcut {
  605. display: inline-flex;
  606. align-items: center;
  607. justify-content: center;
  608. background: rgba(255,255,255,0.1);
  609. color: rgba(255,255,255,0.8);
  610. border-radius: 4px;
  611. min-width: 20px;
  612. height: 20px;
  613. padding: 0 4px;
  614. font-size: 12px;
  615. margin-right: 12px;
  616. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  617. }
  618. .option.selected .shortcut {
  619. background: rgba(0,0,0,0.15);
  620. color: rgba(0,0,0,0.9);
  621. }
  622. .option-text {
  623. flex: 1;
  624. line-height: 1.4;
  625. }
  626. .recommended-badge {
  627. font-size: 11px;
  628. text-transform: uppercase;
  629. letter-spacing: 0.05em;
  630. background: rgba(255,255,255,0.15);
  631. color: rgba(255,255,255,0.9);
  632. padding: 4px 8px;
  633. border-radius: 999px;
  634. margin-left: 12px;
  635. font-weight: 600;
  636. }
  637. .option.selected .recommended-badge {
  638. background: rgba(0,0,0,0.15);
  639. color: rgba(0,0,0,0.8);
  640. }
  641. .submit-shortcut {
  642. display: inline-block;
  643. margin-left: 10px;
  644. padding: 3px 8px;
  645. border-radius: 999px;
  646. background: rgba(0,0,0,0.08);
  647. color: rgba(0,0,0,0.7);
  648. font-size: 12px;
  649. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  650. }
  651. textarea {
  652. width: 100%;
  653. box-sizing: border-box;
  654. min-height: 140px;
  655. border-radius: 6px;
  656. border: 1px solid rgba(255,255,255,0.15);
  657. background: rgba(0,0,0,0.6);
  658. color: inherit;
  659. padding: 16px;
  660. font-family: inherit;
  661. font-size: 16px;
  662. line-height: 1.5;
  663. resize: vertical;
  664. outline: none;
  665. transition: border-color 0.2s ease;
  666. }
  667. textarea:focus {
  668. border-color: rgba(255,255,255,0.5);
  669. box-shadow: 0 0 0 1px rgba(255,255,255,0.1);
  670. }
  671. .hidden-textarea {
  672. display: none;
  673. }
  674. button.primary {
  675. background: #ffffff;
  676. color: #000000;
  677. border: 0;
  678. border-radius: 6px;
  679. padding: 16px 24px;
  680. font-size: 16px;
  681. font-weight: 600;
  682. cursor: pointer;
  683. width: 100%;
  684. transition: opacity 0.2s ease, transform 0.1s ease;
  685. }
  686. button.primary:hover:not(:disabled) {
  687. opacity: 0.9;
  688. transform: translateY(-1px);
  689. }
  690. button.primary:active:not(:disabled) {
  691. transform: translateY(1px);
  692. }
  693. button.primary:disabled {
  694. opacity: 0.3;
  695. cursor: not-allowed;
  696. }
  697. .footer {
  698. margin-top: 32px;
  699. text-align: center;
  700. font-size: 13px;
  701. color: rgba(255,255,255,0.4);
  702. }
  703. .back-link {
  704. display: inline-flex;
  705. align-items: center;
  706. gap: 6px;
  707. color: rgba(255,255,255,0.5);
  708. text-decoration: none;
  709. font-size: 14px;
  710. padding: 8px 0;
  711. transition: color 0.2s ease;
  712. position: relative;
  713. z-index: 200;
  714. }
  715. .back-link:hover { color: #ffffff; }
  716. .status-completed { color: #34d399; }
  717. /* Loading State Overlay */
  718. .loading-overlay {
  719. position: fixed;
  720. top: 0; left: 0; right: 0; bottom: 0;
  721. background: rgba(0, 0, 0, 0.85);
  722. display: flex;
  723. flex-direction: column;
  724. align-items: center;
  725. justify-content: center;
  726. z-index: 100;
  727. opacity: 0;
  728. pointer-events: none;
  729. backdrop-filter: blur(8px);
  730. transition: opacity 0.3s ease;
  731. }
  732. .loading-overlay.active {
  733. opacity: 1;
  734. pointer-events: all;
  735. }
  736. .loading-overlay .status-text {
  737. font-size: 15px;
  738. letter-spacing: 0.1em;
  739. text-transform: uppercase;
  740. color: #ffffff;
  741. font-weight: 500;
  742. }
  743. /* ── Completed Mode ─────────────────────────────────────────── */
  744. .section-label {
  745. font-size: 11px;
  746. font-weight: 700;
  747. letter-spacing: 0.12em;
  748. text-transform: uppercase;
  749. color: rgba(255,255,255,0.28);
  750. margin-bottom: 14px;
  751. }
  752. .spec-block {
  753. background: rgba(255,255,255,0.02);
  754. border: 1px solid rgba(255,255,255,0.08);
  755. border-radius: 6px;
  756. padding: 20px 24px;
  757. margin-bottom: 32px;
  758. color: rgba(255,255,255,0.72);
  759. font-size: 15px;
  760. line-height: 1.75;
  761. }
  762. .spec-block p { margin: 0 0 10px; }
  763. .spec-block p:last-child { margin-bottom: 0; }
  764. .spec-block strong { color: rgba(255,255,255,0.95); font-weight: 600; }
  765. .spec-block code {
  766. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  767. font-size: 13px;
  768. background: rgba(255,255,255,0.08);
  769. padding: 1px 5px;
  770. border-radius: 3px;
  771. }
  772. .qa-list { margin-bottom: 12px; }
  773. .qa-card {
  774. background: rgba(255,255,255,0.02);
  775. border: 1px solid rgba(255,255,255,0.08);
  776. border-radius: 6px;
  777. padding: 18px 22px;
  778. margin-bottom: 10px;
  779. }
  780. .qa-row { display: flex; gap: 14px; align-items: flex-start; }
  781. .qa-row + .qa-row { margin-top: 12px; }
  782. .qa-badge {
  783. display: inline-flex;
  784. align-items: center;
  785. justify-content: center;
  786. width: 22px;
  787. height: 22px;
  788. border-radius: 50%;
  789. font-size: 10px;
  790. font-weight: 700;
  791. flex-shrink: 0;
  792. margin-top: 2px;
  793. font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  794. }
  795. .qa-badge-q { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.85); }
  796. .qa-badge-a { background: rgba(52,211,153,0.15); color: #34d399; }
  797. .qa-divider { height: 1px; background: rgba(255,255,255,0.05); margin: 12px 0; }
  798. .qa-text { font-size: 15px; line-height: 1.55; padding-top: 2px; }
  799. .qa-text-q { color: rgba(255,255,255,0.82); }
  800. .qa-text-a { color: rgba(255,255,255,0.52); }
  801. .qa-empty {
  802. color: rgba(255,255,255,0.25);
  803. font-size: 14px;
  804. text-align: center;
  805. padding: 20px 0;
  806. font-style: italic;
  807. }
  808. .nudge-actions {
  809. display: flex;
  810. gap: 12px;
  811. margin-top: 32px;
  812. margin-bottom: 8px;
  813. }
  814. .nudge-btn {
  815. flex: 1;
  816. background: transparent;
  817. border: 1px solid rgba(255,255,255,0.15);
  818. color: rgba(255,255,255,0.65);
  819. border-radius: 6px;
  820. padding: 13px 16px;
  821. font-size: 14px;
  822. font-weight: 500;
  823. cursor: pointer;
  824. transition: all 0.2s ease;
  825. letter-spacing: 0.01em;
  826. }
  827. .nudge-btn:hover:not(:disabled) {
  828. border-color: rgba(255,255,255,0.4);
  829. color: #ffffff;
  830. background: rgba(255,255,255,0.04);
  831. }
  832. .nudge-btn.nudge-confirm {
  833. border-color: rgba(52,211,153,0.25);
  834. color: rgba(52,211,153,0.8);
  835. }
  836. .nudge-btn.nudge-confirm:hover:not(:disabled) {
  837. border-color: rgba(52,211,153,0.6);
  838. color: #34d399;
  839. background: rgba(52,211,153,0.05);
  840. }
  841. .nudge-btn:disabled { opacity: 0.3; cursor: not-allowed; }
  842. </style>
  843. </head>
  844. <body>
  845. <div class="wrap">
  846. <a href="/" class="back-link">← All Interviews</a>
  847. <div class="brand-header">
  848. ${brandImage(144)}
  849. </div>
  850. <h1 id="idea">Connecting...</h1>
  851. <p class="muted" id="summary">Preparing interview session</p>
  852. <div class="meta">
  853. <span id="status">INITIALIZING</span>
  854. <span>OH MY OPENCODE SLIM</span>
  855. </div>
  856. <div id="filePathContainer" class="file-path-container" style="display: none;">
  857. <span class="file-path-icon">📄</span>
  858. <span id="markdownPath"></span>
  859. <button id="downloadBtn" class="download-btn" type="button">Download</button>
  860. </div>
  861. <div id="resumeRow" class="resume-row" style="display:none;">
  862. <code id="resumeCmd" class="resume-cmd"></code>
  863. <button class="copy-btn" id="copyResumeBtn" type="button">Copy</button>
  864. </div>
  865. <div id="questions"></div>
  866. <div id="nudgeActions" class="nudge-actions" style="display:none;">
  867. <button class="nudge-btn" id="moreQuestionsBtn" type="button">Ask more questions</button>
  868. <button class="nudge-btn nudge-confirm" id="confirmCompleteBtn" type="button">Confirm complete ✓</button>
  869. </div>
  870. <button class="primary" id="submitButton" disabled>Submit Answers <span class="submit-shortcut">⌘↵</span></button>
  871. <div class="footer" id="submitStatus"></div>
  872. </div>
  873. <div class="loading-overlay" id="loadingOverlay">
  874. <div class="status-text" id="loadingText">Processing...</div>
  875. </div>
  876. <script>
  877. ${clipboardHelperJs()}
  878. const interviewId = ${JSON.stringify(interviewId).replace(/</g, '\\u003c')};
  879. const resumeSlug = ${JSON.stringify(resumeSlug).replace(/</g, '\\u003c')};
  880. const state = {
  881. data: null,
  882. answers: {},
  883. activeQuestionIndex: 0,
  884. lastQuestionIds: [],
  885. lastSig: null,
  886. customMode: {},
  887. isSubmitting: false,
  888. };
  889. function updateSubmitButton() {
  890. const button = document.getElementById('submitButton');
  891. if (!state.data) {
  892. button.disabled = true;
  893. return;
  894. }
  895. const questions = state.data.questions || [];
  896. const allAnswered = questions.every((question) =>
  897. (state.answers[question.id] || '').trim().length > 0,
  898. );
  899. button.disabled =
  900. state.data.isBusy ||
  901. state.isSubmitting ||
  902. !questions.length ||
  903. !allAnswered;
  904. const hideSubmit = ['completed', 'session-disconnected'];
  905. button.style.display = hideSubmit.includes(state.data.mode) ? 'none' : '';
  906. const overlay = document.getElementById('loadingOverlay');
  907. const overlayText = document.getElementById('loadingText');
  908. if (state.data.isBusy) {
  909. overlay.classList.add('active');
  910. overlayText.textContent = "Agent Thinking...";
  911. } else {
  912. overlay.classList.remove('active');
  913. }
  914. }
  915. function getOptionButtonId(questionId, index) {
  916. return 'opt-' + questionId + '-' + index;
  917. }
  918. function createOption(question, option, index, isCustom) {
  919. const button = document.createElement('button');
  920. button.type = 'button';
  921. button.className = 'option';
  922. button.id = getOptionButtonId(question.id, index);
  923. const shortcut = index < 9 ? (index + 1) : '';
  924. if (shortcut) {
  925. const kbd = document.createElement('span');
  926. kbd.className = 'shortcut';
  927. kbd.textContent = shortcut;
  928. button.appendChild(kbd);
  929. }
  930. const text = document.createElement('span');
  931. text.className = 'option-text';
  932. text.textContent = isCustom ? 'Custom' : option;
  933. button.appendChild(text);
  934. // Visual marking for suggested/recommended answers
  935. if (!isCustom && question.suggested === option) {
  936. const badge = document.createElement('span');
  937. badge.className = 'recommended-badge';
  938. badge.textContent = 'Recommended';
  939. button.appendChild(badge);
  940. }
  941. button.addEventListener('click', () => {
  942. const questions = state.data?.questions || [];
  943. const qIdx = questions.findIndex(q => q.id === question.id);
  944. if (qIdx !== -1) {
  945. state.activeQuestionIndex = qIdx;
  946. updateActiveQuestionFocus();
  947. }
  948. handleOptionSelect(question, option, isCustom);
  949. });
  950. return button;
  951. }
  952. function handleOptionSelect(question, option, isCustom) {
  953. const textarea = document.getElementById('answer-' + question.id);
  954. if (isCustom) {
  955. state.customMode[question.id] = true;
  956. state.answers[question.id] = state.customMode[question.id]
  957. ? state.answers[question.id] || ''
  958. : '';
  959. updateTextareaVisibility(question.id);
  960. updateOptionsDOM(question.id);
  961. if (textarea) {
  962. textarea.focus();
  963. }
  964. } else {
  965. state.customMode[question.id] = false;
  966. state.answers[question.id] = option;
  967. updateTextareaVisibility(question.id);
  968. advanceToNextQuestion(question.id);
  969. }
  970. updateSubmitButton();
  971. updateOptionsDOM(question.id);
  972. }
  973. function updateTextareaVisibility(questionId) {
  974. const textarea = document.getElementById('answer-' + questionId);
  975. if (!textarea) return;
  976. if (state.customMode[questionId]) {
  977. textarea.classList.remove('hidden-textarea');
  978. } else {
  979. textarea.classList.add('hidden-textarea');
  980. }
  981. }
  982. function advanceToNextQuestion(currentQuestionId) {
  983. const questions = state.data?.questions || [];
  984. const currentIndex = questions.findIndex(q => q.id === currentQuestionId);
  985. if (currentIndex >= 0 && currentIndex < questions.length - 1) {
  986. state.activeQuestionIndex = currentIndex + 1;
  987. updateActiveQuestionFocus();
  988. const nextQuestion = questions[currentIndex + 1];
  989. const nextEl = document.getElementById('question-' + nextQuestion.id);
  990. if (nextEl) {
  991. nextEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
  992. }
  993. } else if (currentIndex === questions.length - 1) {
  994. const submitBtn = document.getElementById('submitButton');
  995. if (submitBtn && !submitBtn.disabled) {
  996. submitBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
  997. }
  998. }
  999. }
  1000. function updateOptionsDOM(questionId) {
  1001. const question = (state.data?.questions || []).find(q => q.id === questionId);
  1002. if (!question) return;
  1003. const predefined = question.options || [];
  1004. const currentAnswer = state.answers[question.id];
  1005. predefined.forEach((opt, idx) => {
  1006. const btn = document.getElementById(getOptionButtonId(questionId, idx));
  1007. if (btn) {
  1008. if (currentAnswer === opt) btn.classList.add('selected');
  1009. else btn.classList.remove('selected');
  1010. }
  1011. });
  1012. const customBtn = document.getElementById(getOptionButtonId(questionId, predefined.length));
  1013. if (customBtn) {
  1014. if (state.customMode[questionId]) {
  1015. customBtn.classList.add('selected');
  1016. } else {
  1017. customBtn.classList.remove('selected');
  1018. }
  1019. }
  1020. }
  1021. function updateActiveQuestionFocus() {
  1022. const questions = state.data?.questions || [];
  1023. questions.forEach((q, idx) => {
  1024. const wrapper = document.getElementById('question-' + q.id);
  1025. if (wrapper) {
  1026. if (idx === state.activeQuestionIndex) {
  1027. wrapper.classList.add('active-question');
  1028. } else {
  1029. wrapper.classList.remove('active-question');
  1030. }
  1031. }
  1032. });
  1033. }
  1034. function scrollToActiveQuestion(behavior) {
  1035. const questions = state.data?.questions || [];
  1036. const activeQ = questions[state.activeQuestionIndex];
  1037. if (!activeQ) return;
  1038. const wrapper = document.getElementById('question-' + activeQ.id);
  1039. if (wrapper) {
  1040. wrapper.scrollIntoView({ behavior, block: 'center' });
  1041. }
  1042. }
  1043. function syncActiveQuestionIndex(questions) {
  1044. if (!questions.length) {
  1045. state.activeQuestionIndex = 0;
  1046. state.lastQuestionIds = [];
  1047. return;
  1048. }
  1049. const nextQuestionIds = questions.map((question) => question.id);
  1050. const previousQuestionIds = state.lastQuestionIds || [];
  1051. const activeQuestionId = previousQuestionIds[state.activeQuestionIndex];
  1052. const nextActiveIndex = activeQuestionId
  1053. ? nextQuestionIds.indexOf(activeQuestionId)
  1054. : -1;
  1055. if (nextActiveIndex >= 0) {
  1056. state.activeQuestionIndex = nextActiveIndex;
  1057. } else {
  1058. state.activeQuestionIndex = 0;
  1059. }
  1060. state.lastQuestionIds = nextQuestionIds;
  1061. }
  1062. function isTextEntryTarget(target) {
  1063. return target &&
  1064. (target.tagName === 'TEXTAREA' ||
  1065. target.tagName === 'INPUT' ||
  1066. target.isContentEditable);
  1067. }
  1068. function isShortcutBlockedTarget(target) {
  1069. if (!target) return false;
  1070. return !!target.closest(
  1071. 'button, a, select, summary, textarea, input, [contenteditable="true"]',
  1072. );
  1073. }
  1074. document.addEventListener('keydown', (e) => {
  1075. const isSubmitShortcut =
  1076. (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) ||
  1077. (e.key === 's' && (e.metaKey || e.ctrlKey));
  1078. if (isSubmitShortcut) {
  1079. const submitBtn = document.getElementById('submitButton');
  1080. if (submitBtn && !submitBtn.disabled) {
  1081. submitBtn.click();
  1082. e.preventDefault();
  1083. }
  1084. return;
  1085. }
  1086. if (isTextEntryTarget(e.target)) return;
  1087. if (e.ctrlKey || e.metaKey || e.altKey) return;
  1088. const questions = state.data?.questions || [];
  1089. if (!questions.length) return;
  1090. if (e.key === 'Enter') {
  1091. if (e.repeat) {
  1092. e.preventDefault();
  1093. return;
  1094. }
  1095. if (isShortcutBlockedTarget(e.target)) return;
  1096. if (state.data.isBusy || state.isSubmitting) return;
  1097. const activeQ = questions[state.activeQuestionIndex];
  1098. if (!activeQ) return;
  1099. const answer = (state.answers[activeQ.id] || '').trim();
  1100. if (!answer) return;
  1101. const isLastQuestion =
  1102. state.activeQuestionIndex === questions.length - 1;
  1103. if (isLastQuestion) {
  1104. const submitBtn = document.getElementById('submitButton');
  1105. if (submitBtn && !submitBtn.disabled) {
  1106. submitBtn.click();
  1107. }
  1108. } else {
  1109. advanceToNextQuestion(activeQ.id);
  1110. }
  1111. e.preventDefault();
  1112. return;
  1113. }
  1114. const num = parseInt(e.key, 10);
  1115. if (num >= 1 && num <= 9) {
  1116. const activeQ = questions[state.activeQuestionIndex];
  1117. if (!activeQ) return;
  1118. const options = activeQ.options || [];
  1119. if (!options.length) return;
  1120. const idx = num - 1;
  1121. if (idx < options.length) {
  1122. handleOptionSelect(activeQ, options[idx], false);
  1123. e.preventDefault();
  1124. } else if (idx === options.length) {
  1125. handleOptionSelect(activeQ, 'Custom', true);
  1126. e.preventDefault();
  1127. }
  1128. }
  1129. if (e.key === 'ArrowDown') {
  1130. if (state.activeQuestionIndex < questions.length - 1) {
  1131. state.activeQuestionIndex++;
  1132. updateActiveQuestionFocus();
  1133. const wrapper = document.getElementById('question-' + questions[state.activeQuestionIndex].id);
  1134. if (wrapper) wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
  1135. e.preventDefault();
  1136. }
  1137. }
  1138. if (e.key === 'ArrowUp') {
  1139. if (state.activeQuestionIndex > 0) {
  1140. state.activeQuestionIndex--;
  1141. updateActiveQuestionFocus();
  1142. const wrapper = document.getElementById('question-' + questions[state.activeQuestionIndex].id);
  1143. if (wrapper) wrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
  1144. e.preventDefault();
  1145. }
  1146. }
  1147. });
  1148. // ── Completed-mode helpers ───────────────────────────────────
  1149. function parseDocument(doc) {
  1150. if (!doc) return { spec: '', qaPairs: [] };
  1151. // Strip frontmatter (---...---) to prevent frontmatter lines from
  1152. // matching section headers or being parsed as content
  1153. let content = doc;
  1154. const fmMatch = content.match(/^---\\n[\\s\\S]*?\\n---\\n/);
  1155. if (fmMatch) content = content.slice(fmMatch[0].length);
  1156. const lines = content.split('\\n');
  1157. const specLines = [];
  1158. const qaLines = [];
  1159. let section = null;
  1160. for (const line of lines) {
  1161. if (/^## Current spec\\b/i.test(line)) { section = 'spec'; continue; }
  1162. if (/^## Q&A history\\b/i.test(line)) { section = 'qa'; continue; }
  1163. if (/^#{1,6} /.test(line)) { section = null; continue; }
  1164. if (section === 'spec') specLines.push(line);
  1165. else if (section === 'qa') qaLines.push(line);
  1166. }
  1167. const qaPairs = [];
  1168. let current = null;
  1169. for (const line of qaLines) {
  1170. const trimmed = line.trim();
  1171. if (trimmed.startsWith('Q: ')) {
  1172. if (current) qaPairs.push(current);
  1173. current = { q: trimmed.slice(3), a: '' };
  1174. } else if (trimmed.startsWith('A: ') && current) {
  1175. current.a = trimmed.slice(3);
  1176. } else if (trimmed && current && current.a) {
  1177. current.a += ' ' + trimmed;
  1178. }
  1179. }
  1180. if (current) qaPairs.push(current);
  1181. return { spec: specLines.join('\\n').trim(), qaPairs };
  1182. }
  1183. // simpleMarkdown: safe because escaping happens BEFORE markdown
  1184. // processing. Adding raw HTML output from user content would break
  1185. // this safety — always use textContent for user data.
  1186. function simpleMarkdown(text) {
  1187. if (!text) return '';
  1188. const escaped = text
  1189. .replace(/&/g, '&amp;')
  1190. .replace(/</g, '&lt;')
  1191. .replace(/>/g, '&gt;');
  1192. const formatted = escaped
  1193. .replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
  1194. .replace(/\`([^\`]+)\`/g, '<code>$1</code>');
  1195. const paragraphs = formatted.split(/\\n\\n+/).filter(p => p.trim());
  1196. return paragraphs.map(p => \`<p>\${p.replace(/\\n/g, '<br>')}</p>\`).join('');
  1197. }
  1198. function renderCompletedView(data) {
  1199. const container = document.getElementById('questions');
  1200. const { spec, qaPairs } = parseDocument(data.document);
  1201. const frag = document.createDocumentFragment();
  1202. // Spec section
  1203. if (spec) {
  1204. const specLabel = document.createElement('div');
  1205. specLabel.className = 'section-label';
  1206. specLabel.textContent = 'Current Spec';
  1207. frag.appendChild(specLabel);
  1208. const specBlock = document.createElement('div');
  1209. specBlock.className = 'spec-block';
  1210. specBlock.innerHTML = simpleMarkdown(spec);
  1211. frag.appendChild(specBlock);
  1212. }
  1213. // Q&A section
  1214. const qaLabel = document.createElement('div');
  1215. qaLabel.className = 'section-label';
  1216. qaLabel.textContent = 'Q&A History';
  1217. frag.appendChild(qaLabel);
  1218. if (!qaPairs.length) {
  1219. const empty = document.createElement('p');
  1220. empty.className = 'qa-empty';
  1221. empty.textContent = 'No Q&A recorded.';
  1222. frag.appendChild(empty);
  1223. } else {
  1224. const qaList = document.createElement('div');
  1225. qaList.className = 'qa-list';
  1226. for (const pair of qaPairs) {
  1227. const card = document.createElement('div');
  1228. card.className = 'qa-card';
  1229. const qRow = document.createElement('div');
  1230. qRow.className = 'qa-row';
  1231. const qBadge = document.createElement('span');
  1232. qBadge.className = 'qa-badge qa-badge-q';
  1233. qBadge.textContent = 'Q';
  1234. const qText = document.createElement('span');
  1235. qText.className = 'qa-text qa-text-q';
  1236. qText.textContent = pair.q;
  1237. qRow.appendChild(qBadge);
  1238. qRow.appendChild(qText);
  1239. card.appendChild(qRow);
  1240. const divider = document.createElement('div');
  1241. divider.className = 'qa-divider';
  1242. card.appendChild(divider);
  1243. const aRow = document.createElement('div');
  1244. aRow.className = 'qa-row';
  1245. const aBadge = document.createElement('span');
  1246. aBadge.className = 'qa-badge qa-badge-a';
  1247. aBadge.textContent = 'A';
  1248. const aText = document.createElement('span');
  1249. aText.className = 'qa-text qa-text-a';
  1250. aText.textContent = pair.a || '—';
  1251. aRow.appendChild(aBadge);
  1252. aRow.appendChild(aText);
  1253. card.appendChild(aRow);
  1254. qaList.appendChild(card);
  1255. }
  1256. frag.appendChild(qaList);
  1257. }
  1258. container.replaceChildren(frag);
  1259. }
  1260. function renderQuestions(questions) {
  1261. const sig = JSON.stringify([questions, state.data?.mode]);
  1262. const container = document.getElementById('questions');
  1263. const previousActiveQuestionId =
  1264. state.lastQuestionIds[state.activeQuestionIndex];
  1265. syncActiveQuestionIndex(questions);
  1266. if (state.lastSig === sig) {
  1267. questions.forEach((q) => updateOptionsDOM(q.id));
  1268. updateActiveQuestionFocus();
  1269. return;
  1270. }
  1271. state.lastSig = sig;
  1272. container.replaceChildren();
  1273. if (!questions.length && !state.data?.isBusy) {
  1274. const doneModes = ['completed', 'session-disconnected'];
  1275. if (doneModes.includes(state.data?.mode)) {
  1276. renderCompletedView(state.data);
  1277. } else {
  1278. const empty = document.createElement('p');
  1279. empty.className = 'muted';
  1280. empty.style.textAlign = 'center';
  1281. empty.style.padding = '48px 0';
  1282. empty.textContent = 'No active questions right now.';
  1283. container.appendChild(empty);
  1284. }
  1285. return;
  1286. }
  1287. questions.forEach((question, idx) => {
  1288. const wrapper = document.createElement('div');
  1289. wrapper.className = 'question';
  1290. wrapper.id = 'question-' + question.id;
  1291. if (question.suggested && !state.answers[question.id]) {
  1292. state.answers[question.id] = question.suggested;
  1293. state.customMode[question.id] = false;
  1294. }
  1295. const title = document.createElement('h3');
  1296. title.textContent = question.question;
  1297. wrapper.appendChild(title);
  1298. const predefined = question.options || [];
  1299. if (predefined.length) {
  1300. const hint = document.createElement('div');
  1301. hint.className = 'question-hint';
  1302. hint.innerHTML =
  1303. '<span class="hint-chip"><kbd>1-9</kbd><span>Choose an option</span></span>' +
  1304. '<span class="hint-chip"><kbd>Enter</kbd><span>Accept selected answer</span></span>';
  1305. wrapper.appendChild(hint);
  1306. }
  1307. if (predefined.length) {
  1308. const options = document.createElement('div');
  1309. options.className = 'options';
  1310. predefined.forEach((option, optIdx) => {
  1311. options.appendChild(createOption(question, option, optIdx, false));
  1312. });
  1313. options.appendChild(createOption(question, 'Custom', predefined.length, true));
  1314. wrapper.appendChild(options);
  1315. }
  1316. const textarea = document.createElement('textarea');
  1317. textarea.id = 'answer-' + question.id;
  1318. textarea.placeholder = 'Type your answer here...';
  1319. textarea.value = state.customMode[question.id] ? (state.answers[question.id] || '') : '';
  1320. if (!state.customMode[question.id]) {
  1321. textarea.classList.add('hidden-textarea');
  1322. }
  1323. textarea.addEventListener('focus', () => {
  1324. state.activeQuestionIndex = idx;
  1325. updateActiveQuestionFocus();
  1326. });
  1327. textarea.addEventListener('input', () => {
  1328. state.answers[question.id] = textarea.value;
  1329. updateSubmitButton();
  1330. updateOptionsDOM(question.id);
  1331. });
  1332. wrapper.appendChild(textarea);
  1333. container.appendChild(wrapper);
  1334. });
  1335. updateActiveQuestionFocus();
  1336. questions.forEach(q => updateOptionsDOM(q.id));
  1337. const currentActiveQuestionId = questions[state.activeQuestionIndex]?.id;
  1338. if (
  1339. questions.length > 0 &&
  1340. previousActiveQuestionId !== currentActiveQuestionId
  1341. ) {
  1342. scrollToActiveQuestion('smooth');
  1343. }
  1344. }
  1345. function render(data) {
  1346. state.data = data;
  1347. document.getElementById('idea').textContent = data.interview.idea || 'Interview';
  1348. const doneModes = ['completed', 'session-disconnected'];
  1349. const isDone = doneModes.includes(data.mode);
  1350. const summaryEl = document.getElementById('summary');
  1351. if (isDone) {
  1352. summaryEl.style.display = 'none';
  1353. } else {
  1354. summaryEl.style.display = '';
  1355. summaryEl.textContent = data.summary || 'Session in progress.';
  1356. }
  1357. const statusEl = document.getElementById('status');
  1358. statusEl.textContent = data.mode.toUpperCase();
  1359. statusEl.className = isDone ? 'status-completed' : '';
  1360. // Render Markdown Path — always visible in completed mode
  1361. const pathContainer = document.getElementById('filePathContainer');
  1362. const pathElement = document.getElementById('markdownPath');
  1363. const mdPath = data.markdownPath || (data.interview && data.interview.markdownPath);
  1364. if (mdPath || isDone) {
  1365. if (mdPath) {
  1366. // Show just the filename for absolute paths
  1367. pathElement.textContent = mdPath.startsWith('/')
  1368. ? mdPath.split('/').pop() || mdPath
  1369. : mdPath;
  1370. } else {
  1371. pathElement.textContent = 'interview.md';
  1372. }
  1373. pathContainer.style.display = 'flex';
  1374. } else {
  1375. pathContainer.style.display = 'none';
  1376. }
  1377. // Resume command — only in session-disconnected mode
  1378. var resumeRow = document.getElementById('resumeRow');
  1379. if (resumeRow) {
  1380. var showResume = data.mode === 'session-disconnected';
  1381. resumeRow.style.display = showResume ? 'flex' : 'none';
  1382. if (showResume) {
  1383. document.getElementById('resumeCmd').textContent = '/interview ' + resumeSlug;
  1384. }
  1385. }
  1386. // Nudge actions — only for completed (live session)
  1387. // session-disconnected can't nudge (no session to receive it)
  1388. const nudgeActions = document.getElementById('nudgeActions');
  1389. const moreBtn = document.getElementById('moreQuestionsBtn');
  1390. const confirmBtn = document.getElementById('confirmCompleteBtn');
  1391. const canNudge = data.mode === 'completed';
  1392. if (nudgeActions) nudgeActions.style.display = canNudge ? 'flex' : 'none';
  1393. if (moreBtn) moreBtn.disabled = data.isBusy;
  1394. if (confirmBtn) confirmBtn.disabled = data.isBusy;
  1395. renderQuestions(data.questions || []);
  1396. updateSubmitButton();
  1397. }
  1398. async function refresh() {
  1399. const url = '/api/interviews/' + encodeURIComponent(interviewId) + '/state';
  1400. const response = await fetch(url);
  1401. const data = await response.json();
  1402. if (!response.ok) throw new Error(data.error || 'Failed to load state');
  1403. render(data);
  1404. }
  1405. function scrollToTop() {
  1406. window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
  1407. }
  1408. document.getElementById('submitButton').addEventListener('click', async () => {
  1409. if (!state.data || state.isSubmitting) return;
  1410. document.getElementById('submitButton').blur();
  1411. const answers = (state.data.questions || []).map((question) => {
  1412. return {
  1413. questionId: question.id,
  1414. answer: (state.answers[question.id] || '').trim(),
  1415. };
  1416. });
  1417. state.isSubmitting = true;
  1418. updateSubmitButton();
  1419. const overlay = document.getElementById('loadingOverlay');
  1420. const overlayText = document.getElementById('loadingText');
  1421. overlay.classList.add('active');
  1422. overlayText.textContent = "Submitting Answers...";
  1423. scrollToTop();
  1424. try {
  1425. const response = await fetch('/api/interviews/' + encodeURIComponent(interviewId) + '/answers', {
  1426. method: 'POST',
  1427. headers: { 'content-type': 'application/json' },
  1428. body: JSON.stringify({ answers }),
  1429. });
  1430. const payload = await response.json();
  1431. document.getElementById('submitStatus').textContent = payload.message || (response.ok ? 'Answers submitted successfully.' : 'Submission failed.');
  1432. } catch (err) {
  1433. document.getElementById('submitStatus').textContent = 'Error submitting answers.';
  1434. }
  1435. state.isSubmitting = false;
  1436. updateSubmitButton();
  1437. try {
  1438. await refresh();
  1439. } catch (_error) {
  1440. overlay.classList.remove('active');
  1441. }
  1442. });
  1443. document.getElementById('downloadBtn').addEventListener('click', () => {
  1444. if (!state.data || !state.data.document) return;
  1445. const blob = new Blob([state.data.document], { type: 'text/markdown' });
  1446. const url = URL.createObjectURL(blob);
  1447. const a = document.createElement('a');
  1448. a.href = url;
  1449. const mdPath = state.data.markdownPath || 'interview.md';
  1450. a.download = mdPath.split('/').pop() || 'interview.md';
  1451. document.body.appendChild(a);
  1452. a.click();
  1453. document.body.removeChild(a);
  1454. URL.revokeObjectURL(url);
  1455. });
  1456. document.getElementById('copyResumeBtn').addEventListener('click', function(e) {
  1457. e.stopPropagation();
  1458. e.preventDefault();
  1459. var cmd = document.getElementById('resumeCmd').textContent;
  1460. if (cmd) copyCommand(cmd, this);
  1461. });
  1462. async function sendNudge(action) {
  1463. const moreBtn = document.getElementById('moreQuestionsBtn');
  1464. const confirmBtn = document.getElementById('confirmCompleteBtn');
  1465. if (moreBtn) moreBtn.disabled = true;
  1466. if (confirmBtn) confirmBtn.disabled = true;
  1467. document.getElementById('submitStatus').textContent = '';
  1468. try {
  1469. const url =
  1470. '/api/interviews/' +
  1471. encodeURIComponent(interviewId) +
  1472. '/nudge';
  1473. const res = await fetch(url, {
  1474. method: 'POST',
  1475. headers: { 'content-type': 'application/json' },
  1476. body: JSON.stringify({ action }),
  1477. });
  1478. if (!res.ok) {
  1479. const err = await res.json().catch(() => ({}));
  1480. document.getElementById('submitStatus').textContent =
  1481. err.error || 'Request failed.';
  1482. if (moreBtn) moreBtn.disabled = false;
  1483. if (confirmBtn) confirmBtn.disabled = false;
  1484. } else {
  1485. try { await refresh(); } catch (_) {}
  1486. schedulePoll(); // Restart polling — nudge may reactivate interview
  1487. }
  1488. } catch (_err) {
  1489. document.getElementById('submitStatus').textContent = 'Network error.';
  1490. if (moreBtn) moreBtn.disabled = false;
  1491. if (confirmBtn) confirmBtn.disabled = false;
  1492. }
  1493. }
  1494. document.getElementById('moreQuestionsBtn').addEventListener('click', () => sendNudge('more-questions'));
  1495. document.getElementById('confirmCompleteBtn').addEventListener('click', () => sendNudge('confirm-complete'));
  1496. function schedulePoll() {
  1497. setTimeout(async () => {
  1498. try { await refresh(); } catch (_) {}
  1499. // Stop polling for terminal states
  1500. const terminalModes = ['abandoned', 'completed', 'session-disconnected'];
  1501. if (!terminalModes.includes(state.data?.mode)) schedulePoll();
  1502. }, 2500);
  1503. }
  1504. refresh().catch((error) => {
  1505. document.getElementById('submitStatus').textContent = error.message || 'Failed to load interview.';
  1506. });
  1507. schedulePoll();
  1508. </script>
  1509. </body>
  1510. </html>`;
  1511. }