Skip to content

Conversation

@julianbenegas
Copy link
Member

  • Add answer jsonb column to posts table with migration
  • Create answer-agent.ts with SetAnswer/SetNoAnswer tools using Haiku 4.5
  • Trigger answer agent on post creation and new comments
  • Skip re-running if post marked as "not-a-question"
  • Add PostAnswerBox UI component shown after root comment
  • Pass answer through CommentThreadClient to CommentThread

- Add answer jsonb column to posts table with migration
- Create answer-agent.ts with SetAnswer/SetNoAnswer tools using Haiku 4.5
- Trigger answer agent on post creation and new comments
- Skip re-running if post marked as "not-a-question"
- Add PostAnswerBox UI component shown after root comment
- Pass answer through CommentThreadClient to CommentThread
@vercel
Copy link
Contributor

vercel bot commented Jan 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
forums Error Error Jan 13, 2026 3:36pm

Answer
</span>
</div>
<div className="prose prose-sm prose-invert max-w-none text-foreground">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The answer.text is rendered as plain text without markdown parsing, causing markdown formatting like bold, lists, and links to appear as literal text in the UI

View Details
📝 Patch Details
diff --git a/app/[owner]/[repo]/[postNumber]/post-answer.tsx b/app/[owner]/[repo]/[postNumber]/post-answer.tsx
index 94d8374..5ca106b 100644
--- a/app/[owner]/[repo]/[postNumber]/post-answer.tsx
+++ b/app/[owner]/[repo]/[postNumber]/post-answer.tsx
@@ -1,6 +1,55 @@
+"use client"
+
+import type { ComponentProps } from "react"
+import { Streamdown } from "streamdown"
 import type { PostAnswer } from "@/agent/types"
 import { cn } from "@/lib/utils"
 
