Merge pull request #3222 from paperclipai/pap-1266-issue-workflow

feat(issue-ui): refine issue workflow surfaces and live updates
This commit is contained in:
Dotta 2026-04-09 14:52:16 -05:00 committed by GitHub
commit 0e87fdbe35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 2860 additions and 1206 deletions

View file

@ -62,6 +62,7 @@ describe("activity routes", () => {
mockActivityService.runsForIssue.mockResolvedValue([ mockActivityService.runsForIssue.mockResolvedValue([
{ {
runId: "run-1", runId: "run-1",
adapterType: "codex_local",
}, },
]); ]);
@ -72,6 +73,6 @@ describe("activity routes", () => {
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475"); expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475");
expect(mockIssueService.getById).not.toHaveBeenCalled(); expect(mockIssueService.getById).not.toHaveBeenCalled();
expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1"); 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" }]);
}); });
}); });

View file

@ -60,19 +60,17 @@ vi.mock("../services/index.js", () => ({
workProductService: () => ({}), workProductService: () => ({}),
})); }));
function createApp( function createApp() {
actor: Record<string, unknown> = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
},
) {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use((req, _res, next) => { 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(); next();
}); });
app.use("/api", issueRoutes({} as any, {} as any)); app.use("/api", issueRoutes({} as any, {} as any));
@ -139,63 +137,4 @@ describe("issue execution policy routes", () => {
expect(updatePatch.executionState).toBeUndefined(); expect(updatePatch.executionState).toBeUndefined();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); 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();
});
}); });

View file

@ -413,45 +413,33 @@ describe("issue execution policy transitions", () => {
const policy = twoStagePolicy(); const policy = twoStagePolicy();
const reviewStageId = policy.stages[0].id; const reviewStageId = policy.stages[0].id;
it("non-participant stage updates are coerced back to the active stage", () => { it("non-participant cannot advance the active stage", () => {
const result = applyIssueExecutionPolicyTransition({ expect(() =>
issue: { applyIssueExecutionPolicyTransition({
status: "in_review", issue: {
assigneeAgentId: qaAgentId, status: "in_review",
assigneeUserId: null, assigneeAgentId: qaAgentId,
executionPolicy: policy, assigneeUserId: null,
executionState: { executionPolicy: policy,
status: "pending", executionState: {
currentStageId: reviewStageId, status: "pending",
currentStageIndex: 0, currentStageId: reviewStageId,
currentStageType: "review", currentStageIndex: 0,
currentParticipant: { type: "agent", agentId: qaAgentId }, currentStageType: "review",
returnAssignee: { type: "agent", agentId: coderAgentId }, currentParticipant: { type: "agent", agentId: qaAgentId },
completedStageIds: [], returnAssignee: { type: "agent", agentId: coderAgentId },
lastDecisionId: null, completedStageIds: [],
lastDecisionOutcome: null, lastDecisionId: null,
lastDecisionOutcome: null,
},
}, },
}, policy,
policy, requestedStatus: "done",
requestedStatus: "done", requestedAssigneePatch: { assigneeUserId: boardUserId },
requestedAssigneePatch: { assigneeUserId: boardUserId }, actor: { agentId: coderAgentId },
actor: { agentId: coderAgentId }, commentBody: "Trying to bypass review",
commentBody: "Trying to bypass review", }),
}); ).toThrow("Only the active reviewer or approver can advance");
expect(result.patch).toMatchObject({
status: "in_review",
assigneeAgentId: qaAgentId,
assigneeUserId: null,
executionState: {
status: "pending",
currentStageId: reviewStageId,
currentStageType: "review",
currentParticipant: { type: "agent", agentId: qaAgentId },
returnAssignee: { type: "agent", agentId: coderAgentId },
},
});
expect(result.decision).toBeUndefined();
}); });
it("non-participant can still post non-advancing updates", () => { it("non-participant can still post non-advancing updates", () => {

View file

@ -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",
}),
}),
);
});
});

View file

@ -96,13 +96,6 @@ function executionPrincipalsEqual(
return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId; 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: { function buildExecutionStageWakeContext(input: {
state: ParsedExecutionState; state: ParsedExecutionState;
wakeRole: ExecutionStageWakeContext["wakeRole"]; wakeRole: ExecutionStageWakeContext["wakeRole"];
@ -1386,14 +1379,10 @@ export function issueRoutes(
? (updateFields.executionPolicy as NormalizedExecutionPolicy | null) ? (updateFields.executionPolicy as NormalizedExecutionPolicy | null)
: previousExecutionPolicy; : previousExecutionPolicy;
const requestedStatus = typeof updateFields.status === "string" ? updateFields.status : undefined;
const requestedAssigneePatchProvided =
req.body.assigneeAgentId !== undefined || req.body.assigneeUserId !== undefined;
const transition = applyIssueExecutionPolicyTransition({ const transition = applyIssueExecutionPolicyTransition({
issue: existing, issue: existing,
policy: nextExecutionPolicy, policy: nextExecutionPolicy,
requestedStatus, requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
requestedAssigneePatch: { requestedAssigneePatch: {
assigneeAgentId: assigneeAgentId:
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null), req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
@ -1419,27 +1408,6 @@ export function issueRoutes(
} }
Object.assign(updateFields, transition.patch); 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 = const nextAssigneeAgentId =
updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null); updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null);
const nextAssigneeUserId = const nextAssigneeUserId =
@ -1733,6 +1701,7 @@ export function issueRoutes(
reason: "issue_assigned", reason: "issue_assigned",
payload: { payload: {
issueId: issue.id, issueId: issue.id,
...(comment ? { commentId: comment.id } : {}),
mutation: "update", mutation: "update",
...(interruptedRunId ? { interruptedRunId } : {}), ...(interruptedRunId ? { interruptedRunId } : {}),
}, },
@ -1740,6 +1709,13 @@ export function issueRoutes(
requestedByActorId: actor.actorId, requestedByActorId: actor.actorId,
contextSnapshot: { contextSnapshot: {
issueId: issue.id, issueId: issue.id,
...(comment
? {
taskId: issue.id,
commentId: comment.id,
wakeCommentId: comment.id,
}
: {}),
source: "issue.update", source: "issue.update",
...(interruptedRunId ? { interruptedRunId } : {}), ...(interruptedRunId ? { interruptedRunId } : {}),
}, },
@ -1767,6 +1743,38 @@ export function issueRoutes(
} }
if (commentBody && comment) { 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[] = []; let mentionedIds: string[] = [];
try { try {
mentionedIds = await svc.findMentionedAgents(issue.companyId, commentBody); mentionedIds = await svc.findMentionedAgents(issue.companyId, commentBody);

View file

@ -1,6 +1,6 @@
import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { activityLog, heartbeatRuns, issues } from "@paperclipai/db"; import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db";
export interface ActivityFilters { export interface ActivityFilters {
companyId: string; companyId: string;
@ -66,14 +66,23 @@ export function activityService(db: Db) {
runId: heartbeatRuns.id, runId: heartbeatRuns.id,
status: heartbeatRuns.status, status: heartbeatRuns.status,
agentId: heartbeatRuns.agentId, agentId: heartbeatRuns.agentId,
adapterType: agents.adapterType,
startedAt: heartbeatRuns.startedAt, startedAt: heartbeatRuns.startedAt,
finishedAt: heartbeatRuns.finishedAt, finishedAt: heartbeatRuns.finishedAt,
createdAt: heartbeatRuns.createdAt, createdAt: heartbeatRuns.createdAt,
invocationSource: heartbeatRuns.invocationSource, invocationSource: heartbeatRuns.invocationSource,
usageJson: heartbeatRuns.usageJson, usageJson: heartbeatRuns.usageJson,
resultJson: heartbeatRuns.resultJson, resultJson: heartbeatRuns.resultJson,
logBytes: heartbeatRuns.logBytes,
}) })
.from(heartbeatRuns) .from(heartbeatRuns)
.innerJoin(
agents,
and(
eq(agents.id, heartbeatRuns.agentId),
eq(agents.companyId, heartbeatRuns.companyId),
),
)
.where( .where(
and( and(
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.companyId, companyId),

View file

@ -707,6 +707,18 @@ export function shouldResetTaskSessionForWake(
return false; return false;
} }
function shouldRequireIssueCommentForWake(
contextSnapshot: Record<string, unknown> | null | undefined,
) {
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
return (
wakeReason === "issue_assigned" ||
wakeReason === "execution_review_requested" ||
wakeReason === "execution_approval_requested" ||
wakeReason === "execution_changes_requested"
);
}
export function formatRuntimeWorkspaceWarningLog(warning: string) { export function formatRuntimeWorkspaceWarningLog(warning: string) {
return { return {
stream: "stdout" as const, stream: "stdout" as const,
@ -2011,18 +2023,6 @@ export function heartbeatService(db: Db) {
return { outcome: "not_applicable" as const, queuedRun: null }; 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); const postedComment = await findRunIssueComment(run.id, run.companyId, issueId);
if (postedComment) { if (postedComment) {
await patchRunIssueCommentStatus(run.id, { await patchRunIssueCommentStatus(run.id, {
@ -2047,6 +2047,17 @@ export function heartbeatService(db: Db) {
return { outcome: "retry_exhausted" as const, queuedRun: null }; return { outcome: "retry_exhausted" as const, queuedRun: null };
} }
if (!shouldRequireIssueCommentForWake(contextSnapshot)) {
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 queuedRun = await enqueueMissingIssueCommentRetry(run, agent, issueId); const queuedRun = await enqueueMissingIssueCommentRetry(run, agent, issueId);
if (queuedRun) { if (queuedRun) {
await appendRunEvent(run, await nextRunEventSeq(run.id), { await appendRunEvent(run, await nextRunEventSeq(run.id), {

View file

@ -393,13 +393,19 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
} }
} }
if ( const attemptedStageAdvance =
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
(requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant));
const stageStateDrifted =
input.issue.status !== "in_review" || input.issue.status !== "in_review" ||
!principalsEqual(currentAssignee, currentParticipant) || !principalsEqual(currentAssignee, currentParticipant) ||
!principalsEqual(existingState?.currentParticipant ?? null, currentParticipant) || !principalsEqual(existingState?.currentParticipant ?? null, currentParticipant);
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
(requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant)) if (attemptedStageAdvance && !stageStateDrifted) {
) { throw unprocessable("Only the active reviewer or approver can advance the current execution stage");
}
if (stageStateDrifted) {
buildPendingStagePatch({ buildPendingStagePatch({
patch, patch,
previous: existingState, previous: existingState,

View file

@ -5,12 +5,14 @@ export interface RunForIssue {
runId: string; runId: string;
status: string; status: string;
agentId: string; agentId: string;
adapterType: string;
startedAt: string | null; startedAt: string | null;
finishedAt: string | null; finishedAt: string | null;
createdAt: string; createdAt: string;
invocationSource: string; invocationSource: string;
usageJson: Record<string, unknown> | null; usageJson: Record<string, unknown> | null;
resultJson: Record<string, unknown> | null; resultJson: Record<string, unknown> | null;
logBytes?: number | null;
} }
export interface IssueForRun { export interface IssueForRun {

View file

@ -30,8 +30,8 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
const runs = liveRuns ?? []; const runs = liveRuns ?? [];
const { data: issues } = useQuery({ const { data: issues } = useQuery({
queryKey: queryKeys.issues.list(companyId), queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"],
queryFn: () => issuesApi.list(companyId), queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }),
enabled: runs.length > 0, enabled: runs.length > 0,
}); });

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 { Link, useLocation } from "react-router-dom";
import type { import type {
Agent, Agent,
@ -631,7 +631,7 @@ const TimelineList = memo(function TimelineList({
); );
}); });
export const CommentThread = memo(function CommentThread({ export function CommentThread({
comments, comments,
queuedComments = [], queuedComments = [],
linkedApprovals = [], linkedApprovals = [],
@ -662,9 +662,17 @@ export const CommentThread = memo(function CommentThread({
interruptingQueuedRunId = null, interruptingQueuedRunId = null,
composerDisabledReason = null, composerDisabledReason = null,
}: CommentThreadProps) { }: 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 effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null); const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
const [votingTargetId, setVotingTargetId] = 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 location = useLocation();
const hasScrolledRef = useRef(false); const hasScrolledRef = useRef(false);
@ -730,6 +738,29 @@ export const CommentThread = memo(function CommentThread({
})); }));
}, [agentMap, providedMentions]); }, [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} // Scroll to comment when URL hash matches #comment-{id}
useEffect(() => { useEffect(() => {
const hash = location.hash; const hash = location.hash;
@ -748,25 +779,72 @@ export const CommentThread = memo(function CommentThread({
} }
}, [location.hash, comments, queuedComments]); }, [location.hash, comments, queuedComments]);
const handleFeedbackVote = useCallback( async function handleSubmit() {
async ( const trimmed = body.trim();
commentId: string, if (!trimmed) return;
vote: FeedbackVoteValue, const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue;
options?: { allowSharing?: boolean; reason?: string }, const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null;
) => { const submittedBody = trimmed;
if (!onVote) return;
setVotingTargetId(commentId); setSubmitting(true);
try { setBody("");
await onVote(commentId, vote, options); try {
} finally { await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
setVotingTargetId(null); if (draftKey) clearDraft(draftKey);
} setReopen(true);
}, setReassignTarget(effectiveSuggestedAssigneeValue);
[onVote], } 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 <TimelineList
timeline={timeline} timeline={timeline}
agentMap={agentMap} agentMap={agentMap}
@ -783,21 +861,6 @@ export const CommentThread = memo(function CommentThread({
highlightCommentId={highlightCommentId} highlightCommentId={highlightCommentId}
feedbackTermsUrl={feedbackTermsUrl} 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} {liveRunSlot}
@ -840,216 +903,92 @@ export const CommentThread = memo(function CommentThread({
{composerDisabledReason} {composerDisabledReason}
</div> </div>
) : ( ) : (
<CommentComposer <div className="space-y-2">
onAdd={onAdd} <MarkdownEditor
mentions={mentions} ref={editorRef}
imageUploadHandler={imageUploadHandler} value={body}
onAttachImage={onAttachImage} onChange={setBody}
draftKey={draftKey} placeholder="Leave a comment..."
enableReassign={enableReassign} mentions={mentions}
reassignOptions={reassignOptions} onSubmit={handleSubmit}
currentAssigneeValue={currentAssigneeValue} imageUploadHandler={imageUploadHandler}
suggestedAssigneeValue={effectiveSuggestedAssigneeValue} contentClassName="min-h-[60px] text-sm"
agentMap={agentMap} />
/> <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> </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>
);
});

View file

@ -3,6 +3,7 @@ import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "
import { NavLink } from "@/lib/router"; import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins"; import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll";
import { SidebarNavItem } from "./SidebarNavItem"; import { SidebarNavItem } from "./SidebarNavItem";
export function InstanceSidebar() { export function InstanceSidebar() {
@ -33,6 +34,7 @@ export function InstanceSidebar() {
<NavLink <NavLink
key={plugin.id} key={plugin.id}
to={`/instance/settings/plugins/${plugin.id}`} to={`/instance/settings/plugins/${plugin.id}`}
state={SIDEBAR_SCROLL_RESET_STATE}
className={({ isActive }) => className={({ isActive }) =>
[ [
"rounded-md px-2 py-1.5 text-xs transition-colors", "rounded-md px-2 py-1.5 text-xs transition-colors",

View file

@ -41,6 +41,7 @@ import {
type IssueChatTranscriptEntry, type IssueChatTranscriptEntry,
type SegmentTiming, type SegmentTiming,
} from "../lib/issue-chat-messages"; } from "../lib/issue-chat-messages";
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@ -907,8 +908,6 @@ function IssueChatUserMessage() {
) : null} ) : null}
</div> </div>
) : null} ) : null}
{pending ? <div className="mb-1 text-xs text-muted-foreground">Sending...</div> : null}
<div className="space-y-3"> <div className="space-y-3">
<MessagePrimitive.Parts <MessagePrimitive.Parts
components={{ components={{
@ -918,39 +917,43 @@ function IssueChatUserMessage() {
</div> </div>
</div> </div>
<div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100"> {pending ? (
<Tooltip> <div className="mt-1 flex justify-end px-1 text-[11px] text-muted-foreground">Sending...</div>
<TooltipTrigger asChild> ) : (
<a <div className="mt-1 flex items-center justify-end gap-1.5 px-1 opacity-0 transition-opacity group-hover:opacity-100">
href={anchorId ? `#${anchorId}` : undefined} <Tooltip>
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline" <TooltipTrigger asChild>
> <a
{message.createdAt ? commentDateLabel(message.createdAt) : ""} href={anchorId ? `#${anchorId}` : undefined}
</a> className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
</TooltipTrigger> >
<TooltipContent side="bottom" className="text-xs"> {message.createdAt ? commentDateLabel(message.createdAt) : ""}
{message.createdAt ? formatDateTime(message.createdAt) : ""} </a>
</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent side="bottom" className="text-xs">
<button {message.createdAt ? formatDateTime(message.createdAt) : ""}
type="button" </TooltipContent>
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground" </Tooltip>
title="Copy message" <button
aria-label="Copy message" type="button"
onClick={() => { className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
const text = message.content title="Copy message"
.filter((p): p is { type: "text"; text: string } => p.type === "text") aria-label="Copy message"
.map((p) => p.text) onClick={() => {
.join("\n\n"); const text = message.content
void navigator.clipboard.writeText(text).then(() => { .filter((p): p is { type: "text"; text: string } => p.type === "text")
setCopied(true); .map((p) => p.text)
setTimeout(() => setCopied(false), 2000); .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> >
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
)}
</div> </div>
<Avatar size="sm" className="mt-1 shrink-0"> <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()); return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}, [activeRun, liveRuns]); }, [activeRun, liveRuns]);
const transcriptRuns = useMemo(() => { const transcriptRuns = useMemo(() => {
const combined = new Map<string, { id: string; status: string; adapterType: string }>(); return resolveIssueChatTranscriptRuns({
for (const run of displayLiveRuns) { linkedRuns,
combined.set(run.id, { liveRuns: displayLiveRuns,
id: run.id, activeRun,
status: run.status, });
adapterType: run.adapterType, }, [activeRun, displayLiveRuns, linkedRuns]);
});
}
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]);
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
runs: enableLiveTranscriptPolling ? transcriptRuns : [], runs: enableLiveTranscriptPolling ? transcriptRuns : [],
companyId, companyId,

View file

@ -351,4 +351,51 @@ describe("IssueDocumentsSection", () => {
}); });
queryClient.clear(); 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();
});
}); });

View file

@ -683,7 +683,7 @@ export function IssueDocumentsSection({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{isEmpty && !draft?.isNew ? ( {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} {extraActions}
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0"> <Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
<Plus className="mr-1.5 h-3.5 w-3.5" /> <Plus className="mr-1.5 h-3.5 w-3.5" />
@ -692,9 +692,9 @@ export function IssueDocumentsSection({
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="flex items-center justify-between gap-2 min-w-0"> <div className="flex flex-wrap items-center gap-2 min-w-0">
<h3 className="text-sm font-medium text-muted-foreground shrink-0">Documents</h3> <h3 className="w-full text-sm font-medium text-muted-foreground shrink-0 sm:w-auto">Documents</h3>
<div className="flex items-center gap-2 min-w-0"> <div className="flex flex-wrap items-center gap-2 min-w-0 sm:ml-auto">
{extraActions} {extraActions}
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0"> <Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
<Plus className="mr-1.5 h-3.5 w-3.5" /> <Plus className="mr-1.5 h-3.5 w-3.5" />

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>
);
}

View file

@ -2,7 +2,11 @@ import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared"; import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { createIssueDetailPath, rememberIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import {
createIssueDetailPath,
rememberIssueDetailLocationState,
withIssueDetailHeaderSeed,
} from "../lib/issueDetailBreadcrumb";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon"; import { StatusIcon } from "./StatusIcon";
@ -48,13 +52,14 @@ export function IssueRow({
const showUnreadSlot = unreadState !== null; const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading"; const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined; const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
return ( return (
<Link <Link
to={createIssueDetailPath(issuePathId)} to={createIssueDetailPath(issuePathId)}
state={issueLinkState} state={detailState}
data-inbox-issue-link data-inbox-issue-link
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, issueLinkState)} onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
className={cn( className={cn(
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1", "group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
selected ? "hover:bg-transparent" : "hover:bg-accent/50", selected ? "hover:bg-transparent" : "hover:bg-accent/50",

View file

@ -307,4 +307,67 @@ describe("IssuesList", () => {
root.unmount(); 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();
});
});
}); });

View file

@ -9,6 +9,15 @@ import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { formatAssigneeUserLabel } from "../lib/assignees"; import { formatAssigneeUserLabel } from "../lib/assignees";
import { groupBy } from "../lib/groupBy"; import { groupBy } from "../lib/groupBy";
import {
applyIssueFilters,
countActiveIssueFilters,
defaultIssueFilterState,
issueFilterLabel,
issuePriorityOrder,
issueStatusOrder,
type IssueFilterState,
} from "../lib/issue-filters";
import { import {
DEFAULT_INBOX_ISSUE_COLUMNS, DEFAULT_INBOX_ISSUE_COLUMNS,
getAvailableInboxIssueColumns, getAvailableInboxIssueColumns,
@ -27,39 +36,24 @@ import {
issueTrailingColumns, issueTrailingColumns,
} from "./IssueColumns"; } from "./IssueColumns";
import { StatusIcon } from "./StatusIcon"; import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { EmptyState } from "./EmptyState"; import { EmptyState } from "./EmptyState";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { IssueFiltersPopover } from "./IssueFiltersPopover";
import { IssueRow } from "./IssueRow"; import { IssueRow } from "./IssueRow";
import { PageSkeleton } from "./PageSkeleton"; import { PageSkeleton } from "./PageSkeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; 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 { KanbanBoard } from "./KanbanBoard";
import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import { buildIssueTree, countDescendants } from "../lib/issue-tree";
import type { Issue, Project } from "@paperclipai/shared"; 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; const ISSUE_SEARCH_DEBOUNCE_MS = 150;
function statusLabel(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/* ── View state ── */ /* ── View state ── */
export type IssueViewState = { export type IssueViewState = IssueFilterState & {
statuses: string[];
priorities: string[];
assignees: string[];
labels: string[];
projects: string[];
sortField: "status" | "priority" | "title" | "created" | "updated"; sortField: "status" | "priority" | "title" | "created" | "updated";
sortDir: "asc" | "desc"; sortDir: "asc" | "desc";
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none"; groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
@ -69,11 +63,7 @@ export type IssueViewState = {
}; };
const defaultViewState: IssueViewState = { const defaultViewState: IssueViewState = {
statuses: [], ...defaultIssueFilterState,
priorities: [],
assignees: [],
labels: [],
projects: [],
sortField: "updated", sortField: "updated",
sortDir: "desc", sortDir: "desc",
groupBy: "none", groupBy: "none",
@ -81,13 +71,6 @@ const defaultViewState: IssueViewState = {
collapsedGroups: [], collapsedGroups: [],
collapsedParents: [], 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 { function getViewState(key: string): IssueViewState {
try { try {
const raw = localStorage.getItem(key); const raw = localStorage.getItem(key);
@ -100,45 +83,15 @@ function saveViewState(key: string, state: IssueViewState) {
localStorage.setItem(key, JSON.stringify(state)); 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[] { function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
const sorted = [...issues]; const sorted = [...issues];
const dir = state.sortDir === "asc" ? 1 : -1; const dir = state.sortDir === "asc" ? 1 : -1;
sorted.sort((a, b) => { sorted.sort((a, b) => {
switch (state.sortField) { switch (state.sortField) {
case "status": case "status":
return dir * (statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status)); return dir * (issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status));
case "priority": case "priority":
return dir * (priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority)); return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.indexOf(b.priority));
case "title": case "title":
return dir * a.title.localeCompare(b.title); return dir * a.title.localeCompare(b.title);
case "created": case "created":
@ -152,16 +105,6 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] {
return sorted; 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 ── */ /* ── Component ── */
interface Agent { interface Agent {
@ -186,6 +129,7 @@ interface IssuesListProps {
searchFilters?: { searchFilters?: {
participantAgentId?: string; participantAgentId?: string;
}; };
enableRoutineVisibilityFilter?: boolean;
onSearchChange?: (search: string) => void; onSearchChange?: (search: string) => void;
onUpdateIssue: (id: string, data: Record<string, unknown>) => void; onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
} }
@ -247,6 +191,7 @@ export function IssuesList({
initialAssignees, initialAssignees,
initialSearch, initialSearch,
searchFilters, searchFilters,
enableRoutineVisibilityFilter = false,
onSearchChange, onSearchChange,
onUpdateIssue, onUpdateIssue,
}: IssuesListProps) { }: IssuesListProps) {
@ -319,8 +264,15 @@ export function IssuesList({
queryKey: [ queryKey: [
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
searchFilters ?? {}, 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, enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
placeholderData: (previousData) => previousData, placeholderData: (previousData) => previousData,
}); });
@ -423,9 +375,9 @@ export function IssuesList({
const filtered = useMemo(() => { const filtered = useMemo(() => {
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); const filteredByControls = applyIssueFilters(sourceIssues, viewState, currentUserId, enableRoutineVisibilityFilter);
return sortIssues(filteredByControls, viewState); return sortIssues(filteredByControls, viewState);
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]); }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]);
const { data: labels } = useQuery({ const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!), queryKey: queryKeys.issues.labels(selectedCompanyId!),
@ -433,7 +385,7 @@ export function IssuesList({
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const activeFilterCount = countActiveFilters(viewState); const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter);
const groupedContent = useMemo(() => { const groupedContent = useMemo(() => {
if (viewState.groupBy === "none") { if (viewState.groupBy === "none") {
@ -441,15 +393,15 @@ export function IssuesList({
} }
if (viewState.groupBy === "status") { if (viewState.groupBy === "status") {
const groups = groupBy(filtered, (i) => i.status); const groups = groupBy(filtered, (i) => i.status);
return statusOrder return issueStatusOrder
.filter((s) => groups[s]?.length) .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") { if (viewState.groupBy === "priority") {
const groups = groupBy(filtered, (i) => i.priority); const groups = groupBy(filtered, (i) => i.priority);
return priorityOrder return issuePriorityOrder
.filter((p) => groups[p]?.length) .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") { if (viewState.groupBy === "workspace") {
const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace"); const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace");
@ -581,175 +533,16 @@ export function IssuesList({
title="Choose which issue columns stay visible" title="Choose which issue columns stay visible"
/> />
{/* Filter */} <IssueFiltersPopover
<Popover> state={viewState}
<PopoverTrigger asChild> onChange={updateView}
<Button variant="ghost" size="sm" className={`text-xs ${activeFilterCount > 0 ? "text-blue-600 dark:text-blue-400" : ""}`}> activeFilterCount={activeFilterCount}
<Filter className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" /> agents={agents}
<span className="hidden sm:inline">{activeFilterCount > 0 ? `Filters: ${activeFilterCount}` : "Filter"}</span> projects={projects?.map((project) => ({ id: project.id, name: project.name }))}
{activeFilterCount > 0 && ( labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
<span className="sm:hidden text-[10px] font-medium ml-0.5">{activeFilterCount}</span> currentUserId={currentUserId}
)} enableRoutineVisibilityFilter={enableRoutineVisibilityFilter}
{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>
{/* Sort (list view only) */} {/* Sort (list view only) */}
{viewState.viewMode === "list" && ( {viewState.viewMode === "list" && (

View file

@ -3,7 +3,7 @@ import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { StatusIcon } from "./StatusIcon"; import { StatusIcon } from "./StatusIcon";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb";
import { timeAgo } from "../lib/timeAgo"; import { timeAgo } from "../lib/timeAgo";
interface IssuesQuicklookProps { interface IssuesQuicklookProps {
@ -36,6 +36,7 @@ export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) {
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" /> <StatusIcon status={issue.status} className="mt-0.5 shrink-0" />
<Link <Link
to={createIssueDetailPath(issue.identifier ?? issue.id)} to={createIssueDetailPath(issue.identifier ?? issue.id)}
state={withIssueDetailHeaderSeed(null, issue)}
className="text-sm font-medium leading-snug hover:underline line-clamp-2" className="text-sm font-medium leading-snug hover:underline line-clamp-2"
> >
{issue.title} {issue.title}

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { BookOpen, Moon, Settings, Sun } from "lucide-react"; 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 { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { InstanceSidebar } from "./InstanceSidebar"; import { InstanceSidebar } from "./InstanceSidebar";
@ -32,6 +32,11 @@ import {
DEFAULT_INSTANCE_SETTINGS_PATH, DEFAULT_INSTANCE_SETTINGS_PATH,
normalizeRememberedInstanceSettingsPath, normalizeRememberedInstanceSettingsPath,
} from "../lib/instance-settings"; } from "../lib/instance-settings";
import {
resetNavigationScroll,
SIDEBAR_SCROLL_RESET_STATE,
shouldResetScrollOnNavigation,
} from "../lib/navigation-scroll";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { scheduleMainContentFocus } from "../lib/main-content-focus"; import { scheduleMainContentFocus } from "../lib/main-content-focus";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@ -66,9 +71,12 @@ export function Layout() {
const { companyPrefix } = useParams<{ companyPrefix: string }>(); const { companyPrefix } = useParams<{ companyPrefix: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const navigationType = useNavigationType();
const isInstanceSettingsRoute = location.pathname.startsWith("/instance/"); const isInstanceSettingsRoute = location.pathname.startsWith("/instance/");
const onboardingTriggered = useRef(false); const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0); const lastMainScrollTop = useRef(0);
const previousPathname = useRef<string | null>(null);
const mainContentRef = useRef<HTMLElement | null>(null);
const [mobileNavVisible, setMobileNavVisible] = useState(true); const [mobileNavVisible, setMobileNavVisible] = useState(true);
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath()); const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
const [shortcutsOpen, setShortcutsOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false);
@ -271,10 +279,24 @@ export function Layout() {
useEffect(() => { useEffect(() => {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
const mainContent = document.getElementById("main-content"); const mainContent = mainContentRef.current;
return scheduleMainContentFocus(mainContent); return scheduleMainContentFocus(mainContent);
}, [location.pathname]); }, [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 ( return (
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}> <GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
<div <div
@ -334,6 +356,7 @@ export function Layout() {
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild> <Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link <Link
to={instanceSettingsTarget} to={instanceSettingsTarget}
state={SIDEBAR_SCROLL_RESET_STATE}
aria-label="Instance settings" aria-label="Instance settings"
title="Instance settings" title="Instance settings"
onClick={() => { onClick={() => {
@ -392,6 +415,7 @@ export function Layout() {
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild> <Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link <Link
to={instanceSettingsTarget} to={instanceSettingsTarget}
state={SIDEBAR_SCROLL_RESET_STATE}
aria-label="Instance settings" aria-label="Instance settings"
title="Instance settings" title="Instance settings"
onClick={() => { onClick={() => {
@ -428,6 +452,7 @@ export function Layout() {
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}> <div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
<main <main
id="main-content" id="main-content"
ref={mainContentRef}
tabIndex={-1} tabIndex={-1}
className={cn( className={cn(
"flex-1 p-4 outline-none md:p-6", "flex-1 p-4 outline-none md:p-6",

View file

@ -1,7 +1,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { useQuery, useQueryClient } from "@tanstack/react-query"; 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 { queryKeys } from "../lib/queryKeys";
import { formatDateTime } from "../lib/utils"; import { formatDateTime } from "../lib/utils";
import { ExternalLink, Square } from "lucide-react"; import { ExternalLink, Square } from "lucide-react";
@ -13,8 +13,6 @@ import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
interface LiveRunWidgetProps { interface LiveRunWidgetProps {
issueId: string; issueId: string;
companyId?: string | null; companyId?: string | null;
liveRunsData?: LiveRunForIssue[];
activeRunData?: ActiveRunForIssue | null;
} }
function toIsoString(value: string | Date | null | undefined): string | null { function toIsoString(value: string | Date | null | undefined): string | null {
@ -26,34 +24,24 @@ function isRunActive(status: string): boolean {
return status === "queued" || status === "running"; return status === "queued" || status === "running";
} }
export function LiveRunWidget({ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
issueId,
companyId,
liveRunsData,
activeRunData,
}: LiveRunWidgetProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [cancellingRunIds, setCancellingRunIds] = useState(new Set<string>()); 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), queryKey: queryKeys.issues.liveRuns(issueId),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
enabled: !!issueId && shouldFetchLiveRuns, enabled: !!issueId,
refetchInterval: 3000, refetchInterval: 3000,
}); });
const { data: fetchedActiveRun } = useQuery({ const { data: activeRun } = useQuery({
queryKey: queryKeys.issues.activeRun(issueId), queryKey: queryKeys.issues.activeRun(issueId),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId), queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
enabled: !!issueId && shouldFetchActiveRun, enabled: !!issueId,
refetchInterval: 3000, refetchInterval: 3000,
}); });
const liveRuns = liveRunsData ?? fetchedLiveRuns;
const activeRun = activeRunData ?? fetchedActiveRun;
const runs = useMemo(() => { const runs = useMemo(() => {
const deduped = new Map<string, LiveRunForIssue>(); const deduped = new Map<string, LiveRunForIssue>();
for (const run of liveRuns ?? []) { for (const run of liveRuns ?? []) {

View file

@ -3,7 +3,16 @@
import { act } from "react"; import { act } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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(() => ({ const mdxEditorMockState = vi.hoisted(() => ({
emitMountEmptyReset: false, emitMountEmptyReset: false,
@ -213,4 +222,94 @@ describe("MarkdownEditor", () => {
it("still rejects slash commands once spaces are typed", () => { it("still rejects slash commands once spaces are typed", () => {
expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull(); 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();
});
}); });

View file

@ -297,6 +297,102 @@ function autocompleteMarkdown(option: AutocompleteOption): string {
return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option); 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. */ /** Replace the active autocomplete token in the markdown string with the selected token. */
function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string { function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string {
const search = `${state.marker}${state.query}`; const search = `${state.marker}${state.query}`;
@ -346,6 +442,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const [mentionState, setMentionState] = useState<MentionState | null>(null); const [mentionState, setMentionState] = useState<MentionState | null>(null);
const mentionStateRef = useRef<MentionState | null>(null); const mentionStateRef = useRef<MentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(0); const [mentionIndex, setMentionIndex] = useState(0);
const skillEnterArmedRef = useRef(false);
const mentionActive = mentionState !== null && ( const mentionActive = mentionState !== null && (
(mentionState.trigger === "mention" && Boolean(mentions?.length)) (mentionState.trigger === "mention" && Boolean(mentions?.length))
|| (mentionState.trigger === "skill" && slashCommands.length > 0) || (mentionState.trigger === "skill" && slashCommands.length > 0)
@ -509,6 +606,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const checkMention = useCallback(() => { const checkMention = useCallback(() => {
if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) { if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) {
mentionStateRef.current = null; mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null); setMentionState(null);
return; return;
} }
@ -519,6 +617,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
&& (!mentions || mentions.length === 0) && (!mentions || mentions.length === 0)
) { ) {
mentionStateRef.current = null; mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null); setMentionState(null);
return; return;
} }
@ -528,16 +627,18 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
&& slashCommands.length === 0 && slashCommands.length === 0
) { ) {
mentionStateRef.current = null; mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null); setMentionState(null);
return; return;
} }
const previous = mentionStateRef.current;
const sameSession = isSameAutocompleteSession(previous, result);
mentionStateRef.current = result; mentionStateRef.current = result;
if (result) { if (!sameSession) {
setMentionState(result); skillEnterArmedRef.current = false;
setMentionIndex(0); setMentionIndex(0);
} else {
setMentionState(null);
} }
setMentionState(result);
}, [mentions, slashCommands.length]); }, [mentions, slashCommands.length]);
useEffect(() => { useEffect(() => {
@ -548,21 +649,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// also fires after typing (e.g. space to dismiss). // also fires after typing (e.g. space to dismiss).
const onInput = () => requestAnimationFrame(checkMention); const onInput = () => requestAnimationFrame(checkMention);
let selRafId: number | null = null; document.addEventListener("selectionchange", checkMention);
const onSelectionChange = () => {
if (selRafId !== null) return;
selRafId = requestAnimationFrame(() => {
selRafId = null;
checkMention();
});
};
document.addEventListener("selectionchange", onSelectionChange);
el?.addEventListener("input", onInput, true); el?.addEventListener("input", onInput, true);
return () => { return () => {
document.removeEventListener("selectionchange", onSelectionChange); document.removeEventListener("selectionchange", checkMention);
el?.removeEventListener("input", onInput, true); el?.removeEventListener("input", onInput, true);
if (selRafId !== null) cancelAnimationFrame(selRafId);
}; };
}, [checkMention, mentions, slashCommands.length]); }, [checkMention, mentions, slashCommands.length]);
@ -589,24 +680,16 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const editable = containerRef.current?.querySelector('[contenteditable="true"]'); const editable = containerRef.current?.querySelector('[contenteditable="true"]');
if (!editable) return; if (!editable) return;
decorateProjectMentions(); decorateProjectMentions();
let rafId: number | null = null;
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
if (rafId !== null) return; decorateProjectMentions();
rafId = requestAnimationFrame(() => {
rafId = null;
decorateProjectMentions();
});
}); });
observer.observe(editable, { observer.observe(editable, {
subtree: true, subtree: true,
childList: true, childList: true,
characterData: true, characterData: true,
}); });
return () => { return () => observer.disconnect();
observer.disconnect(); }, [decorateProjectMentions, value]);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [decorateProjectMentions]);
const selectMention = useCallback( const selectMention = useCallback(
(option: AutocompleteOption) => { (option: AutocompleteOption) => {
@ -623,65 +706,28 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
onChange(next); onChange(next);
} }
requestAnimationFrame(() => { const restoreSelection = (attemptsRemaining: number) => {
requestAnimationFrame(() => { const editable = containerRef.current?.querySelector('[contenteditable="true"]');
const editable = containerRef.current?.querySelector('[contenteditable="true"]'); if (!(editable instanceof HTMLElement)) return;
if (!(editable instanceof HTMLElement)) return;
decorateProjectMentions();
editable.focus();
const mentionHref = option.kind === "skill" decorateProjectMentions();
? option.href editable.focus();
: 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;
const selection = window.getSelection(); const target = findClosestAutocompleteAnchor(editable, option, state);
if (!selection) return; if (!target) {
const range = document.createRange(); if (attemptsRemaining > 0) {
const nextSibling = target.nextSibling; requestAnimationFrame(() => restoreSelection(attemptsRemaining - 1));
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;
}
} }
return;
}
range.setStartAfter(target); placeCaretAfterMentionAnchor(target);
range.collapse(true); };
selection.removeAllRanges();
selection.addRange(range); requestAnimationFrame(() => restoreSelection(4));
});
});
mentionStateRef.current = null; mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null); setMentionState(null);
}, },
[decorateProjectMentions, onChange], [decorateProjectMentions, onChange],
@ -737,6 +783,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
if (mentionActive) { if (mentionActive) {
if (e.key === " " && mentionStateRef.current?.trigger === "skill") { if (e.key === " " && mentionStateRef.current?.trigger === "skill") {
mentionStateRef.current = null; mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null); setMentionState(null);
return; return;
} }
@ -745,6 +792,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
mentionStateRef.current = null; mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null); setMentionState(null);
return; return;
} }
@ -753,16 +801,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill";
setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1)); setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1));
return; return;
} }
if (e.key === "ArrowUp") { if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill";
setMentionIndex((prev) => Math.max(prev - 1, 0)); setMentionIndex((prev) => Math.max(prev - 1, 0));
return; return;
} }
if (e.key === "Enter" || e.key === "Tab") { if (
shouldAcceptAutocompleteKey(
e.key,
mentionStateRef.current?.trigger ?? null,
skillEnterArmedRef.current,
)
) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
selectMention(filteredMentions[mentionIndex]); selectMention(filteredMentions[mentionIndex]);
@ -865,7 +921,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
e.preventDefault(); // prevent blur e.preventDefault(); // prevent blur
selectMention(option); selectMention(option);
}} }}
onMouseEnter={() => setMentionIndex(i)} onMouseEnter={() => {
if (mentionStateRef.current?.trigger === "skill") {
skillEnterArmedRef.current = true;
}
setMentionIndex(i);
}}
> >
{option.kind === "skill" ? ( {option.kind === "skill" ? (
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />

View file

@ -9,6 +9,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useInboxBadge } from "../hooks/useInboxBadge"; import { useInboxBadge } from "../hooks/useInboxBadge";
@ -92,6 +93,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
<NavLink <NavLink
key={item.label} key={item.label}
to={item.to} to={item.to}
state={SIDEBAR_SCROLL_RESET_STATE}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
"relative flex min-w-0 flex-col items-center justify-center gap-1 rounded-md text-[10px] font-medium transition-colors", "relative flex min-w-0 flex-col items-center justify-center gap-1 rounded-md text-[10px] font-medium transition-colors",

View file

@ -384,6 +384,24 @@ describe("NewIssueDialog", () => {
act(() => root.unmount()); 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 () => { it("warns when a sub-issue stops matching the parent workspace", async () => {
mockProjectsApi.list.mockResolvedValue([ mockProjectsApi.list.mockResolvedValue([
{ {
@ -430,6 +448,7 @@ describe("NewIssueDialog", () => {
const { root } = renderDialog(container); const { root } = renderDialog(container);
await flush(); await flush();
await flush();
expect(container.textContent).not.toContain("will no longer use the parent issue workspace"); expect(container.textContent).not.toContain("will no longer use the parent issue workspace");

View file

@ -946,9 +946,9 @@ export function NewIssueDialog() {
showCloseButton={false} showCloseButton={false}
aria-describedby={undefined} aria-describedby={undefined}
className={cn( 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 expanded
? "sm:max-w-2xl h-[calc(100dvh-2rem)]" ? "sm:max-w-2xl sm:h-[calc(100dvh-2rem)]"
: "sm:max-w-lg" : "sm:max-w-lg"
)} )}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@ -1452,7 +1452,7 @@ export function NewIssueDialog() {
{/* Description */} {/* Description */}
<div <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} onDragEnter={handleFileDragEnter}
onDragOver={handleFileDragOver} onDragOver={handleFileDragOver}
onDragLeave={handleFileDragLeave} onDragLeave={handleFileDragLeave}

View file

@ -115,4 +115,77 @@ describe("useLiveRunTranscripts", () => {
expect(socket.closeCalls).toEqual([{ code: 1000, reason: "live_run_transcripts_unmount" }]); expect(socket.closeCalls).toEqual([{ code: 1000, reason: "live_run_transcripts_unmount" }]);
container.remove(); 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();
});
}); });

View file

@ -13,6 +13,7 @@ export interface RunTranscriptSource {
id: string; id: string;
status: string; status: string;
adapterType: string; adapterType: string;
hasStoredOutput?: boolean;
} }
interface UseLiveRunTranscriptsOptions { interface UseLiveRunTranscriptsOptions {
@ -70,7 +71,17 @@ export function useLiveRunTranscripts({
companyId, companyId,
maxChunksPerRun = 200, maxChunksPerRun = 200,
}: UseLiveRunTranscriptsOptions) { }: 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 [chunksByRun, setChunksByRun] = useState<Map<string, RunLogChunk[]>>(new Map());
const [hydratedRunIds, setHydratedRunIds] = useState<Set<string>>(new Set());
const seenChunkKeysRef = useRef(new Set<string>()); const seenChunkKeysRef = useRef(new Set<string>());
const pendingLogRowsByRunRef = useRef(new Map<string, string>()); const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>()); const logOffsetByRunRef = useRef(new Map<string, number>());
@ -84,14 +95,14 @@ export function useLiveRunTranscripts({
queryFn: () => instanceSettingsApi.getGeneral(), 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( const activeRunIds = useMemo(
() => new Set(runs.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)), () => new Set(normalizedRuns.filter((run) => !isTerminalStatus(run.status)).map((run) => run.id)),
[runs], [normalizedRuns],
); );
const runIdsKey = useMemo( const runIdsKey = useMemo(
() => runs.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","), () => normalizedRuns.map((run) => run.id).sort((a, b) => a.localeCompare(b)).join(","),
[runs], [normalizedRuns],
); );
const appendChunks = (runId: string, chunks: Array<RunLogChunk & { dedupeKey: string }>) => { const appendChunks = (runId: string, chunks: Array<RunLogChunk & { dedupeKey: string }>) => {
@ -118,7 +129,7 @@ export function useLiveRunTranscripts({
}; };
useEffect(() => { useEffect(() => {
const knownRunIds = new Set(runs.map((run) => run.id)); const knownRunIds = new Set(normalizedRuns.map((run) => run.id));
setChunksByRun((prev) => { setChunksByRun((prev) => {
const next = new Map<string, RunLogChunk[]>(); const next = new Map<string, RunLogChunk[]>();
for (const [runId, chunks] of prev) { for (const [runId, chunks] of prev) {
@ -128,6 +139,15 @@ export function useLiveRunTranscripts({
} }
return next.size === prev.size ? prev : next; 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()) { for (const key of pendingLogRowsByRunRef.current.keys()) {
const runId = key.replace(/:records$/, ""); const runId = key.replace(/:records$/, "");
@ -140,10 +160,10 @@ export function useLiveRunTranscripts({
logOffsetByRunRef.current.delete(runId); logOffsetByRunRef.current.delete(runId);
} }
} }
}, [runs]); }, [normalizedRuns]);
useEffect(() => { useEffect(() => {
if (runs.length === 0) return; if (normalizedRuns.length === 0) return;
let cancelled = false; let cancelled = false;
@ -164,15 +184,24 @@ export function useLiveRunTranscripts({
} }
} catch { } catch {
// Ignore log read errors while output is initializing. // 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 () => { const readAll = async () => {
await Promise.all(runs.map((run) => readRunLog(run))); await Promise.all(normalizedRuns.map((run) => readRunLog(run)));
}; };
void readAll(); void readAll();
const activeRuns = runs.filter((run) => !isTerminalStatus(run.status)); const activeRuns = normalizedRuns.filter((run) => !isTerminalStatus(run.status));
const interval = activeRuns.length > 0 const interval = activeRuns.length > 0
? window.setInterval(() => { ? window.setInterval(() => {
void Promise.all(activeRuns.map((run) => readRunLog(run))); void Promise.all(activeRuns.map((run) => readRunLog(run)));
@ -183,7 +212,7 @@ export function useLiveRunTranscripts({
cancelled = true; cancelled = true;
if (interval !== null) window.clearInterval(interval); if (interval !== null) window.clearInterval(interval);
}; };
}, [runIdsKey, runs]); }, [normalizedRuns, runIdsKey]);
useEffect(() => { useEffect(() => {
if (!companyId || activeRunIds.size === 0) return; if (!companyId || activeRunIds.size === 0) return;
@ -298,7 +327,7 @@ export function useLiveRunTranscripts({
const transcriptByRun = useMemo(() => { const transcriptByRun = useMemo(() => {
const next = new Map<string, TranscriptEntry[]>(); const next = new Map<string, TranscriptEntry[]>();
const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true; const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true;
for (const run of runs) { for (const run of normalizedRuns) {
const adapter = getUIAdapter(run.adapterType); const adapter = getUIAdapter(run.adapterType);
next.set( next.set(
run.id, run.id,
@ -308,12 +337,13 @@ export function useLiveRunTranscripts({
); );
} }
return next; return next;
}, [chunksByRun, generalSettings?.censorUsernameInLogs, parserTick, runs]); }, [chunksByRun, generalSettings?.censorUsernameInLogs, normalizedRuns, parserTick]);
return { return {
transcriptByRun, transcriptByRun,
isInitialHydrating: normalizedRuns.some((run) => !hydratedRunIds.has(run.id)),
hasOutputForRun(runId: string) { hasOutputForRun(runId: string) {
return (chunksByRun.get(runId)?.length ?? 0) > 0; return (chunksByRun.get(runId)?.length ?? 0) > 0 || runById.get(runId)?.hasStoredOutput === true;
}, },
}; };
} }

View file

@ -23,6 +23,7 @@ describe("LiveUpdatesProvider issue invalidation", () => {
action: "issue.updated", action: "issue.updated",
details: null, details: null,
}, },
{ userId: null, agentId: null },
); );
expect(invalidations).toContainEqual({ expect(invalidations).toContainEqual({
@ -81,12 +82,87 @@ describe("LiveUpdatesProvider issue invalidation", () => {
action: "issue.comment_added", action: "issue.comment_added",
details: null, details: null,
}, },
{ userId: null, agentId: null },
); );
expect(invalidations).toContainEqual({ expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.comments("issue-1"), 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", () => { describe("LiveUpdatesProvider visible issue toast suppression", () => {

View file

@ -480,6 +480,7 @@ function invalidateActivityQueries(
queryClient: ReturnType<typeof useQueryClient>, queryClient: ReturnType<typeof useQueryClient>,
companyId: string, companyId: string,
payload: Record<string, unknown>, payload: Record<string, unknown>,
currentActor: { userId: string | null; agentId: string | null },
) { ) {
queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
@ -488,6 +489,8 @@ function invalidateActivityQueries(
const entityType = readString(payload.entityType); const entityType = readString(payload.entityType);
const entityId = readString(payload.entityId); const entityId = readString(payload.entityId);
const action = readString(payload.action); const action = readString(payload.action);
const actorType = readString(payload.actorType);
const actorId = readString(payload.actorId);
if (entityType === "issue") { if (entityType === "issue") {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
@ -496,12 +499,18 @@ function invalidateActivityQueries(
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) });
if (entityId) { if (entityId) {
const details = readRecord(payload.details); 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); const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
for (const ref of issueRefs) { for (const ref of issueRefs) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) }); const invalidationOptions = selfCommentActivity ? { refetchType: "inactive" as const } : undefined;
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref), ...invalidationOptions });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref), ...invalidationOptions });
if (action === "issue.comment_added") { 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") { if (event.type === "activity.logged") {
invalidateActivityQueries(queryClient, expectedCompanyId, payload); invalidateActivityQueries(queryClient, expectedCompanyId, payload, currentActor);
const action = readString(payload.action); const action = readString(payload.action);
const toast = const toast =
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ?? buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??

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[] = [ export const issueChatUxReviewComments: IssueChatComment[] = [
createComment({ createComment({
id: "comment-review-user", id: "comment-review-user",

View file

@ -14,12 +14,14 @@ import {
DEFAULT_INBOX_ISSUE_COLUMNS, DEFAULT_INBOX_ISSUE_COLUMNS,
buildInboxDismissedAtByKey, buildInboxDismissedAtByKey,
computeInboxBadgeData, computeInboxBadgeData,
filterInboxIssues,
getAvailableInboxIssueColumns, getAvailableInboxIssueColumns,
getApprovalsForTab, getApprovalsForTab,
getInboxWorkItems, getInboxWorkItems,
getInboxKeyboardSelectionIndex, getInboxKeyboardSelectionIndex,
getRecentTouchedIssues, getRecentTouchedIssues,
getUnreadTouchedIssues, getUnreadTouchedIssues,
groupInboxWorkItems,
isInboxEntityDismissed, isInboxEntityDismissed,
isMineInboxTab, isMineInboxTab,
loadInboxIssueColumns, loadInboxIssueColumns,
@ -32,6 +34,7 @@ import {
saveInboxIssueColumns, saveInboxIssueColumns,
saveLastInboxTab, saveLastInboxTab,
shouldShowInboxSection, shouldShowInboxSection,
type InboxWorkItem,
} from "./inbox"; } from "./inbox";
const storage = new Map<string, string>(); const storage = new Map<string, string>();
@ -336,7 +339,6 @@ describe("inbox helpers", () => {
}); });
expect(result.mineIssues).toBe(1); expect(result.mineIssues).toBe(1);
// inbox = mineIssues(1) + agent-error alert(1) + budget alert(1)
expect(result.inbox).toBe(3); expect(result.inbox).toBe(3);
}); });
@ -493,7 +495,7 @@ describe("inbox helpers", () => {
approvals: [], approvals: [],
}); });
expect(items.map((i) => (i.kind === "issue" ? i.issue.id : ""))).toEqual([ expect(items.map((item) => (item.kind === "issue" ? item.issue.id : ""))).toEqual([
"recent", "recent",
"older", "older",
]); ]);
@ -701,4 +703,30 @@ describe("inbox helpers", () => {
expect(getInboxKeyboardSelectionIndex(0, 3, "next")).toBe(1); expect(getInboxKeyboardSelectionIndex(0, 3, "next")).toBe(1);
expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0); 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]] },
]);
});
}); });

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_LAST_TAB_KEY = "paperclip:inbox:last-tab";
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns"; export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
export const INBOX_NESTING_KEY = "paperclip:inbox:nesting"; 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 InboxTab = "mine" | "recent" | "unread" | "all";
export type InboxApprovalFilter = "all" | "actionable" | "resolved"; 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 const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
export type InboxIssueColumn = (typeof inboxIssueColumns)[number]; export type InboxIssueColumn = (typeof inboxIssueColumns)[number];
export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"]; export const DEFAULT_INBOX_ISSUE_COLUMNS: InboxIssueColumn[] = ["status", "id", "updated"];
@ -51,6 +53,12 @@ export interface InboxBadgeData {
alerts: number; alerts: number;
} }
export interface InboxWorkItemGroup {
key: string;
label: string | null;
items: InboxWorkItem[];
}
export function loadDismissedInboxAlerts(): Set<string> { export function loadDismissedInboxAlerts(): Set<string> {
try { try {
const raw = localStorage.getItem(DISMISSED_KEY); 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( export function resolveIssueWorkspaceName(
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">, 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. * Groups parent-child issues in a flat InboxWorkItem list.
* *

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", () => { it("keeps the same assistant message id when a live run becomes a cancelled historical run", () => {
const liveMessages = buildIssueChatMessages({ const liveMessages = buildIssueChatMessages({
comments: [], comments: [],

View file

@ -32,10 +32,12 @@ export interface IssueChatLinkedRun {
runId: string; runId: string;
status: string; status: string;
agentId: string; agentId: string;
adapterType?: string;
agentName?: string; agentName?: string;
createdAt: Date | string; createdAt: Date | string;
startedAt: Date | string | null; startedAt: Date | string | null;
finishedAt?: Date | string | null; finishedAt?: Date | string | null;
hasStoredOutput?: boolean;
} }
export interface IssueChatTranscriptEntry { export interface IssueChatTranscriptEntry {
@ -71,6 +73,8 @@ export interface IssueChatTranscriptEntry {
changeType?: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation"; changeType?: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation";
} }
const ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES = 30;
type MessageWithOrder = { type MessageWithOrder = {
createdAtMs: number; createdAtMs: number;
order: number; order: number;
@ -156,6 +160,62 @@ function formatDiffBlock(lines: string[]) {
return `\`\`\`diff\n${lines.join("\n")}\n\`\`\``; 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>) { function createAssistantMetadata(custom: Record<string, unknown>) {
return { return {
unstable_state: null, unstable_state: null,
@ -401,7 +461,8 @@ function createHistoricalTranscriptMessage(args: {
}) { }) {
const { run, transcript, hasOutput, agentMap } = args; const { run, transcript, hasOutput, agentMap } = args;
const agentName = run.agentName ?? agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8); 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 waitingText = hasOutput ? "" : "Run finished";
const content = parts.length > 0 const content = parts.length > 0
? parts ? parts
@ -595,7 +656,8 @@ function createLiveRunMessage(args: {
transcript: readonly IssueChatTranscriptEntry[]; transcript: readonly IssueChatTranscriptEntry[];
}) { }) {
const { run, transcript } = args; const { run, transcript } = args;
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript); const compactedTranscript = compactIssueChatTranscript(transcript);
const { parts, notices, segments } = buildAssistantPartsFromTranscript(compactedTranscript);
const waitingText = const waitingText =
run.status === "queued" run.status === "queued"
? "Queued..." ? "Queued..."

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;
}

View file

@ -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,
},
]);
});
});

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()];
}

View file

@ -4,11 +4,14 @@ import {
createIssueDetailLocationState, createIssueDetailLocationState,
createIssueDetailPath, createIssueDetailPath,
hasLegacyIssueDetailQuery, hasLegacyIssueDetailQuery,
readIssueDetailHeaderSeed,
readIssueDetailLocationState, readIssueDetailLocationState,
readIssueDetailBreadcrumb, readIssueDetailBreadcrumb,
rememberIssueDetailLocationState, rememberIssueDetailLocationState,
shouldArmIssueDetailInboxQuickArchive, shouldArmIssueDetailInboxQuickArchive,
withIssueDetailHeaderSeed,
} from "./issueDetailBreadcrumb"; } from "./issueDetailBreadcrumb";
import type { Issue } from "@paperclipai/shared";
const sessionStorageMock = (() => { const sessionStorageMock = (() => {
const store = new Map<string, string>(); const store = new Map<string, string>();
@ -29,6 +32,91 @@ Object.defineProperty(globalThis, "window", {
}); });
describe("issueDetailBreadcrumb", () => { 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", () => { it("returns clean issue detail paths", () => {
expect(createIssueDetailPath("PAP-465")).toBe("/issues/PAP-465"); 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", () => { it("can arm quick archive only for explicit inbox keyboard entry state", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"); const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");

View file

@ -1,3 +1,5 @@
import type { Issue } from "@paperclipai/shared";
type IssueDetailSource = "issues" | "inbox"; type IssueDetailSource = "issues" | "inbox";
type IssueDetailBreadcrumb = { type IssueDetailBreadcrumb = {
@ -5,10 +7,23 @@ type IssueDetailBreadcrumb = {
href: string; 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 = { type IssueDetailLocationState = {
issueDetailBreadcrumb?: IssueDetailBreadcrumb; issueDetailBreadcrumb?: IssueDetailBreadcrumb;
issueDetailSource?: IssueDetailSource; issueDetailSource?: IssueDetailSource;
issueDetailInboxQuickArchiveArmed?: boolean; issueDetailInboxQuickArchiveArmed?: boolean;
issueDetailHeaderSeed?: IssueDetailHeaderSeed;
}; };
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from"; const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
@ -25,6 +40,58 @@ function isIssueDetailSource(value: unknown): value is IssueDetailSource {
return value === "issues" || value === "inbox"; 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 { function readIssueDetailSource(state: unknown): IssueDetailSource | null {
if (typeof state !== "object" || state === null) return null; if (typeof state !== "object" || state === null) return null;
const source = (state as IssueDetailLocationState).issueDetailSource; const source = (state as IssueDetailLocationState).issueDetailSource;
@ -96,10 +163,14 @@ function readStoredIssueDetailLocationState(issuePathId: string): IssueDetailLoc
: null; : null;
const source = inferIssueDetailSource(parsed, breadcrumb); const source = inferIssueDetailSource(parsed, breadcrumb);
if (!breadcrumb || !source) return null; if (!breadcrumb || !source) return null;
const headerSeed = isIssueDetailHeaderSeed(parsed.issueDetailHeaderSeed)
? parsed.issueDetailHeaderSeed
: undefined;
return { return {
issueDetailBreadcrumb: breadcrumb, issueDetailBreadcrumb: breadcrumb,
issueDetailSource: source, issueDetailSource: source,
issueDetailInboxQuickArchiveArmed: parsed.issueDetailInboxQuickArchiveArmed === true, issueDetailInboxQuickArchiveArmed: parsed.issueDetailInboxQuickArchiveArmed === true,
issueDetailHeaderSeed: headerSeed,
}; };
} catch { } catch {
return null; return null;
@ -115,11 +186,13 @@ function normalizeIssueDetailLocationState(
if (isIssueDetailBreadcrumb(candidate)) { if (isIssueDetailBreadcrumb(candidate)) {
const source = inferIssueDetailSource(state as Partial<IssueDetailLocationState>, candidate); const source = inferIssueDetailSource(state as Partial<IssueDetailLocationState>, candidate);
if (!source) return null; if (!source) return null;
const headerSeed = readIssueDetailHeaderSeed(state) ?? undefined;
return { return {
issueDetailBreadcrumb: candidate, issueDetailBreadcrumb: candidate,
issueDetailSource: source, issueDetailSource: source,
issueDetailInboxQuickArchiveArmed: issueDetailInboxQuickArchiveArmed:
(state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true, (state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true,
issueDetailHeaderSeed: headerSeed,
}; };
} }
} }

View file

@ -4,7 +4,7 @@ import {
type SerializedLinkNode, type SerializedLinkNode,
} from "@lexical/link"; } from "@lexical/link";
const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//; const CUSTOM_MENTION_URL_RE = /^(agent|project|skill):\/\//;
export class MentionAwareLinkNode extends LinkNode { export class MentionAwareLinkNode extends LinkNode {
static getType(): string { static getType(): string {

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" });
});
});

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;
}

View file

@ -66,6 +66,7 @@ describe("upsertInterruptedRun", () => {
runId: "run-1", runId: "run-1",
status: "cancelled", status: "cancelled",
agentId: "agent-1", agentId: "agent-1",
adapterType: "codex_local",
startedAt: "2026-04-08T21:00:00.000Z", startedAt: "2026-04-08T21:00:00.000Z",
finishedAt: "2026-04-08T21:00:10.000Z", finishedAt: "2026-04-08T21:00:10.000Z",
createdAt: "2026-04-08T21:00:00.000Z", createdAt: "2026-04-08T21:00:00.000Z",
@ -80,6 +81,7 @@ describe("upsertInterruptedRun", () => {
runId: "run-1", runId: "run-1",
status: "running", status: "running",
agentId: "agent-1", agentId: "agent-1",
adapterType: "codex_local",
startedAt: "2026-04-08T21:00:00.000Z", startedAt: "2026-04-08T21:00:00.000Z",
finishedAt: null, finishedAt: null,
createdAt: "2026-04-08T21:00:00.000Z", createdAt: "2026-04-08T21:00:00.000Z",
@ -93,6 +95,7 @@ describe("upsertInterruptedRun", () => {
runId: "run-1", runId: "run-1",
status: "cancelled", status: "cancelled",
agentId: "agent-1", agentId: "agent-1",
adapterType: "codex_local",
startedAt: "2026-04-08T21:00:00.000Z", startedAt: "2026-04-08T21:00:00.000Z",
finishedAt: "2026-04-08T21:00:11.000Z", finishedAt: "2026-04-08T21:00:11.000Z",
createdAt: "2026-04-08T21:00:00.000Z", createdAt: "2026-04-08T21:00:00.000Z",

View file

@ -4,6 +4,7 @@ import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
export interface InterruptRunSource { export interface InterruptRunSource {
id: string; id: string;
agentId: string; agentId: string;
adapterType: string;
startedAt: Date | string | null; startedAt: Date | string | null;
createdAt: Date | string; createdAt: Date | string;
invocationSource: string; invocationSource: string;
@ -30,6 +31,7 @@ export function upsertInterruptedRun(
runId: run.id, runId: run.id,
status: "cancelled", status: "cancelled",
agentId: run.agentId, agentId: run.agentId,
adapterType: run.adapterType,
startedAt: toIsoString(run.startedAt), startedAt: toIsoString(run.startedAt),
finishedAt, finishedAt,
createdAt: toIsoString(run.createdAt) ?? finishedAt, createdAt: toIsoString(run.createdAt) ?? finishedAt,

View file

@ -18,11 +18,18 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useGeneralSettings } from "../context/GeneralSettingsContext"; import { useGeneralSettings } from "../context/GeneralSettingsContext";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import {
applyIssueFilters,
countActiveIssueFilters,
defaultIssueFilterState,
type IssueFilterState,
} from "../lib/issue-filters";
import { import {
armIssueDetailInboxQuickArchive, armIssueDetailInboxQuickArchive,
createIssueDetailLocationState, createIssueDetailLocationState,
createIssueDetailPath, createIssueDetailPath,
rememberIssueDetailLocationState, rememberIssueDetailLocationState,
withIssueDetailHeaderSeed,
} from "../lib/issueDetailBreadcrumb"; } from "../lib/issueDetailBreadcrumb";
import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts"; import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
@ -34,6 +41,7 @@ import {
issueActivityText, issueActivityText,
issueTrailingColumns, issueTrailingColumns,
} from "../components/IssueColumns"; } from "../components/IssueColumns";
import { IssueFiltersPopover } from "../components/IssueFiltersPopover";
import { IssueRow } from "../components/IssueRow"; import { IssueRow } from "../components/IssueRow";
import { SwipeToArchive } from "../components/SwipeToArchive"; import { SwipeToArchive } from "../components/SwipeToArchive";
@ -60,10 +68,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { import {
Inbox as InboxIcon, Inbox as InboxIcon,
AlertTriangle, AlertTriangle,
Check,
ChevronRight, ChevronRight,
Layers,
XCircle, XCircle,
X, X,
RotateCcw, RotateCcw,
@ -84,22 +95,26 @@ import {
getInboxKeyboardSelectionIndex, getInboxKeyboardSelectionIndex,
getLatestFailedRunsByAgent, getLatestFailedRunsByAgent,
getRecentTouchedIssues, getRecentTouchedIssues,
groupInboxWorkItems,
isInboxEntityDismissed, isInboxEntityDismissed,
isMineInboxTab, isMineInboxTab,
loadInboxIssueColumns, loadInboxIssueColumns,
loadInboxNesting, loadInboxNesting,
loadInboxWorkItemGroupBy,
normalizeInboxIssueColumns, normalizeInboxIssueColumns,
resolveInboxNestingEnabled, resolveInboxNestingEnabled,
resolveIssueWorkspaceName, resolveIssueWorkspaceName,
resolveInboxSelectionIndex, resolveInboxSelectionIndex,
saveInboxIssueColumns, saveInboxIssueColumns,
saveInboxNesting, saveInboxNesting,
InboxApprovalFilter, saveInboxWorkItemGroupBy,
type InboxApprovalFilter,
type InboxIssueColumn, type InboxIssueColumn,
saveLastInboxTab, saveLastInboxTab,
shouldShowInboxSection, shouldShowInboxSection,
type InboxTab, type InboxTab,
type InboxWorkItem, type InboxWorkItem,
type InboxWorkItemGroupBy,
} from "../lib/inbox"; } from "../lib/inbox";
import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge"; import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge";
@ -121,6 +136,13 @@ type NavEntry =
| { type: "top"; index: number; item: InboxWorkItem } | { type: "top"; index: number; item: InboxWorkItem }
| { type: "child"; parentIndex: number; issue: Issue }; | { 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 { function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null; if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean); const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@ -596,6 +618,8 @@ export function Inbox() {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything"); const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all"); const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const [issueFilters, setIssueFilters] = useState<IssueFilterState>(defaultIssueFilterState);
const [groupBy, setGroupBy] = useState<InboxWorkItemGroupBy>(() => loadInboxWorkItemGroupBy());
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns); const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts(); const { dismissed: dismissedAlerts, dismiss: dismissAlert } = useDismissedInboxAlerts();
const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId); const { dismissedAtByKey, dismiss: dismissInboxItem } = useInboxDismissals(selectedCompanyId);
@ -633,6 +657,11 @@ export function Inbox() {
queryFn: () => projectsApi.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!),
queryFn: () => issuesApi.listLabels(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
const { data: executionWorkspaces = [] } = useQuery({ const { data: executionWorkspaces = [] } = useQuery({
queryKey: selectedCompanyId queryKey: selectedCompanyId
@ -688,20 +717,21 @@ export function Inbox() {
}); });
const { data: issues, isLoading: isIssuesLoading } = useQuery({ const { data: issues, isLoading: isIssuesLoading } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!), queryKey: [...queryKeys.issues.list(selectedCompanyId!), "with-routine-executions"],
queryFn: () => issuesApi.list(selectedCompanyId!), queryFn: () => issuesApi.list(selectedCompanyId!, { includeRoutineExecutions: true }),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const { const {
data: mineIssuesRaw = [], data: mineIssuesRaw = [],
isLoading: isMineIssuesLoading, isLoading: isMineIssuesLoading,
} = useQuery({ } = useQuery({
queryKey: queryKeys.issues.listMineByMe(selectedCompanyId!), queryKey: [...queryKeys.issues.listMineByMe(selectedCompanyId!), "with-routine-executions"],
queryFn: () => queryFn: () =>
issuesApi.list(selectedCompanyId!, { issuesApi.list(selectedCompanyId!, {
touchedByUserId: "me", touchedByUserId: "me",
inboxArchivedByUserId: "me", inboxArchivedByUserId: "me",
status: INBOX_MINE_ISSUE_STATUS_FILTER, status: INBOX_MINE_ISSUE_STATUS_FILTER,
includeRoutineExecutions: true,
}), }),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
@ -709,11 +739,12 @@ export function Inbox() {
data: touchedIssuesRaw = [], data: touchedIssuesRaw = [],
isLoading: isTouchedIssuesLoading, isLoading: isTouchedIssuesLoading,
} = useQuery({ } = useQuery({
queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId!), queryKey: [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "with-routine-executions"],
queryFn: () => queryFn: () =>
issuesApi.list(selectedCompanyId!, { issuesApi.list(selectedCompanyId!, {
touchedByUserId: "me", touchedByUserId: "me",
status: INBOX_MINE_ISSUE_STATUS_FILTER, status: INBOX_MINE_ISSUE_STATUS_FILTER,
includeRoutineExecutions: true,
}), }),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
@ -723,20 +754,29 @@ export function Inbox() {
queryFn: () => heartbeatsApi.list(selectedCompanyId!), queryFn: () => heartbeatsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]); const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]); 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( const unreadTouchedIssues = useMemo(
() => touchedIssues.filter((issue) => issue.isUnreadForMe), () => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe),
[touchedIssues], [visibleTouchedIssues],
); );
const issuesToRender = useMemo( const issuesToRender = useMemo(
() => { () => {
if (tab === "mine") return mineIssues; if (tab === "mine") return visibleMineIssues;
if (tab === "unread") return unreadTouchedIssues; if (tab === "unread") return unreadTouchedIssues;
return touchedIssues; return visibleTouchedIssues;
}, },
[tab, mineIssues, touchedIssues, unreadTouchedIssues], [tab, visibleMineIssues, visibleTouchedIssues, unreadTouchedIssues],
); );
const agentById = useMemo(() => { const agentById = useMemo(() => {
@ -802,7 +842,6 @@ export function Inbox() {
() => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)), () => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
[availableIssueColumnSet, visibleIssueColumnSet], [availableIssueColumnSet, visibleIssueColumnSet],
); );
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
const failedRuns = useMemo( const failedRuns = useMemo(
() => () =>
@ -935,11 +974,36 @@ export function Inbox() {
}); });
}, []); }, []);
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(new Set()); const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(new Set());
const { displayItems: nestedWorkItems, childrenByIssueId } = useMemo( const groupedSections = useMemo<InboxGroupedSection[]>(() => {
() => nestingEnabled return groupInboxWorkItems(filteredWorkItems, groupBy).map((group) => {
? buildInboxNesting(filteredWorkItems) const nestedGroup = nestingEnabled && group.items.some((item) => item.kind === "issue")
: { displayItems: filteredWorkItems, childrenByIssueId: new Map<string, Issue[]>() }, ? buildInboxNesting(group.items)
[filteredWorkItems, nestingEnabled], : { 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) => { const toggleInboxParentCollapse = useCallback((parentId: string) => {
setCollapsedInboxParents((prev) => { setCollapsedInboxParents((prev) => {
@ -953,21 +1017,24 @@ export function Inbox() {
// Build flat navigation list including expanded children for keyboard traversal // Build flat navigation list including expanded children for keyboard traversal
const flatNavItems = useMemo((): NavEntry[] => { const flatNavItems = useMemo((): NavEntry[] => {
const entries: NavEntry[] = []; const entries: NavEntry[] = [];
for (let i = 0; i < nestedWorkItems.length; i++) { let topIndex = 0;
const item = nestedWorkItems[i]; for (const group of groupedSections) {
entries.push({ type: "top", index: i, item }); for (const item of group.displayItems) {
if (item.kind === "issue") { entries.push({ type: "top", index: topIndex, item });
const children = childrenByIssueId.get(item.issue.id); if (item.kind === "issue") {
const isExpanded = children?.length && !collapsedInboxParents.has(item.issue.id); const children = group.childrenByIssueId.get(item.issue.id);
if (isExpanded) { const isExpanded = children?.length && !collapsedInboxParents.has(item.issue.id);
for (const child of children) { if (isExpanded) {
entries.push({ type: "child", parentIndex: i, issue: child }); for (const child of children) {
entries.push({ type: "child", parentIndex: topIndex, issue: child });
}
} }
} }
topIndex += 1;
} }
} }
return entries; return entries;
}, [nestedWorkItems, childrenByIssueId, collapsedInboxParents]); }, [groupedSections, collapsedInboxParents]);
const agentName = (id: string | null) => { const agentName = (id: string | null) => {
if (!id) return null; if (!id) return null;
@ -985,6 +1052,13 @@ export function Inbox() {
} }
setIssueColumns(visibleIssueColumns.filter((value) => value !== column)); setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
}, [setIssueColumns, visibleIssueColumns]); }, [setIssueColumns, visibleIssueColumns]);
const updateIssueFilters = useCallback((patch: Partial<IssueFilterState>) => {
setIssueFilters((previous) => ({ ...previous, ...patch }));
}, []);
const updateGroupBy = useCallback((nextGroupBy: InboxWorkItemGroupBy) => {
setGroupBy(nextGroupBy);
saveInboxWorkItemGroupBy(nextGroupBy);
}, []);
const approveMutation = useMutation({ const approveMutation = useMutation({
mutationFn: (id: string) => approvalsApi.approve(id), 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 // Cancel in-flight refetches so they don't overwrite our optimistic update
const queryKeys_ = [ const queryKeys_ = [
queryKeys.issues.listMineByMe(selectedCompanyId!), [...queryKeys.issues.listMineByMe(selectedCompanyId!), "with-routine-executions"],
queryKeys.issues.listTouchedByMe(selectedCompanyId!), [...queryKeys.issues.listTouchedByMe(selectedCompanyId!), "with-routine-executions"],
queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId!), queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId!),
]; ];
await Promise.all(queryKeys_.map((qk) => queryClient.cancelQueries({ queryKey: qk }))); 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 // Use refs for keyboard handler to avoid stale closures
const kbStateRef = useRef({ const kbStateRef = useRef({
workItems: nestedWorkItems, workItems: groupedSections,
flatNavItems, flatNavItems,
selectedIndex, selectedIndex,
canArchive: canArchiveFromTab, canArchive: canArchiveFromTab,
@ -1257,7 +1331,7 @@ export function Inbox() {
readItems, readItems,
}); });
kbStateRef.current = { kbStateRef.current = {
workItems: nestedWorkItems, workItems: groupedSections,
flatNavItems, flatNavItems,
selectedIndex, selectedIndex,
canArchive: canArchiveFromTab, canArchive: canArchiveFromTab,
@ -1386,13 +1460,15 @@ export function Inbox() {
const { issue, item } = resolveNavEntry(st.selectedIndex); const { issue, item } = resolveNavEntry(st.selectedIndex);
if (issue) { if (issue) {
const pathId = issue.identifier ?? issue.id; const pathId = issue.identifier ?? issue.id;
const detailState = armIssueDetailInboxQuickArchive(issueLinkState); const detailState = armIssueDetailInboxQuickArchive(withIssueDetailHeaderSeed(issueLinkState, issue));
rememberIssueDetailLocationState(pathId, detailState); rememberIssueDetailLocationState(pathId, detailState);
act.navigate(createIssueDetailPath(pathId), { state: detailState }); act.navigate(createIssueDetailPath(pathId), { state: detailState });
} else if (item) { } else if (item) {
if (item.kind === "issue") { if (item.kind === "issue") {
const pathId = item.issue.identifier ?? item.issue.id; const pathId = item.issue.identifier ?? item.issue.id;
const detailState = armIssueDetailInboxQuickArchive(issueLinkState); const detailState = armIssueDetailInboxQuickArchive(
withIssueDetailHeaderSeed(issueLinkState, item.issue),
);
rememberIssueDetailLocationState(pathId, detailState); rememberIssueDetailLocationState(pathId, detailState);
act.navigate(createIssueDetailPath(pathId), { state: detailState }); act.navigate(createIssueDetailPath(pathId), { state: detailState });
} else if (item.kind === "approval") { } else if (item.kind === "approval") {
@ -1435,7 +1511,7 @@ export function Inbox() {
dashboard.costs.monthUtilizationPercent >= 80 && dashboard.costs.monthUtilizationPercent >= 80 &&
!dismissedAlerts.has("alert:budget"); !dismissedAlerts.has("alert:budget");
const hasAlerts = showAggregateAgentError || showBudgetAlert; const hasAlerts = showAggregateAgentError || showBudgetAlert;
const showWorkItemsSection = nestedWorkItems.length > 0; const showWorkItemsSection = totalVisibleWorkItems > 0;
const showAlertsSection = shouldShowInboxSection({ const showAlertsSection = shouldShowInboxSection({
tab, tab,
hasItems: hasAlerts, hasItems: hasAlerts,
@ -1460,11 +1536,12 @@ export function Inbox() {
!isRunsLoading; !isRunsLoading;
const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0; 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)); .filter((issue) => issue.isUnreadForMe && !fadingOutIssues.has(issue.id) && !archivingIssueIds.has(issue.id));
const unreadIssueIds = markAllReadIssues const unreadIssueIds = markAllReadIssues
.map((issue) => issue.id); .map((issue) => issue.id);
const canMarkAllRead = unreadIssueIds.length > 0; const canMarkAllRead = unreadIssueIds.length > 0;
const activeIssueFilterCount = countActiveIssueFilters(issueFilters, true);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
@ -1518,6 +1595,50 @@ export function Inbox() {
> >
<ListTree className="h-3.5 w-3.5" /> <ListTree className="h-3.5 w-3.5" />
</Button> </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 <IssueColumnPicker
availableColumns={availableIssueColumns} availableColumns={availableIssueColumns}
visibleColumnSet={visibleIssueColumnSet} visibleColumnSet={visibleIssueColumnSet}
@ -1633,197 +1754,70 @@ export function Inbox() {
<div> <div>
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card"> <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; let flatIdx = 0;
const topFlatIndex = new Map<number, number>(); const topFlatIndex = new Map<string, number>();
const childFlatIndex = new Map<string, number>(); const childFlatIndex = new Map<string, number>();
for (let ti = 0; ti < nestedWorkItems.length; ti++) { for (const group of groupedSections) {
topFlatIndex.set(ti, flatIdx); for (const topItem of group.displayItems) {
flatIdx++; const itemKey = `${group.key}:${getWorkItemKey(topItem)}`;
const topItem = nestedWorkItems[ti]; topFlatIndex.set(itemKey, flatIdx);
if (topItem.kind === "issue") { flatIdx++;
const children = childrenByIssueId.get(topItem.issue.id); if (topItem.kind === "issue") {
const isExp = children?.length && !collapsedInboxParents.has(topItem.issue.id); const children = group.childrenByIssueId.get(topItem.issue.id);
if (isExp) { const isExpanded = children?.length && !collapsedInboxParents.has(topItem.issue.id);
for (const c of children) { if (isExpanded) {
childFlatIndex.set(c.id, flatIdx); for (const child of children) {
flatIdx++; childFlatIndex.set(child.id, flatIdx);
flatIdx++;
}
} }
} }
} }
} }
return nestedWorkItems.flatMap((item, index) => { const renderInboxIssue = ({
const navIdx = topFlatIndex.get(index) ?? index; issue,
const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => ( depth,
<div selected,
key={`sel-${key}`} hasChildren = false,
data-inbox-item isExpanded = false,
className="relative" childCount = 0,
onClick={() => setSelectedIndex(navIdx)} collapseParentId = null,
> }: {
{child} issue: Issue;
</div> depth: number;
); selected: boolean;
const todayCutoff = Date.now() - 24 * 60 * 60 * 1000; hasChildren?: boolean;
const showTodayDivider = isExpanded?: boolean;
index > 0 && childCount?: number;
item.timestamp > 0 && collapseParentId?: string | null;
item.timestamp < todayCutoff && }) => {
nestedWorkItems[index - 1].timestamp >= todayCutoff; const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const elements: ReactNode[] = []; const isFading = fadingOutIssues.has(issue.id);
if (showTodayDivider) { const isArchiving = archivingIssueIds.has(issue.id);
elements.push( const project = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
<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;
return ( return (
<IssueRow <IssueRow
key={`issue:${iss.id}`} key={`issue:${issue.id}`}
issue={iss} issue={issue}
issueLinkState={issueLinkState} issueLinkState={issueLinkState}
selected={sel} selected={selected}
className={ className={
isArch isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out" : "transition-all duration-200 ease-out"
} }
desktopMetaLeading={ desktopMetaLeading={
<> <>
{nestingEnabled ? ( {nestingEnabled ? (
depth === 0 && hasChildren ? ( depth === 0 && hasChildren && collapseParentId ? (
<button <button
type="button" type="button"
className="hidden w-4 shrink-0 items-center justify-center sm:inline-flex" className="hidden w-4 shrink-0 items-center justify-center sm:inline-flex"
onClick={(e) => { onClick={(event) => {
e.preventDefault(); event.preventDefault();
e.stopPropagation(); event.stopPropagation();
toggleInboxParentCollapse(issue.id); toggleInboxParentCollapse(collapseParentId);
}} }}
> >
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} /> <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" /> <span className="hidden w-4 shrink-0 sm:block" />
) )
) : null} ) : null}
{depth > 0 ? ( {depth > 0 ? <span className="hidden w-4 shrink-0 sm:block" /> : null}
<span className="hidden w-4 shrink-0 sm:block" />
) : null}
<InboxIssueMetaLeading <InboxIssueMetaLeading
issue={iss} issue={issue}
isLive={liveIssueIds.has(iss.id)} isLive={liveIssueIds.has(issue.id)}
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")} showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")} showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
/> />
@ -1845,47 +1837,44 @@ export function Inbox() {
} }
titleSuffix={hasChildren && !isExpanded && depth === 0 ? ( titleSuffix={hasChildren && !isExpanded && depth === 0 ? (
<span className="ml-1.5 text-xs text-muted-foreground"> <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> </span>
) : undefined} ) : undefined}
mobileMeta={issueActivityText(iss).toLowerCase()} mobileMeta={issueActivityText(issue).toLowerCase()}
mobileLeading={ mobileLeading={
depth === 0 && hasChildren ? ( depth === 0 && hasChildren && collapseParentId ? (
<button type="button" onClick={(e) => { <button
e.preventDefault(); type="button"
e.stopPropagation(); onClick={(event) => {
toggleInboxParentCollapse(issue.id); event.preventDefault();
}}> event.stopPropagation();
toggleInboxParentCollapse(collapseParentId);
}}
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} /> <ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
</button> </button>
) : undefined ) : undefined
} }
unreadState={ unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
isUnread ? "visible" : isFading ? "fading" : "hidden" onMarkRead={() => markReadMutation.mutate(issue.id)}
} onArchive={canArchiveFromTab ? () => archiveIssueMutation.mutate(issue.id) : undefined}
onMarkRead={() => markReadMutation.mutate(iss.id)} archiveDisabled={isArchiving || archiveIssueMutation.isPending}
onArchive={
canArchiveFromTab
? () => archiveIssueMutation.mutate(iss.id)
: undefined
}
archiveDisabled={isArch || archiveIssueMutation.isPending}
desktopTrailing={ desktopTrailing={
visibleTrailingIssueColumns.length > 0 ? ( visibleTrailingIssueColumns.length > 0 ? (
<InboxIssueTrailingColumns <InboxIssueTrailingColumns
issue={iss} issue={issue}
columns={visibleTrailingIssueColumns} columns={visibleTrailingIssueColumns}
projectName={proj?.name ?? null} projectName={project?.name ?? null}
projectColor={proj?.color ?? null} projectColor={project?.color ?? null}
workspaceName={resolveIssueWorkspaceName(iss, { workspaceName={resolveIssueWorkspaceName(issue, {
executionWorkspaceById, executionWorkspaceById,
projectWorkspaceById, projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId, defaultProjectWorkspaceIdByProjectId,
})} })}
assigneeName={agentName(iss.assigneeAgentId)} assigneeName={agentName(issue.assigneeAgentId)}
currentUserId={currentUserId} currentUserId={currentUserId}
parentIdentifier={iss.parentId ? (issueById.get(iss.parentId)?.identifier ?? null) : null} parentIdentifier={issue.parentId ? (issueById.get(issue.parentId)?.identifier ?? null) : null}
parentTitle={iss.parentId ? (issueById.get(iss.parentId)?.title ?? null) : null} parentTitle={issue.parentId ? (issueById.get(issue.parentId)?.title ?? null) : null}
/> />
) : undefined ) : undefined
} }
@ -1893,49 +1882,224 @@ export function Inbox() {
); );
}; };
// Render parent issue let previousTimestamp = Number.POSITIVE_INFINITY;
const parentRow = renderInboxIssue(issue, 0, isSelected); return groupedSections.flatMap((group, groupIndex) => {
elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? ( const elements: ReactNode[] = [];
<SwipeToArchive if (group.label) {
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);
elements.push( elements.push(
<div <div
key={`sel-issue:${child.id}`} key={`group-${group.key}`}
data-inbox-item className={cn(
className="relative" "border-b border-border/70 bg-muted/30 px-4 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground",
onClick={() => setSelectedIndex(cNavIdx)} groupIndex > 0 && "border-t border-border",
)}
> >
{canArchiveFromTab ? ( {group.label}
<SwipeToArchive
key={`issue:${child.id}`}
selected={isChildSelected}
disabled={isChildArchiving || archiveIssueMutation.isPending}
onArchive={() => archiveIssueMutation.mutate(child.id)}
>
{childRow}
</SwipeToArchive>
) : childRow}
</div>, </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>
</div> </div>

View file

@ -14,6 +14,7 @@ import {
issueChatUxReassignOptions, issueChatUxReassignOptions,
issueChatUxReviewComments, issueChatUxReviewComments,
issueChatUxReviewEvents, issueChatUxReviewEvents,
issueChatUxSubmittingComments,
issueChatUxTranscriptsByRunId, issueChatUxTranscriptsByRunId,
} from "../fixtures/issueChatUxFixtures"; } from "../fixtures/issueChatUxFixtures";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@ -25,6 +26,7 @@ const highlights = [
"Running assistant replies with streamed text, reasoning, tool cards, and background status notes", "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", "Historical issue events and linked runs rendered inline with the chat timeline",
"Queued user messages, settled assistant comments, and feedback controls", "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", "Empty and disabled-composer states without relying on live backend data",
]; ];
@ -285,6 +287,26 @@ export function IssueChatUxLab() {
/> />
</LabSection> </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"> <div className="grid gap-6 xl:grid-cols-2">
<LabSection <LabSection
eyebrow="Settled review" eyebrow="Settled review"

View file

@ -23,6 +23,7 @@ import {
createIssueDetailPath, createIssueDetailPath,
readIssueDetailLocationState, readIssueDetailLocationState,
readIssueDetailBreadcrumb, readIssueDetailBreadcrumb,
readIssueDetailHeaderSeed,
rememberIssueDetailLocationState, rememberIssueDetailLocationState,
} from "../lib/issueDetailBreadcrumb"; } from "../lib/issueDetailBreadcrumb";
import { import {
@ -50,10 +51,10 @@ import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"
import { ApprovalCard } from "../components/ApprovalCard"; import { ApprovalCard } from "../components/ApprovalCard";
import { InlineEditor } from "../components/InlineEditor"; import { InlineEditor } from "../components/InlineEditor";
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread"; import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
import { useLiveRunTranscripts } from "../components/transcript/useLiveRunTranscripts";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssueProperties } from "../components/IssueProperties"; import { IssueProperties } from "../components/IssueProperties";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
import { PageSkeleton } from "../components/PageSkeleton";
import type { MentionOption } from "../components/MarkdownEditor"; import type { MentionOption } from "../components/MarkdownEditor";
import { ImageGalleryModal } from "../components/ImageGalleryModal"; import { ImageGalleryModal } from "../components/ImageGalleryModal";
import { ScrollToBottom } from "../components/ScrollToBottom"; import { ScrollToBottom } from "../components/ScrollToBottom";
@ -70,6 +71,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatIssueActivityAction } from "@/lib/activity-format"; import { formatIssueActivityAction } from "@/lib/activity-format";
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
import { import {
Activity as ActivityIcon, Activity as ActivityIcon,
Check, Check,
@ -91,7 +93,6 @@ import {
type ActivityEvent, type ActivityEvent,
type Agent, type Agent,
type FeedbackVote, type FeedbackVote,
type FeedbackVoteValue,
type Issue, type Issue,
type IssueAttachment, type IssueAttachment,
type IssueComment, 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 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; const ISSUE_COMMENT_PAGE_SIZE = 50;
function keepPreviousData<T>(previousData: T | undefined) { 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() { export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>(); const { issueId } = useParams<{ issueId: string }>();
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
@ -309,10 +387,15 @@ export function IssueDetail() {
const [galleryIndex, setGalleryIndex] = useState(0); const [galleryIndex, setGalleryIndex] = useState(0);
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]); const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0); const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
const [issueChatInitialTranscriptReady, setIssueChatInitialTranscriptReady] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null); const lastMarkedReadIssueIdRef = useRef<string | null>(null);
const commentComposerRef = useRef<IssueChatComposerHandle | null>(null); const commentComposerRef = useRef<IssueChatComposerHandle | null>(null);
useEffect(() => {
setIssueChatInitialTranscriptReady(false);
}, [issueId]);
const { data: issue, isLoading, error } = useQuery({ const { data: issue, isLoading, error } = useQuery({
queryKey: queryKeys.issues.detail(issueId!), queryKey: queryKeys.issues.detail(issueId!),
queryFn: () => issuesApi.get(issueId!), queryFn: () => issuesApi.get(issueId!),
@ -358,6 +441,14 @@ export function IssueDetail() {
placeholderData: keepPreviousData, 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({ const { data: linkedApprovals } = useQuery({
queryKey: queryKeys.issues.approvals(issueId!), queryKey: queryKeys.issues.approvals(issueId!),
queryFn: () => issuesApi.listApprovals(issueId!), queryFn: () => issuesApi.listApprovals(issueId!),
@ -376,12 +467,7 @@ export function IssueDetail() {
queryKey: queryKeys.issues.liveRuns(issueId!), queryKey: queryKeys.issues.liveRuns(issueId!),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!),
enabled: !!issueId, enabled: !!issueId,
refetchInterval: (query) => { refetchInterval: 3000,
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;
},
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
@ -389,25 +475,11 @@ export function IssueDetail() {
queryKey: queryKeys.issues.activeRun(issueId!), queryKey: queryKeys.issues.activeRun(issueId!),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"), enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"),
refetchInterval: (query) => refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000,
(liveRuns?.length ?? 0) > 0
? false
: query.state.data
? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS
: IDLE_ISSUE_RUN_POLL_INTERVAL_MS,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; 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( const runningIssueRun = useMemo(
() => ( () => (
activeRun?.status === "running" activeRun?.status === "running"
@ -420,6 +492,10 @@ export function IssueDetail() {
() => readIssueDetailLocationState(issueId, location.state, location.search), () => readIssueDetailLocationState(issueId, location.state, location.search),
[issueId, location.state, location.search], [issueId, location.state, location.search],
); );
const issueHeaderSeed = useMemo(
() => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState),
[location.state, resolvedIssueDetailState],
);
const sourceBreadcrumb = useMemo( const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" }, () => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
[issueId, location.state, location.search], [issueId, location.state, location.search],
@ -430,8 +506,14 @@ export function IssueDetail() {
const liveIds = new Set<string>(); const liveIds = new Set<string>();
for (const r of liveRuns ?? []) liveIds.add(r.id); for (const r of liveRuns ?? []) liveIds.add(r.id);
if (activeRun) liveIds.add(activeRun.id); if (activeRun) liveIds.add(activeRun.id);
if (liveIds.size === 0) return linkedRuns ?? []; const historicalRuns = liveIds.size === 0
return (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId)); ? (linkedRuns ?? [])
: (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId));
return historicalRuns.map((run) => ({
...run,
adapterType: run.adapterType,
hasStoredOutput: (run.logBytes ?? 0) > 0,
}));
}, [linkedRuns, liveRuns, activeRun]); }, [linkedRuns, liveRuns, activeRun]);
const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({ const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({
@ -500,6 +582,23 @@ export function IssueDetail() {
for (const a of agents ?? []) map.set(a.id, a); for (const a of agents ?? []) map.set(a.id, a);
return map; return map;
}, [agents]); }, [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 mentionOptions = useMemo<MentionOption[]>(() => {
const options: MentionOption[] = []; const options: MentionOption[] = [];
@ -699,6 +798,10 @@ export function IssueDetail() {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
}, [issueId, queryClient]); }, [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(() => { const invalidateIssueRunState = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
@ -885,6 +988,10 @@ export function IssueDetail() {
current.filter((entry) => entry.clientId !== context.optimisticCommentId), 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>>( queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(issueId!), queryKeys.issues.comments(issueId!),
(current) => current ? { (current) => current ? {
@ -912,7 +1019,7 @@ export function IssueDetail() {
}); });
}, },
onSettled: (_result, _error, variables) => { onSettled: (_result, _error, variables) => {
invalidateIssueDetail(); invalidateIssueThreadLazily();
if (variables.interrupt) { if (variables.interrupt) {
invalidateIssueRunState(); invalidateIssueRunState();
} }
@ -1011,7 +1118,7 @@ export function IssueDetail() {
}); });
}, },
onSettled: (_result, _error, variables) => { onSettled: (_result, _error, variables) => {
invalidateIssueDetail(); invalidateIssueThreadLazily();
if (variables.interrupt) { if (variables.interrupt) {
invalidateIssueRunState(); 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(() => { useEffect(() => {
const titleLabel = issue?.title ?? issueId ?? "Issue"; const titleLabel = issue?.title ?? issueId ?? "Issue";
setBreadcrumbs([ setBreadcrumbs([
@ -1480,18 +1540,26 @@ export function IssueDetail() {
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
}; };
const issueChatInitialLoading = const issueChatCoreInitialLoading =
(commentsLoading && commentPages === undefined) (commentsLoading && commentPages === undefined)
|| (activityLoading && activity === undefined) || (activityLoading && activity === undefined)
|| (linkedRunsLoading && linkedRuns === undefined) || (linkedRunsLoading && linkedRuns === undefined)
|| (liveRunsLoading && liveRuns === undefined) || (liveRunsLoading && liveRuns === undefined)
|| (activeRunLoading && activeRun === undefined); || (activeRunLoading && activeRun === undefined);
useEffect(() => {
if (issueChatInitialTranscriptReady) return;
if (issueChatCoreInitialLoading || issueChatTranscriptHydrating) return;
setIssueChatInitialTranscriptReady(true);
}, [issueChatCoreInitialLoading, issueChatInitialTranscriptReady, issueChatTranscriptHydrating]);
const issueChatInitialLoading =
issueChatCoreInitialLoading
|| (!issueChatInitialTranscriptReady && issueChatTranscriptHydrating);
const activityInitialLoading = const activityInitialLoading =
(activityLoading && activity === undefined) (activityLoading && activity === undefined)
|| (linkedRunsLoading && linkedRuns === undefined); || (linkedRunsLoading && linkedRuns === undefined);
const attachmentsInitialLoading = attachmentsLoading && attachments === 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 (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!issue) return null; if (!issue) return null;
@ -2075,19 +2143,44 @@ export function IssueDetail() {
issueStatus={issue.status} issueStatus={issue.status}
agentMap={agentMap} agentMap={agentMap}
currentUserId={currentUserId} currentUserId={currentUserId}
enableLiveTranscriptPolling={false}
transcriptsByRunId={issueChatTranscriptByRun}
hasOutputForRun={issueChatHasOutputForRun}
draftKey={`paperclip:issue-comment-draft:${issue.id}`} draftKey={`paperclip:issue-comment-draft:${issue.id}`}
enableReassign enableReassign
reassignOptions={commentReassignOptions} reassignOptions={commentReassignOptions}
currentAssigneeValue={actualAssigneeValue} currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue} suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions} mentions={mentionOptions}
onInterruptQueued={handleInterruptQueued}
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
composerDisabledReason={commentComposerDisabledReason} composerDisabledReason={commentComposerDisabledReason}
onVote={handleCommentVote} onVote={async (commentId, vote, options) => {
onAdd={handleCommentAdd} await feedbackVoteMutation.mutateAsync({
imageUploadHandler={handleCommentImageUpload} targetType: "issue_comment",
onAttachImage={handleCommentAttachImage} 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 onCancelRun={runningIssueRun
? async () => { ? async () => {
await interruptQueuedComment.mutateAsync(runningIssueRun.id); await interruptQueuedComment.mutateAsync(runningIssueRun.id);

View file

@ -80,8 +80,13 @@ export function Issues() {
}, [setBreadcrumbs]); }, [setBreadcrumbs]);
const { data: issues, isLoading, error } = useQuery({ const { data: issues, isLoading, error } = useQuery({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"], queryKey: [
queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }), ...queryKeys.issues.list(selectedCompanyId!),
"participant-agent",
participantAgentId ?? "__all__",
"with-routine-executions",
],
queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId, includeRoutineExecutions: true }),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
@ -110,6 +115,7 @@ export function Issues() {
initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined} initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined}
initialSearch={initialSearch} initialSearch={initialSearch}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
enableRoutineVisibilityFilter
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
searchFilters={participantAgentId ? { participantAgentId } : undefined} searchFilters={participantAgentId ? { participantAgentId } : undefined}
/> />