[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:
Dotta 2026-04-21 12:25:34 -05:00 committed by GitHub
parent 09d0678840
commit a26e1288b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1218 additions and 132 deletions

View file

@ -611,7 +611,9 @@ describe("inbox helpers", () => {
expect(
buildInboxKeyboardNavEntries(groupedSections, new Set(), new Set()).map((entry) => entry.type === "top"
? entry.itemKey
: entry.issueId),
: entry.type === "child"
? entry.issueId
: entry.groupKey),
).toEqual([
`workspace:default:${getInboxWorkItemKey({ kind: "issue", timestamp: 2, issue: parentIssue })}`,
childIssue.id,
@ -620,12 +622,55 @@ describe("inbox helpers", () => {
expect(
buildInboxKeyboardNavEntries(groupedSections, new Set(), new Set([parentIssue.id])).map((entry) => entry.type === "top"
? entry.itemKey
: entry.issueId),
: entry.type === "child"
? entry.issueId
: entry.groupKey),
).toEqual([
`workspace:default:${getInboxWorkItemKey({ kind: "issue", timestamp: 2, issue: parentIssue })}`,
]);
});
it("emits a group nav entry for labeled groups and omits children when the group is collapsed", () => {
const visibleIssue = makeIssue("visible", true);
const hiddenIssue = makeIssue("hidden", true);
const groupedSections = [
{
key: "priority:high",
label: "High priority",
displayItems: [{ kind: "issue", timestamp: 3, issue: visibleIssue } satisfies InboxWorkItem],
childrenByIssueId: new Map(),
},
{
key: "priority:medium",
label: "Medium priority",
displayItems: [{ kind: "issue", timestamp: 2, issue: hiddenIssue } satisfies InboxWorkItem],
childrenByIssueId: new Map(),
},
];
const expanded = buildInboxKeyboardNavEntries(groupedSections, new Set(), new Set());
expect(expanded.map((entry) => entry.type)).toEqual(["group", "top", "group", "top"]);
expect(expanded[0]).toEqual({
type: "group",
groupKey: "priority:high",
label: "High priority",
collapsed: false,
});
const collapsed = buildInboxKeyboardNavEntries(
groupedSections,
new Set(["priority:medium"]),
new Set(),
);
expect(collapsed.map((entry) => entry.type)).toEqual(["group", "top", "group"]);
expect(collapsed[2]).toEqual({
type: "group",
groupKey: "priority:medium",
label: "Medium priority",
collapsed: true,
});
});
it("sorts self-touched issues without external comments by updatedAt", () => {
const recentSelfTouched = makeIssue("recent", false);
recentSelfTouched.lastExternalCommentAt = null as unknown as Date;

View file

@ -100,11 +100,18 @@ export interface InboxGroupedSection {
export interface InboxKeyboardGroupSection {
key: string;
label?: string | null;
displayItems: InboxWorkItem[];
childrenByIssueId: ReadonlyMap<string, Issue[]>;
}
export type InboxKeyboardNavEntry =
| {
type: "group";
groupKey: string;
label: string;
collapsed: boolean;
}
| {
type: "top";
itemKey: string;
@ -965,7 +972,16 @@ export function buildInboxKeyboardNavEntries(
const entries: InboxKeyboardNavEntry[] = [];
for (const group of groupedSections) {
if (collapsedGroupKeys.has(group.key)) continue;
const isCollapsed = collapsedGroupKeys.has(group.key);
if (group.label) {
entries.push({
type: "group",
groupKey: group.key,
label: group.label,
collapsed: isCollapsed,
});
}
if (isCollapsed) continue;
for (const item of group.displayItems) {
entries.push({

View file

@ -10,12 +10,17 @@ function createIssue(overrides: Partial<Issue> = {}) {
assigneeAgentId: "agent-1",
assigneeUserId: null,
projectId: "project-1",
projectWorkspaceId: null,
parentId: null,
createdByUserId: "user-1",
hiddenAt: null,
labelIds: ["label-1"],
executionPolicy: null,
executionState: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
currentExecutionWorkspace: null,
blocks: [],
blockedBy: [],
ancestors: [],
@ -44,4 +49,46 @@ describe("buildIssuePropertiesPanelKey", () => {
expect(second).not.toBe(first);
});
it("changes when workspace detail hydrates after opening from a cached issue", () => {
const first = buildIssuePropertiesPanelKey(createIssue(), []);
const second = buildIssuePropertiesPanelKey(
createIssue({
executionWorkspaceId: "workspace-1",
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "isolated_workspace" },
currentExecutionWorkspace: {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "project-workspace-1",
sourceIssueId: "issue-1",
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "PAP-1 workspace",
status: "active",
cwd: "/tmp/paperclip/PAP-1",
repoUrl: null,
baseRef: "master",
branchName: "PAP-1-workspace",
providerType: "git_worktree",
providerRef: "/tmp/paperclip/PAP-1",
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date("2026-04-12T12:01:00.000Z"),
openedAt: new Date("2026-04-12T12:01:00.000Z"),
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
runtimeServices: [],
createdAt: new Date("2026-04-12T12:01:00.000Z"),
updatedAt: new Date("2026-04-12T12:01:00.000Z"),
},
}),
[],
);
expect(second).not.toBe(first);
});
});

View file

@ -8,12 +8,17 @@ type IssuePropertiesPanelKeyIssue = Pick<
| "assigneeAgentId"
| "assigneeUserId"
| "projectId"
| "projectWorkspaceId"
| "parentId"
| "createdByUserId"
| "hiddenAt"
| "labelIds"
| "executionPolicy"
| "executionState"
| "executionWorkspaceId"
| "executionWorkspacePreference"
| "executionWorkspaceSettings"
| "currentExecutionWorkspace"
| "blocks"
| "blockedBy"
| "ancestors"
@ -34,10 +39,29 @@ export function buildIssuePropertiesPanelKey(
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
projectId: issue.projectId,
projectWorkspaceId: issue.projectWorkspaceId,
parentId: issue.parentId,
createdByUserId: issue.createdByUserId,
hiddenAt: issue.hiddenAt,
labelIds: issue.labelIds ?? [],
executionWorkspaceId: issue.executionWorkspaceId,
executionWorkspacePreference: issue.executionWorkspacePreference,
executionWorkspaceSettings: issue.executionWorkspaceSettings ?? null,
currentExecutionWorkspace: issue.currentExecutionWorkspace
? {
id: issue.currentExecutionWorkspace.id,
mode: issue.currentExecutionWorkspace.mode,
status: issue.currentExecutionWorkspace.status,
projectWorkspaceId: issue.currentExecutionWorkspace.projectWorkspaceId,
branchName: issue.currentExecutionWorkspace.branchName,
cwd: issue.currentExecutionWorkspace.cwd,
runtimeServices: (issue.currentExecutionWorkspace.runtimeServices ?? []).map((service) => ({
id: service.id,
status: service.status,
url: service.url,
})),
}
: null,
executionPolicy: issue.executionPolicy ?? null,
executionState: issue.executionState
? {

View file

@ -12,6 +12,11 @@ describe("issue-reference", () => {
expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBe("PAP-1179");
});
it("does not treat GitHub issue URLs as internal Paperclip issue links", () => {
expect(parseIssuePathIdFromPath("https://github.com/paperclipai/paperclip/issues/1778")).toBeNull();
expect(parseIssueReferenceFromHref("https://github.com/paperclipai/paperclip/issues/1778")).toBeNull();
});
it("ignores placeholder issue paths", () => {
expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull();
expect(parseIssuePathIdFromPath("http://localhost:3100/issues/:id")).toBeNull();

View file

@ -16,7 +16,9 @@ export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined):
if (/^https?:\/\//i.test(pathname)) {
try {
pathname = new URL(pathname).pathname;
const url = new URL(pathname);
if (url.hostname === "github.com" || url.hostname === "www.github.com") return null;
pathname = url.pathname;
} catch {
return null;
}

View file

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { LiveRunForIssue } from "../api/heartbeats";
import { collectLiveIssueIds } from "./liveIssueIds";
describe("collectLiveIssueIds", () => {
it("keeps only runs linked to issues", () => {
const liveRuns: LiveRunForIssue[] = [
{
id: "run-1",
status: "running",
invocationSource: "scheduler",
triggerDetail: null,
startedAt: "2026-04-20T10:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-20T10:00:00.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType: "codex_local",
issueId: "issue-1",
},
{
id: "run-2",
status: "queued",
invocationSource: "scheduler",
triggerDetail: null,
startedAt: null,
finishedAt: null,
createdAt: "2026-04-20T10:01:00.000Z",
agentId: "agent-2",
agentName: "Reviewer",
adapterType: "codex_local",
issueId: null,
},
{
id: "run-3",
status: "running",
invocationSource: "scheduler",
triggerDetail: null,
startedAt: "2026-04-20T10:02:00.000Z",
finishedAt: null,
createdAt: "2026-04-20T10:02:00.000Z",
agentId: "agent-3",
agentName: "Builder",
adapterType: "codex_local",
issueId: "issue-1",
},
{
id: "run-4",
status: "running",
invocationSource: "scheduler",
triggerDetail: null,
startedAt: "2026-04-20T10:03:00.000Z",
finishedAt: null,
createdAt: "2026-04-20T10:03:00.000Z",
agentId: "agent-4",
agentName: "Fixer",
adapterType: "codex_local",
issueId: "issue-2",
},
];
expect([...collectLiveIssueIds(liveRuns)]).toEqual(["issue-1", "issue-2"]);
});
});

View file

@ -0,0 +1,9 @@
import type { LiveRunForIssue } from "../api/heartbeats";
export function collectLiveIssueIds(liveRuns: readonly LiveRunForIssue[] | null | undefined): Set<string> {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}

View file

@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Issue } from "@paperclipai/shared";
import {
applyLocalQueuedIssueCommentState,
applyOptimisticIssueFieldUpdate,
applyOptimisticIssueFieldUpdateToCollection,
applyOptimisticIssueCommentUpdate,
@ -704,4 +705,51 @@ describe("optimistic issue comments", () => {
}),
).toBe(false);
});
it("keeps a confirmed queued comment queued while the target run is still live", () => {
const comment = {
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Follow up after the active run",
createdAt: new Date("2026-03-28T16:20:05.000Z"),
updatedAt: new Date("2026-03-28T16:20:05.000Z"),
};
const result = applyLocalQueuedIssueCommentState(comment, {
queuedTargetRunId: "run-1",
hasLiveRuns: true,
runningRunId: "run-1",
});
expect(result).toMatchObject({
id: "comment-1",
clientStatus: "queued",
queueState: "queued",
queueTargetRunId: "run-1",
});
});
it("does not keep local queued state after the target run is no longer live", () => {
const comment = {
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Follow up after the active run",
createdAt: new Date("2026-03-28T16:20:05.000Z"),
updatedAt: new Date("2026-03-28T16:20:05.000Z"),
};
const result = applyLocalQueuedIssueCommentState(comment, {
queuedTargetRunId: "run-1",
hasLiveRuns: false,
runningRunId: null,
});
expect(result).toBe(comment);
});
});

View file

@ -12,6 +12,11 @@ export interface OptimisticIssueComment extends IssueComment {
}
export type IssueTimelineComment = IssueComment | OptimisticIssueComment;
export type LocallyQueuedIssueComment<T extends IssueComment> = T & {
clientStatus: "queued";
queueState: "queued";
queueTargetRunId: string;
};
function toTimestamp(value: Date | string) {
return new Date(value).getTime();
@ -82,6 +87,26 @@ export function isQueuedIssueComment(params: {
return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt);
}
export function applyLocalQueuedIssueCommentState<T extends IssueComment>(
comment: T,
params: {
queuedTargetRunId?: string | null;
hasLiveRuns: boolean;
runningRunId?: string | null;
},
): T | LocallyQueuedIssueComment<T> {
const queuedTargetRunId = params.queuedTargetRunId ?? null;
if (!queuedTargetRunId || !params.hasLiveRuns) return comment;
if (params.runningRunId && params.runningRunId !== queuedTargetRunId) return comment;
return {
...comment,
clientStatus: "queued",
queueState: "queued",
queueTargetRunId: queuedTargetRunId,
};
}
export function mergeIssueComments(
comments: IssueComment[] | undefined,
optimisticComments: OptimisticIssueComment[],
@ -150,7 +175,7 @@ export function applyOptimisticIssueCommentUpdate(
if (!issue) return issue;
const nextIssue: Issue = { ...issue };
if (params.reopen === true && (issue.status === "done" || issue.status === "cancelled")) {
if (params.reopen === true && (issue.status === "done" || issue.status === "cancelled" || issue.status === "blocked")) {
nextIssue.status = "todo";
}

View file

@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
import type { RunForIssue } from "../api/activity";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { removeLiveRunById, upsertInterruptedRun } from "./optimistic-issue-runs";
import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "./optimistic-issue-runs";
function createLiveRun(overrides: Partial<LiveRunForIssue> = {}): LiveRunForIssue {
return {
@ -91,3 +92,32 @@ describe("removeLiveRunById", () => {
expect(runs?.map((run) => run.id)).toEqual(["run-2"]);
});
});
describe("clearIssueExecutionRun", () => {
it("clears the cached execution run when the interrupted run matches the issue lock", () => {
const issue = {
id: "issue-1",
executionRunId: "run-1",
executionAgentNameKey: "codexcoder",
executionLockedAt: new Date("2026-04-08T21:00:00.000Z"),
updatedAt: new Date("2026-04-08T21:00:00.000Z"),
} as Issue;
expect(clearIssueExecutionRun(issue, "run-1")).toMatchObject({
id: "issue-1",
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
});
});
it("leaves the cached issue alone when another run is interrupted", () => {
const issue = {
id: "issue-1",
executionRunId: "run-2",
executionAgentNameKey: "codexcoder",
} as Issue;
expect(clearIssueExecutionRun(issue, "run-1")).toBe(issue);
});
});

View file

@ -1,3 +1,4 @@
import type { Issue } from "@paperclipai/shared";
import type { RunForIssue } from "../api/activity";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
@ -68,3 +69,17 @@ export function removeLiveRunById(
const nextRuns = runs.filter((run) => run.id !== runId);
return nextRuns.length === runs.length ? runs : nextRuns;
}
export function clearIssueExecutionRun(
issue: Issue | undefined,
runId: string,
) {
if (!issue || issue.executionRunId !== runId) return issue;
return {
...issue,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: new Date(),
};
}

View file

@ -8,7 +8,11 @@ export function cn(...inputs: ClassValue[]) {
}
export function formatCents(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
return `$${(cents / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function formatNumber(n: number): string {
return n.toLocaleString("en-US");
}
export function formatDate(date: Date | string): string {
@ -51,6 +55,7 @@ export function relativeTime(date: Date | string): string {
}
export function formatTokens(n: number): string {
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
return String(n);