Refine issue workflow surfaces and live updates

This commit is contained in:
dotta
2026-04-09 10:26:17 -05:00
parent b4a58ba8a6
commit 03dff1a29a
48 changed files with 2800 additions and 1163 deletions
+2 -1
View File
@@ -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
View File
@@ -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);
+10 -1
View File
@@ -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),
-12
View File
@@ -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, {
+2
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
@@ -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 = `![${safeName}](${url})`;
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 = `![${safeName}](${url})`;
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>
);
});
+2
View File
@@ -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",
+44 -55
View File
@@ -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();
});
});
+4 -4
View File
@@ -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" />
+232
View File
@@ -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>
);
}
+8 -3
View File
@@ -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",
+63
View File
@@ -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();
});
});
});
+42 -249
View File
@@ -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" && (
+2 -1
View File
@@ -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}
+27 -2
View File
@@ -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",
+6 -18
View File
@@ -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 ?? []) {
+100 -1
View File
@@ -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();
});
});
+143 -82
View File
@@ -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" />
+2
View File
@@ -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",
+19
View File
@@ -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");
+3 -3
View File
@@ -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", () => {
+13 -4
View File
@@ -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) ??
+17
View File
@@ -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",
+30 -2
View File
@@ -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]] },
]);
});
});
+79
View File
@@ -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.
*
+64
View File
@@ -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: [],
+64 -2
View File
@@ -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..."
+89
View File
@@ -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,
},
]);
});
});
+42
View File
@@ -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()];
}
+130
View File
@@ -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");
+73
View File
@@ -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,
};
}
}
+1 -1
View File
@@ -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 {
+86
View File
@@ -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" });
});
});
+45
View File
@@ -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;
}
+3
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -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>
+22
View File
@@ -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
View File
@@ -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);
+8 -2
View File
@@ -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}
/>