diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 699506801f..49a1835679 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -1562,6 +1562,8 @@ describe("IssueChatThread", () => { ); expect(bubble).toBeDefined(); expect(bubble?.textContent).toContain("Here is my agent reply."); + expect(bubble?.className).toContain("max-w-[calc(100%-0.5rem)]"); + expect(bubble?.className).toContain("sm:max-w-[85%]"); // Neutral, not the human liveness-blue bubble. expect(bubble?.className).not.toContain("bg-[#2563EB]"); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 5ddf41d185..aa215ac4fc 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -226,6 +226,8 @@ const IssueChatCtx = createContext({ successfulRunHandoff: null, }); +const AGENT_COMMENT_BUBBLE_WIDTH_CLASS = "max-w-[calc(100%-0.5rem)] sm:max-w-[85%]"; + export type IssueChatRunFinalizationAction = { id: "cancel" | "done"; label: string; @@ -1756,7 +1758,8 @@ function IssueChatAssistantMessage({ {/* Canonical conference-room agent bubble (BoardChat.tsx:712). */}
{ act(() => root.unmount()); }); + it("collapses long blocked-by and sub-task lists until the more button is clicked", async () => { + const blockedBy = Array.from({ length: 7 }, (_, index) => ({ + id: `blocker-${index + 1}`, + identifier: `BLOCK-${index + 1}`, + title: `Blocker ${index + 1}`, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + })) as NonNullable; + const childIssues = Array.from({ length: 7 }, (_, index) => createIssue({ + id: `child-${index + 1}`, + identifier: `SUB-${index + 1}`, + title: `Sub-task ${index + 1}`, + })); + const root = renderProperties(container, { + issue: createIssue({ blockedBy }), + childIssues, + onUpdate: vi.fn(), + inline: true, + }); + await flush(); + + expect(container.textContent).toContain("BLOCK-5"); + expect(container.textContent).not.toContain("BLOCK-6"); + expect(container.textContent).toContain("SUB-5"); + expect(container.textContent).not.toContain("SUB-6"); + expect( + Array.from(container.querySelectorAll("button")).filter((button) => + button.textContent?.trim() === "and 2 more...", + ), + ).toHaveLength(2); + + const expandBlockedBy = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.trim() === "and 2 more...", + ); + expect(expandBlockedBy).not.toBeUndefined(); + await act(async () => { + expandBlockedBy!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("BLOCK-6"); + expect(container.textContent).toContain("BLOCK-7"); + expect(container.textContent).not.toContain("SUB-6"); + expect(container.textContent).toContain("show less"); + + const expandSubTasks = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.trim() === "and 2 more...", + ); + expect(expandSubTasks).not.toBeUndefined(); + await act(async () => { + expandSubTasks!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("SUB-6"); + expect(container.textContent).toContain("SUB-7"); + expect( + Array.from(container.querySelectorAll("button")).filter((button) => + button.textContent?.trim() === "and 2 more...", + ), + ).toHaveLength(0); + expect( + Array.from(container.querySelectorAll("button")).filter((button) => + button.textContent?.trim() === "show less", + ), + ).toHaveLength(2); + + const collapseBlockedBy = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.trim() === "show less", + ); + expect(collapseBlockedBy).not.toBeUndefined(); + await act(async () => { + collapseBlockedBy!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).not.toContain("BLOCK-6"); + expect(container.textContent).toContain("SUB-6"); + expect(container.textContent).toContain("and 2 more..."); + + act(() => root.unmount()); + }); + + it("resets expanded relation previews when the issue changes", async () => { + const blockedBy = Array.from({ length: 7 }, (_, index) => ({ + id: `blocker-${index + 1}`, + identifier: `BLOCK-${index + 1}`, + title: `Blocker ${index + 1}`, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + })) as NonNullable; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + await flush(); + + const expandBlockedBy = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.trim() === "and 2 more...", + ); + expect(expandBlockedBy).not.toBeUndefined(); + await act(async () => { + expandBlockedBy!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("BLOCK-6"); + + act(() => { + root.render( + + + , + ); + }); + await flush(); + + expect(container.textContent).not.toContain("BLOCK-6"); + expect(container.textContent).toContain("and 2 more..."); + + act(() => root.unmount()); + }); + it("shows a green service link above the workspace row for a live non-main workspace", async () => { mockProjectsApi.list.mockResolvedValue([createProject()]); const serviceUrl = "http://127.0.0.1:62475"; diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 9d6d70c01e..dadb31e7e8 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -156,6 +156,7 @@ interface IssuePropertiesProps { } const ISSUE_BLOCKER_SEARCH_LIMIT = 50; +const ISSUE_PROPERTY_RELATION_PREVIEW_COUNT = 5; function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return ( @@ -323,6 +324,27 @@ function RemovableIssueReferencePill({ ); } +function ExpandRelationListButton({ + hiddenCount, + expanded, + onClick, +}: { + hiddenCount: number; + expanded: boolean; + onClick: () => void; +}) { + if (!expanded && hiddenCount <= 0) return null; + return ( + + ); +} + /** Renders a Popover on desktop, or an inline collapsible section on mobile (inline mode). */ function PropertyPicker({ inline, @@ -415,6 +437,8 @@ export function IssueProperties({ const [projectSearch, setProjectSearch] = useState(""); const [blockedByOpen, setBlockedByOpen] = useState(false); const [blockedBySearch, setBlockedBySearch] = useState(""); + const [blockedByExpanded, setBlockedByExpanded] = useState(false); + const [subTasksExpanded, setSubTasksExpanded] = useState(false); const [parentOpen, setParentOpen] = useState(false); const [parentSearch, setParentSearch] = useState(""); const [reviewersOpen, setReviewersOpen] = useState(false); @@ -436,6 +460,11 @@ export function IssueProperties({ const [watchdogInstructionsInput, setWatchdogInstructionsInput] = useState(issue.watchdog?.instructions ?? ""); const normalizedBlockedBySearch = blockedBySearch.trim(); + useEffect(() => { + setBlockedByExpanded(false); + setSubTasksExpanded(false); + }, [issue.id]); + const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), @@ -1837,6 +1866,15 @@ export function IssueProperties({ ); const blockedByIds = issue.blockedBy?.map((relation) => relation.id) ?? []; + const blockedByRelations = issue.blockedBy ?? []; + const visibleBlockedByRelations = blockedByExpanded + ? blockedByRelations + : blockedByRelations.slice(0, ISSUE_PROPERTY_RELATION_PREVIEW_COUNT); + const hiddenBlockedByCount = blockedByRelations.length - visibleBlockedByRelations.length; + const visibleChildIssues = subTasksExpanded + ? childIssues + : childIssues.slice(0, ISSUE_PROPERTY_RELATION_PREVIEW_COUNT); + const hiddenChildIssueCount = childIssues.length - visibleChildIssues.length; const descendantIssueIds = useMemo(() => { if (!allIssues?.length) return new Set(); const childrenByParentId = new Map(); @@ -2149,9 +2187,14 @@ export function IssueProperties({ {inline ? (
- {(issue.blockedBy ?? []).map((relation) => ( + {visibleBlockedByRelations.map((relation) => ( ))} + setBlockedByExpanded((expanded) => !expanded)} + /> {renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))} {blockedByOpen && ( @@ -2162,9 +2205,14 @@ export function IssueProperties({
) : ( - {(issue.blockedBy ?? []).map((relation) => ( + {visibleBlockedByRelations.map((relation) => ( ))} + setBlockedByExpanded((expanded) => !expanded)} + /> { @@ -2195,10 +2243,15 @@ export function IssueProperties({
{childIssues.length > 0 - ? childIssues.map((child) => ( + ? visibleChildIssues.map((child) => ( )) : null} + setSubTasksExpanded((expanded) => !expanded)} + /> {onAddSubIssue ? (