From de994bb8378eeed4e95cf6f7c1f0b776ba26c011 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 26 Feb 2026 15:24:21 -0500 Subject: [PATCH] feat(dashboard): add markdown preview for AskUserQuestion options Implement side-by-side preview layout when AskUserQuestion options include markdown content, matching the Claude Code tool spec. Hook changes (bin/amc-hook): - Extract optional 'markdown' field from question options - Only include when present (maintains backward compatibility) QuestionBlock layout: - Detect hasMarkdownPreviews via options.some(opt => opt.markdown) - Standard layout: vertical option list (unchanged) - Preview layout: 40% options left, 60% preview pane right - Fixed 400px preview height prevents layout thrashing on hover - Track previewIndex state, update on mouseEnter/focus Content rendering (smart detection): - Code fences (starts with ```): renderContent() for syntax highlighting - Everything else: raw
 to preserve ASCII diagrams exactly
- "No preview for this option" when hovered option lacks markdown

OptionButton enhancements:
- Accept selected, onMouseEnter, onFocus props
- Visual selected state: brighter border/bg with subtle shadow
- Compact padding (py-2 vs py-3.5) for preview layout density
- Graceful undefined handling for standard layout usage

Bug fixes during development:
- Layout thrashing: Fixed height (not max-height) on preview pane
- ASCII corruption: Detect code fences vs plain text for render path
- Horizontal overflow: Use overflow-auto (not just overflow-y-auto)
- Data pipeline: Hook was stripping markdown field (root cause)

Co-Authored-By: Claude Opus 4.5 
---
 bin/amc-hook                          |   8 +-
 dashboard/components/OptionButton.js  |  18 +++--
 dashboard/components/QuestionBlock.js | 112 +++++++++++++++++++++++++-
 3 files changed, 127 insertions(+), 11 deletions(-)

diff --git a/bin/amc-hook b/bin/amc-hook
index 4405b24..cba8939 100755
--- a/bin/amc-hook
+++ b/bin/amc-hook
@@ -82,10 +82,14 @@ def _extract_questions(hook):
             "options": [],
         }
         for opt in q.get("options", []):
-            entry["options"].append({
+            opt_entry = {
                 "label": opt.get("label", ""),
                 "description": opt.get("description", ""),
-            })
+            }
+            # Include markdown preview if present
+            if opt.get("markdown"):
+                opt_entry["markdown"] = opt.get("markdown")
+            entry["options"].append(opt_entry)
         result.append(entry)
     return result
 
diff --git a/dashboard/components/OptionButton.js b/dashboard/components/OptionButton.js
index 190668a..b3018e4 100644
--- a/dashboard/components/OptionButton.js
+++ b/dashboard/components/OptionButton.js
@@ -1,17 +1,23 @@
 import { html } from '../lib/preact.js';
 
-export function OptionButton({ number, label, description, onClick }) {
+export function OptionButton({ number, label, description, selected, onClick, onMouseEnter, onFocus }) {
+  const selectedStyles = selected 
+    ? 'border-starting/60 bg-starting/15 shadow-sm' 
+    : 'border-selection/70 bg-surface2/55';
+  
   return html`
     
   `;
diff --git a/dashboard/components/QuestionBlock.js b/dashboard/components/QuestionBlock.js
index bf2c977..70df708 100644
--- a/dashboard/components/QuestionBlock.js
+++ b/dashboard/components/QuestionBlock.js
@@ -1,12 +1,15 @@
-import { html, useState } from '../lib/preact.js';
+import { html, useState, useRef } from '../lib/preact.js';
 import { getStatusMeta } from '../utils/status.js';
 import { OptionButton } from './OptionButton.js';
+import { renderContent } from '../lib/markdown.js';
 
 export function QuestionBlock({ questions, sessionId, status, onRespond }) {
   const [freeformText, setFreeformText] = useState('');
   const [focused, setFocused] = useState(false);
   const [sending, setSending] = useState(false);
   const [error, setError] = useState(null);
+  const [previewIndex, setPreviewIndex] = useState(0);
+  const textareaRef = useRef(null);
   const meta = getStatusMeta(status);
 
   if (!questions || questions.length === 0) return null;
@@ -15,6 +18,9 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
   const question = questions[0];
   const remainingCount = questions.length - 1;
   const options = question.options || [];
+  
+  // Check if any option has markdown preview content
+  const hasMarkdownPreviews = options.some(opt => opt.markdown);
 
   const handleOptionClick = async (optionLabel) => {
     if (sending) return;
@@ -44,12 +50,111 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
         console.error('QuestionBlock freeform error:', err);
       } finally {
         setSending(false);
+        // Refocus the textarea after submission
+        // Use setTimeout to ensure React has re-rendered with disabled=false
+        setTimeout(() => {
+          textareaRef.current?.focus();
+        }, 0);
       }
     }
   };
 
+  // Side-by-side layout when options have markdown previews
+  if (hasMarkdownPreviews) {
+    const currentMarkdown = options[previewIndex]?.markdown || '';
+    
+    return html`
+      
e.stopPropagation()}> + ${error && html` +
+ ${error} +
+ `} + + + ${question.header && html` + + ${question.header} + + `} + + +

${question.question || question.text}

+ + +
+ +
+ ${options.map((opt, i) => html` + <${OptionButton} + key=${i} + number=${i + 1} + label=${opt.label || opt} + description=${opt.description} + selected=${previewIndex === i} + onMouseEnter=${() => setPreviewIndex(i)} + onFocus=${() => setPreviewIndex(i)} + onClick=${() => handleOptionClick(opt.label || opt)} + /> + `)} +
+ + +
+ ${currentMarkdown + ? (currentMarkdown.trimStart().startsWith('```') + ? renderContent(currentMarkdown) + : html`
${currentMarkdown}
`) + : html`

No preview for this option

` + } +
+
+ + +
+