Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -62,13 +63,15 @@ const HistoryTab: React.FC<HistoryTabProps> = ({
entries={timelineEntries}
currentUser={currentUser}
compact={isThread}
navigationEnabled={isThread}
/>
);
const discussionTimeline = (
<WorkItemActivityTimeline
entries={discussionEntries}
currentUser={currentUser}
compact
navigationEnabled={isThread}
/>
);
const activityTimeline = (
Expand Down Expand Up @@ -185,37 +188,41 @@ const HistoryTab: React.FC<HistoryTabProps> = ({
</div>
)}
{activityEntries.length > 0 ? (
<details
className="group overflow-hidden rounded-xl border border-border-1 bg-bg-2"
data-testid="work-item-thread-activity-history"
>
<summary className="flex min-h-10 cursor-pointer list-none items-center gap-2 px-3 text-[12px] font-medium text-text-2 marker:hidden [&::-webkit-details-marker]:hidden">
<span className="min-w-0 flex-1">
{t("workItems.activity.activityHistory")}
</span>
<span className="shrink-0 font-normal tabular-nums text-text-4">
{t("workItems.activity.activityHistoryCount", {
count: activityEntries.length,
})}
</span>
<ChevronRight
size={14}
aria-hidden
className="shrink-0 text-text-4 transition-transform group-open:rotate-90"
/>
</summary>
<div className="border-t border-border-1 p-2">
{activityTimeline}
</div>
</details>
<ScrollTrailTarget label={t("workItems.activity.activityHistory")}>
<details
className="group overflow-hidden rounded-xl border border-border-1 bg-bg-2"
data-testid="work-item-thread-activity-history"
>
<summary className="flex min-h-10 cursor-pointer list-none items-center gap-2 px-3 text-[12px] font-medium text-text-2 marker:hidden [&::-webkit-details-marker]:hidden">
<span className="min-w-0 flex-1">
{t("workItems.activity.activityHistory")}
</span>
<span className="shrink-0 font-normal tabular-nums text-text-4">
{t("workItems.activity.activityHistoryCount", {
count: activityEntries.length,
})}
</span>
<ChevronRight
size={14}
aria-hidden
className="shrink-0 text-text-4 transition-transform group-open:rotate-90"
/>
</summary>
<div className="border-t border-border-1 p-2">
{activityTimeline}
</div>
</details>
</ScrollTrailTarget>
) : null}
{canComment ? (
<div
className="sticky bottom-0 z-10 bg-transparent pt-2"
data-testid="work-item-thread-comment-dock"
>
{composer}
</div>
<ScrollTrailTarget label={t("workItems.activity.commentPlaceholder")}>
<div
className="sticky bottom-0 z-10 bg-transparent pt-2"
data-testid="work-item-thread-comment-dock"
>
{composer}
</div>
</ScrollTrailTarget>
) : null}
</section>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand All @@ -71,6 +73,11 @@ export function WorkItemActivityTimeline({
<ConnectedTimelineItem
key={item.id}
isLast={itemIndex === items.length - 1}
trailLabel={
navigationEnabled
? getActivityTimelineTrailLabel(item)
: undefined
}
>
<ActivityTimelineItemView item={item} currentUser={currentUser} />
</ConnectedTimelineItem>
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -452,6 +453,14 @@ const WorkItemContent: React.FC<WorkItemContentProps> = ({
!isGitHubWorkItem ||
(!githubTimelineLoading && githubTimeline.length === 0)
}
trailLabel={
isThread
? workItem.name ||
t("common:labels.description", {
defaultValue: "Description",
})
: undefined
}
>
<TimelineCard
copyBody={normalizedRawDescription}
Expand Down Expand Up @@ -582,6 +591,7 @@ const WorkItemContent: React.FC<WorkItemContentProps> = ({
<IssueTimelineItems
timeline={githubTimeline}
timelineLoading={githubTimelineLoading}
navigationEnabled={isThread}
/>
) : null}
</TimelineStack>
Expand Down Expand Up @@ -711,8 +721,22 @@ const WorkItemContent: React.FC<WorkItemContentProps> = ({

const threadLowerSection = (
<>
{sectionPolicy.showInlineWorkflow ? agentWorkflow : null}
{sectionPolicy.showInlineOutput ? outputContent : null}
{sectionPolicy.showInlineWorkflow ? (
<ScrollTrailTarget
enabled={isThread}
label={t("workItems.agentWorkflow.title")}
>
{agentWorkflow}
</ScrollTrailTarget>
) : null}
{sectionPolicy.showInlineOutput ? (
<ScrollTrailTarget
enabled={isThread}
label={t("common:labels.output", { defaultValue: "Output" })}
>
{outputContent}
</ScrollTrailTarget>
) : null}
</>
);

Expand All @@ -723,26 +747,36 @@ const WorkItemContent: React.FC<WorkItemContentProps> = ({
<>
{handoffNotice}
{descriptionSection}
{todosSection}
<ScrollTrailTarget label={t("workItems.todos.title")}>
{todosSection}
</ScrollTrailTarget>
{threadLowerSection}
{isGitHubWorkItem && githubIssueInteraction ? (
<GitHubIssueComposer interaction={githubIssueInteraction} />
<ScrollTrailTarget
label={t("common:git.issues.composer.addComment")}
>
<GitHubIssueComposer interaction={githubIssueInteraction} />
</ScrollTrailTarget>
) : (
<nav
className="flex min-h-8 items-center justify-end"
aria-label={t("workItems.activity.discussionTitle")}
data-testid="work-item-thread-secondary-navigation"
<ScrollTrailTarget
label={t("workItems.activity.discussionTitle")}
>
<WorkItemThreadViewAction
activeView="overview"
onChange={(view) =>
setThreadViewSelection({
workItemId: workItem.session_id,
view,
})
}
/>
</nav>
<nav
className="flex min-h-8 items-center justify-end"
aria-label={t("workItems.activity.discussionTitle")}
data-testid="work-item-thread-secondary-navigation"
>
<WorkItemThreadViewAction
activeView="overview"
onChange={(view) =>
setThreadViewSelection({
workItemId: workItem.session_id,
view,
})
}
/>
</nav>
</ScrollTrailTarget>
)}
</>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 `<nav>`; each marker is a button with section position and `aria-current` on the active stop.
- [ ] Marker previews appear on hover and keyboard focus, and reduced-motion users receive non-animated scrolling.

## Acceptance Criteria

Expand All @@ -49,3 +56,5 @@
- [ ] Discussion appears after the primary Work Item content; it never shares the property metadata row.
- [ ] The ordinary Work Item presentation remains unchanged.
- [ ] No persistence, orchestration, navigation, polling, or subscription ownership moves into the presentation primitives.
- [ ] GitHub issue activity, local Work Item activity, To-Do, workflow, output, discussion, and comment destinations contribute semantic trail stops.
- [ ] Trail discovery is mutation-driven, scroll updates are frame-coalesced, and all observers/listeners are disposed with the thread.
Loading
Loading