Merge pull request #1857 from paperclipai/PAP-878-create-a-mine-tab-in-inbox

Add a Mine tab and archive flow to inbox
This commit is contained in:
Dotta 2026-03-26 16:21:47 -05:00 committed by GitHub
commit af5b980362
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 12833 additions and 83 deletions

View file

@ -0,0 +1,17 @@
CREATE TABLE "issue_inbox_archives" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"user_id" text NOT NULL,
"archived_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DROP INDEX "board_api_keys_key_hash_idx";--> statement-breakpoint
ALTER TABLE "issue_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "issue_inbox_archives_company_issue_idx" ON "issue_inbox_archives" USING btree ("company_id","issue_id");--> statement-breakpoint
CREATE INDEX "issue_inbox_archives_company_user_idx" ON "issue_inbox_archives" USING btree ("company_id","user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "issue_inbox_archives_company_issue_user_idx" ON "issue_inbox_archives" USING btree ("company_id","issue_id","user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "board_api_keys_key_hash_idx" ON "board_api_keys" USING btree ("key_hash");

File diff suppressed because it is too large Load diff

View file

@ -316,6 +316,13 @@
"when": 1774269579794,
"tag": "0044_illegal_toad",
"breakpoints": true
},
{
"idx": 45,
"version": "7",
"when": 1774530504348,
"tag": "0045_workable_shockwave",
"breakpoints": true
}
]
}

View file

@ -31,6 +31,7 @@ export { labels } from "./labels.js";
export { issueLabels } from "./issue_labels.js";
export { issueApprovals } from "./issue_approvals.js";
export { issueComments } from "./issue_comments.js";
export { issueInboxArchives } from "./issue_inbox_archives.js";
export { issueReadStates } from "./issue_read_states.js";
export { assets } from "./assets.js";
export { issueAttachments } from "./issue_attachments.js";

View file

@ -0,0 +1,25 @@
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
export const issueInboxArchives = pgTable(
"issue_inbox_archives",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull().references(() => issues.id),
userId: text("user_id").notNull(),
archivedAt: timestamp("archived_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyIssueIdx: index("issue_inbox_archives_company_issue_idx").on(table.companyId, table.issueId),
companyUserIdx: index("issue_inbox_archives_company_user_idx").on(table.companyId, table.userId),
companyIssueUserUnique: uniqueIndex("issue_inbox_archives_company_issue_user_idx").on(
table.companyId,
table.issueId,
table.userId,
),
}),
);

View file

@ -6,6 +6,7 @@ import {
companies,
createDb,
issueComments,
issueInboxArchives,
issues,
} from "@paperclipai/db";
import {
@ -36,6 +37,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
afterEach(async () => {
await db.delete(issueComments);
await db.delete(issueInboxArchives);
await db.delete(activityLog);
await db.delete(issues);
await db.delete(agents);
@ -216,4 +218,99 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]);
});
it("hides archived inbox issues until new external activity arrives", async () => {
const companyId = randomUUID();
const userId = "user-1";
const otherUserId = "user-2";
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const visibleIssueId = randomUUID();
const archivedIssueId = randomUUID();
const resurfacedIssueId = randomUUID();
await db.insert(issues).values([
{
id: visibleIssueId,
companyId,
title: "Visible issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
createdAt: new Date("2026-03-26T10:00:00.000Z"),
updatedAt: new Date("2026-03-26T10:00:00.000Z"),
},
{
id: archivedIssueId,
companyId,
title: "Archived issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
createdAt: new Date("2026-03-26T11:00:00.000Z"),
updatedAt: new Date("2026-03-26T11:00:00.000Z"),
},
{
id: resurfacedIssueId,
companyId,
title: "Resurfaced issue",
status: "todo",
priority: "medium",
createdByUserId: userId,
createdAt: new Date("2026-03-26T12:00:00.000Z"),
updatedAt: new Date("2026-03-26T12:00:00.000Z"),
},
]);
await svc.archiveInbox(
companyId,
archivedIssueId,
userId,
new Date("2026-03-26T12:30:00.000Z"),
);
await svc.archiveInbox(
companyId,
resurfacedIssueId,
userId,
new Date("2026-03-26T13:00:00.000Z"),
);
await db.insert(issueComments).values({
companyId,
issueId: resurfacedIssueId,
authorUserId: otherUserId,
body: "This should bring the issue back into Mine.",
createdAt: new Date("2026-03-26T13:30:00.000Z"),
updatedAt: new Date("2026-03-26T13:30:00.000Z"),
});
const archivedFiltered = await svc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
});
expect(archivedFiltered.map((issue) => issue.id)).toEqual([
resurfacedIssueId,
visibleIssueId,
]);
await svc.unarchiveInbox(companyId, archivedIssueId, userId);
const afterUnarchive = await svc.list(companyId, {
touchedByUserId: userId,
inboxArchivedByUserId: userId,
});
expect(new Set(afterUnarchive.map((issue) => issue.id))).toEqual(new Set([
visibleIssueId,
archivedIssueId,
resurfacedIssueId,
]));
});
});

View file

@ -230,6 +230,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
assertCompanyAccess(req, companyId);
const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined;
const touchedByUserFilterRaw = req.query.touchedByUserId as string | undefined;
const inboxArchivedByUserFilterRaw = req.query.inboxArchivedByUserId as string | undefined;
const unreadForUserFilterRaw = req.query.unreadForUserId as string | undefined;
const assigneeUserId =
assigneeUserFilterRaw === "me" && req.actor.type === "board"
@ -239,6 +240,10 @@ export function issueRoutes(db: Db, storage: StorageService) {
touchedByUserFilterRaw === "me" && req.actor.type === "board"
? req.actor.userId
: touchedByUserFilterRaw;
const inboxArchivedByUserId =
inboxArchivedByUserFilterRaw === "me" && req.actor.type === "board"
? req.actor.userId
: inboxArchivedByUserFilterRaw;
const unreadForUserId =
unreadForUserFilterRaw === "me" && req.actor.type === "board"
? req.actor.userId
@ -252,6 +257,10 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.status(403).json({ error: "touchedByUserId=me requires board authentication" });
return;
}
if (inboxArchivedByUserFilterRaw === "me" && (!inboxArchivedByUserId || req.actor.type !== "board")) {
res.status(403).json({ error: "inboxArchivedByUserId=me requires board authentication" });
return;
}
if (unreadForUserFilterRaw === "me" && (!unreadForUserId || req.actor.type !== "board")) {
res.status(403).json({ error: "unreadForUserId=me requires board authentication" });
return;
@ -263,6 +272,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
participantAgentId: req.query.participantAgentId as string | undefined,
assigneeUserId,
touchedByUserId,
inboxArchivedByUserId,
unreadForUserId,
projectId: req.query.projectId as string | undefined,
parentId: req.query.parentId as string | undefined,
@ -703,6 +713,70 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.json(readState);
});
router.post("/issues/:id/inbox-archive", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const archiveState = await svc.archiveInbox(issue.companyId, issue.id, req.actor.userId, new Date());
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.inbox_archived",
entityType: "issue",
entityId: issue.id,
details: { userId: req.actor.userId, archivedAt: archiveState.archivedAt },
});
res.json(archiveState);
});
router.delete("/issues/:id/inbox-archive", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (req.actor.type !== "board") {
res.status(403).json({ error: "Board authentication required" });
return;
}
if (!req.actor.userId) {
res.status(403).json({ error: "Board user context required" });
return;
}
const removed = await svc.unarchiveInbox(issue.companyId, issue.id, req.actor.userId);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.inbox_unarchived",
entityType: "issue",
entityId: issue.id,
details: { userId: req.actor.userId },
});
res.json(removed ?? { ok: true });
});
router.get("/issues/:id/approvals", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);

View file

@ -11,6 +11,7 @@ import {
heartbeatRuns,
executionWorkspaces,
issueAttachments,
issueInboxArchives,
issueLabels,
issueComments,
issueDocuments,
@ -66,6 +67,7 @@ export interface IssueFilters {
participantAgentId?: string;
assigneeUserId?: string;
touchedByUserId?: string;
inboxArchivedByUserId?: string;
unreadForUserId?: string;
projectId?: string;
parentId?: string;
@ -212,6 +214,36 @@ function myLastTouchAtExpr(companyId: string, userId: string) {
`;
}
function lastExternalCommentAtExpr(companyId: string, userId: string) {
return sql<Date | null>`
(
SELECT MAX(${issueComments.createdAt})
FROM ${issueComments}
WHERE ${issueComments.issueId} = ${issues.id}
AND ${issueComments.companyId} = ${companyId}
AND (
${issueComments.authorUserId} IS NULL
OR ${issueComments.authorUserId} <> ${userId}
)
)
`;
}
function issueLastActivityAtExpr(companyId: string, userId: string) {
const lastExternalCommentAt = lastExternalCommentAtExpr(companyId, userId);
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
return sql<Date>`
COALESCE(
${lastExternalCommentAt},
CASE
WHEN ${issues.updatedAt} > COALESCE(${myLastTouchAt}, to_timestamp(0))
THEN ${issues.updatedAt}
ELSE to_timestamp(0)
END
)
`;
}
function unreadForUserCondition(companyId: string, userId: string) {
const touchedCondition = touchedByUserCondition(companyId, userId);
const myLastTouchAt = myLastTouchAtExpr(companyId, userId);
@ -233,6 +265,20 @@ function unreadForUserCondition(companyId: string, userId: string) {
`;
}
function inboxVisibleForUserCondition(companyId: string, userId: string) {
const issueLastActivityAt = issueLastActivityAtExpr(companyId, userId);
return sql<boolean>`
NOT EXISTS (
SELECT 1
FROM ${issueInboxArchives}
WHERE ${issueInboxArchives.issueId} = ${issues.id}
AND ${issueInboxArchives.companyId} = ${companyId}
AND ${issueInboxArchives.userId} = ${userId}
AND ${issueInboxArchives.archivedAt} >= ${issueLastActivityAt}
)
`;
}
/** Named entities commonly emitted in saved issue bodies; unknown `&name;` sequences are left unchanged. */
const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly<Record<string, string>> = {
amp: "&",
@ -556,8 +602,9 @@ export function issueService(db: Db) {
list: async (companyId: string, filters?: IssueFilters) => {
const conditions = [eq(issues.companyId, companyId)];
const touchedByUserId = filters?.touchedByUserId?.trim() || undefined;
const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined;
const unreadForUserId = filters?.unreadForUserId?.trim() || undefined;
const contextUserId = unreadForUserId ?? touchedByUserId;
const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId;
const rawSearch = filters?.q?.trim() ?? "";
const hasSearch = rawSearch.length > 0;
const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : "";
@ -593,6 +640,9 @@ export function issueService(db: Db) {
if (touchedByUserId) {
conditions.push(touchedByUserCondition(companyId, touchedByUserId));
}
if (inboxArchivedByUserId) {
conditions.push(inboxVisibleForUserCondition(companyId, inboxArchivedByUserId));
}
if (unreadForUserId) {
conditions.push(unreadForUserCondition(companyId, unreadForUserId));
}
@ -741,6 +791,42 @@ export function issueService(db: Db) {
return row;
},
archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => {
const now = new Date();
const [row] = await db
.insert(issueInboxArchives)
.values({
companyId,
issueId,
userId,
archivedAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [issueInboxArchives.companyId, issueInboxArchives.issueId, issueInboxArchives.userId],
set: {
archivedAt,
updatedAt: now,
},
})
.returning();
return row;
},
unarchiveInbox: async (companyId: string, issueId: string, userId: string) => {
const [row] = await db
.delete(issueInboxArchives)
.where(
and(
eq(issueInboxArchives.companyId, companyId),
eq(issueInboxArchives.issueId, issueId),
eq(issueInboxArchives.userId, userId),
),
)
.returning();
return row ?? null;
},
getById: async (id: string) => {
const row = await db
.select()

View file

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

View file

@ -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),

View file

@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { X } from "lucide-react";
import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
@ -17,6 +18,8 @@ interface IssueRowProps {
trailingMeta?: ReactNode;
unreadState?: UnreadState | null;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
}
@ -31,6 +34,8 @@ export function IssueRow({
trailingMeta,
unreadState = null,
onMarkRead,
onArchive,
archiveDisabled,
className,
}: IssueRowProps) {
const issuePathId = issue.identifier ?? issue.id;
@ -43,7 +48,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,
)}
>
@ -113,6 +118,26 @@ export function IssueRow({
)}
/>
</button>
) : onArchive ? (
<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={archiveDisabled}
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
aria-label="Dismiss from inbox"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}

View file

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

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

View file

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

View file

@ -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) {

View file

@ -12,7 +12,9 @@ import {
getRecentTouchedIssues,
loadDismissedInboxItems,
saveDismissedInboxItems,
getUnreadTouchedIssues,
loadReadInboxItems,
saveReadInboxItems,
READ_ITEMS_KEY,
} from "../lib/inbox";
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
@ -41,6 +43,30 @@ export function useDismissedInboxItems() {
return { dismissed, dismiss };
}
export function useReadInboxItems() {
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== READ_ITEMS_KEY) return;
setReadItems(loadReadInboxItems());
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const markRead = (id: string) => {
setReadItems((prev) => {
const next = new Set(prev);
next.add(id);
saveReadInboxItems(next);
return next;
});
};
return { readItems, markRead };
}
export function useInboxBadge(companyId: string | null | undefined) {
const { dismissed } = useDismissedInboxItems();
@ -72,20 +98,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 +124,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],
);
}

View file

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

View file

@ -10,8 +10,9 @@ export const RECENT_ISSUES_LIMIT = 100;
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 READ_ITEMS_KEY = "paperclip:inbox:read-items";
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 +41,7 @@ export interface InboxBadgeData {
approvals: number;
failedRuns: number;
joinRequests: number;
unreadTouchedIssues: number;
mineIssues: number;
alerts: number;
}
@ -61,14 +62,31 @@ export function saveDismissedInboxItems(ids: Set<string>) {
}
}
export function loadReadInboxItems(): Set<string> {
try {
const raw = localStorage.getItem(READ_ITEMS_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch {
return new Set();
}
}
export function saveReadInboxItems(ids: Set<string>) {
try {
localStorage.setItem(READ_ITEMS_KEY, JSON.stringify([...ids]));
} catch {
// Ignore localStorage failures.
}
}
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 +153,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 +221,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,23 +245,28 @@ 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) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status),
const actionableApprovals = approvals.filter(
(approval) =>
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
!dismissed.has(`approval:${approval.id}`),
).length;
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
(run) => !dismissed.has(`run:${run.id}`),
).length;
const unreadTouchedIssues = unreadIssues.length;
const visibleJoinRequests = joinRequests.filter(
(jr) => !dismissed.has(`join:${jr.id}`),
).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 +281,11 @@ export function computeInboxBadgeData({
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
return {
inbox: actionableApprovals + joinRequests.length + failedRuns + unreadTouchedIssues + alerts,
inbox: actionableApprovals + visibleJoinRequests + failedRuns + visibleMineIssues + alerts,
approvals: actionableApprovals,
failedRuns,
joinRequests: joinRequests.length,
unreadTouchedIssues,
joinRequests: visibleJoinRequests,
mineIssues: visibleMineIssues,
alerts,
};
}

View file

@ -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,

View file

@ -15,8 +15,10 @@ 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 { cn } from "../lib/utils";
import { StatusBadge } from "../components/StatusBadge";
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
import { timeAgo } from "../lib/timeAgo";
@ -51,7 +53,7 @@ import {
shouldShowInboxSection,
type InboxTab,
} from "../lib/inbox";
import { useDismissedInboxItems } from "../hooks/useInboxBadge";
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
type InboxCategoryFilter =
| "everything"
@ -64,6 +66,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 +95,9 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
return null;
}
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
function FailedRunInboxRow({
run,
issueById,
@ -99,6 +106,11 @@ function FailedRunInboxRow({
onDismiss,
onRetry,
isRetrying,
unreadState = null,
onMarkRead,
onArchive,
archiveDisabled,
className,
}: {
run: HeartbeatRun;
issueById: Map<string, Issue>;
@ -107,19 +119,58 @@ function FailedRunInboxRow({
onDismiss: () => void;
onRetry: () => void;
isRetrying: boolean;
unreadState?: NonIssueUnreadState;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
}) {
const issueId = readIssueIdFromRun(run);
const issue = issueId ? issueById.get(issueId) ?? null : null;
const displayError = runFailureMessage(run);
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
return (
<div className="group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className={cn(
"group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2",
className,
)}>
<div className="flex items-start gap-2 sm:items-center">
{showUnreadSlot ? (
<span className="hidden sm:inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
<button
type="button"
onClick={onMarkRead}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
) : onArchive ? (
<button
type="button"
onClick={onArchive}
disabled={archiveDisabled}
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
aria-label="Dismiss from inbox"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
</span>
) : null}
<Link
to={`/agents/${run.agentId}/runs/${run.id}`}
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
>
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
{!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />}
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="mt-0.5 shrink-0 rounded-md bg-red-500/20 p-1.5 sm:mt-0">
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
@ -157,14 +208,16 @@ function FailedRunInboxRow({
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{isRetrying ? "Retrying…" : "Retry"}
</Button>
<button
type="button"
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
{!showUnreadSlot && (
<button
type="button"
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
<div className="mt-3 flex gap-2 sm:hidden">
@ -179,14 +232,16 @@ function FailedRunInboxRow({
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{isRetrying ? "Retrying…" : "Retry"}
</Button>
<button
type="button"
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
{!showUnreadSlot && (
<button
type="button"
onClick={onDismiss}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
);
@ -198,27 +253,71 @@ function ApprovalInboxRow({
onApprove,
onReject,
isPending,
unreadState = null,
onMarkRead,
onArchive,
archiveDisabled,
className,
}: {
approval: Approval;
requesterName: string | null;
onApprove: () => void;
onReject: () => void;
isPending: boolean;
unreadState?: NonIssueUnreadState;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
}) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
const showResolutionButtons =
approval.type !== "budget_override_required" &&
ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
return (
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className={cn(
"group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2",
className,
)}>
<div className="flex items-start gap-2 sm:items-center">
{showUnreadSlot ? (
<span className="hidden sm:inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
<button
type="button"
onClick={onMarkRead}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
) : onArchive ? (
<button
type="button"
onClick={onArchive}
disabled={archiveDisabled}
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
aria-label="Dismiss from inbox"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
</span>
) : null}
<Link
to={`/approvals/${approval.id}`}
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
>
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
{!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />}
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="mt-0.5 shrink-0 rounded-md bg-muted p-1.5 sm:mt-0">
<Icon className="h-4 w-4 text-muted-foreground" />
@ -286,22 +385,66 @@ function JoinRequestInboxRow({
onApprove,
onReject,
isPending,
unreadState = null,
onMarkRead,
onArchive,
archiveDisabled,
className,
}: {
joinRequest: JoinRequest;
onApprove: () => void;
onReject: () => void;
isPending: boolean;
unreadState?: NonIssueUnreadState;
onMarkRead?: () => void;
onArchive?: () => void;
archiveDisabled?: boolean;
className?: string;
}) {
const label =
joinRequest.requestType === "human"
? "Human join request"
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`;
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
return (
<div className="border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
<div className={cn(
"group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2",
className,
)}>
<div className="flex items-start gap-2 sm:items-center">
{showUnreadSlot ? (
<span className="hidden sm:inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
<button
type="button"
onClick={onMarkRead}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)} />
</button>
) : onArchive ? (
<button
type="button"
onClick={onArchive}
disabled={archiveDisabled}
className="inline-flex h-4 w-4 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 disabled:pointer-events-none disabled:opacity-30"
aria-label="Dismiss from inbox"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
</span>
) : null}
<div className="flex min-w-0 flex-1 items-start gap-2">
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
{!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />}
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
<span className="mt-0.5 shrink-0 rounded-md bg-muted p-1.5 sm:mt-0">
<UserPlus className="h-4 w-4 text-muted-foreground" />
@ -369,10 +512,13 @@ export function Inbox() {
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const { dismissed, dismiss } = useDismissedInboxItems();
const { readItems, markRead: markItemRead } = useReadInboxItems();
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 +582,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 +603,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 +614,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(() => {
@ -491,10 +655,13 @@ export function Inbox() {
return ids;
}, [heartbeatRuns]);
const approvalsToRender = useMemo(
() => getApprovalsForTab(approvals ?? [], tab, allApprovalFilter),
[approvals, tab, allApprovalFilter],
);
const approvalsToRender = useMemo(() => {
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
if (tab === "mine") {
filtered = filtered.filter((a) => !dismissed.has(`approval:${a.id}`));
}
return filtered;
}, [approvals, tab, allApprovalFilter, dismissed]);
const showJoinRequestsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
const showTouchedCategory =
@ -511,10 +678,9 @@ export function Inbox() {
const joinRequestsForTab = useMemo(() => {
if (tab === "all" && !showJoinRequestsCategory) return [];
if (tab === "recent") return joinRequests;
if (tab === "unread") return joinRequests;
if (tab === "mine") return joinRequests.filter((jr) => !dismissed.has(`join:${jr.id}`));
return joinRequests;
}, [joinRequests, tab, showJoinRequestsCategory]);
}, [joinRequests, tab, showJoinRequestsCategory, dismissed]);
const workItemsToRender = useMemo(
() =>
@ -624,14 +790,47 @@ export function Inbox() {
});
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
const [archivingNonIssueIds, setArchivingNonIssueIds] = 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) => {
@ -676,6 +875,39 @@ export function Inbox() {
},
});
const handleMarkNonIssueRead = (key: string) => {
setFadingNonIssueItems((prev) => new Set(prev).add(key));
markItemRead(key);
setTimeout(() => {
setFadingNonIssueItems((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, 300);
};
const handleArchiveNonIssue = (key: string) => {
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
setTimeout(() => {
dismiss(key);
setArchivingNonIssueIds((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}, 200);
};
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
if (tab !== "mine") return null;
const isRead = readItems.has(key);
const isFading = fadingNonIssueItems.has(key);
if (isFading) return "fading";
if (!isRead) return "visible";
return "hidden";
};
if (!selectedCompanyId) {
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
}
@ -692,6 +924,7 @@ export function Inbox() {
const showAlertsSection = shouldShowInboxSection({
tab,
hasItems: hasAlerts,
showOnMine: hasAlerts,
showOnRecent: hasAlerts,
showOnUnread: hasAlerts,
showOnAll: showAlertsCategory && hasAlerts,
@ -707,12 +940,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 +958,10 @@ export function Inbox() {
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
{
value: "mine",
label: "Mine",
},
{
value: "recent",
label: "Recent",
@ -796,7 +1035,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."
@ -811,54 +1052,122 @@ export function Inbox() {
<div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
{workItemsToRender.map((item) => {
const isMineTab = tab === "mine";
if (item.kind === "approval") {
return (
const approvalKey = `approval:${item.approval.id}`;
const isArchiving = archivingNonIssueIds.has(approvalKey);
const row = (
<ApprovalInboxRow
key={`approval:${item.approval.id}`}
key={approvalKey}
approval={item.approval}
requesterName={agentName(item.approval.requestedByAgentId)}
onApprove={() => approveMutation.mutate(item.approval.id)}
onReject={() => rejectMutation.mutate(item.approval.id)}
isPending={approveMutation.isPending || rejectMutation.isPending}
unreadState={nonIssueUnreadState(approvalKey)}
onMarkRead={() => handleMarkNonIssueRead(approvalKey)}
onArchive={isMineTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
archiveDisabled={isArchiving}
className={
isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out"
}
/>
);
return isMineTab ? (
<SwipeToArchive
key={approvalKey}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(approvalKey)}
>
{row}
</SwipeToArchive>
) : row;
}
if (item.kind === "failed_run") {
return (
const runKey = `run:${item.run.id}`;
const isArchiving = archivingNonIssueIds.has(runKey);
const row = (
<FailedRunInboxRow
key={`run:${item.run.id}`}
key={runKey}
run={item.run}
issueById={issueById}
agentName={agentName(item.run.agentId)}
issueLinkState={issueLinkState}
onDismiss={() => dismiss(`run:${item.run.id}`)}
onDismiss={() => dismiss(runKey)}
onRetry={() => retryRunMutation.mutate(item.run)}
isRetrying={retryingRunIds.has(item.run.id)}
unreadState={nonIssueUnreadState(runKey)}
onMarkRead={() => handleMarkNonIssueRead(runKey)}
onArchive={isMineTab ? () => handleArchiveNonIssue(runKey) : undefined}
archiveDisabled={isArchiving}
className={
isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out"
}
/>
);
return isMineTab ? (
<SwipeToArchive
key={runKey}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(runKey)}
>
{row}
</SwipeToArchive>
) : row;
}
if (item.kind === "join_request") {
return (
const joinKey = `join:${item.joinRequest.id}`;
const isArchiving = archivingNonIssueIds.has(joinKey);
const row = (
<JoinRequestInboxRow
key={`join:${item.joinRequest.id}`}
key={joinKey}
joinRequest={item.joinRequest}
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
unreadState={nonIssueUnreadState(joinKey)}
onMarkRead={() => handleMarkNonIssueRead(joinKey)}
onArchive={isMineTab ? () => handleArchiveNonIssue(joinKey) : undefined}
archiveDisabled={isArchiving}
className={
isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out"
}
/>
);
return isMineTab ? (
<SwipeToArchive
key={joinKey}
disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(joinKey)}
>
{row}
</SwipeToArchive>
) : row;
}
const issue = item.issue;
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
return (
const isArchiving = archivingIssueIds.has(issue.id);
const row = (
<IssueRow
key={`issue:${issue.id}`}
issue={issue}
issueLinkState={issueLinkState}
className={
isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out"
}
desktopMetaLeading={(
<>
<span className="hidden shrink-0 sm:inline-flex">
@ -885,8 +1194,16 @@ export function Inbox() {
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`
}
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
unreadState={
isUnread ? "visible" : isFading ? "fading" : "hidden"
}
onMarkRead={() => markReadMutation.mutate(issue.id)}
onArchive={
isMineTab
? () => archiveIssueMutation.mutate(issue.id)
: undefined
}
archiveDisabled={isArchiving || archiveIssueMutation.isPending}
trailingMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
@ -894,6 +1211,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>

View file

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