+const streamdownComponents: ComponentProps<typeof Streamdown>["components"] = {
+  h1: (props) => <h1 className="mt-6 mb-2 font-semibold text-lg first:mt-0" {...props} />,
+  h2: (props) => <h2 className="mt-6 mb-2 font-semibold text-base first:mt-0" {...props} />,
+  h3: (props) => <h3 className="mt-4 mb-2 font-semibold first:mt-0" {...props} />,
+  h4: (props) => <h4 className="mt-4 mb-2 font-semibold first:mt-0" {...props} />,
+  h5: (props) => <h5 className="mt-4 mb-2 font-semibold first:mt-0" {...props} />,
+  h6: (props) => <h6 className="mt-4 mb-2 font-semibold first:mt-0" {...props} />,
+  p: (props) => <p className="my-2 leading-relaxed first:mt-0 last:mb-0" {...props} />,
+  a: (props) => (
+    <a
+      className="text-blue-400 underline-offset-2 hover:underline"
+      rel="noopener noreferrer"
+      target="_blank"
+      {...props}
+    />
+  ),
+  strong: (props) => <strong className="font-semibold" {...props} />,
+  em: (props) => <em className="italic" {...props} />,
+  ul: (props) => <ul className="my-2 list-disc space-y-1 pl-4" {...props} />,
+  ol: (props) => <ol className="my-2 list-decimal space-y-1 pl-6" {...props} />,
+  li: (props) => <li {...props} />,
+  blockquote: (props) => (
+    <blockquote className="my-2 border-l-2 border-green-400/30 pl-3 italic opacity-75" {...props} />
+  ),
+  hr: () => <hr className="my-2 border-green-400/20" />,
+  code: (props) => (
+    <code
+      className="break-all bg-green-950/40 px-1 py-0.5 font-mono text-sm"
+      {...props}
+    />
+  ),
+  pre: (props) => {
+    // biome-ignore lint/suspicious/noExplicitAny: .
+    const childProps = (props.children as any).props as {
+      className: string
+      children: string
+    }
+    return (
+      <pre className="my-2 overflow-x-auto bg-green-950/40 p-2 text-sm" {...props}>
+        <code>{childProps.children}</code>
+      </pre>
+    )
+  },
+}
+
 export function PostAnswerBox({ answer }: { answer: PostAnswer }) {
   if (answer.type !== "answer") {
     return null
@@ -18,8 +67,14 @@ export function PostAnswerBox({ answer }: { answer: PostAnswer }) {
           Answer
         </span>
       </div>
-      <div className="prose prose-sm prose-invert max-w-none text-foreground">
-        {answer.text}
+      <div className="text-foreground">
+        <Streamdown
+          components={streamdownComponents}
+          mode="static"
+          shikiTheme={["github-light", "github-dark"]}
+        >
+          {answer.text}
+        </Streamdown>
       </div>
     </div>
   )

Analysis

Why This Bug Exists

The PostAnswerBox component in post-answer.tsx was rendering answer.text directly into a plain div without any markdown parsing:

<div className="prose prose-sm prose-invert max-w-none text-foreground">
  {answer.text}
</div>

The answer agent (answer-agent.ts) is designed to generate markdown-formatted text - the system prompt instructs it to provide "concise summary" and "summarizing the key points". Agents typically generate markdown naturally with formatting like bold, lists, links, and code blocks.

However, when markdown text is rendered as plain JSX children in a div, it appears as literal text. For example, **bold text** will display as **bold text** instead of rendered bold text.

Meanwhile, elsewhere in the codebase (comment-content.tsx), markdown content is properly parsed using the Streamdown component with custom styled components, demonstrating this is the established pattern in the application.

The Fix

The fix implements markdown parsing for the answer box by:

  1. Added "use client" directive - Required because Streamdown is a client-side component
  2. Imported Streamdown and created custom components - Following the same pattern as comment-content.tsx, created a streamdownComponents object that styles markdown elements (headings, lists, code blocks, etc.) to match the green-themed answer box aesthetic
  3. Replaced plain text rendering with Streamdown - Updated the render to:
    <Streamdown
      components={streamdownComponents}
      mode="static"
      shikiTheme={["github-light", "github-dark"]}
    >
      {answer.text}
    </Streamdown>

The styling is tailored for the answer box with green accents for consistency with the visual design, but ensures all markdown formatting (bold, lists, links, code blocks, blockquotes, etc.) is properly parsed and displayed.

</CommentItem>
{showAnswer && (
<div className="mt-8">
<PostAnswerBox answer={answer} />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostAnswerBox component type signature requires non-null PostAnswer but receives potentially null answer value

View Details
📝 Patch Details
diff --git a/app/[owner]/[repo]/[postNumber]/post-answer.tsx b/app/[owner]/[repo]/[postNumber]/post-answer.tsx
index 94d8374..05eb933 100644
--- a/app/[owner]/[repo]/[postNumber]/post-answer.tsx
+++ b/app/[owner]/[repo]/[postNumber]/post-answer.tsx
@@ -1,8 +1,8 @@
 import type { PostAnswer } from "@/agent/types"
 import { cn } from "@/lib/utils"
 
-export function PostAnswerBox({ answer }: { answer: PostAnswer }) {
-  if (answer.type !== "answer") {
+export function PostAnswerBox({ answer }: { answer: PostAnswer | null }) {
+  if (!answer || answer.type !== "answer") {
     return null
   }
 

Analysis

Bug Explanation

At line 337 in comment-thread.tsx, PostAnswerBox is called with {answer} where answer is typed as PostAnswer | null | undefined (from line 236: answer?: PostAnswer | null). However, the component's type signature originally declared { answer: PostAnswer }, which does not accept null values.

The guard condition at line 287 is const showAnswer = isRootComment && answer?.type === "answer". While this logically ensures that when showAnswer is true, answer must be valid with type "answer", TypeScript's type narrowing cannot infer this because:

  1. The condition result is stored in a boolean variable (showAnswer)
  2. When checking if (showAnswer) at line 334, TypeScript doesn't understand the original condition
  3. Therefore, answer remains typed as PostAnswer | null | undefined rather than being narrowed to PostAnswer

This is a type safety violation where the caller passes a potentially-null value to a component that claims to require a non-null value.

Fix Explanation

I updated the PostAnswerBox component signature from:

export function PostAnswerBox({ answer }: { answer: PostAnswer }) {
  if (answer.type !== "answer") {
    return null
  }

To:

export function PostAnswerBox({ answer }: { answer: PostAnswer | null }) {
  if (!answer || answer.type !== "answer") {
    return null
  }

This fix:

  1. Accepts the reality: The component is called with potentially null values due to TypeScript's type narrowing limitations
  2. Maintains existing logic: The component already had defensive checks; now the type signature reflects this
  3. Preserves correctness: The guard at line 287 ensures we only render when answer is valid, and the component handles unexpected cases gracefully
  4. Resolves type mismatch: No type error when calling PostAnswerBox answer={answer}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants