mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-20 04:20: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
|
|
@ -23,6 +23,7 @@ import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUs
|
|||
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
|
||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||
import {
|
||||
hasLegacyIssueDetailQuery,
|
||||
createIssueDetailPath,
|
||||
|
|
@ -42,6 +43,7 @@ import {
|
|||
applyOptimisticIssueFieldUpdate,
|
||||
applyOptimisticIssueFieldUpdateToCollection,
|
||||
applyOptimisticIssueCommentUpdate,
|
||||
applyLocalQueuedIssueCommentState,
|
||||
createOptimisticIssueComment,
|
||||
flattenIssueCommentPages,
|
||||
getNextIssueCommentPageParam,
|
||||
|
|
@ -54,7 +56,7 @@ import {
|
|||
type IssueCommentReassignment,
|
||||
type OptimisticIssueComment,
|
||||
} from "../lib/optimistic-issue-comments";
|
||||
import { removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
|
||||
import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
|
|
@ -504,7 +506,9 @@ type IssueDetailChatTabProps = {
|
|||
projectId: string | null;
|
||||
issueStatus: Issue["status"];
|
||||
executionRunId: string | null;
|
||||
blockedBy: Issue["blockedBy"];
|
||||
comments: IssueDetailComment[];
|
||||
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
|
||||
hasOlderComments: boolean;
|
||||
commentsLoadingOlder: boolean;
|
||||
onLoadOlderComments: () => void;
|
||||
|
|
@ -542,7 +546,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
projectId,
|
||||
issueStatus,
|
||||
executionRunId,
|
||||
blockedBy,
|
||||
comments,
|
||||
locallyQueuedCommentRunIds,
|
||||
hasOlderComments,
|
||||
commentsLoadingOlder,
|
||||
onLoadOlderComments,
|
||||
|
|
@ -645,6 +651,14 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
return comments.map((comment) => {
|
||||
const meta = runMetaByCommentId.get(comment.id);
|
||||
const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment };
|
||||
const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, {
|
||||
queuedTargetRunId: locallyQueuedCommentRunIds.get(comment.id) ?? null,
|
||||
hasLiveRuns,
|
||||
runningRunId: runningIssueRun?.id ?? null,
|
||||
});
|
||||
if (locallyQueuedComment !== nextComment) {
|
||||
return locallyQueuedComment;
|
||||
}
|
||||
if (
|
||||
isQueuedIssueComment({
|
||||
comment: nextComment,
|
||||
|
|
@ -662,7 +676,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
}
|
||||
return nextComment;
|
||||
});
|
||||
}, [comments, resolvedActivity, resolvedLinkedRuns, runningIssueRun]);
|
||||
}, [comments, hasLiveRuns, locallyQueuedCommentRunIds, resolvedActivity, resolvedLinkedRuns, runningIssueRun]);
|
||||
const timelineEvents = useMemo(
|
||||
() => extractIssueTimelineEvents(resolvedActivity),
|
||||
[resolvedActivity],
|
||||
|
|
@ -693,6 +707,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
timelineEvents={timelineEvents}
|
||||
liveRuns={resolvedLiveRuns}
|
||||
activeRun={resolvedActiveRun}
|
||||
blockedBy={blockedBy ?? []}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
issueStatus={issueStatus}
|
||||
|
|
@ -931,6 +946,7 @@ export function IssueDetail() {
|
|||
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||
const [galleryIndex, setGalleryIndex] = useState(0);
|
||||
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
|
||||
const [locallyQueuedCommentRunIds, setLocallyQueuedCommentRunIds] = useState<Map<string, string>>(() => new Map());
|
||||
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||
|
|
@ -1013,6 +1029,11 @@ export function IssueDetail() {
|
|||
});
|
||||
const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun;
|
||||
const hasLiveRuns = liveRunCount > 0 || resolvedHasActiveRun;
|
||||
useEffect(() => {
|
||||
if (!hasLiveRuns && locallyQueuedCommentRunIds.size > 0) {
|
||||
setLocallyQueuedCommentRunIds(new Map());
|
||||
}
|
||||
}, [hasLiveRuns, locallyQueuedCommentRunIds.size]);
|
||||
const sourceBreadcrumb = useMemo(
|
||||
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
||||
[issueId, location.state, location.search],
|
||||
|
|
@ -1027,6 +1048,13 @@ export function IssueDetail() {
|
|||
enabled: !!resolvedCompanyId && !!issue?.id,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<Issue[]>(issue?.id ?? "pending"),
|
||||
});
|
||||
const { data: companyLiveRuns } = useQuery({
|
||||
queryKey: resolvedCompanyId ? queryKeys.liveRuns(resolvedCompanyId) : ["live-runs", "pending"],
|
||||
queryFn: () => heartbeatsApi.liveRunsForCompany(resolvedCompanyId!),
|
||||
enabled: !!resolvedCompanyId,
|
||||
refetchInterval: 5000,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(resolvedCompanyId ?? "pending"),
|
||||
});
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
|
|
@ -1113,6 +1141,7 @@ export function IssueDetail() {
|
|||
() => [...rawChildIssues].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
|
||||
[rawChildIssues],
|
||||
);
|
||||
const liveIssueIds = useMemo(() => collectLiveIssueIds(companyLiveRuns), [companyLiveRuns]);
|
||||
const issuePanelKey = useMemo(
|
||||
() => buildIssuePropertiesPanelKey(issue ?? null, childIssues),
|
||||
[childIssues, issue],
|
||||
|
|
@ -1393,6 +1422,7 @@ export function IssueDetail() {
|
|||
|
||||
return {
|
||||
optimisticCommentId: optimisticComment?.clientId ?? null,
|
||||
queuedCommentTargetRunId: queuedComment?.id ?? null,
|
||||
previousIssue,
|
||||
};
|
||||
},
|
||||
|
|
@ -1418,6 +1448,13 @@ export function IssueDetail() {
|
|||
});
|
||||
}
|
||||
}
|
||||
if (context?.queuedCommentTargetRunId) {
|
||||
setLocallyQueuedCommentRunIds((current) => {
|
||||
const next = new Map(current);
|
||||
next.set(comment.id, context.queuedCommentTargetRunId!);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
|
||||
queryKeys.issues.comments(issueId!),
|
||||
(current) => current ? {
|
||||
|
|
@ -1503,6 +1540,7 @@ export function IssueDetail() {
|
|||
|
||||
return {
|
||||
optimisticCommentId: optimisticComment?.clientId ?? null,
|
||||
queuedCommentTargetRunId: queuedComment?.id ?? null,
|
||||
previousIssue,
|
||||
};
|
||||
},
|
||||
|
|
@ -1531,6 +1569,13 @@ export function IssueDetail() {
|
|||
});
|
||||
}
|
||||
}
|
||||
if (comment && context?.queuedCommentTargetRunId) {
|
||||
setLocallyQueuedCommentRunIds((current) => {
|
||||
const next = new Map(current);
|
||||
next.set(comment.id, context.queuedCommentTargetRunId!);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
if (comment) {
|
||||
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
|
||||
queryKeys.issues.comments(issueId!),
|
||||
|
|
@ -1574,10 +1619,12 @@ export function IssueDetail() {
|
|||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(issueId!) });
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
|
||||
const previousRuns = queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(issueId!));
|
||||
const previousLiveRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueId!));
|
||||
const previousActiveRun = queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(issueId!));
|
||||
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
||||
const liveRunList = previousLiveRuns ?? [];
|
||||
const cachedActiveRun = previousActiveRun ?? null;
|
||||
const runningIssueRun = resolveRunningIssueRun(cachedActiveRun, liveRunList);
|
||||
|
|
@ -1602,11 +1649,16 @@ export function IssueDetail() {
|
|||
queryKeys.issues.activeRun(issueId!),
|
||||
(current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current),
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.detail(issueId!),
|
||||
(current: Issue | undefined) => clearIssueExecutionRun(current, runId),
|
||||
);
|
||||
|
||||
return {
|
||||
previousRuns,
|
||||
previousLiveRuns,
|
||||
previousActiveRun,
|
||||
previousIssue,
|
||||
};
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
|
@ -1622,6 +1674,7 @@ export function IssueDetail() {
|
|||
queryClient.setQueryData(queryKeys.issues.runs(issueId!), context?.previousRuns);
|
||||
queryClient.setQueryData(queryKeys.issues.liveRuns(issueId!), context?.previousLiveRuns);
|
||||
queryClient.setQueryData(queryKeys.issues.activeRun(issueId!), context?.previousActiveRun);
|
||||
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context?.previousIssue);
|
||||
pushToast({
|
||||
title: "Interrupt failed",
|
||||
body: err instanceof Error ? err.message : "Unable to interrupt the active run",
|
||||
|
|
@ -1633,6 +1686,12 @@ export function IssueDetail() {
|
|||
const cancelQueuedComment = useMutation({
|
||||
mutationFn: async ({ commentId }: { commentId: string }) => issuesApi.cancelComment(issueId!, commentId),
|
||||
onSuccess: (comment) => {
|
||||
setLocallyQueuedCommentRunIds((current) => {
|
||||
if (!current.has(comment.id)) return current;
|
||||
const next = new Map(current);
|
||||
next.delete(comment.id);
|
||||
return next;
|
||||
});
|
||||
removeCommentFromCache(comment.id);
|
||||
restoreQueuedCommentDraft(comment.body);
|
||||
invalidateIssueDetail();
|
||||
|
|
@ -2481,6 +2540,7 @@ export function IssueDetail() {
|
|||
isLoading={childIssuesLoading}
|
||||
agents={agents}
|
||||
projects={projects}
|
||||
liveIssueIds={liveIssueIds}
|
||||
projectId={issue.projectId ?? undefined}
|
||||
viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`}
|
||||
issueLinkState={resolvedIssueDetailState ?? location.state}
|
||||
|
|
@ -2699,7 +2759,9 @@ export function IssueDetail() {
|
|||
projectId={issue.projectId ?? null}
|
||||
issueStatus={issue.status}
|
||||
executionRunId={issue.executionRunId ?? null}
|
||||
blockedBy={issue.blockedBy ?? []}
|
||||
comments={threadComments}
|
||||
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
|
||||
hasOlderComments={hasOlderComments}
|
||||
commentsLoadingOlder={commentsLoadingOlder}
|
||||
onLoadOlderComments={loadOlderComments}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue