mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +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
|
|
@ -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