Skip to content

Commit 76d82ee

Browse files
committed
feat(messages): auto-convert review findings text into markdown tables
1 parent 5ae61b5 commit 76d82ee

File tree

5 files changed

+301
-2
lines changed

5 files changed

+301
-2
lines changed

src/features/messages/components/Markdown.test.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,4 +518,43 @@ describe("Markdown file-like href behavior", () => {
518518
expect(onOpenFileLink).not.toHaveBeenCalled();
519519
});
520520

521+
it("converts blank-line-separated structured review findings into one markdown table", () => {
522+
const { container } = render(
523+
<Markdown
524+
value={[
525+
"src/features/app/hooks/useMainAppLayoutSurfaces.ts | category=clarity | Layout assembly is still too broad. | Split surface assembly by domain. | high",
526+
"",
527+
"src/features/app/components/SidebarWorkspaceGroups.tsx | category=clarity | Workspace derivation still lives in the render component. | Move derivation into a focused hook. | high",
528+
"",
529+
"src/features/threads/hooks/threadMessagingHelpers.ts | category=clarity | Helper responsibilities are too broad. | Split helpers by concern. | medium",
530+
].join("\n")}
531+
className="markdown"
532+
/>,
533+
);
534+
535+
expect(container.querySelector(".markdown-table-wrap")).toBeTruthy();
536+
expect(container.querySelector(".markdown-table")).toBeTruthy();
537+
expect(screen.getByRole("columnheader", { name: "File" })).toBeTruthy();
538+
expect(screen.getByRole("columnheader", { name: "Recommendation" })).toBeTruthy();
539+
expect(screen.getAllByText("clarity")).toHaveLength(3);
540+
expect(container.querySelectorAll(".markdown-table").length).toBe(1);
541+
expect(container.querySelectorAll("tbody tr").length).toBe(3);
542+
expect(screen.getAllByText("high")).toHaveLength(2);
543+
expect(screen.getByText("Split helpers by concern.")).toBeTruthy();
544+
});
545+
546+
it("wraps standard gfm tables in the styled table container", () => {
547+
const { container } = render(
548+
<Markdown
549+
value={["| Name | Value |", "| --- | --- |", "| Status | Ready |"].join("\n")}
550+
className="markdown"
551+
/>,
552+
);
553+
554+
expect(container.querySelector(".markdown-table-wrap")).toBeTruthy();
555+
expect(container.querySelector(".markdown-table")).toBeTruthy();
556+
expect(screen.getByRole("columnheader", { name: "Name" })).toBeTruthy();
557+
expect(screen.getByText("Ready")).toBeTruthy();
558+
});
559+
521560
});

src/features/messages/components/Markdown.tsx

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,123 @@ function normalizeUrlLine(line: string) {
8787
return withoutBullet;
8888
}
8989

