[codex] Improve issue detail and issue-list UX (#3678)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - A core part of that is the operator experience around reading issue
state, agent chat, and sub-task structure
> - The current branch had a long run of issue-detail and issue-list UX
fixes that all improve how humans follow and steer active work
> - Those changes mostly live in the UI/chat surface and should be
reviewed together instead of mixed with workspace/runtime work
> - This pull request packages the issue-detail, chat, markdown, and
sub-issue list improvements into one standalone change
> - The benefit is a cleaner, less jumpy, more reliable issue workflow
on desktop and mobile without coupling it to unrelated server/runtime
refactors

## What Changed

- Stabilized issue chat runtime wiring, optimistic comment handling,
queued-comment cancellation, and composer anchoring during live updates
- Fixed several issue-detail rendering and navigation regressions
including placeholder bleed, local polling scope, mobile inbox-to-issue
transitions, and visible refresh resets
- Improved markdown and rich-content handling with advisory image
normalization, editor fallback behavior, touch mention recovery, and
`issue:` quicklook links
- Refined sub-issue behavior with parent-derived defaults, current-user
inheritance fixes, empty-state cleanup, and a reusable issue-list
presentation for sub-issues
- Added targeted UI tests for the new issue-detail, chat scroll/message,
placeholder-data, markdown, and issue-list behaviors

## Verification

- `pnpm vitest run ui/src/components/IssueChatThread.test.tsx
ui/src/components/MarkdownEditor.test.tsx
ui/src/components/IssuesList.test.tsx
ui/src/context/LiveUpdatesProvider.test.tsx
ui/src/lib/issue-chat-messages.test.ts
ui/src/lib/issue-chat-scroll.test.ts
ui/src/lib/issue-detail-subissues.test.ts
ui/src/lib/query-placeholder-data.test.tsx
ui/src/hooks/usePaperclipIssueRuntime.test.tsx`

## Risks

- Medium: this branch touches the highest-traffic issue-detail UI paths,
so regressions would show up as chat/thread or sub-issue UX glitches
- The changes are UI-heavy and would benefit from reviewer screenshots
or a quick manual browser pass before merge

## Model Used

- OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact
deployed model ID is not exposed in this environment), reasoning
enabled, tool use and local code execution enabled

## 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
- [ ] 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-14 12:50:48 -05:00 committed by GitHub
parent 5d1ed71779
commit 6e6f538630
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 4141 additions and 590 deletions

View file

