[codex] Polish issue and operator workflow UI (#4090)

## Thinking Path

> - Paperclip operators spend much of their time in issues, inboxes,
selectors, and rich comment threads.
> - Small interaction problems in those surfaces slow down supervision
of AI-agent work.
> - The branch included related operator quality-of-life fixes for issue
layout, inbox actions, recent selectors, mobile inputs, and chat
rendering stability.
> - These changes are UI-focused and can land independently from
workspace navigation and access-profile work.
> - This pull request groups the operator QoL fixes into one standalone
branch.
> - The benefit is a more stable and efficient board workflow for issue
triage and task editing.

## What Changed

- Widened issue detail content and added a desktop inbox archive action.
- Fixed mobile text-field zoom by keeping touch input font sizes at
16px.
- Prioritized recent picker selections for assignees/projects in issue
and routine flows.
- Showed actionable approvals in the Mine inbox model.
- Fixed issue chat renderer state crashes and hardened tests.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run ui/src/components/IssueChatThread.test.tsx
ui/src/lib/inbox.test.ts ui/src/lib/recent-selections.test.ts`
- Split integration check: merged last after the other
[PAP-1614](/PAP/issues/PAP-1614) branches with no merge conflicts.
- Confirmed this branch does not include `pnpm-lock.yaml`.

## Risks

- Low to medium risk: mostly UI state, layout, and selection-priority
behavior.
- Visual layout and mobile zoom behavior may need browser/device QA
beyond component tests.
- No database migrations are included.

> 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, GPT-5.4 tool-enabled coding model, agentic
code-editing/runtime with local shell and GitHub CLI access; exact
context window and reasoning mode are not exposed by the Paperclip
harness.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-20 06:16:41 -05:00 committed by GitHub
parent fee514efcb
commit 057fee4836
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 596 additions and 275 deletions

View file

@ -12,7 +12,15 @@ import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import {
getRecentAssigneeIds,
getRecentAssigneeSelectionIds,
sortAgentsByRecency,
trackRecentAssignee,
trackRecentAssigneeUser,
} from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
import { orderItemsBySelectedAndRecent } from "../lib/recent-selections";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy";
import { StatusIcon } from "./StatusIcon";
@ -294,10 +302,16 @@ export function IssueProperties({
};
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]);
const recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), [assigneeOpen]);
const sortedAgents = useMemo(
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
[agents, recentAssigneeIds],
);
const recentAssigneeValues = useMemo(
() => recentAssigneeSelectionIds,
[recentAssigneeSelectionIds],
);
const recentProjectIds = useMemo(() => getRecentProjectIds(), [projectOpen]);
const userLabelMap = useMemo(
() => buildCompanyUserLabelMap(companyMembers?.users),
[companyMembers?.users],
@ -315,6 +329,11 @@ export function IssueProperties({
const userLabel = (userId: string | null | undefined) => formatAssigneeUserLabel(userId, currentUserId, userLabelMap);
const assigneeUserLabel = userLabel(issue.assigneeUserId);
const creatorUserLabel = userLabel(issue.createdByUserId);
const selectedAssigneeValue = issue.assigneeAgentId
? `agent:${issue.assigneeAgentId}`
: issue.assigneeUserId
? `user:${issue.assigneeUserId}`
: "";
const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => {
onUpdate({
executionPolicy: buildExecutionPolicy({
@ -499,6 +518,46 @@ export function IssueProperties({
</>
);
const assigneePickerOptions = orderItemsBySelectedAndRecent(
[
{ id: "", kind: "none" as const, label: "No assignee", searchText: "" },
...(currentUserId
? [{
id: `user:${currentUserId}`,
kind: "user" as const,
userId: currentUserId,
label: "Assign to me",
searchText: userLabel(currentUserId) ?? "",
}]
: []),
...(issue.createdByUserId && issue.createdByUserId !== currentUserId
? [{
id: `user:${issue.createdByUserId}`,
kind: "user" as const,
userId: issue.createdByUserId,
label: creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester",
searchText: creatorUserLabel ?? "requester",
}]
: []),
...otherUserOptions.map((option) => ({
id: option.id,
kind: "user" as const,
userId: option.id.slice("user:".length),
label: option.label,
searchText: option.searchText ?? "",
})),
...sortedAgents.map((agent) => ({
id: `agent:${agent.id}`,
kind: "agent" as const,
agent,
label: agent.name,
searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`,
})),
],
selectedAssigneeValue,
recentAssigneeValues,
);
const assigneeContent = (
<>
<input
@ -509,89 +568,40 @@ export function IssueProperties({
autoFocus={!inline}
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent"
)}
onClick={() => { onUpdate({ assigneeAgentId: null, assigneeUserId: null }); setAssigneeOpen(false); }}
>
No assignee
</button>
{currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: currentUserId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
Assign to me
</button>
)}
{issue.createdByUserId && issue.createdByUserId !== currentUserId && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === issue.createdByUserId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: issue.createdByUserId });
setAssigneeOpen(false);
}}
>
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester"}
</button>
)}
{otherUserOptions
{assigneePickerOptions
.filter((option) => {
if (!assigneeSearch.trim()) return true;
const q = assigneeSearch.toLowerCase();
return `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(q);
return `${option.label} ${option.searchText}`.toLowerCase().includes(q);
})
.map((option) => {
const userId = option.id.slice("user:".length);
return (
<button
key={option.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
issue.assigneeUserId === userId && "bg-accent",
)}
onClick={() => {
onUpdate({ assigneeAgentId: null, assigneeUserId: userId });
setAssigneeOpen(false);
}}
>
.map((option) => (
<button
key={option.id || "__none__"}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
option.id === selectedAssigneeValue && "bg-accent",
)}
onClick={() => {
if (option.kind === "agent") {
trackRecentAssignee(option.agent.id);
onUpdate({ assigneeAgentId: option.agent.id, assigneeUserId: null });
} else if (option.kind === "user") {
trackRecentAssigneeUser(option.userId);
onUpdate({ assigneeAgentId: null, assigneeUserId: option.userId });
} else {
onUpdate({ assigneeAgentId: null, assigneeUserId: null });
}
setAssigneeOpen(false);
}}
>
{option.kind === "agent" ? (
<AgentIcon icon={option.agent.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
) : option.kind === "user" ? (
<User className="h-3 w-3 shrink-0 text-muted-foreground" />
{option.label}
</button>
);
})}
{sortedAgents
.filter((a) => {
if (!assigneeSearch.trim()) return true;
const q = assigneeSearch.toLowerCase();
return a.name.toLowerCase().includes(q);
})
.map((a) => (
<button
key={a.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
a.id === issue.assigneeAgentId && "bg-accent"
)}
onClick={() => { trackRecentAssignee(a.id); onUpdate({ assigneeAgentId: a.id, assigneeUserId: null }); setAssigneeOpen(false); }}
>
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
{a.name}
</button>
))}
) : null}
{option.label}
</button>
))}
</div>
</>
);
@ -702,6 +712,20 @@ export function IssueProperties({
<span className="text-sm text-muted-foreground">No project</span>
</>
);
const projectPickerOptions = orderItemsBySelectedAndRecent(
[
{ id: "", kind: "none" as const, name: "No project", color: null as string | null },
...orderedProjects.map((project) => ({
id: project.id,
kind: "project" as const,
project,
name: project.name,
color: project.color ?? null,
})),
],
issue.projectId ?? "",
recentProjectIds,
);
const projectContent = (
<>
@ -713,58 +737,53 @@ export function IssueProperties({
autoFocus={!inline}
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
!issue.projectId && "bg-accent"
)}
onClick={() => {
onUpdate({
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
});
setProjectOpen(false);
}}
>
No project
</button>
{orderedProjects
.filter((p) => {
{projectPickerOptions
.filter((option) => {
if (!projectSearch.trim()) return true;
const q = projectSearch.toLowerCase();
return p.name.toLowerCase().includes(q);
return option.name.toLowerCase().includes(q);
})
.map((p) => (
<button
key={p.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
p.id === issue.projectId && "bg-accent"
)}
onClick={() => {
const defaultMode = defaultExecutionWorkspaceModeForProject(p);
onUpdate({
projectId: p.id,
projectWorkspaceId: defaultProjectWorkspaceIdForProject(p),
executionWorkspaceId: null,
executionWorkspacePreference: defaultMode,
executionWorkspaceSettings: p.executionWorkspacePolicy?.enabled
? { mode: defaultMode }
: null,
});
setProjectOpen(false);
}}
>
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: p.color ?? "#6366f1" }}
/>
{p.name}
</button>
))}
.map((option) => (
<button
key={option.id || "__none__"}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 whitespace-nowrap",
option.id === (issue.projectId ?? "") && "bg-accent",
)}
onClick={() => {
if (option.kind === "project") {
const defaultMode = defaultExecutionWorkspaceModeForProject(option.project);
trackRecentProject(option.project.id);
onUpdate({
projectId: option.project.id,
projectWorkspaceId: defaultProjectWorkspaceIdForProject(option.project),
executionWorkspaceId: null,
executionWorkspacePreference: defaultMode,
executionWorkspaceSettings: option.project.executionWorkspacePolicy?.enabled
? { mode: defaultMode }
: null,
});
} else {
onUpdate({
projectId: null,
projectWorkspaceId: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
});
}
setProjectOpen(false);
}}
>
{option.kind === "project" ? (
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: option.color ?? "#6366f1" }}
/>
) : null}
{option.name}
</button>
))}
</div>
</>
);