mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
[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:
parent
09d0678840
commit
a26e1288b6
40 changed files with 1218 additions and 132 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
64
ui/src/lib/liveIssueIds.test.ts
Normal file
64
ui/src/lib/liveIssueIds.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
9
ui/src/lib/liveIssueIds.ts
Normal file
9
ui/src/lib/liveIssueIds.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue