mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
Add the inbox mine tab and archive flow
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b34fa3b273
commit
995f5b0b66
21 changed files with 12514 additions and 43 deletions
|
|
@ -165,10 +165,11 @@ function boardRoutes() {
|
|||
<Route path="costs" element={<Costs />} />
|
||||
<Route path="activity" element={<Activity />} />
|
||||
<Route path="inbox" element={<InboxRootRedirect />} />
|
||||
<Route path="inbox/mine" element={<Inbox />} />
|
||||
<Route path="inbox/recent" element={<Inbox />} />
|
||||
<Route path="inbox/unread" element={<Inbox />} />
|
||||
<Route path="inbox/all" element={<Inbox />} />
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/recent" replace />} />
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
|
||||
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const issuesApi = {
|
|||
participantAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
touchedByUserId?: string;
|
||||
inboxArchivedByUserId?: string;
|
||||
unreadForUserId?: string;
|
||||
labelId?: string;
|
||||
originKind?: string;
|
||||
|
|
@ -36,6 +37,7 @@ export const issuesApi = {
|
|||
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
|
||||
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
|
||||
if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId);
|
||||
if (filters?.inboxArchivedByUserId) params.set("inboxArchivedByUserId", filters.inboxArchivedByUserId);
|
||||
if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId);
|
||||
if (filters?.labelId) params.set("labelId", filters.labelId);
|
||||
if (filters?.originKind) params.set("originKind", filters.originKind);
|
||||
|
|
@ -51,6 +53,10 @@ export const issuesApi = {
|
|||
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
|
||||
get: (id: string) => api.get<Issue>(`/issues/${id}`),
|
||||
markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}),
|
||||
archiveFromInbox: (id: string) =>
|
||||
api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}),
|
||||
unarchiveFromInbox: (id: string) =>
|
||||
api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`),
|
||||
create: (companyId: string, data: Record<string, unknown>) =>
|
||||
api.post<Issue>(`/companies/${companyId}/issues`, data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<Issue>(`/issues/${id}`, data),
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function IssueRow({
|
|||
to={`/issues/${issuePathId}`}
|
||||
state={issueLinkState}
|
||||
className={cn(
|
||||
"flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors hover:bg-accent/50 last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
|
||||
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors hover:bg-accent/50 last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -424,6 +424,7 @@ export function NewIssueDialog() {
|
|||
},
|
||||
onSuccess: ({ issue, companyId, failures }) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
||||
|
|
|
|||
152
ui/src/components/SwipeToArchive.tsx
Normal file
152
ui/src/components/SwipeToArchive.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Archive } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface SwipeToArchiveProps {
|
||||
children: ReactNode;
|
||||
onArchive: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COMMIT_THRESHOLD = 0.4;
|
||||
const MAX_SWIPE = 0.92;
|
||||
const COMMIT_DELAY_MS = 210;
|
||||
|
||||
export function SwipeToArchive({
|
||||
children,
|
||||
onArchive,
|
||||
disabled = false,
|
||||
className,
|
||||
}: SwipeToArchiveProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const startPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const widthRef = useRef(0);
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isCollapsing, setIsCollapsing] = useState(false);
|
||||
const [lockedHeight, setLockedHeight] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current !== null) {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const reset = () => {
|
||||
startPointRef.current = null;
|
||||
setIsDragging(false);
|
||||
setOffsetX(0);
|
||||
};
|
||||
|
||||
const commitArchive = () => {
|
||||
const node = containerRef.current;
|
||||
if (!node) {
|
||||
onArchive();
|
||||
return;
|
||||
}
|
||||
setIsDragging(false);
|
||||
setLockedHeight(node.offsetHeight);
|
||||
setOffsetX(-Math.max(widthRef.current, node.offsetWidth));
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
setIsCollapsing(true);
|
||||
});
|
||||
});
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
onArchive();
|
||||
}, COMMIT_DELAY_MS);
|
||||
};
|
||||
|
||||
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (disabled || event.touches.length !== 1) return;
|
||||
const touch = event.touches[0];
|
||||
const node = containerRef.current;
|
||||
widthRef.current = node?.offsetWidth ?? 0;
|
||||
setLockedHeight(node?.offsetHeight ?? null);
|
||||
setIsCollapsing(false);
|
||||
startPointRef.current = { x: touch.clientX, y: touch.clientY };
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (disabled || isCollapsing) return;
|
||||
const startPoint = startPointRef.current;
|
||||
if (!startPoint || event.touches.length !== 1) return;
|
||||
|
||||
const touch = event.touches[0];
|
||||
const deltaX = touch.clientX - startPoint.x;
|
||||
const deltaY = touch.clientY - startPoint.y;
|
||||
|
||||
if (!isDragging) {
|
||||
if (Math.abs(deltaX) < 6) return;
|
||||
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
||||
startPointRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (deltaX >= 0) {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
setOffsetX(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxSwipe = widthRef.current > 0 ? widthRef.current * MAX_SWIPE : Number.POSITIVE_INFINITY;
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
setOffsetX(Math.max(deltaX, -maxSwipe));
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (disabled || isCollapsing) return;
|
||||
const shouldCommit =
|
||||
widthRef.current > 0 && Math.abs(offsetX) >= widthRef.current * COMMIT_THRESHOLD;
|
||||
if (shouldCommit) {
|
||||
commitArchive();
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
};
|
||||
|
||||
const archiveReveal = widthRef.current > 0 ? Math.min(Math.abs(offsetX) / widthRef.current, 1) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("relative overflow-hidden touch-pan-y", className)}
|
||||
style={{
|
||||
height: lockedHeight === null ? undefined : isCollapsing ? 0 : lockedHeight,
|
||||
opacity: isCollapsing ? 0 : 1,
|
||||
transition: isCollapsing ? "height 200ms ease, opacity 200ms ease" : undefined,
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-end bg-emerald-600 px-4 text-white"
|
||||
style={{ opacity: Math.max(archiveReveal, 0.2) }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<Archive className="h-4 w-4" />
|
||||
Archive
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="relative bg-card will-change-transform"
|
||||
style={{
|
||||
transform: `translate3d(${offsetX}px, 0, 0)`,
|
||||
transition: isDragging ? "none" : "transform 180ms ease-out",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -24,6 +24,9 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
|||
},
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.listMineByMe("company-1"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.listTouchedByMe("company-1"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -374,7 +374,7 @@ function buildJoinRequestToast(
|
|||
title: `${label} wants to join`,
|
||||
body: "A new join request is waiting for approval.",
|
||||
tone: "info",
|
||||
action: { label: "View inbox", href: "/inbox/unread" },
|
||||
action: { label: "View inbox", href: "/inbox/mine" },
|
||||
dedupeKey: `join-request:${entityId}`,
|
||||
};
|
||||
}
|
||||
|
|
@ -479,6 +479,7 @@ function invalidateActivityQueries(
|
|||
|
||||
if (entityType === "issue") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) });
|
||||
if (entityId) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
getRecentTouchedIssues,
|
||||
loadDismissedInboxItems,
|
||||
saveDismissedInboxItems,
|
||||
getUnreadTouchedIssues,
|
||||
} from "../lib/inbox";
|
||||
|
||||
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
||||
|
|
@ -72,20 +71,18 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
|||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const { data: touchedIssues = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.listTouchedByMe(companyId!),
|
||||
const { data: mineIssuesRaw = [] } = useQuery({
|
||||
queryKey: queryKeys.issues.listMineByMe(companyId!),
|
||||
queryFn: () =>
|
||||
issuesApi.list(companyId!, {
|
||||
touchedByUserId: "me",
|
||||
inboxArchivedByUserId: "me",
|
||||
status: INBOX_ISSUE_STATUSES,
|
||||
}),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const unreadIssues = useMemo(
|
||||
() => getUnreadTouchedIssues(getRecentTouchedIssues(touchedIssues)),
|
||||
[touchedIssues],
|
||||
);
|
||||
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
|
||||
|
||||
const { data: heartbeatRuns = [] } = useQuery({
|
||||
queryKey: queryKeys.heartbeats(companyId!),
|
||||
|
|
@ -100,9 +97,9 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
|||
joinRequests,
|
||||
dashboard,
|
||||
heartbeatRuns,
|
||||
unreadIssues,
|
||||
mineIssues,
|
||||
dismissed,
|
||||
}),
|
||||
[approvals, joinRequests, dashboard, heartbeatRuns, unreadIssues, dismissed],
|
||||
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissed],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ describe("inbox helpers", () => {
|
|||
makeRun("run-latest", "timed_out", "2026-03-11T01:00:00.000Z"),
|
||||
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
|
||||
],
|
||||
unreadIssues: [makeIssue("1", true)],
|
||||
mineIssues: [makeIssue("1", true)],
|
||||
dismissed: new Set<string>(),
|
||||
});
|
||||
|
||||
|
|
@ -219,7 +219,7 @@ describe("inbox helpers", () => {
|
|||
approvals: 1,
|
||||
failedRuns: 2,
|
||||
joinRequests: 1,
|
||||
unreadTouchedIssues: 1,
|
||||
mineIssues: 1,
|
||||
alerts: 1,
|
||||
});
|
||||
});
|
||||
|
|
@ -230,7 +230,7 @@ describe("inbox helpers", () => {
|
|||
joinRequests: [],
|
||||
dashboard,
|
||||
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
|
||||
unreadIssues: [],
|
||||
mineIssues: [],
|
||||
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
|
||||
});
|
||||
|
||||
|
|
@ -239,7 +239,7 @@ describe("inbox helpers", () => {
|
|||
approvals: 0,
|
||||
failedRuns: 0,
|
||||
joinRequests: 0,
|
||||
unreadTouchedIssues: 0,
|
||||
mineIssues: 0,
|
||||
alerts: 0,
|
||||
});
|
||||
});
|
||||
|
|
@ -262,6 +262,11 @@ describe("inbox helpers", () => {
|
|||
),
|
||||
];
|
||||
|
||||
expect(getApprovalsForTab(approvals, "mine", "all").map((approval) => approval.id)).toEqual([
|
||||
"approval-revision",
|
||||
"approval-approved",
|
||||
"approval-pending",
|
||||
]);
|
||||
expect(getApprovalsForTab(approvals, "recent", "all").map((approval) => approval.id)).toEqual([
|
||||
"approval-revision",
|
||||
"approval-approved",
|
||||
|
|
@ -338,10 +343,21 @@ describe("inbox helpers", () => {
|
|||
});
|
||||
|
||||
it("can include sections on recent without forcing them to be unread", () => {
|
||||
expect(
|
||||
shouldShowInboxSection({
|
||||
tab: "mine",
|
||||
hasItems: true,
|
||||
showOnMine: true,
|
||||
showOnRecent: false,
|
||||
showOnUnread: false,
|
||||
showOnAll: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldShowInboxSection({
|
||||
tab: "recent",
|
||||
hasItems: true,
|
||||
showOnMine: false,
|
||||
showOnRecent: true,
|
||||
showOnUnread: false,
|
||||
showOnAll: false,
|
||||
|
|
@ -351,6 +367,7 @@ describe("inbox helpers", () => {
|
|||
shouldShowInboxSection({
|
||||
tab: "unread",
|
||||
hasItems: true,
|
||||
showOnMine: true,
|
||||
showOnRecent: true,
|
||||
showOnUnread: false,
|
||||
showOnAll: false,
|
||||
|
|
@ -371,16 +388,16 @@ describe("inbox helpers", () => {
|
|||
expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
it("defaults the remembered inbox tab to recent and persists all", () => {
|
||||
it("defaults the remembered inbox tab to mine and persists all", () => {
|
||||
localStorage.clear();
|
||||
expect(loadLastInboxTab()).toBe("recent");
|
||||
expect(loadLastInboxTab()).toBe("mine");
|
||||
|
||||
saveLastInboxTab("all");
|
||||
expect(loadLastInboxTab()).toBe("all");
|
||||
});
|
||||
|
||||
it("maps legacy new-tab storage to recent", () => {
|
||||
it("maps legacy new-tab storage to mine", () => {
|
||||
localStorage.setItem("paperclip:inbox:last-tab", "new");
|
||||
expect(loadLastInboxTab()).toBe("recent");
|
||||
expect(loadLastInboxTab()).toBe("mine");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
|||
export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
||||
export const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
||||
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||
export type InboxTab = "recent" | "unread" | "all";
|
||||
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||
export type InboxWorkItem =
|
||||
| {
|
||||
|
|
@ -40,7 +40,7 @@ export interface InboxBadgeData {
|
|||
approvals: number;
|
||||
failedRuns: number;
|
||||
joinRequests: number;
|
||||
unreadTouchedIssues: number;
|
||||
mineIssues: number;
|
||||
alerts: number;
|
||||
}
|
||||
|
||||
|
|
@ -64,11 +64,11 @@ export function saveDismissedInboxItems(ids: Set<string>) {
|
|||
export function loadLastInboxTab(): InboxTab {
|
||||
try {
|
||||
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
|
||||
if (raw === "all" || raw === "unread" || raw === "recent") return raw;
|
||||
if (raw === "new") return "recent";
|
||||
return "recent";
|
||||
if (raw === "all" || raw === "unread" || raw === "recent" || raw === "mine") return raw;
|
||||
if (raw === "new") return "mine";
|
||||
return "mine";
|
||||
} catch {
|
||||
return "recent";
|
||||
return "mine";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,7 +135,7 @@ export function getApprovalsForTab(
|
|||
(a, b) => normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt),
|
||||
);
|
||||
|
||||
if (tab === "recent") return sortedApprovals;
|
||||
if (tab === "mine" || tab === "recent") return sortedApprovals;
|
||||
if (tab === "unread") {
|
||||
return sortedApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status));
|
||||
}
|
||||
|
|
@ -203,17 +203,20 @@ export function getInboxWorkItems({
|
|||
export function shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems,
|
||||
showOnMine,
|
||||
showOnRecent,
|
||||
showOnUnread,
|
||||
showOnAll,
|
||||
}: {
|
||||
tab: InboxTab;
|
||||
hasItems: boolean;
|
||||
showOnMine: boolean;
|
||||
showOnRecent: boolean;
|
||||
showOnUnread: boolean;
|
||||
showOnAll: boolean;
|
||||
}): boolean {
|
||||
if (!hasItems) return false;
|
||||
if (tab === "mine") return showOnMine;
|
||||
if (tab === "recent") return showOnRecent;
|
||||
if (tab === "unread") return showOnUnread;
|
||||
return showOnAll;
|
||||
|
|
@ -224,14 +227,14 @@ export function computeInboxBadgeData({
|
|||
joinRequests,
|
||||
dashboard,
|
||||
heartbeatRuns,
|
||||
unreadIssues,
|
||||
mineIssues,
|
||||
dismissed,
|
||||
}: {
|
||||
approvals: Approval[];
|
||||
joinRequests: JoinRequest[];
|
||||
dashboard: DashboardSummary | undefined;
|
||||
heartbeatRuns: HeartbeatRun[];
|
||||
unreadIssues: Issue[];
|
||||
mineIssues: Issue[];
|
||||
dismissed: Set<string>;
|
||||
}): InboxBadgeData {
|
||||
const actionableApprovals = approvals.filter((approval) =>
|
||||
|
|
@ -240,7 +243,7 @@ export function computeInboxBadgeData({
|
|||
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
|
||||
(run) => !dismissed.has(`run:${run.id}`),
|
||||
).length;
|
||||
const unreadTouchedIssues = unreadIssues.length;
|
||||
const visibleMineIssues = mineIssues.length;
|
||||
const agentErrorCount = dashboard?.agents.error ?? 0;
|
||||
const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0;
|
||||
const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0;
|
||||
|
|
@ -255,11 +258,11 @@ export function computeInboxBadgeData({
|
|||
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
|
||||
|
||||
return {
|
||||
inbox: actionableApprovals + joinRequests.length + failedRuns + unreadTouchedIssues + alerts,
|
||||
inbox: actionableApprovals + joinRequests.length + failedRuns + visibleMineIssues + alerts,
|
||||
approvals: actionableApprovals,
|
||||
failedRuns,
|
||||
joinRequests: joinRequests.length,
|
||||
unreadTouchedIssues,
|
||||
mineIssues: visibleMineIssues,
|
||||
alerts,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export const queryKeys = {
|
|||
search: (companyId: string, q: string, projectId?: string) =>
|
||||
["issues", companyId, "search", q, projectId ?? "__all-projects__"] as const,
|
||||
listAssignedToMe: (companyId: string) => ["issues", companyId, "assigned-to-me"] as const,
|
||||
listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const,
|
||||
listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const,
|
||||
listUnreadTouchedByMe: (companyId: string) => ["issues", companyId, "unread-touched-by-me"] as const,
|
||||
labels: (companyId: string) => ["issues", companyId, "labels"] as const,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
|||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { IssueRow } from "../components/IssueRow";
|
||||
import { SwipeToArchive } from "../components/SwipeToArchive";
|
||||
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
|
|
@ -64,6 +65,8 @@ type SectionKey =
|
|||
| "work_items"
|
||||
| "alerts";
|
||||
|
||||
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
||||
|
||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
||||
|
|
@ -91,6 +94,36 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function InboxArchiveButton({
|
||||
onArchive,
|
||||
disabled,
|
||||
}: {
|
||||
onArchive: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onArchive();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onArchive();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
|
||||
aria-label="Archive from mine"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedRunInboxRow({
|
||||
run,
|
||||
issueById,
|
||||
|
|
@ -370,9 +403,11 @@ export function Inbox() {
|
|||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||
const { dismissed, dismiss } = useDismissedInboxItems();
|
||||
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "recent";
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
||||
const tab: InboxTab =
|
||||
pathSegment === "all" || pathSegment === "unread" ? pathSegment : "recent";
|
||||
pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread"
|
||||
? pathSegment
|
||||
: "mine";
|
||||
const issueLinkState = useMemo(
|
||||
() =>
|
||||
createIssueDetailLocationState(
|
||||
|
|
@ -436,6 +471,19 @@ export function Inbox() {
|
|||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const {
|
||||
data: mineIssuesRaw = [],
|
||||
isLoading: isMineIssuesLoading,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.issues.listMineByMe(selectedCompanyId!),
|
||||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
touchedByUserId: "me",
|
||||
inboxArchivedByUserId: "me",
|
||||
status: INBOX_ISSUE_STATUSES,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const {
|
||||
data: touchedIssuesRaw = [],
|
||||
isLoading: isTouchedIssuesLoading,
|
||||
|
|
@ -444,7 +492,7 @@ export function Inbox() {
|
|||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
touchedByUserId: "me",
|
||||
status: "backlog,todo,in_progress,in_review,blocked,done",
|
||||
status: INBOX_ISSUE_STATUSES,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
|
@ -455,14 +503,19 @@ export function Inbox() {
|
|||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
|
||||
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
|
||||
const unreadTouchedIssues = useMemo(
|
||||
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||
[touchedIssues],
|
||||
);
|
||||
const issuesToRender = useMemo(
|
||||
() => (tab === "unread" ? unreadTouchedIssues : touchedIssues),
|
||||
[tab, touchedIssues, unreadTouchedIssues],
|
||||
() => {
|
||||
if (tab === "mine") return mineIssues;
|
||||
if (tab === "unread") return unreadTouchedIssues;
|
||||
return touchedIssues;
|
||||
},
|
||||
[tab, mineIssues, touchedIssues, unreadTouchedIssues],
|
||||
);
|
||||
|
||||
const agentById = useMemo(() => {
|
||||
|
|
@ -511,6 +564,7 @@ export function Inbox() {
|
|||
|
||||
const joinRequestsForTab = useMemo(() => {
|
||||
if (tab === "all" && !showJoinRequestsCategory) return [];
|
||||
if (tab === "mine") return joinRequests;
|
||||
if (tab === "recent") return joinRequests;
|
||||
if (tab === "unread") return joinRequests;
|
||||
return joinRequests;
|
||||
|
|
@ -624,14 +678,45 @@ export function Inbox() {
|
|||
});
|
||||
|
||||
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
|
||||
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const invalidateInboxIssueQueries = () => {
|
||||
if (!selectedCompanyId) return;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||
};
|
||||
|
||||
const archiveIssueMutation = useMutation({
|
||||
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
|
||||
onMutate: (id) => {
|
||||
setActionError(null);
|
||||
setArchivingIssueIds((prev) => new Set(prev).add(id));
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateInboxIssueQueries();
|
||||
},
|
||||
onError: (err, id) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to archive issue");
|
||||
setArchivingIssueIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onSettled: (_data, error, id) => {
|
||||
if (error) return;
|
||||
window.setTimeout(() => {
|
||||
setArchivingIssueIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: (id: string) => issuesApi.markRead(id),
|
||||
onMutate: (id) => {
|
||||
|
|
@ -692,6 +777,7 @@ export function Inbox() {
|
|||
const showAlertsSection = shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems: hasAlerts,
|
||||
showOnMine: hasAlerts,
|
||||
showOnRecent: hasAlerts,
|
||||
showOnUnread: hasAlerts,
|
||||
showOnAll: showAlertsCategory && hasAlerts,
|
||||
|
|
@ -707,12 +793,14 @@ export function Inbox() {
|
|||
!isApprovalsLoading &&
|
||||
!isDashboardLoading &&
|
||||
!isIssuesLoading &&
|
||||
!isMineIssuesLoading &&
|
||||
!isTouchedIssuesLoading &&
|
||||
!isRunsLoading;
|
||||
|
||||
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
||||
const unreadIssueIds = unreadTouchedIssues
|
||||
.filter((issue) => !fadingOutIssues.has(issue.id))
|
||||
const markAllReadIssues = (tab === "mine" ? mineIssues : unreadTouchedIssues)
|
||||
.filter((issue) => issue.isUnreadForMe && !fadingOutIssues.has(issue.id) && !archivingIssueIds.has(issue.id));
|
||||
const unreadIssueIds = markAllReadIssues
|
||||
.map((issue) => issue.id);
|
||||
const canMarkAllRead = unreadIssueIds.length > 0;
|
||||
|
||||
|
|
@ -723,6 +811,10 @@ export function Inbox() {
|
|||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{
|
||||
value: "mine",
|
||||
label: "Mine",
|
||||
},
|
||||
{
|
||||
value: "recent",
|
||||
label: "Recent",
|
||||
|
|
@ -796,7 +888,9 @@ export function Inbox() {
|
|||
<EmptyState
|
||||
icon={InboxIcon}
|
||||
message={
|
||||
tab === "unread"
|
||||
tab === "mine"
|
||||
? "Inbox zero."
|
||||
: tab === "unread"
|
||||
? "No new inbox items."
|
||||
: tab === "recent"
|
||||
? "No recent inbox items."
|
||||
|
|
@ -854,11 +948,18 @@ export function Inbox() {
|
|||
const issue = item.issue;
|
||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||
const isFading = fadingOutIssues.has(issue.id);
|
||||
return (
|
||||
const isMineTab = tab === "mine";
|
||||
const isArchiving = archivingIssueIds.has(issue.id);
|
||||
const row = (
|
||||
<IssueRow
|
||||
key={`issue:${issue.id}`}
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
className={
|
||||
isArchiving
|
||||
? "pointer-events-none -translate-x-3 opacity-0 transition-transform transition-opacity duration-200"
|
||||
: "transition-transform transition-opacity duration-200"
|
||||
}
|
||||
desktopMetaLeading={(
|
||||
<>
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
|
|
@ -885,8 +986,20 @@ export function Inbox() {
|
|||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
: `updated ${timeAgo(issue.updatedAt)}`
|
||||
}
|
||||
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
||||
unreadState={
|
||||
isMineTab
|
||||
? null
|
||||
: isUnread ? "visible" : isFading ? "fading" : "hidden"
|
||||
}
|
||||
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||
desktopTrailing={
|
||||
isMineTab ? (
|
||||
<InboxArchiveButton
|
||||
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
||||
disabled={isArchiving || archiveIssueMutation.isPending}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
trailingMeta={
|
||||
issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
|
|
@ -894,6 +1007,16 @@ export function Inbox() {
|
|||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return isMineTab ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${issue.id}`}
|
||||
disabled={isArchiving || archiveIssueMutation.isPending}
|
||||
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -462,6 +462,7 @@ export function IssueDetail() {
|
|||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||
|
|
@ -472,6 +473,7 @@ export function IssueDetail() {
|
|||
mutationFn: (id: string) => issuesApi.markRead(id),
|
||||
onSuccess: () => {
|
||||
if (selectedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue