[codex] improve issue and routine UI responsiveness (#3744)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Operators rely on issue, inbox, and routine views to understand what
the company is doing in real time
> - Those views need to stay fast and readable even when issue lists,
markdown comments, and run metadata get large
> - The current branch had a coherent set of UI and live-update
improvements spread across issue search, issue detail rendering, routine
affordances, and workspace lookups
> - This pull request groups those board-facing changes into one
standalone branch that can merge independently of the heartbeat/runtime
work
> - The benefit is a faster, clearer issue and routine workflow without
changing the underlying task model

## What Changed

- Show routine execution issues by default and rename the filter to
`Hide routine runs` so the default state no longer looks like an active
filter.
- Show the routine name in the run dialog and tighten the issue
properties pane with a workspace link, copy-on-click behavior, and an
inline parent arrow.
- Reduce issue detail rerenders, keep queued issue chat mounted, improve
issues page search responsiveness, and speed up issues first paint.
- Add inbox "other search results", refresh visible issue runs after
status updates, and optimize workspace lookups through summary-mode
execution workspace queries.
- Improve markdown wrapping and scrolling behavior for long strings and
self-comment code blocks.
- Relax the markdown sanitizer assertion so the test still validates
safety after the new wrap-friendly inline styles.

## Verification

- `pnpm vitest run ui/src/components/IssuesList.test.tsx
ui/src/lib/inbox.test.ts ui/src/pages/Issues.test.tsx
ui/src/context/BreadcrumbContext.test.tsx
ui/src/context/LiveUpdatesProvider.test.ts
ui/src/components/MarkdownBody.test.tsx
ui/src/api/execution-workspaces.test.ts
server/src/__tests__/execution-workspaces-routes.test.ts`

## Risks

- This touches several issue-facing UI surfaces at once, so regressions
would most likely show up as stale rendering, search result mismatches,
or small markdown presentation differences.
- The workspace lookup optimization depends on the summary-mode route
shape staying aligned between server and UI.

## Model Used

- OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment.
Exact backend model deployment ID was not exposed in-session.
Tool-assisted editing and shell execution were used.

## 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 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-15 15:54:05 -05:00 committed by GitHub
parent 7463479fc8
commit d4c3899ca4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1035 additions and 241 deletions

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type Ref } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type Ref } from "react";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query";
@ -488,7 +488,10 @@ function InboxMobileToolbar({
type IssueDetailChatTabProps = {
issueId: string;
issue: Issue;
companyId: string;
projectId: string | null;
issueStatus: Issue["status"];
executionRunId: string | null;
comments: IssueDetailComment[];
hasOlderComments: boolean;
commentsLoadingOlder: boolean;
@ -519,9 +522,12 @@ type IssueDetailChatTabProps = {
onImageClick: (src: string) => void;
};
function IssueDetailChatTab({
const IssueDetailChatTab = memo(function IssueDetailChatTab({
issueId,
issue,
companyId,
projectId,
issueStatus,
executionRunId,
comments,
hasOlderComments,
commentsLoadingOlder,
@ -547,59 +553,62 @@ function IssueDetailChatTab({
interruptingQueuedRunId,
onImageClick,
}: IssueDetailChatTabProps) {
const { data: activity, isLoading: activityLoading } = useQuery({
const { data: activity } = useQuery({
queryKey: queryKeys.issues.activity(issueId),
queryFn: () => activityApi.forIssue(issueId),
placeholderData: keepPreviousDataForSameQueryTail<ActivityEvent[]>(issueId),
});
const { data: liveRuns, isLoading: liveRunsLoading } = useQuery({
const { data: liveRuns } = useQuery({
queryKey: queryKeys.issues.liveRuns(issueId),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
refetchInterval: 3000,
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
});
const liveRunCount = liveRuns?.length ?? 0;
const { data: activeRun, isLoading: activeRunLoading } = useQuery({
const resolvedLiveRuns = liveRuns ?? [];
const liveRunCount = resolvedLiveRuns.length;
const { data: activeRun = null } = useQuery({
queryKey: queryKeys.issues.activeRun(issueId),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
enabled: !!issue.executionRunId || issue.status === "in_progress",
enabled: !!executionRunId || issueStatus === "in_progress",
refetchInterval: liveRunCount > 0 ? false : 3000,
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(issueId),
});
const hasLiveRuns = liveRunCount > 0 || !!activeRun;
const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({
const { data: linkedRuns } = useQuery({
queryKey: queryKeys.issues.runs(issueId),
queryFn: () => activityApi.runsForIssue(issueId),
refetchInterval: hasLiveRuns ? 5000 : false,
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
});
const resolvedActivity = activity ?? [];
const resolvedLinkedRuns = linkedRuns ?? [];
const runningIssueRun = useMemo(
() => resolveRunningIssueRun(activeRun, liveRuns),
[activeRun, liveRuns],
() => resolveRunningIssueRun(activeRun, resolvedLiveRuns),
[activeRun, resolvedLiveRuns],
);
const timelineRuns = useMemo(() => {
const liveIds = new Set<string>();
for (const run of liveRuns ?? []) liveIds.add(run.id);
for (const run of resolvedLiveRuns) liveIds.add(run.id);
if (activeRun) liveIds.add(activeRun.id);
const historicalRuns = liveIds.size === 0
? (linkedRuns ?? [])
: (linkedRuns ?? []).filter((run) => !liveIds.has(run.runId));
? resolvedLinkedRuns
: resolvedLinkedRuns.filter((run) => !liveIds.has(run.runId));
return historicalRuns.map((run) => ({
...run,
adapterType: run.adapterType,
hasStoredOutput: (run.logBytes ?? 0) > 0,
}));
}, [activeRun, linkedRuns, liveRuns]);
}, [activeRun, resolvedLinkedRuns, resolvedLiveRuns]);
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
const agentIdByRunId = new Map<string, string>();
for (const run of linkedRuns ?? []) {
for (const run of resolvedLinkedRuns) {
agentIdByRunId.set(run.runId, run.agentId);
}
for (const evt of activity ?? []) {
for (const evt of resolvedActivity) {
if (evt.action !== "issue.comment_added" || !evt.runId) continue;
const details = evt.details ?? {};
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
@ -633,20 +642,11 @@ function IssueDetailChatTab({
}
return nextComment;
});
}, [activity, comments, linkedRuns, runningIssueRun]);
}, [comments, resolvedActivity, resolvedLinkedRuns, runningIssueRun]);
const timelineEvents = useMemo(
() => extractIssueTimelineEvents(activity),
[activity],
() => extractIssueTimelineEvents(resolvedActivity),
[resolvedActivity],
);
const initialLoading =
(activityLoading && activity === undefined)
|| (linkedRunsLoading && linkedRuns === undefined)
|| (liveRunsLoading && liveRuns === undefined)
|| (activeRunLoading && activeRun === undefined);
if (initialLoading) {
return <IssueChatSkeleton />;
}
return (
<div className="space-y-3">
@ -671,11 +671,11 @@ function IssueDetailChatTab({
feedbackTermsUrl={feedbackTermsUrl}
linkedRuns={timelineRuns}
timelineEvents={timelineEvents}
liveRuns={liveRuns}
liveRuns={resolvedLiveRuns}
activeRun={activeRun}
companyId={issue.companyId}
projectId={issue.projectId}
issueStatus={issue.status}
companyId={companyId}
projectId={projectId}
issueStatus={issueStatus}
agentMap={agentMap}
currentUserId={currentUserId}
draftKey={draftKey}
@ -703,7 +703,7 @@ function IssueDetailChatTab({
/>
</div>
);
}
});
type IssueDetailActivityTabProps = {
issueId: string;
@ -1060,6 +1060,14 @@ export function IssueDetail() {
() => buildIssuePropertiesPanelKey(issue ?? null, childIssues),
[childIssues, issue],
);
const panelIssue = useMemo(
() => issue ?? null,
[issue?.id, issuePanelKey],
);
const panelChildIssues = useMemo(
() => childIssues,
[issuePanelKey],
);
const showRichSubIssuesSection = shouldRenderRichSubIssuesSection(childIssuesLoading, childIssues.length);
const openNewSubIssue = useCallback(() => {
if (!issue) return;
@ -1103,6 +1111,7 @@ export function IssueDetail() {
() => mergeIssueComments(comments ?? [], optimisticComments),
[comments, optimisticComments],
);
const breadcrumbTitle = issue?.title ?? issueId ?? "Issue";
const invalidateIssueDetail = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
@ -1743,12 +1752,17 @@ export function IssueDetail() {
});
useEffect(() => {
const titleLabel = issue?.title ?? issueId ?? "Issue";
setBreadcrumbs([
sourceBreadcrumb,
{ label: hasLiveRuns ? `🔵 ${titleLabel}` : titleLabel },
{ label: hasLiveRuns ? `🔵 ${breadcrumbTitle}` : breadcrumbTitle },
]);
}, [setBreadcrumbs, sourceBreadcrumb, issue, issueId, hasLiveRuns]);
}, [
breadcrumbTitle,
hasLiveRuns,
setBreadcrumbs,
sourceBreadcrumb.href,
sourceBreadcrumb.label,
]);
const isFromInbox = resolvedIssueDetailState?.issueDetailSource === "inbox";
@ -1790,20 +1804,28 @@ export function IssueDetail() {
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!issue) {
if (!panelIssue) {
closePanel();
return;
}
openPanel(
<IssueProperties
issue={issue}
childIssues={childIssues}
issue={panelIssue}
childIssues={panelChildIssues}
onAddSubIssue={openNewSubIssue}
onUpdate={handleIssuePropertiesUpdate}
/>
);
return () => closePanel();
}, [closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel]);
}, [
closePanel,
handleIssuePropertiesUpdate,
issuePanelKey,
openNewSubIssue,
openPanel,
panelChildIssues,
panelIssue,
]);
const goToInboxShortcutArmedRef = useRef(false);
const goToInboxShortcutTimeoutRef = useRef<number | null>(null);
@ -2032,6 +2054,36 @@ export function IssueDetail() {
}, [showInboxToolbar, backHref, issue?.id, issueHidden, archivePending, setMobileToolbar]);
const attachmentsInitialLoading = attachmentsLoading && attachments === undefined;
const loadOlderComments = useCallback(() => {
void fetchOlderComments();
}, [fetchOlderComments]);
const handleCommentVote = useCallback(async (commentId: string, vote: "up" | "down", options?: { allowSharing?: boolean; reason?: string }) => {
await feedbackVoteMutation.mutateAsync({
targetType: "issue_comment",
targetId: commentId,
vote,
reason: options?.reason,
allowSharing: options?.allowSharing,
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
}, [feedbackDataSharingPreference, feedbackVoteMutation]);
const handleChatAdd = useCallback(async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
return;
}
await addComment.mutateAsync({ body, reopen });
}, [addComment, addCommentAndReassign]);
const handleCommentImageUpload = useCallback(async (file: File) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}, [uploadAttachment]);
const handleCommentAttachImage = useCallback(async (file: File) => {
await uploadAttachment.mutateAsync(file);
}, [uploadAttachment]);
const handleInterruptQueuedRun = useCallback(async (runId: string) => {
await interruptQueuedComment.mutateAsync(runId);
}, [interruptQueuedComment]);
if (isLoading) return <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
@ -2557,13 +2609,14 @@ export function IssueDetail() {
{detailTab === "chat" ? (
<IssueDetailChatTab
issueId={issue.id}
issue={issue}
companyId={issue.companyId}
projectId={issue.projectId ?? null}
issueStatus={issue.status}
executionRunId={issue.executionRunId ?? null}
comments={threadComments}
hasOlderComments={hasOlderComments}
commentsLoadingOlder={commentsLoadingOlder}
onLoadOlderComments={() => {
void fetchOlderComments();
}}
onLoadOlderComments={loadOlderComments}
composerRef={commentComposerRef}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
@ -2576,33 +2629,11 @@ export function IssueDetail() {
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions}
composerDisabledReason={commentComposerDisabledReason}
onVote={async (commentId, vote, options) => {
await feedbackVoteMutation.mutateAsync({
targetType: "issue_comment",
targetId: commentId,
vote,
reason: options?.reason,
allowSharing: options?.allowSharing,
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
});
}}
onAdd={async (body, reopen, reassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
return;
}
await addComment.mutateAsync({ body, reopen });
}}
onImageUpload={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
onAttachImage={async (file) => {
await uploadAttachment.mutateAsync(file);
}}
onInterruptQueued={async (runId) => {
await interruptQueuedComment.mutateAsync(runId);
}}
onVote={handleCommentVote}
onAdd={handleChatAdd}
onImageUpload={handleCommentImageUpload}
onAttachImage={handleCommentAttachImage}
onInterruptQueued={handleInterruptQueuedRun}
onCancelQueued={handleCancelQueuedComment}
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
onImageClick={handleChatImageClick}