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;
|
] as const;
|
||||||
export type IssueStatus = (typeof ISSUE_STATUSES)[number];
|
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 const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
|
||||||
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
|
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ export {
|
||||||
AGENT_ROLE_LABELS,
|
AGENT_ROLE_LABELS,
|
||||||
AGENT_ICON_NAMES,
|
AGENT_ICON_NAMES,
|
||||||
ISSUE_STATUSES,
|
ISSUE_STATUSES,
|
||||||
|
INBOX_MINE_ISSUE_STATUSES,
|
||||||
|
INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||||
ISSUE_PRIORITIES,
|
ISSUE_PRIORITIES,
|
||||||
ISSUE_ORIGIN_KINDS,
|
ISSUE_ORIGIN_KINDS,
|
||||||
GOAL_LEVELS,
|
GOAL_LEVELS,
|
||||||
|
|
@ -344,6 +346,7 @@ export {
|
||||||
upsertAgentInstructionsFileSchema,
|
upsertAgentInstructionsFileSchema,
|
||||||
updateAgentInstructionsPathSchema,
|
updateAgentInstructionsPathSchema,
|
||||||
createAgentKeySchema,
|
createAgentKeySchema,
|
||||||
|
agentMineInboxQuerySchema,
|
||||||
wakeAgentSchema,
|
wakeAgentSchema,
|
||||||
resetAgentSessionSchema,
|
resetAgentSessionSchema,
|
||||||
testAdapterEnvironmentSchema,
|
testAdapterEnvironmentSchema,
|
||||||
|
|
@ -356,6 +359,7 @@ export {
|
||||||
type UpsertAgentInstructionsFile,
|
type UpsertAgentInstructionsFile,
|
||||||
type UpdateAgentInstructionsPath,
|
type UpdateAgentInstructionsPath,
|
||||||
type CreateAgentKey,
|
type CreateAgentKey,
|
||||||
|
type AgentMineInboxQuery,
|
||||||
type WakeAgent,
|
type WakeAgent,
|
||||||
type ResetAgentSession,
|
type ResetAgentSession,
|
||||||
type TestAdapterEnvironment,
|
type TestAdapterEnvironment,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
AGENT_ICON_NAMES,
|
AGENT_ICON_NAMES,
|
||||||
AGENT_ROLES,
|
AGENT_ROLES,
|
||||||
AGENT_STATUSES,
|
AGENT_STATUSES,
|
||||||
|
INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||||
} from "../constants.js";
|
} from "../constants.js";
|
||||||
import { envConfigSchema } from "./secret.js";
|
import { envConfigSchema } from "./secret.js";
|
||||||
|
|
||||||
|
|
@ -93,6 +94,13 @@ export const createAgentKeySchema = z.object({
|
||||||
|
|
||||||
export type CreateAgentKey = z.infer<typeof createAgentKeySchema>;
|
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({
|
export const wakeAgentSchema = z.object({
|
||||||
source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"),
|
source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"),
|
||||||
triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(),
|
triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(),
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ export {
|
||||||
upsertAgentInstructionsFileSchema,
|
upsertAgentInstructionsFileSchema,
|
||||||
updateAgentInstructionsPathSchema,
|
updateAgentInstructionsPathSchema,
|
||||||
createAgentKeySchema,
|
createAgentKeySchema,
|
||||||
|
agentMineInboxQuerySchema,
|
||||||
wakeAgentSchema,
|
wakeAgentSchema,
|
||||||
resetAgentSessionSchema,
|
resetAgentSessionSchema,
|
||||||
testAdapterEnvironmentSchema,
|
testAdapterEnvironmentSchema,
|
||||||
|
|
@ -97,6 +98,7 @@ export {
|
||||||
type UpsertAgentInstructionsFile,
|
type UpsertAgentInstructionsFile,
|
||||||
type UpdateAgentInstructionsPath,
|
type UpdateAgentInstructionsPath,
|
||||||
type CreateAgentKey,
|
type CreateAgentKey,
|
||||||
|
type AgentMineInboxQuery,
|
||||||
type WakeAgent,
|
type WakeAgent,
|
||||||
type ResetAgentSession,
|
type ResetAgentSession,
|
||||||
type TestAdapterEnvironment,
|
type TestAdapterEnvironment,
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
|
||||||
export const updateIssueSchema = createIssueSchema.partial().extend({
|
export const updateIssueSchema = createIssueSchema.partial().extend({
|
||||||
comment: z.string().min(1).optional(),
|
comment: z.string().min(1).optional(),
|
||||||
reopen: z.boolean().optional(),
|
reopen: z.boolean().optional(),
|
||||||
|
interrupt: z.boolean().optional(),
|
||||||
hiddenAt: z.string().datetime().nullable().optional(),
|
hiddenAt: z.string().datetime().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
|
||||||
import { agentRoutes } from "../routes/agents.js";
|
import { agentRoutes } from "../routes/agents.js";
|
||||||
import { errorHandler } from "../middleware/index.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.canAssignTasks).toBe(true);
|
||||||
expect(res.body.access.taskAssignSource).toBe("agent_creator");
|
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(() => ({
|
const mockHeartbeatService = vi.hoisted(() => ({
|
||||||
wakeup: vi.fn(async () => undefined),
|
wakeup: vi.fn(async () => undefined),
|
||||||
reportRunActivity: 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(() => ({
|
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 { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
agentSkillSyncSchema,
|
agentSkillSyncSchema,
|
||||||
|
agentMineInboxQuerySchema,
|
||||||
createAgentKeySchema,
|
createAgentKeySchema,
|
||||||
createAgentHireSchema,
|
createAgentHireSchema,
|
||||||
createAgentSchema,
|
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) => {
|
router.get("/agents/:id", async (req, res) => {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const agent = await svc.getById(id);
|
const agent = await svc.getById(id);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Router, type Request, type Response } from "express";
|
import { Router, type Request, type Response } from "express";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
|
import { z } from "zod";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
addIssueCommentSchema,
|
addIssueCommentSchema,
|
||||||
|
|
@ -38,6 +39,9 @@ import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.
|
||||||
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
||||||
|
|
||||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||||
|
const updateIssueRouteSchema = updateIssueSchema.extend({
|
||||||
|
interrupt: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export function issueRoutes(db: Db, storage: StorageService) {
|
export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -161,6 +165,30 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
return true;
|
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> {
|
async function normalizeIssueIdentifier(rawId: string): Promise<string> {
|
||||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||||
const issue = await svc.getByIdentifier(rawId);
|
const issue = await svc.getByIdentifier(rawId);
|
||||||
|
|
@ -713,6 +741,38 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
res.json(readState);
|
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) => {
|
router.post("/issues/:id/inbox-archive", async (req, res) => {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const issue = await svc.getById(id);
|
const issue = await svc.getById(id);
|
||||||
|
|
@ -887,7 +947,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
res.status(201).json(issue);
|
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 id = req.params.id as string;
|
||||||
const existing = await svc.getById(id);
|
const existing = await svc.getById(id);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
|
@ -917,7 +977,45 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const isClosed = existing.status === "done" || existing.status === "cancelled";
|
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) {
|
if (hiddenAtRaw !== undefined) {
|
||||||
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
|
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
|
||||||
}
|
}
|
||||||
|
|
@ -992,6 +1090,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
identifier: issue.identifier,
|
identifier: issue.identifier,
|
||||||
...(commentBody ? { source: "comment" } : {}),
|
...(commentBody ? { source: "comment" } : {}),
|
||||||
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
|
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
|
||||||
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
_previous: hasFieldChanges ? previous : undefined,
|
_previous: hasFieldChanges ? previous : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -1018,6 +1117,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
identifier: issue.identifier,
|
identifier: issue.identifier,
|
||||||
issueTitle: issue.title,
|
issueTitle: issue.title,
|
||||||
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
||||||
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
...(hasFieldChanges ? { updated: true } : {}),
|
...(hasFieldChanges ? { updated: true } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -1039,10 +1139,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||||
source: "assignment",
|
source: "assignment",
|
||||||
triggerDetail: "system",
|
triggerDetail: "system",
|
||||||
reason: "issue_assigned",
|
reason: "issue_assigned",
|
||||||
payload: { issueId: issue.id, mutation: "update" },
|
payload: {
|
||||||
|
issueId: issue.id,
|
||||||
|
mutation: "update",
|
||||||
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
|
},
|
||||||
requestedByActorType: actor.actorType,
|
requestedByActorType: actor.actorType,
|
||||||
requestedByActorId: actor.actorId,
|
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",
|
source: "automation",
|
||||||
triggerDetail: "system",
|
triggerDetail: "system",
|
||||||
reason: "issue_status_changed",
|
reason: "issue_status_changed",
|
||||||
payload: { issueId: issue.id, mutation: "update" },
|
payload: {
|
||||||
|
issueId: issue.id,
|
||||||
|
mutation: "update",
|
||||||
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
|
},
|
||||||
requestedByActorType: actor.actorType,
|
requestedByActorType: actor.actorType,
|
||||||
requestedByActorId: actor.actorId,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let runToInterrupt = currentIssue.executionRunId
|
const runToInterrupt = await resolveActiveIssueRun(currentIssue);
|
||||||
? await heartbeat.getRun(currentIssue.executionRunId)
|
if (runToInterrupt) {
|
||||||
: 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 cancelled = await heartbeat.cancelRun(runToInterrupt.id);
|
const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
interruptedRunId = cancelled.id;
|
interruptedRunId = cancelled.id;
|
||||||
|
|
|
||||||
|
|
@ -791,6 +791,20 @@ export function issueService(db: Db) {
|
||||||
return row;
|
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()) => {
|
archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,7 @@ PATCH /api/agents/{agentId}/instructions-path
|
||||||
| ----------------------------------------- | ------------------------------------------------------------------------------------------ |
|
| ----------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| My identity | `GET /api/agents/me` |
|
| My identity | `GET /api/agents/me` |
|
||||||
| My compact inbox | `GET /api/agents/me/inbox-lite` |
|
| 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` |
|
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
|
||||||
| Checkout task | `POST /api/issues/:issueId/checkout` |
|
| Checkout task | `POST /api/issues/:issueId/checkout` |
|
||||||
| Get task + ancestors | `GET /api/issues/:issueId` |
|
| 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." }
|
{ "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
|
## Worked Example: Manager Heartbeat
|
||||||
|
|
@ -566,6 +594,7 @@ Terminal states: `done`, `cancelled`
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ---------------------------------- | ------------------------------------ |
|
| ------ | ---------------------------------- | ------------------------------------ |
|
||||||
| GET | `/api/agents/me` | Your agent record + chain of command |
|
| 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/agents/:agentId` | Agent details + chain of command |
|
||||||
| GET | `/api/companies/:companyId/agents` | List all agents in company |
|
| GET | `/api/companies/:companyId/agents` | List all agents in company |
|
||||||
| GET | `/api/companies/:companyId/org` | Org chart tree |
|
| GET | `/api/companies/:companyId/org` | Org chart tree |
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import type {
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export type IssueUpdateResponse = Issue & {
|
||||||
|
comment?: IssueComment | null;
|
||||||
|
};
|
||||||
|
|
||||||
export const issuesApi = {
|
export const issuesApi = {
|
||||||
list: (
|
list: (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
|
|
@ -53,13 +57,15 @@ export const issuesApi = {
|
||||||
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
|
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
|
||||||
get: (id: string) => api.get<Issue>(`/issues/${id}`),
|
get: (id: string) => api.get<Issue>(`/issues/${id}`),
|
||||||
markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}),
|
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) =>
|
archiveFromInbox: (id: string) =>
|
||||||
api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}),
|
api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}),
|
||||||
unarchiveFromInbox: (id: string) =>
|
unarchiveFromInbox: (id: string) =>
|
||||||
api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`),
|
api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`),
|
||||||
create: (companyId: string, data: Record<string, unknown>) =>
|
create: (companyId: string, data: Record<string, unknown>) =>
|
||||||
api.post<Issue>(`/companies/${companyId}/issues`, data),
|
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}`),
|
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
|
||||||
checkout: (id: string, agentId: string) =>
|
checkout: (id: string, agentId: string) =>
|
||||||
api.post<Issue>(`/issues/${id}/checkout`, {
|
api.post<Issue>(`/issues/${id}/checkout`, {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ import { PluginSlotOutlet } from "@/plugins/slots";
|
||||||
interface CommentWithRunMeta extends IssueComment {
|
interface CommentWithRunMeta extends IssueComment {
|
||||||
runId?: string | null;
|
runId?: string | null;
|
||||||
runAgentId?: string | null;
|
runAgentId?: string | null;
|
||||||
|
clientId?: string;
|
||||||
|
clientStatus?: "pending" | "queued";
|
||||||
|
queueState?: "queued";
|
||||||
|
queueTargetRunId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinkedRunItem {
|
interface LinkedRunItem {
|
||||||
|
|
@ -32,6 +36,7 @@ interface CommentReassignment {
|
||||||
|
|
||||||
interface CommentThreadProps {
|
interface CommentThreadProps {
|
||||||
comments: CommentWithRunMeta[];
|
comments: CommentWithRunMeta[];
|
||||||
|
queuedComments?: CommentWithRunMeta[];
|
||||||
linkedRuns?: LinkedRunItem[];
|
linkedRuns?: LinkedRunItem[];
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
|
@ -48,6 +53,8 @@ interface CommentThreadProps {
|
||||||
currentAssigneeValue?: string;
|
currentAssigneeValue?: string;
|
||||||
suggestedAssigneeValue?: string;
|
suggestedAssigneeValue?: string;
|
||||||
mentions?: MentionOption[];
|
mentions?: MentionOption[];
|
||||||
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
|
interruptingQueuedRunId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DRAFT_DEBOUNCE_MS = 800;
|
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 =
|
type TimelineItem =
|
||||||
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
|
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
|
||||||
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
||||||
|
|
@ -168,86 +291,15 @@ const TimelineList = memo(function TimelineList({
|
||||||
}
|
}
|
||||||
|
|
||||||
const comment = item.comment;
|
const comment = item.comment;
|
||||||
const isHighlighted = highlightCommentId === comment.id;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CommentCard
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
id={`comment-${comment.id}`}
|
comment={comment}
|
||||||
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${isHighlighted ? "border-primary/50 bg-primary/5" : "border-border"}`}
|
agentMap={agentMap}
|
||||||
>
|
companyId={companyId}
|
||||||
<div className="flex items-center justify-between mb-1">
|
projectId={projectId}
|
||||||
{comment.authorAgentId ? (
|
highlightCommentId={highlightCommentId}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -256,6 +308,7 @@ const TimelineList = memo(function TimelineList({
|
||||||
|
|
||||||
export function CommentThread({
|
export function CommentThread({
|
||||||
comments,
|
comments,
|
||||||
|
queuedComments = [],
|
||||||
linkedRuns = [],
|
linkedRuns = [],
|
||||||
companyId,
|
companyId,
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -270,6 +323,8 @@ export function CommentThread({
|
||||||
currentAssigneeValue = "",
|
currentAssigneeValue = "",
|
||||||
suggestedAssigneeValue,
|
suggestedAssigneeValue,
|
||||||
mentions: providedMentions,
|
mentions: providedMentions,
|
||||||
|
onInterruptQueued,
|
||||||
|
interruptingQueuedRunId = null,
|
||||||
}: CommentThreadProps) {
|
}: CommentThreadProps) {
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [reopen, setReopen] = useState(true);
|
const [reopen, setReopen] = useState(true);
|
||||||
|
|
@ -345,7 +400,7 @@ export function CommentThread({
|
||||||
// Scroll to comment when URL hash matches #comment-{id}
|
// Scroll to comment when URL hash matches #comment-{id}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = location.hash;
|
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);
|
const commentId = hash.slice("#comment-".length);
|
||||||
// Only scroll once per hash
|
// Only scroll once per hash
|
||||||
if (hasScrolledRef.current) return;
|
if (hasScrolledRef.current) return;
|
||||||
|
|
@ -358,7 +413,7 @@ export function CommentThread({
|
||||||
const timer = setTimeout(() => setHighlightCommentId(null), 3000);
|
const timer = setTimeout(() => setHighlightCommentId(null), 3000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [location.hash, comments]);
|
}, [location.hash, comments, queuedComments]);
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const trimmed = body.trim();
|
const trimmed = body.trim();
|
||||||
|
|
@ -368,11 +423,14 @@ export function CommentThread({
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
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);
|
await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined);
|
||||||
setBody("");
|
setBody("");
|
||||||
if (draftKey) clearDraft(draftKey);
|
if (draftKey) clearDraft(draftKey);
|
||||||
setReopen(true);
|
setReopen(true);
|
||||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||||
|
} catch {
|
||||||
|
// Parent mutation handlers surface the failure and keep the draft intact.
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -401,18 +459,54 @@ export function CommentThread({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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.length > 0 ? (
|
||||||
timeline={timeline}
|
<TimelineList
|
||||||
agentMap={agentMap}
|
timeline={timeline}
|
||||||
companyId={companyId}
|
agentMap={agentMap}
|
||||||
projectId={projectId}
|
companyId={companyId}
|
||||||
highlightCommentId={highlightCommentId}
|
projectId={projectId}
|
||||||
/>
|
highlightCommentId={highlightCommentId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{liveRunSlot}
|
{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">
|
<div className="space-y-2">
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useQueries } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
PointerSensor,
|
MouseSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
|
|
@ -244,7 +244,8 @@ export function CompanyRail() {
|
||||||
|
|
||||||
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
// Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
|
||||||
|
useSensor(MouseSensor, {
|
||||||
activationConstraint: { distance: 8 },
|
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 type { Issue } from "@paperclipai/shared";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
|
|
||||||
|
|
@ -10,6 +11,7 @@ type UnreadState = "hidden" | "visible" | "fading";
|
||||||
interface IssueRowProps {
|
interface IssueRowProps {
|
||||||
issue: Issue;
|
issue: Issue;
|
||||||
issueLinkState?: unknown;
|
issueLinkState?: unknown;
|
||||||
|
selected?: boolean;
|
||||||
mobileLeading?: ReactNode;
|
mobileLeading?: ReactNode;
|
||||||
desktopMetaLeading?: ReactNode;
|
desktopMetaLeading?: ReactNode;
|
||||||
desktopLeadingSpacer?: boolean;
|
desktopLeadingSpacer?: boolean;
|
||||||
|
|
@ -26,6 +28,7 @@ interface IssueRowProps {
|
||||||
export function IssueRow({
|
export function IssueRow({
|
||||||
issue,
|
issue,
|
||||||
issueLinkState,
|
issueLinkState,
|
||||||
|
selected = false,
|
||||||
mobileLeading,
|
mobileLeading,
|
||||||
desktopMetaLeading,
|
desktopMetaLeading,
|
||||||
desktopLeadingSpacer = false,
|
desktopLeadingSpacer = false,
|
||||||
|
|
@ -42,18 +45,21 @@ export function IssueRow({
|
||||||
const identifier = issue.identifier ?? issue.id.slice(0, 8);
|
const identifier = issue.identifier ?? issue.id.slice(0, 8);
|
||||||
const showUnreadSlot = unreadState !== null;
|
const showUnreadSlot = unreadState !== null;
|
||||||
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
||||||
|
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/issues/${issuePathId}`}
|
to={createIssueDetailPath(issuePathId, issueLinkState)}
|
||||||
state={issueLinkState}
|
state={issueLinkState}
|
||||||
|
data-inbox-issue-link
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="shrink-0 pt-px sm:hidden">
|
<span className="shrink-0 pt-px sm:hidden">
|
||||||
{mobileLeading ?? <StatusIcon status={issue.status} />}
|
{mobileLeading ?? <StatusIcon status={issue.status} className={selectedStatusClass} />}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
<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">
|
<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 ?? (
|
{desktopMetaLeading ?? (
|
||||||
<>
|
<>
|
||||||
<span className="hidden shrink-0 sm:inline-flex">
|
<span className="hidden shrink-0 sm:inline-flex">
|
||||||
<StatusIcon status={issue.status} />
|
<StatusIcon status={issue.status} className={selectedStatusClass} />
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
{identifier}
|
{identifier}
|
||||||
|
|
@ -108,12 +114,16 @@ export function IssueRow({
|
||||||
onMarkRead?.();
|
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"
|
aria-label="Mark as read"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -564,8 +564,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
{mentionActive && filteredMentions.length > 0 &&
|
{mentionActive && filteredMentions.length > 0 &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed z-[9999] min-w-[180px] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
|
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: mentionState.viewportTop + 4, left: mentionState.viewportLeft }}
|
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) => (
|
{filteredMentions.map((option, i) => (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronRight, Plus } from "lucide-react";
|
import { ChevronRight, Plus } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
PointerSensor,
|
MouseSensor,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
useSensor,
|
useSensor,
|
||||||
|
|
@ -153,7 +153,8 @@ export function SidebarProjects() {
|
||||||
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
|
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
|
||||||
const activeProjectRef = projectMatch?.[1] ?? null;
|
const activeProjectRef = projectMatch?.[1] ?? null;
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
// Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior.
|
||||||
|
useSensor(MouseSensor, {
|
||||||
activationConstraint: { distance: 8 },
|
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;
|
children: ReactNode;
|
||||||
onArchive: () => void;
|
onArchive: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMIT_THRESHOLD = 0.4;
|
const COMMIT_THRESHOLD = 0.32;
|
||||||
const MAX_SWIPE = 0.92;
|
const MAX_SWIPE = 0.88;
|
||||||
const COMMIT_DELAY_MS = 210;
|
const COMMIT_DELAY_MS = 140;
|
||||||
|
const SELECTED_ROW_BACKGROUND = "hsl(var(--muted))";
|
||||||
|
|
||||||
export function SwipeToArchive({
|
export function SwipeToArchive({
|
||||||
children,
|
children,
|
||||||
onArchive,
|
onArchive,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
selected = false,
|
||||||
className,
|
className,
|
||||||
}: SwipeToArchiveProps) {
|
}: SwipeToArchiveProps) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const startPointRef = useRef<{ x: number; y: number } | null>(null);
|
const startPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
const widthRef = useRef(0);
|
const widthRef = useRef(0);
|
||||||
const timeoutRef = useRef<number | null>(null);
|
const timeoutRef = useRef<number | null>(null);
|
||||||
|
const suppressClickRef = useRef(false);
|
||||||
const [offsetX, setOffsetX] = useState(0);
|
const [offsetX, setOffsetX] = useState(0);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isCollapsing, setIsCollapsing] = useState(false);
|
const [isCollapsing, setIsCollapsing] = useState(false);
|
||||||
|
|
@ -68,6 +72,7 @@ export function SwipeToArchive({
|
||||||
widthRef.current = node?.offsetWidth ?? 0;
|
widthRef.current = node?.offsetWidth ?? 0;
|
||||||
setLockedHeight(node?.offsetHeight ?? null);
|
setLockedHeight(node?.offsetHeight ?? null);
|
||||||
setIsCollapsing(false);
|
setIsCollapsing(false);
|
||||||
|
suppressClickRef.current = false;
|
||||||
startPointRef.current = { x: touch.clientX, y: touch.clientY };
|
startPointRef.current = { x: touch.clientX, y: touch.clientY };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -86,6 +91,7 @@ export function SwipeToArchive({
|
||||||
startPointRef.current = null;
|
startPointRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
suppressClickRef.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deltaX >= 0) {
|
if (deltaX >= 0) {
|
||||||
|
|
@ -127,6 +133,12 @@ export function SwipeToArchive({
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
onTouchCancel={handleTouchEnd}
|
onTouchCancel={handleTouchEnd}
|
||||||
|
onClickCapture={(event) => {
|
||||||
|
if (!suppressClickRef.current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|
@ -139,10 +151,12 @@ export function SwipeToArchive({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
data-inbox-row-surface
|
||||||
className="relative bg-card will-change-transform"
|
className="relative bg-card will-change-transform"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate3d(${offsetX}px, 0, 0)`,
|
transform: `translate3d(${offsetX}px, 0, 0)`,
|
||||||
transition: isDragging ? "none" : "transform 180ms ease-out",
|
transition: isDragging ? "none" : "transform 180ms ease-out",
|
||||||
|
backgroundColor: selected ? SELECTED_ROW_BACKGROUND : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{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) {
|
export function useInboxBadge(companyId: string | null | undefined) {
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,17 @@
|
||||||
margin-top: 1.1em;
|
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-mention-chip,
|
||||||
.paperclip-mdxeditor-content a.paperclip-project-mention-chip {
|
.paperclip-mdxeditor-content a.paperclip-project-mention-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
@ -661,12 +672,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||||
|
|
||||||
.paperclip-markdown a {
|
.paperclip-markdown a {
|
||||||
color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%);
|
color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%);
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.paperclip-markdown a:hover {
|
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 0.15em;
|
text-underline-offset: 0.15em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-markdown a.paperclip-mention-chip {
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .paperclip-markdown a {
|
.dark .paperclip-markdown a {
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,13 @@ import {
|
||||||
computeInboxBadgeData,
|
computeInboxBadgeData,
|
||||||
getApprovalsForTab,
|
getApprovalsForTab,
|
||||||
getInboxWorkItems,
|
getInboxWorkItems,
|
||||||
|
getInboxKeyboardSelectionIndex,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
getUnreadTouchedIssues,
|
getUnreadTouchedIssues,
|
||||||
|
isMineInboxTab,
|
||||||
loadLastInboxTab,
|
loadLastInboxTab,
|
||||||
RECENT_ISSUES_LIMIT,
|
RECENT_ISSUES_LIMIT,
|
||||||
|
resolveInboxSelectionIndex,
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
shouldShowInboxSection,
|
shouldShowInboxSection,
|
||||||
} from "./inbox";
|
} from "./inbox";
|
||||||
|
|
@ -400,4 +403,24 @@ describe("inbox helpers", () => {
|
||||||
localStorage.setItem("paperclip:inbox:last-tab", "new");
|
localStorage.setItem("paperclip:inbox:last-tab", "new");
|
||||||
expect(loadLastInboxTab()).toBe("mine");
|
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[] {
|
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
|
||||||
const sorted = [...runs].sort(
|
const sorted = [...runs].sort(
|
||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
(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 = {
|
type IssueDetailBreadcrumb = {
|
||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
|
|
@ -5,20 +7,64 @@ type IssueDetailBreadcrumb = {
|
||||||
|
|
||||||
type IssueDetailLocationState = {
|
type IssueDetailLocationState = {
|
||||||
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
|
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
|
||||||
|
issueDetailSource?: IssueDetailSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
|
||||||
|
|
||||||
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
|
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
|
||||||
if (typeof value !== "object" || value === null) return false;
|
if (typeof value !== "object" || value === null) return false;
|
||||||
const candidate = value as Partial<IssueDetailBreadcrumb>;
|
const candidate = value as Partial<IssueDetailBreadcrumb>;
|
||||||
return typeof candidate.label === "string" && typeof candidate.href === "string";
|
return typeof candidate.label === "string" && typeof candidate.href === "string";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState {
|
function isIssueDetailSource(value: unknown): value is IssueDetailSource {
|
||||||
return { issueDetailBreadcrumb: { label, href } };
|
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;
|
if (typeof state !== "object" || state === null) return null;
|
||||||
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
|
const source = (state as IssueDetailLocationState).issueDetailSource;
|
||||||
return isIssueDetailBreadcrumb(candidate) ? candidate : null;
|
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 { Link, useLocation, useNavigate } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
|
||||||
import { approvalsApi } from "../api/approvals";
|
import { approvalsApi } from "../api/approvals";
|
||||||
import { accessApi } from "../api/access";
|
import { accessApi } from "../api/access";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
|
|
@ -11,7 +12,7 @@ import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
import { createIssueDetailLocationState, createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { IssueRow } from "../components/IssueRow";
|
import { IssueRow } from "../components/IssueRow";
|
||||||
|
|
@ -46,12 +47,16 @@ import {
|
||||||
ACTIONABLE_APPROVAL_STATUSES,
|
ACTIONABLE_APPROVAL_STATUSES,
|
||||||
getApprovalsForTab,
|
getApprovalsForTab,
|
||||||
getInboxWorkItems,
|
getInboxWorkItems,
|
||||||
|
getInboxKeyboardSelectionIndex,
|
||||||
getLatestFailedRunsByAgent,
|
getLatestFailedRunsByAgent,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
|
isMineInboxTab,
|
||||||
|
resolveInboxSelectionIndex,
|
||||||
InboxApprovalFilter,
|
InboxApprovalFilter,
|
||||||
saveLastInboxTab,
|
saveLastInboxTab,
|
||||||
shouldShowInboxSection,
|
shouldShowInboxSection,
|
||||||
type InboxTab,
|
type InboxTab,
|
||||||
|
type InboxWorkItem,
|
||||||
} from "../lib/inbox";
|
} from "../lib/inbox";
|
||||||
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
|
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
|
||||||
|
|
||||||
|
|
@ -66,8 +71,6 @@ type SectionKey =
|
||||||
| "work_items"
|
| "work_items"
|
||||||
| "alerts";
|
| "alerts";
|
||||||
|
|
||||||
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
|
||||||
|
|
||||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
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;
|
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,
|
run,
|
||||||
issueById,
|
issueById,
|
||||||
agentName: linkedAgentName,
|
agentName: linkedAgentName,
|
||||||
|
|
@ -110,6 +174,7 @@ function FailedRunInboxRow({
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onArchive,
|
onArchive,
|
||||||
archiveDisabled,
|
archiveDisabled,
|
||||||
|
selected = false,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
run: HeartbeatRun;
|
run: HeartbeatRun;
|
||||||
|
|
@ -123,6 +188,7 @@ function FailedRunInboxRow({
|
||||||
onMarkRead?: () => void;
|
onMarkRead?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
archiveDisabled?: boolean;
|
archiveDisabled?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const issueId = readIssueIdFromRun(run);
|
const issueId = readIssueIdFromRun(run);
|
||||||
|
|
@ -143,11 +209,15 @@ function FailedRunInboxRow({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onMarkRead}
|
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"
|
aria-label="Mark as read"
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<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",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -168,7 +238,10 @@ function FailedRunInboxRow({
|
||||||
) : null}
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
to={`/agents/${run.agentId}/runs/${run.id}`}
|
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" />}
|
{!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />}
|
||||||
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
|
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||||
|
|
@ -257,6 +330,7 @@ function ApprovalInboxRow({
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onArchive,
|
onArchive,
|
||||||
archiveDisabled,
|
archiveDisabled,
|
||||||
|
selected = false,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
approval: Approval;
|
approval: Approval;
|
||||||
|
|
@ -268,6 +342,7 @@ function ApprovalInboxRow({
|
||||||
onMarkRead?: () => void;
|
onMarkRead?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
archiveDisabled?: boolean;
|
archiveDisabled?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||||
|
|
@ -290,11 +365,15 @@ function ApprovalInboxRow({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onMarkRead}
|
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"
|
aria-label="Mark as read"
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<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",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -315,7 +394,10 @@ function ApprovalInboxRow({
|
||||||
) : null}
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
to={`/approvals/${approval.id}`}
|
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" />}
|
{!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />}
|
||||||
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
|
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||||
|
|
@ -389,6 +471,7 @@ function JoinRequestInboxRow({
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onArchive,
|
onArchive,
|
||||||
archiveDisabled,
|
archiveDisabled,
|
||||||
|
selected = false,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
joinRequest: JoinRequest;
|
joinRequest: JoinRequest;
|
||||||
|
|
@ -399,6 +482,7 @@ function JoinRequestInboxRow({
|
||||||
onMarkRead?: () => void;
|
onMarkRead?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
archiveDisabled?: boolean;
|
archiveDisabled?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const label =
|
const label =
|
||||||
|
|
@ -420,11 +504,15 @@ function JoinRequestInboxRow({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onMarkRead}
|
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"
|
aria-label="Mark as read"
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<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",
|
unreadState === "fading" ? "opacity-0" : "opacity-100",
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -512,18 +600,20 @@ export function Inbox() {
|
||||||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||||
const { dismissed, dismiss } = useDismissedInboxItems();
|
const { dismissed, dismiss } = useDismissedInboxItems();
|
||||||
const { readItems, markRead: markItemRead } = useReadInboxItems();
|
const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
|
||||||
|
|
||||||
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
const pathSegment = location.pathname.split("/").pop() ?? "mine";
|
||||||
const tab: InboxTab =
|
const tab: InboxTab =
|
||||||
pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread"
|
pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread"
|
||||||
? pathSegment
|
? pathSegment
|
||||||
: "mine";
|
: "mine";
|
||||||
|
const canArchiveFromTab = isMineInboxTab(tab);
|
||||||
const issueLinkState = useMemo(
|
const issueLinkState = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createIssueDetailLocationState(
|
createIssueDetailLocationState(
|
||||||
"Inbox",
|
"Inbox",
|
||||||
`${location.pathname}${location.search}${location.hash}`,
|
`${location.pathname}${location.search}${location.hash}`,
|
||||||
|
"inbox",
|
||||||
),
|
),
|
||||||
[location.pathname, location.search, location.hash],
|
[location.pathname, location.search, location.hash],
|
||||||
);
|
);
|
||||||
|
|
@ -540,6 +630,7 @@ export function Inbox() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveLastInboxTab(tab);
|
saveLastInboxTab(tab);
|
||||||
|
setSelectedIndex(-1);
|
||||||
}, [tab]);
|
}, [tab]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -591,7 +682,7 @@ export function Inbox() {
|
||||||
issuesApi.list(selectedCompanyId!, {
|
issuesApi.list(selectedCompanyId!, {
|
||||||
touchedByUserId: "me",
|
touchedByUserId: "me",
|
||||||
inboxArchivedByUserId: "me",
|
inboxArchivedByUserId: "me",
|
||||||
status: INBOX_ISSUE_STATUSES,
|
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||||
}),
|
}),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
@ -603,7 +694,7 @@ export function Inbox() {
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
issuesApi.list(selectedCompanyId!, {
|
issuesApi.list(selectedCompanyId!, {
|
||||||
touchedByUserId: "me",
|
touchedByUserId: "me",
|
||||||
status: INBOX_ISSUE_STATUSES,
|
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||||
}),
|
}),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
@ -793,6 +884,8 @@ export function Inbox() {
|
||||||
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
|
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
|
||||||
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
|
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
|
||||||
const [archivingNonIssueIds, setArchivingNonIssueIds] = 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 = () => {
|
const invalidateInboxIssueQueries = () => {
|
||||||
if (!selectedCompanyId) return;
|
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));
|
setFadingNonIssueItems((prev) => new Set(prev).add(key));
|
||||||
markItemRead(key);
|
markItemRead(key);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -885,9 +985,9 @@ export function Inbox() {
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, 300);
|
}, 300);
|
||||||
};
|
}, [markItemRead]);
|
||||||
|
|
||||||
const handleArchiveNonIssue = (key: string) => {
|
const handleArchiveNonIssue = useCallback((key: string) => {
|
||||||
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
|
setArchivingNonIssueIds((prev) => new Set(prev).add(key));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
dismiss(key);
|
dismiss(key);
|
||||||
|
|
@ -897,10 +997,10 @@ export function Inbox() {
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, 200);
|
}, 200);
|
||||||
};
|
}, [dismiss]);
|
||||||
|
|
||||||
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
|
const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
|
||||||
if (tab !== "mine") return null;
|
if (!canArchiveFromTab) return null;
|
||||||
const isRead = readItems.has(key);
|
const isRead = readItems.has(key);
|
||||||
const isFading = fadingNonIssueItems.has(key);
|
const isFading = fadingNonIssueItems.has(key);
|
||||||
if (isFading) return "fading";
|
if (isFading) return "fading";
|
||||||
|
|
@ -908,6 +1008,170 @@ export function Inbox() {
|
||||||
return "hidden";
|
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) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
|
||||||
}
|
}
|
||||||
|
|
@ -953,25 +1217,25 @@ export function Inbox() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
||||||
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
|
<PageTabBar
|
||||||
<PageTabBar
|
items={[
|
||||||
items={[
|
{
|
||||||
{
|
value: "mine",
|
||||||
value: "mine",
|
label: "Mine",
|
||||||
label: "Mine",
|
},
|
||||||
},
|
{
|
||||||
{
|
value: "recent",
|
||||||
value: "recent",
|
label: "Recent",
|
||||||
label: "Recent",
|
},
|
||||||
},
|
{ value: "unread", label: "Unread" },
|
||||||
{ value: "unread", label: "Unread" },
|
{ value: "all", label: "All" },
|
||||||
{ value: "all", label: "All" },
|
]}
|
||||||
]}
|
/>
|
||||||
/>
|
</Tabs>
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{canMarkAllRead && (
|
{canMarkAllRead && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -985,44 +1249,44 @@ export function Inbox() {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{tab === "all" && (
|
{tab === "all" && (
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
<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
|
<Select
|
||||||
value={allCategoryFilter}
|
value={allApprovalFilter}
|
||||||
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-[170px] text-xs">
|
<SelectTrigger className="h-8 w-[170px] text-xs">
|
||||||
<SelectValue placeholder="Category" />
|
<SelectValue placeholder="Approval status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="everything">All categories</SelectItem>
|
<SelectItem value="all">All approval statuses</SelectItem>
|
||||||
<SelectItem value="issues_i_touched">My recent issues</SelectItem>
|
<SelectItem value="actionable">Needs action</SelectItem>
|
||||||
<SelectItem value="join_requests">Join requests</SelectItem>
|
<SelectItem value="resolved">Resolved</SelectItem>
|
||||||
<SelectItem value="approvals">Approvals</SelectItem>
|
|
||||||
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
|
||||||
<SelectItem value="alerts">Alerts</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
)}
|
||||||
{showApprovalsCategory && (
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
||||||
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
|
||||||
|
|
@ -1050,9 +1314,36 @@ export function Inbox() {
|
||||||
<>
|
<>
|
||||||
{showSeparatorBefore("work_items") && <Separator />}
|
{showSeparatorBefore("work_items") && <Separator />}
|
||||||
<div>
|
<div>
|
||||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
|
||||||
{workItemsToRender.map((item) => {
|
{workItemsToRender.flatMap((item, index) => {
|
||||||
const isMineTab = tab === "mine";
|
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") {
|
if (item.kind === "approval") {
|
||||||
const approvalKey = `approval:${item.approval.id}`;
|
const approvalKey = `approval:${item.approval.id}`;
|
||||||
|
|
@ -1061,13 +1352,14 @@ export function Inbox() {
|
||||||
<ApprovalInboxRow
|
<ApprovalInboxRow
|
||||||
key={approvalKey}
|
key={approvalKey}
|
||||||
approval={item.approval}
|
approval={item.approval}
|
||||||
|
selected={isSelected}
|
||||||
requesterName={agentName(item.approval.requestedByAgentId)}
|
requesterName={agentName(item.approval.requestedByAgentId)}
|
||||||
onApprove={() => approveMutation.mutate(item.approval.id)}
|
onApprove={() => approveMutation.mutate(item.approval.id)}
|
||||||
onReject={() => rejectMutation.mutate(item.approval.id)}
|
onReject={() => rejectMutation.mutate(item.approval.id)}
|
||||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||||
unreadState={nonIssueUnreadState(approvalKey)}
|
unreadState={nonIssueUnreadState(approvalKey)}
|
||||||
onMarkRead={() => handleMarkNonIssueRead(approvalKey)}
|
onMarkRead={() => handleMarkNonIssueRead(approvalKey)}
|
||||||
onArchive={isMineTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
|
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
|
||||||
archiveDisabled={isArchiving}
|
archiveDisabled={isArchiving}
|
||||||
className={
|
className={
|
||||||
isArchiving
|
isArchiving
|
||||||
|
|
@ -1076,15 +1368,17 @@ export function Inbox() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return isMineTab ? (
|
elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
key={approvalKey}
|
key={approvalKey}
|
||||||
|
selected={isSelected}
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
onArchive={() => handleArchiveNonIssue(approvalKey)}
|
onArchive={() => handleArchiveNonIssue(approvalKey)}
|
||||||
>
|
>
|
||||||
{row}
|
{row}
|
||||||
</SwipeToArchive>
|
</SwipeToArchive>
|
||||||
) : row;
|
) : row));
|
||||||
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.kind === "failed_run") {
|
if (item.kind === "failed_run") {
|
||||||
|
|
@ -1094,6 +1388,7 @@ export function Inbox() {
|
||||||
<FailedRunInboxRow
|
<FailedRunInboxRow
|
||||||
key={runKey}
|
key={runKey}
|
||||||
run={item.run}
|
run={item.run}
|
||||||
|
selected={isSelected}
|
||||||
issueById={issueById}
|
issueById={issueById}
|
||||||
agentName={agentName(item.run.agentId)}
|
agentName={agentName(item.run.agentId)}
|
||||||
issueLinkState={issueLinkState}
|
issueLinkState={issueLinkState}
|
||||||
|
|
@ -1102,7 +1397,7 @@ export function Inbox() {
|
||||||
isRetrying={retryingRunIds.has(item.run.id)}
|
isRetrying={retryingRunIds.has(item.run.id)}
|
||||||
unreadState={nonIssueUnreadState(runKey)}
|
unreadState={nonIssueUnreadState(runKey)}
|
||||||
onMarkRead={() => handleMarkNonIssueRead(runKey)}
|
onMarkRead={() => handleMarkNonIssueRead(runKey)}
|
||||||
onArchive={isMineTab ? () => handleArchiveNonIssue(runKey) : undefined}
|
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined}
|
||||||
archiveDisabled={isArchiving}
|
archiveDisabled={isArchiving}
|
||||||
className={
|
className={
|
||||||
isArchiving
|
isArchiving
|
||||||
|
|
@ -1111,15 +1406,17 @@ export function Inbox() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return isMineTab ? (
|
elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
key={runKey}
|
key={runKey}
|
||||||
|
selected={isSelected}
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
onArchive={() => handleArchiveNonIssue(runKey)}
|
onArchive={() => handleArchiveNonIssue(runKey)}
|
||||||
>
|
>
|
||||||
{row}
|
{row}
|
||||||
</SwipeToArchive>
|
</SwipeToArchive>
|
||||||
) : row;
|
) : row));
|
||||||
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.kind === "join_request") {
|
if (item.kind === "join_request") {
|
||||||
|
|
@ -1129,12 +1426,13 @@ export function Inbox() {
|
||||||
<JoinRequestInboxRow
|
<JoinRequestInboxRow
|
||||||
key={joinKey}
|
key={joinKey}
|
||||||
joinRequest={item.joinRequest}
|
joinRequest={item.joinRequest}
|
||||||
|
selected={isSelected}
|
||||||
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
|
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
|
||||||
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
|
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
|
||||||
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
||||||
unreadState={nonIssueUnreadState(joinKey)}
|
unreadState={nonIssueUnreadState(joinKey)}
|
||||||
onMarkRead={() => handleMarkNonIssueRead(joinKey)}
|
onMarkRead={() => handleMarkNonIssueRead(joinKey)}
|
||||||
onArchive={isMineTab ? () => handleArchiveNonIssue(joinKey) : undefined}
|
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined}
|
||||||
archiveDisabled={isArchiving}
|
archiveDisabled={isArchiving}
|
||||||
className={
|
className={
|
||||||
isArchiving
|
isArchiving
|
||||||
|
|
@ -1143,15 +1441,17 @@ export function Inbox() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return isMineTab ? (
|
elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
key={joinKey}
|
key={joinKey}
|
||||||
|
selected={isSelected}
|
||||||
disabled={isArchiving}
|
disabled={isArchiving}
|
||||||
onArchive={() => handleArchiveNonIssue(joinKey)}
|
onArchive={() => handleArchiveNonIssue(joinKey)}
|
||||||
>
|
>
|
||||||
{row}
|
{row}
|
||||||
</SwipeToArchive>
|
</SwipeToArchive>
|
||||||
) : row;
|
) : row));
|
||||||
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
const issue = item.issue;
|
const issue = item.issue;
|
||||||
|
|
@ -1163,32 +1463,19 @@ export function Inbox() {
|
||||||
key={`issue:${issue.id}`}
|
key={`issue:${issue.id}`}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
issueLinkState={issueLinkState}
|
issueLinkState={issueLinkState}
|
||||||
|
selected={isSelected}
|
||||||
className={
|
className={
|
||||||
isArchiving
|
isArchiving
|
||||||
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
||||||
: "transition-all duration-200 ease-out"
|
: "transition-all duration-200 ease-out"
|
||||||
}
|
}
|
||||||
desktopMetaLeading={(
|
desktopMetaLeading={
|
||||||
<>
|
<InboxIssueMetaLeading
|
||||||
<span className="hidden shrink-0 sm:inline-flex">
|
issue={issue}
|
||||||
<StatusIcon status={issue.status} />
|
selected={isSelected}
|
||||||
</span>
|
isLive={liveIssueIds.has(issue.id)}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
mobileMeta={
|
mobileMeta={
|
||||||
issue.lastExternalCommentAt
|
issue.lastExternalCommentAt
|
||||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||||
|
|
@ -1199,7 +1486,7 @@ export function Inbox() {
|
||||||
}
|
}
|
||||||
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||||
onArchive={
|
onArchive={
|
||||||
isMineTab
|
canArchiveFromTab
|
||||||
? () => archiveIssueMutation.mutate(issue.id)
|
? () => archiveIssueMutation.mutate(issue.id)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
@ -1212,15 +1499,17 @@ export function Inbox() {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return isMineTab ? (
|
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
|
||||||
<SwipeToArchive
|
<SwipeToArchive
|
||||||
key={`issue:${issue.id}`}
|
key={`issue:${issue.id}`}
|
||||||
|
selected={isSelected}
|
||||||
disabled={isArchiving || archiveIssueMutation.isPending}
|
disabled={isArchiving || archiveIssueMutation.isPending}
|
||||||
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
||||||
>
|
>
|
||||||
{row}
|
{row}
|
||||||
</SwipeToArchive>
|
</SwipeToArchive>
|
||||||
) : row;
|
) : row));
|
||||||
|
return elements;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,16 @@ import { useToast } from "../context/ToastContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
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 { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||||
import { InlineEditor } from "../components/InlineEditor";
|
import { InlineEditor } from "../components/InlineEditor";
|
||||||
|
|
@ -55,11 +64,15 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ActivityEvent } from "@paperclipai/shared";
|
import type { ActivityEvent } from "@paperclipai/shared";
|
||||||
import type { Agent, IssueAttachment } from "@paperclipai/shared";
|
import type { Agent, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
|
||||||
|
|
||||||
type CommentReassignment = {
|
type CommentReassignment = IssueCommentReassignment;
|
||||||
assigneeAgentId: string | null;
|
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||||
assigneeUserId: string | null;
|
runId?: string | null;
|
||||||
|
runAgentId?: string | null;
|
||||||
|
interruptedRunId?: string | null;
|
||||||
|
queueState?: "queued";
|
||||||
|
queueTargetRunId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
|
@ -213,6 +226,7 @@ export function IssueDetail() {
|
||||||
});
|
});
|
||||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||||
|
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -269,9 +283,17 @@ export function IssueDetail() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
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(
|
const sourceBreadcrumb = useMemo(
|
||||||
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" },
|
() => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
||||||
[location.state],
|
[location.state, location.search],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out runs already shown by the live widget to avoid duplication
|
// Filter out runs already shown by the live widget to avoid duplication
|
||||||
|
|
@ -386,12 +408,23 @@ export function IssueDetail() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const suggestedAssigneeValue = useMemo(
|
const suggestedAssigneeValue = useMemo(
|
||||||
() => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId),
|
() =>
|
||||||
[issue, comments, currentUserId],
|
suggestedCommentAssigneeValue(
|
||||||
|
issue ?? {},
|
||||||
|
mergeIssueComments(comments ?? [], optimisticComments),
|
||||||
|
currentUserId,
|
||||||
|
),
|
||||||
|
[issue, comments, optimisticComments, currentUserId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const commentsWithRunMeta = useMemo(() => {
|
const threadComments = useMemo(
|
||||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
|
() => 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>();
|
const agentIdByRunId = new Map<string, string>();
|
||||||
for (const run of linkedRuns ?? []) {
|
for (const run of linkedRuns ?? []) {
|
||||||
agentIdByRunId.set(run.runId, run.agentId);
|
agentIdByRunId.set(run.runId, run.agentId);
|
||||||
|
|
@ -401,16 +434,44 @@ export function IssueDetail() {
|
||||||
const details = evt.details ?? {};
|
const details = evt.details ?? {};
|
||||||
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
|
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
|
||||||
if (!commentId || runMetaByCommentId.has(commentId)) continue;
|
if (!commentId || runMetaByCommentId.has(commentId)) continue;
|
||||||
|
const interruptedRunId =
|
||||||
|
typeof details["interruptedRunId"] === "string" ? details["interruptedRunId"] : null;
|
||||||
runMetaByCommentId.set(commentId, {
|
runMetaByCommentId.set(commentId, {
|
||||||
runId: evt.runId,
|
runId: evt.runId,
|
||||||
runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null,
|
runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null,
|
||||||
|
interruptedRunId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return (comments ?? []).map((comment) => {
|
return threadComments.map((comment) => {
|
||||||
const meta = runMetaByCommentId.get(comment.id);
|
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(() => {
|
const issueCostSummary = useMemo(() => {
|
||||||
let input = 0;
|
let input = 0;
|
||||||
|
|
@ -489,9 +550,67 @@ export function IssueDetail() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const addComment = useMutation({
|
const addComment = useMutation({
|
||||||
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
|
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
|
||||||
issuesApi.addComment(issueId!, body, reopen),
|
issuesApi.addComment(issueId!, body, reopen, interrupt),
|
||||||
onSuccess: () => {
|
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();
|
invalidateIssue();
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||||
},
|
},
|
||||||
|
|
@ -501,10 +620,12 @@ export function IssueDetail() {
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
body,
|
body,
|
||||||
reopen,
|
reopen,
|
||||||
|
interrupt,
|
||||||
reassignment,
|
reassignment,
|
||||||
}: {
|
}: {
|
||||||
body: string;
|
body: string;
|
||||||
reopen?: boolean;
|
reopen?: boolean;
|
||||||
|
interrupt?: boolean;
|
||||||
reassignment: CommentReassignment;
|
reassignment: CommentReassignment;
|
||||||
}) =>
|
}) =>
|
||||||
issuesApi.update(issueId!, {
|
issuesApi.update(issueId!, {
|
||||||
|
|
@ -512,13 +633,96 @@ export function IssueDetail() {
|
||||||
assigneeAgentId: reassignment.assigneeAgentId,
|
assigneeAgentId: reassignment.assigneeAgentId,
|
||||||
assigneeUserId: reassignment.assigneeUserId,
|
assigneeUserId: reassignment.assigneeUserId,
|
||||||
...(reopen ? { status: "todo" } : {}),
|
...(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();
|
invalidateIssue();
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
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({
|
const uploadAttachment = useMutation({
|
||||||
mutationFn: async (file: File) => {
|
mutationFn: async (file: File) => {
|
||||||
if (!selectedCompanyId) throw new Error("No company selected");
|
if (!selectedCompanyId) throw new Error("No company selected");
|
||||||
|
|
@ -581,9 +785,12 @@ export function IssueDetail() {
|
||||||
// Redirect to identifier-based URL if navigated via UUID
|
// Redirect to identifier-based URL if navigated via UUID
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (issue?.identifier && issueId !== issue.identifier) {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!issue?.id) return;
|
if (!issue?.id) return;
|
||||||
|
|
@ -695,7 +902,7 @@ export function IssueDetail() {
|
||||||
<span key={ancestor.id} className="flex items-center gap-1">
|
<span key={ancestor.id} className="flex items-center gap-1">
|
||||||
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||||
<Link
|
<Link
|
||||||
to={`/issues/${ancestor.identifier ?? ancestor.id}`}
|
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id, location.state, location.search)}
|
||||||
state={location.state}
|
state={location.state}
|
||||||
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
className="hover:text-foreground transition-colors truncate max-w-[200px]"
|
||||||
title={ancestor.title}
|
title={ancestor.title}
|
||||||
|
|
@ -1025,7 +1232,8 @@ export function IssueDetail() {
|
||||||
|
|
||||||
<TabsContent value="comments">
|
<TabsContent value="comments">
|
||||||
<CommentThread
|
<CommentThread
|
||||||
comments={commentsWithRunMeta}
|
comments={timelineComments}
|
||||||
|
queuedComments={queuedComments}
|
||||||
linkedRuns={timelineRuns}
|
linkedRuns={timelineRuns}
|
||||||
companyId={issue.companyId}
|
companyId={issue.companyId}
|
||||||
projectId={issue.projectId}
|
projectId={issue.projectId}
|
||||||
|
|
@ -1037,6 +1245,10 @@ export function IssueDetail() {
|
||||||
currentAssigneeValue={actualAssigneeValue}
|
currentAssigneeValue={actualAssigneeValue}
|
||||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||||
mentions={mentionOptions}
|
mentions={mentionOptions}
|
||||||
|
onInterruptQueued={async (runId) => {
|
||||||
|
await interruptQueuedComment.mutateAsync(runId);
|
||||||
|
}}
|
||||||
|
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
|
||||||
onAdd={async (body, reopen, reassignment) => {
|
onAdd={async (body, reopen, reassignment) => {
|
||||||
if (reassignment) {
|
if (reassignment) {
|
||||||
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
||||||
|
|
@ -1063,7 +1275,7 @@ export function IssueDetail() {
|
||||||
{childIssues.map((child) => (
|
{childIssues.map((child) => (
|
||||||
<Link
|
<Link
|
||||||
key={child.id}
|
key={child.id}
|
||||||
to={`/issues/${child.identifier ?? child.id}`}
|
to={createIssueDetailPath(child.identifier ?? child.id, location.state, location.search)}
|
||||||
state={location.state}
|
state={location.state}
|
||||||
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
|
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(
|
createIssueDetailLocationState(
|
||||||
"Issues",
|
"Issues",
|
||||||
`${location.pathname}${location.search}${location.hash}`,
|
`${location.pathname}${location.search}${location.hash}`,
|
||||||
|
"issues",
|
||||||
),
|
),
|
||||||
[location.pathname, location.search, location.hash],
|
[location.pathname, location.search, location.hash],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue