[codex] Fix mobile issue chat spacing (#8493)

## 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
This commit is contained in:
Dotta 2026-06-22 15:57:03 -05:00 committed by GitHub
parent 03362b347d
commit 1ca3331c33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 205 additions and 4 deletions

View File

@ -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]");

View File

@ -226,6 +226,8 @@ const IssueChatCtx = createContext<IssueChatMessageContext>({
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). */}
<div
className={cn(
"min-w-0 max-w-[85%] break-words px-3 py-2 text-sm overflow-x-auto overflow-y-visible [border-radius:14px_14px_14px_4px]",
"min-w-0 break-words px-3 py-2 text-sm overflow-x-auto overflow-y-visible [border-radius:14px_14px_14px_4px]",
AGENT_COMMENT_BUBBLE_WIDTH_CLASS,
deleted
? "border border-border bg-muted/50 text-muted-foreground"
: "border border-border bg-card text-foreground",

View File

@ -771,6 +771,149 @@ describe("IssueProperties", () => {
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<Issue["blockedBy"]>;
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<Issue["blockedBy"]>;
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
const root = createRoot(container);
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties
issue={createIssue({ id: "issue-a", blockedBy })}
childIssues={[]}
onUpdate={vi.fn()}
inline
/>
</QueryClientProvider>,
);
});
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(
<QueryClientProvider client={queryClient}>
<IssueProperties
issue={createIssue({ id: "issue-b", blockedBy })}
childIssues={[]}
onUpdate={vi.fn()}
inline
/>
</QueryClientProvider>,
);
});
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";

View File

@ -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 (
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
onClick={onClick}
>
{expanded ? "show less" : `and ${hiddenCount} more...`}
</button>
);
}
/** 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<string>();
const childrenByParentId = new Map<string, string[]>();
@ -2149,9 +2187,14 @@ export function IssueProperties({
{inline ? (
<div>
<PropertyRow label="Blocked by">
{(issue.blockedBy ?? []).map((relation) => (
{visibleBlockedByRelations.map((relation) => (
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
))}
<ExpandRelationListButton
hiddenCount={hiddenBlockedByCount}
expanded={blockedByExpanded}
onClick={() => setBlockedByExpanded((expanded) => !expanded)}
/>
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
</PropertyRow>
{blockedByOpen && (
@ -2162,9 +2205,14 @@ export function IssueProperties({
</div>
) : (
<PropertyRow label="Blocked by">
{(issue.blockedBy ?? []).map((relation) => (
{visibleBlockedByRelations.map((relation) => (
<RemovableIssueReferencePill key={relation.id} issue={relation} onRemove={removeBlockedBy} />
))}
<ExpandRelationListButton
hiddenCount={hiddenBlockedByCount}
expanded={blockedByExpanded}
onClick={() => setBlockedByExpanded((expanded) => !expanded)}
/>
<Popover
open={blockedByOpen}
onOpenChange={(open) => {
@ -2195,10 +2243,15 @@ export function IssueProperties({
<PropertyRow label="Sub-tasks">
<div className="flex flex-wrap items-center gap-1.5">
{childIssues.length > 0
? childIssues.map((child) => (
? visibleChildIssues.map((child) => (
<IssueReferencePill key={child.id} issue={child} />
))
: null}
<ExpandRelationListButton
hiddenCount={hiddenChildIssueCount}
expanded={subTasksExpanded}
onClick={() => setSubTasksExpanded((expanded) => !expanded)}
/>
{onAddSubIssue ? (
<button
type="button"