From 1ca3331c33ae9a98370839d5fd183f7a0153b9f5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:57:03 -0500 Subject: [PATCH] [codex] Fix mobile issue chat spacing (#8493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The issue page is one of the main surfaces where operators read agent updates and manage task relationships. > - On narrow mobile viewports, agent chat bubbles kept the desktop `max-w-[85%]` cap, leaving the right side overly indented. > - The same issue sidebar can also become noisy when many blockers or sub-tasks are attached to a task. > - This pull request adjusts the mobile chat bubble width while preserving the existing desktop cap. > - It also limits long issue-relation previews until the operator expands them, then allows collapsing them again. > - The benefit is a denser, more readable mobile issue page without changing the underlying issue data model. ## Linked Issues or Issue Description No public GitHub issue exists for this internal report. Bug report: - On mobile issue pages, agent chat bubbles are unnecessarily narrow because the desktop max-width applies at all breakpoints. - Long blocked-by and sub-task relation lists can dominate the issue properties area before the operator needs the full list. - Related prior work: #4861 improved issue-thread scale and markdown polish, but did not address this mobile bubble width regression. ## What Changed - Added a responsive agent-comment bubble width class so mobile bubbles use `max-w-[calc(100%-0.5rem)]` and desktop keeps `sm:max-w-[85%]`. - Added test coverage for the responsive agent bubble classes. - Added a five-item preview limit for blocked-by and sub-task relation pills in issue properties. - Added an inline `and N more...` / `show less` toggle for long blocked-by and sub-task relation lists. - Reset relation preview expansion when the `IssueProperties` panel receives a different `issue.id`. - Added component test coverage for collapsed, expanded, re-collapsed, and issue-switch reset states. ## Verification - Passed: `pnpm exec vitest run ui/src/components/IssueProperties.test.tsx` (`27 passed`). - Attempted: `pnpm exec vitest run ui/src/components/IssueChatThread.test.tsx`. - Result: failed before reaching the changed assertion because the existing suite imports `act` from `react`, which resolves to a non-function in this local Vitest environment. - Scope: 50 render tests fail with `TypeError: act is not a function`; 8 non-render tests pass. - Attempted: `pnpm --dir ui exec vitest run src/components/IssueChatThread.test.tsx` with the same `act is not a function` result. - Passed: Greptile rerun reported Confidence Score 5/5 on commit `e0f9c40b7faedcfb877d4f4b0e9f2c77a6593c16`. - Passed: all latest-head PR checks/statuses are green, including Typecheck + Release Registry, Build, general tests, serialized server suites, e2e, canary dry run, policy, security review, Socket, Snyk, and Greptile. ## Risks Low risk: - The chat change only alters max-width classes on agent comment bubbles and preserves the desktop cap. - The relation-list change is presentational; hidden blockers and sub-tasks remain in memory and can expand/collapse in place. - Expansion state now resets when navigating between issues in a reused panel instance. - Main residual risk is visual polish on real mobile devices because local browser screenshot verification was not run in this heartbeat; screenshots were not added because the source task explicitly requested not adding screenshots/images unless specifically part of the work. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex coding agent based on GPT-5, with repository tool use, shell execution, GitHub connector access, local Vitest verification, Greptile follow-up, and PR check monitoring. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- ui/src/components/IssueChatThread.test.tsx | 2 + ui/src/components/IssueChatThread.tsx | 5 +- ui/src/components/IssueProperties.test.tsx | 143 +++++++++++++++++++++ ui/src/components/IssueProperties.tsx | 59 ++++++++- 4 files changed, 205 insertions(+), 4 deletions(-) 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 ? (