Explorar el Código

feat: add TOC sidebar navigation and chat panel to interview dashboard

- TOC sidebar: lists all spec block titles, click to scroll-to-block
  (visible only in completed/session-disconnected mode)
- Chat panel: bottom bar input to send freeform messages to the agent
  (add sections, revise content, ask questions) without returning to terminal
- Backend: submitChat in service.ts, POST/GET /api/interviews/{id}/chat
  endpoints, pollChat in manager.ts, pendingChatMessage in state entry
- All 1190 tests pass, typecheck clean, biome check clean
bobbyunknown hace 1 mes
padre
commit
465b4de91e

+ 77 - 0
src/interview/dashboard.ts

@@ -411,6 +411,7 @@ export function createDashboardServer(config: DashboardConfig): {
           filePath: path.join(interviewDir, entry),
           nudgeAction: null,
           pendingBlockComment: null,
+          pendingChatMessage: null,
         });
 
         // Also register the session directory
@@ -674,6 +675,7 @@ export function createDashboardServer(config: DashboardConfig): {
         filePath: '',
         nudgeAction: null,
         pendingBlockComment: null,
+        pendingChatMessage: null,
       });
       dedupRecovered(interviewId, stateCache);
       fileCache = null;
@@ -734,6 +736,7 @@ export function createDashboardServer(config: DashboardConfig): {
           filePath: state.filePath ?? '',
           nudgeAction: null,
           pendingBlockComment: state.pendingBlockComment ?? null,
+          pendingChatMessage: state.pendingChatMessage ?? null,
         });
       }
 
@@ -951,6 +954,78 @@ export function createDashboardServer(config: DashboardConfig): {
       return;
     }
 
+    // ── API: submit chat message (browser → dashboard) ──────────────
+    if (
+      request.method === 'POST' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/chat')
+    ) {
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/chat', '');
+      if (!isValidId(interviewId)) {
+        sendJson(response, 400, { error: 'Invalid interview ID' });
+        return;
+      }
+      const entry = stateCache.get(interviewId);
+      if (!entry) {
+        sendJson(response, 404, { error: 'Interview not found' });
+        return;
+      }
+
+      let body: unknown;
+      try {
+        body = await readJsonBody(request);
+      } catch {
+        sendJson(response, 400, { error: 'Invalid JSON' });
+        return;
+      }
+
+      const { message } = body as { message?: string };
+      if (typeof message !== 'string' || !message.trim()) {
+        sendJson(response, 400, {
+          error: 'message must be a non-empty string',
+        });
+        return;
+      }
+
+      entry.pendingChatMessage = message.trim();
+      entry.mode = 'awaiting-agent';
+      entry.lastUpdatedAt = Date.now();
+      sendJson(response, 200, { status: 'ok' });
+      return;
+    }
+
+    // ── API: get pending chat message (session polls, auth required) ─
+    if (
+      request.method === 'GET' &&
+      pathname.startsWith('/api/interviews/') &&
+      pathname.endsWith('/chat')
+    ) {
+      if (!isAuthenticated(request)) {
+        sendJson(response, 401, { error: 'Unauthorized' });
+        return;
+      }
+      const interviewId = pathname
+        .replace('/api/interviews/', '')
+        .replace('/chat', '');
+      if (!isValidId(interviewId)) {
+        sendJson(response, 400, { error: 'Invalid interview ID' });
+        return;
+      }
+      const entry = stateCache.get(interviewId);
+      if (!entry) {
+        sendJson(response, 404, { error: 'Interview not found' });
+        return;
+      }
+      const val = entry.pendingChatMessage;
+      if (val) {
+        entry.pendingChatMessage = null;
+      }
+      sendJson(response, 200, { message: val || null });
+      return;
+    }
+
     // ── API: get pending answers (session polls, auth required) ────
     if (
       request.method === 'GET' &&
@@ -1169,6 +1244,8 @@ export function createDashboardServer(config: DashboardConfig): {
         if (existing.nudgeAction) entry.nudgeAction ??= existing.nudgeAction;
         if (existing.pendingBlockComment)
           entry.pendingBlockComment ??= existing.pendingBlockComment;
+        if (existing.pendingChatMessage)
+          entry.pendingChatMessage ??= existing.pendingChatMessage;
       }
       stateCache.set(entry.interviewId, entry);
       dedupRecovered(entry.interviewId, stateCache);

+ 33 - 0
src/interview/manager.ts

@@ -61,6 +61,8 @@ export function createInterviewManager(
         service.submitAnswers(interviewId, answers),
       submitBlockComment: async (interviewId, section, comment) =>
         service.submitBlockComment(interviewId, section, comment),
+      submitChat: async (interviewId, message) =>
+        service.submitChat(interviewId, message),
       handleNudgeAction: async (interviewId, action) =>
         service.handleNudgeAction(interviewId, action),
       outputFolder: resolvedOutputPath,
@@ -105,6 +107,7 @@ export function createInterviewManager(
         pollPendingAnswers(sessionID).catch(() => {});
         pollNudgeAction(sessionID).catch(() => {});
         pollBlockComment(sessionID).catch(() => {});
+        pollChat(sessionID).catch(() => {});
       }
     }, FALLBACK_POLL_INTERVAL);
     fallbackTimer?.unref();