90+
type StructuredReviewFinding = {
91+
file: string;
92+
category: string;
93+
finding: string;
94+
recommendation: string;
95+
severity: string;
96+
};
97+
98+
function escapeTableCell(value: string) {
99+
return value
100+
.replace(/\\/g, "\\\\")
101+
.replace(/\|/g, "\\|")
102+
.replace(/\r?\n/g, "<br />")
103+
.trim();
104+
}
105+
106+
function parseStructuredReviewFinding(line: string): StructuredReviewFinding | null {
107+
const parts = line.split(/\s+\|\s+/).map((part) => part.trim());
108+
if (parts.length !== 5) {
109+
return null;
110+
}
111+
const [file, rawCategory, finding, recommendation, rawSeverity] = parts;
112+
if (!file || !finding || !recommendation || !/^category=/i.test(rawCategory)) {
113+
return null;
114+
}
115+
const category = rawCategory.replace(/^category=/i, "").trim();
116+
const severity = rawSeverity.replace(/^severity=/i, "").trim();
117+
if (!category || !severity) {
118+
return null;
119+
}
120+
if (!/^(critical|high|medium|low|info|warning|error)$/i.test(severity)) {
121+
return null;
122+
}
123+
return {
124+
file,
125+
category,
126+
finding,
127+
recommendation,
128+
severity,
129+
};
130+
}
131+
132+
function buildStructuredReviewTable(rows: StructuredReviewFinding[]) {
133+
const header = [
134+
"| File | Category | Finding | Recommendation | Severity |",
135+
"| --- | --- | --- | --- | --- |",
136+
];
137+
const body = rows.map(
138+
({ file, category, finding, recommendation, severity }) =>
139+
`| \`${escapeTableCell(file)}\` | ${escapeTableCell(category)} | ${escapeTableCell(
140+
finding,
141+
)} | ${escapeTableCell(recommendation)} | ${escapeTableCell(severity)} |`,
142+
);
143+
return [...header, ...body].join("\n");
144+
}
145+
146+
function normalizeStructuredReviewTables(value: string) {
147+
const lines = value.split(/\r?\n/);
148+
let inFence = false;
149+
let pendingRows: StructuredReviewFinding[] = [];
150+
const output: string[] = [];
151+
152+
const flushPendingRows = () => {
153+
if (pendingRows.length === 0) {
154+
return;
155+
}
156+
if (output.length > 0 && output[output.length - 1].trim()) {
157+
output.push("");
158+
}
159+
output.push(buildStructuredReviewTable(pendingRows));
160+
output.push("");
161+
pendingRows = [];
162+
};
163+
164+
for (const line of lines) {
165+
const fenceMatch = line.match(/^\s*(```|~~~)/);
166+
if (fenceMatch) {
167+
flushPendingRows();
168+
inFence = !inFence;
169+
output.push(line);
170+
continue;
171+
}
172+
const structuredRow = inFence ? null : parseStructuredReviewFinding(line);
173+
if (structuredRow) {
174+
pendingRows.push(structuredRow);
175+
continue;
176+
}
177+
if (!inFence && pendingRows.length > 0 && !line.trim()) {
178+
continue;
179+
}
180+
flushPendingRows();
181+
output.push(line);
182+
}
183+
184+
flushPendingRows();
185+
return output.join("\n");
186+
}
187+
188+
function stripTrailingMemoryCitation(value: string) {
189+
return value.replace(/\n*<oai-mem-citation>[\s\S]*?<\/oai-mem-citation>\s*$/i, "").trim();
190+
}
191+
192+
export function isStandaloneMarkdownTable(value: string) {
193+
const stripped = stripTrailingMemoryCitation(value);
194+
if (!stripped) {
195+
return false;
196+
}
197+
const normalized = normalizeStructuredReviewTables(normalizeListIndentation(stripped)).trim();
198+
if (!normalized) {
199+
return false;
200+
}
201+
const lines = normalized.split(/\r?\n/).filter((line) => line.trim().length > 0);
202+
if (lines.length < 2) {
203+
return false;
204+
}
205+
return lines.every((line) => /^\|.*\|\s*$/.test(line.trim()));
206+
}
90207

91208
function extractUrlLines(value: string) {
92209
const lines = value.split(/\r?\n/);
@@ -323,7 +440,9 @@ export function Markdown({
323440
onOpenFileLinkMenu,
324441
onOpenThreadLink,
325442
}: MarkdownProps) {
326-
const normalizedValue = codeBlock ? value : normalizeListIndentation(value);
443+
const normalizedValue = codeBlock
444+
? value
445+
: normalizeStructuredReviewTables(normalizeListIndentation(value));
327446
const content = codeBlock
328447
? `\`\`\`\n${normalizedValue}\n\`\`\``
329448
: normalizedValue;
@@ -358,6 +477,11 @@ export function Markdown({
358477
return resolvedPath;
359478
};
360479
const components: Components = {
480+
table: ({ children }) => (
481+
<div className="markdown-table-wrap">
482+
<table className="markdown-table">{children}</table>
483+
</div>
484+
),
361485
a: ({ href, children }) => {
362486
const url = (href ?? "").trim();
363487
const threadId = url.startsWith("thread://")

src/features/messages/components/MessageRows.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
type ToolSummary,
3636
} from "../utils/messageRenderUtils";
3737
import { Markdown } from "./Markdown";
38+
import { isStandaloneMarkdownTable } from "./Markdown";
3839

3940
type MarkdownFileLinkProps = {
4041
showMessageFilePath?: boolean;
@@ -395,6 +396,11 @@ export const MessageRow = memo(function MessageRow({
395396
})
396397
.filter(Boolean) as MessageImage[];
397398
}, [item.images]);
399+
const isTableOnlyAssistantMessage =
400+
item.role === "assistant" &&
401+
hasText &&
402+
imageItems.length === 0 &&
403+
isStandaloneMarkdownTable(item.text);
398404

399405
const getSelectedMessageText = useCallback(() => {
400406
const bubble = bubbleRef.current;
@@ -436,7 +442,10 @@ export const MessageRow = memo(function MessageRow({
436442

437443
return (
438444
<div className={`message ${item.role}`}>
439-
<div ref={bubbleRef} className="bubble message-bubble">
445+
<div
446+
ref={bubbleRef}
447+
className={`bubble message-bubble${isTableOnlyAssistantMessage ? " message-bubble-table-only" : ""}`}
448+
>
440449
{imageItems.length > 0 && (
441450
<MessageImageGrid
442451
images={imageItems}

src/features/messages/components/Messages.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,35 @@ describe("Messages", () => {
145145
expect(markdown?.textContent ?? "").toContain("Literal [image] token");
146146
});
147147

148+
it("uses the table container as the visual bubble for assistant table-only messages", () => {
149+
const items: ConversationItem[] = [
150+
{
151+
id: "msg-table-1",
152+
kind: "message",
153+
role: "assistant",
154+
text: [
155+
"src/features/app/hooks/useMainAppLayoutSurfaces.ts | category=clarity | Layout assembly is still too broad. | Split surface assembly by domain. | high",
156+
"",
157+
"src/features/threads/hooks/threadMessagingHelpers.ts | category=clarity | Helper responsibilities are too broad. | Split helpers by concern. | medium",
158+
].join("\n"),
159+
},
160+
];
161+
162+
const { container } = render(
163+
<Messages
164+
items={items}
165+
threadId="thread-1"
166+
workspaceId="ws-1"
167+
isThinking={false}
168+
openTargets={[]}
169+
selectedOpenAppId=""
170+
/>,
171+
);
172+
173+
expect(container.querySelector(".message-bubble-table-only")).toBeTruthy();
174+
expect(container.querySelector(".markdown-table-wrap")).toBeTruthy();
175+
});
176+
148177
it("quotes a message into composer using markdown blockquote format", () => {
149178
const onQuoteMessage = vi.fn();
150179
const items: ConversationItem[] = [

src/styles/messages.css

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,18 @@
186186
position: relative;
187187
}
188188

189+
.message.assistant .message-bubble.message-bubble-table-only {
190+
max-width: min(100%, 960px);
191+
padding: 0;
192+
border-color: transparent;
193+
background: transparent;
194+
box-shadow: none;
195+
}
196+
197+
.message.assistant .message-bubble.message-bubble-table-only .markdown-table-wrap {
198+
margin: 0;
199+
}
200+
189201
.message-copy-button {
190202
display: inline-flex;
191203
align-items: center;
@@ -1285,6 +1297,92 @@
12851297
color: var(--text-strong);
12861298
}
12871299

1300+
.markdown .markdown-table-wrap {
1301+
--markdown-table-wide-col: max(18rem, 33%);
1302+
margin: 10px 0;
1303+
width: 100%;
1304+
overflow-x: auto;
1305+
border: 1px solid var(--border-strong);
1306+
border-radius: 14px;
1307+
background: color-mix(in srgb, var(--surface-card-strong) 92%, transparent);
1308+
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
1309+
}
1310+
1311+
.markdown .markdown-table {
1312+
width: max(100%, calc(var(--markdown-table-wide-col) * 2 + 40rem));
1313+
min-width: 0;
1314+
border-collapse: separate;
1315+
border-spacing: 0;
1316+
table-layout: fixed;
1317+
}
1318+
1319+
.markdown .markdown-table th,
1320+
.markdown .markdown-table td {
1321+
padding: 12px 14px;
1322+
text-align: left;
1323+
vertical-align: top;
1324+
white-space: normal;
1325+
overflow-wrap: anywhere;
1326+
}
1327+
1328+
.markdown .markdown-table thead th {
1329+
position: sticky;
1330+
top: 0;
1331+
z-index: 1;
1332+
background: color-mix(in srgb, var(--surface-control) 90%, transparent);
1333+
border-bottom: 1px solid var(--border-strong);
1334+
font-size: 10px;
1335+
font-weight: 700;
1336+
letter-spacing: 0.08em;
1337+
text-transform: uppercase;
1338+
color: var(--text-faint);
1339+
}
1340+
1341+
.markdown .markdown-table tbody td {
1342+
border-top: 1px solid color-mix(in srgb, var(--border-subtle) 88%, transparent);
1343+
}
1344+
1345+
.markdown .markdown-table tbody tr:first-child td {
1346+
border-top: none;
1347+
}
1348+
1349+
.markdown .markdown-table tbody tr:hover td {
1350+
background: color-mix(in srgb, var(--surface-control-hover) 36%, transparent);
1351+
}
1352+
1353+
.message .markdown .markdown-table td:first-child {
1354+
width: 22rem;
1355+
}
1356+
1357+
.message .markdown .markdown-table th:first-child,
1358+
.message .markdown .markdown-table td:first-child {
1359+
width: 22rem;
1360+
}
1361+
1362+
.message .markdown .markdown-table th:nth-child(2),
1363+
.message .markdown .markdown-table td:nth-child(2) {
1364+
width: 10rem;
1365+
}
1366+
1367+
.message .markdown .markdown-table th:nth-child(3),
1368+
.message .markdown .markdown-table td:nth-child(3),
1369+
.message .markdown .markdown-table th:nth-child(4),
1370+
.message .markdown .markdown-table td:nth-child(4) {
1371+
width: var(--markdown-table-wide-col);
1372+
}
1373+
1374+
.message .markdown .markdown-table th:last-child,
1375+
.message .markdown .markdown-table td:last-child {
1376+
width: 8rem;
1377+
white-space: nowrap;
1378+
font-weight: 650;
1379+
text-transform: capitalize;
1380+
}
1381+
1382+
.message .markdown .markdown-table td p {
1383+
margin: 0;
1384+
}
1385+
12881386
.markdown ul,
12891387
.markdown ol {
12901388
padding-left: 18px;

0 commit comments

Comments
 (0)