mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
[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:
parent
fee514efcb
commit
057fee4836
19 changed files with 596 additions and 275 deletions
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue