mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
fix(ui): polish issue detail timelines and attachments
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
36376968af
commit
bd6d07d0b4
25 changed files with 2020 additions and 82 deletions
153
ui/src/lib/issue-timeline-events.test.ts
Normal file
153
ui/src/lib/issue-timeline-events.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import { extractIssueTimelineEvents } from "./issue-timeline-events";
|
||||
|
||||
describe("extractIssueTimelineEvents", () => {
|
||||
it("extracts and sorts status and assignee changes from issue updates", () => {
|
||||
const events = extractIssueTimelineEvents([
|
||||
{
|
||||
id: "evt-2",
|
||||
companyId: "company-1",
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
createdAt: new Date("2026-03-31T12:02:00.000Z"),
|
||||
details: {
|
||||
assigneeAgentId: "agent-2",
|
||||
assigneeUserId: null,
|
||||
_previous: {
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt-1",
|
||||
companyId: "company-1",
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
details: {
|
||||
status: "in_progress",
|
||||
_previous: {
|
||||
status: "todo",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt-ignored",
|
||||
companyId: "company-1",
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
action: "issue.comment_added",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
createdAt: new Date("2026-03-31T12:03:00.000Z"),
|
||||
details: {
|
||||
commentId: "comment-1",
|
||||
},
|
||||
},
|
||||
] satisfies ActivityEvent[]);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
id: "evt-1",
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
statusChange: {
|
||||
from: "todo",
|
||||
to: "in_progress",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt-2",
|
||||
createdAt: new Date("2026-03-31T12:02:00.000Z"),
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
assigneeChange: {
|
||||
from: {
|
||||
agentId: "agent-1",
|
||||
userId: null,
|
||||
},
|
||||
to: {
|
||||
agentId: "agent-2",
|
||||
userId: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses reopenedFrom when a reopen update omits _previous", () => {
|
||||
const events = extractIssueTimelineEvents([
|
||||
{
|
||||
id: "evt-reopen",
|
||||
companyId: "company-1",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
details: {
|
||||
status: "todo",
|
||||
reopened: true,
|
||||
reopenedFrom: "done",
|
||||
source: "comment",
|
||||
},
|
||||
},
|
||||
] satisfies ActivityEvent[]);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
id: "evt-reopen",
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
statusChange: {
|
||||
from: "done",
|
||||
to: "todo",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores issue updates without visible status or assignee transitions", () => {
|
||||
const events = extractIssueTimelineEvents([
|
||||
{
|
||||
id: "evt-title",
|
||||
companyId: "company-1",
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
details: {
|
||||
title: "New title",
|
||||
_previous: {
|
||||
title: "Old title",
|
||||
},
|
||||
},
|
||||
},
|
||||
] satisfies ActivityEvent[]);
|
||||
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
});
|
||||
105
ui/src/lib/issue-timeline-events.ts
Normal file
105
ui/src/lib/issue-timeline-events.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
|
||||
export interface IssueTimelineAssignee {
|
||||
agentId: string | null;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
export interface IssueTimelineEvent {
|
||||
id: string;
|
||||
createdAt: Date | string;
|
||||
actorType: ActivityEvent["actorType"];
|
||||
actorId: string;
|
||||
statusChange?: {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
};
|
||||
assigneeChange?: {
|
||||
from: IssueTimelineAssignee;
|
||||
to: IssueTimelineAssignee;
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function hasOwn(record: Record<string, unknown>, key: string) {
|
||||
return Object.prototype.hasOwnProperty.call(record, key);
|
||||
}
|
||||
|
||||
function nullableString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function toTimestamp(value: Date | string) {
|
||||
return new Date(value).getTime();
|
||||
}
|
||||
|
||||
function sameAssignee(left: IssueTimelineAssignee, right: IssueTimelineAssignee) {
|
||||
return left.agentId === right.agentId && left.userId === right.userId;
|
||||
}
|
||||
|
||||
function sortTimelineEvents<T extends { createdAt: Date | string; id: string }>(events: T[]) {
|
||||
return [...events].sort((a, b) => {
|
||||
const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
|
||||
if (createdAtDiff !== 0) return createdAtDiff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | undefined): IssueTimelineEvent[] {
|
||||
const events: IssueTimelineEvent[] = [];
|
||||
|
||||
for (const event of activity ?? []) {
|
||||
if (event.action !== "issue.updated") continue;
|
||||
|
||||
const details = asRecord(event.details);
|
||||
if (!details) continue;
|
||||
|
||||
const previous = asRecord(details._previous);
|
||||
const timelineEvent: IssueTimelineEvent = {
|
||||
id: event.id,
|
||||
createdAt: event.createdAt,
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
};
|
||||
|
||||
if (hasOwn(details, "status")) {
|
||||
const from = nullableString(previous?.status) ?? nullableString(details.reopenedFrom);
|
||||
const to = nullableString(details.status);
|
||||
if (from !== to) {
|
||||
timelineEvent.statusChange = { from, to };
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOwn(details, "assigneeAgentId") || hasOwn(details, "assigneeUserId")) {
|
||||
const previousAssignee: IssueTimelineAssignee = {
|
||||
agentId: nullableString(previous?.assigneeAgentId),
|
||||
userId: nullableString(previous?.assigneeUserId),
|
||||
};
|
||||
const nextAssignee: IssueTimelineAssignee = {
|
||||
agentId: hasOwn(details, "assigneeAgentId")
|
||||
? nullableString(details.assigneeAgentId)
|
||||
: previousAssignee.agentId,
|
||||
userId: hasOwn(details, "assigneeUserId")
|
||||
? nullableString(details.assigneeUserId)
|
||||
: previousAssignee.userId,
|
||||
};
|
||||
|
||||
if (!sameAssignee(previousAssignee, nextAssignee)) {
|
||||
timelineEvent.assigneeChange = {
|
||||
from: previousAssignee,
|
||||
to: nextAssignee,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (timelineEvent.statusChange || timelineEvent.assigneeChange) {
|
||||
events.push(timelineEvent);
|
||||
}
|
||||
}
|
||||
|
||||
return sortTimelineEvents(events);
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
armIssueDetailInboxQuickArchive,
|
||||
createIssueDetailLocationState,
|
||||
createIssueDetailPath,
|
||||
readIssueDetailBreadcrumb,
|
||||
shouldArmIssueDetailInboxQuickArchive,
|
||||
} from "./issueDetailBreadcrumb";
|
||||
|
||||
describe("issueDetailBreadcrumb", () => {
|
||||
|
|
@ -25,10 +27,30 @@ describe("issueDetailBreadcrumb", () => {
|
|||
it("adds the source query param when building an issue detail path", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
expect(createIssueDetailPath("PAP-465", state)).toBe("/issues/PAP-465?from=inbox");
|
||||
expect(createIssueDetailPath("PAP-465", state)).toBe(
|
||||
"/issues/PAP-465?from=inbox&fromHref=%2Finbox%2Fmine",
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses the current source query param when state has been dropped", () => {
|
||||
expect(createIssueDetailPath("PAP-465", null, "?from=issues")).toBe("/issues/PAP-465?from=issues");
|
||||
expect(createIssueDetailPath("PAP-465", null, "?from=issues&fromHref=%2Fissues%3Fq%3Dabc")).toBe(
|
||||
"/issues/PAP-465?from=issues&fromHref=%2Fissues%3Fq%3Dabc",
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the exact breadcrumb href from the query fallback", () => {
|
||||
expect(
|
||||
readIssueDetailBreadcrumb(null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
|
||||
).toEqual({
|
||||
label: "Inbox",
|
||||
href: "/PAP/inbox/unread",
|
||||
});
|
||||
});
|
||||
|
||||
it("can arm quick archive only for explicit inbox keyboard entry state", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
expect(shouldArmIssueDetailInboxQuickArchive(state)).toBe(false);
|
||||
expect(shouldArmIssueDetailInboxQuickArchive(armIssueDetailInboxQuickArchive(state))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ type IssueDetailBreadcrumb = {
|
|||
type IssueDetailLocationState = {
|
||||
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
|
||||
issueDetailSource?: IssueDetailSource;
|
||||
issueDetailInboxQuickArchiveArmed?: boolean;
|
||||
};
|
||||
|
||||
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
|
||||
const ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM = "fromHref";
|
||||
|
||||
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
|
|
@ -35,6 +37,13 @@ function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | n
|
|||
return isIssueDetailSource(source) ? source : null;
|
||||
}
|
||||
|
||||
function readIssueDetailBreadcrumbHrefFromSearch(search?: string): string | null {
|
||||
if (!search) return null;
|
||||
const params = new URLSearchParams(search);
|
||||
const href = params.get(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM);
|
||||
return href && href.startsWith("/") ? href : null;
|
||||
}
|
||||
|
||||
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
|
||||
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
|
||||
return { label: "Issues", href: "/issues" };
|
||||
|
|
@ -51,11 +60,30 @@ export function createIssueDetailLocationState(
|
|||
};
|
||||
}
|
||||
|
||||
export function armIssueDetailInboxQuickArchive(state: unknown): IssueDetailLocationState {
|
||||
if (typeof state !== "object" || state === null) {
|
||||
return { issueDetailInboxQuickArchiveArmed: true };
|
||||
}
|
||||
|
||||
return {
|
||||
...(state as IssueDetailLocationState),
|
||||
issueDetailInboxQuickArchiveArmed: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string {
|
||||
const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
|
||||
const breadcrumb =
|
||||
(typeof state === "object" && state !== null
|
||||
? (state as IssueDetailLocationState).issueDetailBreadcrumb
|
||||
: null);
|
||||
const breadcrumbHref =
|
||||
(isIssueDetailBreadcrumb(breadcrumb) ? breadcrumb.href : null) ??
|
||||
readIssueDetailBreadcrumbHrefFromSearch(search);
|
||||
if (!source) return `/issues/${issuePathId}`;
|
||||
const params = new URLSearchParams();
|
||||
params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
|
||||
if (breadcrumbHref) params.set(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM, breadcrumbHref);
|
||||
return `/issues/${issuePathId}?${params.toString()}`;
|
||||
}
|
||||
|
||||
|
|
@ -66,5 +94,14 @@ export function readIssueDetailBreadcrumb(state: unknown, search?: string): Issu
|
|||
}
|
||||
|
||||
const source = readIssueDetailSourceFromSearch(search);
|
||||
return source ? breadcrumbForSource(source) : null;
|
||||
if (!source) return null;
|
||||
|
||||
const fallback = breadcrumbForSource(source);
|
||||
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
|
||||
return href ? { ...fallback, href } : fallback;
|
||||
}
|
||||
|
||||
export function shouldArmIssueDetailInboxQuickArchive(state: unknown): boolean {
|
||||
if (typeof state !== "object" || state === null) return false;
|
||||
return (state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true;
|
||||
}
|
||||
|
|
|
|||
106
ui/src/lib/keyboardShortcuts.test.ts
Normal file
106
ui/src/lib/keyboardShortcuts.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasBlockingShortcutDialog,
|
||||
isKeyboardShortcutTextInputTarget,
|
||||
resolveInboxQuickArchiveKeyAction,
|
||||
} from "./keyboardShortcuts";
|
||||
|
||||
describe("keyboardShortcuts helpers", () => {
|
||||
it("detects editable shortcut targets", () => {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = `
|
||||
<div contenteditable="true"><span id="contenteditable-child">Editable</span></div>
|
||||
<div role="textbox"><span id="textbox-child">Textbox</span></div>
|
||||
<button id="button">Action</button>
|
||||
`;
|
||||
|
||||
const editableChild = wrapper.querySelector("#contenteditable-child");
|
||||
const textboxChild = wrapper.querySelector("#textbox-child");
|
||||
const button = wrapper.querySelector("#button");
|
||||
|
||||
expect(isKeyboardShortcutTextInputTarget(editableChild)).toBe(true);
|
||||
expect(isKeyboardShortcutTextInputTarget(textboxChild)).toBe(true);
|
||||
expect(isKeyboardShortcutTextInputTarget(button)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports when a modal dialog is open", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `<div role="dialog" aria-modal="true"></div>`;
|
||||
|
||||
expect(hasBlockingShortcutDialog(root)).toBe(true);
|
||||
expect(hasBlockingShortcutDialog(document.createElement("div"))).toBe(false);
|
||||
});
|
||||
|
||||
it("archives only the first clean y press", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(resolveInboxQuickArchiveKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "y",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("archive");
|
||||
});
|
||||
|
||||
it("disarms on the first non-y keypress", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(resolveInboxQuickArchiveKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "n",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("disarm");
|
||||
});
|
||||
|
||||
it("stays inert for modifier combos before a real keypress", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(resolveInboxQuickArchiveKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "Meta",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("ignore");
|
||||
|
||||
expect(resolveInboxQuickArchiveKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "y",
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("ignore");
|
||||
});
|
||||
|
||||
it("disarms instead of archiving when typing into an editor", () => {
|
||||
const input = document.createElement("input");
|
||||
|
||||
expect(resolveInboxQuickArchiveKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "y",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: input,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("disarm");
|
||||
});
|
||||
});
|
||||
54
ui/src/lib/keyboardShortcuts.ts
Normal file
54
ui/src/lib/keyboardShortcuts.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"[contenteditable='true']",
|
||||
"[contenteditable='plaintext-only']",
|
||||
"[role='textbox']",
|
||||
"[role='combobox']",
|
||||
].join(", ");
|
||||
|
||||
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
|
||||
|
||||
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
|
||||
|
||||
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
return !!target.closest(KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR);
|
||||
}
|
||||
|
||||
export function hasBlockingShortcutDialog(root: ParentNode = document): boolean {
|
||||
return !!root.querySelector("[role='dialog'], [aria-modal='true']");
|
||||
}
|
||||
|
||||
export function isModifierOnlyKey(key: string): boolean {
|
||||
return MODIFIER_ONLY_KEYS.has(key);
|
||||
}
|
||||
|
||||
export function resolveInboxQuickArchiveKeyAction({
|
||||
armed,
|
||||
defaultPrevented,
|
||||
key,
|
||||
metaKey,
|
||||
ctrlKey,
|
||||
altKey,
|
||||
target,
|
||||
hasOpenDialog,
|
||||
}: {
|
||||
armed: boolean;
|
||||
defaultPrevented: boolean;
|
||||
key: string;
|
||||
metaKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
target: EventTarget | null;
|
||||
hasOpenDialog: boolean;
|
||||
}): InboxQuickArchiveKeyAction {
|
||||
if (!armed) return "ignore";
|
||||
if (defaultPrevented) return "disarm";
|
||||
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
|
||||
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "disarm";
|
||||
if (key === "y") return "archive";
|
||||
return "disarm";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue