mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 04:00:38 +09:00
[codex] Polish issue board workflows (#4224)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Human operators supervise that work through issue lists, issue detail, comments, inbox groups, markdown references, and profile/activity surfaces > - The branch had many small UI fixes that improve the operator loop but do not need to ship with backend runtime migrations > - These changes belong together as board workflow polish because they affect scanning, navigation, issue context, comment state, and markdown clarity > - This pull request groups the UI-only slice so it can merge independently from runtime/backend changes > - The benefit is a clearer board experience with better issue context, steadier optimistic updates, and more predictable keyboard navigation ## What Changed - Improves issue properties, sub-issue actions, blocker chips, and issue list/detail refresh behavior. - Adds blocker context above the issue composer and stabilizes queued/interrupted comment UI state. - Improves markdown issue/GitHub link rendering and opens external markdown links in a new tab. - Adds inbox group keyboard navigation and fold/unfold support. - Polishes activity/avatar/profile/settings/workspace presentation details. ## Verification - `pnpm exec vitest run ui/src/components/IssueProperties.test.tsx ui/src/components/IssueChatThread.test.tsx ui/src/components/MarkdownBody.test.tsx ui/src/lib/inbox.test.ts ui/src/lib/optimistic-issue-comments.test.ts` ## Risks - Low to medium risk: changes are UI-focused but cover high-traffic issue and inbox surfaces. - This branch intentionally does not include the backend runtime changes from the companion PR; where UI calls newer API filters, unsupported servers should continue to fail visibly through existing API error handling. - Visual screenshots were not captured in this heartbeat; targeted component/helper tests cover the changed behavior. > 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-based coding agent runtime, shell/git tool use enabled. Exact hosted model build and context window are not exposed in this Paperclip heartbeat environment. ## 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 - [ ] 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
This commit is contained in:
parent
09d0678840
commit
a26e1288b6
40 changed files with 1218 additions and 132 deletions
|
|
@ -1,14 +1,16 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { Link } from "@/lib/router";
|
||||
import type { Issue, Project, WorkspaceRuntimeService } from "@paperclipai/shared";
|
||||
import type { Issue, IssueLabel, IssueRelationIssueSummary, Project, WorkspaceRuntimeService } from "@paperclipai/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { resolveIssueFilterWorkspaceId } from "../lib/issue-filters";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
|
|
@ -110,6 +112,12 @@ function runningRuntimeServiceWithUrl(
|
|||
return runtimeServices?.find((service) => service.status === "running" && service.url?.trim()) ?? null;
|
||||
}
|
||||
|
||||
function issuesWorkspaceFilterHref(workspaceId: string) {
|
||||
const params = new URLSearchParams();
|
||||
params.append("workspace", workspaceId);
|
||||
return `/issues?${params.toString()}`;
|
||||
}
|
||||
|
||||
interface IssuePropertiesProps {
|
||||
issue: Issue;
|
||||
childIssues?: Issue[];
|
||||
|
|
@ -189,6 +197,21 @@ function PropertyPicker({
|
|||
);
|
||||
}
|
||||
|
||||
function IssuePillLink({
|
||||
issue,
|
||||
}: {
|
||||
issue: Pick<Issue, "id" | "identifier" | "title"> | IssueRelationIssueSummary;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
className="inline-flex max-w-full items-center rounded-full border border-border px-2 py-0.5 text-xs hover:bg-accent/50"
|
||||
>
|
||||
<span className="truncate">{issue.identifier ?? issue.title}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueProperties({
|
||||
issue,
|
||||
childIssues = [],
|
||||
|
|
@ -232,6 +255,11 @@ export function IssueProperties({
|
|||
queryFn: () => accessApi.listUserDirectory(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
const { data: experimentalSettings } = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: queryKeys.projects.list(companyId!),
|
||||
|
|
@ -263,8 +291,16 @@ export function IssueProperties({
|
|||
const createLabel = useMutation({
|
||||
mutationFn: (data: { name: string; color: string }) => issuesApi.createLabel(companyId!, data),
|
||||
onSuccess: async (created) => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
|
||||
queryClient.setQueryData<IssueLabel[] | undefined>(
|
||||
queryKeys.issues.labels(companyId!),
|
||||
(current) => {
|
||||
if (!current) return [created];
|
||||
if (current.some((label) => label.id === created.id)) return current;
|
||||
return [...current, created];
|
||||
},
|
||||
);
|
||||
onUpdate({ labelIds: [...(issue.labelIds ?? []), created.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) });
|
||||
setNewLabelName("");
|
||||
},
|
||||
});
|
||||
|
|
@ -292,10 +328,21 @@ export function IssueProperties({
|
|||
? orderedProjects.find((project) => project.id === issue.projectId) ?? null
|
||||
: null;
|
||||
const issueProject = issue.project ?? currentProject;
|
||||
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
const issueUsesMainWorkspace = useMemo(
|
||||
() => isMainIssueWorkspace({ issue, project: issueProject }),
|
||||
[issue, issueProject],
|
||||
);
|
||||
const workspaceFilterId = useMemo(() => {
|
||||
if (!isolatedWorkspacesEnabled) return null;
|
||||
if (issueUsesMainWorkspace) return null;
|
||||
return resolveIssueFilterWorkspaceId(issue);
|
||||
}, [isolatedWorkspacesEnabled, issue, issueUsesMainWorkspace]);
|
||||
const showWorkspaceDetailLink = Boolean(issue.executionWorkspaceId) && !issueUsesMainWorkspace;
|
||||
const liveWorkspaceService = useMemo(() => {
|
||||
if (isMainIssueWorkspace({ issue, project: issueProject })) return null;
|
||||
if (issueUsesMainWorkspace) return null;
|
||||
return runningRuntimeServiceWithUrl(issue.currentExecutionWorkspace?.runtimeServices);
|
||||
}, [issue, issueProject]);
|
||||
}, [issue.currentExecutionWorkspace?.runtimeServices, issueUsesMainWorkspace]);
|
||||
const referencedIssueIdentifiers = issue.referencedIssueIdentifiers ?? [];
|
||||
const relatedTasks = useMemo(() => {
|
||||
const excluded = new Set<string>();
|
||||
|
|
@ -427,10 +474,22 @@ export function IssueProperties({
|
|||
}
|
||||
return `${stageLabel} pending${participantLabel ? ` with ${participantLabel}` : ""}`;
|
||||
})();
|
||||
const selectedIssueLabels = useMemo(() => {
|
||||
const selectedIds = issue.labelIds ?? [];
|
||||
if (selectedIds.length === 0) return issue.labels ?? [];
|
||||
|
||||
const labelsTrigger = (issue.labels ?? []).length > 0 ? (
|
||||
const labelById = new Map<string, IssueLabel>();
|
||||
for (const label of labels ?? []) labelById.set(label.id, label);
|
||||
for (const label of issue.labels ?? []) labelById.set(label.id, label);
|
||||
|
||||
return selectedIds
|
||||
.map((id) => labelById.get(id))
|
||||
.filter((label): label is IssueLabel => Boolean(label));
|
||||
}, [issue.labelIds, issue.labels, labels]);
|
||||
|
||||
const labelsTrigger = selectedIssueLabels.length > 0 ? (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
||||
{selectedIssueLabels.slice(0, 3).map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium border"
|
||||
|
|
@ -443,8 +502,8 @@ export function IssueProperties({
|
|||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{(issue.labels ?? []).length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
|
||||
{selectedIssueLabels.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">+{selectedIssueLabels.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -492,7 +551,8 @@ export function IssueProperties({
|
|||
onClick={() => toggleLabel(label.id)}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: label.color }} />
|
||||
<span className="truncate">{label.name}</span>
|
||||
<span className="truncate flex-1">{label.name}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 shrink-0 text-foreground" aria-hidden="true" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -917,21 +977,6 @@ export function IssueProperties({
|
|||
</div>
|
||||
</>
|
||||
);
|
||||
const blockedByTrigger = blockedByIds.length > 0 ? (
|
||||
<div className="flex items-center gap-1 flex-wrap min-w-0">
|
||||
{(issue.blockedBy ?? []).slice(0, 2).map((relation) => (
|
||||
<span key={relation.id} className="inline-flex max-w-full items-center rounded-full border border-border px-2 py-0.5 text-xs">
|
||||
<span className="truncate">{relation.identifier ?? relation.title}</span>
|
||||
</span>
|
||||
))}
|
||||
{(issue.blockedBy ?? []).length > 2 && (
|
||||
<span className="text-xs text-muted-foreground">+{(issue.blockedBy ?? []).length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">No blockers</span>
|
||||
);
|
||||
|
||||
const blockingIssues = issue.blocks ?? [];
|
||||
const blockerOptions = (allIssues ?? [])
|
||||
.filter((candidate) => candidate.id !== issue.id)
|
||||
|
|
@ -997,6 +1042,16 @@ export function IssueProperties({
|
|||
</div>
|
||||
</>
|
||||
);
|
||||
const renderAddBlockedByButton = (onClick?: () => void) => (
|
||||
<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}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add blocker
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -1087,32 +1142,47 @@ export function IssueProperties({
|
|||
{parentContent}
|
||||
</PropertyPicker>
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Blocked by"
|
||||
open={blockedByOpen}
|
||||
onOpenChange={(open) => {
|
||||
setBlockedByOpen(open);
|
||||
if (!open) setBlockedBySearch("");
|
||||
}}
|
||||
triggerContent={blockedByTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName="w-72"
|
||||
>
|
||||
{blockedByContent}
|
||||
</PropertyPicker>
|
||||
{inline ? (
|
||||
<div>
|
||||
<PropertyRow label="Blocked by">
|
||||
{(issue.blockedBy ?? []).map((relation) => (
|
||||
<IssuePillLink key={relation.id} issue={relation} />
|
||||
))}
|
||||
{renderAddBlockedByButton(() => setBlockedByOpen((open) => !open))}
|
||||
</PropertyRow>
|
||||
{blockedByOpen && (
|
||||
<div className="rounded-md border border-border bg-popover p-1 mb-2">
|
||||
{blockedByContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<PropertyRow label="Blocked by">
|
||||
{(issue.blockedBy ?? []).map((relation) => (
|
||||
<IssuePillLink key={relation.id} issue={relation} />
|
||||
))}
|
||||
<Popover
|
||||
open={blockedByOpen}
|
||||
onOpenChange={(open) => {
|
||||
setBlockedByOpen(open);
|
||||
if (!open) setBlockedBySearch("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
{renderAddBlockedByButton()}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-1" align="end" collisionPadding={16}>
|
||||
{blockedByContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
<PropertyRow label="Blocking">
|
||||
{blockingIssues.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{blockingIssues.map((relation) => (
|
||||
<Link
|
||||
key={relation.id}
|
||||
to={`/issues/${relation.identifier ?? relation.id}`}
|
||||
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs hover:bg-accent/50"
|
||||
>
|
||||
{relation.identifier ?? relation.title}
|
||||
</Link>
|
||||
<IssuePillLink key={relation.id} issue={relation} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -1122,13 +1192,7 @@ export function IssueProperties({
|
|||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{childIssues.length > 0
|
||||
? childIssues.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={`/issues/${child.identifier ?? child.id}`}
|
||||
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs hover:bg-accent/50"
|
||||
>
|
||||
{child.identifier ?? child.title}
|
||||
</Link>
|
||||
<IssuePillLink key={child.id} issue={child} />
|
||||
))
|
||||
: null}
|
||||
{onAddSubIssue ? (
|
||||
|
|
@ -1222,7 +1286,7 @@ export function IssueProperties({
|
|||
</a>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.executionWorkspaceId && (
|
||||
{showWorkspaceDetailLink && issue.executionWorkspaceId && (
|
||||
<PropertyRow label="Workspace">
|
||||
<Link
|
||||
to={`/execution-workspaces/${issue.executionWorkspaceId}`}
|
||||
|
|
@ -1233,6 +1297,17 @@ export function IssueProperties({
|
|||
</Link>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{workspaceFilterId && (
|
||||
<PropertyRow label="Tasks">
|
||||
<Link
|
||||
to={issuesWorkspaceFilterHref(workspaceFilterId)}
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
View workspace tasks
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</PropertyRow>
|
||||
)}
|
||||
{issue.currentExecutionWorkspace?.branchName && (
|
||||
<PropertyRow label="Branch">
|
||||
<TruncatedCopyable
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue