From 6a86687a18d197118801b7660eca8543c1a5e722 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:33:05 +0800 Subject: [PATCH] feat(ui): add persistent detail navigation trails --- .../GitHubIssueThreadSurface.test.ts | 19 + .../components/WorkItemContent/HistoryTab.tsx | 65 +-- .../WorkItemActivityTimeline.tsx | 17 + .../WorkItemDescriptionEditing.test.ts | 3 + .../components/WorkItemContent/index.tsx | 70 +++- .../WorkItemThread/__tests__/TEST_CASES.md | 25 +- .../components/WorkItemThread/index.tsx | 73 ++-- .../IssuesContent/IssueTimelineItems.tsx | 37 +- .../__tests__/TEST_CASES.md | 5 + .../detail/PrConversationTab.tsx | 333 +++++++++------- .../detail/PrDetailPanel.test.ts | 16 + .../detail/PrDetailPanel.tsx | 169 +++++--- .../ActivityTimeline/ActivityTimeline.test.ts | 16 + .../components/ActivityTimeline/index.tsx | 13 +- .../shared/layouts/blocks/ScrollTrail.test.ts | 324 +++++++++++++++ .../shared/layouts/blocks/ScrollTrail.tsx | 377 ++++++++++++++++++ src/modules/shared/layouts/blocks/index.ts | 11 + 17 files changed, 1279 insertions(+), 294 deletions(-) create mode 100644 src/modules/shared/layouts/blocks/ScrollTrail.test.ts create mode 100644 src/modules/shared/layouts/blocks/ScrollTrail.tsx diff --git a/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts b/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts index 5804d294d..77d1b8e04 100644 --- a/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/GitHubIssueThreadSurface.test.ts @@ -167,6 +167,25 @@ describe("mapGitHubIssueToThreadWorkItem", () => { ); }); + it("contributes semantic stops to the shared issue and work-item trail", () => { + const markup = renderToStaticMarkup( + React.createElement(GitHubIssueThreadSurface, { + issue, + timeline: [], + timelineLoading: false, + interaction: createInteraction(), + }) + ); + + expect(markup).toContain("data-scroll-trail-target"); + expect(markup).toContain( + 'data-scroll-trail-label="Use one issue detail surface"' + ); + expect( + markup.match(/data-scroll-trail-target/g)?.length + ).toBeGreaterThanOrEqual(4); + }); + it("toggles external assignees without duplicating login casing", () => { expect(toggleExternalAssigneeIds(["Ada", "Grace"], "ada")).toEqual([ "Grace", diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx index 537a73743..08f4aa5b9 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/HistoryTab.tsx @@ -7,6 +7,7 @@ import Button from "@src/components/Button"; import ComposerShell from "@src/components/ComposerShell"; import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; import RichMarkdownEditor from "@src/modules/shared/components/RichMarkdownEditor"; +import { ScrollTrailTarget } from "@src/modules/shared/layouts/blocks"; import { WorkItemActivityTimeline } from "./WorkItemActivityTimeline"; import WorkItemMentionPicker from "./WorkItemMentionPicker"; @@ -62,6 +63,7 @@ const HistoryTab: React.FC = ({ entries={timelineEntries} currentUser={currentUser} compact={isThread} + navigationEnabled={isThread} /> ); const discussionTimeline = ( @@ -69,6 +71,7 @@ const HistoryTab: React.FC = ({ entries={discussionEntries} currentUser={currentUser} compact + navigationEnabled={isThread} /> ); const activityTimeline = ( @@ -185,37 +188,41 @@ const HistoryTab: React.FC = ({ )} {activityEntries.length > 0 ? ( -
- - - {t("workItems.activity.activityHistory")} - - - {t("workItems.activity.activityHistoryCount", { - count: activityEntries.length, - })} - - - -
- {activityTimeline} -
-
+ +
+ + + {t("workItems.activity.activityHistory")} + + + {t("workItems.activity.activityHistoryCount", { + count: activityEntries.length, + })} + + + +
+ {activityTimeline} +
+
+
) : null} {canComment ? ( -
- {composer} -
+ +
+ {composer} +
+
) : null} ); diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/WorkItemActivityTimeline.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/WorkItemActivityTimeline.tsx index a5d6f3ec9..3018d4ac1 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/WorkItemActivityTimeline.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/WorkItemActivityTimeline.tsx @@ -53,12 +53,14 @@ interface WorkItemActivityTimelineProps { entries: TimelineEntry[]; currentUser: Person; compact?: boolean; + navigationEnabled?: boolean; } export function WorkItemActivityTimeline({ entries, currentUser, compact = false, + navigationEnabled = false, }: WorkItemActivityTimelineProps): React.ReactNode { const items = useMemo(() => groupActivityTimelineEntries(entries), [entries]); @@ -71,6 +73,11 @@ export function WorkItemActivityTimeline({ @@ -80,6 +87,16 @@ export function WorkItemActivityTimeline({ ); } +function getActivityTimelineTrailLabel(item: ActivityTimelineItem): string { + if (item.kind === "change-group") { + return `${item.actor.userName}: ${item.fieldLabels.join(", ")}`; + } + const description = item.entry.descriptions.join("; "); + return description + ? `${item.entry.userName}: ${description}` + : item.entry.userName; +} + function ActivityTimelineItemView({ item, currentUser, diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts index 34f53c4ec..eba67024e 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/__tests__/WorkItemDescriptionEditing.test.ts @@ -210,6 +210,9 @@ vi.mock("@src/modules/shared/components/ActivityTimeline", () => ({ vi.mock("@src/modules/shared/layouts/blocks", () => ({ DetailPanelContainer: ({ children }: { children?: React.ReactNode }) => createElement("div", null, children), + ScrollTrail: () => null, + ScrollTrailTarget: ({ children }: { children?: React.ReactNode }) => + createElement("div", null, children), SessionTable: () => null, PanelFooter: ({ secondaryActions = [], diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx index 4b87d23b7..e2d4f7a69 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/index.tsx @@ -24,6 +24,7 @@ import RichMarkdownEditor from "@src/modules/shared/components/RichMarkdownEdito import { DetailPanelContainer, PanelFooter, + ScrollTrailTarget, SessionTable, type SessionTableItem, } from "@src/modules/shared/layouts/blocks"; @@ -452,6 +453,14 @@ const WorkItemContent: React.FC = ({ !isGitHubWorkItem || (!githubTimelineLoading && githubTimeline.length === 0) } + trailLabel={ + isThread + ? workItem.name || + t("common:labels.description", { + defaultValue: "Description", + }) + : undefined + } > = ({ ) : null} @@ -711,8 +721,22 @@ const WorkItemContent: React.FC = ({ const threadLowerSection = ( <> - {sectionPolicy.showInlineWorkflow ? agentWorkflow : null} - {sectionPolicy.showInlineOutput ? outputContent : null} + {sectionPolicy.showInlineWorkflow ? ( + + {agentWorkflow} + + ) : null} + {sectionPolicy.showInlineOutput ? ( + + {outputContent} + + ) : null} ); @@ -723,26 +747,36 @@ const WorkItemContent: React.FC = ({ <> {handoffNotice} {descriptionSection} - {todosSection} + + {todosSection} + {threadLowerSection} {isGitHubWorkItem && githubIssueInteraction ? ( - + + + ) : ( - + + )} ) : ( diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/TEST_CASES.md b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/TEST_CASES.md index 3fdcc12de..8d9edc615 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/TEST_CASES.md +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemThread/__tests__/TEST_CASES.md @@ -14,17 +14,22 @@ | 2 | Inspect To-Do and Agent Workflow. | Both use the same radius, border, background, and header-divider treatment. | | 3 | Add or complete a To-Do, then start/open an Agent workflow. | Existing Work Item persistence and canonical Agent behavior remain unchanged. | | 4 | Open Discussion, then use Back. | The secondary navigation replaces the body in place while the independent metadata header remains unchanged. | +| 5 | Scroll a long GitHub Issue or Work Item thread. | The right-edge trail highlights the current semantic stop; selecting a marker scrolls its activity or section into view. | ## Edge Cases -| # | Scenario | Steps | Expected Result | -| --- | ------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | -| 1 | No metadata content | Render without path or properties. | No empty metadata control row renders; the independent Discussion entry remains available after content. | -| 2 | One header source | Render with only path, then only properties. | The available content renders without an orphan divider. | -| 3 | Narrow width | Resize the detail until property pills overflow. | The unframed metadata row scrolls horizontally while the content remains a single reading column. | -| 4 | Empty To-Do | Open a Work Item with no committed To-Dos. | The shared section shell remains intact and exposes the demand-mounted add action. | -| 5 | Rapid interaction | Toggle To-Dos and collapse Workflow quickly. | Each owning component handles its own state; layout primitives introduce no duplicate updates. | -| 6 | Work Item switch | Open Discussion, then select another Work Item. | The new Work Item starts on its primary body without showing the previous item's Discussion. | +| # | Scenario | Steps | Expected Result | +| --- | ------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| 1 | No metadata content | Render without path or properties. | No empty metadata control row renders; the independent Discussion entry remains available after content. | +| 2 | One header source | Render with only path, then only properties. | The available content renders without an orphan divider. | +| 3 | Narrow width | Resize the detail until property pills overflow. | The unframed metadata row scrolls horizontally while the content remains a single reading column. | +| 4 | Empty To-Do | Open a Work Item with no committed To-Dos. | The shared section shell remains intact and exposes the demand-mounted add action. | +| 5 | Rapid interaction | Toggle To-Dos and collapse Workflow quickly. | Each owning component handles its own state; layout primitives introduce no duplicate updates. | +| 6 | Work Item switch | Open Discussion, then select another Work Item. | The new Work Item starts on its primary body without showing the previous item's Discussion. | +| 7 | Long activity trail | Open an issue with more than 20 activity stops. | The trail keeps 20 evenly sampled markers, including the first and final stops. | +| 8 | Short thread | Open a thread that fits without vertical scroll. | The dedicated trail rail remains visible, including when only one semantic stop is available. | +| 9 | Narrow thread | Narrow the thread until its readable content appears. | The same dedicated trail rail remains available without depending on a second container breakpoint. | +| 10 | Team Inbox item | Open an assigned Work Item from Team Inbox. | The canonical thread includes the same always-visible right-side navigation rail. | ## Error / Degraded States @@ -40,6 +45,8 @@ - [ ] Icon-only controls retain translated accessible names. - [ ] Collapsible Workflow keeps the existing button semantics and focus treatment. - [ ] Discussion and Back use the shared `Button` with visible, translated names and keyboard focus treatment. +- [ ] The navigation trail is a labeled `