From f8d7438eec4069e67e0ffaae036211604d4cd227 Mon Sep 17 00:00:00 2001 From: dave Date: Thu, 2 Apr 2026 10:57:18 +0000 Subject: [PATCH] storkit: merge 454_story_deduplicate_work_item_display_in_web_ui_story_panel --- frontend/src/components/Chat.test.tsx | 14 +-- .../components/WorkItemDetailPanel.test.tsx | 98 ++++++++++++++++++- .../src/components/WorkItemDetailPanel.tsx | 44 ++++++++- 3 files changed, 142 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index d77a61ec..1e7b4fe4 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -1643,7 +1643,9 @@ describe("Slash command handling (Story 374)", () => { }); it("AC: /help calls botCommand and displays response", async () => { - mockedApi.botCommand.mockResolvedValue({ response: "Available commands: status, help, ..." }); + mockedApi.botCommand.mockResolvedValue({ + response: "Available commands: status, help, ...", + }); render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); @@ -1660,11 +1662,7 @@ describe("Slash command handling (Story 374)", () => { }); await waitFor(() => { - expect(mockedApi.botCommand).toHaveBeenCalledWith( - "help", - "", - undefined, - ); + expect(mockedApi.botCommand).toHaveBeenCalledWith("help", "", undefined); }); expect(lastSendChatArgs).toBeNull(); }); @@ -1723,7 +1721,9 @@ describe("Bug 450: WebSocket error messages displayed in chat", () => { ); }); - const link = await screen.findByRole("link", { name: /https:\/\/example\.com\/oauth\/login/ }); + const link = await screen.findByRole("link", { + name: /https:\/\/example\.com\/oauth\/login/, + }); expect(link).toBeInTheDocument(); expect(link).toHaveAttribute("href", "https://example.com/oauth/login"); }); diff --git a/frontend/src/components/WorkItemDetailPanel.test.tsx b/frontend/src/components/WorkItemDetailPanel.test.tsx index 56127c3a..c4e3ce59 100644 --- a/frontend/src/components/WorkItemDetailPanel.test.tsx +++ b/frontend/src/components/WorkItemDetailPanel.test.tsx @@ -69,7 +69,7 @@ afterEach(() => { }); describe("WorkItemDetailPanel", () => { - it("renders the story name in the header", async () => { + it("renders the story name in the header with type and ID prefix", async () => { render( { ); await waitFor(() => { expect(screen.getByTestId("detail-panel-title")).toHaveTextContent( - "Big Title Story", + "Bug 237: Big Title Story", ); }); }); @@ -110,6 +110,10 @@ describe("WorkItemDetailPanel", () => { }); it("renders markdown headings with constrained inline font size", async () => { + mockedGetWorkItemContent.mockResolvedValue({ + ...DEFAULT_CONTENT, + content: "# Title Heading\n\n## Section Heading\n\nSome content.", + }); render( { ); await waitFor(() => { const content = screen.getByTestId("detail-panel-content"); - const h1 = content.querySelector("h1"); - expect(h1).not.toBeNull(); - expect(h1?.style.fontSize).toBeTruthy(); + // H1 is stripped by stripDisplayContent; h2 should be constrained + const h2 = content.querySelector("h2"); + expect(h2).not.toBeNull(); + expect(h2?.style.fontSize).toBeTruthy(); }); }); + + it("strips YAML front matter so 'name' is not shown as a prefix in content", async () => { + mockedGetWorkItemContent.mockResolvedValue({ + content: + '---\nname: "My Story Name"\n---\n\n# Story 42: My Story Name\n\n## User Story\n\nAs a user...', + stage: "current", + name: "My Story Name", + agent: null, + }); + render( + {}} + />, + ); + const content = await screen.findByTestId("detail-panel-content"); + expect(content.textContent).not.toMatch(/name:/i); + }); + + it("strips the first H1 heading so the story title is not shown twice", async () => { + mockedGetWorkItemContent.mockResolvedValue({ + content: + '---\nname: "My Story Name"\n---\n\n# Story 42: My Story Name\n\n## User Story\n\nAs a user...', + stage: "current", + name: "My Story Name", + agent: null, + }); + render( + {}} + />, + ); + const content = await screen.findByTestId("detail-panel-content"); + expect(content.querySelector("h1")).toBeNull(); + }); + + it("shows 'Type N: Name' format in the panel header title (story ID/title left-justified)", async () => { + mockedGetWorkItemContent.mockResolvedValue({ + content: "## User Story\n\nAs a user...", + stage: "current", + name: "My Story Name", + agent: null, + }); + render( + {}} + />, + ); + await waitFor(() => { + expect(screen.getByTestId("detail-panel-title")).toHaveTextContent( + "Story 42: My Story Name", + ); + }); + }); + + it("does not show the work item type label twice when front matter and H1 are stripped", async () => { + mockedGetWorkItemContent.mockResolvedValue({ + content: + '---\nname: "My Story Name"\n---\n\n# Story 42: My Story Name\n\n## User Story\n\nContent.', + stage: "current", + name: "My Story Name", + agent: null, + }); + render( + {}} + />, + ); + await screen.findByTestId("detail-panel-content"); + // "Story" type label appears exactly once — in the panel header title + const title = screen.getByTestId("detail-panel-title"); + expect(title.textContent).toContain("Story 42:"); + // The content body should not contain an H1 repeating the type + title + const content = screen.getByTestId("detail-panel-content"); + expect(content.querySelector("h1")).toBeNull(); + }); }); describe("WorkItemDetailPanel - Agent Logs", () => { diff --git a/frontend/src/components/WorkItemDetailPanel.tsx b/frontend/src/components/WorkItemDetailPanel.tsx index cc837b52..f119c923 100644 --- a/frontend/src/components/WorkItemDetailPanel.tsx +++ b/frontend/src/components/WorkItemDetailPanel.tsx @@ -17,6 +17,46 @@ import { api } from "../api/client"; const { useCallback, useEffect, useRef, useState } = React; +/** + * Strip YAML front matter and the first H1 heading from story content before + * rendering. The panel header already shows the story ID/title, so rendering + * them again inside the markdown body creates duplicate information. + */ +function stripDisplayContent(content: string): string { + let text = content; + // Strip YAML front matter (--- ... ---) + if (text.startsWith("---")) { + const eol = text.indexOf("\n"); + if (eol !== -1) { + const closeIdx = text.indexOf("\n---", eol); + if (closeIdx !== -1) { + text = text.slice(closeIdx + 4); + } + } + } + // Trim leading blank lines left by the front matter + text = text.trimStart(); + // Strip the first H1 heading — it duplicates the panel header title + if (text.startsWith("# ")) { + const eol = text.indexOf("\n"); + text = eol !== -1 ? text.slice(eol + 1).trimStart() : ""; + } + return text; +} + +/** + * Format the story ID/title line shown in the panel header. + * Produces e.g. "Story 454: My Story Name" or "Bug 12: Crash on startup". + * Falls back to name or storyId when the pattern doesn't match. + */ +function formatStoryTitle(storyId: string, name: string | null): string { + const match = storyId.match(/^(\d+)_([a-z]+)_/); + if (!match || !name) return name ?? storyId; + const [, number, type] = match; + const typeLabel = type.charAt(0).toUpperCase() + type.slice(1); + return `${typeLabel} ${number}: ${name}`; +} + const STAGE_LABELS: Record = { backlog: "Backlog", current: "Current", @@ -352,7 +392,7 @@ export function WorkItemDetailPanel({ whiteSpace: "nowrap", }} > - {name ?? storyId} + {formatStoryTitle(storyId, name)} {stage && (
- {content} + {stripDisplayContent(content)}
)}