mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
[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:
parent
5d1ed71779
commit
6e6f538630
41 changed files with 4141 additions and 590 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue