mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
Merge public-gh/master into pap-1239-ui-ux
This commit is contained in:
commit
b578bf1f51
56 changed files with 16126 additions and 397 deletions
8
ui/src/api/inboxDismissals.ts
Normal file
8
ui/src/api/inboxDismissals.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { InboxDismissal } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const inboxDismissalsApi = {
|
||||
list: (companyId: string) => api.get<InboxDismissal[]>(`/companies/${companyId}/inbox-dismissals`),
|
||||
dismiss: (companyId: string, itemKey: string) =>
|
||||
api.post<InboxDismissal>(`/companies/${companyId}/inbox-dismissals`, { itemKey }),
|
||||
};
|
||||
|
|
@ -15,4 +15,5 @@ export { dashboardApi } from "./dashboard";
|
|||
export { heartbeatsApi } from "./heartbeats";
|
||||
export { instanceSettingsApi } from "./instanceSettings";
|
||||
export { sidebarBadgesApi } from "./sidebarBadges";
|
||||
export { inboxDismissalsApi } from "./inboxDismissals";
|
||||
export { companySkillsApi } from "./companySkills";
|
||||
|
|
|
|||
|
|
@ -2,72 +2,9 @@ import { Link } from "@/lib/router";
|
|||
import { Identity } from "./Identity";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn } from "../lib/utils";
|
||||
import { formatActivityVerb } from "../lib/activity-format";
|
||||
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared";
|
||||
|
||||
const ACTION_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",
|
||||
};
|
||||
|
||||
function humanizeValue(value: unknown): string {
|
||||
if (typeof value !== "string") return String(value ?? "none");
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function formatVerb(action: string, details?: Record<string, unknown> | null): string {
|
||||
if (action === "issue.updated" && details) {
|
||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
||||
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 ACTION_VERBS[action] ?? action.replace(/[._]/g, " ");
|
||||
}
|
||||
|
||||
function entityLink(entityType: string, entityId: string, name?: string | null): string | null {
|
||||
switch (entityType) {
|
||||
case "issue": return `/issues/${name ?? entityId}`;
|
||||
|
|
@ -88,7 +25,7 @@ interface ActivityRowProps {
|
|||
}
|
||||
|
||||
export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
|
||||
const verb = formatVerb(event.action, event.details);
|
||||
const verb = formatActivityVerb(event.action, event.details, { agentMap });
|
||||
|
||||
const isHeartbeatEvent = event.entityType === "heartbeat_run";
|
||||
const heartbeatAgentId = isHeartbeatEvent
|
||||
|
|
|
|||
|
|
@ -923,14 +923,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
<ToggleWithNumber
|
||||
label="Heartbeat on interval"
|
||||
hint={help.heartbeatInterval}
|
||||
checked={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||
checked={eff("heartbeat", "enabled", heartbeat.enabled === true)}
|
||||
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
|
||||
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
|
||||
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
|
||||
numberLabel="sec"
|
||||
numberPrefix="Run heartbeat every"
|
||||
numberHint={help.intervalSec}
|
||||
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||
showNumber={eff("heartbeat", "enabled", heartbeat.enabled === true)}
|
||||
/>
|
||||
</div>
|
||||
<CollapsibleSection
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
buildOnboardingProjectPayload,
|
||||
selectDefaultCompanyGoalId
|
||||
} from "../lib/onboarding-launch";
|
||||
import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config";
|
||||
import {
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
DEFAULT_CODEX_LOCAL_MODEL
|
||||
|
|
@ -460,15 +461,7 @@ export function OnboardingWizard() {
|
|||
role: "ceo",
|
||||
adapterType,
|
||||
adapterConfig: buildAdapterConfig(),
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
intervalSec: 3600,
|
||||
wakeOnDemand: true,
|
||||
cooldownSec: 10,
|
||||
maxConcurrentRuns: 1
|
||||
}
|
||||
}
|
||||
runtimeConfig: buildNewAgentRuntimeConfig()
|
||||
});
|
||||
setCreatedAgentId(agent.id);
|
||||
queryClient.invalidateQueries({
|
||||
|
|
|
|||
|
|
@ -36,18 +36,20 @@ function updateVariableList(
|
|||
}
|
||||
|
||||
export function RoutineVariablesEditor({
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
value: RoutineVariable[];
|
||||
onChange: (value: RoutineVariable[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const syncedVariables = useMemo(
|
||||
() => syncRoutineVariablesWithTemplate(description, value),
|
||||
[description, value],
|
||||
() => syncRoutineVariablesWithTemplate([title, description], value),
|
||||
[description, title, value],
|
||||
);
|
||||
const syncedSignature = serializeVariables(syncedVariables);
|
||||
const currentSignature = serializeVariables(value);
|
||||
|
|
@ -68,7 +70,7 @@ export function RoutineVariablesEditor({
|
|||
<div>
|
||||
<p className="text-sm font-medium">Variables</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Detected from `{"{{name}}"}` placeholders in the routine instructions.
|
||||
Detected from `{"{{name}}"}` placeholders in the routine title and instructions.
|
||||
</p>
|
||||
</div>
|
||||
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { accessApi } from "../api/access";
|
||||
import { ApiError } from "../api/client";
|
||||
import { inboxDismissalsApi } from "../api/inboxDismissals";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
buildInboxDismissedAtByKey,
|
||||
computeInboxBadgeData,
|
||||
getRecentTouchedIssues,
|
||||
loadDismissedInboxItems,
|
||||
saveDismissedInboxItems,
|
||||
loadDismissedInboxAlerts,
|
||||
saveDismissedInboxAlerts,
|
||||
loadReadInboxItems,
|
||||
saveReadInboxItems,
|
||||
READ_ITEMS_KEY,
|
||||
|
|
@ -19,13 +21,13 @@ import {
|
|||
|
||||
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
||||
|
||||
export function useDismissedInboxItems() {
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems);
|
||||
export function useDismissedInboxAlerts() {
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxAlerts);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== "paperclip:inbox:dismissed") return;
|
||||
setDismissed(loadDismissedInboxItems());
|
||||
setDismissed(loadDismissedInboxAlerts());
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
|
|
@ -35,7 +37,7 @@ export function useDismissedInboxItems() {
|
|||
setDismissed((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(id);
|
||||
saveDismissedInboxItems(next);
|
||||
saveDismissedInboxAlerts(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
|
@ -43,6 +45,63 @@ export function useDismissedInboxItems() {
|
|||
return { dismissed, dismiss };
|
||||
}
|
||||
|
||||
export function useInboxDismissals(companyId: string | null | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = companyId
|
||||
? queryKeys.inboxDismissals(companyId)
|
||||
: ["inbox-dismissals", "__disabled__"] as const;
|
||||
|
||||
const { data: dismissals = [] } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => inboxDismissalsApi.list(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const dismissMutation = useMutation({
|
||||
mutationFn: ({ itemKey }: { itemKey: string }) => inboxDismissalsApi.dismiss(companyId!, itemKey),
|
||||
onMutate: async ({ itemKey }) => {
|
||||
if (!companyId) return { previous: [] as typeof dismissals };
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
const previous = queryClient.getQueryData<typeof dismissals>(queryKey) ?? [];
|
||||
const now = new Date();
|
||||
queryClient.setQueryData(queryKey, [
|
||||
{
|
||||
id: `optimistic:${itemKey}`,
|
||||
companyId,
|
||||
userId: "me",
|
||||
itemKey,
|
||||
dismissedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
...previous.filter((dismissal) => dismissal.itemKey !== itemKey),
|
||||
]);
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (!context) return;
|
||||
queryClient.setQueryData(queryKey, context.previous);
|
||||
},
|
||||
onSettled: () => {
|
||||
if (!companyId) return;
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
||||
},
|
||||
});
|
||||
|
||||
const dismissedAtByKey = useMemo(
|
||||
() => buildInboxDismissedAtByKey(dismissals),
|
||||
[dismissals],
|
||||
);
|
||||
|
||||
return {
|
||||
dismissals,
|
||||
dismissedAtByKey,
|
||||
dismiss: (itemKey: string) => dismissMutation.mutate({ itemKey }),
|
||||
isPending: dismissMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
export function useReadInboxItems() {
|
||||
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
|
||||
|
||||
|
|
@ -77,7 +136,8 @@ export function useReadInboxItems() {
|
|||
}
|
||||
|
||||
export function useInboxBadge(companyId: string | null | undefined) {
|
||||
const { dismissed } = useDismissedInboxItems();
|
||||
const { dismissed: dismissedAlerts } = useDismissedInboxAlerts();
|
||||
const { dismissedAtByKey } = useInboxDismissals(companyId);
|
||||
|
||||
const { data: approvals = [] } = useQuery({
|
||||
queryKey: queryKeys.approvals.list(companyId!),
|
||||
|
|
@ -134,8 +194,9 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
|||
dashboard,
|
||||
heartbeatRuns,
|
||||
mineIssues,
|
||||
dismissed,
|
||||
dismissedAlerts,
|
||||
dismissedAtByKey,
|
||||
}),
|
||||
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissed],
|
||||
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissedAlerts, dismissedAtByKey],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
60
ui/src/lib/activity-format.test.ts
Normal file
60
ui/src/lib/activity-format.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
289
ui/src/lib/activity-format.ts
Normal file
289
ui/src/lib/activity-format.ts
Normal 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, " ");
|
||||
}
|
||||
|
|
@ -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)];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
34
ui/src/lib/new-agent-runtime-config.test.ts
Normal file
34
ui/src/lib/new-agent-runtime-config.test.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
16
ui/src/lib/new-agent-runtime-config.ts
Normal file
16
ui/src/lib/new-agent-runtime-config.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ import {
|
|||
getInboxKeyboardSelectionIndex,
|
||||
getLatestFailedRunsByAgent,
|
||||
getRecentTouchedIssues,
|
||||
isInboxEntityDismissed,
|
||||
isMineInboxTab,
|
||||
loadInboxIssueColumns,
|
||||
loadInboxNesting,
|
||||
|
|
@ -100,7 +101,7 @@ import {
|
|||
type InboxTab,
|
||||
type InboxWorkItem,
|
||||
} from "../lib/inbox";
|
||||
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
|
||||
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
|
||||
|
||||
export { InboxIssueMetaLeading, InboxIssueTrailingColumns } from "../components/IssueColumns";
|
||||
|
||||
|
|
@ -596,7 +597,8 @@ export function Inbox() {
|
|||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||
const { dismissed, dismiss } = useDismissedInboxItems();
|
||||
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
|
||||
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
|
||||
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
|
||||
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
||||
|
|
@ -803,8 +805,11 @@ export function Inbox() {
|
|||
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
||||
|
||||
const failedRuns = useMemo(
|
||||
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
|
||||
[heartbeatRuns, dismissed],
|
||||
() =>
|
||||
getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter(
|
||||
(r) => !isInboxEntityDismissed(dismissedAtByKey, `run:${r.id}`, r.createdAt),
|
||||
),
|
||||
[heartbeatRuns, dismissedAtByKey],
|
||||
);
|
||||
const liveIssueIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
|
|
@ -819,10 +824,12 @@ export function Inbox() {
|
|||
const approvalsToRender = useMemo(() => {
|
||||
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
|
||||
if (tab === "mine") {
|
||||
filtered = filtered.filter((a) => !dismissed.has(`approval:${a.id}`));
|
||||
filtered = filtered.filter(
|
||||
(a) => !isInboxEntityDismissed(dismissedAtByKey, `approval:${a.id}`, a.updatedAt),
|
||||
);
|
||||
}
|
||||
return filtered;
|
||||
}, [approvals, tab, allApprovalFilter, dismissed]);
|
||||
}, [approvals, tab, allApprovalFilter, dismissedAtByKey]);
|
||||
const showJoinRequestsCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
|
||||
const showTouchedCategory =
|
||||
|
|
@ -839,9 +846,13 @@ export function Inbox() {
|
|||
|
||||
const joinRequestsForTab = useMemo(() => {
|
||||
if (tab === "all" && !showJoinRequestsCategory) return [];
|
||||
if (tab === "mine") return joinRequests.filter((jr) => !dismissed.has(`join:${jr.id}`));
|
||||
if (tab === "mine") {
|
||||
return joinRequests.filter(
|
||||
(jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt),
|
||||
);
|
||||
}
|
||||
return joinRequests;
|
||||
}, [joinRequests, tab, showJoinRequestsCategory, dismissed]);
|
||||
}, [joinRequests, tab, showJoinRequestsCategory, dismissedAtByKey]);
|
||||
|
||||
const workItemsToRender = useMemo(
|
||||
() =>
|
||||
|
|
@ -1200,14 +1211,18 @@ export function Inbox() {
|
|||
const handleArchiveNonIssue = useCallback((key: string) => {
|
||||
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
|
||||
setTimeout(() => {
|
||||
dismiss(key);
|
||||
if (key.startsWith("alert:")) {
|
||||
dismissAlert(key);
|
||||
} else {
|
||||
dismissInboxItem(key);
|
||||
}
|
||||
setArchivingNonIssueIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}, 200);
|
||||
}, [dismiss]);
|
||||
}, [dismissAlert, dismissInboxItem]);
|
||||
|
||||
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
|
||||
if (!canArchiveFromTab) return null;
|
||||
|
|
@ -1409,12 +1424,16 @@ export function Inbox() {
|
|||
}
|
||||
|
||||
const hasRunFailures = failedRuns.length > 0;
|
||||
const showAggregateAgentError = !!dashboard && dashboard.agents.error > 0 && !hasRunFailures && !dismissed.has("alert:agent-errors");
|
||||
const showAggregateAgentError =
|
||||
!!dashboard &&
|
||||
dashboard.agents.error > 0 &&
|
||||
!hasRunFailures &&
|
||||
!dismissedAlerts.has("alert:agent-errors");
|
||||
const showBudgetAlert =
|
||||
!!dashboard &&
|
||||
dashboard.costs.monthBudgetCents > 0 &&
|
||||
dashboard.costs.monthUtilizationPercent >= 80 &&
|
||||
!dismissed.has("alert:budget");
|
||||
!dismissedAlerts.has("alert:budget");
|
||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||
const showWorkItemsSection = nestedWorkItems.length > 0;
|
||||
const showAlertsSection = shouldShowInboxSection({
|
||||
|
|
@ -1711,7 +1730,7 @@ export function Inbox() {
|
|||
issueById={issueById}
|
||||
agentName={agentName(item.run.agentId)}
|
||||
issueLinkState={issueLinkState}
|
||||
onDismiss={() => dismiss(runKey)}
|
||||
onDismiss={() => dismissInboxItem(runKey)}
|
||||
onRetry={() => retryRunMutation.mutate(item.run)}
|
||||
isRetrying={retryingRunIds.has(item.run.id)}
|
||||
unreadState={nonIssueUnreadState(runKey)}
|
||||
|
|
@ -1945,7 +1964,7 @@ export function Inbox() {
|
|||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss("alert:agent-errors")}
|
||||
onClick={() => dismissAlert("alert:agent-errors")}
|
||||
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
|
|
@ -1968,7 +1987,7 @@ export function Inbox() {
|
|||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss("alert:budget")}
|
||||
onClick={() => dismissAlert("alert:budget")}
|
||||
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/alert:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sh
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatIssueActivityAction } from "@/lib/activity-format";
|
||||
import {
|
||||
Activity as ActivityIcon,
|
||||
Check,
|
||||
|
|
@ -105,48 +106,17 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
|||
queueTargetRunId?: string | null;
|
||||
};
|
||||
|
||||
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
||||
const ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS = 3000;
|
||||
const IDLE_ISSUE_RUN_POLL_INTERVAL_MS = 30000;
|
||||
const ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 5000;
|
||||
const IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 30000;
|
||||
|
||||
const ACTION_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",
|
||||
};
|
||||
|
||||
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
||||
const ISSUE_COMMENT_PAGE_SIZE = 50;
|
||||
|
||||
function keepPreviousData<T>(previousData: T | undefined) {
|
||||
return previousData;
|
||||
}
|
||||
|
||||
function humanizeValue(value: unknown): string {
|
||||
if (typeof value !== "string") return String(value ?? "none");
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
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>;
|
||||
|
|
@ -196,50 +166,6 @@ function titleizeFilename(input: string) {
|
|||
.join(" ");
|
||||
}
|
||||
|
||||
function formatAction(action: string, details?: Record<string, unknown> | null): string {
|
||||
if (action === "issue.updated" && details) {
|
||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
||||
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");
|
||||
|
||||
if (parts.length > 0) return parts.join(", ");
|
||||
}
|
||||
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 `${ACTION_LABELS[action] ?? action} ${key}${title}`;
|
||||
}
|
||||
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
|
||||
}
|
||||
|
||||
function mergeOptimisticFeedbackVote(
|
||||
previousVotes: FeedbackVote[] | undefined,
|
||||
nextVote: {
|
||||
|
|
@ -2229,7 +2155,7 @@ export function IssueDetail() {
|
|||
{activity.slice(0, 20).map((evt) => (
|
||||
<div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ActorIdentity evt={evt} agentMap={agentMap} />
|
||||
<span>{formatAction(evt.action, evt.details)}</span>
|
||||
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, currentUserId })}</span>
|
||||
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -2255,7 +2181,6 @@ export function IssueDetail() {
|
|||
)}
|
||||
</Tabs>
|
||||
|
||||
|
||||
{/* Mobile properties drawer */}
|
||||
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
|
||||
<SheetContent side="bottom" className="max-h-[85dvh] pb-[env(safe-area-inset-bottom)]">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { getUIAdapter, listUIAdapters } from "../adapters";
|
|||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
import { isValidAdapterType } from "../adapters/metadata";
|
||||
import { ReportsToPicker } from "../components/ReportsToPicker";
|
||||
import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config";
|
||||
import {
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
|
|
@ -175,15 +176,10 @@ export function NewAgent() {
|
|||
...(selectedSkillKeys.length > 0 ? { desiredSkills: selectedSkillKeys } : {}),
|
||||
adapterType: configValues.adapterType,
|
||||
adapterConfig: buildAdapterConfig(),
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: configValues.heartbeatEnabled,
|
||||
intervalSec: configValues.intervalSec,
|
||||
wakeOnDemand: true,
|
||||
cooldownSec: 10,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
},
|
||||
runtimeConfig: buildNewAgentRuntimeConfig({
|
||||
heartbeatEnabled: configValues.heartbeatEnabled,
|
||||
intervalSec: configValues.intervalSec,
|
||||
}),
|
||||
budgetMonthlyCents: 0,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -860,6 +860,7 @@ export function RoutineDetail() {
|
|||
/>
|
||||
<RoutineVariablesHint />
|
||||
<RoutineVariablesEditor
|
||||
title={editDraft.title}
|
||||
description={editDraft.description}
|
||||
value={editDraft.variables}
|
||||
onChange={(variables) => setEditDraft((current) => ({ ...current, variables }))}
|
||||
|
|
|
|||
|
|
@ -806,6 +806,7 @@ export function Routines() {
|
|||
<div className="mt-3 space-y-3">
|
||||
<RoutineVariablesHint />
|
||||
<RoutineVariablesEditor
|
||||
title={draft.title}
|
||||
description={draft.description}
|
||||
value={draft.variables}
|
||||
onChange={(variables) => setDraft((current) => ({ ...current, variables }))}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue