Merge pull request #2072 from paperclipai/pap-979-board-ux
ui: improve board inbox and issue detail workflows
This commit is contained in:
commit
26a974da17
33 changed files with 2098 additions and 270 deletions
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<typeof createAgentKeySchema>;
|
||||
|
||||
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<typeof agentMineInboxQuerySchema>;
|
||||
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
|
|||
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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => ({
|
||||
...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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>).issueId === "string"
|
||||
? ((activeRun.contextSnapshot as Record<string, unknown>).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<string> {
|
||||
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<string, unknown>).issueId === "string"
|
||||
? ((activeRun.contextSnapshot as Record<string, unknown>).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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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<IssueLabel>(`/labels/${id}`),
|
||||
get: (id: string) => api.get<Issue>(`/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<string, unknown>) =>
|
||||
api.post<Issue>(`/companies/${companyId}/issues`, data),
|
||||
update: (id: string, data: Record<string, unknown>) => api.patch<Issue>(`/issues/${id}`, data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<IssueUpdateResponse>(`/issues/${id}`, data),
|
||||
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
|
||||
checkout: (id: string, agentId: string) =>
|
||||
api.post<Issue>(`/issues/${id}/checkout`, {
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
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<string, Agent>;
|
||||
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 (
|
||||
<div
|
||||
key={comment.id}
|
||||
id={`comment-${comment.id}`}
|
||||
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${
|
||||
isQueued
|
||||
? "border-amber-300/70 bg-amber-50/70 dark:border-amber-500/40 dark:bg-amber-500/10"
|
||||
: isHighlighted
|
||||
? "border-primary/50 bg-primary/5"
|
||||
: "border-border"
|
||||
} ${isPending ? "opacity-80" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
{comment.authorAgentId ? (
|
||||
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
|
||||
<Identity
|
||||
name={agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8)}
|
||||
size="sm"
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Identity name="You" size="sm" />
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
{isQueued ? (
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
|
||||
Queued
|
||||
</span>
|
||||
) : null}
|
||||
{companyId && !isPending ? (
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["commentContextMenuItem"]}
|
||||
entityType="comment"
|
||||
context={{
|
||||
companyId,
|
||||
projectId: projectId ?? null,
|
||||
entityId: comment.id,
|
||||
entityType: "comment",
|
||||
parentEntityId: comment.issueId,
|
||||
}}
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
itemClassName="inline-flex"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
) : null}
|
||||
{isPending ? (
|
||||
<span className="text-xs text-muted-foreground">{isQueued ? "Queueing..." : "Sending..."}</span>
|
||||
) : (
|
||||
<a
|
||||
href={`#comment-${comment.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
||||
>
|
||||
{formatDateTime(comment.createdAt)}
|
||||
</a>
|
||||
)}
|
||||
<CopyMarkdownButton text={comment.body} />
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
||||
{companyId && !isPending ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["commentAnnotation"]}
|
||||
entityType="comment"
|
||||
context={{
|
||||
companyId,
|
||||
projectId: projectId ?? null,
|
||||
entityId: comment.id,
|
||||
entityType: "comment",
|
||||
parentEntityId: comment.issueId,
|
||||
}}
|
||||
className="space-y-2"
|
||||
itemClassName="rounded-md"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{comment.runId && !isPending ? (
|
||||
<div className="mt-2 pt-2 border-t border-border/60">
|
||||
{comment.runAgentId ? (
|
||||
<Link
|
||||
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
|
||||
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
run {comment.runId.slice(0, 8)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
|
||||
run {comment.runId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
<CommentCard
|
||||
key={comment.id}
|
||||
id={`comment-${comment.id}`}
|
||||
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${isHighlighted ? "border-primary/50 bg-primary/5" : "border-border"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
{comment.authorAgentId ? (
|
||||
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
|
||||
<Identity
|
||||
name={agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8)}
|
||||
size="sm"
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Identity name="You" size="sm" />
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
{companyId ? (
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["commentContextMenuItem"]}
|
||||
entityType="comment"
|
||||
context={{
|
||||
companyId,
|
||||
projectId: projectId ?? null,
|
||||
entityId: comment.id,
|
||||
entityType: "comment",
|
||||
parentEntityId: comment.issueId,
|
||||
}}
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
itemClassName="inline-flex"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
) : null}
|
||||
<a
|
||||
href={`#comment-${comment.id}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
||||
>
|
||||
{formatDateTime(comment.createdAt)}
|
||||
</a>
|
||||
<CopyMarkdownButton text={comment.body} />
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
||||
{companyId ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["commentAnnotation"]}
|
||||
entityType="comment"
|
||||
context={{
|
||||
companyId,
|
||||
projectId: projectId ?? null,
|
||||
entityId: comment.id,
|
||||
entityType: "comment",
|
||||
parentEntityId: comment.issueId,
|
||||
}}
|
||||
className="space-y-2"
|
||||
itemClassName="rounded-md"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{comment.runId && (
|
||||
<div className="mt-2 pt-2 border-t border-border/60">
|
||||
{comment.runAgentId ? (
|
||||
<Link
|
||||
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
|
||||
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
run {comment.runId.slice(0, 8)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
|
||||
run {comment.runId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
comment={comment}
|
||||
agentMap={agentMap}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
highlightCommentId={highlightCommentId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Comments & Runs ({timeline.length})</h3>
|
||||
<h3 className="text-sm font-semibold">Comments & Runs ({timeline.length + queuedComments.length})</h3>
|
||||
|
||||
<TimelineList
|
||||
timeline={timeline}
|
||||
agentMap={agentMap}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
highlightCommentId={highlightCommentId}
|
||||
/>
|
||||
{timeline.length > 0 ? (
|
||||
<TimelineList
|
||||
timeline={timeline}
|
||||
agentMap={agentMap}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
highlightCommentId={highlightCommentId}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{liveRunSlot}
|
||||
|
||||
{queuedComments.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-[0.14em] text-amber-700 dark:text-amber-300">
|
||||
Queued Comments ({queuedComments.length})
|
||||
</h4>
|
||||
{onInterruptQueued && queuedComments[0]?.queueTargetRunId ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-300 text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||
disabled={interruptingQueuedRunId === queuedComments[0].queueTargetRunId}
|
||||
onClick={() => void onInterruptQueued(queuedComments[0]!.queueTargetRunId!)}
|
||||
>
|
||||
{interruptingQueuedRunId === queuedComments[0].queueTargetRunId ? "Interrupting..." : "Interrupt"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{queuedComments.map((comment) => (
|
||||
<CommentCard
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
agentMap={agentMap}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
highlightCommentId={highlightCommentId}
|
||||
queued
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useQueries } from "@tanstack/react-query";
|
|||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
MouseSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
|
|
@ -244,7 +244,8 @@ export function CompanyRail() {
|
|||
|
||||
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
// Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
|
|
|||
116
ui/src/components/IssueRow.test.tsx
Normal file
116
ui/src/components/IssueRow.test.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueRow } from "./IssueRow";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, className, ...props }: React.ComponentProps<"a">) => (
|
||||
<a className={className} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}): 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(<IssueRow issue={issue} selected />);
|
||||
});
|
||||
|
||||
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(<IssueRow issue={createIssue()} selected unreadState="visible" />);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<Link
|
||||
to={`/issues/${issuePathId}`}
|
||||
to={createIssueDetailPath(issuePathId, issueLinkState)}
|
||||
state={issueLinkState}
|
||||
data-inbox-issue-link
|
||||
className={cn(
|
||||
"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",
|
||||
"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 last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
|
||||
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 pt-px sm:hidden">
|
||||
{mobileLeading ?? <StatusIcon status={issue.status} />}
|
||||
{mobileLeading ?? <StatusIcon status={issue.status} className={selectedStatusClass} />}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
||||
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
|
||||
|
|
@ -66,7 +72,7 @@ export function IssueRow({
|
|||
{desktopMetaLeading ?? (
|
||||
<>
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
<StatusIcon status={issue.status} />
|
||||
<StatusIcon status={issue.status} className={selectedStatusClass} />
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{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"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
|
||||
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||
selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400",
|
||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -564,8 +564,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
{mentionActive && filteredMentions.length > 0 &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed z-[9999] min-w-[180px] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
||||
style={{ top: mentionState.viewportTop + 4, left: mentionState.viewportLeft }}
|
||||
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
||||
style={{
|
||||
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
|
||||
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
|
||||
}}
|
||||
>
|
||||
{filteredMentions.map((option, i) => (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { ChevronRight, Plus } from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
MouseSensor,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
useSensor,
|
||||
|
|
@ -153,7 +153,8 @@ export function SidebarProjects() {
|
|||
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
|
||||
const activeProjectRef = projectMatch?.[1] ?? null;
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
// Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior.
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
146
ui/src/components/SwipeToArchive.test.tsx
Normal file
146
ui/src/components/SwipeToArchive.test.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SwipeToArchive } from "./SwipeToArchive";
|
||||
|
||||
// Tell React this environment uses act() for event flushing.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function dispatchTouchEvent(
|
||||
node: Element,
|
||||
type: "touchstart" | "touchmove" | "touchend",
|
||||
coords: { x: number; y: number },
|
||||
) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
const touchPoint = { clientX: coords.x, clientY: coords.y };
|
||||
|
||||
Object.defineProperty(event, "touches", {
|
||||
configurable: true,
|
||||
value: type === "touchend" ? [] : [touchPoint],
|
||||
});
|
||||
Object.defineProperty(event, "changedTouches", {
|
||||
configurable: true,
|
||||
value: [touchPoint],
|
||||
});
|
||||
|
||||
node.dispatchEvent(event);
|
||||
}
|
||||
|
||||
describe("SwipeToArchive", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("suppresses descendant clicks after a horizontal swipe and archives the row", () => {
|
||||
const onArchive = vi.fn();
|
||||
const onClick = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<SwipeToArchive onArchive={onArchive}>
|
||||
<button type="button" onClick={onClick}>
|
||||
Open issue
|
||||
</button>
|
||||
</SwipeToArchive>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<SwipeToArchive onArchive={onArchive}>
|
||||
<button type="button" onClick={onClick}>
|
||||
Open issue
|
||||
</button>
|
||||
</SwipeToArchive>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<SwipeToArchive onArchive={() => {}} selected>
|
||||
<button type="button">Open issue</button>
|
||||
</SwipeToArchive>,
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<HTMLDivElement | null>(null);
|
||||
const startPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const widthRef = useRef(0);
|
||||
const timeoutRef = useRef<number | null>(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;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
|
|
@ -139,10 +151,12 @@ export function SwipeToArchive({
|
|||
</span>
|
||||
</div>
|
||||
<div
|
||||
data-inbox-row-surface
|
||||
className="relative bg-card will-change-transform"
|
||||
style={{
|
||||
transform: `translate3d(${offsetX}px, 0, 0)`,
|
||||
transition: isDragging ? "none" : "transform 180ms ease-out",
|
||||
backgroundColor: selected ? SELECTED_ROW_BACKGROUND : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
34
ui/src/lib/issueDetailBreadcrumb.test.ts
Normal file
34
ui/src/lib/issueDetailBreadcrumb.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<IssueDetailBreadcrumb>;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
215
ui/src/lib/optimistic-issue-comments.test.ts
Normal file
215
ui/src/lib/optimistic-issue-comments.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
123
ui/src/lib/optimistic-issue-comments.ts
Normal file
123
ui/src/lib/optimistic-issue-comments.ts
Normal file
|
|
@ -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<T extends { createdAt: Date | string; id: string }>(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<IssueTimelineComment, "createdAt"> & Partial<Pick<OptimisticIssueComment, "clientStatus">>;
|
||||
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;
|
||||
}
|
||||
181
ui/src/pages/Inbox.test.tsx
Normal file
181
ui/src/pages/Inbox.test.tsx
Normal file
|
|
@ -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">) => (
|
||||
<a className={className} {...props}>{children}</a>
|
||||
),
|
||||
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> = {}): 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(
|
||||
<FailedRunInboxRow
|
||||
run={run}
|
||||
issueById={new Map()}
|
||||
agentName="Agent"
|
||||
issueLinkState={null}
|
||||
onDismiss={() => {}}
|
||||
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(<InboxIssueMetaLeading issue={createIssue()} selected isLive />);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<span className="hidden shrink-0 sm:inline-flex">
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
className={selected ? selectedInboxAccentClass : undefined}
|
||||
/>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{isLive && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
|
||||
selected ? "bg-muted" : "bg-blue-500/10",
|
||||
)}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
{!selected ? (
|
||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex h-2 w-2 rounded-full",
|
||||
selected ? "bg-muted-foreground/70" : "bg-blue-500",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"hidden text-[11px] font-medium sm:inline",
|
||||
selected ? "text-muted-foreground" : "text-blue-600 dark:text-blue-400",
|
||||
)}
|
||||
>
|
||||
Live
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
|||
<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"
|
||||
className={cn(
|
||||
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||
getSelectedUnreadButtonClass(selected),
|
||||
)}
|
||||
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",
|
||||
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||
getSelectedUnreadDotClass(selected),
|
||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)} />
|
||||
</button>
|
||||
|
|
@ -168,7 +238,10 @@ function FailedRunInboxRow({
|
|||
) : 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"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors",
|
||||
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{!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" />
|
||||
|
|
@ -257,6 +330,7 @@ function ApprovalInboxRow({
|
|||
onMarkRead,
|
||||
onArchive,
|
||||
archiveDisabled,
|
||||
selected = false,
|
||||
className,
|
||||
}: {
|
||||
approval: Approval;
|
||||
|
|
@ -268,6 +342,7 @@ function ApprovalInboxRow({
|
|||
onMarkRead?: () => void;
|
||||
onArchive?: () => void;
|
||||
archiveDisabled?: boolean;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
|
|
@ -290,11 +365,15 @@ function ApprovalInboxRow({
|
|||
<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"
|
||||
className={cn(
|
||||
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||
getSelectedUnreadButtonClass(selected),
|
||||
)}
|
||||
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",
|
||||
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||
getSelectedUnreadDotClass(selected),
|
||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)} />
|
||||
</button>
|
||||
|
|
@ -315,7 +394,10 @@ function ApprovalInboxRow({
|
|||
) : 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"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors",
|
||||
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{!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" />
|
||||
|
|
@ -389,6 +471,7 @@ function JoinRequestInboxRow({
|
|||
onMarkRead,
|
||||
onArchive,
|
||||
archiveDisabled,
|
||||
selected = false,
|
||||
className,
|
||||
}: {
|
||||
joinRequest: JoinRequest;
|
||||
|
|
@ -399,6 +482,7 @@ function JoinRequestInboxRow({
|
|||
onMarkRead?: () => void;
|
||||
onArchive?: () => void;
|
||||
archiveDisabled?: boolean;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const label =
|
||||
|
|
@ -420,11 +504,15 @@ function JoinRequestInboxRow({
|
|||
<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"
|
||||
className={cn(
|
||||
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
|
||||
getSelectedUnreadButtonClass(selected),
|
||||
)}
|
||||
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",
|
||||
"block h-2 w-2 rounded-full transition-opacity duration-300",
|
||||
getSelectedUnreadDotClass(selected),
|
||||
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||
)} />
|
||||
</button>
|
||||
|
|
@ -512,18 +600,20 @@ 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 { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
|
||||
|
||||
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
||||
const tab: InboxTab =
|
||||
pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread"
|
||||
? pathSegment
|
||||
: "mine";
|
||||
const canArchiveFromTab = isMineInboxTab(tab);
|
||||
const issueLinkState = useMemo(
|
||||
() =>
|
||||
createIssueDetailLocationState(
|
||||
"Inbox",
|
||||
`${location.pathname}${location.search}${location.hash}`,
|
||||
"inbox",
|
||||
),
|
||||
[location.pathname, location.search, location.hash],
|
||||
);
|
||||
|
|
@ -540,6 +630,7 @@ export function Inbox() {
|
|||
|
||||
useEffect(() => {
|
||||
saveLastInboxTab(tab);
|
||||
setSelectedIndex(-1);
|
||||
}, [tab]);
|
||||
|
||||
const {
|
||||
|
|
@ -591,7 +682,7 @@ export function Inbox() {
|
|||
issuesApi.list(selectedCompanyId!, {
|
||||
touchedByUserId: "me",
|
||||
inboxArchivedByUserId: "me",
|
||||
status: INBOX_ISSUE_STATUSES,
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
|
@ -603,7 +694,7 @@ export function Inbox() {
|
|||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
touchedByUserId: "me",
|
||||
status: INBOX_ISSUE_STATUSES,
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
|
@ -793,6 +884,8 @@ export function Inbox() {
|
|||
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 [selectedIndex, setSelectedIndex] = useState<number>(-1);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const invalidateInboxIssueQueries = () => {
|
||||
if (!selectedCompanyId) return;
|
||||
|
|
@ -875,7 +968,14 @@ export function Inbox() {
|
|||
},
|
||||
});
|
||||
|
||||
const handleMarkNonIssueRead = (key: string) => {
|
||||
const markUnreadMutation = useMutation({
|
||||
mutationFn: (id: string) => issuesApi.markUnread(id),
|
||||
onSuccess: () => {
|
||||
invalidateInboxIssueQueries();
|
||||
},
|
||||
});
|
||||
|
||||
const handleMarkNonIssueRead = useCallback((key: string) => {
|
||||
setFadingNonIssueItems((prev) => new Set(prev).add(key));
|
||||
markItemRead(key);
|
||||
setTimeout(() => {
|
||||
|
|
@ -885,9 +985,9 @@ export function Inbox() {
|
|||
return next;
|
||||
});
|
||||
}, 300);
|
||||
};
|
||||
}, [markItemRead]);
|
||||
|
||||
const handleArchiveNonIssue = (key: string) => {
|
||||
const handleArchiveNonIssue = useCallback((key: string) => {
|
||||
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
|
||||
setTimeout(() => {
|
||||
dismiss(key);
|
||||
|
|
@ -897,10 +997,10 @@ export function Inbox() {
|
|||
return next;
|
||||
});
|
||||
}, 200);
|
||||
};
|
||||
}, [dismiss]);
|
||||
|
||||
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
|
||||
if (tab !== "mine") return null;
|
||||
if (!canArchiveFromTab) return null;
|
||||
const isRead = readItems.has(key);
|
||||
const isFading = fadingNonIssueItems.has(key);
|
||||
if (isFading) return "fading";
|
||||
|
|
@ -908,6 +1008,170 @@ export function Inbox() {
|
|||
return "hidden";
|
||||
};
|
||||
|
||||
const getWorkItemKey = useCallback((item: InboxWorkItem): string => {
|
||||
if (item.kind === "issue") return `issue:${item.issue.id}`;
|
||||
if (item.kind === "approval") return `approval:${item.approval.id}`;
|
||||
if (item.kind === "failed_run") return `run:${item.run.id}`;
|
||||
return `join:${item.joinRequest.id}`;
|
||||
}, []);
|
||||
|
||||
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
|
||||
useEffect(() => {
|
||||
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length));
|
||||
}, [workItemsToRender.length]);
|
||||
|
||||
// Use refs for keyboard handler to avoid stale closures
|
||||
const kbStateRef = useRef({
|
||||
workItems: workItemsToRender,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
archivingIssueIds,
|
||||
archivingNonIssueIds,
|
||||
fadingOutIssues,
|
||||
readItems,
|
||||
});
|
||||
kbStateRef.current = {
|
||||
workItems: workItemsToRender,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
archivingIssueIds,
|
||||
archivingNonIssueIds,
|
||||
fadingOutIssues,
|
||||
readItems,
|
||||
};
|
||||
|
||||
const kbActionsRef = useRef({
|
||||
archiveIssue: (id: string) => archiveIssueMutation.mutate(id),
|
||||
archiveNonIssue: handleArchiveNonIssue,
|
||||
markRead: (id: string) => markReadMutation.mutate(id),
|
||||
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
|
||||
markNonIssueRead: handleMarkNonIssueRead,
|
||||
markNonIssueUnread: markItemUnread,
|
||||
navigate,
|
||||
});
|
||||
kbActionsRef.current = {
|
||||
archiveIssue: (id: string) => archiveIssueMutation.mutate(id),
|
||||
archiveNonIssue: handleArchiveNonIssue,
|
||||
markRead: (id: string) => markReadMutation.mutate(id),
|
||||
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
|
||||
markNonIssueRead: handleMarkNonIssueRead,
|
||||
markNonIssueUnread: markItemUnread,
|
||||
navigate,
|
||||
};
|
||||
|
||||
// Keyboard shortcuts (mail-client style) — single stable listener using refs
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
// Don't capture when typing in inputs/textareas or with modifier keys
|
||||
const target = e.target;
|
||||
if (
|
||||
!(target instanceof HTMLElement) ||
|
||||
target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], [role='combobox']") ||
|
||||
target.isContentEditable ||
|
||||
document.querySelector("[role='dialog'], [aria-modal='true']") ||
|
||||
e.metaKey ||
|
||||
e.ctrlKey ||
|
||||
e.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const st = kbStateRef.current;
|
||||
const act = kbActionsRef.current;
|
||||
|
||||
// Keyboard shortcuts are only active on the "mine" tab
|
||||
if (!st.canArchive) return;
|
||||
|
||||
const itemCount = st.workItems.length;
|
||||
if (itemCount === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "j": {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
|
||||
break;
|
||||
}
|
||||
case "k": {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous"));
|
||||
break;
|
||||
}
|
||||
case "a":
|
||||
case "y": {
|
||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
||||
e.preventDefault();
|
||||
const item = st.workItems[st.selectedIndex];
|
||||
if (item.kind === "issue") {
|
||||
if (!st.archivingIssueIds.has(item.issue.id)) {
|
||||
act.archiveIssue(item.issue.id);
|
||||
}
|
||||
} else {
|
||||
const key = getWorkItemKey(item);
|
||||
if (!st.archivingNonIssueIds.has(key)) {
|
||||
act.archiveNonIssue(key);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "U": {
|
||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
||||
e.preventDefault();
|
||||
const item = st.workItems[st.selectedIndex];
|
||||
if (item.kind === "issue") {
|
||||
act.markUnreadIssue(item.issue.id);
|
||||
} else {
|
||||
act.markNonIssueUnread(getWorkItemKey(item));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "r": {
|
||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
||||
e.preventDefault();
|
||||
const item = st.workItems[st.selectedIndex];
|
||||
if (item.kind === "issue") {
|
||||
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) {
|
||||
act.markRead(item.issue.id);
|
||||
}
|
||||
} else {
|
||||
const key = getWorkItemKey(item);
|
||||
if (!st.readItems.has(key)) {
|
||||
act.markNonIssueRead(key);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Enter": {
|
||||
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
|
||||
e.preventDefault();
|
||||
const item = st.workItems[st.selectedIndex];
|
||||
if (item.kind === "issue") {
|
||||
const pathId = item.issue.identifier ?? item.issue.id;
|
||||
act.navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState });
|
||||
} else if (item.kind === "approval") {
|
||||
act.navigate(`/approvals/${item.approval.id}`);
|
||||
} else if (item.kind === "failed_run") {
|
||||
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [getWorkItemKey, issueLinkState]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (selectedIndex < 0 || !listRef.current) return;
|
||||
const rows = listRef.current.querySelectorAll("[data-inbox-item]");
|
||||
const row = rows[selectedIndex];
|
||||
if (row) row.scrollIntoView({ block: "nearest" });
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
||||
}
|
||||
|
|
@ -953,25 +1217,25 @@ export function Inbox() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{
|
||||
value: "mine",
|
||||
label: "Mine",
|
||||
},
|
||||
{
|
||||
value: "recent",
|
||||
label: "Recent",
|
||||
},
|
||||
{ value: "unread", label: "Unread" },
|
||||
{ value: "all", label: "All" },
|
||||
]}
|
||||
/>
|
||||
</Tabs>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{
|
||||
value: "mine",
|
||||
label: "Mine",
|
||||
},
|
||||
{
|
||||
value: "recent",
|
||||
label: "Recent",
|
||||
},
|
||||
{ value: "unread", label: "Unread" },
|
||||
{ value: "all", label: "All" },
|
||||
]}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{canMarkAllRead && (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -985,44 +1249,44 @@ export function Inbox() {
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === "all" && (
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
{tab === "all" && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={allCategoryFilter}
|
||||
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[170px] text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="everything">All categories</SelectItem>
|
||||
<SelectItem value="issues_i_touched">My recent issues</SelectItem>
|
||||
<SelectItem value="join_requests">Join requests</SelectItem>
|
||||
<SelectItem value="approvals">Approvals</SelectItem>
|
||||
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
||||
<SelectItem value="alerts">Alerts</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showApprovalsCategory && (
|
||||
<Select
|
||||
value={allCategoryFilter}
|
||||
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
||||
value={allApprovalFilter}
|
||||
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[170px] text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
<SelectValue placeholder="Approval status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="everything">All categories</SelectItem>
|
||||
<SelectItem value="issues_i_touched">My recent issues</SelectItem>
|
||||
<SelectItem value="join_requests">Join requests</SelectItem>
|
||||
<SelectItem value="approvals">Approvals</SelectItem>
|
||||
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
||||
<SelectItem value="alerts">Alerts</SelectItem>
|
||||
<SelectItem value="all">All approval statuses</SelectItem>
|
||||
<SelectItem value="actionable">Needs action</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showApprovalsCategory && (
|
||||
<Select
|
||||
value={allApprovalFilter}
|
||||
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[170px] text-xs">
|
||||
<SelectValue placeholder="Approval status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All approval statuses</SelectItem>
|
||||
<SelectItem value="actionable">Needs action</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||
|
|
@ -1050,9 +1314,36 @@ export function Inbox() {
|
|||
<>
|
||||
{showSeparatorBefore("work_items") && <Separator />}
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{workItemsToRender.map((item) => {
|
||||
const isMineTab = tab === "mine";
|
||||
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{workItemsToRender.flatMap((item, index) => {
|
||||
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
|
||||
<div
|
||||
key={`sel-${key}`}
|
||||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(index)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
const todayCutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const showTodayDivider =
|
||||
index > 0 &&
|
||||
item.timestamp > 0 &&
|
||||
item.timestamp < todayCutoff &&
|
||||
workItemsToRender[index - 1].timestamp >= todayCutoff;
|
||||
const elements: ReactNode[] = [];
|
||||
if (showTodayDivider) {
|
||||
elements.push(
|
||||
<div key="today-divider" className="flex items-center gap-3 px-4 my-2">
|
||||
<div className="flex-1 border-t border-border" />
|
||||
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Earlier
|
||||
</span>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
const isSelected = selectedIndex === index;
|
||||
|
||||
if (item.kind === "approval") {
|
||||
const approvalKey = `approval:${item.approval.id}`;
|
||||
|
|
@ -1061,13 +1352,14 @@ export function Inbox() {
|
|||
<ApprovalInboxRow
|
||||
key={approvalKey}
|
||||
approval={item.approval}
|
||||
selected={isSelected}
|
||||
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}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
className={
|
||||
isArchiving
|
||||
|
|
@ -1076,15 +1368,17 @@ export function Inbox() {
|
|||
}
|
||||
/>
|
||||
);
|
||||
return isMineTab ? (
|
||||
elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={approvalKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(approvalKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row;
|
||||
) : row));
|
||||
return elements;
|
||||
}
|
||||
|
||||
if (item.kind === "failed_run") {
|
||||
|
|
@ -1094,6 +1388,7 @@ export function Inbox() {
|
|||
<FailedRunInboxRow
|
||||
key={runKey}
|
||||
run={item.run}
|
||||
selected={isSelected}
|
||||
issueById={issueById}
|
||||
agentName={agentName(item.run.agentId)}
|
||||
issueLinkState={issueLinkState}
|
||||
|
|
@ -1102,7 +1397,7 @@ export function Inbox() {
|
|||
isRetrying={retryingRunIds.has(item.run.id)}
|
||||
unreadState={nonIssueUnreadState(runKey)}
|
||||
onMarkRead={() => handleMarkNonIssueRead(runKey)}
|
||||
onArchive={isMineTab ? () => handleArchiveNonIssue(runKey) : undefined}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
className={
|
||||
isArchiving
|
||||
|
|
@ -1111,15 +1406,17 @@ export function Inbox() {
|
|||
}
|
||||
/>
|
||||
);
|
||||
return isMineTab ? (
|
||||
elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={runKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(runKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row;
|
||||
) : row));
|
||||
return elements;
|
||||
}
|
||||
|
||||
if (item.kind === "join_request") {
|
||||
|
|
@ -1129,12 +1426,13 @@ export function Inbox() {
|
|||
<JoinRequestInboxRow
|
||||
key={joinKey}
|
||||
joinRequest={item.joinRequest}
|
||||
selected={isSelected}
|
||||
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}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
className={
|
||||
isArchiving
|
||||
|
|
@ -1143,15 +1441,17 @@ export function Inbox() {
|
|||
}
|
||||
/>
|
||||
);
|
||||
return isMineTab ? (
|
||||
elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={joinKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(joinKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row;
|
||||
) : row));
|
||||
return elements;
|
||||
}
|
||||
|
||||
const issue = item.issue;
|
||||
|
|
@ -1163,32 +1463,19 @@ export function Inbox() {
|
|||
key={`issue:${issue.id}`}
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
selected={isSelected}
|
||||
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">
|
||||
<StatusIcon status={issue.status} />
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{liveIssueIds.has(issue.id) && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
|
||||
</span>
|
||||
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
|
||||
Live
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
desktopMetaLeading={
|
||||
<InboxIssueMetaLeading
|
||||
issue={issue}
|
||||
selected={isSelected}
|
||||
isLive={liveIssueIds.has(issue.id)}
|
||||
/>
|
||||
}
|
||||
mobileMeta={
|
||||
issue.lastExternalCommentAt
|
||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||
|
|
@ -1199,7 +1486,7 @@ export function Inbox() {
|
|||
}
|
||||
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||
onArchive={
|
||||
isMineTab
|
||||
canArchiveFromTab
|
||||
? () => archiveIssueMutation.mutate(issue.id)
|
||||
: undefined
|
||||
}
|
||||
|
|
@ -1212,15 +1499,17 @@ export function Inbox() {
|
|||
/>
|
||||
);
|
||||
|
||||
return isMineTab ? (
|
||||
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${issue.id}`}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving || archiveIssueMutation.isPending}
|
||||
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row;
|
||||
) : row));
|
||||
return elements;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,16 @@ import { useToast } from "../context/ToastContext";
|
|||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import {
|
||||
applyOptimisticIssueCommentUpdate,
|
||||
createOptimisticIssueComment,
|
||||
isQueuedIssueComment,
|
||||
mergeIssueComments,
|
||||
upsertIssueComment,
|
||||
type IssueCommentReassignment,
|
||||
type OptimisticIssueComment,
|
||||
} from "../lib/optimistic-issue-comments";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
|
|
@ -55,11 +64,15 @@ import {
|
|||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import type { Agent, IssueAttachment } from "@paperclipai/shared";
|
||||
import type { Agent, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
|
||||
|
||||
type CommentReassignment = {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
type CommentReassignment = IssueCommentReassignment;
|
||||
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||
runId?: string | null;
|
||||
runAgentId?: string | null;
|
||||
interruptedRunId?: string | null;
|
||||
queueState?: "queued";
|
||||
queueTargetRunId?: string | null;
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
|
|
@ -213,6 +226,7 @@ export function IssueDetail() {
|
|||
});
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||
|
||||
|
|
@ -269,9 +283,17 @@ export function IssueDetail() {
|
|||
});
|
||||
|
||||
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
||||
const runningIssueRun = useMemo(
|
||||
() => (
|
||||
activeRun?.status === "running"
|
||||
? activeRun
|
||||
: (liveRuns ?? []).find((run) => run.status === "running") ?? null
|
||||
),
|
||||
[activeRun, liveRuns],
|
||||
);
|
||||
const sourceBreadcrumb = useMemo(
|
||||
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" },
|
||||
[location.state],
|
||||
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
||||
[location.state, location.search],
|
||||
);
|
||||
|
||||
// Filter out runs already shown by the live widget to avoid duplication
|
||||
|
|
@ -386,12 +408,23 @@ export function IssueDetail() {
|
|||
);
|
||||
|
||||
const suggestedAssigneeValue = useMemo(
|
||||
() => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId),
|
||||
[issue, comments, currentUserId],
|
||||
() =>
|
||||
suggestedCommentAssigneeValue(
|
||||
issue ?? {},
|
||||
mergeIssueComments(comments ?? [], optimisticComments),
|
||||
currentUserId,
|
||||
),
|
||||
[issue, comments, optimisticComments, currentUserId],
|
||||
);
|
||||
|
||||
const commentsWithRunMeta = useMemo(() => {
|
||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
|
||||
const threadComments = useMemo(
|
||||
() => mergeIssueComments(comments ?? [], optimisticComments),
|
||||
[comments, optimisticComments],
|
||||
);
|
||||
|
||||
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
|
||||
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
|
||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
|
||||
const agentIdByRunId = new Map<string, string>();
|
||||
for (const run of linkedRuns ?? []) {
|
||||
agentIdByRunId.set(run.runId, run.agentId);
|
||||
|
|
@ -401,16 +434,44 @@ export function IssueDetail() {
|
|||
const details = evt.details ?? {};
|
||||
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
|
||||
if (!commentId || runMetaByCommentId.has(commentId)) continue;
|
||||
const interruptedRunId =
|
||||
typeof details["interruptedRunId"] === "string" ? details["interruptedRunId"] : null;
|
||||
runMetaByCommentId.set(commentId, {
|
||||
runId: evt.runId,
|
||||
runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null,
|
||||
interruptedRunId,
|
||||
});
|
||||
}
|
||||
return (comments ?? []).map((comment) => {
|
||||
return threadComments.map((comment) => {
|
||||
const meta = runMetaByCommentId.get(comment.id);
|
||||
return meta ? { ...comment, ...meta } : comment;
|
||||
const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment };
|
||||
if (
|
||||
isQueuedIssueComment({
|
||||
comment: nextComment,
|
||||
activeRunStartedAt,
|
||||
runId: meta?.runId ?? nextComment.runId ?? null,
|
||||
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
...nextComment,
|
||||
queueState: "queued" as const,
|
||||
queueTargetRunId: runningIssueRun?.id ?? nextComment.queueTargetRunId ?? null,
|
||||
};
|
||||
}
|
||||
return nextComment;
|
||||
});
|
||||
}, [activity, comments, linkedRuns]);
|
||||
}, [activity, threadComments, linkedRuns, runningIssueRun]);
|
||||
|
||||
const queuedComments = useMemo(
|
||||
() => commentsWithRunMeta.filter((comment) => comment.queueState === "queued"),
|
||||
[commentsWithRunMeta],
|
||||
);
|
||||
|
||||
const timelineComments = useMemo(
|
||||
() => commentsWithRunMeta.filter((comment) => comment.queueState !== "queued"),
|
||||
[commentsWithRunMeta],
|
||||
);
|
||||
|
||||
const issueCostSummary = useMemo(() => {
|
||||
let input = 0;
|
||||
|
|
@ -489,9 +550,67 @@ export function IssueDetail() {
|
|||
});
|
||||
|
||||
const addComment = useMutation({
|
||||
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
|
||||
issuesApi.addComment(issueId!, body, reopen),
|
||||
onSuccess: () => {
|
||||
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
|
||||
issuesApi.addComment(issueId!, body, reopen, interrupt),
|
||||
onMutate: async ({ body, reopen, interrupt }) => {
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
|
||||
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
||||
const queuedComment = !interrupt && runningIssueRun;
|
||||
const optimisticComment = issue
|
||||
? createOptimisticIssueComment({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
body,
|
||||
authorUserId: currentUserId,
|
||||
clientStatus: queuedComment ? "queued" : "pending",
|
||||
queueTargetRunId: queuedComment ? runningIssueRun.id : null,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (optimisticComment) {
|
||||
setOptimisticComments((current) => [...current, optimisticComment]);
|
||||
}
|
||||
if (previousIssue) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.detail(issueId!),
|
||||
applyOptimisticIssueCommentUpdate(previousIssue, { reopen }),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
optimisticCommentId: optimisticComment?.clientId ?? null,
|
||||
previousIssue,
|
||||
};
|
||||
},
|
||||
onSuccess: (comment, _variables, context) => {
|
||||
if (context?.optimisticCommentId) {
|
||||
setOptimisticComments((current) =>
|
||||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
queryClient.setQueryData<IssueComment[]>(
|
||||
queryKeys.issues.comments(issueId!),
|
||||
(current) => upsertIssueComment(current, comment),
|
||||
);
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.optimisticCommentId) {
|
||||
setOptimisticComments((current) =>
|
||||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
if (context?.previousIssue) {
|
||||
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
|
||||
}
|
||||
pushToast({
|
||||
title: "Comment failed",
|
||||
body: err instanceof Error ? err.message : "Unable to post comment",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
invalidateIssue();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
},
|
||||
|
|
@ -501,10 +620,12 @@ export function IssueDetail() {
|
|||
mutationFn: ({
|
||||
body,
|
||||
reopen,
|
||||
interrupt,
|
||||
reassignment,
|
||||
}: {
|
||||
body: string;
|
||||
reopen?: boolean;
|
||||
interrupt?: boolean;
|
||||
reassignment: CommentReassignment;
|
||||
}) =>
|
||||
issuesApi.update(issueId!, {
|
||||
|
|
@ -512,13 +633,96 @@ export function IssueDetail() {
|
|||
assigneeAgentId: reassignment.assigneeAgentId,
|
||||
assigneeUserId: reassignment.assigneeUserId,
|
||||
...(reopen ? { status: "todo" } : {}),
|
||||
...(interrupt ? { interrupt } : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onMutate: async ({ body, reopen, reassignment, interrupt }) => {
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
|
||||
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
||||
const queuedComment = !interrupt && runningIssueRun;
|
||||
const optimisticComment = issue
|
||||
? createOptimisticIssueComment({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
body,
|
||||
authorUserId: currentUserId,
|
||||
clientStatus: queuedComment ? "queued" : "pending",
|
||||
queueTargetRunId: queuedComment ? runningIssueRun.id : null,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (optimisticComment) {
|
||||
setOptimisticComments((current) => [...current, optimisticComment]);
|
||||
}
|
||||
if (previousIssue) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.detail(issueId!),
|
||||
applyOptimisticIssueCommentUpdate(previousIssue, { reopen, reassignment }),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
optimisticCommentId: optimisticComment?.clientId ?? null,
|
||||
previousIssue,
|
||||
};
|
||||
},
|
||||
onSuccess: (result, _variables, context) => {
|
||||
if (context?.optimisticCommentId) {
|
||||
setOptimisticComments((current) =>
|
||||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
|
||||
const { comment, ...nextIssue } = result;
|
||||
queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue);
|
||||
if (comment) {
|
||||
queryClient.setQueryData<IssueComment[]>(
|
||||
queryKeys.issues.comments(issueId!),
|
||||
(current) => upsertIssueComment(current, comment),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.optimisticCommentId) {
|
||||
setOptimisticComments((current) =>
|
||||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
if (context?.previousIssue) {
|
||||
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
|
||||
}
|
||||
pushToast({
|
||||
title: "Comment failed",
|
||||
body: err instanceof Error ? err.message : "Unable to post comment",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
invalidateIssue();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||
},
|
||||
});
|
||||
|
||||
const interruptQueuedComment = useMutation({
|
||||
mutationFn: (runId: string) => heartbeatsApi.cancel(runId),
|
||||
onSuccess: () => {
|
||||
invalidateIssue();
|
||||
pushToast({
|
||||
title: "Interrupt requested",
|
||||
body: "The active run is stopping so queued comments can continue next.",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Interrupt failed",
|
||||
body: err instanceof Error ? err.message : "Unable to interrupt the active run",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const uploadAttachment = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!selectedCompanyId) throw new Error("No company selected");
|
||||
|
|
@ -581,9 +785,12 @@ export function IssueDetail() {
|
|||
// Redirect to identifier-based URL if navigated via UUID
|
||||
useEffect(() => {
|
||||
if (issue?.identifier && issueId !== issue.identifier) {
|
||||
navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state });
|
||||
navigate(createIssueDetailPath(issue.identifier, location.state, location.search), {
|
||||
replace: true,
|
||||
state: location.state,
|
||||
});
|
||||
}
|
||||
}, [issue, issueId, navigate, location.state]);
|
||||
}, [issue, issueId, navigate, location.state, location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issue?.id) return;
|
||||
|
|
@ -695,7 +902,7 @@ export function IssueDetail() {
|
|||
<span key={ancestor.id} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
<Link
|
||||
to={`/issues/${ancestor.identifier ?? ancestor.id}`}
|
||||
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id, location.state, location.search)}
|
||||
state={location.state}
|
||||
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
||||
title={ancestor.title}
|
||||
|
|
@ -1025,7 +1232,8 @@ export function IssueDetail() {
|
|||
|
||||
<TabsContent value="comments">
|
||||
<CommentThread
|
||||
comments={commentsWithRunMeta}
|
||||
comments={timelineComments}
|
||||
queuedComments={queuedComments}
|
||||
linkedRuns={timelineRuns}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId}
|
||||
|
|
@ -1037,6 +1245,10 @@ export function IssueDetail() {
|
|||
currentAssigneeValue={actualAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentionOptions}
|
||||
onInterruptQueued={async (runId) => {
|
||||
await interruptQueuedComment.mutateAsync(runId);
|
||||
}}
|
||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
|
||||
onAdd={async (body, reopen, reassignment) => {
|
||||
if (reassignment) {
|
||||
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
||||
|
|
@ -1063,7 +1275,7 @@ export function IssueDetail() {
|
|||
{childIssues.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
to={`/issues/${child.identifier ?? child.id}`}
|
||||
to={createIssueDetailPath(child.identifier ?? child.id, location.state, location.search)}
|
||||
state={location.state}
|
||||
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export function Issues() {
|
|||
createIssueDetailLocationState(
|
||||
"Issues",
|
||||
`${location.pathname}${location.search}${location.hash}`,
|
||||
"issues",
|
||||
),
|
||||
[location.pathname, location.search, location.hash],
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue