Merge public-gh/master into pap-1239-ui-ux

This commit is contained in:
dotta 2026-04-09 09:04:22 -05:00
commit b578bf1f51
56 changed files with 16126 additions and 397 deletions

View file

@ -0,0 +1,60 @@
import type { Agent } from "@paperclipai/shared";
import { describe, expect, it } from "vitest";
import { formatActivityVerb, formatIssueActivityAction } from "./activity-format";
describe("activity formatting", () => {
const agentMap = new Map<string, Agent>([
["agent-reviewer", { id: "agent-reviewer", name: "Reviewer Bot" } as Agent],
["agent-approver", { id: "agent-approver", name: "Approver Bot" } as Agent],
]);
it("formats blocker activity using linked issue identifiers", () => {
const details = {
addedBlockedByIssues: [
{ id: "issue-2", identifier: "PAP-22", title: "Blocked task" },
],
removedBlockedByIssues: [],
};
expect(formatActivityVerb("issue.blockers_updated", details)).toBe("added blocker PAP-22 to");
expect(formatIssueActivityAction("issue.blockers_updated", details)).toBe("added blocker PAP-22");
});
it("formats reviewer activity using agent names", () => {
const details = {
addedParticipants: [
{ type: "agent", agentId: "agent-reviewer", userId: null },
],
removedParticipants: [],
};
expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("added reviewer Reviewer Bot to");
expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("added reviewer Reviewer Bot");
});
it("formats approver removals using user-aware labels", () => {
const details = {
addedParticipants: [],
removedParticipants: [
{ type: "user", agentId: null, userId: "local-board" },
],
};
expect(formatActivityVerb("issue.approvers_updated", details)).toBe("removed approver Board from");
expect(formatIssueActivityAction("issue.approvers_updated", details)).toBe("removed approver Board");
});
it("falls back to updated wording when reviewers are both added and removed", () => {
const details = {
addedParticipants: [
{ type: "agent", agentId: "agent-reviewer", userId: null },
],
removedParticipants: [
{ type: "agent", agentId: "agent-approver", userId: null },
],
};
expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers on");
expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers");
});
});

View file

