Browse Source

Merge pull request #394 from alvinunreal/feat/interview-enter-shortcut

feat: add Enter shortcut for interview questions
Alvin 3 months ago
parent
commit
ea2b87b10d
4 changed files with 317 additions and 25 deletions
  1. 128 11
      src/interview/interview.test.ts
  2. 30 0
      src/interview/service.ts
  3. 158 13
      src/interview/ui.ts
  4. 1 1
      src/utils/system-collapse.test.ts

+ 128 - 11
src/interview/interview.test.ts

@@ -803,6 +803,49 @@ describe('interview service', () => {
       // Cleanup
       await fs.rm(tempDir, { recursive: true, force: true });
     });
+
+    test('message.updated stores current session model for interview follow-ups', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+      const ctx = createMockContext({ directory: tempDir });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const sessionID = 'session-model-track';
+
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+      await service.handleCommandExecuteBefore(
+        { command: 'interview', sessionID, arguments: 'Model Track Test' },
+        output,
+      );
+
+      const interviewId = requireInterviewId(
+        extractInterviewIdFromLastPrompt(ctx.client.session.prompt),
+      );
+
+      await service.handleEvent({
+        event: {
+          type: 'message.updated',
+          properties: {
+            info: {
+              sessionID,
+              providerID: 'openai',
+              modelID: 'gpt-5.4-mini',
+            },
+          },
+        },
+      });
+
+      ctx.client.session.promptAsync.mock.calls.length = 0;
+      await service.handleNudgeAction(interviewId, 'more-questions');
+
+      const call = ctx.client.session.promptAsync.mock.calls[0]?.[0];
+      expect(call.body.model).toEqual({
+        providerID: 'openai',
+        modelID: 'gpt-5.4-mini',
+      });
+
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
   });
 
   describe('configurable output folder', () => {
@@ -1218,6 +1261,74 @@ describe('interview service', () => {
       // Cleanup
       await fs.rm(tempDir, { recursive: true, force: true });
     });
+
+    test('reuses observed session model when submitting interview answers', async () => {
+      const tempDir = await fs.mkdtemp('/tmp/interview-test-');
+
+      const messagesData: Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }> = [];
+
+      const ctx = createMockContext({
+        directory: tempDir,
+        messagesData,
+      });
+
+      const service = createInterviewService(ctx);
+      service.setBaseUrlResolver(async () => 'http://localhost:9999');
+      const sessionID = 'session-answer-model';
+      const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+      await service.handleCommandExecuteBefore(
+        {
+          command: 'interview',
+          sessionID,
+          arguments: 'Answer Model Test',
+        },
+        output,
+      );
+
+      const interviewId = requireInterviewId(
+        extractInterviewIdFromLastPrompt(ctx.client.session.prompt),
+      );
+
+      messagesData.push({
+        info: { role: 'assistant' },
+        parts: [
+          {
+            type: 'text',
+            text: 'Question.\n<interview_state>\n{\n  "summary": "Test",\n  "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
+          },
+        ],
+      });
+
+      await service.handleEvent({
+        event: {
+          type: 'message.updated',
+          properties: {
+            info: {
+              sessionID,
+              providerID: 'anthropic',
+              modelID: 'claude-sonnet-4-6',
+            },
+          },
+        },
+      });
+
+      ctx.client.session.promptAsync.mock.calls.length = 0;
+      await service.submitAnswers(interviewId, [
+        { questionId: 'q-1', answer: 'A' },
+      ]);
+
+      const call = ctx.client.session.promptAsync.mock.calls[0]?.[0];
+      expect(call.body.model).toEqual({
+        providerID: 'anthropic',
+        modelID: 'claude-sonnet-4-6',
+      });
+
+      await fs.rm(tempDir, { recursive: true, force: true });
+    });
   });
 
   describe('agent-provided title', () => {
@@ -1604,22 +1715,22 @@ describe('renderInterviewPage', () => {
     );
   });
 
-  test('renders a self-contained brand mark', () => {
+  test('renders the hosted brand logo', () => {
     const html = renderInterviewPage('brand-test', 'brand-test');
 
-    expect(html).toContain('<svg');
-    expect(html).not.toContain('https://ohmyopencodeslim.com');
+    expect(html).toContain('<img class="brand-mark"');
+    expect(html).toContain(
+      'https://ohmyopencodeslim.com/android-chrome-512x512.png',
+    );
   });
 
-  test('includes smooth scroll-to-top behavior in the submit handler', () => {
-    const html = renderInterviewPage('scroll-test', 'scroll-test');
+  test('shows explicit Enter guidance for option questions', () => {
+    const html = renderInterviewPage('enter-hint-test', 'enter-hint-test');
 
-    expect(html).toContain('function scrollToTop()');
-    expect(html).toContain(
-      "window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });",
-    );
-    expect(html).toContain('overlayText.textContent = "Submitting Answers...";');
-    expect(html).toContain('scrollToTop();');
+    expect(html).toContain('.question-hint {');
+    expect(html).toContain('.hint-chip {');
+    expect(html).toContain('<kbd>Enter</kbd><span>Accept selected answer</span>');
+    expect(html).toContain('<kbd>1-9</kbd><span>Choose an option</span>');
   });
 });
 
@@ -1657,7 +1768,13 @@ describe('interview server port configuration', () => {
           isBusy: false,
         }) as any,
     ),
+    listInterviewFiles: mock(async () => []),
+    listInterviews: mock(() => []),
     submitAnswers: mock(async (_id: string, _answers: InterviewAnswer[]) => {}),
+    handleNudgeAction: mock(
+      async (_id: string, _action: 'more-questions' | 'confirm-complete') => {},
+    ),
+    outputFolder: 'interview',
   };
 
   test('server starts on a specific port when port is non-zero', async () => {

+ 30 - 0
src/interview/service.ts

@@ -8,6 +8,7 @@ import {
   hasInternalInitiatorMarker,
   log,
 } from '../utils';
+import { parseModelReference } from '../utils/session';
 import {
   appendInterviewAnswers,
   createInterviewDirectoryPath,
@@ -154,6 +155,7 @@ export function createInterviewService(
   const activeInterviewIds = new Map<string, string>();
   const interviewsById = new Map<string, InterviewRecord>();
   const sessionBusy = new Map<string, boolean>();
+  const sessionModel = new Map<string, string>();
   const browserOpened = new Set<string>(); // Track interviews that have opened browser
   let resolveBaseUrl: (() => Promise<string>) | null = null;
   let onStateChange:
@@ -520,10 +522,14 @@ export function createInterviewService(
 
       // Use promptAsync for non-blocking — returns immediately, LLM
       // processes in background. State push updates dashboard when done.
+      const model = sessionModel.get(interview.sessionID);
       await ctx.client.session.promptAsync({
         path: { id: interview.sessionID },
         body: {
           parts: [createInternalAgentTextPart(prompt)],
+          ...(model
+            ? { model: parseModelReference(model) ?? undefined }
+            : {}),
         },
       });
       promptSent = true;
@@ -603,6 +609,25 @@ export function createInterviewService(
       return;
     }
 
+    if (event.type === 'message.updated') {
+      const info = properties as
+        | {
+            info?: {
+              sessionID?: string;
+              providerID?: string;
+              modelID?: string;
+            };
+          }
+        | undefined;
+      const sessionID = info?.info?.sessionID;
+      const providerID = info?.info?.providerID;
+      const modelID = info?.info?.modelID;
+      if (sessionID && providerID && modelID) {
+        sessionModel.set(sessionID, `${providerID}/${modelID}`);
+      }
+      return;
+    }
+
     if (event.type === 'session.deleted') {
       const deletedSessionId =
         ((properties.info as { id?: string } | undefined)?.id ??
@@ -613,6 +638,7 @@ export function createInterviewService(
       }
 
       sessionBusy.delete(deletedSessionId);
+      sessionModel.delete(deletedSessionId);
       const interviewId = activeInterviewIds.get(deletedSessionId);
       if (!interviewId) {
         return;
@@ -731,10 +757,14 @@ export function createInterviewService(
         ].join('\n');
       }
 
+      const model = sessionModel.get(interview.sessionID);
       await ctx.client.session.promptAsync({
         path: { id: interview.sessionID },
         body: {
           parts: [createInternalAgentTextPart(prompt)],
+          ...(model
+            ? { model: parseModelReference(model) ?? undefined }
+            : {}),
         },
       });
       promptSent = true;

+ 158 - 13
src/interview/ui.ts

@@ -8,6 +8,9 @@ interface DashboardInterviewItem extends InterviewListItem {
   directory?: string;
 }
 
+const BRAND_LOGO_URL =
+  'https://ohmyopencodeslim.com/android-chrome-512x512.png';
+
 export function escapeHtml(value: string): string {
   return value
     .replaceAll('&', '&amp;')
@@ -117,14 +120,10 @@ function sharedStyles(): string {
       .footer { margin-top: 32px; text-align: center; font-size: 13px; color: rgba(255,255,255,0.4); }`;
 }
 
-// ─── Dashboard brand SVG ───────────────────────────────────────────
+// ─── Dashboard brand image ─────────────────────────────────────────
 
-function brandSvg(size: number): string {
-  return `<svg class="brand-mark" viewBox="0 0 144 144" role="img" aria-label="Oh My Opencode Slim" style="width:${size}px;height:${size}px">
-          <rect x="12" y="12" width="120" height="120" rx="32" fill="rgba(255,255,255,0.08)" stroke="rgba(255,255,255,0.18)" stroke-width="2"/>
-          <path d="M50 48h18c16 0 26 10 26 24s-10 24-26 24H50z" fill="none" stroke="white" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
-          <path d="M74 48h20c10 0 18 8 18 18v12c0 10-8 18-18 18H74" fill="none" stroke="white" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" opacity="0.65"/>
-        </svg>`;
+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(
@@ -371,7 +370,7 @@ export function renderDashboardPage(
   <body>
     <div class="wrap">
       <div class="brand-header">
-        ${brandSvg(96)}
+        ${brandImage(96)}
         <h1>Interviews</h1>
         <p class="muted">${totalCount} item${totalCount === 1 ? '' : 's'}</p>
       </div>
@@ -577,6 +576,37 @@ export function renderInterviewPage(
       }
       
       .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); 
@@ -856,7 +886,7 @@ export function renderInterviewPage(
     <div class="wrap">
       <a href="/" class="back-link">← All Interviews</a>
       <div class="brand-header">
-        ${brandSvg(144)}
+        ${brandImage(144)}
       </div>
       <h1 id="idea">Connecting...</h1>
       <p class="muted" id="summary">Preparing interview session</p>
@@ -897,7 +927,15 @@ export function renderInterviewPage(
       ${clipboardHelperJs()}
       const interviewId = ${JSON.stringify(interviewId).replace(/</g, '\\u003c')};
       const resumeSlug = ${JSON.stringify(resumeSlug).replace(/</g, '\\u003c')};
-      const state = { data: null, answers: {}, activeQuestionIndex: 0, lastSig: null, customMode: {} };
+      const state = {
+        data: null,
+        answers: {},
+        activeQuestionIndex: 0,
+        lastQuestionIds: [],
+        lastSig: null,
+        customMode: {},
+        isSubmitting: false,
+      };
 
       function updateSubmitButton() {
         const button = document.getElementById('submitButton');
@@ -910,7 +948,11 @@ export function renderInterviewPage(
         const allAnswered = questions.every((question) =>
           (state.answers[question.id] || '').trim().length > 0,
         );
-        button.disabled = state.data.isBusy || !questions.length || !allAnswered;
+        button.disabled =
+          state.data.isBusy ||
+          state.isSubmitting ||
+          !questions.length ||
+          !allAnswered;
         const hideSubmit = ['completed', 'session-disconnected'];
         button.style.display = hideSubmit.includes(state.data.mode) ? 'none' : '';
         
@@ -1061,6 +1103,54 @@ export function renderInterviewPage(
          });
       }
 
+      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)) ||
@@ -1074,12 +1164,41 @@ export function renderInterviewPage(
           return;
         }
 
-        if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') 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];
@@ -1247,6 +1366,10 @@ export function renderInterviewPage(
       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));
@@ -1287,6 +1410,15 @@ export function renderInterviewPage(
           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';
@@ -1322,6 +1454,13 @@ export function renderInterviewPage(
         
         updateActiveQuestionFocus();
         questions.forEach(q => updateOptionsDOM(q.id));
+        const currentActiveQuestionId = questions[state.activeQuestionIndex]?.id;
+        if (
+          questions.length > 0 &&
+          previousActiveQuestionId !== currentActiveQuestionId
+        ) {
+          scrollToActiveQuestion('smooth');
+        }
       }
 
       function render(data) {
@@ -1395,7 +1534,8 @@ export function renderInterviewPage(
       }
 
       document.getElementById('submitButton').addEventListener('click', async () => {
-        if (!state.data) return;
+        if (!state.data || state.isSubmitting) return;
+        document.getElementById('submitButton').blur();
         const answers = (state.data.questions || []).map((question) => {
           return {
             questionId: question.id,
@@ -1403,6 +1543,9 @@ export function renderInterviewPage(
           };
         });
 
+        state.isSubmitting = true;
+        updateSubmitButton();
+
         const overlay = document.getElementById('loadingOverlay');
         const overlayText = document.getElementById('loadingText');
         overlay.classList.add('active');
@@ -1420,6 +1563,8 @@ export function renderInterviewPage(
         } catch (err) {
           document.getElementById('submitStatus').textContent = 'Error submitting answers.';
         }
+        state.isSubmitting = false;
+        updateSubmitButton();
         try {
           await refresh();
         } catch (_error) {

+ 1 - 1
src/utils/system-collapse.test.ts

@@ -1,4 +1,4 @@
-import { expect, describe, test } from 'bun:test';
+import { describe, expect, test } from 'bun:test';
 import { collapseSystemInPlace } from './system-collapse';
 
 /**