Refine issue workflow surfaces and live updates
This commit is contained in:
@@ -62,6 +62,7 @@ describe("activity routes", () => {
|
||||
mockActivityService.runsForIssue.mockResolvedValue([
|
||||
{
|
||||
runId: "run-1",
|
||||
adapterType: "codex_local",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -72,6 +73,6 @@ describe("activity routes", () => {
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475");
|
||||
expect(mockIssueService.getById).not.toHaveBeenCalled();
|
||||
expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1");
|
||||
expect(res.body).toEqual([{ runId: "run-1" }]);
|
||||
expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,19 +60,17 @@ vi.mock("../services/index.js", () => ({
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp(
|
||||
actor: Record<string, unknown> = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
) {
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
@@ -139,63 +137,4 @@ describe("issue execution policy routes", () => {
|
||||
expect(updatePatch.executionState).toBeUndefined();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects agent stage advances from non-participants", async () => {
|
||||
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const approverAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
const executorAgentId = "22222222-2222-4222-8222-222222222222";
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [
|
||||
{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
type: "review",
|
||||
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||
},
|
||||
{
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
type: "approval",
|
||||
participants: [{ type: "agent", agentId: approverAgentId }],
|
||||
},
|
||||
],
|
||||
})!;
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_review",
|
||||
assigneeAgentId: reviewerAgentId,
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1000",
|
||||
title: "Execution policy guard",
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: "11111111-1111-4111-8111-111111111111",
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||
returnAssignee: { type: "agent", agentId: executorAgentId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
|
||||
const res = await request(
|
||||
createApp({
|
||||
type: "agent",
|
||||
agentId: approverAgentId,
|
||||
companyId: "company-1",
|
||||
source: "api_key",
|
||||
runId: "run-1",
|
||||
}),
|
||||
)
|
||||
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.send({ status: "done", comment: "Skipping review." });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain("active review participant");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
|
||||
const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
getRelationSummaries: vi.fn(),
|
||||
listWakeableBlockedDependents: vi.fn(),
|
||||
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => true),
|
||||
hasPermission: vi.fn(async () => true),
|
||||
}),
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({
|
||||
listIssueVotesForUser: vi.fn(async () => []),
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
general: {
|
||||
censorUsernameInLogs: false,
|
||||
feedbackDataSharingPreference: "prompt",
|
||||
},
|
||||
})),
|
||||
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||
}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeIssue(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
projectId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "local-board",
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-999",
|
||||
title: "Wake test",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
hiddenAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue update comment wakeups", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("includes the new comment in assignment wakes from issue updates", async () => {
|
||||
const existing = makeIssue();
|
||||
const updated = makeIssue({
|
||||
assigneeAgentId: ASSIGNEE_AGENT_ID,
|
||||
assigneeUserId: null,
|
||||
});
|
||||
mockIssueService.getById.mockResolvedValue(existing);
|
||||
mockIssueService.update.mockResolvedValue(updated);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-1",
|
||||
issueId: existing.id,
|
||||
companyId: existing.companyId,
|
||||
body: "write the whole thing",
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch(`/api/issues/${existing.id}`)
|
||||
.send({
|
||||
assigneeAgentId: ASSIGNEE_AGENT_ID,
|
||||
assigneeUserId: null,
|
||||
comment: "write the whole thing",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
ASSIGNEE_AGENT_ID,
|
||||
expect.objectContaining({
|
||||
source: "assignment",
|
||||
reason: "issue_assigned",
|
||||
payload: expect.objectContaining({
|
||||
issueId: existing.id,
|
||||
commentId: "comment-1",
|
||||
mutation: "update",
|
||||
}),
|
||||
contextSnapshot: expect.objectContaining({
|
||||
issueId: existing.id,
|
||||
taskId: existing.id,
|
||||
commentId: "comment-1",
|
||||
wakeCommentId: "comment-1",
|
||||
source: "issue.update",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("wakes the assignee on comment-only issue updates", async () => {
|
||||
const existing = makeIssue({
|
||||
assigneeAgentId: ASSIGNEE_AGENT_ID,
|
||||
assigneeUserId: null,
|
||||
status: "in_progress",
|
||||
});
|
||||
const updated = { ...existing };
|
||||
mockIssueService.getById.mockResolvedValue(existing);
|
||||
mockIssueService.update.mockResolvedValue(updated);
|
||||
mockIssueService.addComment.mockResolvedValue({
|
||||
id: "comment-2",
|
||||
issueId: existing.id,
|
||||
companyId: existing.companyId,
|
||||
body: "please revise this",
|
||||
});
|
||||
|
||||
const res = await request(createApp())
|
||||
.patch(`/api/issues/${existing.id}`)
|
||||
.send({
|
||||
comment: "please revise this",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
ASSIGNEE_AGENT_ID,
|
||||
expect.objectContaining({
|
||||
source: "automation",
|
||||
reason: "issue_commented",
|
||||
payload: expect.objectContaining({
|
||||
issueId: existing.id,
|
||||
commentId: "comment-2",
|
||||
mutation: "comment",
|
||||
}),
|
||||
contextSnapshot: expect.objectContaining({
|
||||
issueId: existing.id,
|
||||
taskId: existing.id,
|
||||
commentId: "comment-2",
|
||||
wakeCommentId: "comment-2",
|
||||
wakeReason: "issue_commented",
|
||||
source: "issue.comment",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+41
-33
@@ -96,13 +96,6 @@ function executionPrincipalsEqual(
|
||||
return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId;
|
||||
}
|
||||
|
||||
function executionParticipantMatchesAgent(
|
||||
participant: ParsedExecutionState["currentParticipant"] | null,
|
||||
agentId: string | null | undefined,
|
||||
) {
|
||||
return Boolean(agentId) && participant?.type === "agent" && participant.agentId === agentId;
|
||||
}
|
||||
|
||||
function buildExecutionStageWakeContext(input: {
|
||||
state: ParsedExecutionState;
|
||||
wakeRole: ExecutionStageWakeContext["wakeRole"];
|
||||
@@ -1386,14 +1379,10 @@ export function issueRoutes(
|
||||
? (updateFields.executionPolicy as NormalizedExecutionPolicy | null)
|
||||
: previousExecutionPolicy;
|
||||
|
||||
const requestedStatus = typeof updateFields.status === "string" ? updateFields.status : undefined;
|
||||
const requestedAssigneePatchProvided =
|
||||
req.body.assigneeAgentId !== undefined || req.body.assigneeUserId !== undefined;
|
||||
|
||||
const transition = applyIssueExecutionPolicyTransition({
|
||||
issue: existing,
|
||||
policy: nextExecutionPolicy,
|
||||
requestedStatus,
|
||||
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
|
||||
requestedAssigneePatch: {
|
||||
assigneeAgentId:
|
||||
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
|
||||
@@ -1419,27 +1408,6 @@ export function issueRoutes(
|
||||
}
|
||||
Object.assign(updateFields, transition.patch);
|
||||
|
||||
const effectiveExecutionState = parseIssueExecutionState(
|
||||
transition.patch.executionState !== undefined ? transition.patch.executionState : existing.executionState,
|
||||
);
|
||||
const isUnauthorizedAgentStageMutation =
|
||||
req.actor.type === "agent" &&
|
||||
req.actor.agentId &&
|
||||
existing.status === "in_review" &&
|
||||
transition.workflowControlledAssignment &&
|
||||
!transition.decision &&
|
||||
effectiveExecutionState?.status === "pending" &&
|
||||
(
|
||||
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
|
||||
requestedAssigneePatchProvided
|
||||
) &&
|
||||
!executionParticipantMatchesAgent(effectiveExecutionState.currentParticipant, req.actor.agentId);
|
||||
if (isUnauthorizedAgentStageMutation) {
|
||||
const stageLabel = effectiveExecutionState.currentStageType ?? "execution";
|
||||
res.status(403).json({ error: `Only the active ${stageLabel} participant can update this stage` });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAssigneeAgentId =
|
||||
updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null);
|
||||
const nextAssigneeUserId =
|
||||
@@ -1733,6 +1701,7 @@ export function issueRoutes(
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
issueId: issue.id,
|
||||
...(comment ? { commentId: comment.id } : {}),
|
||||
mutation: "update",
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
},
|
||||
@@ -1740,6 +1709,13 @@ export function issueRoutes(
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: {
|
||||
issueId: issue.id,
|
||||
...(comment
|
||||
? {
|
||||
taskId: issue.id,
|
||||
commentId: comment.id,
|
||||
wakeCommentId: comment.id,
|
||||
}
|
||||
: {}),
|
||||
source: "issue.update",
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
},
|
||||
@@ -1767,6 +1743,38 @@ export function issueRoutes(
|
||||
}
|
||||
|
||||
if (commentBody && comment) {
|
||||
const assigneeId = issue.assigneeAgentId;
|
||||
const actorIsAgent = actor.actorType === "agent";
|
||||
const selfComment = actorIsAgent && actor.actorId === assigneeId;
|
||||
const skipAssigneeCommentWake = selfComment || isClosed;
|
||||
|
||||
if (assigneeId && !assigneeChanged && !skipAssigneeCommentWake) {
|
||||
addWakeup(assigneeId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: reopened ? "issue_reopened_via_comment" : "issue_commented",
|
||||
payload: {
|
||||
issueId: id,
|
||||
commentId: comment.id,
|
||||
mutation: "comment",
|
||||
...(reopened ? { reopenedFrom: reopenFromStatus } : {}),
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
},
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: {
|
||||
issueId: id,
|
||||
taskId: id,
|
||||
commentId: comment.id,
|
||||
wakeCommentId: comment.id,
|
||||
source: reopened ? "issue.comment.reopen" : "issue.comment",
|
||||
wakeReason: reopened ? "issue_reopened_via_comment" : "issue_commented",
|
||||
...(reopened ? { reopenedFrom: reopenFromStatus } : {}),
|
||||
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let mentionedIds: string[] = [];
|
||||
try {
|
||||
mentionedIds = await svc.findMentionedAgents(issue.companyId, commentBody);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { activityLog, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
|
||||
export interface ActivityFilters {
|
||||
companyId: string;
|
||||
@@ -66,14 +66,23 @@ export function activityService(db: Db) {
|
||||
runId: heartbeatRuns.id,
|
||||
status: heartbeatRuns.status,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
adapterType: agents.adapterType,
|
||||
startedAt: heartbeatRuns.startedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
usageJson: heartbeatRuns.usageJson,
|
||||
resultJson: heartbeatRuns.resultJson,
|
||||
logBytes: heartbeatRuns.logBytes,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(
|
||||
agents,
|
||||
and(
|
||||
eq(agents.id, heartbeatRuns.agentId),
|
||||
eq(agents.companyId, heartbeatRuns.companyId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
|
||||
@@ -2011,18 +2011,6 @@ export function heartbeatService(db: Db) {
|
||||
return { outcome: "not_applicable" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
const wakeReason = readNonEmptyString(contextSnapshot.wakeReason);
|
||||
if (wakeReason === "issue_commented" || wakeReason === "issue_comment_mentioned" || wakeReason === "issue_reopened_via_comment") {
|
||||
if (run.issueCommentStatus !== "not_applicable") {
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
issueCommentStatus: "not_applicable",
|
||||
issueCommentSatisfiedByCommentId: null,
|
||||
issueCommentRetryQueuedAt: null,
|
||||
});
|
||||
}
|
||||
return { outcome: "not_applicable" as const, queuedRun: null };
|
||||
}
|
||||
|
||||
const postedComment = await findRunIssueComment(run.id, run.companyId, issueId);
|
||||
if (postedComment) {
|
||||
await patchRunIssueCommentStatus(run.id, {
|
||||
|
||||
@@ -5,12 +5,14 @@ export interface RunForIssue {
|
||||
runId: string;
|
||||
status: string;
|
||||
agentId: string;
|
||||
adapterType: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
createdAt: string;
|
||||
invocationSource: string;
|
||||
usageJson: Record<string, unknown> | null;
|
||||
resultJson: Record<string, unknown> | null;
|
||||
logBytes?: number | null;
|
||||
}
|
||||
|
||||
export interface IssueForRun {
|
||||
|
||||
@@ -30,8 +30,8 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
|
||||
|
||||
const runs = liveRuns ?? [];
|
||||
const { data: issues } = useQuery({
|
||||
queryKey: queryKeys.issues.list(companyId),
|
||||
queryFn: () => issuesApi.list(companyId),
|
||||
queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
|
||||
queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }),
|
||||
enabled: runs.length > 0,
|
||||
});
|
||||
|
||||
|
||||
+182
-243
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import type {
|
||||
Agent,
|
||||
@@ -631,7 +631,7 @@ const TimelineList = memo(function TimelineList({
|
||||
);
|
||||
});
|
||||
|
||||
export const CommentThread = memo(function CommentThread({
|
||||
export function CommentThread({
|
||||
comments,
|
||||
queuedComments = [],
|
||||
linkedApprovals = [],
|
||||
@@ -662,9 +662,17 @@ export const CommentThread = memo(function CommentThread({
|
||||
interruptingQueuedRunId = null,
|
||||
composerDisabledReason = null,
|
||||
}: CommentThreadProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
||||
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
|
||||
const [votingTargetId, setVotingTargetId] = useState<string | null>(null);
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const location = useLocation();
|
||||
const hasScrolledRef = useRef(false);
|
||||
|
||||
@@ -730,6 +738,29 @@ export const CommentThread = memo(function CommentThread({
|
||||
}));
|
||||
}, [agentMap, providedMentions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
setBody(loadDraft(draftKey));
|
||||
}, [draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
draftTimer.current = setTimeout(() => {
|
||||
saveDraft(draftKey, body);
|
||||
}, DRAFT_DEBOUNCE_MS);
|
||||
}, [body, draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [effectiveSuggestedAssigneeValue]);
|
||||
|
||||
// Scroll to comment when URL hash matches #comment-{id}
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
@@ -748,25 +779,72 @@ export const CommentThread = memo(function CommentThread({
|
||||
}
|
||||
}, [location.hash, comments, queuedComments]);
|
||||
|
||||
const handleFeedbackVote = useCallback(
|
||||
async (
|
||||
commentId: string,
|
||||
vote: FeedbackVoteValue,
|
||||
options?: { allowSharing?: boolean; reason?: string },
|
||||
) => {
|
||||
if (!onVote) return;
|
||||
setVotingTargetId(commentId);
|
||||
try {
|
||||
await onVote(commentId, vote, options);
|
||||
} finally {
|
||||
setVotingTargetId(null);
|
||||
}
|
||||
},
|
||||
[onVote],
|
||||
);
|
||||
async function handleSubmit() {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
|
||||
const submittedBody = trimmed;
|
||||
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
try {
|
||||
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(true);
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} catch {
|
||||
setBody((current) =>
|
||||
restoreSubmittedCommentDraft({
|
||||
currentBody: current,
|
||||
submittedBody,
|
||||
}),
|
||||
);
|
||||
// Parent mutation handlers surface the failure and the draft is restored for retry.
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
|
||||
const file = evt.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAttaching(true);
|
||||
try {
|
||||
if (imageUploadHandler) {
|
||||
const url = await imageUploadHandler(file);
|
||||
const safeName = file.name.replace(/[[\]]/g, "\\$&");
|
||||
const markdown = ``;
|
||||
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
|
||||
} else if (onAttachImage) {
|
||||
await onAttachImage(file);
|
||||
}
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
if (attachInputRef.current) attachInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFeedbackVote(
|
||||
commentId: string,
|
||||
vote: FeedbackVoteValue,
|
||||
options?: { allowSharing?: boolean; reason?: string },
|
||||
) {
|
||||
if (!onVote) return;
|
||||
setVotingTargetId(commentId);
|
||||
try {
|
||||
await onVote(commentId, vote, options);
|
||||
} finally {
|
||||
setVotingTargetId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = !submitting && !!body.trim();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Timeline ({timeline.length + queuedComments.length})</h3>
|
||||
|
||||
const timelineSection = useMemo(
|
||||
() => (
|
||||
<TimelineList
|
||||
timeline={timeline}
|
||||
agentMap={agentMap}
|
||||
@@ -783,21 +861,6 @@ export const CommentThread = memo(function CommentThread({
|
||||
highlightCommentId={highlightCommentId}
|
||||
feedbackTermsUrl={feedbackTermsUrl}
|
||||
/>
|
||||
),
|
||||
[
|
||||
timeline, agentMap, currentUserId, companyId, projectId,
|
||||
onApproveApproval, onRejectApproval, pendingApprovalAction,
|
||||
feedbackVoteByTargetId, feedbackDataSharingPreference,
|
||||
onVote, handleFeedbackVote, votingTargetId, highlightCommentId,
|
||||
feedbackTermsUrl,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Timeline ({timeline.length + queuedComments.length})</h3>
|
||||
|
||||
{timelineSection}
|
||||
|
||||
{liveRunSlot}
|
||||
|
||||
@@ -840,216 +903,92 @@ export const CommentThread = memo(function CommentThread({
|
||||
{composerDisabledReason}
|
||||
</div>
|
||||
) : (
|
||||
<CommentComposer
|
||||
onAdd={onAdd}
|
||||
mentions={mentions}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={effectiveSuggestedAssigneeValue}
|
||||
agentMap={agentMap}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="Leave a comment..."
|
||||
mentions={mentions}
|
||||
onSubmit={handleSubmit}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
contentClassName="min-h-[60px] text-sm"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{(imageUploadHandler || onAttachImage) && (
|
||||
<div className="mr-auto flex items-center gap-3">
|
||||
<input
|
||||
ref={attachInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleAttachFile}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => attachInputRef.current?.click()}
|
||||
disabled={attaching}
|
||||
title="Attach image"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopen}
|
||||
onChange={(e) => setReopen(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Re-open
|
||||
</label>
|
||||
{enableReassign && reassignOptions.length > 0 && (
|
||||
<InlineEntitySelector
|
||||
value={reassignTarget}
|
||||
options={reassignOptions}
|
||||
placeholder="Assignee"
|
||||
noneLabel="No assignee"
|
||||
searchPlaceholder="Search assignees..."
|
||||
emptyMessage="No assignees found."
|
||||
onChange={setReassignTarget}
|
||||
className="text-xs h-8"
|
||||
renderTriggerValue={(option) => {
|
||||
if (!option) return <span className="text-muted-foreground">Assignee</span>;
|
||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{submitting ? "Posting..." : "Comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CommentThread.displayName = "CommentThread";
|
||||
|
||||
/* ---- Isolated Composer (body state lives here, not in CommentThread) ---- */
|
||||
|
||||
interface CommentComposerProps {
|
||||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
||||
mentions: MentionOption[];
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
onAttachImage?: (file: File) => Promise<void>;
|
||||
draftKey?: string;
|
||||
enableReassign: boolean;
|
||||
reassignOptions: InlineEntityOption[];
|
||||
currentAssigneeValue: string;
|
||||
suggestedAssigneeValue: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
}
|
||||
|
||||
const CommentComposer = memo(function CommentComposer({
|
||||
onAdd,
|
||||
mentions,
|
||||
imageUploadHandler,
|
||||
onAttachImage,
|
||||
draftKey,
|
||||
enableReassign,
|
||||
reassignOptions,
|
||||
currentAssigneeValue,
|
||||
suggestedAssigneeValue,
|
||||
agentMap,
|
||||
}: CommentComposerProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const [reassignTarget, setReassignTarget] = useState(suggestedAssigneeValue);
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
setBody(loadDraft(draftKey));
|
||||
}, [draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
draftTimer.current = setTimeout(() => {
|
||||
saveDraft(draftKey, body);
|
||||
}, DRAFT_DEBOUNCE_MS);
|
||||
}, [body, draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setReassignTarget(suggestedAssigneeValue);
|
||||
}, [suggestedAssigneeValue]);
|
||||
|
||||
async function handleSubmit() {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
|
||||
const submittedBody = trimmed;
|
||||
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
try {
|
||||
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(true);
|
||||
setReassignTarget(suggestedAssigneeValue);
|
||||
} catch {
|
||||
setBody((current) =>
|
||||
restoreSubmittedCommentDraft({
|
||||
currentBody: current,
|
||||
submittedBody,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachFile(evt: ChangeEvent<HTMLInputElement>) {
|
||||
const file = evt.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAttaching(true);
|
||||
try {
|
||||
if (imageUploadHandler) {
|
||||
const url = await imageUploadHandler(file);
|
||||
const safeName = file.name.replace(/[[\]]/g, "\\$&");
|
||||
const markdown = ``;
|
||||
setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown);
|
||||
} else if (onAttachImage) {
|
||||
await onAttachImage(file);
|
||||
}
|
||||
} finally {
|
||||
setAttaching(false);
|
||||
if (attachInputRef.current) attachInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = !submitting && !!body.trim();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="Leave a comment..."
|
||||
mentions={mentions}
|
||||
onSubmit={handleSubmit}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
contentClassName="min-h-[60px] text-sm"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{(imageUploadHandler || onAttachImage) && (
|
||||
<div className="mr-auto flex items-center gap-3">
|
||||
<input
|
||||
ref={attachInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleAttachFile}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => attachInputRef.current?.click()}
|
||||
disabled={attaching}
|
||||
title="Attach image"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopen}
|
||||
onChange={(e) => setReopen(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Re-open
|
||||
</label>
|
||||
{enableReassign && reassignOptions.length > 0 && (
|
||||
<InlineEntitySelector
|
||||
value={reassignTarget}
|
||||
options={reassignOptions}
|
||||
placeholder="Assignee"
|
||||
noneLabel="No assignee"
|
||||
searchPlaceholder="Search assignees..."
|
||||
emptyMessage="No assignees found."
|
||||
onChange={setReassignTarget}
|
||||
className="text-xs h-8"
|
||||
renderTriggerValue={(option) => {
|
||||
if (!option) return <span className="text-muted-foreground">Assignee</span>;
|
||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
renderOption={(option) => {
|
||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
|
||||
const agent = agentId ? agentMap?.get(agentId) : null;
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{submitting ? "Posting..." : "Comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { pluginsApi } from "@/api/plugins";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
|
||||
export function InstanceSidebar() {
|
||||
@@ -33,6 +34,7 @@ export function InstanceSidebar() {
|
||||
<NavLink
|
||||
key={plugin.id}
|
||||
to={`/instance/settings/plugins/${plugin.id}`}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"rounded-md px-2 py-1.5 text-xs transition-colors",
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
type IssueChatTranscriptEntry,
|
||||
type SegmentTiming,
|
||||
} from "../lib/issue-chat-messages";
|
||||
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
|
||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
@@ -907,8 +908,6 @@ function IssueChatUserMessage() {
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{pending ? <div className="mb-1 text-xs text-muted-foreground">Sending...</div> : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
@@ -918,39 +917,43 @@ function IssueChatUserMessage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={anchorId ? `#${anchorId}` : undefined}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
onClick={() => {
|
||||
const text = message.content
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n\n");
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
{pending ? (
|
||||
<div className="mt-1 flex justify-end px-1 text-[11px] text-muted-foreground">Sending...</div>
|
||||
) : (
|
||||
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={anchorId ? `#${anchorId}` : undefined}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
onClick={() => {
|
||||
const text = message.content
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n\n");
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Avatar size="sm" className="mt-1 shrink-0">
|
||||
@@ -1820,26 +1823,12 @@ export function IssueChatThread({
|
||||
return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
}, [activeRun, liveRuns]);
|
||||
const transcriptRuns = useMemo(() => {
|
||||
const combined = new Map<string, { id: string; status: string; adapterType: string }>();
|
||||
for (const run of displayLiveRuns) {
|
||||
combined.set(run.id, {
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
adapterType: run.adapterType,
|
||||
});
|
||||
}
|
||||
for (const run of linkedRuns) {
|
||||
if (combined.has(run.runId)) continue;
|
||||
const adapterType = agentMap?.get(run.agentId)?.adapterType;
|
||||
if (!adapterType) continue;
|
||||
combined.set(run.runId, {
|
||||
id: run.runId,
|
||||
status: run.status,
|
||||
adapterType,
|
||||
});
|
||||
}
|
||||
return [...combined.values()];
|
||||
}, [agentMap, displayLiveRuns, linkedRuns]);
|
||||
return resolveIssueChatTranscriptRuns({
|
||||
linkedRuns,
|
||||
liveRuns: displayLiveRuns,
|
||||
activeRun,
|
||||
});
|
||||
}, [activeRun, displayLiveRuns, linkedRuns]);
|
||||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||||
runs: enableLiveTranscriptPolling ? transcriptRuns : [],
|
||||
companyId,
|
||||
|
||||
@@ -351,4 +351,51 @@ describe("IssueDocumentsSection", () => {
|
||||
});
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("wraps the documents header actions so mobile layouts do not overflow", async () => {
|
||||
const issue = createIssue();
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockIssuesApi.listDocuments.mockResolvedValue([createIssueDocument()]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentsSection
|
||||
issue={issue}
|
||||
canDeleteDocuments={false}
|
||||
extraActions={(
|
||||
<>
|
||||
<button type="button">Upload</button>
|
||||
<button type="button">Sub-issue</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const heading = container.querySelector("h3");
|
||||
expect(heading).toBeTruthy();
|
||||
expect(heading?.parentElement?.className).toContain("flex-wrap");
|
||||
expect(heading?.nextElementSibling?.className).toContain("flex-wrap");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -683,7 +683,7 @@ export function IssueDocumentsSection({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{isEmpty && !draft?.isNew ? (
|
||||
<div className="flex items-center justify-end gap-2 min-w-0">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 min-w-0">
|
||||
{extraActions}
|
||||
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
@@ -692,9 +692,9 @@ export function IssueDocumentsSection({
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<h3 className="text-sm font-medium text-muted-foreground shrink-0">Documents</h3>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||
<h3 className="w-full text-sm font-medium text-muted-foreground shrink-0 sm:w-auto">Documents</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0 sm:ml-auto">
|
||||
{extraActions}
|
||||
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Filter, X, User } from "lucide-react";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import {
|
||||
defaultIssueFilterState,
|
||||
issueFilterArraysEqual,
|
||||
issueFilterLabel,
|
||||
issuePriorityOrder,
|
||||
issueQuickFilterPresets,
|
||||
issueStatusOrder,
|
||||
toggleIssueFilterValue,
|
||||
type IssueFilterState,
|
||||
} from "../lib/issue-filters";
|
||||
|
||||
type AgentOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ProjectOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type LabelOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export function IssueFiltersPopover({
|
||||
state,
|
||||
onChange,
|
||||
activeFilterCount,
|
||||
agents,
|
||||
projects,
|
||||
labels,
|
||||
currentUserId,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
buttonVariant = "ghost",
|
||||
}: {
|
||||
state: IssueFilterState;
|
||||
onChange: (patch: Partial<IssueFilterState>) => void;
|
||||
activeFilterCount: number;
|
||||
agents?: AgentOption[];
|
||||
projects?: ProjectOption[];
|
||||
labels?: LabelOption[];
|
||||
currentUserId?: string | null;
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
buttonVariant?: "ghost" | "outline";
|
||||
}) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={buttonVariant} size="sm" className={`text-xs ${activeFilterCount > 0 ? "text-blue-600 dark:text-blue-400" : ""}`}>
|
||||
<Filter className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}</span>
|
||||
{activeFilterCount > 0 ? <span className="ml-0.5 text-[10px] font-medium sm:hidden">{activeFilterCount}</span> : null}
|
||||
{activeFilterCount > 0 ? (
|
||||
<X
|
||||
className="ml-1 hidden h-3 w-3 sm:block"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onChange(defaultIssueFilterState);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[min(480px,calc(100vw-2rem))] p-0">
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Filters</span>
|
||||
{activeFilterCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onChange(defaultIssueFilterState)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">Quick filters</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{issueQuickFilterPresets.map((preset) => {
|
||||
const isActive = issueFilterArraysEqual(state.statuses, preset.statuses);
|
||||
return (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||
isActive
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => onChange({ statuses: isActive ? [] : [...preset.statuses] })}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Status</span>
|
||||
<div className="space-y-0.5">
|
||||
{issueStatusOrder.map((status) => (
|
||||
<label key={status} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.statuses.includes(status)}
|
||||
onCheckedChange={() => onChange({ statuses: toggleIssueFilterValue(state.statuses, status) })}
|
||||
/>
|
||||
<StatusIcon status={status} />
|
||||
<span className="text-sm">{issueFilterLabel(status)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Priority</span>
|
||||
<div className="space-y-0.5">
|
||||
{issuePriorityOrder.map((priority) => (
|
||||
<label key={priority} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.priorities.includes(priority)}
|
||||
onCheckedChange={() => onChange({ priorities: toggleIssueFilterValue(state.priorities, priority) })}
|
||||
/>
|
||||
<PriorityIcon priority={priority} />
|
||||
<span className="text-sm">{issueFilterLabel(priority)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Assignee</span>
|
||||
<div className="max-h-32 space-y-0.5 overflow-y-auto">
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.assignees.includes("__unassigned")}
|
||||
onCheckedChange={() => onChange({ assignees: toggleIssueFilterValue(state.assignees, "__unassigned") })}
|
||||
/>
|
||||
<span className="text-sm">No assignee</span>
|
||||
</label>
|
||||
{currentUserId ? (
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.assignees.includes("__me")}
|
||||
onCheckedChange={() => onChange({ assignees: toggleIssueFilterValue(state.assignees, "__me") })}
|
||||
/>
|
||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm">Me</span>
|
||||
</label>
|
||||
) : null}
|
||||
{(agents ?? []).map((agent) => (
|
||||
<label key={agent.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.assignees.includes(agent.id)}
|
||||
onCheckedChange={() => onChange({ assignees: toggleIssueFilterValue(state.assignees, agent.id) })}
|
||||
/>
|
||||
<span className="text-sm">{agent.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{labels && labels.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Labels</span>
|
||||
<div className="max-h-32 space-y-0.5 overflow-y-auto">
|
||||
{labels.map((label) => (
|
||||
<label key={label.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.labels.includes(label.id)}
|
||||
onCheckedChange={() => onChange({ labels: toggleIssueFilterValue(state.labels, label.id) })}
|
||||
/>
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: label.color }} />
|
||||
<span className="text-sm">{label.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{projects && projects.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Project</span>
|
||||
<div className="max-h-32 space-y-0.5 overflow-y-auto">
|
||||
{projects.map((project) => (
|
||||
<label key={project.id} className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.projects.includes(project.id)}
|
||||
onCheckedChange={() => onChange({ projects: toggleIssueFilterValue(state.projects, project.id) })}
|
||||
/>
|
||||
<span className="text-sm">{project.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{enableRoutineVisibilityFilter ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Visibility</span>
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50">
|
||||
<Checkbox
|
||||
checked={state.showRoutineExecutions}
|
||||
onCheckedChange={(checked) => onChange({ showRoutineExecutions: checked === true })}
|
||||
/>
|
||||
<span className="text-sm">Show routine runs</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import type { ReactNode } from "react";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { X } from "lucide-react";
|
||||
import { createIssueDetailPath, rememberIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||
import {
|
||||
createIssueDetailPath,
|
||||
rememberIssueDetailLocationState,
|
||||
withIssueDetailHeaderSeed,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { cn } from "../lib/utils";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
|
||||
@@ -48,13 +52,14 @@ export function IssueRow({
|
||||
const showUnreadSlot = unreadState !== null;
|
||||
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
||||
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
|
||||
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={createIssueDetailPath(issuePathId)}
|
||||
state={issueLinkState}
|
||||
state={detailState}
|
||||
data-inbox-issue-link
|
||||
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, issueLinkState)}
|
||||
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
|
||||
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 last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
|
||||
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
||||
|
||||
@@ -307,4 +307,67 @@ describe("IssuesList", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides routine-backed issues by default and reveals them when the routine filter is enabled", async () => {
|
||||
const manualIssue = createIssue({
|
||||
id: "issue-manual",
|
||||
identifier: "PAP-10",
|
||||
title: "Manual issue",
|
||||
originKind: "manual",
|
||||
});
|
||||
const routineIssue = createIssue({
|
||||
id: "issue-routine",
|
||||
identifier: "PAP-11",
|
||||
title: "Routine issue",
|
||||
originKind: "routine_execution",
|
||||
});
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[manualIssue, routineIssue]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
enableRoutineVisibilityFilter
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Manual issue");
|
||||
expect(container.textContent).not.toContain("Routine issue");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const filterButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Filter"),
|
||||
);
|
||||
filterButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const toggle = Array.from(document.body.querySelectorAll("label")).find(
|
||||
(label) => label.textContent?.includes("Show routine runs"),
|
||||
);
|
||||
expect(toggle).not.toBeUndefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const toggle = Array.from(document.body.querySelectorAll("label")).find(
|
||||
(label) => label.textContent?.includes("Show routine runs"),
|
||||
);
|
||||
toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(container.textContent).toContain("Routine issue");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,15 @@ import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import {
|
||||
applyIssueFilters,
|
||||
countActiveIssueFilters,
|
||||
defaultIssueFilterState,
|
||||
issueFilterLabel,
|
||||
issuePriorityOrder,
|
||||
issueStatusOrder,
|
||||
type IssueFilterState,
|
||||
} from "../lib/issue-filters";
|
||||
import {
|
||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||
getAvailableInboxIssueColumns,
|
||||
@@ -27,39 +36,24 @@ import {
|
||||
issueTrailingColumns,
|
||||
} from "./IssueColumns";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { Identity } from "./Identity";
|
||||
import { IssueFiltersPopover } from "./IssueFiltersPopover";
|
||||
import { IssueRow } from "./IssueRow";
|
||||
import { PageSkeleton } from "./PageSkeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
|
||||
import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, Columns3, User, Search } from "lucide-react";
|
||||
import { KanbanBoard } from "./KanbanBoard";
|
||||
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||
import type { Issue, Project } from "@paperclipai/shared";
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
||||
const priorityOrder = ["critical", "high", "medium", "low"];
|
||||
const ISSUE_SEARCH_DEBOUNCE_MS = 150;
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/* ── View state ── */
|
||||
|
||||
export type IssueViewState = {
|
||||
statuses: string[];
|
||||
priorities: string[];
|
||||
assignees: string[];
|
||||
labels: string[];
|
||||
projects: string[];
|
||||
export type IssueViewState = IssueFilterState & {
|
||||
sortField: "status" | "priority" | "title" | "created" | "updated";
|
||||
sortDir: "asc" | "desc";
|
||||
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
|
||||
@@ -69,11 +63,7 @@ export type IssueViewState = {
|
||||
};
|
||||
|
||||
const defaultViewState: IssueViewState = {
|
||||
statuses: [],
|
||||
priorities: [],
|
||||
assignees: [],
|
||||
labels: [],
|
||||
projects: [],
|
||||
...defaultIssueFilterState,
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
groupBy: "none",
|
||||
@@ -81,13 +71,6 @@ const defaultViewState: IssueViewState = {
|
||||
collapsedGroups: [],
|
||||
collapsedParents: [],
|
||||
};
|
||||
|
||||
const quickFilterPresets = [
|
||||
{ label: "All", statuses: [] as string[] },
|
||||
{ label: "Active", statuses: ["todo", "in_progress", "in_review", "blocked"] },
|
||||
{ label: "Backlog", statuses: ["backlog"] },
|
||||
{ label: "Done", statuses: ["done", "cancelled"] },
|
||||
];
|
||||
function getViewState(key: string): IssueViewState {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
@@ -100,45 +83,15 @@ function saveViewState(key: string, state: IssueViewState) {
|
||||
localStorage.setItem(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sa = [...a].sort();
|
||||
const sb = [...b].sort();
|
||||
return sa.every((v, i) => v === sb[i]);
|
||||
}
|
||||
|
||||
function toggleInArray(arr: string[], value: string): string[] {
|
||||
return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value];
|
||||
}
|
||||
|
||||
function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] {
|
||||
let result = issues;
|
||||
if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status));
|
||||
if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority));
|
||||
if (state.assignees.length > 0) {
|
||||
result = result.filter((issue) => {
|
||||
for (const assignee of state.assignees) {
|
||||
if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true;
|
||||
if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true;
|
||||
if (issue.assigneeAgentId === assignee) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id)));
|
||||
if (state.projects.length > 0) result = result.filter((i) => i.projectId != null && state.projects.includes(i.projectId));
|
||||
return result;
|
||||
}
|
||||
|
||||
function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
|
||||
const sorted = [...issues];
|
||||
const dir = state.sortDir === "asc" ? 1 : -1;
|
||||
sorted.sort((a, b) => {
|
||||
switch (state.sortField) {
|
||||
case "status":
|
||||
return dir * (statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status));
|
||||
return dir * (issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status));
|
||||
case "priority":
|
||||
return dir * (priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority));
|
||||
return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.indexOf(b.priority));
|
||||
case "title":
|
||||
return dir * a.title.localeCompare(b.title);
|
||||
case "created":
|
||||
@@ -152,16 +105,6 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function countActiveFilters(state: IssueViewState): number {
|
||||
let count = 0;
|
||||
if (state.statuses.length > 0) count++;
|
||||
if (state.priorities.length > 0) count++;
|
||||
if (state.assignees.length > 0) count++;
|
||||
if (state.labels.length > 0) count++;
|
||||
if (state.projects.length > 0) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
/* ── Component ── */
|
||||
|
||||
interface Agent {
|
||||
@@ -186,6 +129,7 @@ interface IssuesListProps {
|
||||
searchFilters?: {
|
||||
participantAgentId?: string;
|
||||
};
|
||||
enableRoutineVisibilityFilter?: boolean;
|
||||
onSearchChange?: (search: string) => void;
|
||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||
}
|
||||
@@ -247,6 +191,7 @@ export function IssuesList({
|
||||
initialAssignees,
|
||||
initialSearch,
|
||||
searchFilters,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
onSearchChange,
|
||||
onUpdateIssue,
|
||||
}: IssuesListProps) {
|
||||
@@ -319,8 +264,15 @@ export function IssuesList({
|
||||
queryKey: [
|
||||
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
|
||||
searchFilters ?? {},
|
||||
enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions",
|
||||
],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
|
||||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
q: normalizedIssueSearch,
|
||||
projectId,
|
||||
...searchFilters,
|
||||
...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}),
|
||||
}),
|
||||
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
@@ -423,9 +375,9 @@ export function IssuesList({
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
||||
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
||||
const filteredByControls = applyIssueFilters(sourceIssues, viewState, currentUserId, enableRoutineVisibilityFilter);
|
||||
return sortIssues(filteredByControls, viewState);
|
||||
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
|
||||
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]);
|
||||
|
||||
const { data: labels } = useQuery({
|
||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||
@@ -433,7 +385,7 @@ export function IssuesList({
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const activeFilterCount = countActiveFilters(viewState);
|
||||
const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter);
|
||||
|
||||
const groupedContent = useMemo(() => {
|
||||
if (viewState.groupBy === "none") {
|
||||
@@ -441,15 +393,15 @@ export function IssuesList({
|
||||
}
|
||||
if (viewState.groupBy === "status") {
|
||||
const groups = groupBy(filtered, (i) => i.status);
|
||||
return statusOrder
|
||||
return issueStatusOrder
|
||||
.filter((s) => groups[s]?.length)
|
||||
.map((s) => ({ key: s, label: statusLabel(s), items: groups[s]! }));
|
||||
.map((s) => ({ key: s, label: issueFilterLabel(s), items: groups[s]! }));
|
||||
}
|
||||
if (viewState.groupBy === "priority") {
|
||||
const groups = groupBy(filtered, (i) => i.priority);
|
||||
return priorityOrder
|
||||
return issuePriorityOrder
|
||||
.filter((p) => groups[p]?.length)
|
||||
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
|
||||
.map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! }));
|
||||
}
|
||||
if (viewState.groupBy === "workspace") {
|
||||
const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace");
|
||||
@@ -581,175 +533,16 @@ export function IssuesList({
|
||||
title="Choose which issue columns stay visible"
|
||||
/>
|
||||
|
||||
{/* Filter */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className={`text-xs ${activeFilterCount > 0 ? "text-blue-600 dark:text-blue-400" : ""}`}>
|
||||
<Filter className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="sm:hidden text-[10px] font-medium ml-0.5">{activeFilterCount}</span>
|
||||
)}
|
||||
{activeFilterCount > 0 && (
|
||||
<X
|
||||
className="h-3 w-3 ml-1 hidden sm:block"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateView({ statuses: [], priorities: [], assignees: [], labels: [], projects: [] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[min(480px,calc(100vw-2rem))] p-0">
|
||||
<div className="p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Filters</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => updateView({ statuses: [], priorities: [], assignees: [], labels: [] })}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick filters */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">Quick filters</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{quickFilterPresets.map((preset) => {
|
||||
const isActive = arraysEqual(viewState.statuses, preset.statuses);
|
||||
return (
|
||||
<button
|
||||
key={preset.label}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"
|
||||
}`}
|
||||
onClick={() => updateView({ statuses: isActive ? [] : [...preset.statuses] })}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Multi-column filter sections */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-3">
|
||||
{/* Status */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Status</span>
|
||||
<div className="space-y-0.5">
|
||||
{statusOrder.map((s) => (
|
||||
<label key={s} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.statuses.includes(s)}
|
||||
onCheckedChange={() => updateView({ statuses: toggleInArray(viewState.statuses, s) })}
|
||||
/>
|
||||
<StatusIcon status={s} />
|
||||
<span className="text-sm">{statusLabel(s)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority + Assignee stacked in right column */}
|
||||
<div className="space-y-3">
|
||||
{/* Priority */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Priority</span>
|
||||
<div className="space-y-0.5">
|
||||
{priorityOrder.map((p) => (
|
||||
<label key={p} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.priorities.includes(p)}
|
||||
onCheckedChange={() => updateView({ priorities: toggleInArray(viewState.priorities, p) })}
|
||||
/>
|
||||
<PriorityIcon priority={p} />
|
||||
<span className="text-sm">{statusLabel(p)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Assignee</span>
|
||||
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.assignees.includes("__unassigned")}
|
||||
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__unassigned") })}
|
||||
/>
|
||||
<span className="text-sm">No assignee</span>
|
||||
</label>
|
||||
{currentUserId && (
|
||||
<label className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.assignees.includes("__me")}
|
||||
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, "__me") })}
|
||||
/>
|
||||
<User className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm">Me</span>
|
||||
</label>
|
||||
)}
|
||||
{(agents ?? []).map((agent) => (
|
||||
<label key={agent.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.assignees.includes(agent.id)}
|
||||
onCheckedChange={() => updateView({ assignees: toggleInArray(viewState.assignees, agent.id) })}
|
||||
/>
|
||||
<span className="text-sm">{agent.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{labels && labels.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Labels</span>
|
||||
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||
{labels.map((label) => (
|
||||
<label key={label.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.labels.includes(label.id)}
|
||||
onCheckedChange={() => updateView({ labels: toggleInArray(viewState.labels, label.id) })}
|
||||
/>
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: label.color }} />
|
||||
<span className="text-sm">{label.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects && projects.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Project</span>
|
||||
<div className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||
{projects.map((project) => (
|
||||
<label key={project.id} className="flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-accent/50 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={viewState.projects.includes(project.id)}
|
||||
onCheckedChange={() => updateView({ projects: toggleInArray(viewState.projects, project.id) })}
|
||||
/>
|
||||
<span className="text-sm">{project.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<IssueFiltersPopover
|
||||
state={viewState}
|
||||
onChange={updateView}
|
||||
activeFilterCount={activeFilterCount}
|
||||
agents={agents}
|
||||
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
|
||||
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||
currentUserId={currentUserId}
|
||||
enableRoutineVisibilityFilter={enableRoutineVisibilityFilter}
|
||||
/>
|
||||
|
||||
{/* Sort (list view only) */}
|
||||
{viewState.viewMode === "list" && (
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Issue } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
|
||||
interface IssuesQuicklookProps {
|
||||
@@ -36,6 +36,7 @@ export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) {
|
||||
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" />
|
||||
<Link
|
||||
to={createIssueDetailPath(issue.identifier ?? issue.id)}
|
||||
state={withIssueDetailHeaderSeed(null, issue)}
|
||||
className="text-sm font-medium leading-snug hover:underline line-clamp-2"
|
||||
>
|
||||
{issue.title}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BookOpen, Moon, Settings, Sun } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||
import { Link, Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||
import { CompanyRail } from "./CompanyRail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { InstanceSidebar } from "./InstanceSidebar";
|
||||
@@ -32,6 +32,11 @@ import {
|
||||
DEFAULT_INSTANCE_SETTINGS_PATH,
|
||||
normalizeRememberedInstanceSettingsPath,
|
||||
} from "../lib/instance-settings";
|
||||
import {
|
||||
resetNavigationScroll,
|
||||
SIDEBAR_SCROLL_RESET_STATE,
|
||||
shouldResetScrollOnNavigation,
|
||||
} from "../lib/navigation-scroll";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { scheduleMainContentFocus } from "../lib/main-content-focus";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -66,9 +71,12 @@ export function Layout() {
|
||||
const { companyPrefix } = useParams<{ companyPrefix: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
const isInstanceSettingsRoute = location.pathname.startsWith("/instance/");
|
||||
const onboardingTriggered = useRef(false);
|
||||
const lastMainScrollTop = useRef(0);
|
||||
const previousPathname = useRef<string | null>(null);
|
||||
const mainContentRef = useRef<HTMLElement | null>(null);
|
||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
@@ -271,10 +279,24 @@ export function Layout() {
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const mainContent = document.getElementById("main-content");
|
||||
const mainContent = mainContentRef.current;
|
||||
return scheduleMainContentFocus(mainContent);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldResetScroll = shouldResetScrollOnNavigation({
|
||||
previousPathname: previousPathname.current,
|
||||
pathname: location.pathname,
|
||||
navigationType,
|
||||
state: location.state,
|
||||
});
|
||||
|
||||
previousPathname.current = location.pathname;
|
||||
|
||||
if (!shouldResetScroll) return;
|
||||
resetNavigationScroll(mainContentRef.current);
|
||||
}, [location.pathname, navigationType]);
|
||||
|
||||
return (
|
||||
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
|
||||
<div
|
||||
@@ -334,6 +356,7 @@ export function Layout() {
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to={instanceSettingsTarget}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
@@ -392,6 +415,7 @@ export function Layout() {
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
|
||||
<Link
|
||||
to={instanceSettingsTarget}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
aria-label="Instance settings"
|
||||
title="Instance settings"
|
||||
onClick={() => {
|
||||
@@ -428,6 +452,7 @@ export function Layout() {
|
||||
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
|
||||
<main
|
||||
id="main-content"
|
||||
ref={mainContentRef}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"flex-1 p-4 outline-none md:p-6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats";
|
||||
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { formatDateTime } from "../lib/utils";
|
||||
import { ExternalLink, Square } from "lucide-react";
|
||||
@@ -13,8 +13,6 @@ import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
|
||||
interface LiveRunWidgetProps {
|
||||
issueId: string;
|
||||
companyId?: string | null;
|
||||
liveRunsData?: LiveRunForIssue[];
|
||||
activeRunData?: ActiveRunForIssue | null;
|
||||
}
|
||||
|
||||
function toIsoString(value: string | Date | null | undefined): string | null {
|
||||
@@ -26,34 +24,24 @@ function isRunActive(status: string): boolean {
|
||||
return status === "queued" || status === "running";
|
||||
}
|
||||
|
||||
export function LiveRunWidget({
|
||||
issueId,
|
||||
companyId,
|
||||
liveRunsData,
|
||||
activeRunData,
|
||||
}: LiveRunWidgetProps) {
|
||||
export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [cancellingRunIds, setCancellingRunIds] = useState(new Set<string>());
|
||||
const shouldFetchLiveRuns = liveRunsData === undefined;
|
||||
const shouldFetchActiveRun = activeRunData === undefined;
|
||||
|
||||
const { data: fetchedLiveRuns } = useQuery({
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: queryKeys.issues.liveRuns(issueId),
|
||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
|
||||
enabled: !!issueId && shouldFetchLiveRuns,
|
||||
enabled: !!issueId,
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const { data: fetchedActiveRun } = useQuery({
|
||||
const { data: activeRun } = useQuery({
|
||||
queryKey: queryKeys.issues.activeRun(issueId),
|
||||
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
|
||||
enabled: !!issueId && shouldFetchActiveRun,
|
||||
enabled: !!issueId,
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const liveRuns = liveRunsData ?? fetchedLiveRuns;
|
||||
const activeRun = activeRunData ?? fetchedActiveRun;
|
||||
|
||||
const runs = useMemo(() => {
|
||||
const deduped = new Map<string, LiveRunForIssue>();
|
||||
for (const run of liveRuns ?? []) {
|
||||
|
||||
@@ -3,7 +3,16 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { computeMentionMenuPosition, findMentionMatch, MarkdownEditor } from "./MarkdownEditor";
|
||||
import { buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import {
|
||||
computeMentionMenuPosition,
|
||||
findClosestAutocompleteAnchor,
|
||||
findMentionMatch,
|
||||
isSameAutocompleteSession,
|
||||
MarkdownEditor,
|
||||
placeCaretAfterMentionAnchor,
|
||||
shouldAcceptAutocompleteKey,
|
||||
} from "./MarkdownEditor";
|
||||
|
||||
const mdxEditorMockState = vi.hoisted(() => ({
|
||||
emitMountEmptyReset: false,
|
||||
@@ -213,4 +222,94 @@ describe("MarkdownEditor", () => {
|
||||
it("still rejects slash commands once spaces are typed", () => {
|
||||
expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat Enter as skill autocomplete accept", () => {
|
||||
expect(shouldAcceptAutocompleteKey("Enter", "skill")).toBe(false);
|
||||
expect(shouldAcceptAutocompleteKey("Enter", "skill", true)).toBe(true);
|
||||
expect(shouldAcceptAutocompleteKey("Enter", "mention")).toBe(true);
|
||||
expect(shouldAcceptAutocompleteKey("Tab", "skill")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the same autocomplete session active while the slash query is unchanged", () => {
|
||||
const textNode = document.createTextNode("/agent");
|
||||
expect(isSameAutocompleteSession(
|
||||
{
|
||||
trigger: "skill",
|
||||
marker: "/",
|
||||
query: "agent",
|
||||
textNode,
|
||||
atPos: 0,
|
||||
endPos: 6,
|
||||
},
|
||||
{
|
||||
trigger: "skill",
|
||||
marker: "/",
|
||||
query: "agent",
|
||||
textNode,
|
||||
atPos: 0,
|
||||
endPos: 6,
|
||||
},
|
||||
)).toBe(true);
|
||||
|
||||
expect(isSameAutocompleteSession(
|
||||
{
|
||||
trigger: "skill",
|
||||
marker: "/",
|
||||
query: "agent",
|
||||
textNode,
|
||||
atPos: 0,
|
||||
endPos: 6,
|
||||
},
|
||||
{
|
||||
trigger: "skill",
|
||||
marker: "/",
|
||||
query: "agent-browser",
|
||||
textNode,
|
||||
atPos: 0,
|
||||
endPos: 14,
|
||||
},
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("finds skill anchors by mention metadata instead of visible text", () => {
|
||||
const editable = document.createElement("div");
|
||||
const skillLink = document.createElement("a");
|
||||
skillLink.setAttribute("href", buildSkillMentionHref("skill-123", "agent-browser"));
|
||||
skillLink.textContent = "/agent-browser ";
|
||||
editable.appendChild(skillLink);
|
||||
|
||||
const found = findClosestAutocompleteAnchor(editable, {
|
||||
id: "skill:skill-123",
|
||||
kind: "skill",
|
||||
skillId: "skill-123",
|
||||
key: "agent-browser",
|
||||
name: "Agent Browser",
|
||||
slug: "agent-browser",
|
||||
description: null,
|
||||
href: buildSkillMentionHref("skill-123", "agent-browser"),
|
||||
aliases: ["agent-browser", "Agent Browser"],
|
||||
});
|
||||
|
||||
expect(found).toBe(skillLink);
|
||||
});
|
||||
|
||||
it("places the caret after the mention's trailing space when present", () => {
|
||||
const editable = document.createElement("div");
|
||||
editable.contentEditable = "true";
|
||||
document.body.appendChild(editable);
|
||||
|
||||
const skillLink = document.createElement("a");
|
||||
skillLink.setAttribute("href", buildSkillMentionHref("skill-123", "agent-browser"));
|
||||
skillLink.textContent = "/agent-browser";
|
||||
const trailingSpace = document.createTextNode(" ");
|
||||
editable.append(skillLink, trailingSpace);
|
||||
|
||||
expect(placeCaretAfterMentionAnchor(skillLink)).toBe(true);
|
||||
|
||||
const selection = window.getSelection();
|
||||
expect(selection?.anchorNode).toBe(trailingSpace);
|
||||
expect(selection?.anchorOffset).toBe(1);
|
||||
|
||||
editable.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -297,6 +297,102 @@ function autocompleteMarkdown(option: AutocompleteOption): string {
|
||||
return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option);
|
||||
}
|
||||
|
||||
export function shouldAcceptAutocompleteKey(
|
||||
key: string,
|
||||
trigger: MentionState["trigger"] | null,
|
||||
skillEnterArmed = false,
|
||||
): boolean {
|
||||
if (key === "Tab") return true;
|
||||
if (key !== "Enter") return false;
|
||||
return trigger === "mention" || (trigger === "skill" && skillEnterArmed);
|
||||
}
|
||||
|
||||
export function isSameAutocompleteSession(
|
||||
left: Pick<MentionState, "trigger" | "marker" | "query" | "textNode" | "atPos" | "endPos"> | null,
|
||||
right: Pick<MentionState, "trigger" | "marker" | "query" | "textNode" | "atPos" | "endPos"> | null,
|
||||
): boolean {
|
||||
if (!left || !right) return false;
|
||||
return left.trigger === right.trigger
|
||||
&& left.marker === right.marker
|
||||
&& left.query === right.query
|
||||
&& left.textNode === right.textNode
|
||||
&& left.atPos === right.atPos
|
||||
&& left.endPos === right.endPos;
|
||||
}
|
||||
|
||||
function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string): boolean {
|
||||
const parsed = parseMentionChipHref(href);
|
||||
if (!parsed) return false;
|
||||
|
||||
if (option.kind === "skill") {
|
||||
return parsed.kind === "skill" && parsed.skillId === option.skillId;
|
||||
}
|
||||
|
||||
if (option.kind === "project" && option.projectId) {
|
||||
return parsed.kind === "project" && parsed.projectId === option.projectId;
|
||||
}
|
||||
|
||||
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
|
||||
return parsed.kind === "agent" && parsed.agentId === agentId;
|
||||
}
|
||||
|
||||
export function findClosestAutocompleteAnchor(
|
||||
editable: HTMLElement,
|
||||
option: AutocompleteOption,
|
||||
origin?: Pick<MentionState, "left" | "top"> | null,
|
||||
): HTMLAnchorElement | null {
|
||||
const matchingMentions = Array.from(editable.querySelectorAll("a"))
|
||||
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
|
||||
.filter((link) => autocompleteOptionMatchesLink(option, link.getAttribute("href") ?? ""));
|
||||
|
||||
if (matchingMentions.length === 0) return null;
|
||||
if (!origin) return matchingMentions[0] ?? null;
|
||||
|
||||
const containerRect = editable.getBoundingClientRect();
|
||||
return matchingMentions.sort((a, b) => {
|
||||
const rectA = a.getBoundingClientRect();
|
||||
const rectB = b.getBoundingClientRect();
|
||||
const leftA = rectA.left - containerRect.left;
|
||||
const topA = rectA.top - containerRect.top;
|
||||
const leftB = rectB.left - containerRect.left;
|
||||
const topB = rectB.top - containerRect.top;
|
||||
const distA = Math.hypot(leftA - origin.left, topA - origin.top);
|
||||
const distB = Math.hypot(leftB - origin.left, topB - origin.top);
|
||||
return distA - distB;
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
export function placeCaretAfterMentionAnchor(target: HTMLAnchorElement): boolean {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return false;
|
||||
|
||||
const range = document.createRange();
|
||||
const nextSibling = target.nextSibling;
|
||||
if (nextSibling?.nodeType === Node.TEXT_NODE) {
|
||||
const text = nextSibling.textContent ?? "";
|
||||
if (text.startsWith(" ")) {
|
||||
range.setStart(nextSibling, 1);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return true;
|
||||
}
|
||||
if (text.length > 0) {
|
||||
range.setStart(nextSibling, 0);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
range.setStartAfter(target);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replace the active autocomplete token in the markdown string with the selected token. */
|
||||
function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string {
|
||||
const search = `${state.marker}${state.query}`;
|
||||
@@ -346,6 +442,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
||||
const mentionStateRef = useRef<MentionState | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const skillEnterArmedRef = useRef(false);
|
||||
const mentionActive = mentionState !== null && (
|
||||
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|
||||
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
|
||||
@@ -509,6 +606,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
const checkMention = useCallback(() => {
|
||||
if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) {
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
return;
|
||||
}
|
||||
@@ -519,6 +617,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
&& (!mentions || mentions.length === 0)
|
||||
) {
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
return;
|
||||
}
|
||||
@@ -528,16 +627,18 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
&& slashCommands.length === 0
|
||||
) {
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
return;
|
||||
}
|
||||
const previous = mentionStateRef.current;
|
||||
const sameSession = isSameAutocompleteSession(previous, result);
|
||||
mentionStateRef.current = result;
|
||||
if (result) {
|
||||
setMentionState(result);
|
||||
if (!sameSession) {
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionIndex(0);
|
||||
} else {
|
||||
setMentionState(null);
|
||||
}
|
||||
setMentionState(result);
|
||||
}, [mentions, slashCommands.length]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -548,21 +649,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
// also fires after typing (e.g. space to dismiss).
|
||||
const onInput = () => requestAnimationFrame(checkMention);
|
||||
|
||||
let selRafId: number | null = null;
|
||||
const onSelectionChange = () => {
|
||||
if (selRafId !== null) return;
|
||||
selRafId = requestAnimationFrame(() => {
|
||||
selRafId = null;
|
||||
checkMention();
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("selectionchange", onSelectionChange);
|
||||
document.addEventListener("selectionchange", checkMention);
|
||||
el?.addEventListener("input", onInput, true);
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", onSelectionChange);
|
||||
document.removeEventListener("selectionchange", checkMention);
|
||||
el?.removeEventListener("input", onInput, true);
|
||||
if (selRafId !== null) cancelAnimationFrame(selRafId);
|
||||
};
|
||||
}, [checkMention, mentions, slashCommands.length]);
|
||||
|
||||
@@ -589,24 +680,16 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
if (!editable) return;
|
||||
decorateProjectMentions();
|
||||
let rafId: number | null = null;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
decorateProjectMentions();
|
||||
});
|
||||
decorateProjectMentions();
|
||||
});
|
||||
observer.observe(editable, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [decorateProjectMentions]);
|
||||
return () => observer.disconnect();
|
||||
}, [decorateProjectMentions, value]);
|
||||
|
||||
const selectMention = useCallback(
|
||||
(option: AutocompleteOption) => {
|
||||
@@ -623,65 +706,28 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
if (!(editable instanceof HTMLElement)) return;
|
||||
decorateProjectMentions();
|
||||
editable.focus();
|
||||
const restoreSelection = (attemptsRemaining: number) => {
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
if (!(editable instanceof HTMLElement)) return;
|
||||
|
||||
const mentionHref = option.kind === "skill"
|
||||
? option.href
|
||||
: option.kind === "project" && option.projectId
|
||||
? buildProjectMentionHref(option.projectId, option.projectColor ?? null)
|
||||
: buildAgentMentionHref(
|
||||
option.agentId ?? option.id.replace(/^agent:/, ""),
|
||||
option.agentIcon ?? null,
|
||||
);
|
||||
const expectedLabel = option.kind === "skill" ? `/${option.slug}` : `@${option.name}`;
|
||||
const matchingMentions = Array.from(editable.querySelectorAll("a"))
|
||||
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
|
||||
.filter((link) => {
|
||||
const href = link.getAttribute("href") ?? "";
|
||||
return href === mentionHref && link.textContent === expectedLabel;
|
||||
});
|
||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||
const target = matchingMentions.sort((a, b) => {
|
||||
const rectA = a.getBoundingClientRect();
|
||||
const rectB = b.getBoundingClientRect();
|
||||
const leftA = containerRect ? rectA.left - containerRect.left : rectA.left;
|
||||
const topA = containerRect ? rectA.top - containerRect.top : rectA.top;
|
||||
const leftB = containerRect ? rectB.left - containerRect.left : rectB.left;
|
||||
const topB = containerRect ? rectB.top - containerRect.top : rectB.top;
|
||||
const distA = Math.hypot(leftA - state.left, topA - state.top);
|
||||
const distB = Math.hypot(leftB - state.left, topB - state.top);
|
||||
return distA - distB;
|
||||
})[0] ?? null;
|
||||
if (!target) return;
|
||||
decorateProjectMentions();
|
||||
editable.focus();
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
const range = document.createRange();
|
||||
const nextSibling = target.nextSibling;
|
||||
if (nextSibling?.nodeType === Node.TEXT_NODE) {
|
||||
const text = nextSibling.textContent ?? "";
|
||||
if (text.startsWith(" ")) {
|
||||
range.setStart(nextSibling, 1);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return;
|
||||
}
|
||||
const target = findClosestAutocompleteAnchor(editable, option, state);
|
||||
if (!target) {
|
||||
if (attemptsRemaining > 0) {
|
||||
requestAnimationFrame(() => restoreSelection(attemptsRemaining - 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
range.setStartAfter(target);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
});
|
||||
placeCaretAfterMentionAnchor(target);
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => restoreSelection(4));
|
||||
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
},
|
||||
[decorateProjectMentions, onChange],
|
||||
@@ -737,6 +783,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
if (mentionActive) {
|
||||
if (e.key === " " && mentionStateRef.current?.trigger === "skill") {
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
return;
|
||||
}
|
||||
@@ -745,6 +792,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
return;
|
||||
}
|
||||
@@ -753,16 +801,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill";
|
||||
setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill";
|
||||
setMentionIndex((prev) => Math.max(prev - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
if (
|
||||
shouldAcceptAutocompleteKey(
|
||||
e.key,
|
||||
mentionStateRef.current?.trigger ?? null,
|
||||
skillEnterArmedRef.current,
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectMention(filteredMentions[mentionIndex]);
|
||||
@@ -865,7 +921,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
e.preventDefault(); // prevent blur
|
||||
selectMention(option);
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
onMouseEnter={() => {
|
||||
if (mentionStateRef.current?.trigger === "skill") {
|
||||
skillEnterArmedRef.current = true;
|
||||
}
|
||||
setMentionIndex(i);
|
||||
}}
|
||||
>
|
||||
{option.kind === "skill" ? (
|
||||
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||
|
||||
@@ -92,6 +93,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
||||
<NavLink
|
||||
key={item.label}
|
||||
to={item.to}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"relative flex min-w-0 flex-col items-center justify-center gap-1 rounded-md text-[10px] font-medium transition-colors",
|
||||
|
||||
@@ -372,6 +372,24 @@ describe("NewIssueDialog", () => {
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps the mobile dialog bounded with an internal flexible scroll region", async () => {
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
const dialogContent = Array.from(container.querySelectorAll("div")).find((element) =>
|
||||
typeof element.className === "string" && element.className.includes("max-h-[calc(100dvh-2rem)]"),
|
||||
);
|
||||
expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]");
|
||||
expect(dialogContent?.className).toContain("overflow-hidden");
|
||||
|
||||
const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]');
|
||||
const descriptionScrollRegion = descriptionInput?.parentElement?.parentElement;
|
||||
expect(descriptionScrollRegion?.className).toContain("flex-1");
|
||||
expect(descriptionScrollRegion?.className).toContain("overflow-y-auto");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("warns when a sub-issue stops matching the parent workspace", async () => {
|
||||
mockProjectsApi.list.mockResolvedValue([
|
||||
{
|
||||
@@ -418,6 +436,7 @@ describe("NewIssueDialog", () => {
|
||||
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).not.toContain("will no longer use the parent issue workspace");
|
||||
|
||||
|
||||
@@ -946,9 +946,9 @@ export function NewIssueDialog() {
|
||||
showCloseButton={false}
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
"p-0 gap-0 flex flex-col max-h-[calc(100dvh-2rem)]",
|
||||
"flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:h-auto",
|
||||
expanded
|
||||
? "sm:max-w-2xl h-[calc(100dvh-2rem)]"
|
||||
? "sm:max-w-2xl sm:h-[calc(100dvh-2rem)]"
|
||||
: "sm:max-w-lg"
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -1452,7 +1452,7 @@ export function NewIssueDialog() {
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn("px-4 pb-2 overflow-y-auto min-h-0 border-t border-border/60 pt-3", expanded ? "flex-1" : "")}
|
||||
className="min-h-0 flex-1 overflow-y-auto border-t border-border/60 px-4 pb-2 pt-3"
|
||||
onDragEnter={handleFileDragEnter}
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
|
||||
@@ -115,4 +115,77 @@ describe("useLiveRunTranscripts", () => {
|
||||
expect(socket.closeCalls).toEqual([{ code: 1000, reason: "live_run_transcripts_unmount" }]);
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("treats stored run output as available before transcript chunks finish loading", async () => {
|
||||
let latestHasOutput = false;
|
||||
|
||||
function Harness() {
|
||||
const { hasOutputForRun } = useLiveRunTranscripts({
|
||||
companyId: "company-1",
|
||||
runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local", hasStoredOutput: true }],
|
||||
});
|
||||
latestHasOutput = hasOutputForRun("run-1");
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(latestHasOutput).toBe(true);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("reports initial hydration until the first persisted-log read completes", async () => {
|
||||
let latestIsInitialHydrating = false;
|
||||
type RunLogResult = { runId: string; store: string; logRef: string; content: string; nextOffset: number };
|
||||
let resolveLog: ((value: RunLogResult | PromiseLike<RunLogResult>) => void) | null = null;
|
||||
logMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<RunLogResult>((resolve) => {
|
||||
resolveLog = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
function Harness() {
|
||||
const { isInitialHydrating } = useLiveRunTranscripts({
|
||||
companyId: "company-1",
|
||||
runs: [{ id: "run-1", status: "succeeded", adapterType: "codex_local" }],
|
||||
});
|
||||
latestIsInitialHydrating = isInitialHydrating;
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(latestIsInitialHydrating).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveLog?.({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(latestIsInitialHydrating).toBe(false);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface RunTranscriptSource {
|
||||
id: string;
|
||||
status: string;
|
||||
adapterType: string;
|
||||
hasStoredOutput?: boolean;
|
||||
}
|
||||
|
||||
interface UseLiveRunTranscriptsOptions {
|
||||
@@ -70,7 +71,17 @@ export function useLiveRunTranscripts({
|
||||
companyId,
|
||||
maxChunksPerRun = 200,
|
||||
}: UseLiveRunTranscriptsOptions) {
|
||||
const runsKey = useMemo(
|
||||
() =>
|
||||
runs
|
||||
.map((run) => `${run.id}:${run.status}:${run.adapterType}:${run.hasStoredOutput === true ? "1" : "0"}`)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join(","),
|
||||
[runs],
|
||||
);
|
||||
const normalizedRuns = useMemo(() => runs.map((run) => ({ ...run })), [runsKey]);
|
||||
const [chunksByRun, setChunksByRun] = useState<Map<string, RunLogChunk[]>>(new Map());
|
||||
const [hydratedRunIds, setHydratedRunIds] = useState<Set<string>>(new Set());
|
||||
const seenChunkKeysRef = useRef(new Set<string>());
|
||||
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
|
||||
const logOffsetByRunRef = useRef(new Map<string, number>());
|
||||
@@ -84,14 +95,14 @@ export function useLiveRunTranscripts({
|
||||
queryFn: () => instanceSettingsApi.getGeneral(),
|
||||
});
|
||||
|
||||
const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]);
|
||||
const runById = useMemo(() => new Map(normalizedRuns.map((run) => [run.id, run])), [normalizedRuns]);
|
||||
const activeRunIds = useMemo(
|
||||
() => new Set(runs.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)),
|
||||
[runs],
|
||||
() => new Set(normalizedRuns.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)),
|
||||
[normalizedRuns],
|
||||
);
|
||||
const runIdsKey = useMemo(
|
||||
() => runs.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","),
|
||||
[runs],
|
||||
() => normalizedRuns.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","),
|
||||
[normalizedRuns],
|
||||
);
|
||||
|
||||
const appendChunks = (runId: string, chunks: Array<RunLogChunk & { dedupeKey: string }>) => {
|
||||
@@ -118,7 +129,7 @@ export function useLiveRunTranscripts({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const knownRunIds = new Set(runs.map((run) => run.id));
|
||||
const knownRunIds = new Set(normalizedRuns.map((run) => run.id));
|
||||
setChunksByRun((prev) => {
|
||||
const next = new Map<string, RunLogChunk[]>();
|
||||
for (const [runId, chunks] of prev) {
|
||||
@@ -128,6 +139,15 @@ export function useLiveRunTranscripts({
|
||||
}
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
setHydratedRunIds((prev) => {
|
||||
const next = new Set<string>();
|
||||
for (const runId of prev) {
|
||||
if (knownRunIds.has(runId)) {
|
||||
next.add(runId);
|
||||
}
|
||||
}
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
|
||||
for (const key of pendingLogRowsByRunRef.current.keys()) {
|
||||
const runId = key.replace(/:records$/, "");
|
||||
@@ -140,10 +160,10 @@ export function useLiveRunTranscripts({
|
||||
logOffsetByRunRef.current.delete(runId);
|
||||
}
|
||||
}
|
||||
}, [runs]);
|
||||
}, [normalizedRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (runs.length === 0) return;
|
||||
if (normalizedRuns.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -164,15 +184,24 @@ export function useLiveRunTranscripts({
|
||||
}
|
||||
} catch {
|
||||
// Ignore log read errors while output is initializing.
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setHydratedRunIds((prev) => {
|
||||
if (prev.has(run.id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(run.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const readAll = async () => {
|
||||
await Promise.all(runs.map((run) => readRunLog(run)));
|
||||
await Promise.all(normalizedRuns.map((run) => readRunLog(run)));
|
||||
};
|
||||
|
||||
void readAll();
|
||||
const activeRuns = runs.filter((run) => !isTerminalStatus(run.status));
|
||||
const activeRuns = normalizedRuns.filter((run) => !isTerminalStatus(run.status));
|
||||
const interval = activeRuns.length > 0
|
||||
? window.setInterval(() => {
|
||||
void Promise.all(activeRuns.map((run) => readRunLog(run)));
|
||||
@@ -183,7 +212,7 @@ export function useLiveRunTranscripts({
|
||||
cancelled = true;
|
||||
if (interval !== null) window.clearInterval(interval);
|
||||
};
|
||||
}, [runIdsKey, runs]);
|
||||
}, [normalizedRuns, runIdsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyId || activeRunIds.size === 0) return;
|
||||
@@ -298,7 +327,7 @@ export function useLiveRunTranscripts({
|
||||
const transcriptByRun = useMemo(() => {
|
||||
const next = new Map<string, TranscriptEntry[]>();
|
||||
const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true;
|
||||
for (const run of runs) {
|
||||
for (const run of normalizedRuns) {
|
||||
const adapter = getUIAdapter(run.adapterType);
|
||||
next.set(
|
||||
run.id,
|
||||
@@ -308,12 +337,13 @@ export function useLiveRunTranscripts({
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}, [chunksByRun, generalSettings?.censorUsernameInLogs, parserTick, runs]);
|
||||
}, [chunksByRun, generalSettings?.censorUsernameInLogs, normalizedRuns, parserTick]);
|
||||
|
||||
return {
|
||||
transcriptByRun,
|
||||
isInitialHydrating: normalizedRuns.some((run) => !hydratedRunIds.has(run.id)),
|
||||
hasOutputForRun(runId: string) {
|
||||
return (chunksByRun.get(runId)?.length ?? 0) > 0;
|
||||
return (chunksByRun.get(runId)?.length ?? 0) > 0 || runById.get(runId)?.hasStoredOutput === true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
action: "issue.updated",
|
||||
details: null,
|
||||
},
|
||||
{ userId: null, agentId: null },
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
@@ -81,12 +82,87 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
action: "issue.comment_added",
|
||||
details: null,
|
||||
},
|
||||
{ userId: null, agentId: null },
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.comments("issue-1"),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps self-authored comment events from refetching the active issue tree", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
invalidations.push(input);
|
||||
},
|
||||
getQueryData: () => undefined,
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
action: "issue.comment_added",
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
details: null,
|
||||
},
|
||||
{ userId: "user-1", agentId: null },
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.detail("issue-1"),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.activity("issue-1"),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.comments("issue-1"),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats self-authored comment-driven issue updates as inactive-only refreshes", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
invalidations.push(input);
|
||||
},
|
||||
getQueryData: () => undefined,
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
action: "issue.updated",
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
details: { source: "comment" },
|
||||
},
|
||||
{ userId: "user-1", agentId: null },
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.detail("issue-1"),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.activity("issue-1"),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.issues.comments("issue-1"),
|
||||
refetchType: "inactive",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LiveUpdatesProvider visible issue toast suppression", () => {
|
||||
|
||||
@@ -480,6 +480,7 @@ function invalidateActivityQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
companyId: string,
|
||||
payload: Record<string, unknown>,
|
||||
currentActor: { userId: string | null; agentId: string | null },
|
||||
) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
|
||||
@@ -488,6 +489,8 @@ function invalidateActivityQueries(
|
||||
const entityType = readString(payload.entityType);
|
||||
const entityId = readString(payload.entityId);
|
||||
const action = readString(payload.action);
|
||||
const actorType = readString(payload.actorType);
|
||||
const actorId = readString(payload.actorId);
|
||||
|
||||
if (entityType === "issue") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
@@ -496,12 +499,18 @@ function invalidateActivityQueries(
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) });
|
||||
if (entityId) {
|
||||
const details = readRecord(payload.details);
|
||||
const selfCommentActivity =
|
||||
((action === "issue.comment_added") ||
|
||||
(action === "issue.updated" && readString(details?.source) === "comment")) &&
|
||||
((actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) ||
|
||||
(actorType === "agent" && !!currentActor.agentId && actorId === currentActor.agentId));
|
||||
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
|
||||
for (const ref of issueRefs) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
|
||||
const invalidationOptions = selfCommentActivity ? { refetchType: "inactive" as const } : undefined;
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref), ...invalidationOptions });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref), ...invalidationOptions });
|
||||
if (action === "issue.comment_added") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref), ...invalidationOptions });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -646,7 +655,7 @@ function handleLiveEvent(
|
||||
}
|
||||
|
||||
if (event.type === "activity.logged") {
|
||||
invalidateActivityQueries(queryClient, expectedCompanyId, payload);
|
||||
invalidateActivityQueries(queryClient, expectedCompanyId, payload, currentActor);
|
||||
const action = readString(payload.action);
|
||||
const toast =
|
||||
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
|
||||
|
||||
@@ -273,6 +273,23 @@ export const issueChatUxTranscriptsByRunId = new Map<string, readonly IssueChatT
|
||||
],
|
||||
]);
|
||||
|
||||
export const issueChatUxSubmittingComments: IssueChatComment[] = [
|
||||
createComment({
|
||||
id: "comment-submitting-user-settled",
|
||||
body: "Let me know once the thread layout is locked down.",
|
||||
createdAt: new Date("2026-04-06T12:40:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:40:00.000Z"),
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-submitting-pending",
|
||||
body: "Looks good — go ahead and ship it when you're ready.",
|
||||
createdAt: new Date("2026-04-06T12:42:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:42:00.000Z"),
|
||||
clientId: "client-pending-1",
|
||||
clientStatus: "pending",
|
||||
}),
|
||||
];
|
||||
|
||||
export const issueChatUxReviewComments: IssueChatComment[] = [
|
||||
createComment({
|
||||
id: "comment-review-user",
|
||||
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||
buildInboxDismissedAtByKey,
|
||||
computeInboxBadgeData,
|
||||
filterInboxIssues,
|
||||
getAvailableInboxIssueColumns,
|
||||
getApprovalsForTab,
|
||||
getInboxWorkItems,
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getRecentTouchedIssues,
|
||||
getUnreadTouchedIssues,
|
||||
groupInboxWorkItems,
|
||||
isInboxEntityDismissed,
|
||||
isMineInboxTab,
|
||||
loadInboxIssueColumns,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
saveInboxIssueColumns,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
type InboxWorkItem,
|
||||
} from "./inbox";
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
@@ -336,7 +339,6 @@ describe("inbox helpers", () => {
|
||||
});
|
||||
|
||||
expect(result.mineIssues).toBe(1);
|
||||
// inbox = mineIssues(1) + agent-error alert(1) + budget alert(1)
|
||||
expect(result.inbox).toBe(3);
|
||||
});
|
||||
|
||||
@@ -493,7 +495,7 @@ describe("inbox helpers", () => {
|
||||
approvals: [],
|
||||
});
|
||||
|
||||
expect(items.map((i) => (i.kind === "issue" ? i.issue.id : ""))).toEqual([
|
||||
expect(items.map((item) => (item.kind === "issue" ? item.issue.id : ""))).toEqual([
|
||||
"recent",
|
||||
"older",
|
||||
]);
|
||||
@@ -701,4 +703,30 @@ describe("inbox helpers", () => {
|
||||
expect(getInboxKeyboardSelectionIndex(0, 3, "next")).toBe(1);
|
||||
expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0);
|
||||
});
|
||||
|
||||
it("hides routine execution issues until the toggle is enabled", () => {
|
||||
const manualIssue = { ...makeIssue("manual", true), originKind: "manual" as const };
|
||||
const routineIssue = { ...makeIssue("routine", true), originKind: "routine_execution" as const };
|
||||
|
||||
expect(filterInboxIssues([manualIssue, routineIssue], false)).toEqual([manualIssue]);
|
||||
expect(filterInboxIssues([manualIssue, routineIssue], true)).toEqual([manualIssue, routineIssue]);
|
||||
});
|
||||
|
||||
it("groups mixed inbox items by type while preserving item order within each group", () => {
|
||||
const items: InboxWorkItem[] = [
|
||||
{ kind: "approval", timestamp: 4, approval: makeApproval("pending") },
|
||||
{ kind: "issue", timestamp: 3, issue: makeIssue("1", true) },
|
||||
{ kind: "issue", timestamp: 2, issue: makeIssue("2", false) },
|
||||
{ kind: "failed_run", timestamp: 1, run: makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z") },
|
||||
{ kind: "join_request", timestamp: 0, joinRequest: makeJoinRequest("join-1") },
|
||||
];
|
||||
|
||||
expect(groupInboxWorkItems(items, "none")).toEqual([{ key: "__all", label: null, items }]);
|
||||
expect(groupInboxWorkItems(items, "type")).toEqual([
|
||||
{ key: "issue", label: "Issues", items: [items[1], items[2]] },
|
||||
{ key: "approval", label: "Approvals", items: [items[0]] },
|
||||
{ key: "failed_run", label: "Failed runs", items: [items[3]] },
|
||||
{ key: "join_request", label: "Join requests", items: [items[4]] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,10 @@ export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
|
||||
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
|
||||
export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
|
||||
export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by";
|
||||
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||
export type InboxWorkItemGroupBy = "none" | "type";
|
||||
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
|
||||
export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
|
||||
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
|
||||
@@ -51,6 +53,12 @@ export interface InboxBadgeData {
|
||||
alerts: number;
|
||||
}
|
||||
|
||||
export interface InboxWorkItemGroup {
|
||||
key: string;
|
||||
label: string | null;
|
||||
items: InboxWorkItem[];
|
||||
}
|
||||
|
||||
export function loadDismissedInboxAlerts(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISSED_KEY);
|
||||
@@ -137,6 +145,35 @@ export function saveInboxIssueColumns(columns: InboxIssueColumn[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadInboxWorkItemGroupBy(): InboxWorkItemGroupBy {
|
||||
try {
|
||||
const raw = localStorage.getItem(INBOX_GROUP_BY_KEY);
|
||||
return raw === "type" ? raw : "none";
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
export function saveInboxWorkItemGroupBy(groupBy: InboxWorkItemGroupBy) {
|
||||
try {
|
||||
localStorage.setItem(INBOX_GROUP_BY_KEY, groupBy);
|
||||
} catch {
|
||||
// Ignore localStorage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldIncludeRoutineExecutionIssue(
|
||||
issue: Pick<Issue, "originKind">,
|
||||
showRoutineExecutions: boolean,
|
||||
): boolean {
|
||||
return showRoutineExecutions || issue.originKind !== "routine_execution";
|
||||
}
|
||||
|
||||
export function filterInboxIssues(issues: Issue[], showRoutineExecutions: boolean): Issue[] {
|
||||
if (showRoutineExecutions) return issues;
|
||||
return issues.filter((issue) => shouldIncludeRoutineExecutionIssue(issue, showRoutineExecutions));
|
||||
}
|
||||
|
||||
export function resolveIssueWorkspaceName(
|
||||
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
|
||||
{
|
||||
@@ -362,6 +399,48 @@ export function getInboxWorkItems({
|
||||
});
|
||||
}
|
||||
|
||||
const inboxWorkItemKindOrder: InboxWorkItem["kind"][] = [
|
||||
"issue",
|
||||
"approval",
|
||||
"failed_run",
|
||||
"join_request",
|
||||
];
|
||||
|
||||
const inboxWorkItemKindLabels: Record<InboxWorkItem["kind"], string> = {
|
||||
issue: "Issues",
|
||||
approval: "Approvals",
|
||||
failed_run: "Failed runs",
|
||||
join_request: "Join requests",
|
||||
};
|
||||
|
||||
export function groupInboxWorkItems(
|
||||
items: InboxWorkItem[],
|
||||
groupBy: InboxWorkItemGroupBy,
|
||||
): InboxWorkItemGroup[] {
|
||||
if (groupBy === "none") {
|
||||
return [{ key: "__all", label: null, items }];
|
||||
}
|
||||
|
||||
const groups = new Map<InboxWorkItem["kind"], InboxWorkItem[]>();
|
||||
for (const item of items) {
|
||||
const existing = groups.get(item.kind) ?? [];
|
||||
existing.push(item);
|
||||
groups.set(item.kind, existing);
|
||||
}
|
||||
|
||||
const orderedGroups: InboxWorkItemGroup[] = [];
|
||||
for (const kind of inboxWorkItemKindOrder) {
|
||||
const groupItems = groups.get(kind) ?? [];
|
||||
if (groupItems.length === 0) continue;
|
||||
orderedGroups.push({
|
||||
key: kind,
|
||||
label: inboxWorkItemKindLabels[kind],
|
||||
items: groupItems,
|
||||
});
|
||||
}
|
||||
return orderedGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups parent-child issues in a flat InboxWorkItem list.
|
||||
*
|
||||
|
||||
@@ -370,6 +370,70 @@ describe("buildIssueChatMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("compacts long run transcripts in issue chat while preserving matching tool context", () => {
|
||||
const isoAt = (baseMs: number, offsetSeconds: number) =>
|
||||
new Date(baseMs + offsetSeconds * 1000).toISOString();
|
||||
const baseMs = Date.parse("2026-04-06T12:00:00.000Z");
|
||||
const transcript = [
|
||||
...Array.from({ length: 9 }, (_, index) => ({
|
||||
kind: "assistant" as const,
|
||||
ts: isoAt(baseMs, index),
|
||||
text: `Older update ${index + 1}`,
|
||||
})),
|
||||
{
|
||||
kind: "tool_call" as const,
|
||||
ts: isoAt(baseMs, 9),
|
||||
name: "search",
|
||||
toolUseId: "tool-keep",
|
||||
input: { query: "issue chat virtualization" },
|
||||
},
|
||||
...Array.from({ length: 79 }, (_, index) => ({
|
||||
kind: "assistant" as const,
|
||||
ts: isoAt(baseMs, 10 + index),
|
||||
text: `Recent update ${index + 1}`,
|
||||
})),
|
||||
{
|
||||
kind: "tool_result" as const,
|
||||
ts: isoAt(baseMs, 89),
|
||||
toolUseId: "tool-keep",
|
||||
content: "search completed",
|
||||
isError: false,
|
||||
},
|
||||
];
|
||||
|
||||
const messages = buildIssueChatMessages({
|
||||
comments: [],
|
||||
timelineEvents: [],
|
||||
linkedRuns: [
|
||||
{
|
||||
runId: "run-history-3",
|
||||
status: "succeeded",
|
||||
agentId: "agent-1",
|
||||
agentName: "CodexCoder",
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
startedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
finishedAt: new Date("2026-04-06T12:03:00.000Z"),
|
||||
},
|
||||
],
|
||||
liveRuns: [],
|
||||
transcriptsByRunId: new Map([["run-history-3", transcript]]),
|
||||
hasOutputForRun: (runId) => runId === "run-history-3",
|
||||
currentUserId: "user-1",
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
const textParts = messages[0]?.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text) ?? [];
|
||||
expect(textParts.join("\n")).not.toContain("Older update 1");
|
||||
expect(messages[0]?.content).toContainEqual(expect.objectContaining({
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-keep",
|
||||
toolName: "search",
|
||||
result: "search completed",
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps the same assistant message id when a live run becomes a cancelled historical run", () => {
|
||||
const liveMessages = buildIssueChatMessages({
|
||||
comments: [],
|
||||
|
||||
@@ -32,10 +32,12 @@ export interface IssueChatLinkedRun {
|
||||
runId: string;
|
||||
status: string;
|
||||
agentId: string;
|
||||
adapterType?: string;
|
||||
agentName?: string;
|
||||
createdAt: Date | string;
|
||||
startedAt: Date | string | null;
|
||||
finishedAt?: Date | string | null;
|
||||
hasStoredOutput?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueChatTranscriptEntry {
|
||||
@@ -71,6 +73,8 @@ export interface IssueChatTranscriptEntry {
|
||||
changeType?: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation";
|
||||
}
|
||||
|
||||
const ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES = 30;
|
||||
|
||||
type MessageWithOrder = {
|
||||
createdAtMs: number;
|
||||
order: number;
|
||||
@@ -156,6 +160,62 @@ function formatDiffBlock(lines: string[]) {
|
||||
return `\`\`\`diff\n${lines.join("\n")}\n\`\`\``;
|
||||
}
|
||||
|
||||
function isIssueChatRenderableTranscriptEntry(entry: IssueChatTranscriptEntry) {
|
||||
return entry.kind !== "init"
|
||||
&& entry.kind !== "stderr"
|
||||
&& entry.kind !== "stdout"
|
||||
&& entry.kind !== "system";
|
||||
}
|
||||
|
||||
function compactIssueChatTranscript(
|
||||
entries: readonly IssueChatTranscriptEntry[],
|
||||
maxVisibleEntries = ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES,
|
||||
): readonly IssueChatTranscriptEntry[] {
|
||||
const renderable = entries
|
||||
.map((entry, fullIndex) => ({ entry, fullIndex }))
|
||||
.filter(({ entry }) => isIssueChatRenderableTranscriptEntry(entry));
|
||||
|
||||
if (renderable.length <= maxVisibleEntries) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
let startPos = Math.max(0, renderable.length - maxVisibleEntries);
|
||||
while (
|
||||
startPos > 0
|
||||
&& renderable[startPos]?.entry.kind === "diff"
|
||||
&& renderable[startPos - 1]?.entry.kind === "diff"
|
||||
) {
|
||||
startPos -= 1;
|
||||
}
|
||||
|
||||
const keptRenderablePositions = new Set<number>();
|
||||
for (let pos = startPos; pos < renderable.length; pos += 1) {
|
||||
keptRenderablePositions.add(pos);
|
||||
}
|
||||
|
||||
// Keep the matching tool call when the visible tail starts at a tool result.
|
||||
for (let pos = startPos; pos < renderable.length; pos += 1) {
|
||||
const entry = renderable[pos]?.entry;
|
||||
if (entry?.kind !== "tool_result" || !entry.toolUseId) continue;
|
||||
for (let scan = pos - 1; scan >= 0; scan -= 1) {
|
||||
const candidate = renderable[scan]?.entry;
|
||||
if (candidate?.kind === "tool_call" && candidate.toolUseId === entry.toolUseId) {
|
||||
keptRenderablePositions.add(scan);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const keptFullIndices = new Set<number>();
|
||||
for (const pos of keptRenderablePositions) {
|
||||
const fullIndex = renderable[pos]?.fullIndex;
|
||||
if (fullIndex !== undefined) keptFullIndices.add(fullIndex);
|
||||
}
|
||||
|
||||
const compactedEntries = entries.filter((_entry, index) => keptFullIndices.has(index));
|
||||
return compactedEntries;
|
||||
}
|
||||
|
||||
function createAssistantMetadata(custom: Record<string, unknown>) {
|
||||
return {
|
||||
unstable_state: null,
|
||||
@@ -401,7 +461,8 @@ function createHistoricalTranscriptMessage(args: {
|
||||
}) {
|
||||
const { run, transcript, hasOutput, agentMap } = args;
|
||||
const agentName = run.agentName ?? agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
||||
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
|
||||
const compactedTranscript = compactIssueChatTranscript(transcript);
|
||||
const { parts, notices, segments } = buildAssistantPartsFromTranscript(compactedTranscript);
|
||||
const waitingText = hasOutput ? "" : "Run finished";
|
||||
const content = parts.length > 0
|
||||
? parts
|
||||
@@ -595,7 +656,8 @@ function createLiveRunMessage(args: {
|
||||
transcript: readonly IssueChatTranscriptEntry[];
|
||||
}) {
|
||||
const { run, transcript } = args;
|
||||
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
|
||||
const compactedTranscript = compactIssueChatTranscript(transcript);
|
||||
const { parts, notices, segments } = buildAssistantPartsFromTranscript(compactedTranscript);
|
||||
const waitingText =
|
||||
run.status === "queued"
|
||||
? "Queued..."
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
|
||||
export type IssueFilterState = {
|
||||
statuses: string[];
|
||||
priorities: string[];
|
||||
assignees: string[];
|
||||
labels: string[];
|
||||
projects: string[];
|
||||
showRoutineExecutions: boolean;
|
||||
};
|
||||
|
||||
export const defaultIssueFilterState: IssueFilterState = {
|
||||
statuses: [],
|
||||
priorities: [],
|
||||
assignees: [],
|
||||
labels: [],
|
||||
projects: [],
|
||||
showRoutineExecutions: false,
|
||||
};
|
||||
|
||||
export const issueStatusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
||||
export const issuePriorityOrder = ["critical", "high", "medium", "low"];
|
||||
|
||||
export const issueQuickFilterPresets = [
|
||||
{ label: "All", statuses: [] as string[] },
|
||||
{ label: "Active", statuses: ["todo", "in_progress", "in_review", "blocked"] },
|
||||
{ label: "Backlog", statuses: ["backlog"] },
|
||||
{ label: "Done", statuses: ["done", "cancelled"] },
|
||||
];
|
||||
|
||||
export function issueFilterLabel(value: string): string {
|
||||
return value.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
export function issueFilterArraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return sortedA.every((value, index) => value === sortedB[index]);
|
||||
}
|
||||
|
||||
export function toggleIssueFilterValue(values: string[], value: string): string[] {
|
||||
return values.includes(value) ? values.filter((existing) => existing !== value) : [...values, value];
|
||||
}
|
||||
|
||||
export function applyIssueFilters(
|
||||
issues: Issue[],
|
||||
state: IssueFilterState,
|
||||
currentUserId?: string | null,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
): Issue[] {
|
||||
let result = issues;
|
||||
if (enableRoutineVisibilityFilter && !state.showRoutineExecutions) {
|
||||
result = result.filter((issue) => issue.originKind !== "routine_execution");
|
||||
}
|
||||
if (state.statuses.length > 0) result = result.filter((issue) => state.statuses.includes(issue.status));
|
||||
if (state.priorities.length > 0) result = result.filter((issue) => state.priorities.includes(issue.priority));
|
||||
if (state.assignees.length > 0) {
|
||||
result = result.filter((issue) => {
|
||||
for (const assignee of state.assignees) {
|
||||
if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true;
|
||||
if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true;
|
||||
if (issue.assigneeAgentId === assignee) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
if (state.labels.length > 0) {
|
||||
result = result.filter((issue) => (issue.labelIds ?? []).some((id) => state.labels.includes(id)));
|
||||
}
|
||||
if (state.projects.length > 0) {
|
||||
result = result.filter((issue) => issue.projectId != null && state.projects.includes(issue.projectId));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function countActiveIssueFilters(
|
||||
state: IssueFilterState,
|
||||
enableRoutineVisibilityFilter = false,
|
||||
): number {
|
||||
let count = 0;
|
||||
if (state.statuses.length > 0) count += 1;
|
||||
if (state.priorities.length > 0) count += 1;
|
||||
if (state.assignees.length > 0) count += 1;
|
||||
if (state.labels.length > 0) count += 1;
|
||||
if (state.projects.length > 0) count += 1;
|
||||
if (enableRoutineVisibilityFilter && state.showRoutineExecutions) count += 1;
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveIssueChatTranscriptRuns } from "./issueChatTranscriptRuns";
|
||||
|
||||
describe("resolveIssueChatTranscriptRuns", () => {
|
||||
it("uses adapterType from linked runs without requiring agent metadata", () => {
|
||||
const runs = resolveIssueChatTranscriptRuns({
|
||||
linkedRuns: [
|
||||
{
|
||||
runId: "run-1",
|
||||
status: "succeeded",
|
||||
agentId: "agent-1",
|
||||
adapterType: "codex_local",
|
||||
createdAt: "2026-04-09T12:00:00.000Z",
|
||||
startedAt: "2026-04-09T12:00:00.000Z",
|
||||
finishedAt: "2026-04-09T12:01:00.000Z",
|
||||
hasStoredOutput: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(runs).toEqual([
|
||||
{
|
||||
id: "run-1",
|
||||
status: "succeeded",
|
||||
adapterType: "codex_local",
|
||||
hasStoredOutput: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||
import type { RunTranscriptSource } from "../components/transcript/useLiveRunTranscripts";
|
||||
import type { IssueChatLinkedRun } from "./issue-chat-messages";
|
||||
|
||||
export function resolveIssueChatTranscriptRuns(args: {
|
||||
linkedRuns?: readonly IssueChatLinkedRun[];
|
||||
liveRuns?: readonly LiveRunForIssue[];
|
||||
activeRun?: ActiveRunForIssue | null;
|
||||
}): RunTranscriptSource[] {
|
||||
const { linkedRuns = [], liveRuns = [], activeRun = null } = args;
|
||||
const combined = new Map<string, RunTranscriptSource>();
|
||||
|
||||
for (const run of liveRuns) {
|
||||
combined.set(run.id, {
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
adapterType: run.adapterType,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeRun) {
|
||||
combined.set(activeRun.id, {
|
||||
id: activeRun.id,
|
||||
status: activeRun.status,
|
||||
adapterType: activeRun.adapterType,
|
||||
});
|
||||
}
|
||||
|
||||
for (const run of linkedRuns) {
|
||||
if (combined.has(run.runId)) continue;
|
||||
const adapterType = run.adapterType;
|
||||
if (!adapterType) continue;
|
||||
combined.set(run.runId, {
|
||||
id: run.runId,
|
||||
status: run.status,
|
||||
adapterType,
|
||||
hasStoredOutput: run.hasStoredOutput,
|
||||
});
|
||||
}
|
||||
|
||||
return [...combined.values()];
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import {
|
||||
createIssueDetailLocationState,
|
||||
createIssueDetailPath,
|
||||
hasLegacyIssueDetailQuery,
|
||||
readIssueDetailHeaderSeed,
|
||||
readIssueDetailLocationState,
|
||||
readIssueDetailBreadcrumb,
|
||||
rememberIssueDetailLocationState,
|
||||
shouldArmIssueDetailInboxQuickArchive,
|
||||
withIssueDetailHeaderSeed,
|
||||
} from "./issueDetailBreadcrumb";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
|
||||
const sessionStorageMock = (() => {
|
||||
const store = new Map<string, string>();
|
||||
@@ -29,6 +32,91 @@ Object.defineProperty(globalThis, "window", {
|
||||
});
|
||||
|
||||
describe("issueDetailBreadcrumb", () => {
|
||||
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title: "Prefilled issue title",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: 42,
|
||||
identifier: "PAP-42",
|
||||
originKind: "manual",
|
||||
originId: null,
|
||||
originRunId: null,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
project: {
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
urlKey: "paperclip-app",
|
||||
goalId: null,
|
||||
goalIds: [],
|
||||
goals: [],
|
||||
name: "Paperclip App",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
codebase: {
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
repoName: null,
|
||||
localFolder: null,
|
||||
managedFolder: "/tmp/paperclip-app",
|
||||
effectiveLocalFolder: "/tmp/paperclip-app",
|
||||
origin: "local_folder",
|
||||
},
|
||||
workspaces: [],
|
||||
primaryWorkspace: null,
|
||||
archivedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
goal: null,
|
||||
currentExecutionWorkspace: null,
|
||||
workProducts: [],
|
||||
mentionedProjects: [],
|
||||
myLastTouchAt: null,
|
||||
lastExternalCommentAt: null,
|
||||
lastActivityAt: null,
|
||||
isUnreadForMe: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("returns clean issue detail paths", () => {
|
||||
expect(createIssueDetailPath("PAP-465")).toBe("/issues/PAP-465");
|
||||
});
|
||||
@@ -77,6 +165,48 @@ describe("issueDetailBreadcrumb", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches and reads issue header seed data from route state", () => {
|
||||
const seededState = withIssueDetailHeaderSeed(
|
||||
createIssueDetailLocationState("Issues", "/issues", "issues"),
|
||||
createIssue(),
|
||||
);
|
||||
|
||||
expect(readIssueDetailHeaderSeed(seededState)).toEqual({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
identifier: "PAP-42",
|
||||
title: "Prefilled issue title",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
projectId: "project-1",
|
||||
projectName: "Paperclip App",
|
||||
originKind: "manual",
|
||||
originId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("persists issue header seed data when breadcrumb state is remembered", () => {
|
||||
const seededState = withIssueDetailHeaderSeed(
|
||||
createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"),
|
||||
createIssue(),
|
||||
);
|
||||
|
||||
sessionStorageMock.clear();
|
||||
rememberIssueDetailLocationState("PAP-42", seededState);
|
||||
|
||||
const restoredState = readIssueDetailLocationState("PAP-42", null);
|
||||
expect(readIssueDetailHeaderSeed(restoredState)).toEqual({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
identifier: "PAP-42",
|
||||
title: "Prefilled issue title",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
projectId: "project-1",
|
||||
projectName: "Paperclip App",
|
||||
originKind: "manual",
|
||||
originId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("can arm quick archive only for explicit inbox keyboard entry state", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
|
||||
type IssueDetailSource = "issues" | "inbox";
|
||||
|
||||
type IssueDetailBreadcrumb = {
|
||||
@@ -5,10 +7,23 @@ type IssueDetailBreadcrumb = {
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type IssueDetailHeaderSeed = {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: Issue["status"];
|
||||
priority: Issue["priority"];
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
originKind?: Issue["originKind"];
|
||||
originId?: string | null;
|
||||
};
|
||||
|
||||
type IssueDetailLocationState = {
|
||||
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
|
||||
issueDetailSource?: IssueDetailSource;
|
||||
issueDetailInboxQuickArchiveArmed?: boolean;
|
||||
issueDetailHeaderSeed?: IssueDetailHeaderSeed;
|
||||
};
|
||||
|
||||
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
|
||||
@@ -25,6 +40,58 @@ function isIssueDetailSource(value: unknown): value is IssueDetailSource {
|
||||
return value === "issues" || value === "inbox";
|
||||
}
|
||||
|
||||
function isIssueDetailHeaderSeed(value: unknown): value is IssueDetailHeaderSeed {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as Partial<IssueDetailHeaderSeed>;
|
||||
const hasOriginKind =
|
||||
candidate.originKind === undefined || typeof candidate.originKind === "string";
|
||||
const hasOriginId =
|
||||
candidate.originId === undefined || candidate.originId === null || typeof candidate.originId === "string";
|
||||
return (
|
||||
typeof candidate.id === "string"
|
||||
&& (candidate.identifier === null || typeof candidate.identifier === "string")
|
||||
&& typeof candidate.title === "string"
|
||||
&& typeof candidate.status === "string"
|
||||
&& typeof candidate.priority === "string"
|
||||
&& (candidate.projectId === null || typeof candidate.projectId === "string")
|
||||
&& (candidate.projectName === null || typeof candidate.projectName === "string")
|
||||
&& hasOriginKind
|
||||
&& hasOriginId
|
||||
);
|
||||
}
|
||||
|
||||
function createIssueDetailHeaderSeed(issue: Issue): IssueDetailHeaderSeed {
|
||||
return {
|
||||
id: issue.id,
|
||||
identifier: issue.identifier ?? null,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
projectId: issue.projectId ?? null,
|
||||
projectName: issue.project?.name ?? null,
|
||||
originKind: issue.originKind,
|
||||
originId: issue.originId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function withIssueDetailHeaderSeed(state: unknown, issue: Issue): IssueDetailLocationState {
|
||||
const headerSeed = createIssueDetailHeaderSeed(issue);
|
||||
if (typeof state !== "object" || state === null) {
|
||||
return { issueDetailHeaderSeed: headerSeed };
|
||||
}
|
||||
|
||||
return {
|
||||
...(state as IssueDetailLocationState),
|
||||
issueDetailHeaderSeed: headerSeed,
|
||||
};
|
||||
}
|
||||
|
||||
export function readIssueDetailHeaderSeed(state: unknown): IssueDetailHeaderSeed | null {
|
||||
if (typeof state !== "object" || state === null) return null;
|
||||
const candidate = (state as IssueDetailLocationState).issueDetailHeaderSeed;
|
||||
return isIssueDetailHeaderSeed(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
function readIssueDetailSource(state: unknown): IssueDetailSource | null {
|
||||
if (typeof state !== "object" || state === null) return null;
|
||||
const source = (state as IssueDetailLocationState).issueDetailSource;
|
||||
@@ -96,10 +163,14 @@ function readStoredIssueDetailLocationState(issuePathId: string): IssueDetailLoc
|
||||
: null;
|
||||
const source = inferIssueDetailSource(parsed, breadcrumb);
|
||||
if (!breadcrumb || !source) return null;
|
||||
const headerSeed = isIssueDetailHeaderSeed(parsed.issueDetailHeaderSeed)
|
||||
? parsed.issueDetailHeaderSeed
|
||||
: undefined;
|
||||
return {
|
||||
issueDetailBreadcrumb: breadcrumb,
|
||||
issueDetailSource: source,
|
||||
issueDetailInboxQuickArchiveArmed: parsed.issueDetailInboxQuickArchiveArmed === true,
|
||||
issueDetailHeaderSeed: headerSeed,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -115,11 +186,13 @@ function normalizeIssueDetailLocationState(
|
||||
if (isIssueDetailBreadcrumb(candidate)) {
|
||||
const source = inferIssueDetailSource(state as Partial<IssueDetailLocationState>, candidate);
|
||||
if (!source) return null;
|
||||
const headerSeed = readIssueDetailHeaderSeed(state) ?? undefined;
|
||||
return {
|
||||
issueDetailBreadcrumb: candidate,
|
||||
issueDetailSource: source,
|
||||
issueDetailInboxQuickArchiveArmed:
|
||||
(state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true,
|
||||
issueDetailHeaderSeed: headerSeed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type SerializedLinkNode,
|
||||
} from "@lexical/link";
|
||||
|
||||
const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//;
|
||||
const CUSTOM_MENTION_URL_RE = /^(agent|project|skill):\/\//;
|
||||
|
||||
export class MentionAwareLinkNode extends LinkNode {
|
||||
static getType(): string {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resetNavigationScroll,
|
||||
SIDEBAR_SCROLL_RESET_STATE,
|
||||
shouldResetScrollOnNavigation,
|
||||
} from "./navigation-scroll";
|
||||
|
||||
describe("navigation-scroll", () => {
|
||||
it("resets scroll only for flagged sidebar navigation", () => {
|
||||
expect(
|
||||
shouldResetScrollOnNavigation({
|
||||
previousPathname: "/issues",
|
||||
pathname: "/dashboard",
|
||||
navigationType: "PUSH",
|
||||
state: SIDEBAR_SCROLL_RESET_STATE,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldResetScrollOnNavigation({
|
||||
previousPathname: "/issues",
|
||||
pathname: "/dashboard",
|
||||
navigationType: "PUSH",
|
||||
state: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves scroll restoration for browser history navigation even for sidebar entries", () => {
|
||||
expect(
|
||||
shouldResetScrollOnNavigation({
|
||||
previousPathname: "/issues",
|
||||
pathname: "/dashboard",
|
||||
navigationType: "POP",
|
||||
state: SIDEBAR_SCROLL_RESET_STATE,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reset scroll on the initial render or when the pathname is unchanged", () => {
|
||||
expect(
|
||||
shouldResetScrollOnNavigation({
|
||||
previousPathname: null,
|
||||
pathname: "/dashboard",
|
||||
navigationType: "PUSH",
|
||||
state: SIDEBAR_SCROLL_RESET_STATE,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldResetScrollOnNavigation({
|
||||
previousPathname: "/dashboard",
|
||||
pathname: "/dashboard",
|
||||
navigationType: "REPLACE",
|
||||
state: SIDEBAR_SCROLL_RESET_STATE,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resets both the main content container and page scroll state", () => {
|
||||
const main = document.createElement("main");
|
||||
main.scrollTop = 180;
|
||||
main.scrollLeft = 14;
|
||||
main.scrollTo = vi.fn();
|
||||
document.body.appendChild(main);
|
||||
|
||||
document.documentElement.scrollTop = 240;
|
||||
document.documentElement.scrollLeft = 9;
|
||||
document.body.scrollTop = 120;
|
||||
document.body.scrollLeft = 7;
|
||||
const windowScrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
|
||||
resetNavigationScroll(main);
|
||||
|
||||
expect(main.scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: "auto" });
|
||||
expect(main.scrollTop).toBe(0);
|
||||
expect(main.scrollLeft).toBe(0);
|
||||
expect(document.documentElement.scrollTop).toBe(0);
|
||||
expect(document.documentElement.scrollLeft).toBe(0);
|
||||
expect(document.body.scrollTop).toBe(0);
|
||||
expect(document.body.scrollLeft).toBe(0);
|
||||
expect(windowScrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: "auto" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export type NavigationType = "POP" | "PUSH" | "REPLACE";
|
||||
|
||||
export const SIDEBAR_SCROLL_RESET_STATE = {
|
||||
paperclipSidebarScrollReset: true,
|
||||
} as const;
|
||||
|
||||
export function shouldResetScrollOnNavigation(params: {
|
||||
previousPathname: string | null;
|
||||
pathname: string;
|
||||
navigationType: NavigationType;
|
||||
state: unknown;
|
||||
}): boolean {
|
||||
const { previousPathname, pathname, navigationType, state } = params;
|
||||
if (previousPathname === null) return false;
|
||||
if (previousPathname === pathname) return false;
|
||||
if (navigationType === "POP") return false;
|
||||
return hasSidebarScrollResetState(state);
|
||||
}
|
||||
|
||||
export function resetNavigationScroll(mainElement: HTMLElement | null): void {
|
||||
mainElement?.scrollTo?.({ top: 0, left: 0, behavior: "auto" });
|
||||
|
||||
if (mainElement) {
|
||||
mainElement.scrollTop = 0;
|
||||
mainElement.scrollLeft = 0;
|
||||
}
|
||||
|
||||
const scrollingElement = document.scrollingElement ?? document.documentElement;
|
||||
if (scrollingElement) {
|
||||
scrollingElement.scrollTop = 0;
|
||||
scrollingElement.scrollLeft = 0;
|
||||
}
|
||||
|
||||
if (document.body) {
|
||||
document.body.scrollTop = 0;
|
||||
document.body.scrollLeft = 0;
|
||||
}
|
||||
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
}
|
||||
|
||||
function hasSidebarScrollResetState(state: unknown): boolean {
|
||||
if (!state || typeof state !== "object") return false;
|
||||
return (state as Record<string, unknown>).paperclipSidebarScrollReset === true;
|
||||
}
|
||||
@@ -66,6 +66,7 @@ describe("upsertInterruptedRun", () => {
|
||||
runId: "run-1",
|
||||
status: "cancelled",
|
||||
agentId: "agent-1",
|
||||
adapterType: "codex_local",
|
||||
startedAt: "2026-04-08T21:00:00.000Z",
|
||||
finishedAt: "2026-04-08T21:00:10.000Z",
|
||||
createdAt: "2026-04-08T21:00:00.000Z",
|
||||
@@ -80,6 +81,7 @@ describe("upsertInterruptedRun", () => {
|
||||
runId: "run-1",
|
||||
status: "running",
|
||||
agentId: "agent-1",
|
||||
adapterType: "codex_local",
|
||||
startedAt: "2026-04-08T21:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-08T21:00:00.000Z",
|
||||
@@ -93,6 +95,7 @@ describe("upsertInterruptedRun", () => {
|
||||
runId: "run-1",
|
||||
status: "cancelled",
|
||||
agentId: "agent-1",
|
||||
adapterType: "codex_local",
|
||||
startedAt: "2026-04-08T21:00:00.000Z",
|
||||
finishedAt: "2026-04-08T21:00:11.000Z",
|
||||
createdAt: "2026-04-08T21:00:00.000Z",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||
export interface InterruptRunSource {
|
||||
id: string;
|
||||
agentId: string;
|
||||
adapterType: string;
|
||||
startedAt: Date | string | null;
|
||||
createdAt: Date | string;
|
||||
invocationSource: string;
|
||||
@@ -30,6 +31,7 @@ export function upsertInterruptedRun(
|
||||
runId: run.id,
|
||||
status: "cancelled",
|
||||
agentId: run.agentId,
|
||||
adapterType: run.adapterType,
|
||||
startedAt: toIsoString(run.startedAt),
|
||||
finishedAt,
|
||||
createdAt: toIsoString(run.createdAt) ?? finishedAt,
|
||||
|
||||
+437
-273
@@ -18,11 +18,18 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useGeneralSettings } from "../context/GeneralSettingsContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
applyIssueFilters,
|
||||
countActiveIssueFilters,
|
||||
defaultIssueFilterState,
|
||||
type IssueFilterState,
|
||||
} from "../lib/issue-filters";
|
||||
import {
|
||||
armIssueDetailInboxQuickArchive,
|
||||
createIssueDetailLocationState,
|
||||
createIssueDetailPath,
|
||||
rememberIssueDetailLocationState,
|
||||
withIssueDetailHeaderSeed,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
@@ -34,6 +41,7 @@ import {
|
||||
issueActivityText,
|
||||
issueTrailingColumns,
|
||||
} from "../components/IssueColumns";
|
||||
import { IssueFiltersPopover } from "../components/IssueFiltersPopover";
|
||||
import { IssueRow } from "../components/IssueRow";
|
||||
import { SwipeToArchive } from "../components/SwipeToArchive";
|
||||
|
||||
@@ -60,10 +68,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Inbox as InboxIcon,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Layers,
|
||||
XCircle,
|
||||
X,
|
||||
RotateCcw,
|
||||
@@ -84,22 +95,26 @@ import {
|
||||
getInboxKeyboardSelectionIndex,
|
||||
getLatestFailedRunsByAgent,
|
||||
getRecentTouchedIssues,
|
||||
groupInboxWorkItems,
|
||||
isInboxEntityDismissed,
|
||||
isMineInboxTab,
|
||||
loadInboxIssueColumns,
|
||||
loadInboxNesting,
|
||||
loadInboxWorkItemGroupBy,
|
||||
normalizeInboxIssueColumns,
|
||||
resolveInboxNestingEnabled,
|
||||
resolveIssueWorkspaceName,
|
||||
resolveInboxSelectionIndex,
|
||||
saveInboxIssueColumns,
|
||||
saveInboxNesting,
|
||||
InboxApprovalFilter,
|
||||
saveInboxWorkItemGroupBy,
|
||||
type InboxApprovalFilter,
|
||||
type InboxIssueColumn,
|
||||
saveLastInboxTab,
|
||||
shouldShowInboxSection,
|
||||
type InboxTab,
|
||||
type InboxWorkItem,
|
||||
type InboxWorkItemGroupBy,
|
||||
} from "../lib/inbox";
|
||||
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
|
||||
|
||||
@@ -121,6 +136,13 @@ type NavEntry =
|
||||
| { type: "top"; index: number; item: InboxWorkItem }
|
||||
| { type: "child"; parentIndex: number; issue: Issue };
|
||||
|
||||
type InboxGroupedSection = {
|
||||
key: string;
|
||||
label: string | null;
|
||||
displayItems: InboxWorkItem[];
|
||||
childrenByIssueId: Map<string, Issue[]>;
|
||||
};
|
||||
|
||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
||||
@@ -596,6 +618,8 @@ export function Inbox() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
|
||||
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
|
||||
const [issueFilters, setIssueFilters] = useState<IssueFilterState>(defaultIssueFilterState);
|
||||
const [groupBy, setGroupBy] = useState<InboxWorkItemGroupBy>(() => loadInboxWorkItemGroupBy());
|
||||
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
|
||||
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
|
||||
@@ -633,6 +657,11 @@ export function Inbox() {
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: labels } = useQuery({
|
||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.listLabels(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
const { data: executionWorkspaces = [] } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
@@ -688,20 +717,21 @@ export function Inbox() {
|
||||
});
|
||||
|
||||
const { data: issues, isLoading: isIssuesLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "with-routine-executions"],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { includeRoutineExecutions: true }),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const {
|
||||
data: mineIssuesRaw = [],
|
||||
isLoading: isMineIssuesLoading,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.issues.listMineByMe(selectedCompanyId!),
|
||||
queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "with-routine-executions"],
|
||||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
touchedByUserId: "me",
|
||||
inboxArchivedByUserId: "me",
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
includeRoutineExecutions: true,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
@@ -709,11 +739,12 @@ export function Inbox() {
|
||||
data: touchedIssuesRaw = [],
|
||||
isLoading: isTouchedIssuesLoading,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId!),
|
||||
queryKey: [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "with-routine-executions"],
|
||||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
touchedByUserId: "me",
|
||||
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
includeRoutineExecutions: true,
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
@@ -723,20 +754,29 @@ export function Inbox() {
|
||||
queryFn: () => heartbeatsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
||||
|
||||
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
|
||||
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
|
||||
const visibleMineIssues = useMemo(
|
||||
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true),
|
||||
[mineIssues, issueFilters, currentUserId],
|
||||
);
|
||||
const visibleTouchedIssues = useMemo(
|
||||
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true),
|
||||
[touchedIssues, issueFilters, currentUserId],
|
||||
);
|
||||
const unreadTouchedIssues = useMemo(
|
||||
() => touchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||
[touchedIssues],
|
||||
() => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe),
|
||||
[visibleTouchedIssues],
|
||||
);
|
||||
const issuesToRender = useMemo(
|
||||
() => {
|
||||
if (tab === "mine") return mineIssues;
|
||||
if (tab === "mine") return visibleMineIssues;
|
||||
if (tab === "unread") return unreadTouchedIssues;
|
||||
return touchedIssues;
|
||||
return visibleTouchedIssues;
|
||||
},
|
||||
[tab, mineIssues, touchedIssues, unreadTouchedIssues],
|
||||
[tab, visibleMineIssues, visibleTouchedIssues, unreadTouchedIssues],
|
||||
);
|
||||
|
||||
const agentById = useMemo(() => {
|
||||
@@ -802,7 +842,6 @@ export function Inbox() {
|
||||
() => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
|
||||
[availableIssueColumnSet, visibleIssueColumnSet],
|
||||
);
|
||||
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
|
||||
|
||||
const failedRuns = useMemo(
|
||||
() =>
|
||||
@@ -935,11 +974,36 @@ export function Inbox() {
|
||||
});
|
||||
}, []);
|
||||
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(new Set());
|
||||
const { displayItems: nestedWorkItems, childrenByIssueId } = useMemo(
|
||||
() => nestingEnabled
|
||||
? buildInboxNesting(filteredWorkItems)
|
||||
: { displayItems: filteredWorkItems, childrenByIssueId: new Map<string, Issue[]>() },
|
||||
[filteredWorkItems, nestingEnabled],
|
||||
const groupedSections = useMemo<InboxGroupedSection[]>(() => {
|
||||
return groupInboxWorkItems(filteredWorkItems, groupBy).map((group) => {
|
||||
const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
|
||||
? buildInboxNesting(group.items)
|
||||
: { displayItems: group.items, childrenByIssueId: new Map<string, Issue[]>() };
|
||||
|
||||
return {
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
displayItems: nestedGroup.displayItems,
|
||||
childrenByIssueId: nestedGroup.childrenByIssueId,
|
||||
};
|
||||
});
|
||||
}, [filteredWorkItems, groupBy, nestingEnabled]);
|
||||
const nestedWorkItems = useMemo(
|
||||
() => groupedSections.flatMap((group) => group.displayItems),
|
||||
[groupedSections],
|
||||
);
|
||||
const childrenByIssueId = useMemo(() => {
|
||||
const merged = new Map<string, Issue[]>();
|
||||
for (const group of groupedSections) {
|
||||
for (const [issueId, children] of group.childrenByIssueId) {
|
||||
merged.set(issueId, children);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}, [groupedSections]);
|
||||
const totalVisibleWorkItems = useMemo(
|
||||
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
|
||||
[groupedSections],
|
||||
);
|
||||
const toggleInboxParentCollapse = useCallback((parentId: string) => {
|
||||
setCollapsedInboxParents((prev) => {
|
||||
@@ -953,21 +1017,24 @@ export function Inbox() {
|
||||
// Build flat navigation list including expanded children for keyboard traversal
|
||||
const flatNavItems = useMemo((): NavEntry[] => {
|
||||
const entries: NavEntry[] = [];
|
||||
for (let i = 0; i < nestedWorkItems.length; i++) {
|
||||
const item = nestedWorkItems[i];
|
||||
entries.push({ type: "top", index: i, item });
|
||||
if (item.kind === "issue") {
|
||||
const children = childrenByIssueId.get(item.issue.id);
|
||||
const isExpanded = children?.length && !collapsedInboxParents.has(item.issue.id);
|
||||
if (isExpanded) {
|
||||
for (const child of children) {
|
||||
entries.push({ type: "child", parentIndex: i, issue: child });
|
||||
let topIndex = 0;
|
||||
for (const group of groupedSections) {
|
||||
for (const item of group.displayItems) {
|
||||
entries.push({ type: "top", index: topIndex, item });
|
||||
if (item.kind === "issue") {
|
||||
const children = group.childrenByIssueId.get(item.issue.id);
|
||||
const isExpanded = children?.length && !collapsedInboxParents.has(item.issue.id);
|
||||
if (isExpanded) {
|
||||
for (const child of children) {
|
||||
entries.push({ type: "child", parentIndex: topIndex, issue: child });
|
||||
}
|
||||
}
|
||||
}
|
||||
topIndex += 1;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}, [nestedWorkItems, childrenByIssueId, collapsedInboxParents]);
|
||||
}, [groupedSections, collapsedInboxParents]);
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
if (!id) return null;
|
||||
@@ -985,6 +1052,13 @@ export function Inbox() {
|
||||
}
|
||||
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
|
||||
}, [setIssueColumns, visibleIssueColumns]);
|
||||
const updateIssueFilters = useCallback((patch: Partial<IssueFilterState>) => {
|
||||
setIssueFilters((previous) => ({ ...previous, ...patch }));
|
||||
}, []);
|
||||
const updateGroupBy = useCallback((nextGroupBy: InboxWorkItemGroupBy) => {
|
||||
setGroupBy(nextGroupBy);
|
||||
saveInboxWorkItemGroupBy(nextGroupBy);
|
||||
}, []);
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: string) => approvalsApi.approve(id),
|
||||
@@ -1101,8 +1175,8 @@ export function Inbox() {
|
||||
|
||||
// Cancel in-flight refetches so they don't overwrite our optimistic update
|
||||
const queryKeys_ = [
|
||||
queryKeys.issues.listMineByMe(selectedCompanyId!),
|
||||
queryKeys.issues.listTouchedByMe(selectedCompanyId!),
|
||||
[...queryKeys.issues.listMineByMe(selectedCompanyId!), "with-routine-executions"],
|
||||
[...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "with-routine-executions"],
|
||||
queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId!),
|
||||
];
|
||||
await Promise.all(queryKeys_.map((qk) => queryClient.cancelQueries({ queryKey: qk })));
|
||||
@@ -1247,7 +1321,7 @@ export function Inbox() {
|
||||
|
||||
// Use refs for keyboard handler to avoid stale closures
|
||||
const kbStateRef = useRef({
|
||||
workItems: nestedWorkItems,
|
||||
workItems: groupedSections,
|
||||
flatNavItems,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
@@ -1257,7 +1331,7 @@ export function Inbox() {
|
||||
readItems,
|
||||
});
|
||||
kbStateRef.current = {
|
||||
workItems: nestedWorkItems,
|
||||
workItems: groupedSections,
|
||||
flatNavItems,
|
||||
selectedIndex,
|
||||
canArchive: canArchiveFromTab,
|
||||
@@ -1386,13 +1460,15 @@ export function Inbox() {
|
||||
const { issue, item } = resolveNavEntry(st.selectedIndex);
|
||||
if (issue) {
|
||||
const pathId = issue.identifier ?? issue.id;
|
||||
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
|
||||
const detailState = armIssueDetailInboxQuickArchive(withIssueDetailHeaderSeed(issueLinkState, issue));
|
||||
rememberIssueDetailLocationState(pathId, detailState);
|
||||
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
||||
} else if (item) {
|
||||
if (item.kind === "issue") {
|
||||
const pathId = item.issue.identifier ?? item.issue.id;
|
||||
const detailState = armIssueDetailInboxQuickArchive(issueLinkState);
|
||||
const detailState = armIssueDetailInboxQuickArchive(
|
||||
withIssueDetailHeaderSeed(issueLinkState, item.issue),
|
||||
);
|
||||
rememberIssueDetailLocationState(pathId, detailState);
|
||||
act.navigate(createIssueDetailPath(pathId), { state: detailState });
|
||||
} else if (item.kind === "approval") {
|
||||
@@ -1435,7 +1511,7 @@ export function Inbox() {
|
||||
dashboard.costs.monthUtilizationPercent >= 80 &&
|
||||
!dismissedAlerts.has("alert:budget");
|
||||
const hasAlerts = showAggregateAgentError || showBudgetAlert;
|
||||
const showWorkItemsSection = nestedWorkItems.length > 0;
|
||||
const showWorkItemsSection = totalVisibleWorkItems > 0;
|
||||
const showAlertsSection = shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems: hasAlerts,
|
||||
@@ -1460,11 +1536,12 @@ export function Inbox() {
|
||||
!isRunsLoading;
|
||||
|
||||
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0;
|
||||
const markAllReadIssues = (tab === "mine" ? mineIssues : unreadTouchedIssues)
|
||||
const markAllReadIssues = (tab === "mine" ? visibleMineIssues : unreadTouchedIssues)
|
||||
.filter((issue) => issue.isUnreadForMe && !fadingOutIssues.has(issue.id) && !archivingIssueIds.has(issue.id));
|
||||
const unreadIssueIds = markAllReadIssues
|
||||
.map((issue) => issue.id);
|
||||
const canMarkAllRead = unreadIssueIds.length > 0;
|
||||
const activeIssueFilterCount = countActiveIssueFilters(issueFilters, true);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
@@ -1518,6 +1595,50 @@ export function Inbox() {
|
||||
>
|
||||
<ListTree className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<IssueFiltersPopover
|
||||
state={issueFilters}
|
||||
onChange={updateIssueFilters}
|
||||
activeFilterCount={activeIssueFilterCount}
|
||||
agents={agents}
|
||||
projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
|
||||
labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||
currentUserId={currentUserId}
|
||||
enableRoutineVisibilityFilter
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("h-8 shrink-0 text-xs", groupBy !== "none" && "bg-accent")}
|
||||
>
|
||||
<Layers className="mr-1.5 h-3.5 w-3.5" />
|
||||
Group
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-40 p-2">
|
||||
<div className="space-y-0.5">
|
||||
{([
|
||||
["none", "None"],
|
||||
["type", "Type"],
|
||||
] as const).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm",
|
||||
groupBy === value ? "bg-accent/50 text-foreground" : "text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => updateGroupBy(value)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<IssueColumnPicker
|
||||
availableColumns={availableIssueColumns}
|
||||
visibleColumnSet={visibleIssueColumnSet}
|
||||
@@ -1633,197 +1754,70 @@ export function Inbox() {
|
||||
<div>
|
||||
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
|
||||
{(() => {
|
||||
// Pre-compute flat nav index for each top-level item and child issue
|
||||
// Pre-compute flat nav index for each top-level item and child issue.
|
||||
let flatIdx = 0;
|
||||
const topFlatIndex = new Map<number, number>();
|
||||
const topFlatIndex = new Map<string, number>();
|
||||
const childFlatIndex = new Map<string, number>();
|
||||
for (let ti = 0; ti < nestedWorkItems.length; ti++) {
|
||||
topFlatIndex.set(ti, flatIdx);
|
||||
flatIdx++;
|
||||
const topItem = nestedWorkItems[ti];
|
||||
if (topItem.kind === "issue") {
|
||||
const children = childrenByIssueId.get(topItem.issue.id);
|
||||
const isExp = children?.length && !collapsedInboxParents.has(topItem.issue.id);
|
||||
if (isExp) {
|
||||
for (const c of children) {
|
||||
childFlatIndex.set(c.id, flatIdx);
|
||||
flatIdx++;
|
||||
for (const group of groupedSections) {
|
||||
for (const topItem of group.displayItems) {
|
||||
const itemKey = `${group.key}:${getWorkItemKey(topItem)}`;
|
||||
topFlatIndex.set(itemKey, flatIdx);
|
||||
flatIdx++;
|
||||
if (topItem.kind === "issue") {
|
||||
const children = group.childrenByIssueId.get(topItem.issue.id);
|
||||
const isExpanded = children?.length && !collapsedInboxParents.has(topItem.issue.id);
|
||||
if (isExpanded) {
|
||||
for (const child of children) {
|
||||
childFlatIndex.set(child.id, flatIdx);
|
||||
flatIdx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nestedWorkItems.flatMap((item, index) => {
|
||||
const navIdx = topFlatIndex.get(index) ?? index;
|
||||
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
|
||||
<div
|
||||
key={`sel-${key}`}
|
||||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(navIdx)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
const todayCutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const showTodayDivider =
|
||||
index > 0 &&
|
||||
item.timestamp > 0 &&
|
||||
item.timestamp < todayCutoff &&
|
||||
nestedWorkItems[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-zinc-600" />
|
||||
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||||
Earlier
|
||||
</span>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
const isSelected = selectedIndex === navIdx;
|
||||
|
||||
if (item.kind === "approval") {
|
||||
const approvalKey = `approval:${item.approval.id}`;
|
||||
const isArchiving = archivingNonIssueIds.has(approvalKey);
|
||||
const row = (
|
||||
<ApprovalInboxRow
|
||||
key={approvalKey}
|
||||
approval={item.approval}
|
||||
selected={isSelected}
|
||||
requesterName={agentName(item.approval.requestedByAgentId)}
|
||||
onApprove={() => approveMutation.mutate(item.approval.id)}
|
||||
onReject={() => rejectMutation.mutate(item.approval.id)}
|
||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||
unreadState={nonIssueUnreadState(approvalKey)}
|
||||
onMarkRead={() => handleMarkNonIssueRead(approvalKey)}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
className={
|
||||
isArchiving
|
||||
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
||||
: "transition-all duration-200 ease-out"
|
||||
}
|
||||
/>
|
||||
);
|
||||
elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={approvalKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(approvalKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row));
|
||||
return elements;
|
||||
}
|
||||
|
||||
if (item.kind === "failed_run") {
|
||||
const runKey = `run:${item.run.id}`;
|
||||
const isArchiving = archivingNonIssueIds.has(runKey);
|
||||
const row = (
|
||||
<FailedRunInboxRow
|
||||
key={runKey}
|
||||
run={item.run}
|
||||
selected={isSelected}
|
||||
issueById={issueById}
|
||||
agentName={agentName(item.run.agentId)}
|
||||
issueLinkState={issueLinkState}
|
||||
onDismiss={() => dismissInboxItem(runKey)}
|
||||
onRetry={() => retryRunMutation.mutate(item.run)}
|
||||
isRetrying={retryingRunIds.has(item.run.id)}
|
||||
unreadState={nonIssueUnreadState(runKey)}
|
||||
onMarkRead={() => handleMarkNonIssueRead(runKey)}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
className={
|
||||
isArchiving
|
||||
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
||||
: "transition-all duration-200 ease-out"
|
||||
}
|
||||
/>
|
||||
);
|
||||
elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={runKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(runKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row));
|
||||
return elements;
|
||||
}
|
||||
|
||||
if (item.kind === "join_request") {
|
||||
const joinKey = `join:${item.joinRequest.id}`;
|
||||
const isArchiving = archivingNonIssueIds.has(joinKey);
|
||||
const row = (
|
||||
<JoinRequestInboxRow
|
||||
key={joinKey}
|
||||
joinRequest={item.joinRequest}
|
||||
selected={isSelected}
|
||||
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
|
||||
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
|
||||
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
||||
unreadState={nonIssueUnreadState(joinKey)}
|
||||
onMarkRead={() => handleMarkNonIssueRead(joinKey)}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
className={
|
||||
isArchiving
|
||||
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
||||
: "transition-all duration-200 ease-out"
|
||||
}
|
||||
/>
|
||||
);
|
||||
elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={joinKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(joinKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row));
|
||||
return elements;
|
||||
}
|
||||
|
||||
const issue = item.issue;
|
||||
const childIssues = childrenByIssueId.get(issue.id) ?? [];
|
||||
const hasChildren = childIssues.length > 0;
|
||||
const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id);
|
||||
|
||||
const renderInboxIssue = (iss: Issue, depth: number, sel: boolean) => {
|
||||
const isUnread = iss.isUnreadForMe && !fadingOutIssues.has(iss.id);
|
||||
const isFading = fadingOutIssues.has(iss.id);
|
||||
const isArch = archivingIssueIds.has(iss.id);
|
||||
const proj = iss.projectId ? projectById.get(iss.projectId) ?? null : null;
|
||||
const renderInboxIssue = ({
|
||||
issue,
|
||||
depth,
|
||||
selected,
|
||||
hasChildren = false,
|
||||
isExpanded = false,
|
||||
childCount = 0,
|
||||
collapseParentId = null,
|
||||
}: {
|
||||
issue: Issue;
|
||||
depth: number;
|
||||
selected: boolean;
|
||||
hasChildren?: boolean;
|
||||
isExpanded?: boolean;
|
||||
childCount?: number;
|
||||
collapseParentId?: string | null;
|
||||
}) => {
|
||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||
const isFading = fadingOutIssues.has(issue.id);
|
||||
const isArchiving = archivingIssueIds.has(issue.id);
|
||||
const project = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
||||
return (
|
||||
<IssueRow
|
||||
key={`issue:${iss.id}`}
|
||||
issue={iss}
|
||||
key={`issue:${issue.id}`}
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
selected={sel}
|
||||
selected={selected}
|
||||
className={
|
||||
isArch
|
||||
isArchiving
|
||||
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
||||
: "transition-all duration-200 ease-out"
|
||||
}
|
||||
desktopMetaLeading={
|
||||
<>
|
||||
{nestingEnabled ? (
|
||||
depth === 0 && hasChildren ? (
|
||||
depth === 0 && hasChildren && collapseParentId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="hidden w-4 shrink-0 items-center justify-center sm:inline-flex"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleInboxParentCollapse(issue.id);
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleInboxParentCollapse(collapseParentId);
|
||||
}}
|
||||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
@@ -1832,12 +1826,10 @@ export function Inbox() {
|
||||
<span className="hidden w-4 shrink-0 sm:block" />
|
||||
)
|
||||
) : null}
|
||||
{depth > 0 ? (
|
||||
<span className="hidden w-4 shrink-0 sm:block" />
|
||||
) : null}
|
||||
{depth > 0 ? <span className="hidden w-4 shrink-0 sm:block" /> : null}
|
||||
<InboxIssueMetaLeading
|
||||
issue={iss}
|
||||
isLive={liveIssueIds.has(iss.id)}
|
||||
issue={issue}
|
||||
isLive={liveIssueIds.has(issue.id)}
|
||||
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
|
||||
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
|
||||
/>
|
||||
@@ -1845,47 +1837,44 @@ export function Inbox() {
|
||||
}
|
||||
titleSuffix={hasChildren && !isExpanded && depth === 0 ? (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
({childIssues.length} sub-task{childIssues.length !== 1 ? "s" : ""})
|
||||
({childCount} sub-task{childCount !== 1 ? "s" : ""})
|
||||
</span>
|
||||
) : undefined}
|
||||
mobileMeta={issueActivityText(iss).toLowerCase()}
|
||||
mobileMeta={issueActivityText(issue).toLowerCase()}
|
||||
mobileLeading={
|
||||
depth === 0 && hasChildren ? (
|
||||
<button type="button" onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleInboxParentCollapse(issue.id);
|
||||
}}>
|
||||
depth === 0 && hasChildren && collapseParentId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleInboxParentCollapse(collapseParentId);
|
||||
}}
|
||||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
unreadState={
|
||||
isUnread ? "visible" : isFading ? "fading" : "hidden"
|
||||
}
|
||||
onMarkRead={() => markReadMutation.mutate(iss.id)}
|
||||
onArchive={
|
||||
canArchiveFromTab
|
||||
? () => archiveIssueMutation.mutate(iss.id)
|
||||
: undefined
|
||||
}
|
||||
archiveDisabled={isArch || archiveIssueMutation.isPending}
|
||||
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
||||
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||
onArchive={canArchiveFromTab ? () => archiveIssueMutation.mutate(issue.id) : undefined}
|
||||
archiveDisabled={isArchiving || archiveIssueMutation.isPending}
|
||||
desktopTrailing={
|
||||
visibleTrailingIssueColumns.length > 0 ? (
|
||||
<InboxIssueTrailingColumns
|
||||
issue={iss}
|
||||
issue={issue}
|
||||
columns={visibleTrailingIssueColumns}
|
||||
projectName={proj?.name ?? null}
|
||||
projectColor={proj?.color ?? null}
|
||||
workspaceName={resolveIssueWorkspaceName(iss, {
|
||||
projectName={project?.name ?? null}
|
||||
projectColor={project?.color ?? null}
|
||||
workspaceName={resolveIssueWorkspaceName(issue, {
|
||||
executionWorkspaceById,
|
||||
projectWorkspaceById,
|
||||
defaultProjectWorkspaceIdByProjectId,
|
||||
})}
|
||||
assigneeName={agentName(iss.assigneeAgentId)}
|
||||
assigneeName={agentName(issue.assigneeAgentId)}
|
||||
currentUserId={currentUserId}
|
||||
parentIdentifier={iss.parentId ? (issueById.get(iss.parentId)?.identifier ?? null) : null}
|
||||
parentTitle={iss.parentId ? (issueById.get(iss.parentId)?.title ?? null) : null}
|
||||
parentIdentifier={issue.parentId ? (issueById.get(issue.parentId)?.identifier ?? null) : null}
|
||||
parentTitle={issue.parentId ? (issueById.get(issue.parentId)?.title ?? null) : null}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -1893,49 +1882,224 @@ export function Inbox() {
|
||||
);
|
||||
};
|
||||
|
||||
// Render parent issue
|
||||
const parentRow = renderInboxIssue(issue, 0, isSelected);
|
||||
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${issue.id}`}
|
||||
selected={isSelected}
|
||||
disabled={archivingIssueIds.has(issue.id) || archiveIssueMutation.isPending}
|
||||
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
||||
>
|
||||
{parentRow}
|
||||
</SwipeToArchive>
|
||||
) : parentRow));
|
||||
|
||||
// Render children if expanded
|
||||
if (isExpanded) {
|
||||
for (const child of childIssues) {
|
||||
const cNavIdx = childFlatIndex.get(child.id) ?? -1;
|
||||
const isChildSelected = selectedIndex === cNavIdx;
|
||||
const childRow = renderInboxIssue(child, 1, isChildSelected);
|
||||
const isChildArchiving = archivingIssueIds.has(child.id);
|
||||
let previousTimestamp = Number.POSITIVE_INFINITY;
|
||||
return groupedSections.flatMap((group, groupIndex) => {
|
||||
const elements: ReactNode[] = [];
|
||||
if (group.label) {
|
||||
elements.push(
|
||||
<div
|
||||
key={`sel-issue:${child.id}`}
|
||||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(cNavIdx)}
|
||||
key={`group-${group.key}`}
|
||||
className={cn(
|
||||
"border-b border-border/70 bg-muted/30 px-4 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground",
|
||||
groupIndex > 0 && "border-t border-border",
|
||||
)}
|
||||
>
|
||||
{canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${child.id}`}
|
||||
selected={isChildSelected}
|
||||
disabled={isChildArchiving || archiveIssueMutation.isPending}
|
||||
onArchive={() => archiveIssueMutation.mutate(child.id)}
|
||||
>
|
||||
{childRow}
|
||||
</SwipeToArchive>
|
||||
) : childRow}
|
||||
{group.label}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
});
|
||||
|
||||
for (let index = 0; index < group.displayItems.length; index += 1) {
|
||||
const item = group.displayItems[index]!;
|
||||
const navIdx = topFlatIndex.get(`${group.key}:${getWorkItemKey(item)}`) ?? 0;
|
||||
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
|
||||
<div
|
||||
key={`sel-${key}`}
|
||||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(navIdx)}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
const todayCutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const showTodayDivider =
|
||||
groupBy === "none" &&
|
||||
item.timestamp > 0 &&
|
||||
item.timestamp < todayCutoff &&
|
||||
previousTimestamp >= todayCutoff;
|
||||
previousTimestamp = item.timestamp > 0 ? item.timestamp : previousTimestamp;
|
||||
if (showTodayDivider) {
|
||||
elements.push(
|
||||
<div key={`today-divider-${group.key}-${index}`} className="my-2 flex items-center gap-3 px-4">
|
||||
<div className="flex-1 border-t border-zinc-600" />
|
||||
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
||||
Earlier
|
||||
</span>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
const isSelected = selectedIndex === navIdx;
|
||||
|
||||
if (item.kind === "approval") {
|
||||
const approvalKey = `approval:${item.approval.id}`;
|
||||
const isArchiving = archivingNonIssueIds.has(approvalKey);
|
||||
const row = (
|
||||
<ApprovalInboxRow
|
||||
key={approvalKey}
|
||||
approval={item.approval}
|
||||
selected={isSelected}
|
||||
requesterName={agentName(item.approval.requestedByAgentId)}
|
||||
onApprove={() => approveMutation.mutate(item.approval.id)}
|
||||
onReject={() => rejectMutation.mutate(item.approval.id)}
|
||||
isPending={approveMutation.isPending || rejectMutation.isPending}
|
||||
unreadState={nonIssueUnreadState(approvalKey)}
|
||||
onMarkRead={() => handleMarkNonIssueRead(approvalKey)}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
className={
|
||||
isArchiving
|
||||
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
||||
: "transition-all duration-200 ease-out"
|
||||
}
|
||||
/>
|
||||
);
|
||||
elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={approvalKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(approvalKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind === "failed_run") {
|
||||
const runKey = `run:${item.run.id}`;
|
||||
const isArchiving = archivingNonIssueIds.has(runKey);
|
||||
const row = (
|
||||
<FailedRunInboxRow
|
||||
key={runKey}
|
||||
run={item.run}
|
||||
selected={isSelected}
|
||||
issueById={issueById}
|
||||
agentName={agentName(item.run.agentId)}
|
||||
issueLinkState={issueLinkState}
|
||||
onDismiss={() => dismissInboxItem(runKey)}
|
||||
onRetry={() => retryRunMutation.mutate(item.run)}
|
||||
isRetrying={retryingRunIds.has(item.run.id)}
|
||||
unreadState={nonIssueUnreadState(runKey)}
|
||||
onMarkRead={() => handleMarkNonIssueRead(runKey)}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
className={
|
||||
isArchiving
|
||||
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
||||
: "transition-all duration-200 ease-out"
|
||||
}
|
||||
/>
|
||||
);
|
||||
elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={runKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(runKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.kind === "join_request") {
|
||||
const joinKey = `join:${item.joinRequest.id}`;
|
||||
const isArchiving = archivingNonIssueIds.has(joinKey);
|
||||
const row = (
|
||||
<JoinRequestInboxRow
|
||||
key={joinKey}
|
||||
joinRequest={item.joinRequest}
|
||||
selected={isSelected}
|
||||
onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
|
||||
onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
|
||||
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
|
||||
unreadState={nonIssueUnreadState(joinKey)}
|
||||
onMarkRead={() => handleMarkNonIssueRead(joinKey)}
|
||||
onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined}
|
||||
archiveDisabled={isArchiving}
|
||||
className={
|
||||
isArchiving
|
||||
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
|
||||
: "transition-all duration-200 ease-out"
|
||||
}
|
||||
/>
|
||||
);
|
||||
elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={joinKey}
|
||||
selected={isSelected}
|
||||
disabled={isArchiving}
|
||||
onArchive={() => handleArchiveNonIssue(joinKey)}
|
||||
>
|
||||
{row}
|
||||
</SwipeToArchive>
|
||||
) : row));
|
||||
continue;
|
||||
}
|
||||
|
||||
const issue = item.issue;
|
||||
const childIssues = group.childrenByIssueId.get(issue.id) ?? [];
|
||||
const hasChildren = childIssues.length > 0;
|
||||
const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id);
|
||||
const parentRow = renderInboxIssue({
|
||||
issue,
|
||||
depth: 0,
|
||||
selected: isSelected,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
childCount: childIssues.length,
|
||||
collapseParentId: issue.id,
|
||||
});
|
||||
|
||||
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${issue.id}`}
|
||||
selected={isSelected}
|
||||
disabled={archivingIssueIds.has(issue.id) || archiveIssueMutation.isPending}
|
||||
onArchive={() => archiveIssueMutation.mutate(issue.id)}
|
||||
>
|
||||
{parentRow}
|
||||
</SwipeToArchive>
|
||||
) : parentRow));
|
||||
|
||||
if (isExpanded) {
|
||||
for (const child of childIssues) {
|
||||
const childNavIdx = childFlatIndex.get(child.id) ?? -1;
|
||||
const isChildSelected = selectedIndex === childNavIdx;
|
||||
const childRow = renderInboxIssue({
|
||||
issue: child,
|
||||
depth: 1,
|
||||
selected: isChildSelected,
|
||||
});
|
||||
const isChildArchiving = archivingIssueIds.has(child.id);
|
||||
elements.push(
|
||||
<div
|
||||
key={`sel-issue:${child.id}`}
|
||||
data-inbox-item
|
||||
className="relative"
|
||||
onClick={() => setSelectedIndex(childNavIdx)}
|
||||
>
|
||||
{canArchiveFromTab ? (
|
||||
<SwipeToArchive
|
||||
key={`issue:${child.id}`}
|
||||
selected={isChildSelected}
|
||||
disabled={isChildArchiving || archiveIssueMutation.isPending}
|
||||
onArchive={() => archiveIssueMutation.mutate(child.id)}
|
||||
>
|
||||
{childRow}
|
||||
</SwipeToArchive>
|
||||
) : childRow}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
issueChatUxReassignOptions,
|
||||
issueChatUxReviewComments,
|
||||
issueChatUxReviewEvents,
|
||||
issueChatUxSubmittingComments,
|
||||
issueChatUxTranscriptsByRunId,
|
||||
} from "../fixtures/issueChatUxFixtures";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -25,6 +26,7 @@ const highlights = [
|
||||
"Running assistant replies with streamed text, reasoning, tool cards, and background status notes",
|
||||
"Historical issue events and linked runs rendered inline with the chat timeline",
|
||||
"Queued user messages, settled assistant comments, and feedback controls",
|
||||
"Submitting (pending) message bubble with Sending... label and reduced opacity",
|
||||
"Empty and disabled-composer states without relying on live backend data",
|
||||
];
|
||||
|
||||
@@ -285,6 +287,26 @@ export function IssueChatUxLab() {
|
||||
/>
|
||||
</LabSection>
|
||||
|
||||
<LabSection
|
||||
eyebrow="Submitting state"
|
||||
title="Pending message bubble"
|
||||
description='When a user sends a message, the bubble briefly shows a "Sending..." label at reduced opacity until the server confirms receipt. This preview renders that transient state.'
|
||||
accentClassName="bg-[linear-gradient(180deg,rgba(59,130,246,0.06),transparent_28%),var(--background)]"
|
||||
>
|
||||
<IssueChatThread
|
||||
comments={issueChatUxSubmittingComments}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
issueStatus="in_progress"
|
||||
agentMap={issueChatUxAgentMap}
|
||||
currentUserId="user-1"
|
||||
onAdd={noop}
|
||||
draftKey="issue-chat-ux-lab-submitting"
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</LabSection>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<LabSection
|
||||
eyebrow="Settled review"
|
||||
|
||||
+179
-86
@@ -23,6 +23,7 @@ import {
|
||||
createIssueDetailPath,
|
||||
readIssueDetailLocationState,
|
||||
readIssueDetailBreadcrumb,
|
||||
readIssueDetailHeaderSeed,
|
||||
rememberIssueDetailLocationState,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import {
|
||||
@@ -50,10 +51,10 @@ import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
|
||||
import { useLiveRunTranscripts } from "../components/transcript/useLiveRunTranscripts";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import type { MentionOption } from "../components/MarkdownEditor";
|
||||
import { ImageGalleryModal } from "../components/ImageGalleryModal";
|
||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
@@ -70,6 +71,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatIssueActivityAction } from "@/lib/activity-format";
|
||||
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
|
||||
import {
|
||||
Activity as ActivityIcon,
|
||||
Check,
|
||||
@@ -91,7 +93,6 @@ import {
|
||||
type ActivityEvent,
|
||||
type Agent,
|
||||
type FeedbackVote,
|
||||
type FeedbackVoteValue,
|
||||
type Issue,
|
||||
type IssueAttachment,
|
||||
type IssueComment,
|
||||
@@ -107,10 +108,6 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||
};
|
||||
|
||||
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
||||
const ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS = 3000;
|
||||
const IDLE_ISSUE_RUN_POLL_INTERVAL_MS = 30000;
|
||||
const ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 5000;
|
||||
const IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS = 30000;
|
||||
const ISSUE_COMMENT_PAGE_SIZE = 50;
|
||||
|
||||
function keepPreviousData<T>(previousData: T | undefined) {
|
||||
@@ -284,6 +281,87 @@ function IssueChatSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function IssueDetailLoadingState({
|
||||
headerSeed,
|
||||
}: {
|
||||
headerSeed: ReturnType<typeof readIssueDetailHeaderSeed>;
|
||||
}) {
|
||||
const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
{headerSeed ? (
|
||||
<>
|
||||
<StatusIcon status={headerSeed.status} />
|
||||
<PriorityIcon priority={headerSeed.priority} />
|
||||
{identifier ? (
|
||||
<span className="text-sm font-mono text-muted-foreground shrink-0">{identifier}</span>
|
||||
) : null}
|
||||
{headerSeed.originKind === "routine_execution" && headerSeed.originId ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-violet-500/30 bg-violet-500/10 px-2 py-0.5 text-[10px] font-medium text-violet-600 dark:text-violet-400 shrink-0">
|
||||
<Repeat className="h-3 w-3" />
|
||||
Routine
|
||||
</span>
|
||||
) : null}
|
||||
{headerSeed.projectId ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground rounded px-1 -mx-1 py-0.5 min-w-0">
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{headerSeed.projectName ?? headerSeed.projectId.slice(0, 8)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
No project
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Skeleton className="h-6 w-6" />
|
||||
<Skeleton className="h-6 w-6" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{headerSeed ? (
|
||||
<>
|
||||
<h2 className="text-xl font-bold leading-tight">{headerSeed.title}</h2>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full max-w-xl" />
|
||||
<Skeleton className="h-4 w-[72%]" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Skeleton className="h-8 w-[min(100%,22rem)]" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-28 w-full rounded-lg border border-border" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
<IssueChatSkeleton />
|
||||
</div>
|
||||
|
||||
<IssueSectionSkeleton titleWidth="w-24" rows={3} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueDetail() {
|
||||
const { issueId } = useParams<{ issueId: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
@@ -309,10 +387,15 @@ export function IssueDetail() {
|
||||
const [galleryIndex, setGalleryIndex] = useState(0);
|
||||
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
|
||||
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
|
||||
const [issueChatInitialTranscriptReady, setIssueChatInitialTranscriptReady] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||
const commentComposerRef = useRef<IssueChatComposerHandle | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIssueChatInitialTranscriptReady(false);
|
||||
}, [issueId]);
|
||||
|
||||
const { data: issue, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.issues.detail(issueId!),
|
||||
queryFn: () => issuesApi.get(issueId!),
|
||||
@@ -358,6 +441,14 @@ export function IssueDetail() {
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.runs(issueId!),
|
||||
queryFn: () => activityApi.runsForIssue(issueId!),
|
||||
enabled: !!issueId,
|
||||
refetchInterval: 5000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const { data: linkedApprovals } = useQuery({
|
||||
queryKey: queryKeys.issues.approvals(issueId!),
|
||||
queryFn: () => issuesApi.listApprovals(issueId!),
|
||||
@@ -376,12 +467,7 @@ export function IssueDetail() {
|
||||
queryKey: queryKeys.issues.liveRuns(issueId!),
|
||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!),
|
||||
enabled: !!issueId,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as Array<unknown> | undefined;
|
||||
return data && data.length > 0
|
||||
? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS
|
||||
: IDLE_ISSUE_RUN_POLL_INTERVAL_MS;
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
@@ -389,25 +475,11 @@ export function IssueDetail() {
|
||||
queryKey: queryKeys.issues.activeRun(issueId!),
|
||||
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
|
||||
enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"),
|
||||
refetchInterval: (query) =>
|
||||
(liveRuns?.length ?? 0) > 0
|
||||
? false
|
||||
: query.state.data
|
||||
? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS
|
||||
: IDLE_ISSUE_RUN_POLL_INTERVAL_MS,
|
||||
refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
||||
const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.runs(issueId!),
|
||||
queryFn: () => activityApi.runsForIssue(issueId!),
|
||||
enabled: !!issueId,
|
||||
refetchInterval: hasLiveRuns
|
||||
? ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS
|
||||
: IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const runningIssueRun = useMemo(
|
||||
() => (
|
||||
activeRun?.status === "running"
|
||||
@@ -420,6 +492,10 @@ export function IssueDetail() {
|
||||
() => readIssueDetailLocationState(issueId, location.state, location.search),
|
||||
[issueId, location.state, location.search],
|
||||
);
|
||||
const issueHeaderSeed = useMemo(
|
||||
() => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState),
|
||||
[location.state, resolvedIssueDetailState],
|
||||
);
|
||||
const sourceBreadcrumb = useMemo(
|
||||
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
||||
[issueId, location.state, location.search],
|
||||
@@ -430,8 +506,14 @@ export function IssueDetail() {
|
||||
const liveIds = new Set<string>();
|
||||
for (const r of liveRuns ?? []) liveIds.add(r.id);
|
||||
if (activeRun) liveIds.add(activeRun.id);
|
||||
if (liveIds.size === 0) return linkedRuns ?? [];
|
||||
return (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId));
|
||||
const historicalRuns = liveIds.size === 0
|
||||
? (linkedRuns ?? [])
|
||||
: (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId));
|
||||
return historicalRuns.map((run) => ({
|
||||
...run,
|
||||
adapterType: run.adapterType,
|
||||
hasStoredOutput: (run.logBytes ?? 0) > 0,
|
||||
}));
|
||||
}, [linkedRuns, liveRuns, activeRun]);
|
||||
|
||||
const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({
|
||||
@@ -500,6 +582,23 @@ export function IssueDetail() {
|
||||
for (const a of agents ?? []) map.set(a.id, a);
|
||||
return map;
|
||||
}, [agents]);
|
||||
const transcriptRuns = useMemo(
|
||||
() =>
|
||||
resolveIssueChatTranscriptRuns({
|
||||
linkedRuns: timelineRuns,
|
||||
liveRuns: liveRuns ?? [],
|
||||
activeRun,
|
||||
}),
|
||||
[activeRun, liveRuns, timelineRuns],
|
||||
);
|
||||
const {
|
||||
transcriptByRun: issueChatTranscriptByRun,
|
||||
hasOutputForRun: issueChatHasOutputForRun,
|
||||
isInitialHydrating: issueChatTranscriptHydrating,
|
||||
} = useLiveRunTranscripts({
|
||||
runs: transcriptRuns,
|
||||
companyId: issue?.companyId ?? selectedCompanyId,
|
||||
});
|
||||
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
const options: MentionOption[] = [];
|
||||
@@ -699,6 +798,10 @@ export function IssueDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
|
||||
}, [issueId, queryClient]);
|
||||
const invalidateIssueThreadLazily = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), refetchType: "inactive" });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!), refetchType: "inactive" });
|
||||
}, [issueId, queryClient]);
|
||||
|
||||
const invalidateIssueRunState = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
|
||||
@@ -885,6 +988,10 @@ export function IssueDetail() {
|
||||
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
||||
);
|
||||
}
|
||||
queryClient.setQueryData<Issue | undefined>(
|
||||
queryKeys.issues.detail(issueId!),
|
||||
(current) => current ? { ...current, updatedAt: comment.createdAt } : current,
|
||||
);
|
||||
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
|
||||
queryKeys.issues.comments(issueId!),
|
||||
(current) => current ? {
|
||||
@@ -912,7 +1019,7 @@ export function IssueDetail() {
|
||||
});
|
||||
},
|
||||
onSettled: (_result, _error, variables) => {
|
||||
invalidateIssueDetail();
|
||||
invalidateIssueThreadLazily();
|
||||
if (variables.interrupt) {
|
||||
invalidateIssueRunState();
|
||||
}
|
||||
@@ -1011,7 +1118,7 @@ export function IssueDetail() {
|
||||
});
|
||||
},
|
||||
onSettled: (_result, _error, variables) => {
|
||||
invalidateIssueDetail();
|
||||
invalidateIssueThreadLazily();
|
||||
if (variables.interrupt) {
|
||||
invalidateIssueRunState();
|
||||
}
|
||||
@@ -1213,53 +1320,6 @@ export function IssueDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleInterruptQueued = useCallback(
|
||||
async (runId: string) => {
|
||||
await interruptQueuedComment.mutateAsync(runId);
|
||||
},
|
||||
[interruptQueuedComment.mutateAsync],
|
||||
);
|
||||
|
||||
const handleCommentImageUpload = useCallback(
|
||||
async (file: File) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
},
|
||||
[uploadAttachment.mutateAsync],
|
||||
);
|
||||
|
||||
const handleCommentAttachImage = useCallback(
|
||||
async (file: File) => {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
},
|
||||
[uploadAttachment.mutateAsync],
|
||||
);
|
||||
|
||||
const handleCommentAdd = useCallback(
|
||||
async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => {
|
||||
if (reassignment) {
|
||||
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
||||
return;
|
||||
}
|
||||
await addComment.mutateAsync({ body, reopen });
|
||||
},
|
||||
[addComment.mutateAsync, addCommentAndReassign.mutateAsync],
|
||||
);
|
||||
|
||||
const handleCommentVote = useCallback(
|
||||
async (commentId: string, vote: FeedbackVoteValue, options?: { reason?: string; allowSharing?: boolean }) => {
|
||||
await feedbackVoteMutation.mutateAsync({
|
||||
targetType: "issue_comment",
|
||||
targetId: commentId,
|
||||
vote,
|
||||
reason: options?.reason,
|
||||
allowSharing: options?.allowSharing,
|
||||
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
|
||||
});
|
||||
},
|
||||
[feedbackVoteMutation.mutateAsync, feedbackDataSharingPreference],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const titleLabel = issue?.title ?? issueId ?? "Issue";
|
||||
setBreadcrumbs([
|
||||
@@ -1480,18 +1540,26 @@ export function IssueDetail() {
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const issueChatInitialLoading =
|
||||
const issueChatCoreInitialLoading =
|
||||
(commentsLoading && commentPages === undefined)
|
||||
|| (activityLoading && activity === undefined)
|
||||
|| (linkedRunsLoading && linkedRuns === undefined)
|
||||
|| (liveRunsLoading && liveRuns === undefined)
|
||||
|| (activeRunLoading && activeRun === undefined);
|
||||
useEffect(() => {
|
||||
if (issueChatInitialTranscriptReady) return;
|
||||
if (issueChatCoreInitialLoading || issueChatTranscriptHydrating) return;
|
||||
setIssueChatInitialTranscriptReady(true);
|
||||
}, [issueChatCoreInitialLoading, issueChatInitialTranscriptReady, issueChatTranscriptHydrating]);
|
||||
const issueChatInitialLoading =
|
||||
issueChatCoreInitialLoading
|
||||
|| (!issueChatInitialTranscriptReady && issueChatTranscriptHydrating);
|
||||
const activityInitialLoading =
|
||||
(activityLoading && activity === undefined)
|
||||
|| (linkedRunsLoading && linkedRuns === undefined);
|
||||
const attachmentsInitialLoading = attachmentsLoading && attachments === undefined;
|
||||
|
||||
if (isLoading) return <PageSkeleton variant="detail" />;
|
||||
if (isLoading) return <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
|
||||
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
|
||||
if (!issue) return null;
|
||||
|
||||
@@ -2075,19 +2143,44 @@ export function IssueDetail() {
|
||||
issueStatus={issue.status}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueChatTranscriptByRun}
|
||||
hasOutputForRun={issueChatHasOutputForRun}
|
||||
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
||||
enableReassign
|
||||
reassignOptions={commentReassignOptions}
|
||||
currentAssigneeValue={actualAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentionOptions}
|
||||
onInterruptQueued={handleInterruptQueued}
|
||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
|
||||
composerDisabledReason={commentComposerDisabledReason}
|
||||
onVote={handleCommentVote}
|
||||
onAdd={handleCommentAdd}
|
||||
imageUploadHandler={handleCommentImageUpload}
|
||||
onAttachImage={handleCommentAttachImage}
|
||||
onVote={async (commentId, vote, options) => {
|
||||
await feedbackVoteMutation.mutateAsync({
|
||||
targetType: "issue_comment",
|
||||
targetId: commentId,
|
||||
vote,
|
||||
reason: options?.reason,
|
||||
allowSharing: options?.allowSharing,
|
||||
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
|
||||
});
|
||||
}}
|
||||
onAdd={async (body, reopen, reassignment) => {
|
||||
if (reassignment) {
|
||||
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
||||
return;
|
||||
}
|
||||
await addComment.mutateAsync({ body, reopen });
|
||||
}}
|
||||
imageUploadHandler={async (file) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
}}
|
||||
onAttachImage={async (file) => {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
}}
|
||||
onInterruptQueued={async (runId) => {
|
||||
await interruptQueuedComment.mutateAsync(runId);
|
||||
}}
|
||||
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
|
||||
onCancelRun={runningIssueRun
|
||||
? async () => {
|
||||
await interruptQueuedComment.mutateAsync(runningIssueRun.id);
|
||||
|
||||
@@ -80,8 +80,13 @@ export function Issues() {
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }),
|
||||
queryKey: [
|
||||
...queryKeys.issues.list(selectedCompanyId!),
|
||||
"participant-agent",
|
||||
participantAgentId ?? "__all__",
|
||||
"with-routine-executions",
|
||||
],
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId, includeRoutineExecutions: true }),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
@@ -110,6 +115,7 @@ export function Issues() {
|
||||
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
|
||||
initialSearch={initialSearch}
|
||||
onSearchChange={handleSearchChange}
|
||||
enableRoutineVisibilityFilter
|
||||
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
|
||||
searchFilters={participantAgentId ? { participantAgentId } : undefined}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user