[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:
parent
03362b347d
commit
1ca3331c33
|
|
@ -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]");
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue