fix(ui): polish issue detail timelines and attachments

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-02 11:51:40 -05:00
parent 36376968af
commit bd6d07d0b4
25 changed files with 2020 additions and 82 deletions

View 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([]);
});
});

View 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);
}

View file

@ -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);
});
});

View file

@ -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;
}

View 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");
});
});

View 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";
}