[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

@ -37,6 +37,7 @@ import {
mergeHeartbeatRunResultJson,
summarizeHeartbeatRunResultJson,
} from "./heartbeat-run-summary.js";
import { logActivity, type LogActivityInput } from "./activity-log.js";
import {
buildWorkspaceReadyComment,
cleanupExecutionWorkspaceArtifacts,
@ -3485,9 +3486,12 @@ export function heartbeatService(db: Db) {
});
if (issueId && outcome === "succeeded") {
try {
const issueComment = buildHeartbeatRunIssueComment(persistedResultJson);
if (issueComment) {
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
const existingRunComment = await findRunIssueComment(finalizedRun.id, finalizedRun.companyId, issueId);
if (!existingRunComment) {
const issueComment = buildHeartbeatRunIssueComment(persistedResultJson);
if (issueComment) {
await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id });
}
}
} catch (err) {
await onLog(
@ -3632,31 +3636,50 @@ export function heartbeatService(db: Db) {
}
async function releaseIssueExecutionAndPromote(run: typeof heartbeatRuns.$inferSelect) {
const promotedRun = await db.transaction(async (tx) => {
await tx.execute(
sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`,
);
const runContext = parseObject(run.contextSnapshot);
const contextIssueId = readNonEmptyString(runContext.issueId);
const promotionResult = await db.transaction(async (tx) => {
if (contextIssueId) {
await tx.execute(
sql`select id from issues where company_id = ${run.companyId} and id = ${contextIssueId} for update`,
);
} else {
await tx.execute(
sql`select id from issues where company_id = ${run.companyId} and execution_run_id = ${run.id} for update`,
);
}
const issue = await tx
let issue = await tx
.select({
id: issues.id,
companyId: issues.companyId,
identifier: issues.identifier,
status: issues.status,
executionRunId: issues.executionRunId,
})
.from(issues)
.where(and(eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id)))
.where(
and(
eq(issues.companyId, run.companyId),
contextIssueId ? eq(issues.id, contextIssueId) : eq(issues.executionRunId, run.id),
),
)
.then((rows) => rows[0] ?? null);
if (!issue) return;
if (!issue) return null;
if (issue.executionRunId && issue.executionRunId !== run.id) return null;
await tx
.update(issues)
.set({
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: new Date(),
})
.where(eq(issues.id, issue.id));
if (issue.executionRunId === run.id) {
await tx
.update(issues)
.set({
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: new Date(),
})
.where(eq(issues.id, issue.id));
}
while (true) {
const deferred = await tx
@ -3703,6 +3726,51 @@ export function heartbeatService(db: Db) {
const deferredPayload = parseObject(deferred.payload);
const deferredContextSeed = parseObject(deferredPayload[DEFERRED_WAKE_CONTEXT_KEY]);
const promotedContextSeed: Record<string, unknown> = { ...deferredContextSeed };
const deferredCommentIds = extractWakeCommentIds(deferredContextSeed);
const shouldReopenDeferredCommentWake =
deferredCommentIds.length > 0 && (issue.status === "done" || issue.status === "cancelled");
let reopenedActivity: LogActivityInput | null = null;
if (shouldReopenDeferredCommentWake) {
const reopenedFromStatus = issue.status;
const reopenedIssue = await issuesSvc.update(
issue.id,
{
status: "todo",
executionState: null,
},
tx,
);
if (reopenedIssue) {
issue = {
...issue,
identifier: reopenedIssue.identifier,
status: reopenedIssue.status,
executionRunId: reopenedIssue.executionRunId,
};
if (!readNonEmptyString(promotedContextSeed.reopenedFrom)) {
promotedContextSeed.reopenedFrom = reopenedFromStatus;
}
reopenedActivity = {
companyId: issue.companyId,
actorType: "system",
actorId: "heartbeat",
agentId: deferred.agentId,
runId: run.id,
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
details: {
status: "todo",
reopened: true,
reopenedFrom: reopenedFromStatus,
source: "deferred_comment_wake",
identifier: issue.identifier,
},
};
}
}
const promotedReason = readNonEmptyString(deferred.reason) ?? "issue_execution_promoted";
const promotedSource =
(readNonEmptyString(deferred.source) as WakeupOptions["source"]) ?? "automation";
@ -3764,12 +3832,20 @@ export function heartbeatService(db: Db) {
})
.where(eq(issues.id, issue.id));
return newRun;
return {
run: newRun,
reopenedActivity,
};
}
});
const promotedRun = promotionResult?.run ?? null;
if (!promotedRun) return;
if (promotionResult?.reopenedActivity) {
await logActivity(db, promotionResult.reopenedActivity);
}
publishLiveEvent({
companyId: promotedRun.companyId,
type: "heartbeat.run.queued",

View file

@ -2112,6 +2112,28 @@ export function issueService(db: Db) {
return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
})),
removeComment: async (commentId: string) => {
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
return db.transaction(async (tx) => {
const [comment] = await tx
.delete(issueComments)
.where(eq(issueComments.id, commentId))
.returning();
if (!comment) return null;
await tx
.update(issues)
.set({ updatedAt: new Date() })
.where(eq(issues.id, comment.issueId));
return redactIssueComment(comment, currentUserRedactionOptions.enabled);
});
},
addComment: async (
issueId: string,
body: string,