diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0c5aa424..6ace12a4 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -119,6 +119,16 @@ export const ISSUE_STATUSES = [ ] as const; export type IssueStatus = (typeof ISSUE_STATUSES)[number]; +export const INBOX_MINE_ISSUE_STATUSES = [ + "backlog", + "todo", + "in_progress", + "in_review", + "blocked", + "done", +] as const; +export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join(","); + export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 891011f7..982a825c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,8 @@ export { AGENT_ROLE_LABELS, AGENT_ICON_NAMES, ISSUE_STATUSES, + INBOX_MINE_ISSUE_STATUSES, + INBOX_MINE_ISSUE_STATUS_FILTER, ISSUE_PRIORITIES, ISSUE_ORIGIN_KINDS, GOAL_LEVELS, @@ -344,6 +346,7 @@ export { upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, createAgentKeySchema, + agentMineInboxQuerySchema, wakeAgentSchema, resetAgentSessionSchema, testAdapterEnvironmentSchema, @@ -356,6 +359,7 @@ export { type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, type CreateAgentKey, + type AgentMineInboxQuery, type WakeAgent, type ResetAgentSession, type TestAdapterEnvironment, diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 8c29150b..288ae683 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -4,6 +4,7 @@ import { AGENT_ICON_NAMES, AGENT_ROLES, AGENT_STATUSES, + INBOX_MINE_ISSUE_STATUS_FILTER, } from "../constants.js"; import { envConfigSchema } from "./secret.js"; @@ -93,6 +94,13 @@ export const createAgentKeySchema = z.object({ export type CreateAgentKey = z.infer; +export const agentMineInboxQuerySchema = z.object({ + userId: z.string().trim().min(1), + status: z.string().trim().min(1).optional().default(INBOX_MINE_ISSUE_STATUS_FILTER), +}); + +export type AgentMineInboxQuery = z.infer; + export const wakeAgentSchema = z.object({ source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"), triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 3f33bceb..1ab21793 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -85,6 +85,7 @@ export { upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, createAgentKeySchema, + agentMineInboxQuerySchema, wakeAgentSchema, resetAgentSessionSchema, testAdapterEnvironmentSchema, @@ -97,6 +98,7 @@ export { type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, type CreateAgentKey, + type AgentMineInboxQuery, type WakeAgent, type ResetAgentSession, type TestAdapterEnvironment, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 3715e4e6..22ae43d2 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -66,6 +66,7 @@ export type CreateIssueLabel = z.infer; export const updateIssueSchema = createIssueSchema.partial().extend({ comment: z.string().min(1).optional(), reopen: z.boolean().optional(), + interrupt: z.boolean().optional(), hiddenAt: z.string().datetime().nullable().optional(), }); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 08941f77..7bd79f76 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1,6 +1,7 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; import { agentRoutes } from "../routes/agents.js"; import { errorHandler } from "../middleware/index.js"; @@ -272,4 +273,42 @@ describe("agent permission routes", () => { expect(res.body.access.canAssignTasks).toBe(true); expect(res.body.access.taskAssignSource).toBe("agent_creator"); }); + + it("exposes a dedicated agent route for the inbox mine view", async () => { + mockIssueService.list.mockResolvedValue([ + { + id: "issue-1", + identifier: "PAP-910", + title: "Inbox follow-up", + status: "todo", + }, + ]); + + const app = createApp({ + type: "agent", + agentId, + companyId, + runId: "run-1", + source: "agent_key", + }); + + const res = await request(app) + .get("/api/agents/me/inbox/mine") + .query({ userId: "board-user" }); + + expect(res.status).toBe(200); + expect(mockIssueService.list).toHaveBeenCalledWith(companyId, { + touchedByUserId: "board-user", + inboxArchivedByUserId: "board-user", + status: INBOX_MINE_ISSUE_STATUS_FILTER, + }); + expect(res.body).toEqual([ + { + id: "issue-1", + identifier: "PAP-910", + title: "Inbox follow-up", + status: "todo", + }, + ]); + }); }); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 42c4cb0d..21bb44aa 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -19,6 +19,9 @@ const mockAccessService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn(async () => undefined), reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), })); const mockAgentService = vi.hoisted(() => ({ @@ -143,4 +146,46 @@ describe("issue comment reopen routes", () => { }), ); }); + + it("interrupts an active run before a combined comment update", async () => { + const issue = { + ...makeIssue("todo"), + executionRunId: "run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + })); + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "running", + }); + mockHeartbeatService.cancelRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "cancelled", + }); + + const res = await request(createApp()) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1"); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1"); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "heartbeat.cancelled", + details: expect.objectContaining({ + source: "issue_comment_interrupt", + issueId: "11111111-1111-4111-8111-111111111111", + }), + }), + ); + }); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index b4964578..2ad85e63 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -6,6 +6,7 @@ import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { agentSkillSyncSchema, + agentMineInboxQuerySchema, createAgentKeySchema, createAgentHireSchema, createAgentSchema, @@ -1006,6 +1007,23 @@ export function agentRoutes(db: Db) { ); }); + router.get("/agents/me/inbox/mine", async (req, res) => { + if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) { + res.status(401).json({ error: "Agent authentication required" }); + return; + } + + const query = agentMineInboxQuerySchema.parse(req.query); + const issuesSvc = issueService(db); + const rows = await issuesSvc.list(req.actor.companyId, { + touchedByUserId: query.userId, + inboxArchivedByUserId: query.userId, + status: query.status, + }); + + res.json(rows); + }); + router.get("/agents/:id", async (req, res) => { const id = req.params.id as string; const agent = await svc.getById(id); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 11ec162c..edf86063 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,5 +1,6 @@ import { Router, type Request, type Response } from "express"; import multer from "multer"; +import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { addIssueCommentSchema, @@ -38,6 +39,9 @@ import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types. import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; const MAX_ISSUE_COMMENT_LIMIT = 500; +const updateIssueRouteSchema = updateIssueSchema.extend({ + interrupt: z.boolean().optional(), +}); export function issueRoutes(db: Db, storage: StorageService) { const router = Router(); @@ -161,6 +165,30 @@ export function issueRoutes(db: Db, storage: StorageService) { return true; } + async function resolveActiveIssueRun(issue: { + id: string; + assigneeAgentId: string | null; + executionRunId?: string | null; + }) { + let runToInterrupt = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null; + + if ((!runToInterrupt || runToInterrupt.status !== "running") && issue.assigneeAgentId) { + const activeRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId); + const activeIssueId = + activeRun && + activeRun.contextSnapshot && + typeof activeRun.contextSnapshot === "object" && + typeof (activeRun.contextSnapshot as Record).issueId === "string" + ? ((activeRun.contextSnapshot as Record).issueId as string) + : null; + if (activeRun && activeRun.status === "running" && activeIssueId === issue.id) { + runToInterrupt = activeRun; + } + } + + return runToInterrupt?.status === "running" ? runToInterrupt : null; + } + async function normalizeIssueIdentifier(rawId: string): Promise { if (/^[A-Z]+-\d+$/i.test(rawId)) { const issue = await svc.getByIdentifier(rawId); @@ -713,6 +741,38 @@ export function issueRoutes(db: Db, storage: StorageService) { res.json(readState); }); + router.delete("/issues/:id/read", 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.markUnread(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.read_unmarked", + entityType: "issue", + entityId: issue.id, + details: { userId: req.actor.userId }, + }); + res.json({ id: issue.id, removed }); + }); + router.post("/issues/:id/inbox-archive", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); @@ -887,7 +947,7 @@ export function issueRoutes(db: Db, storage: StorageService) { res.status(201).json(issue); }); - router.patch("/issues/:id", validate(updateIssueSchema), async (req, res) => { + router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { @@ -917,7 +977,45 @@ export function issueRoutes(db: Db, storage: StorageService) { const actor = getActorInfo(req); const isClosed = existing.status === "done" || existing.status === "cancelled"; - const { comment: commentBody, reopen: reopenRequested, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; + const { + comment: commentBody, + reopen: reopenRequested, + interrupt: interruptRequested, + hiddenAt: hiddenAtRaw, + ...updateFields + } = req.body; + let interruptedRunId: string | null = null; + + if (interruptRequested) { + if (!commentBody) { + res.status(400).json({ error: "Interrupt is only supported when posting a comment" }); + return; + } + if (req.actor.type !== "board") { + res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" }); + return; + } + + const runToInterrupt = await resolveActiveIssueRun(existing); + if (runToInterrupt) { + const cancelled = await heartbeat.cancelRun(runToInterrupt.id); + if (cancelled) { + interruptedRunId = cancelled.id; + await logActivity(db, { + companyId: cancelled.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "heartbeat.cancelled", + entityType: "heartbeat_run", + entityId: cancelled.id, + details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: existing.id }, + }); + } + } + } + if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } @@ -992,6 +1090,7 @@ export function issueRoutes(db: Db, storage: StorageService) { identifier: issue.identifier, ...(commentBody ? { source: "comment" } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), _previous: hasFieldChanges ? previous : undefined, }, }); @@ -1018,6 +1117,7 @@ export function issueRoutes(db: Db, storage: StorageService) { identifier: issue.identifier, issueTitle: issue.title, ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), ...(hasFieldChanges ? { updated: true } : {}), }, }); @@ -1039,10 +1139,18 @@ export function issueRoutes(db: Db, storage: StorageService) { source: "assignment", triggerDetail: "system", reason: "issue_assigned", - payload: { issueId: issue.id, mutation: "update" }, + payload: { + issueId: issue.id, + mutation: "update", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, - contextSnapshot: { issueId: issue.id, source: "issue.update" }, + contextSnapshot: { + issueId: issue.id, + source: "issue.update", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, }); } @@ -1051,10 +1159,18 @@ export function issueRoutes(db: Db, storage: StorageService) { source: "automation", triggerDetail: "system", reason: "issue_status_changed", - payload: { issueId: issue.id, mutation: "update" }, + payload: { + issueId: issue.id, + mutation: "update", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, - contextSnapshot: { issueId: issue.id, source: "issue.status_change" }, + contextSnapshot: { + issueId: issue.id, + source: "issue.status_change", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, }); } @@ -1347,28 +1463,8 @@ export function issueRoutes(db: Db, storage: StorageService) { return; } - let runToInterrupt = currentIssue.executionRunId - ? await heartbeat.getRun(currentIssue.executionRunId) - : null; - - if ( - (!runToInterrupt || runToInterrupt.status !== "running") && - currentIssue.assigneeAgentId - ) { - const activeRun = await heartbeat.getActiveRunForAgent(currentIssue.assigneeAgentId); - const activeIssueId = - activeRun && - activeRun.contextSnapshot && - typeof activeRun.contextSnapshot === "object" && - typeof (activeRun.contextSnapshot as Record).issueId === "string" - ? ((activeRun.contextSnapshot as Record).issueId as string) - : null; - if (activeRun && activeRun.status === "running" && activeIssueId === currentIssue.id) { - runToInterrupt = activeRun; - } - } - - if (runToInterrupt && runToInterrupt.status === "running") { + const runToInterrupt = await resolveActiveIssueRun(currentIssue); + if (runToInterrupt) { const cancelled = await heartbeat.cancelRun(runToInterrupt.id); if (cancelled) { interruptedRunId = cancelled.id; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 086f4658..70652535 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -791,6 +791,20 @@ export function issueService(db: Db) { return row; }, + markUnread: async (companyId: string, issueId: string, userId: string) => { + const deleted = await db + .delete(issueReadStates) + .where( + and( + eq(issueReadStates.companyId, companyId), + eq(issueReadStates.issueId, issueId), + eq(issueReadStates.userId, userId), + ), + ) + .returning(); + return deleted.length > 0; + }, + archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => { const now = new Date(); const [row] = await db diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 407f08da..142ee63a 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -255,6 +255,7 @@ PATCH /api/agents/{agentId}/instructions-path | ----------------------------------------- | ------------------------------------------------------------------------------------------ | | My identity | `GET /api/agents/me` | | My compact inbox | `GET /api/agents/me/inbox-lite` | +| Report a user's Mine inbox view | `GET /api/agents/me/inbox/mine?userId=:userId` | | My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` | | Checkout task | `POST /api/issues/:issueId/checkout` | | Get task + ancestors | `GET /api/issues/:issueId` | diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 63293725..aea4250c 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -226,6 +226,34 @@ PATCH /api/issues/issue-99 { "comment": "JWT signing done. Still need token refresh logic. Will continue next heartbeat." } ``` +### Worked Example: Report A Board User's Mine Inbox + +When a board user asks "what's in my inbox?", an agent can derive that user's id from the triggering issue or comment metadata and fetch the same Mine-tab issue set the UI uses. + +``` +# Board user created the requesting issue. +GET /api/issues/issue-200 +-> { id: "issue-200", createdByUserId: "user-7", ... } + +# Fetch the board user's Mine inbox issues. +GET /api/agents/me/inbox/mine?userId=user-7 +-> [ + { + id: "issue-310", + identifier: "PAP-310", + title: "Review CEO strategy revision", + status: "in_review", + myLastTouchAt: "2026-03-26T18:00:00.000Z", + lastExternalCommentAt: "2026-03-26T19:10:00.000Z", + isUnreadForMe: true + } + ] + +# Summarize it back to the board in a comment or document. +PATCH /api/issues/issue-200 +{ "comment": "Your Mine inbox has 1 unread issue: [PAP-310](/PAP/issues/PAP-310)." } +``` + --- ## Worked Example: Manager Heartbeat @@ -566,6 +594,7 @@ Terminal states: `done`, `cancelled` | Method | Path | Description | | ------ | ---------------------------------- | ------------------------------------ | | GET | `/api/agents/me` | Your agent record + chain of command | +| GET | `/api/agents/me/inbox/mine?userId=:userId` | Mine-tab issue list for a specific board user | | GET | `/api/agents/:agentId` | Agent details + chain of command | | GET | `/api/companies/:companyId/agents` | List all agents in company | | GET | `/api/companies/:companyId/org` | Org chart tree | diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 436c6dfd..33ba63be 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -11,6 +11,10 @@ import type { } from "@paperclipai/shared"; import { api } from "./client"; +export type IssueUpdateResponse = Issue & { + comment?: IssueComment | null; +}; + export const issuesApi = { list: ( companyId: string, @@ -53,13 +57,15 @@ export const issuesApi = { deleteLabel: (id: string) => api.delete(`/labels/${id}`), get: (id: string) => api.get(`/issues/${id}`), markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}), + markUnread: (id: string) => api.delete<{ id: string; removed: boolean }>(`/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) => api.post(`/companies/${companyId}/issues`, data), - update: (id: string, data: Record) => api.patch(`/issues/${id}`, data), + update: (id: string, data: Record) => + api.patch(`/issues/${id}`, data), remove: (id: string) => api.delete(`/issues/${id}`), checkout: (id: string, agentId: string) => api.post(`/issues/${id}/checkout`, { diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index cdf0ddd2..84041401 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -15,6 +15,10 @@ import { PluginSlotOutlet } from "@/plugins/slots"; interface CommentWithRunMeta extends IssueComment { runId?: string | null; runAgentId?: string | null; + clientId?: string; + clientStatus?: "pending" | "queued"; + queueState?: "queued"; + queueTargetRunId?: string | null; } interface LinkedRunItem { @@ -32,6 +36,7 @@ interface CommentReassignment { interface CommentThreadProps { comments: CommentWithRunMeta[]; + queuedComments?: CommentWithRunMeta[]; linkedRuns?: LinkedRunItem[]; companyId?: string | null; projectId?: string | null; @@ -48,6 +53,8 @@ interface CommentThreadProps { currentAssigneeValue?: string; suggestedAssigneeValue?: string; mentions?: MentionOption[]; + onInterruptQueued?: (runId: string) => Promise; + interruptingQueuedRunId?: string | null; } const DRAFT_DEBOUNCE_MS = 800; @@ -114,6 +121,122 @@ function CopyMarkdownButton({ text }: { text: string }) { ); } +function CommentCard({ + comment, + agentMap, + companyId, + projectId, + highlightCommentId, + queued = false, +}: { + comment: CommentWithRunMeta; + agentMap?: Map; + companyId?: string | null; + projectId?: string | null; + highlightCommentId?: string | null; + queued?: boolean; +}) { + const isHighlighted = highlightCommentId === comment.id; + const isPending = comment.clientStatus === "pending"; + const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued"; + + return ( +
+
+ {comment.authorAgentId ? ( + + + + ) : ( + + )} + + {isQueued ? ( + + Queued + + ) : null} + {companyId && !isPending ? ( + + ) : null} + {isPending ? ( + {isQueued ? "Queueing..." : "Sending..."} + ) : ( + + {formatDateTime(comment.createdAt)} + + )} + + +
+ {comment.body} + {companyId && !isPending ? ( +
+ +
+ ) : null} + {comment.runId && !isPending ? ( +
+ {comment.runAgentId ? ( + + run {comment.runId.slice(0, 8)} + + ) : ( + + run {comment.runId.slice(0, 8)} + + )} +
+ ) : null} +
+ ); +} + type TimelineItem = | { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta } | { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem }; @@ -168,86 +291,15 @@ const TimelineList = memo(function TimelineList({ } const comment = item.comment; - const isHighlighted = highlightCommentId === comment.id; return ( -
-
- {comment.authorAgentId ? ( - - - - ) : ( - - )} - - {companyId ? ( - - ) : null} - - {formatDateTime(comment.createdAt)} - - - -
- {comment.body} - {companyId ? ( -
- -
- ) : null} - {comment.runId && ( -
- {comment.runAgentId ? ( - - run {comment.runId.slice(0, 8)} - - ) : ( - - run {comment.runId.slice(0, 8)} - - )} -
- )} -
+ comment={comment} + agentMap={agentMap} + companyId={companyId} + projectId={projectId} + highlightCommentId={highlightCommentId} + /> ); })} @@ -256,6 +308,7 @@ const TimelineList = memo(function TimelineList({ export function CommentThread({ comments, + queuedComments = [], linkedRuns = [], companyId, projectId, @@ -270,6 +323,8 @@ export function CommentThread({ currentAssigneeValue = "", suggestedAssigneeValue, mentions: providedMentions, + onInterruptQueued, + interruptingQueuedRunId = null, }: CommentThreadProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); @@ -345,7 +400,7 @@ export function CommentThread({ // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { const hash = location.hash; - if (!hash.startsWith("#comment-") || comments.length === 0) return; + if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return; const commentId = hash.slice("#comment-".length); // Only scroll once per hash if (hasScrolledRef.current) return; @@ -358,7 +413,7 @@ export function CommentThread({ const timer = setTimeout(() => setHighlightCommentId(null), 3000); return () => clearTimeout(timer); } - }, [location.hash, comments]); + }, [location.hash, comments, queuedComments]); async function handleSubmit() { const trimmed = body.trim(); @@ -368,11 +423,14 @@ export function CommentThread({ setSubmitting(true); try { + // TODO: wire an explicit "send + interrupt" action through the composer if we expose it in the UI. await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined); setBody(""); if (draftKey) clearDraft(draftKey); setReopen(true); setReassignTarget(effectiveSuggestedAssigneeValue); + } catch { + // Parent mutation handlers surface the failure and keep the draft intact. } finally { setSubmitting(false); } @@ -401,18 +459,54 @@ export function CommentThread({ return (
-

Comments & Runs ({timeline.length})

+

Comments & Runs ({timeline.length + queuedComments.length})

- + {timeline.length > 0 ? ( + + ) : null} {liveRunSlot} + {queuedComments.length > 0 && ( +
+
+

+ Queued Comments ({queuedComments.length}) +

+ {onInterruptQueued && queuedComments[0]?.queueTargetRunId ? ( + + ) : null} +
+
+ {queuedComments.map((comment) => ( + + ))} +
+
+ )} +
({ + Link: ({ children, className, ...props }: React.ComponentProps<"a">) => ( + {children} + ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Inbox item", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + isUnreadForMe: false, + ...overrides, + }; +} + +describe("IssueRow", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("suppresses accent hover styling when the row is selected", () => { + const root = createRoot(container); + const issue = createIssue(); + + act(() => { + root.render(); + }); + + const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null; + expect(link).not.toBeNull(); + expect(link?.className).toContain("hover:bg-transparent"); + expect(link?.className).not.toContain("hover:bg-accent/50"); + + act(() => { + root.unmount(); + }); + }); + + it("neutralizes selected status and unread dot accents", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const markReadButton = container.querySelector('button[aria-label="Mark as read"]'); + const unreadDot = markReadButton?.querySelector("span"); + const statusIcon = container.querySelector('span[class*="border-muted-foreground"]'); + + expect(markReadButton).not.toBeNull(); + expect(markReadButton?.className).toContain("hover:bg-muted/80"); + expect(markReadButton?.className).not.toContain("hover:bg-blue-500/20"); + expect(unreadDot).not.toBeNull(); + expect(unreadDot?.className).toContain("bg-muted-foreground/70"); + expect(unreadDot?.className).not.toContain("bg-blue-600"); + expect(statusIcon).not.toBeNull(); + expect(statusIcon?.className).toContain("!border-muted-foreground"); + expect(statusIcon?.className).toContain("!text-muted-foreground"); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index d3bcfcff..8a01e585 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { X } from "lucide-react"; +import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { cn } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; @@ -10,6 +11,7 @@ type UnreadState = "hidden" | "visible" | "fading"; interface IssueRowProps { issue: Issue; issueLinkState?: unknown; + selected?: boolean; mobileLeading?: ReactNode; desktopMetaLeading?: ReactNode; desktopLeadingSpacer?: boolean; @@ -26,6 +28,7 @@ interface IssueRowProps { export function IssueRow({ issue, issueLinkState, + selected = false, mobileLeading, desktopMetaLeading, desktopLeadingSpacer = false, @@ -42,18 +45,21 @@ export function IssueRow({ const identifier = issue.identifier ?? issue.id.slice(0, 8); const showUnreadSlot = unreadState !== null; const showUnreadDot = unreadState === "visible" || unreadState === "fading"; + const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined; return ( - {mobileLeading ?? } + {mobileLeading ?? } @@ -66,7 +72,7 @@ export function IssueRow({ {desktopMetaLeading ?? ( <> - + {identifier} @@ -108,12 +114,16 @@ export function IssueRow({ onMarkRead?.(); } }} - className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" + className={cn( + "inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors", + selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20", + )} aria-label="Mark as read" > diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 68469761..e04ceda2 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -564,8 +564,11 @@ export const MarkdownEditor = forwardRef {mentionActive && filteredMentions.length > 0 && createPortal(
{filteredMentions.map((option, i) => ( + , + ); + }); + + const wrapper = container.firstElementChild as HTMLDivElement; + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + Object.defineProperty(wrapper, "offsetWidth", { configurable: true, value: 200 }); + Object.defineProperty(wrapper, "offsetHeight", { configurable: true, value: 48 }); + + act(() => { + dispatchTouchEvent(wrapper, "touchstart", { x: 180, y: 20 }); + }); + act(() => { + dispatchTouchEvent(wrapper, "touchmove", { x: 80, y: 22 }); + }); + act(() => { + dispatchTouchEvent(wrapper, "touchend", { x: 80, y: 22 }); + }); + + act(() => { + button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onClick).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(140); + }); + + expect(onArchive).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); + + it("does not suppress a normal tap click", () => { + const onArchive = vi.fn(); + const onClick = vi.fn(); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + act(() => { + button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onArchive).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); + + it("renders the selected inbox treatment on the swipe surface", () => { + const root = createRoot(container); + + act(() => { + root.render( + {}} selected> + + , + ); + }); + + const surface = container.querySelector("[data-inbox-row-surface]") as HTMLDivElement | null; + expect(surface).not.toBeNull(); + expect(surface?.style.backgroundColor).toBe("hsl(var(--muted))"); + expect(surface?.style.boxShadow).toBe(""); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/SwipeToArchive.tsx b/ui/src/components/SwipeToArchive.tsx index 17ea3707..acd1d467 100644 --- a/ui/src/components/SwipeToArchive.tsx +++ b/ui/src/components/SwipeToArchive.tsx @@ -6,23 +6,27 @@ interface SwipeToArchiveProps { children: ReactNode; onArchive: () => void; disabled?: boolean; + selected?: boolean; className?: string; } -const COMMIT_THRESHOLD = 0.4; -const MAX_SWIPE = 0.92; -const COMMIT_DELAY_MS = 210; +const COMMIT_THRESHOLD = 0.32; +const MAX_SWIPE = 0.88; +const COMMIT_DELAY_MS = 140; +const SELECTED_ROW_BACKGROUND = "hsl(var(--muted))"; export function SwipeToArchive({ children, onArchive, disabled = false, + selected = false, className, }: SwipeToArchiveProps) { const containerRef = useRef(null); const startPointRef = useRef<{ x: number; y: number } | null>(null); const widthRef = useRef(0); const timeoutRef = useRef(null); + const suppressClickRef = useRef(false); const [offsetX, setOffsetX] = useState(0); const [isDragging, setIsDragging] = useState(false); const [isCollapsing, setIsCollapsing] = useState(false); @@ -68,6 +72,7 @@ export function SwipeToArchive({ widthRef.current = node?.offsetWidth ?? 0; setLockedHeight(node?.offsetHeight ?? null); setIsCollapsing(false); + suppressClickRef.current = false; startPointRef.current = { x: touch.clientX, y: touch.clientY }; }; @@ -86,6 +91,7 @@ export function SwipeToArchive({ startPointRef.current = null; return; } + suppressClickRef.current = true; } if (deltaX >= 0) { @@ -127,6 +133,12 @@ export function SwipeToArchive({ onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} onTouchCancel={handleTouchEnd} + onClickCapture={(event) => { + if (!suppressClickRef.current) return; + event.preventDefault(); + event.stopPropagation(); + suppressClickRef.current = false; + }} >
{children} diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts index 50f4323d..6b7daa2b 100644 --- a/ui/src/hooks/useInboxBadge.ts +++ b/ui/src/hooks/useInboxBadge.ts @@ -64,7 +64,16 @@ export function useReadInboxItems() { }); }; - return { readItems, markRead }; + const markUnread = (id: string) => { + setReadItems((prev) => { + const next = new Set(prev); + next.delete(id); + saveReadInboxItems(next); + return next; + }); + }; + + return { readItems, markRead, markUnread }; } export function useInboxBadge(companyId: string | null | undefined) { diff --git a/ui/src/index.css b/ui/src/index.css index b0f839ec..c220e8cd 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -343,6 +343,17 @@ margin-top: 1.1em; } +.paperclip-mdxeditor-content a:not(.paperclip-mention-chip):not(.paperclip-project-mention-chip) { + color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%); + text-decoration: underline; + text-underline-offset: 0.15em; + cursor: pointer; +} + +.dark .paperclip-mdxeditor-content a:not(.paperclip-mention-chip):not(.paperclip-project-mention-chip) { + color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%); +} + .paperclip-mdxeditor-content a.paperclip-mention-chip, .paperclip-mdxeditor-content a.paperclip-project-mention-chip { display: inline-flex; @@ -661,12 +672,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { .paperclip-markdown a { color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%); - text-decoration: none; -} - -.paperclip-markdown a:hover { text-decoration: underline; text-underline-offset: 0.15em; + cursor: pointer; +} + +.paperclip-markdown a.paperclip-mention-chip { + text-decoration: none; } .dark .paperclip-markdown a { diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index af7c1d35..a67a7451 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -6,10 +6,13 @@ import { computeInboxBadgeData, getApprovalsForTab, getInboxWorkItems, + getInboxKeyboardSelectionIndex, getRecentTouchedIssues, getUnreadTouchedIssues, + isMineInboxTab, loadLastInboxTab, RECENT_ISSUES_LIMIT, + resolveInboxSelectionIndex, saveLastInboxTab, shouldShowInboxSection, } from "./inbox"; @@ -400,4 +403,24 @@ describe("inbox helpers", () => { localStorage.setItem("paperclip:inbox:last-tab", "new"); expect(loadLastInboxTab()).toBe("mine"); }); + + it("enables swipe archive only on the mine tab", () => { + expect(isMineInboxTab("mine")).toBe(true); + expect(isMineInboxTab("recent")).toBe(false); + expect(isMineInboxTab("unread")).toBe(false); + expect(isMineInboxTab("all")).toBe(false); + }); + + it("anchors Mine selection to the first available inbox row", () => { + expect(resolveInboxSelectionIndex(-1, 3)).toBe(-1); + expect(resolveInboxSelectionIndex(5, 3)).toBe(2); + expect(resolveInboxSelectionIndex(1, 0)).toBe(-1); + }); + + it("selects the first row only after keyboard navigation starts", () => { + expect(getInboxKeyboardSelectionIndex(-1, 3, "next")).toBe(0); + expect(getInboxKeyboardSelectionIndex(-1, 3, "previous")).toBe(0); + expect(getInboxKeyboardSelectionIndex(0, 3, "next")).toBe(1); + expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0); + }); }); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 3b4297b8..e86a02ee 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -98,6 +98,31 @@ export function saveLastInboxTab(tab: InboxTab) { } } +export function isMineInboxTab(tab: InboxTab): boolean { + return tab === "mine"; +} + +export function resolveInboxSelectionIndex( + previousIndex: number, + itemCount: number, +): number { + if (itemCount === 0) return -1; + if (previousIndex < 0) return -1; + return Math.min(previousIndex, itemCount - 1); +} + +export function getInboxKeyboardSelectionIndex( + previousIndex: number, + itemCount: number, + direction: "next" | "previous", +): number { + if (itemCount === 0) return -1; + if (previousIndex < 0) return 0; + return direction === "next" + ? Math.min(previousIndex + 1, itemCount - 1) + : Math.max(previousIndex - 1, 0); +} + export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] { const sorted = [...runs].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), diff --git a/ui/src/lib/issueDetailBreadcrumb.test.ts b/ui/src/lib/issueDetailBreadcrumb.test.ts new file mode 100644 index 00000000..dcb18479 --- /dev/null +++ b/ui/src/lib/issueDetailBreadcrumb.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + createIssueDetailLocationState, + createIssueDetailPath, + readIssueDetailBreadcrumb, +} from "./issueDetailBreadcrumb"; + +describe("issueDetailBreadcrumb", () => { + it("prefers the full breadcrumb from route state", () => { + const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"); + + expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({ + label: "Inbox", + href: "/inbox/mine", + }); + }); + + it("falls back to the source query param when route state is unavailable", () => { + expect(readIssueDetailBreadcrumb(null, "?from=inbox")).toEqual({ + label: "Inbox", + href: "/inbox", + }); + }); + + it("adds the source query param when building an issue detail path", () => { + const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"); + + expect(createIssueDetailPath("PAP-465", state)).toBe("/issues/PAP-465?from=inbox"); + }); + + it("reuses the current source query param when state has been dropped", () => { + expect(createIssueDetailPath("PAP-465", null, "?from=issues")).toBe("/issues/PAP-465?from=issues"); + }); +}); diff --git a/ui/src/lib/issueDetailBreadcrumb.ts b/ui/src/lib/issueDetailBreadcrumb.ts index ba330eb3..1f940ef8 100644 --- a/ui/src/lib/issueDetailBreadcrumb.ts +++ b/ui/src/lib/issueDetailBreadcrumb.ts @@ -1,3 +1,5 @@ +type IssueDetailSource = "issues" | "inbox"; + type IssueDetailBreadcrumb = { label: string; href: string; @@ -5,20 +7,64 @@ type IssueDetailBreadcrumb = { type IssueDetailLocationState = { issueDetailBreadcrumb?: IssueDetailBreadcrumb; + issueDetailSource?: IssueDetailSource; }; +const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from"; + function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb { if (typeof value !== "object" || value === null) return false; const candidate = value as Partial; return typeof candidate.label === "string" && typeof candidate.href === "string"; } -export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState { - return { issueDetailBreadcrumb: { label, href } }; +function isIssueDetailSource(value: unknown): value is IssueDetailSource { + return value === "issues" || value === "inbox"; } -export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null { +function readIssueDetailSource(state: unknown): IssueDetailSource | null { if (typeof state !== "object" || state === null) return null; - const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb; - return isIssueDetailBreadcrumb(candidate) ? candidate : null; + const source = (state as IssueDetailLocationState).issueDetailSource; + return isIssueDetailSource(source) ? source : null; +} + +function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | null { + if (!search) return null; + const params = new URLSearchParams(search); + const source = params.get(ISSUE_DETAIL_SOURCE_QUERY_PARAM); + return isIssueDetailSource(source) ? source : null; +} + +function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb { + if (source === "inbox") return { label: "Inbox", href: "/inbox" }; + return { label: "Issues", href: "/issues" }; +} + +export function createIssueDetailLocationState( + label: string, + href: string, + source?: IssueDetailSource, +): IssueDetailLocationState { + return { + issueDetailBreadcrumb: { label, href }, + issueDetailSource: source, + }; +} + +export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string { + const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search); + if (!source) return `/issues/${issuePathId}`; + const params = new URLSearchParams(); + params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source); + return `/issues/${issuePathId}?${params.toString()}`; +} + +export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null { + if (typeof state === "object" && state !== null) { + const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb; + if (isIssueDetailBreadcrumb(candidate)) return candidate; + } + + const source = readIssueDetailSourceFromSearch(search); + return source ? breadcrumbForSource(source) : null; } diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts new file mode 100644 index 00000000..bb4ae9ae --- /dev/null +++ b/ui/src/lib/optimistic-issue-comments.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + applyOptimisticIssueCommentUpdate, + createOptimisticIssueComment, + isQueuedIssueComment, + mergeIssueComments, + upsertIssueComment, +} from "./optimistic-issue-comments"; + +describe("optimistic issue comments", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("creates a pending optimistic comment for the current user", () => { + const comment = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "Working on it", + authorUserId: "board-1", + }); + + expect(comment.id).toMatch(/^optimistic-/); + expect(comment.clientId).toBe(comment.id); + expect(comment.clientStatus).toBe("pending"); + expect(comment.authorUserId).toBe("board-1"); + expect(comment.authorAgentId).toBeNull(); + }); + + it("falls back when crypto.randomUUID is unavailable", () => { + vi.stubGlobal("crypto", {}); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_746_000_000_000); + const mathSpy = vi.spyOn(Math, "random").mockReturnValue(0.123456789); + + const comment = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "Working on it", + authorUserId: "board-1", + }); + + expect(comment.id).toBe("optimistic-1746000000000-4fzzzxjy"); + expect(comment.clientId).toBe(comment.id); + + nowSpy.mockRestore(); + mathSpy.mockRestore(); + }); + + it("supports queued optimistic comments for active-run follow-ups", () => { + const comment = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "Queue this", + authorUserId: "board-1", + clientStatus: "queued", + queueTargetRunId: "run-1", + }); + + expect(comment.clientStatus).toBe("queued"); + expect(comment.queueTargetRunId).toBe("run-1"); + }); + + it("merges optimistic comments into the server thread in chronological order", () => { + const merged = mergeIssueComments( + [ + { + id: "comment-2", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Second", + createdAt: new Date("2026-03-28T14:00:02.000Z"), + updatedAt: new Date("2026-03-28T14:00:02.000Z"), + }, + ], + [ + { + id: "optimistic-1", + clientId: "optimistic-1", + clientStatus: "pending", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "First", + createdAt: new Date("2026-03-28T14:00:01.000Z"), + updatedAt: new Date("2026-03-28T14:00:01.000Z"), + }, + ], + ); + + expect(merged.map((comment) => comment.id)).toEqual(["optimistic-1", "comment-2"]); + }); + + it("upserts confirmed comments without creating duplicates", () => { + const next = upsertIssueComment( + [ + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Original", + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + ], + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Updated", + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:05.000Z"), + }, + ); + + expect(next).toHaveLength(1); + expect(next[0]?.body).toBe("Updated"); + }); + + it("applies optimistic reopen and reassignment updates to the issue cache", () => { + const next = applyOptimisticIssueCommentUpdate( + { + id: "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Fix comment flow", + description: null, + status: "done", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: "board-1", + issueNumber: 1, + identifier: "PAP-1", + originKind: "manual", + originId: null, + originRunId: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + { + reopen: true, + reassignment: { + assigneeAgentId: null, + assigneeUserId: "board-2", + }, + }, + ); + + expect(next?.status).toBe("todo"); + expect(next?.assigneeAgentId).toBeNull(); + expect(next?.assigneeUserId).toBe("board-2"); + }); + + it("treats comments without a run id as queued when they arrive during an active run", () => { + expect( + isQueuedIssueComment({ + comment: { + createdAt: new Date("2026-03-28T16:20:05.000Z"), + }, + activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"), + runId: null, + }), + ).toBe(true); + }); + + it("does not mark comments with an associated run as queued", () => { + expect( + isQueuedIssueComment({ + comment: { + createdAt: new Date("2026-03-28T16:20:05.000Z"), + }, + activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"), + runId: "run-1", + }), + ).toBe(false); + }); + + it("does not mark interrupt comments as queued", () => { + expect( + isQueuedIssueComment({ + comment: { + createdAt: new Date("2026-03-28T16:20:05.000Z"), + }, + activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"), + interruptedRunId: "run-1", + }), + ).toBe(false); + }); +}); diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts new file mode 100644 index 00000000..44d85332 --- /dev/null +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -0,0 +1,123 @@ +import type { Issue, IssueComment } from "@paperclipai/shared"; + +export interface IssueCommentReassignment { + assigneeAgentId: string | null; + assigneeUserId: string | null; +} + +export interface OptimisticIssueComment extends IssueComment { + clientId: string; + clientStatus: "pending" | "queued"; + queueTargetRunId?: string | null; +} + +export type IssueTimelineComment = IssueComment | OptimisticIssueComment; + +function toTimestamp(value: Date | string) { + return new Date(value).getTime(); +} + +function createOptimisticCommentId() { + const randomUuid = globalThis.crypto?.randomUUID?.(); + if (randomUuid) { + return `optimistic-${randomUuid}`; + } + return `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +export function sortIssueComments(comments: T[]) { + return [...comments].sort((a, b) => { + const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt); + if (createdAtDiff !== 0) return createdAtDiff; + return a.id.localeCompare(b.id); + }); +} + +export function createOptimisticIssueComment(params: { + companyId: string; + issueId: string; + body: string; + authorUserId: string | null; + clientStatus?: OptimisticIssueComment["clientStatus"]; + queueTargetRunId?: string | null; +}): OptimisticIssueComment { + const now = new Date(); + const clientId = createOptimisticCommentId(); + return { + id: clientId, + clientId, + companyId: params.companyId, + issueId: params.issueId, + authorAgentId: null, + authorUserId: params.authorUserId, + body: params.body, + clientStatus: params.clientStatus ?? "pending", + queueTargetRunId: params.queueTargetRunId ?? null, + createdAt: now, + updatedAt: now, + }; +} + +export function isQueuedIssueComment(params: { + comment: Pick & Partial>; + activeRunStartedAt?: Date | string | null; + runId?: string | null; + interruptedRunId?: string | null; +}) { + if (params.runId) return false; + if (params.interruptedRunId) return false; + if (params.comment.clientStatus === "queued") return true; + if (!params.activeRunStartedAt) return false; + return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt); +} + +export function mergeIssueComments( + comments: IssueComment[] | undefined, + optimisticComments: OptimisticIssueComment[], +): IssueTimelineComment[] { + const merged = [...(comments ?? [])]; + const existingIds = new Set(merged.map((comment) => comment.id)); + for (const comment of optimisticComments) { + if (!existingIds.has(comment.id)) { + merged.push(comment); + } + } + return sortIssueComments(merged); +} + +export function upsertIssueComment( + comments: IssueComment[] | undefined, + nextComment: IssueComment, +): IssueComment[] { + const current = comments ?? []; + const existingIndex = current.findIndex((comment) => comment.id === nextComment.id); + if (existingIndex === -1) { + return sortIssueComments([...current, nextComment]); + } + + const updated = [...current]; + updated[existingIndex] = nextComment; + return sortIssueComments(updated); +} + +export function applyOptimisticIssueCommentUpdate( + issue: Issue | undefined, + params: { + reopen?: boolean; + reassignment?: IssueCommentReassignment; + }, +) { + if (!issue) return issue; + const nextIssue: Issue = { ...issue }; + + if (params.reopen === true && (issue.status === "done" || issue.status === "cancelled")) { + nextIssue.status = "todo"; + } + + if (params.reassignment) { + nextIssue.assigneeAgentId = params.reassignment.assigneeAgentId; + nextIssue.assigneeUserId = params.reassignment.assigneeUserId; + } + + return nextIssue; +} diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx new file mode 100644 index 00000000..c103a523 --- /dev/null +++ b/ui/src/pages/Inbox.test.tsx @@ -0,0 +1,181 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ComponentProps } from "react"; +import { createRoot } from "react-dom/client"; +import type { Issue } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FailedRunInboxRow, InboxIssueMetaLeading } from "./Inbox"; + +vi.mock("@/lib/router", () => ({ + Link: ({ children, className, ...props }: ComponentProps<"a">) => ( + {children} + ), + useLocation: () => ({ pathname: "/", search: "", hash: "" }), + useNavigate: () => () => {}, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-904", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Inbox item", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 904, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + isUnreadForMe: false, + ...overrides, + }; +} + +describe("FailedRunInboxRow", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("suppresses accent hover styling when selected", () => { + const root = createRoot(container); + const run = { + id: "run-1", + companyId: "company-1", + agentId: "agent-1", + invocationSource: "assignment", + triggerDetail: null, + status: "failed", + error: "boom", + wakeupRequestId: null, + exitCode: null, + signal: null, + usageJson: null, + resultJson: null, + sessionIdBefore: null, + sessionIdAfter: null, + logStore: null, + logRef: null, + logBytes: null, + logSha256: null, + logCompressed: false, + errorCode: null, + externalRunId: null, + processPid: null, + processStartedAt: null, + retryOfRunId: null, + processLossRetryCount: 0, + stdoutExcerpt: null, + stderrExcerpt: null, + contextSnapshot: null, + startedAt: new Date("2026-03-11T00:00:00.000Z"), + finishedAt: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + } as const; + + act(() => { + root.render( + {}} + onRetry={() => {}} + isRetrying={false} + selected + />, + ); + }); + + const link = container.querySelector("a"); + expect(link).not.toBeNull(); + expect(link?.className).toContain("hover:bg-transparent"); + expect(link?.className).not.toContain("hover:bg-accent/50"); + + act(() => { + root.unmount(); + }); + }); +}); + +describe("InboxIssueMetaLeading", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("neutralizes selected status and live accents", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const statusIcon = container.querySelector('span[class*="border-muted-foreground"]'); + const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-muted"]'); + const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find( + (node) => node.textContent === "Live" && node.className.includes("text-"), + ); + const liveDot = container.querySelector('span[class*="bg-muted-foreground/70"]'); + const pulseRing = container.querySelector('span[class*="animate-pulse"]'); + + expect(statusIcon).not.toBeNull(); + expect(statusIcon?.className).toContain("!border-muted-foreground"); + expect(statusIcon?.className).toContain("!text-muted-foreground"); + expect(liveBadge).not.toBeNull(); + expect(liveBadge?.className).toContain("bg-muted"); + expect(liveBadgeLabel).not.toBeNull(); + expect(liveBadgeLabel?.className).toContain("text-muted-foreground"); + expect(liveBadgeLabel?.className).not.toContain("text-blue-600"); + expect(liveDot).not.toBeNull(); + expect(pulseRing).toBeNull(); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 149bd292..9c86b04b 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1,6 +1,7 @@ -import { useEffect, useMemo, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; import { approvalsApi } from "../api/approvals"; import { accessApi } from "../api/access"; import { ApiError } from "../api/client"; @@ -11,7 +12,7 @@ import { heartbeatsApi } from "../api/heartbeats"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; -import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; +import { createIssueDetailLocationState, createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { IssueRow } from "../components/IssueRow"; @@ -46,12 +47,16 @@ import { ACTIONABLE_APPROVAL_STATUSES, getApprovalsForTab, getInboxWorkItems, + getInboxKeyboardSelectionIndex, getLatestFailedRunsByAgent, getRecentTouchedIssues, + isMineInboxTab, + resolveInboxSelectionIndex, InboxApprovalFilter, saveLastInboxTab, shouldShowInboxSection, type InboxTab, + type InboxWorkItem, } from "../lib/inbox"; import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge"; @@ -66,8 +71,6 @@ 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); @@ -97,8 +100,69 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null { type NonIssueUnreadState = "visible" | "fading" | "hidden" | null; +const selectedInboxAccentClass = "!text-muted-foreground !border-muted-foreground"; -function FailedRunInboxRow({ +function getSelectedUnreadButtonClass(selected: boolean): string { + return selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20"; +} + +function getSelectedUnreadDotClass(selected: boolean): string { + return selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400"; +} + +export function InboxIssueMetaLeading({ + issue, + selected, + isLive, +}: { + issue: Issue; + selected: boolean; + isLive: boolean; +}) { + return ( + <> + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {isLive && ( + + + {!selected ? ( + + ) : null} + + + + Live + + + )} + + ); +} + +export function FailedRunInboxRow({ run, issueById, agentName: linkedAgentName, @@ -110,6 +174,7 @@ function FailedRunInboxRow({ onMarkRead, onArchive, archiveDisabled, + selected = false, className, }: { run: HeartbeatRun; @@ -123,6 +188,7 @@ function FailedRunInboxRow({ onMarkRead?: () => void; onArchive?: () => void; archiveDisabled?: boolean; + selected?: boolean; className?: string; }) { const issueId = readIssueIdFromRun(run); @@ -143,11 +209,15 @@ function FailedRunInboxRow({ @@ -168,7 +238,10 @@ function FailedRunInboxRow({ ) : null} {!showUnreadSlot &&