Browse Source

fix: polish interview keyboard flow

Alvin Unreal 3 months ago
parent
commit
7c2786f8ad
3 changed files with 286 additions and 12 deletions
  1. 161 3
      src/interview/interview.test.ts
  2. 30 0
      src/interview/service.ts
  3. 95 9
      src/interview/ui.ts

+ 161 - 3
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,11 +1715,13 @@ 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', () => {
@@ -1645,6 +1758,51 @@ describe('renderInterviewPage', () => {
     expect(html).toContain('submitBtn.click();');
   });
 
+  test('shows explicit Enter guidance for option questions', () => {
+    const html = renderInterviewPage('enter-hint-test', 'enter-hint-test');
+
+    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>');
+  });
+
+  test('resets active Enter target when a new question set arrives', () => {
+    const html = renderInterviewPage('enter-refresh-test', 'enter-refresh-test');
+
+    expect(html).toContain('lastQuestionIds: [],');
+    expect(html).toContain('function syncActiveQuestionIndex(questions) {');
+    expect(html).toContain(
+      'const activeQuestionId = previousQuestionIds[state.activeQuestionIndex];',
+    );
+    expect(html).toContain('state.activeQuestionIndex = 0;');
+    expect(html).toContain('state.lastQuestionIds = nextQuestionIds;');
+    expect(html).toContain('syncActiveQuestionIndex(questions);');
+  });
+
+  test('scrolls to the active question when a new question set loads', () => {
+    const html = renderInterviewPage('initial-scroll-test', 'initial-scroll-test');
+
+    expect(html).toContain('function scrollToActiveQuestion(behavior) {');
+    expect(html).toContain("wrapper.scrollIntoView({ behavior, block: 'center' });");
+    expect(html).toContain(
+      'const previousActiveQuestionId =',
+    );
+    expect(html).toContain(
+      'const currentActiveQuestionId = questions[state.activeQuestionIndex]?.id;',
+    );
+    expect(html).toContain(
+      'previousActiveQuestionId !== currentActiveQuestionId',
+    );
+    expect(html).toContain("scrollToActiveQuestion('smooth');");
+  });
+
+  test('blurs submit button before posting answers', () => {
+    const html = renderInterviewPage('submit-blur-test', 'submit-blur-test');
+
+    expect(html).toContain("document.getElementById('submitButton').blur();");
+  });
+
   test('keeps Enter in textarea on normal multiline behavior', () => {
     const html = renderInterviewPage(
       'textarea-enter-test',

+ 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;

+ 95 - 9
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>
@@ -901,6 +931,7 @@ export function renderInterviewPage(
         data: null,
         answers: {},
         activeQuestionIndex: 0,
+        lastQuestionIds: [],
         lastSig: null,
         customMode: {},
         isSubmitting: false,
@@ -1072,6 +1103,40 @@ 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' ||
@@ -1301,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));
@@ -1341,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';
@@ -1376,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) {
@@ -1450,6 +1535,7 @@ export function renderInterviewPage(
 
       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,