From 035d5024be3bbd1092cdaf670b88c582acccba42 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 26 Jul 2026 23:13:40 +0000 Subject: [PATCH] taOStalk s1: content_blocks types + renderContent dispatcher Add ContentBlock union (text, thinking, tool_call, status, unknown) and content_blocks field to Message/MessageRow interfaces. Add a dispatcher in renderContent() that switches on block.kind when content_blocks is non-empty, falling through to the markdown path otherwise. Ship the unknown-kind fallback (dim unsupported-block line) as the slice-2 seam; known kind cases are stubs for separate cards. --- desktop/src/apps/MessagesApp.tsx | 66 ++++++++++++++++++- desktop/src/apps/chat/MessageList.tsx | 4 +- .../chat/__tests__/render-helpers.test.tsx | 41 ++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/desktop/src/apps/MessagesApp.tsx b/desktop/src/apps/MessagesApp.tsx index 4ef3db28f..9bc61e8a6 100644 --- a/desktop/src/apps/MessagesApp.tsx +++ b/desktop/src/apps/MessagesApp.tsx @@ -158,6 +158,44 @@ export function resolveAuthorDisplayState( return "removed"; } +interface TextContentBlock { + kind: "text"; + text: string; +} + +interface ThinkingContentBlock { + kind: "thinking"; + text: string; + collapsed?: boolean; +} + +interface ToolCallContentBlock { + kind: "tool_call"; + call_id: string; + name: string; + input_preview?: string; + status: "running" | "done" | "error"; + result_preview?: string; +} + +interface StatusContentBlock { + kind: "status"; + text: string; +} + +/** + * Structured message content for taOStalk session turns. + * Known kinds are handled by dedicated block components (separate cards); + * any unrecognized kind falls through to the unknown-block fallback in + * renderContent, which is the slice-2 seam. + */ +export type ContentBlock = + | TextContentBlock + | ThinkingContentBlock + | ToolCallContentBlock + | StatusContentBlock + | { kind: string; [key: string]: unknown }; + interface Message { id: string; channel_id: string; @@ -167,6 +205,7 @@ interface Message { /** Parent message id when this message is a thread reply. */ thread_id?: string; content_type?: "text" | "canvas" | string; + content_blocks?: ContentBlock[]; metadata?: { canvas_id?: string; canvas_url?: string; @@ -217,7 +256,32 @@ export function relativeTime(ts: number | string, nowMs: number = Date.now()): s return new Date(ms).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); } -export function renderContent(text: string) { +/** + * Dispatch a single content block to its renderer. Known kinds (text, + * thinking, tool_call, status) are dispatched to dedicated block components + * in separate cards; until those land, they fall through to the unknown + * fallback. This is the slice-2 seam: add a case per kind and return the + * block component. + */ +function renderContentBlock(block: ContentBlock, index: number): React.ReactElement { + switch (block.kind) { + case "text": + case "thinking": + case "tool_call": + case "status": + default: + return ( +
+ unsupported block: {block.kind} +
+ ); + } +} + +export function renderContent(text: string, content_blocks?: ContentBlock[]) { + if (content_blocks && content_blocks.length > 0) { + return content_blocks.map((block, i) => renderContentBlock(block, i)); + } // Split on fenced code blocks first, then apply inline markdown to non-code segments. const result: (string | React.ReactElement)[] = []; const fenceRegex = /```(?:[^\n]*)?\n([\s\S]*?)```/g; diff --git a/desktop/src/apps/chat/MessageList.tsx b/desktop/src/apps/chat/MessageList.tsx index e800b1772..4684b27aa 100644 --- a/desktop/src/apps/chat/MessageList.tsx +++ b/desktop/src/apps/chat/MessageList.tsx @@ -27,6 +27,7 @@ import { ReactionBar } from "./ReactionBar"; import { resolveAgentEmoji } from "@/lib/agent-emoji"; import { startDrag, endDrag } from "@/shell/dnd/dnd-bus"; import { renderContent, dayLabel, relativeTime, toMs, resolveAuthorDisplayState } from "../MessagesApp"; +import type { ContentBlock } from "../MessagesApp"; import type { AttachmentRecord } from "@/lib/chat-attachments-api"; import { displayAuthor } from "./format-author"; import type { LiveAgent, ArchivedAgentEntry, Channel } from "./types"; @@ -41,6 +42,7 @@ export interface MessageRow { content: string; thread_id?: string; content_type?: "text" | "canvas" | string; + content_blocks?: ContentBlock[]; metadata?: { canvas_id?: string; canvas_url?: string; @@ -538,7 +540,7 @@ export const MessageList = forwardRef(funct : "text-shell-text" }`} > - {renderContent(msg.content)} + {renderContent(msg.content, msg.content_blocks)} {msg.state === "pending" && ( ... diff --git a/desktop/src/apps/chat/__tests__/render-helpers.test.tsx b/desktop/src/apps/chat/__tests__/render-helpers.test.tsx index 5fe9d1b75..c284866e1 100644 --- a/desktop/src/apps/chat/__tests__/render-helpers.test.tsx +++ b/desktop/src/apps/chat/__tests__/render-helpers.test.tsx @@ -57,6 +57,47 @@ describe("renderContent", () => { expect(a?.getAttribute("href")).toBe("https://example.com"); expect(a?.getAttribute("target")).toBe("_blank"); }); + + it("dispatches to content_blocks when non-empty", () => { + const { container } = render(
{renderContent("", [{ kind: "text", text: "hello" }])}
); + expect(container.textContent).toContain("unsupported block: text"); + }); + + it("falls through to markdown when content_blocks is empty", () => { + const { container } = render(
{renderContent("hello world", [])}
); + expect(container.textContent).toContain("hello world"); + }); + + it("renders unknown fallback for unrecognized block kinds", () => { + const { container } = render(
{renderContent("", [{ kind: "question", text: "what?" }])}
); + expect(container.textContent).toContain("unsupported block: question"); + }); + + it("renders unknown fallback for all known kinds (separate cards)", () => { + const { container } = render( +
{renderContent("", [ + { kind: "text", text: "hi" }, + { kind: "thinking", text: "thinking...", collapsed: true }, + { kind: "tool_call", call_id: "c1", name: "Bash", status: "running" as const }, + { kind: "status", text: "done" }, + ])}
, + ); + expect(container.textContent).toContain("unsupported block: text"); + expect(container.textContent).toContain("unsupported block: thinking"); + expect(container.textContent).toContain("unsupported block: tool_call"); + expect(container.textContent).toContain("unsupported block: status"); + }); + + it("renders one fallback line per block", () => { + const { container } = render( +
{renderContent("", [ + { kind: "text", text: "a" }, + { kind: "status", text: "b" }, + ])}
, + ); + const text = container.textContent || ""; + expect(text.match(/unsupported block/g)?.length).toBe(2); + }); }); describe("dayLabel", () => {