fix(ui): render user message text as markdown

User text parts now use the same Markdown renderer + cache path as assistant messages, while keeping role-specific heading and accent colors.
This commit is contained in:
Shantur Rathore
2026-02-17 22:44:30 +00:00
parent 6de6ef5a4a
commit c8ff858565
3 changed files with 48 additions and 28 deletions

View File

@@ -3,7 +3,6 @@ import ToolCall from "./tool-call"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme"
import { useConfig } from "../stores/preferences"
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -17,16 +16,18 @@ interface MessagePartProps {
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
primaryUserTextPartId?: string | null
onRendered?: () => void
}
export default function MessagePart(props: MessagePartProps) {
}
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme()
const { preferences } = useConfig()
const partType = () => props.part?.type || ""
const reasoningId = () => `reasoning-${props.part?.id || ""}`
const isReasoningExpanded = () => isItemExpanded(reasoningId())
const isAssistantMessage = () => props.messageType === "assistant"
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
const markdownContainerClass = () => "message-text message-text-assistant"
const textContainerRole = () => props.messageType || "assistant"
const shouldHideTextPart = () => {
const part = props.part
@@ -57,6 +58,11 @@ interface MessagePartProps {
return ""
}
const canRenderMarkdown = () => {
const id = (props.part as unknown as { id?: unknown })?.id
return typeof id === "string" && id.length > 0
}
function reasoningSegmentHasText(segment: unknown): boolean {
if (typeof segment === "string") {
return segment.trim().length > 0
@@ -91,20 +97,28 @@ interface MessagePartProps {
const createTextPartForMarkdown = (): TextPart => {
const part = props.part
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
if (part.type === "text" && typeof part.text === "string") {
// Pass through the original part so `renderCache` updates persist.
return part as unknown as TextPart
}
if (part.type === "reasoning" && typeof (part as any).text === "string") {
// Reasoning parts render as markdown in some views; normalize to TextPart.
return {
id: part.id,
type: "text",
text: part.text,
synthetic: part.type === "text" ? part.synthetic : false,
version: (part as { version?: number }).version
text: (part as any).text,
synthetic: false,
version: (part as { version?: number }).version,
renderCache: (part as any).renderCache,
}
}
return {
id: part.id,
type: "text",
type: "text",
text: "",
synthetic: false
synthetic: false,
}
}
@@ -117,22 +131,18 @@ interface MessagePartProps {
<Switch>
<Match when={partType() === "text"}>
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div class={textContainerClass()}>
<Show
when={isAssistantMessage()}
fallback={<span class="text-primary">{plainTextContent()}</span>}
>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
</div>
<div class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} data-role={textContainerRole()}>
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
</div>
</Show>
</Match>

View File

@@ -9,6 +9,9 @@
line-height: var(--line-height-normal);
font-weight: var(--font-weight-regular);
color: var(--text-primary);
/* Message containers may use `whitespace-pre-wrap` for plain text.
Markdown should always match assistant rendering (normal whitespace). */
white-space: normal;
}
.markdown-body p,
@@ -28,7 +31,7 @@
.markdown-body h5,
.markdown-body h6 {
font-family: inherit;
color: inherit;
color: var(--markdown-heading-color, inherit);
font-weight: var(--font-weight-semibold);
line-height: 1.3;
margin-top: 0.9em;
@@ -71,7 +74,7 @@
.markdown-body strong {
font-weight: var(--font-weight-regular);
color: var(--message-assistant-border);
color: var(--markdown-accent, var(--message-assistant-border));
}
.markdown-body em {

View File

@@ -1,6 +1,10 @@
/* Message item base styles */
.message-item-base {
@apply flex flex-col gap-2 p-3 w-full;
/* Markdown rendering uses these to theme emphasis + headings per message role. */
--markdown-accent: var(--message-user-border);
--markdown-heading-color: var(--message-user-border);
}
.message-item-header {
@@ -71,6 +75,9 @@
padding: 0.6rem 0.65rem;
margin-top: 0;
margin-bottom: 0;
--markdown-accent: var(--message-assistant-border);
--markdown-heading-color: var(--text-primary);
}
.message-item-base:not(.assistant-message) {