@ -0,0 +1,289 @@
import type { Agent } from "@paperclipai/shared";
type ActivityDetails = Record<string, unknown> | null | undefined;
type ActivityParticipant = {
type: "agent" | "user";
agentId?: string | null;
userId?: string | null;
};
type ActivityIssueReference = {
id?: string | null;
identifier?: string | null;
title?: string | null;
};
interface ActivityFormatOptions {
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
}
const ACTIVITY_ROW_VERBS: Record<string, string> = {
"issue.created": "created",
"issue.updated": "updated",
"issue.checked_out": "checked out",
"issue.released": "released",
"issue.comment_added": "commented on",
"issue.attachment_added": "attached file to",
"issue.attachment_removed": "removed attachment from",
"issue.document_created": "created document for",
"issue.document_updated": "updated document on",
"issue.document_deleted": "deleted document from",
"issue.commented": "commented on",
"issue.deleted": "deleted",
"agent.created": "created",
"agent.updated": "updated",
"agent.paused": "paused",
"agent.resumed": "resumed",
"agent.terminated": "terminated",
"agent.key_created": "created API key for",
"agent.budget_updated": "updated budget for",
"agent.runtime_session_reset": "reset session for",
"heartbeat.invoked": "invoked heartbeat for",
"heartbeat.cancelled": "cancelled heartbeat for",
"approval.created": "requested approval",
"approval.approved": "approved",
"approval.rejected": "rejected",
"project.created": "created",
"project.updated": "updated",
"project.deleted": "deleted",
"goal.created": "created",
"goal.updated": "updated",
"goal.deleted": "deleted",
"cost.reported": "reported cost for",
"cost.recorded": "recorded cost for",
"company.created": "created company",
"company.updated": "updated company",
"company.archived": "archived",
"company.budget_updated": "updated budget for",
};
const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
"issue.created": "created the issue",
"issue.updated": "updated the issue",
"issue.checked_out": "checked out the issue",
"issue.released": "released the issue",
"issue.comment_added": "added a comment",
"issue.feedback_vote_saved": "saved feedback on an AI output",
"issue.attachment_added": "added an attachment",
"issue.attachment_removed": "removed an attachment",
"issue.document_created": "created a document",
"issue.document_updated": "updated a document",
"issue.document_deleted": "deleted a document",
"issue.deleted": "deleted the issue",
"agent.created": "created an agent",
"agent.updated": "updated the agent",
"agent.paused": "paused the agent",
"agent.resumed": "resumed the agent",
"agent.terminated": "terminated the agent",
"heartbeat.invoked": "invoked a heartbeat",
"heartbeat.cancelled": "cancelled a heartbeat",
"approval.created": "requested approval",
"approval.approved": "approved",
"approval.rejected": "rejected",
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function humanizeValue(value: unknown): string {
if (typeof value !== "string") return String(value ?? "none");
return value.replace(/_/g, " ");
}
function isActivityParticipant(value: unknown): value is ActivityParticipant {
const record = asRecord(value);
if (!record) return false;
return record.type === "agent" || record.type === "user";
}
function isActivityIssueReference(value: unknown): value is ActivityIssueReference {
return asRecord(value) !== null;
}
function readParticipants(details: ActivityDetails, key: string): ActivityParticipant[] {
const value = details?.[key];
if (!Array.isArray(value)) return [];
return value.filter(isActivityParticipant);
}
function readIssueReferences(details: ActivityDetails, key: string): ActivityIssueReference[] {
const value = details?.[key];
if (!Array.isArray(value)) return [];
return value.filter(isActivityIssueReference);
}
function formatUserLabel(userId: string | null | undefined, currentUserId?: string | null): string {
if (!userId || userId === "local-board") return "Board";
if (currentUserId && userId === currentUserId) return "You";
return `user ${userId.slice(0, 5)}`;
}
function formatParticipantLabel(participant: ActivityParticipant, options: ActivityFormatOptions): string {
if (participant.type === "agent") {
const agentId = participant.agentId ?? "";
return options.agentMap?.get(agentId)?.name ?? "agent";
}
return formatUserLabel(participant.userId, options.currentUserId);
}
function formatIssueReferenceLabel(reference: ActivityIssueReference): string {
if (reference.identifier) return reference.identifier;
if (reference.title) return reference.title;
if (reference.id) return reference.id.slice(0, 8);
return "issue";
}
function formatChangedEntityLabel(
singular: string,
plural: string,
labels: string[],
): string {
if (labels.length <= 0) return plural;
if (labels.length === 1) return `${singular} ${labels[0]}`;
return `${labels.length} ${plural}`;
}
function formatIssueUpdatedVerb(details: ActivityDetails): string | null {
if (!details) return null;
const previous = asRecord(details._previous) ?? {};
if (details.status !== undefined) {
const from = previous.status;
return from
? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on`
: `changed status to ${humanizeValue(details.status)} on`;
}
if (details.priority !== undefined) {
const from = previous.priority;
return from
? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on`
: `changed priority to ${humanizeValue(details.priority)} on`;
}
return null;
}
function formatIssueUpdatedAction(details: ActivityDetails): string | null {
if (!details) return null;
const previous = asRecord(details._previous) ?? {};
const parts: string[] = [];
if (details.status !== undefined) {
const from = previous.status;
parts.push(
from
? `changed the status from ${humanizeValue(from)} to ${humanizeValue(details.status)}`
: `changed the status to ${humanizeValue(details.status)}`,
);
}
if (details.priority !== undefined) {
const from = previous.priority;
parts.push(
from
? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}`
: `changed the priority to ${humanizeValue(details.priority)}`,
);
}
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
parts.push(details.assigneeAgentId || details.assigneeUserId ? "assigned the issue" : "unassigned the issue");
}
if (details.title !== undefined) parts.push("updated the title");
if (details.description !== undefined) parts.push("updated the description");
return parts.length > 0 ? parts.join(", ") : null;
}
function formatStructuredIssueChange(input: {
action: string;
details: ActivityDetails;
options: ActivityFormatOptions;
forIssueDetail: boolean;
}): string | null {
const details = input.details;
if (!details) return null;
if (input.action === "issue.blockers_updated") {
const added = readIssueReferences(details, "addedBlockedByIssues").map(formatIssueReferenceLabel);
const removed = readIssueReferences(details, "removedBlockedByIssues").map(formatIssueReferenceLabel);
if (added.length > 0 && removed.length === 0) {
const changed = formatChangedEntityLabel("blocker", "blockers", added);
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
}
if (removed.length > 0 && added.length === 0) {
const changed = formatChangedEntityLabel("blocker", "blockers", removed);
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
}
return input.forIssueDetail ? "updated blockers" : "updated blockers on";
}
if (input.action === "issue.reviewers_updated" || input.action === "issue.approvers_updated") {
const added = readParticipants(details, "addedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
const removed = readParticipants(details, "removedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
const singular = input.action === "issue.reviewers_updated" ? "reviewer" : "approver";
const plural = input.action === "issue.reviewers_updated" ? "reviewers" : "approvers";
if (added.length > 0 && removed.length === 0) {
const changed = formatChangedEntityLabel(singular, plural, added);
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
}
if (removed.length > 0 && added.length === 0) {
const changed = formatChangedEntityLabel(singular, plural, removed);
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
}
return input.forIssueDetail ? `updated ${plural}` : `updated ${plural} on`;
}
return null;
}
export function formatActivityVerb(
action: string,
details?: Record<string, unknown> | null,
options: ActivityFormatOptions = {},
): string {
if (action === "issue.updated") {
const issueUpdatedVerb = formatIssueUpdatedVerb(details);
if (issueUpdatedVerb) return issueUpdatedVerb;
}
const structuredChange = formatStructuredIssueChange({
action,
details,
options,
forIssueDetail: false,
});
if (structuredChange) return structuredChange;
return ACTIVITY_ROW_VERBS[action] ?? action.replace(/[._]/g, " ");
}
export function formatIssueActivityAction(
action: string,
details?: Record<string, unknown> | null,
options: ActivityFormatOptions = {},
): string {
if (action === "issue.updated") {
const issueUpdatedAction = formatIssueUpdatedAction(details);
if (issueUpdatedAction) return issueUpdatedAction;
}
const structuredChange = formatStructuredIssueChange({
action,
details,
options,
forIssueDetail: true,
});
if (structuredChange) return structuredChange;
if (
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
details
) {
const key = typeof details.key === "string" ? details.key : "document";
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
return `${ISSUE_ACTIVITY_LABELS[action] ?? action} ${key}${title}`;
}
return ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
}

View file

@ -12,6 +12,7 @@ import type {
} from "@paperclipai/shared";
import {
DEFAULT_INBOX_ISSUE_COLUMNS,
buildInboxDismissedAtByKey,
computeInboxBadgeData,
getAvailableInboxIssueColumns,
getApprovalsForTab,
@ -19,6 +20,7 @@ import {
getInboxKeyboardSelectionIndex,
getRecentTouchedIssues,
getUnreadTouchedIssues,
isInboxEntityDismissed,
isMineInboxTab,
loadInboxIssueColumns,
loadLastInboxTab,
@ -287,7 +289,8 @@ describe("inbox helpers", () => {
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
],
mineIssues: [makeIssue("1", true)],
dismissed: new Set<string>(),
dismissedAlerts: new Set<string>(),
dismissedAtByKey: new Map<string, number>(),
});
expect(result).toEqual({
@ -307,7 +310,8 @@ describe("inbox helpers", () => {
dashboard,
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
mineIssues: [],
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
dismissedAlerts: new Set<string>(["alert:budget", "alert:agent-errors"]),
dismissedAtByKey: new Map<string, number>([["run:run-1", new Date("2026-03-11T00:00:00.000Z").getTime()]]),
});
expect(result).toEqual({
@ -327,7 +331,8 @@ describe("inbox helpers", () => {
dashboard,
heartbeatRuns: [],
mineIssues: [makeIssue("1", false), makeIssue("2", false), makeIssue("3", true)],
dismissed: new Set<string>(),
dismissedAlerts: new Set<string>(),
dismissedAtByKey: new Map(),
});
expect(result.mineIssues).toBe(1);
@ -335,6 +340,35 @@ describe("inbox helpers", () => {
expect(result.inbox).toBe(3);
});
it("resurfaces non-issue items when they change after dismissal", () => {
const dismissedAtByKey = buildInboxDismissedAtByKey([
{
id: "dismissal-1",
companyId: "company-1",
userId: "user-1",
itemKey: "approval:approval-1",
dismissedAt: new Date("2026-03-11T01:00:00.000Z"),
createdAt: new Date("2026-03-11T01:00:00.000Z"),
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
},
]);
expect(
isInboxEntityDismissed(
dismissedAtByKey,
"approval:approval-1",
new Date("2026-03-11T00:30:00.000Z"),
),
).toBe(true);
expect(
isInboxEntityDismissed(
dismissedAtByKey,
"approval:approval-1",
new Date("2026-03-11T01:30:00.000Z"),
),
).toBe(false);
});
it("keeps read issues in the touched list but excludes them from unread counts", () => {
const issues = [makeIssue("1", true), makeIssue("2", false)];

View file

@ -1,4 +1,11 @@
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
import type {
Approval,
DashboardSummary,
HeartbeatRun,
InboxDismissal,
Issue,
JoinRequest,
} from "@paperclipai/shared";
export const RECENT_ISSUES_LIMIT = 100;
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
@ -44,16 +51,19 @@ export interface InboxBadgeData {
alerts: number;
}
export function loadDismissedInboxItems(): Set<string> {
export function loadDismissedInboxAlerts(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.startsWith("alert:")));
} catch {
return new Set();
}
}
export function saveDismissedInboxItems(ids: Set<string>) {
export function saveDismissedInboxAlerts(ids: Set<string>) {
try {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
} catch {
@ -61,6 +71,22 @@ export function saveDismissedInboxItems(ids: Set<string>) {
}
}
export function buildInboxDismissedAtByKey(dismissals: InboxDismissal[]): Map<string, number> {
return new Map(
dismissals.map((dismissal) => [dismissal.itemKey, normalizeTimestamp(dismissal.dismissedAt)]),
);
}
export function isInboxEntityDismissed(
dismissedAtByKey: ReadonlyMap<string, number>,
itemKey: string,
activityAt: string | Date | null | undefined,
): boolean {
const dismissedAt = dismissedAtByKey.get(itemKey);
if (dismissedAt == null) return false;
return dismissedAt >= normalizeTimestamp(activityAt);
}
export function loadReadInboxItems(): Set<string> {
try {
const raw = localStorage.getItem(READ_ITEMS_KEY);
@ -426,25 +452,27 @@ export function computeInboxBadgeData({
dashboard,
heartbeatRuns,
mineIssues,
dismissed,
dismissedAlerts,
dismissedAtByKey,
}: {
approvals: Approval[];
joinRequests: JoinRequest[];
dashboard: DashboardSummary | undefined;
heartbeatRuns: HeartbeatRun[];
mineIssues: Issue[];
dismissed: Set<string>;
dismissedAlerts: Set<string>;
dismissedAtByKey: ReadonlyMap<string, number>;
}): InboxBadgeData {
const actionableApprovals = approvals.filter(
(approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
!dismissed.has(`approval:${approval.id}`),
!isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt),
).length;
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !dismissed.has(`run:${run.id}`),
(run) => !isInboxEntityDismissed(dismissedAtByKey, `run:${run.id}`, run.createdAt),
).length;
const visibleJoinRequests = joinRequests.filter(
(jr) => !dismissed.has(`join:${jr.id}`),
(jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt),
).length;
const visibleMineIssues = mineIssues.filter((issue) => issue.isUnreadForMe).length;
const agentErrorCount = dashboard?.agents.error ?? 0;
@ -453,11 +481,11 @@ export function computeInboxBadgeData({
const showAggregateAgentError =
agentErrorCount > 0 &&
failedRuns === 0 &&
!dismissed.has("alert:agent-errors");
!dismissedAlerts.has("alert:agent-errors");
const showBudgetAlert =
monthBudgetCents > 0 &&
monthUtilizationPercent >= 80 &&
!dismissed.has("alert:budget");
!dismissedAlerts.has("alert:budget");
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return {

View file

@ -130,6 +130,43 @@ describe("buildAssistantPartsFromTranscript", () => {
]);
});
it("treats a completed tool-only segment as resolved once a tool_result arrives", () => {
const result = buildAssistantPartsFromTranscript([
{ kind: "thinking", ts: "2026-04-06T12:00:00.000Z", text: "Checking the task." },
{
kind: "tool_call",
ts: "2026-04-06T12:00:01.000Z",
name: "search",
toolUseId: "tool-1",
input: { query: "paperclip" },
},
{
kind: "tool_result",
ts: "2026-04-06T12:00:02.000Z",
toolUseId: "tool-1",
content: "search completed",
isError: false,
},
{ kind: "assistant", ts: "2026-04-06T12:00:03.000Z", text: "Found the relevant code." },
]);
expect(result.parts).toMatchObject([
{ type: "reasoning", text: "Checking the task." },
{
type: "tool-call",
toolCallId: "tool-1",
toolName: "search",
result: "search completed",
isError: false,
},
{ type: "text", text: "Found the relevant code." },
]);
expect(result.segments).toEqual([{
startMs: new Date("2026-04-06T12:00:00.000Z").getTime(),
endMs: new Date("2026-04-06T12:00:02.000Z").getTime(),
}]);
});
it("keeps run errors while suppressing init and system transcript noise", () => {
const result = buildAssistantPartsFromTranscript([
{

View file

@ -0,0 +1,34 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { buildNewAgentRuntimeConfig } from "./new-agent-runtime-config";
describe("buildNewAgentRuntimeConfig", () => {
it("defaults new agents to no timer heartbeat", () => {
expect(buildNewAgentRuntimeConfig()).toEqual({
heartbeat: {
enabled: false,
intervalSec: 300,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
});
});
it("preserves explicit heartbeat settings", () => {
expect(
buildNewAgentRuntimeConfig({
heartbeatEnabled: true,
intervalSec: 3600,
}),
).toEqual({
heartbeat: {
enabled: true,
intervalSec: 3600,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
});
});
});

View file

@ -0,0 +1,16 @@
import { defaultCreateValues } from "../components/agent-config-defaults";
export function buildNewAgentRuntimeConfig(input?: {
heartbeatEnabled?: boolean;
intervalSec?: number;
}) {
return {
heartbeat: {
enabled: input?.heartbeatEnabled ?? defaultCreateValues.heartbeatEnabled,
intervalSec: input?.intervalSec ?? defaultCreateValues.intervalSec,
wakeOnDemand: true,
cooldownSec: 10,
maxConcurrentRuns: 1,
},
};
}

View file

@ -107,6 +107,7 @@ export const queryKeys = {
},
dashboard: (companyId: string) => ["dashboard", companyId] as const,
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,
inboxDismissals: (companyId: string) => ["inbox-dismissals", companyId] as const,
activity: (companyId: string) => ["activity", companyId] as const,
costs: (companyId: string, from?: string, to?: string) =>
["costs", companyId, from, to] as const,