@ -15,6 +15,7 @@ import {
useContext,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
@ -36,8 +37,10 @@ import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from
import {
buildIssueChatMessages,
formatDurationWords,
stabilizeThreadMessages,
type IssueChatComment,
type IssueChatLinkedRun,
type StableThreadMessageCacheEntry,
type IssueChatTranscriptEntry,
type SegmentTiming,
} from "../lib/issue-chat-messages";
@ -65,6 +68,11 @@ import { Identity } from "./Identity";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { AgentIcon } from "./AgentIconPicker";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import {
captureComposerViewportSnapshot,
restoreComposerViewportSnapshot,
shouldPreserveComposerViewport,
} from "../lib/issue-chat-scroll";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { timeAgo } from "../lib/timeAgo";
import {
@ -80,7 +88,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react";
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, Square, ThumbsDown, ThumbsUp } from "lucide-react";
interface IssueChatMessageContext {
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
@ -88,12 +96,16 @@ interface IssueChatMessageContext {
feedbackTermsUrl: string | null;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
activeRunIds: ReadonlySet<string>;
onVote?: (
commentId: string,
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
onStopRun?: (runId: string) => Promise<void>;
stoppingRunId?: string | null;
onInterruptQueued?: (runId: string) => Promise<void>;
onCancelQueued?: (commentId: string) => void;
interruptingQueuedRunId?: string | null;
onImageClick?: (src: string) => void;
}
@ -102,6 +114,7 @@ const IssueChatCtx = createContext<IssueChatMessageContext>({
feedbackVoteByTargetId: new Map(),
feedbackDataSharingPreference: "prompt",
feedbackTermsUrl: null,
activeRunIds: new Set<string>(),
});
export function resolveAssistantMessageFoldedState(args: {
@ -125,6 +138,17 @@ export function resolveAssistantMessageFoldedState(args: {
return currentFolded;
}
export function canStopIssueChatRun(args: {
runId: string | null;
runStatus: string | null;
activeRunIds: ReadonlySet<string>;
}) {
const { runId, runStatus, activeRunIds } = args;
if (!runId) return false;
if (activeRunIds.has(runId)) return true;
return runStatus === "queued" || runStatus === "running";
}
function findCoTSegmentIndex(
messageParts: ReadonlyArray<{ type: string }>,
cotParts: ReadonlyArray<{ type: string }>,
@ -162,6 +186,7 @@ interface CommentReassignment {
export interface IssueChatComposerHandle {
focus: () => void;
restoreDraft: (submittedBody: string) => void;
}
interface IssueChatComposerProps {
@ -199,6 +224,7 @@ interface IssueChatThreadProps {
) => Promise<void>;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
onCancelRun?: () => Promise<void>;
onStopRun?: (runId: string) => Promise<void>;
imageUploadHandler?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<void>;
draftKey?: string;
@ -217,7 +243,9 @@ interface IssueChatThreadProps {
hasOutputForRun?: (runId: string) => boolean;
includeSucceededRunsWithoutOutput?: boolean;
onInterruptQueued?: (runId: string) => Promise<void>;
onCancelQueued?: (commentId: string) => void;
interruptingQueuedRunId?: string | null;
stoppingRunId?: string | null;
onImageClick?: (src: string) => void;
composerRef?: Ref<IssueChatComposerHandle>;
}
@ -412,6 +440,11 @@ function parseReassignment(target: string): PaperclipIssueRuntimeReassignment |
return null;
}
function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) {
const isClosed = issueStatus === "done" || issueStatus === "cancelled";
return isClosed && assigneeValue.startsWith("agent:");
}
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
function commentDateLabel(date: Date | string | undefined): string {
@ -873,10 +906,11 @@ function IssueChatToolPart({
}
function IssueChatUserMessage() {
const { onInterruptQueued, interruptingQueuedRunId } = useContext(IssueChatCtx);
const { onInterruptQueued, onCancelQueued, interruptingQueuedRunId } = useContext(IssueChatCtx);
const message = useMessage();
const custom = message.metadata.custom as Record<string, unknown>;
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
const commentId = typeof custom.commentId === "string" ? custom.commentId : message.id;
const queued = custom.queueState === "queued" || custom.clientStatus === "queued";
const pending = custom.clientStatus === "pending";
const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null;
@ -911,6 +945,16 @@ function IssueChatUserMessage() {
{interruptingQueuedRunId === queueTargetRunId ? "Interrupting..." : "Interrupt"}
</Button>
) : null}
{onCancelQueued ? (
<Button
size="sm"
variant="outline"
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
onClick={() => onCancelQueued(commentId)}
>
Cancel
</Button>
) : null}
</div>
) : null}
<div className="space-y-3">
@ -976,6 +1020,9 @@ function IssueChatAssistantMessage() {
feedbackTermsUrl,
onVote,
agentMap,
activeRunIds,
onStopRun,
stoppingRunId,
} = useContext(IssueChatCtx);
const message = useMessage();
const custom = message.metadata.custom as Record<string, unknown>;
@ -988,6 +1035,7 @@ function IssueChatAssistantMessage() {
const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null;
const runId = typeof custom.runId === "string" ? custom.runId : null;
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
const runStatus = typeof custom.runStatus === "string" ? custom.runStatus : null;
const agentId = authorAgentId ?? runAgentId;
const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined;
const commentId = typeof custom.commentId === "string" ? custom.commentId : null;
@ -997,6 +1045,7 @@ function IssueChatAssistantMessage() {
const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : "";
const isRunning = message.role === "assistant" && message.status?.type === "running";
const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
const canStopRun = canStopIssueChatRun({ runId, runStatus, activeRunIds });
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
const isFoldable = !isRunning && !!chainOfThoughtLabel;
@ -1162,6 +1211,18 @@ function IssueChatAssistantMessage() {
<Copy className="mr-2 h-3.5 w-3.5" />
Copy message
</DropdownMenuItem>
{canStopRun && onStopRun && runId ? (
<DropdownMenuItem
disabled={stoppingRunId === runId}
className="text-red-700 focus:text-red-800 dark:text-red-300 dark:focus:text-red-200"
onSelect={() => {
void onStopRun(runId);
}}
>
<Square className="mr-2 h-3.5 w-3.5 fill-current" />
{stoppingRunId === runId ? "Stopping…" : "Stop run"}
</DropdownMenuItem>
) : null}
{runHref ? (
<DropdownMenuItem asChild>
<Link to={runHref} target="_blank" rel="noreferrer noopener">
@ -1557,7 +1618,6 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
}, forwardedRef) {
const api = useAui();
const [body, setBody] = useState("");
const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled");
const [submitting, setSubmitting] = useState(false);
const [attaching, setAttaching] = useState(false);
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
@ -1567,6 +1627,23 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
const composerContainerRef = useRef<HTMLDivElement | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
function queueViewportRestore(snapshot: ReturnType<typeof captureComposerViewportSnapshot>) {
if (!snapshot) return;
requestAnimationFrame(() => {
restoreComposerViewportSnapshot(snapshot, composerContainerRef.current);
});
}
function focusComposer() {
if (typeof composerContainerRef.current?.scrollIntoView === "function") {
composerContainerRef.current.scrollIntoView({ behavior: "smooth", block: "end" });
}
requestAnimationFrame(() => {
window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" });
editorRef.current?.focus();
});
}
useEffect(() => {
if (!draftKey) return;
setBody(loadDraft(draftKey));
@ -1591,12 +1668,15 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
}, [effectiveSuggestedAssigneeValue]);
useImperativeHandle(forwardedRef, () => ({
focus: () => {
composerContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
requestAnimationFrame(() => {
window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" });
editorRef.current?.focus();
});
focus: focusComposer,
restoreDraft: (submittedBody: string) => {
setBody((current) =>
restoreSubmittedCommentDraft({
currentBody: current,
submittedBody,
}),
);
focusComposer();
},
}), []);
@ -1606,12 +1686,17 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : undefined;
const reopen = shouldImplicitlyReopenComment(
issueStatus,
hasReassignment ? reassignTarget : currentAssigneeValue,
) ? true : undefined;
const submittedBody = trimmed;
const viewportSnapshot = captureComposerViewportSnapshot(composerContainerRef.current);
setSubmitting(true);
setBody("");
try {
await api.thread().append({
const appendPromise = api.thread().append({
role: "user",
content: [{ type: "text", text: submittedBody }],
metadata: { custom: {} },
@ -1623,8 +1708,9 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
},
},
});
queueViewportRestore(viewportSnapshot);
await appendPromise;
if (draftKey) clearDraft(draftKey);
setReopen(issueStatus === "done" || issueStatus === "cancelled");
setReassignTarget(effectiveSuggestedAssigneeValue);
} catch {
setBody((current) =>
@ -1635,6 +1721,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
);
} finally {
setSubmitting(false);
queueViewportRestore(viewportSnapshot);
}
}
@ -1707,16 +1794,6 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
</div>
) : null}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={reopen}
onChange={(event) => setReopen(event.target.checked)}
className="rounded border-border"
/>
Re-open
</label>
{enableReassign && reassignOptions.length > 0 ? (
<InlineEntitySelector
value={reassignTarget}
@ -1781,6 +1858,7 @@ export function IssueChatThread({
onVote,
onAdd,
onCancelRun,
onStopRun,
imageUploadHandler,
onAttachImage,
draftKey,
@ -1799,13 +1877,18 @@ export function IssueChatThread({
hasOutputForRun: hasOutputForRunOverride,
includeSucceededRunsWithoutOutput = false,
onInterruptQueued,
onCancelQueued,
interruptingQueuedRunId = null,
stoppingRunId = null,
onImageClick,
composerRef,
}: IssueChatThreadProps) {
const location = useLocation();
const hasScrolledRef = useRef(false);
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
const composerViewportAnchorRef = useRef<HTMLDivElement | null>(null);
const composerViewportSnapshotRef = useRef<ReturnType<typeof captureComposerViewportSnapshot>>(null);
const preserveComposerViewportRef = useRef(false);
const displayLiveRuns = useMemo(() => {
const deduped = new Map<string, LiveRunForIssue>();
for (const run of liveRuns) {
@ -1834,14 +1917,22 @@ export function IssueChatThread({
activeRun,
});
}, [activeRun, displayLiveRuns, linkedRuns]);
const activeRunIds = useMemo(() => {
const ids = new Set<string>();
for (const run of displayLiveRuns) {
if (run.status === "queued" || run.status === "running") {
ids.add(run.id);
}
}
return ids;
}, [displayLiveRuns]);
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
runs: enableLiveTranscriptPolling ? transcriptRuns : [],
companyId,
});
const resolvedTranscriptByRun = transcriptsByRunId ?? transcriptByRun;
const resolvedHasOutputForRun = hasOutputForRunOverride ?? hasOutputForRun;
const messages = useMemo(
const rawMessages = useMemo(
() =>
buildIssueChatMessages({
comments,
@ -1872,6 +1963,18 @@ export function IssueChatThread({
currentUserId,
],
);
const stableMessagesRef = useRef<readonly import("@assistant-ui/react").ThreadMessage[]>([]);
const stableMessageCacheRef = useRef<Map<string, StableThreadMessageCacheEntry>>(new Map());
const messages = useMemo(() => {
const stabilized = stabilizeThreadMessages(
rawMessages,
stableMessagesRef.current,
stableMessageCacheRef.current,
);
stableMessagesRef.current = stabilized.messages;
stableMessageCacheRef.current = stabilized.cache;
return stabilized.messages;
}, [rawMessages]);
const isRunning = displayLiveRuns.some((run) => run.status === "queued" || run.status === "running");
const feedbackVoteByTargetId = useMemo(() => {
@ -1890,6 +1993,19 @@ export function IssueChatThread({
onCancel: onCancelRun,
});
useLayoutEffect(() => {
const composerElement = composerViewportAnchorRef.current;
if (preserveComposerViewportRef.current) {
restoreComposerViewportSnapshot(
composerViewportSnapshotRef.current,
composerElement,
);
}
composerViewportSnapshotRef.current = captureComposerViewportSnapshot(composerElement);
preserveComposerViewportRef.current = shouldPreserveComposerViewport(composerElement);
}, [messages]);
useEffect(() => {
const hash = location.hash;
if (!(hash.startsWith("#comment-") || hash.startsWith("#activity-") || hash.startsWith("#run-"))) return;
@ -1912,8 +2028,12 @@ export function IssueChatThread({
feedbackTermsUrl,
agentMap,
currentUserId,
activeRunIds,
onVote,
onStopRun,
stoppingRunId,
onInterruptQueued,
onCancelQueued,
interruptingQueuedRunId,
onImageClick,
}),
@ -1923,8 +2043,12 @@ export function IssueChatThread({
feedbackTermsUrl,
agentMap,
currentUserId,
activeRunIds,
onVote,
onStopRun,
stoppingRunId,
onInterruptQueued,
onCancelQueued,
interruptingQueuedRunId,
onImageClick,
],
@ -1990,20 +2114,22 @@ export function IssueChatThread({
</IssueChatErrorBoundary>
{showComposer ? (
<IssueChatComposer
ref={composerRef}
onImageUpload={imageUploadHandler}
onAttachImage={onAttachImage}
draftKey={draftKey}
enableReassign={enableReassign}
reassignOptions={reassignOptions}
currentAssigneeValue={currentAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentions}
agentMap={agentMap}
composerDisabledReason={composerDisabledReason}
issueStatus={issueStatus}
/>
<div ref={composerViewportAnchorRef}>
<IssueChatComposer
ref={composerRef}
onImageUpload={imageUploadHandler}
onAttachImage={onAttachImage}
draftKey={draftKey}
enableReassign={enableReassign}
reassignOptions={reassignOptions}
currentAssigneeValue={currentAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentions}
agentMap={agentMap}
composerDisabledReason={composerDisabledReason}
issueStatus={issueStatus}
/>
</div>
) : null}
</div>
</IssueChatCtx.Provider>