@@ -146,6 +149,7 @@ export function createInterviewManager(
             filePath: interview.markdownPath,
             nudgeAction: null,
             pendingBlockComment: null,
+            pendingChatMessage: null,
           });
           // Register session directory for file scanning
           dashboard?.registerSession({
@@ -245,6 +249,8 @@ export function createInterviewManager(
           service.submitAnswers(interviewId, answers),
         submitBlockComment: async (interviewId, section, comment) =>
           service.submitBlockComment(interviewId, section, comment),
+        submitChat: async (interviewId, message) =>
+          service.submitChat(interviewId, message),
         handleNudgeAction: async (interviewId, action) =>
           service.handleNudgeAction(interviewId, action),
         outputFolder: resolvedOutputPath,
@@ -347,6 +353,31 @@ export function createInterviewManager(
     }
   }
 
+  async function pollChat(sessionID: string) {
+    const interviewId = service.getActiveInterviewId(sessionID);
+    if (!interviewId) return;
+
+    try {
+      const res = await fetch(
+        `${dashboardBaseUrl}/api/interviews/${interviewId}/chat?token=${authToken}`,
+        { signal: AbortSignal.timeout(3000) },
+      );
+      const body = (await res.json()) as {
+        message?: string | null;
+      };
+      if (res.ok && body.message) {
+        log('[interview] delivering chat message (HTTP poll)', {
+          interviewId,
+        });
+        await service.submitChat(interviewId, body.message);
+      }
+    } catch (err) {
+      log('[interview] failed polling chat message:', {
+        error: err instanceof Error ? err.message : String(err),
+      });
+    }
+  }
+
   return {
     registerCommand: (c) => service.registerCommand(c),
     handleCommandExecuteBefore: async (input, output) => {
@@ -456,6 +487,7 @@ function stateToEntry(
     filePath: state.interview.markdownPath,
     nudgeAction: null,
     pendingBlockComment: null,
+    pendingChatMessage: null,
   };
 }
 
@@ -498,6 +530,7 @@ async function registerInterviewViaHttp(
       filePath: interview.markdownPath,
       nudgeAction: null,
       pendingBlockComment: null,
+      pendingChatMessage: null,
     }),
     signal: AbortSignal.timeout(3000),
   }).catch((err) => {

+ 33 - 0
src/interview/server.ts

@@ -83,6 +83,7 @@ export function createInterviewServer(deps: {
     section: string,
     comment: string,
   ) => Promise<void>;
+  submitChat: (interviewId: string, message: string) => Promise<void>;
   handleNudgeAction: (
     interviewId: string,
     action: 'more-questions' | 'confirm-complete',
@@ -238,6 +239,38 @@ export function createInterviewServer(deps: {
       return;
     }
 
+    // ── Chat: freeform message to agent ─────────────────────────────
+    const chatMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/chat$/);
+    if (request.method === 'POST' && chatMatch) {
+      try {
+        const body = (await readJsonBody(request)) as {
+          message?: string;
+        };
+        if (typeof body.message !== 'string' || !body.message.trim()) {
+          sendJson(response, 400, {
+            error: 'message must be a non-empty string',
+          });
+          return;
+        }
+        await deps.submitChat(
+          decodeURIComponent(chatMatch[1]),
+          body.message.trim(),
+        );
+        sendJson(response, 200, {
+          ok: true,
+          message: 'Chat message forwarded to agent.',
+        });
+      } catch (error) {
+        const message =
+          error instanceof Error
+            ? error.message
+            : 'Failed to submit chat message.';
+        const status = getSubmissionStatus(error);
+        sendJson(response, status, { ok: false, message });
+      }
+      return;
+    }
+
     // Nudge: ask more questions or confirm complete
     const nudgeMatch = pathname.match(/^\/api\/interviews\/([^/]+)\/nudge$/);
     if (request.method === 'POST' && nudgeMatch) {

+ 64 - 0
src/interview/service.ts

@@ -144,6 +144,7 @@ export function createInterviewService(
     section: string,
     comment: string,
   ) => Promise<void>;
+  submitChat: (interviewId: string, message: string) => Promise<void>;
   handleNudgeAction: (
     interviewId: string,
     action: 'more-questions' | 'confirm-complete',
@@ -802,6 +803,68 @@ export function createInterviewService(
     }
   }
 
+  async function submitChat(
+    interviewId: string,
+    message: string,
+  ): Promise<void> {
+    const interview = getInterviewById(interviewId);
+    if (!interview) {
+      throw new Error('Interview not found');
+    }
+    if (interview.status === 'abandoned') {
+      throw new Error('Interview session is no longer active.');
+    }
+    if (sessionBusy.get(interview.sessionID) === true) {
+      throw new Error(
+        'Interview session is busy. Wait for the current response.',
+      );
+    }
+
+    sessionBusy.set(interview.sessionID, true);
+    let promptSent = false;
+
+    try {
+      const state = await getInterviewState(interviewId);
+      if (state.mode === 'error') {
+        throw new Error('Interview is waiting for a valid agent update.');
+      }
+
+      const relativePath = relativeInterviewPath(
+        ctx.directory,
+        interview.markdownPath,
+      );
+
+      const prompt = [
+        `You are continuing the interview for the specification document at "${relativePath}".`,
+        `The current document content on disk is:`,
+        `\`\`\`markdown`,
+        state.document,
+        `\`\`\``,
+        ``,
+        `The user sent a freeform message via the dashboard chat panel:`,
+        `${message}`,
+        ``,
+        `Process this request — it may be a request to add a new section, revise existing content, ask clarifying questions, or make structural changes.`,
+        `Update the specification document accordingly and include the updated 11-section specification.`,
+        `Ask up to ${maxQuestions} clarifying questions if needed using the same <interview_state> JSON block format as before.`,
+      ].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;
+    } finally {
+      if (!promptSent) {
+        sessionBusy.set(interview.sessionID, false);
+      }
+    }
+  }
+
   async function handleNudgeAction(
     interviewId: string,
     action: 'more-questions' | 'confirm-complete',
@@ -889,6 +952,7 @@ export function createInterviewService(
     listInterviews,
     submitAnswers,
     submitBlockComment,
+    submitChat,
     handleNudgeAction,
   };
 }

+ 1 - 0
src/interview/types.ts

@@ -132,4 +132,5 @@ export interface InterviewStateEntry {
     section: string;
     comment: string;
   } | null;
+  pendingChatMessage: string | null;
 }

+ 197 - 0
src/interview/ui.ts

@@ -939,9 +939,102 @@ export function renderInterviewPage(
         background: rgba(52,211,153,0.05);
       }
       .nudge-btn:disabled { opacity: 0.3; cursor: not-allowed; }
+
+      /* ── TOC Sidebar ──────────────────────────────────────────────── */
+      .toc-sidebar {
+        position: fixed;
+        top: 0; left: 0; bottom: 0;
+        width: 240px;
+        background: rgba(255,255,255,0.02);
+        border-right: 1px solid rgba(255,255,255,0.06);
+        padding: 24px 0 80px;
+        overflow-y: auto;
+        z-index: 50;
+        display: none;
+      }
+      .toc-sidebar.visible { display: block; }
+      .toc-header {
+        font-size: 11px;
+        font-weight: 700;
+        letter-spacing: 0.12em;
+        text-transform: uppercase;
+        color: rgba(255,255,255,0.28);
+        padding: 0 20px 14px;
+        border-bottom: 1px solid rgba(255,255,255,0.05);
+        margin-bottom: 8px;
+      }
+      .toc-item {
+        display: block;
+        padding: 7px 20px;
+        font-size: 13px;
+        color: rgba(255,255,255,0.5);
+        text-decoration: none;
+        cursor: pointer;
+        transition: all 0.15s ease;
+        border-left: 2px solid transparent;
+        line-height: 1.4;
+      }
+      .toc-item:hover {
+        color: rgba(255,255,255,0.85);
+        background: rgba(255,255,255,0.03);
+      }
+      .toc-item.active {
+        color: #ffffff;
+        border-left-color: #ffffff;
+        background: rgba(255,255,255,0.04);
+      }
+      body.toc-visible .wrap {
+        margin-left: 240px;
+      }
+
+      /* ── Chat Panel ───────────────────────────────────────────────── */
+      .chat-panel {
+        position: fixed;
+        bottom: 0; left: 0; right: 0;
+        background: rgba(0,0,0,0.92);
+        backdrop-filter: blur(12px);
+        border-top: 1px solid rgba(255,255,255,0.08);
+        padding: 12px 20px;
+        z-index: 60;
+        display: none;
+        gap: 10px;
+        align-items: center;
+      }
+      .chat-panel.visible { display: flex; }
+      body.chat-visible .wrap { padding-bottom: 72px; }
+      body.toc-visible .chat-panel { left: 240px; }
+      .chat-input {
+        flex: 1;
+        background: rgba(255,255,255,0.06);
+        border: 1px solid rgba(255,255,255,0.1);
+        border-radius: 8px;
+        color: #ffffff;
+        font-family: inherit;
+        font-size: 14px;
+        padding: 10px 14px;
+        outline: none;
+        transition: border-color 0.2s ease;
+      }
+      .chat-input:focus { border-color: rgba(255,255,255,0.35); }
+      .chat-input::placeholder { color: rgba(255,255,255,0.3); }
+      .chat-send {
+        flex-shrink: 0;
+        background: #ffffff;
+        color: #000000;
+        border: 0;
+        border-radius: 8px;
+        padding: 10px 18px;
+        font-size: 14px;
+        font-weight: 600;
+        cursor: pointer;
+        transition: opacity 0.2s ease;
+      }
+      .chat-send:hover:not(:disabled) { opacity: 0.85; }
+      .chat-send:disabled { opacity: 0.3; cursor: not-allowed; }
     </style>
   </head>
   <body>
+    <nav id="tocSidebar" class="toc-sidebar"></nav>
     <div class="wrap">
       <a href="/" class="back-link">← All Interviews</a>
       <div class="brand-header">
@@ -982,6 +1075,11 @@ export function renderInterviewPage(
       <div class="status-text" id="loadingText">Processing...</div>
     </div>
 
+    <div class="chat-panel" id="chatPanel">
+      <input type="text" id="chatInput" class="chat-input" placeholder="Send a message to the agent — add a section, revise content, ask questions..." autocomplete="off" />
+      <button class="chat-send" id="chatSendBtn" type="button" disabled>Send</button>
+    </div>
+
     <script>
       ${clipboardHelperJs()}
       const interviewId = ${JSON.stringify(interviewId).replace(/</g, '\\u003c')};
@@ -1496,6 +1594,103 @@ export function renderInterviewPage(
         container.replaceChildren(frag);
       }
 
+      // ── TOC Sidebar ──────────────────────────────────────────────
+      function updateTocSidebar(data) {
+        const sidebar = document.getElementById('tocSidebar');
+        const blocks = data.blocks || [];
+        const isDone = ['completed', 'session-disconnected'].includes(data.mode);
+        if (!isDone || !blocks.length) {
+          sidebar.classList.remove('visible');
+          document.body.classList.remove('toc-visible');
+          return;
+        }
+        sidebar.classList.add('visible');
+        document.body.classList.add('toc-visible');
+        sidebar.innerHTML = '';
+        const header = document.createElement('div');
+        header.className = 'toc-header';
+        header.textContent = 'Sections';
+        sidebar.appendChild(header);
+        blocks.forEach((block) => {
+          const item = document.createElement('a');
+          item.className = 'toc-item';
+          item.textContent = block.title;
+          item.addEventListener('click', () => {
+            const cards = document.querySelectorAll('.spec-block-card h3');
+            for (const h3 of cards) {
+              if (h3.textContent === block.title) {
+                h3.closest('.spec-block-card').scrollIntoView({ behavior: 'smooth', block: 'start' });
+                break;
+              }
+            }
+            document.querySelectorAll('.toc-item').forEach((el) => el.classList.remove('active'));
+            item.classList.add('active');
+          });
+          sidebar.appendChild(item);
+        });
+      }
+
+      // ── Chat Panel ───────────────────────────────────────────────
+      function updateChatPanel(data) {
+        const panel = document.getElementById('chatPanel');
+        const input = document.getElementById('chatInput');
+        const sendBtn = document.getElementById('chatSendBtn');
+        const isDone = ['completed', 'session-disconnected'].includes(data.mode);
+        if (!isDone) {
+          panel.classList.remove('visible');
+          document.body.classList.remove('chat-visible');
+          return;
+        }
+        panel.classList.add('visible');
+        document.body.classList.add('chat-visible');
+        const busy = data.isBusy || false;
+        sendBtn.disabled = busy;
+        input.disabled = busy;
+        if (busy) {
+          input.placeholder = 'Agent is processing...';
+        } else {
+          input.placeholder = 'Send a message to the agent — add a section, revise content, ask questions...';
+        }
+      }
+
+      async function sendChatMessage() {
+        const input = document.getElementById('chatInput');
+        const sendBtn = document.getElementById('chatSendBtn');
+        const message = input.value.trim();
+        if (!message) return;
+        sendBtn.disabled = true;
+        input.value = '';
+        const submitStatus = document.getElementById('submitStatus');
+        submitStatus.textContent = '';
+        try {
+          const res = await fetch('/api/interviews/' + encodeURIComponent(interviewId) + '/chat', {
+            method: 'POST',
+            headers: { 'content-type': 'application/json' },
+            body: JSON.stringify({ message }),
+          });
+          const payload = await res.json();
+          if (res.ok) {
+            submitStatus.textContent = 'Chat message sent to agent.';
+            refresh().catch(() => {});
+            schedulePoll();
+          } else {
+            submitStatus.textContent = payload.message || payload.error || 'Failed to send chat message.';
+            sendBtn.disabled = false;
+          }
+        } catch (_err) {
+          submitStatus.textContent = 'Network error sending chat message.';
+          sendBtn.disabled = false;
+        }
+      }
+
+      document.getElementById('chatSendBtn').addEventListener('click', sendChatMessage);
+      document.getElementById('chatInput').addEventListener('keydown', (e) => {
+        if (e.key === 'Enter' && !e.shiftKey) {
+          e.preventDefault();
+          sendChatMessage();
+        }
+      });
+
       function renderQuestions(questions) {
         const sig = JSON.stringify([questions, state.data?.mode]);
         const container = document.getElementById('questions');
@@ -1652,6 +1847,8 @@ export function renderInterviewPage(
         
         renderQuestions(data.questions || []);
         updateSubmitButton();
+        updateTocSidebar(data);
+        updateChatPanel(data);
       }
 
       async function refresh() {