From 03dff1a29afaebe8705027e17926175aec9ec5bc Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 9 Apr 2026 10:26:17 -0500 Subject: [PATCH 1/2] Refine issue workflow surfaces and live updates --- server/src/__tests__/activity-routes.test.ts | 3 +- .../issue-execution-policy-routes.test.ts | 77 +- ...issue-update-comment-wakeup-routes.test.ts | 202 +++++ server/src/routes/issues.ts | 74 +- server/src/services/activity.ts | 11 +- server/src/services/heartbeat.ts | 12 - ui/src/api/activity.ts | 2 + ui/src/components/ActiveAgentsPanel.tsx | 4 +- ui/src/components/CommentThread.tsx | 425 +++++------ ui/src/components/InstanceSidebar.tsx | 2 + ui/src/components/IssueChatThread.tsx | 99 ++- .../components/IssueDocumentsSection.test.tsx | 47 ++ ui/src/components/IssueDocumentsSection.tsx | 8 +- ui/src/components/IssueFiltersPopover.tsx | 232 ++++++ ui/src/components/IssueRow.tsx | 11 +- ui/src/components/IssuesList.test.tsx | 63 ++ ui/src/components/IssuesList.tsx | 291 ++----- ui/src/components/IssuesQuicklook.tsx | 3 +- ui/src/components/Layout.tsx | 29 +- ui/src/components/LiveRunWidget.tsx | 24 +- ui/src/components/MarkdownEditor.test.tsx | 101 ++- ui/src/components/MarkdownEditor.tsx | 225 ++++-- ui/src/components/MobileBottomNav.tsx | 2 + ui/src/components/NewIssueDialog.test.tsx | 19 + ui/src/components/NewIssueDialog.tsx | 6 +- .../transcript/useLiveRunTranscripts.test.tsx | 73 ++ .../transcript/useLiveRunTranscripts.ts | 58 +- ui/src/context/LiveUpdatesProvider.test.ts | 76 ++ ui/src/context/LiveUpdatesProvider.tsx | 17 +- ui/src/fixtures/issueChatUxFixtures.ts | 17 + ui/src/lib/inbox.test.ts | 32 +- ui/src/lib/inbox.ts | 79 ++ ui/src/lib/issue-chat-messages.test.ts | 64 ++ ui/src/lib/issue-chat-messages.ts | 66 +- ui/src/lib/issue-filters.ts | 89 +++ ui/src/lib/issueChatTranscriptRuns.test.ts | 30 + ui/src/lib/issueChatTranscriptRuns.ts | 42 ++ ui/src/lib/issueDetailBreadcrumb.test.ts | 130 ++++ ui/src/lib/issueDetailBreadcrumb.ts | 73 ++ ui/src/lib/mention-aware-link-node.ts | 2 +- ui/src/lib/navigation-scroll.test.ts | 86 +++ ui/src/lib/navigation-scroll.ts | 45 ++ ui/src/lib/optimistic-issue-runs.test.ts | 3 + ui/src/lib/optimistic-issue-runs.ts | 2 + ui/src/pages/Inbox.tsx | 710 +++++++++++------- ui/src/pages/IssueChatUxLab.tsx | 22 + ui/src/pages/IssueDetail.tsx | 265 ++++--- ui/src/pages/Issues.tsx | 10 +- 48 files changed, 2800 insertions(+), 1163 deletions(-) create mode 100644 server/src/__tests__/issue-update-comment-wakeup-routes.test.ts create mode 100644 ui/src/components/IssueFiltersPopover.tsx create mode 100644 ui/src/lib/issue-filters.ts create mode 100644 ui/src/lib/issueChatTranscriptRuns.test.ts create mode 100644 ui/src/lib/issueChatTranscriptRuns.ts create mode 100644 ui/src/lib/navigation-scroll.test.ts create mode 100644 ui/src/lib/navigation-scroll.ts diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 0235fdbce9..86ee374dbd 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -62,6 +62,7 @@ describe("activity routes", () => { mockActivityService.runsForIssue.mockResolvedValue([ { runId: "run-1", + adapterType: "codex_local", }, ]); @@ -72,6 +73,6 @@ describe("activity routes", () => { expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475"); expect(mockIssueService.getById).not.toHaveBeenCalled(); expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1"); - expect(res.body).toEqual([{ runId: "run-1" }]); + expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]); }); }); diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index 190cb077af..4f8d9fc626 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -60,19 +60,17 @@ vi.mock("../services/index.js", () => ({ workProductService: () => ({}), })); -function createApp( - actor: Record = { - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }, -) { +function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = actor; + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; next(); }); app.use("/api", issueRoutes({} as any, {} as any)); @@ -139,63 +137,4 @@ describe("issue execution policy routes", () => { expect(updatePatch.executionState).toBeUndefined(); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); - - it("rejects agent stage advances from non-participants", async () => { - const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const approverAgentId = "44444444-4444-4444-8444-444444444444"; - const executorAgentId = "22222222-2222-4222-8222-222222222222"; - const policy = normalizeIssueExecutionPolicy({ - stages: [ - { - id: "11111111-1111-4111-8111-111111111111", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - { - id: "55555555-5555-4555-8555-555555555555", - type: "approval", - participants: [{ type: "agent", agentId: approverAgentId }], - }, - ], - })!; - const issue = { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - companyId: "company-1", - status: "in_review", - assigneeAgentId: reviewerAgentId, - assigneeUserId: null, - createdByUserId: "local-board", - identifier: "PAP-1000", - title: "Execution policy guard", - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: "11111111-1111-4111-8111-111111111111", - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { type: "agent", agentId: executorAgentId }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; - mockIssueService.getById.mockResolvedValue(issue); - - const res = await request( - createApp({ - type: "agent", - agentId: approverAgentId, - companyId: "company-1", - source: "api_key", - runId: "run-1", - }), - ) - .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") - .send({ status: "done", comment: "Skipping review." }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("active review participant"); - expect(mockIssueService.update).not.toHaveBeenCalled(); - }); }); diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts new file mode 100644 index 0000000000..c6bf91779d --- /dev/null +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -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 = {}) { + 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", + }), + }), + ); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 344c262c73..6ffd524e66 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -96,13 +96,6 @@ function executionPrincipalsEqual( return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId; } -function executionParticipantMatchesAgent( - participant: ParsedExecutionState["currentParticipant"] | null, - agentId: string | null | undefined, -) { - return Boolean(agentId) && participant?.type === "agent" && participant.agentId === agentId; -} - function buildExecutionStageWakeContext(input: { state: ParsedExecutionState; wakeRole: ExecutionStageWakeContext["wakeRole"]; @@ -1386,14 +1379,10 @@ export function issueRoutes( ? (updateFields.executionPolicy as NormalizedExecutionPolicy | null) : previousExecutionPolicy; - const requestedStatus = typeof updateFields.status === "string" ? updateFields.status : undefined; - const requestedAssigneePatchProvided = - req.body.assigneeAgentId !== undefined || req.body.assigneeUserId !== undefined; - const transition = applyIssueExecutionPolicyTransition({ issue: existing, policy: nextExecutionPolicy, - requestedStatus, + requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined, requestedAssigneePatch: { assigneeAgentId: req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null), @@ -1419,27 +1408,6 @@ export function issueRoutes( } Object.assign(updateFields, transition.patch); - const effectiveExecutionState = parseIssueExecutionState( - transition.patch.executionState !== undefined ? transition.patch.executionState : existing.executionState, - ); - const isUnauthorizedAgentStageMutation = - req.actor.type === "agent" && - req.actor.agentId && - existing.status === "in_review" && - transition.workflowControlledAssignment && - !transition.decision && - effectiveExecutionState?.status === "pending" && - ( - (requestedStatus !== undefined && requestedStatus !== "in_review") || - requestedAssigneePatchProvided - ) && - !executionParticipantMatchesAgent(effectiveExecutionState.currentParticipant, req.actor.agentId); - if (isUnauthorizedAgentStageMutation) { - const stageLabel = effectiveExecutionState.currentStageType ?? "execution"; - res.status(403).json({ error: `Only the active ${stageLabel} participant can update this stage` }); - return; - } - const nextAssigneeAgentId = updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null); const nextAssigneeUserId = @@ -1733,6 +1701,7 @@ export function issueRoutes( reason: "issue_assigned", payload: { issueId: issue.id, + ...(comment ? { commentId: comment.id } : {}), mutation: "update", ...(interruptedRunId ? { interruptedRunId } : {}), }, @@ -1740,6 +1709,13 @@ export function issueRoutes( requestedByActorId: actor.actorId, contextSnapshot: { issueId: issue.id, + ...(comment + ? { + taskId: issue.id, + commentId: comment.id, + wakeCommentId: comment.id, + } + : {}), source: "issue.update", ...(interruptedRunId ? { interruptedRunId } : {}), }, @@ -1767,6 +1743,38 @@ export function issueRoutes( } if (commentBody && comment) { + const assigneeId = issue.assigneeAgentId; + const actorIsAgent = actor.actorType === "agent"; + const selfComment = actorIsAgent && actor.actorId === assigneeId; + const skipAssigneeCommentWake = selfComment || isClosed; + + if (assigneeId && !assigneeChanged && !skipAssigneeCommentWake) { + addWakeup(assigneeId, { + source: "automation", + triggerDetail: "system", + reason: reopened ? "issue_reopened_via_comment" : "issue_commented", + payload: { + issueId: id, + commentId: comment.id, + mutation: "comment", + ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: id, + taskId: id, + commentId: comment.id, + wakeCommentId: comment.id, + source: reopened ? "issue.comment.reopen" : "issue.comment", + wakeReason: reopened ? "issue_reopened_via_comment" : "issue_commented", + ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + }); + } + let mentionedIds: string[] = []; try { mentionedIds = await svc.findMentionedAgents(issue.companyId, commentBody); diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index a86d7f68ba..62e995307d 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -1,6 +1,6 @@ import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { activityLog, heartbeatRuns, issues } from "@paperclipai/db"; +import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db"; export interface ActivityFilters { companyId: string; @@ -66,14 +66,23 @@ export function activityService(db: Db) { runId: heartbeatRuns.id, status: heartbeatRuns.status, agentId: heartbeatRuns.agentId, + adapterType: agents.adapterType, startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, invocationSource: heartbeatRuns.invocationSource, usageJson: heartbeatRuns.usageJson, resultJson: heartbeatRuns.resultJson, + logBytes: heartbeatRuns.logBytes, }) .from(heartbeatRuns) + .innerJoin( + agents, + and( + eq(agents.id, heartbeatRuns.agentId), + eq(agents.companyId, heartbeatRuns.companyId), + ), + ) .where( and( eq(heartbeatRuns.companyId, companyId), diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index d94922a0f7..954fe51b59 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2011,18 +2011,6 @@ export function heartbeatService(db: Db) { return { outcome: "not_applicable" as const, queuedRun: null }; } - const wakeReason = readNonEmptyString(contextSnapshot.wakeReason); - if (wakeReason === "issue_commented" || wakeReason === "issue_comment_mentioned" || wakeReason === "issue_reopened_via_comment") { - if (run.issueCommentStatus !== "not_applicable") { - await patchRunIssueCommentStatus(run.id, { - issueCommentStatus: "not_applicable", - issueCommentSatisfiedByCommentId: null, - issueCommentRetryQueuedAt: null, - }); - } - return { outcome: "not_applicable" as const, queuedRun: null }; - } - const postedComment = await findRunIssueComment(run.id, run.companyId, issueId); if (postedComment) { await patchRunIssueCommentStatus(run.id, { diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index b1f43d49df..46f887ae28 100644 --- a/ui/src/api/activity.ts +++ b/ui/src/api/activity.ts @@ -5,12 +5,14 @@ export interface RunForIssue { runId: string; status: string; agentId: string; + adapterType: string; startedAt: string | null; finishedAt: string | null; createdAt: string; invocationSource: string; usageJson: Record | null; resultJson: Record | null; + logBytes?: number | null; } export interface IssueForRun { diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 26c969e3c1..feca6161a8 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -30,8 +30,8 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { const runs = liveRuns ?? []; const { data: issues } = useQuery({ - queryKey: queryKeys.issues.list(companyId), - queryFn: () => issuesApi.list(companyId), + queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"], + queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }), enabled: runs.length > 0, }); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 062cbcf7a3..02e0d4b40e 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; +import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { Link, useLocation } from "react-router-dom"; import type { Agent, @@ -631,7 +631,7 @@ const TimelineList = memo(function TimelineList({ ); }); -export const CommentThread = memo(function CommentThread({ +export function CommentThread({ comments, queuedComments = [], linkedApprovals = [], @@ -662,9 +662,17 @@ export const CommentThread = memo(function CommentThread({ interruptingQueuedRunId = null, composerDisabledReason = null, }: CommentThreadProps) { + const [body, setBody] = useState(""); + const [reopen, setReopen] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [attaching, setAttaching] = useState(false); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; + const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); const [highlightCommentId, setHighlightCommentId] = useState(null); const [votingTargetId, setVotingTargetId] = useState(null); + const editorRef = useRef(null); + const attachInputRef = useRef(null); + const draftTimer = useRef | null>(null); const location = useLocation(); const hasScrolledRef = useRef(false); @@ -730,6 +738,29 @@ export const CommentThread = memo(function CommentThread({ })); }, [agentMap, providedMentions]); + useEffect(() => { + if (!draftKey) return; + setBody(loadDraft(draftKey)); + }, [draftKey]); + + useEffect(() => { + if (!draftKey) return; + if (draftTimer.current) clearTimeout(draftTimer.current); + draftTimer.current = setTimeout(() => { + saveDraft(draftKey, body); + }, DRAFT_DEBOUNCE_MS); + }, [body, draftKey]); + + useEffect(() => { + return () => { + if (draftTimer.current) clearTimeout(draftTimer.current); + }; + }, []); + + useEffect(() => { + setReassignTarget(effectiveSuggestedAssigneeValue); + }, [effectiveSuggestedAssigneeValue]); + // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { const hash = location.hash; @@ -748,25 +779,72 @@ export const CommentThread = memo(function CommentThread({ } }, [location.hash, comments, queuedComments]); - const handleFeedbackVote = useCallback( - async ( - commentId: string, - vote: FeedbackVoteValue, - options?: { allowSharing?: boolean; reason?: string }, - ) => { - if (!onVote) return; - setVotingTargetId(commentId); - try { - await onVote(commentId, vote, options); - } finally { - setVotingTargetId(null); - } - }, - [onVote], - ); + async function handleSubmit() { + const trimmed = body.trim(); + if (!trimmed) return; + const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; + const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null; + const submittedBody = trimmed; + + setSubmitting(true); + setBody(""); + try { + await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined); + if (draftKey) clearDraft(draftKey); + setReopen(true); + setReassignTarget(effectiveSuggestedAssigneeValue); + } catch { + setBody((current) => + restoreSubmittedCommentDraft({ + currentBody: current, + submittedBody, + }), + ); + // Parent mutation handlers surface the failure and the draft is restored for retry. + } finally { + setSubmitting(false); + } + } + + async function handleAttachFile(evt: ChangeEvent) { + 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 ( +
+

Timeline ({timeline.length + queuedComments.length})

- const timelineSection = useMemo( - () => ( - ), - [ - timeline, agentMap, currentUserId, companyId, projectId, - onApproveApproval, onRejectApproval, pendingApprovalAction, - feedbackVoteByTargetId, feedbackDataSharingPreference, - onVote, handleFeedbackVote, votingTargetId, highlightCommentId, - feedbackTermsUrl, - ], - ); - - return ( -
-

Timeline ({timeline.length + queuedComments.length})

- - {timelineSection} {liveRunSlot} @@ -840,216 +903,92 @@ export const CommentThread = memo(function CommentThread({ {composerDisabledReason}
) : ( - +
+ +
+ {(imageUploadHandler || onAttachImage) && ( +
+ + +
+ )} + + {enableReassign && reassignOptions.length > 0 && ( + { + if (!option) return Assignee; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + renderOption={(option) => { + if (!option.id) return {option.label}; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + /> + )} + +
+
)}
); -}); - -CommentThread.displayName = "CommentThread"; - -/* ---- Isolated Composer (body state lives here, not in CommentThread) ---- */ - -interface CommentComposerProps { - onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; - mentions: MentionOption[]; - imageUploadHandler?: (file: File) => Promise; - onAttachImage?: (file: File) => Promise; - draftKey?: string; - enableReassign: boolean; - reassignOptions: InlineEntityOption[]; - currentAssigneeValue: string; - suggestedAssigneeValue: string; - agentMap?: Map; } - -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(null); - const attachInputRef = useRef(null); - const draftTimer = useRef | 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) { - 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 ( -
- -
- {(imageUploadHandler || onAttachImage) && ( -
- - -
- )} - - {enableReassign && reassignOptions.length > 0 && ( - { - if (!option) return Assignee; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - renderOption={(option) => { - if (!option.id) return {option.label}; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - /> - )} - -
-
- ); -}); diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx index 076b970265..b39b959c65 100644 --- a/ui/src/components/InstanceSidebar.tsx +++ b/ui/src/components/InstanceSidebar.tsx @@ -3,6 +3,7 @@ import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from " import { NavLink } from "@/lib/router"; import { pluginsApi } from "@/api/plugins"; import { queryKeys } from "@/lib/queryKeys"; +import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll"; import { SidebarNavItem } from "./SidebarNavItem"; export function InstanceSidebar() { @@ -33,6 +34,7 @@ export function InstanceSidebar() { [ "rounded-md px-2 py-1.5 text-xs transition-colors", diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 884517b440..1313b8e79f 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -41,6 +41,7 @@ import { type IssueChatTranscriptEntry, type SegmentTiming, } from "../lib/issue-chat-messages"; +import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -907,8 +908,6 @@ function IssueChatUserMessage() { ) : null} ) : null} - {pending ?
Sending...
: null} -
-
- - - - {message.createdAt ? commentDateLabel(message.createdAt) : ""} - - - - {message.createdAt ? formatDateTime(message.createdAt) : ""} - - - -
+ {pending ? ( +
Sending...
+ ) : ( +
+ + + + {message.createdAt ? commentDateLabel(message.createdAt) : ""} + + + + {message.createdAt ? formatDateTime(message.createdAt) : ""} + + + +
+ )} @@ -1820,26 +1823,12 @@ export function IssueChatThread({ return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); }, [activeRun, liveRuns]); const transcriptRuns = useMemo(() => { - const combined = new Map(); - for (const run of displayLiveRuns) { - combined.set(run.id, { - id: run.id, - status: run.status, - adapterType: run.adapterType, - }); - } - for (const run of linkedRuns) { - if (combined.has(run.runId)) continue; - const adapterType = agentMap?.get(run.agentId)?.adapterType; - if (!adapterType) continue; - combined.set(run.runId, { - id: run.runId, - status: run.status, - adapterType, - }); - } - return [...combined.values()]; - }, [agentMap, displayLiveRuns, linkedRuns]); + return resolveIssueChatTranscriptRuns({ + linkedRuns, + liveRuns: displayLiveRuns, + activeRun, + }); + }, [activeRun, displayLiveRuns, linkedRuns]); const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs: enableLiveTranscriptPolling ? transcriptRuns : [], companyId, diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 117e5b13e0..99f0cbd157 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -351,4 +351,51 @@ describe("IssueDocumentsSection", () => { }); queryClient.clear(); }); + + it("wraps the documents header actions so mobile layouts do not overflow", async () => { + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([createIssueDocument()]); + + await act(async () => { + root.render( + + + + + + )} + /> + , + ); + }); + + 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(); + }); }); diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 26db726609..c3f0a9d783 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -683,7 +683,7 @@ export function IssueDocumentsSection({ return (
{isEmpty && !draft?.isNew ? ( -
+
{extraActions}
) : ( -
-

Documents

-
+
+

Documents

+
{extraActions} + + +
+
+ Filters + {activeFilterCount > 0 ? ( + + ) : null} +
+ +
+ Quick filters +
+ {issueQuickFilterPresets.map((preset) => { + const isActive = issueFilterArraysEqual(state.statuses, preset.statuses); + return ( + + ); + })} +
+
+ +
+ +
+
+ Status +
+ {issueStatusOrder.map((status) => ( + + ))} +
+
+ +
+
+ Priority +
+ {issuePriorityOrder.map((priority) => ( + + ))} +
+
+ +
+ Assignee +
+ + {currentUserId ? ( + + ) : null} + {(agents ?? []).map((agent) => ( + + ))} +
+
+ + {labels && labels.length > 0 ? ( +
+ Labels +
+ {labels.map((label) => ( + + ))} +
+
+ ) : null} + + {projects && projects.length > 0 ? ( +
+ Project +
+ {projects.map((project) => ( + + ))} +
+
+ ) : null} + + {enableRoutineVisibilityFilter ? ( +
+ Visibility + +
+ ) : null} +
+
+
+ + + ); +} diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 09df3f038b..1348848909 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -2,7 +2,11 @@ import type { ReactNode } from "react"; import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { X } from "lucide-react"; -import { createIssueDetailPath, rememberIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; +import { + createIssueDetailPath, + rememberIssueDetailLocationState, + withIssueDetailHeaderSeed, +} from "../lib/issueDetailBreadcrumb"; import { cn } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; @@ -48,13 +52,14 @@ export function IssueRow({ const showUnreadSlot = unreadState !== null; const showUnreadDot = unreadState === "visible" || unreadState === "fading"; const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined; + const detailState = withIssueDetailHeaderSeed(issueLinkState, issue); return ( rememberIssueDetailLocationState(issuePathId, issueLinkState)} + onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)} className={cn( "group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1", selected ? "hover:bg-transparent" : "hover:bg-accent/50", diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 16e6faf585..96ea92e825 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -307,4 +307,67 @@ describe("IssuesList", () => { root.unmount(); }); }); + + it("hides routine-backed issues by default and reveals them when the routine filter is enabled", async () => { + const manualIssue = createIssue({ + id: "issue-manual", + identifier: "PAP-10", + title: "Manual issue", + originKind: "manual", + }); + const routineIssue = createIssue({ + id: "issue-routine", + identifier: "PAP-11", + title: "Routine issue", + originKind: "routine_execution", + }); + + const { root } = renderWithQueryClient( + 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(); + }); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 198a1a1619..576a9edeb6 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -9,6 +9,15 @@ import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { groupBy } from "../lib/groupBy"; +import { + applyIssueFilters, + countActiveIssueFilters, + defaultIssueFilterState, + issueFilterLabel, + issuePriorityOrder, + issueStatusOrder, + type IssueFilterState, +} from "../lib/issue-filters"; import { DEFAULT_INBOX_ISSUE_COLUMNS, getAvailableInboxIssueColumns, @@ -27,39 +36,24 @@ import { issueTrailingColumns, } from "./IssueColumns"; import { StatusIcon } from "./StatusIcon"; -import { PriorityIcon } from "./PriorityIcon"; import { EmptyState } from "./EmptyState"; import { Identity } from "./Identity"; +import { IssueFiltersPopover } from "./IssueFiltersPopover"; import { IssueRow } from "./IssueRow"; import { PageSkeleton } from "./PageSkeleton"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; -import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; -import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react"; +import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, Columns3, User, Search } from "lucide-react"; import { KanbanBoard } from "./KanbanBoard"; import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import type { Issue, Project } from "@paperclipai/shared"; - -/* ── Helpers ── */ - -const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"]; -const priorityOrder = ["critical", "high", "medium", "low"]; const ISSUE_SEARCH_DEBOUNCE_MS = 150; -function statusLabel(status: string): string { - return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); -} - /* ── View state ── */ -export type IssueViewState = { - statuses: string[]; - priorities: string[]; - assignees: string[]; - labels: string[]; - projects: string[]; +export type IssueViewState = IssueFilterState & { sortField: "status" | "priority" | "title" | "created" | "updated"; sortDir: "asc" | "desc"; groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none"; @@ -69,11 +63,7 @@ export type IssueViewState = { }; const defaultViewState: IssueViewState = { - statuses: [], - priorities: [], - assignees: [], - labels: [], - projects: [], + ...defaultIssueFilterState, sortField: "updated", sortDir: "desc", groupBy: "none", @@ -81,13 +71,6 @@ const defaultViewState: IssueViewState = { collapsedGroups: [], collapsedParents: [], }; - -const quickFilterPresets = [ - { label: "All", statuses: [] as string[] }, - { label: "Active", statuses: ["todo", "in_progress", "in_review", "blocked"] }, - { label: "Backlog", statuses: ["backlog"] }, - { label: "Done", statuses: ["done", "cancelled"] }, -]; function getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); @@ -100,45 +83,15 @@ function saveViewState(key: string, state: IssueViewState) { localStorage.setItem(key, JSON.stringify(state)); } -function arraysEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - const sa = [...a].sort(); - const sb = [...b].sort(); - return sa.every((v, i) => v === sb[i]); -} - -function toggleInArray(arr: string[], value: string): string[] { - return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; -} - -function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] { - let result = issues; - if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status)); - if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority)); - if (state.assignees.length > 0) { - result = result.filter((issue) => { - for (const assignee of state.assignees) { - if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true; - if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true; - if (issue.assigneeAgentId === assignee) return true; - } - return false; - }); - } - if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id))); - if (state.projects.length > 0) result = result.filter((i) => i.projectId != null && state.projects.includes(i.projectId)); - return result; -} - function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { const sorted = [...issues]; const dir = state.sortDir === "asc" ? 1 : -1; sorted.sort((a, b) => { switch (state.sortField) { case "status": - return dir * (statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status)); + return dir * (issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status)); case "priority": - return dir * (priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority)); + return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.indexOf(b.priority)); case "title": return dir * a.title.localeCompare(b.title); case "created": @@ -152,16 +105,6 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { return sorted; } -function countActiveFilters(state: IssueViewState): number { - let count = 0; - if (state.statuses.length > 0) count++; - if (state.priorities.length > 0) count++; - if (state.assignees.length > 0) count++; - if (state.labels.length > 0) count++; - if (state.projects.length > 0) count++; - return count; -} - /* ── Component ── */ interface Agent { @@ -186,6 +129,7 @@ interface IssuesListProps { searchFilters?: { participantAgentId?: string; }; + enableRoutineVisibilityFilter?: boolean; onSearchChange?: (search: string) => void; onUpdateIssue: (id: string, data: Record) => void; } @@ -247,6 +191,7 @@ export function IssuesList({ initialAssignees, initialSearch, searchFilters, + enableRoutineVisibilityFilter = false, onSearchChange, onUpdateIssue, }: IssuesListProps) { @@ -319,8 +264,15 @@ export function IssuesList({ queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), searchFilters ?? {}, + enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions", ], - queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), + queryFn: () => + issuesApi.list(selectedCompanyId!, { + q: normalizedIssueSearch, + projectId, + ...searchFilters, + ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), + }), enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, placeholderData: (previousData) => previousData, }); @@ -423,9 +375,9 @@ export function IssuesList({ const filtered = useMemo(() => { const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; - const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); + const filteredByControls = applyIssueFilters(sourceIssues, viewState, currentUserId, enableRoutineVisibilityFilter); return sortIssues(filteredByControls, viewState); - }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]); + }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -433,7 +385,7 @@ export function IssuesList({ enabled: !!selectedCompanyId, }); - const activeFilterCount = countActiveFilters(viewState); + const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter); const groupedContent = useMemo(() => { if (viewState.groupBy === "none") { @@ -441,15 +393,15 @@ export function IssuesList({ } if (viewState.groupBy === "status") { const groups = groupBy(filtered, (i) => i.status); - return statusOrder + return issueStatusOrder .filter((s) => groups[s]?.length) - .map((s) => ({ key: s, label: statusLabel(s), items: groups[s]! })); + .map((s) => ({ key: s, label: issueFilterLabel(s), items: groups[s]! })); } if (viewState.groupBy === "priority") { const groups = groupBy(filtered, (i) => i.priority); - return priorityOrder + return issuePriorityOrder .filter((p) => groups[p]?.length) - .map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! })); + .map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! })); } if (viewState.groupBy === "workspace") { const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace"); @@ -581,175 +533,16 @@ export function IssuesList({ title="Choose which issue columns stay visible" /> - {/* Filter */} - - - - - -
-
- Filters - {activeFilterCount > 0 && ( - - )} -
- - {/* Quick filters */} -
- Quick filters -
- {quickFilterPresets.map((preset) => { - const isActive = arraysEqual(viewState.statuses, preset.statuses); - return ( - - ); - })} -
-
- -
- - {/* Multi-column filter sections */} -
- {/* Status */} -
- Status -
- {statusOrder.map((s) => ( - - ))} -
-
- - {/* Priority + Assignee stacked in right column */} -
- {/* Priority */} -
- Priority -
- {priorityOrder.map((p) => ( - - ))} -
-
- - {/* Assignee */} -
- Assignee -
- - {currentUserId && ( - - )} - {(agents ?? []).map((agent) => ( - - ))} -
-
- - {labels && labels.length > 0 && ( -
- Labels -
- {labels.map((label) => ( - - ))} -
-
- )} - - {projects && projects.length > 0 && ( -
- Project -
- {projects.map((project) => ( - - ))} -
-
- )} -
-
-
- - + ({ id: project.id, name: project.name }))} + labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} + currentUserId={currentUserId} + enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} + /> {/* Sort (list view only) */} {viewState.viewMode === "list" && ( diff --git a/ui/src/components/IssuesQuicklook.tsx b/ui/src/components/IssuesQuicklook.tsx index c4e02e49b5..ba89d1cdf3 100644 --- a/ui/src/components/IssuesQuicklook.tsx +++ b/ui/src/components/IssuesQuicklook.tsx @@ -3,7 +3,7 @@ import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { StatusIcon } from "./StatusIcon"; -import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; +import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb"; import { timeAgo } from "../lib/timeAgo"; interface IssuesQuicklookProps { @@ -36,6 +36,7 @@ export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) { {issue.title} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index b5809f464b..461d429ce3 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Moon, Settings, Sun } from "lucide-react"; -import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; +import { Link, Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; import { InstanceSidebar } from "./InstanceSidebar"; @@ -32,6 +32,11 @@ import { DEFAULT_INSTANCE_SETTINGS_PATH, normalizeRememberedInstanceSettingsPath, } from "../lib/instance-settings"; +import { + resetNavigationScroll, + SIDEBAR_SCROLL_RESET_STATE, + shouldResetScrollOnNavigation, +} from "../lib/navigation-scroll"; import { queryKeys } from "../lib/queryKeys"; import { scheduleMainContentFocus } from "../lib/main-content-focus"; import { cn } from "../lib/utils"; @@ -66,9 +71,12 @@ export function Layout() { const { companyPrefix } = useParams<{ companyPrefix: string }>(); const navigate = useNavigate(); const location = useLocation(); + const navigationType = useNavigationType(); const isInstanceSettingsRoute = location.pathname.startsWith("/instance/"); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); + const previousPathname = useRef(null); + const mainContentRef = useRef(null); const [mobileNavVisible, setMobileNavVisible] = useState(true); const [instanceSettingsTarget, setInstanceSettingsTarget] = useState(() => readRememberedInstanceSettingsPath()); const [shortcutsOpen, setShortcutsOpen] = useState(false); @@ -271,10 +279,24 @@ export function Layout() { useEffect(() => { if (typeof document === "undefined") return; - const mainContent = document.getElementById("main-content"); + const mainContent = mainContentRef.current; return scheduleMainContentFocus(mainContent); }, [location.pathname]); + useEffect(() => { + const shouldResetScroll = shouldResetScrollOnNavigation({ + previousPathname: previousPathname.current, + pathname: location.pathname, + navigationType, + state: location.state, + }); + + previousPathname.current = location.pathname; + + if (!shouldResetScroll) return; + resetNavigationScroll(mainContentRef.current); + }, [location.pathname, navigationType]); + return (
{ @@ -392,6 +415,7 @@ export function Layout() { + ({ id: project.id, name: project.name }))} + labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} + currentUserId={currentUserId} + enableRoutineVisibilityFilter + /> + + + + + +
+ {([ + ["none", "None"], + ["type", "Type"], + ] as const).map(([value, label]) => ( + + ))} +
+
+
{(() => { - // Pre-compute flat nav index for each top-level item and child issue + // Pre-compute flat nav index for each top-level item and child issue. let flatIdx = 0; - const topFlatIndex = new Map(); + const topFlatIndex = new Map(); const childFlatIndex = new Map(); - for (let ti = 0; ti < nestedWorkItems.length; ti++) { - topFlatIndex.set(ti, flatIdx); - flatIdx++; - const topItem = nestedWorkItems[ti]; - if (topItem.kind === "issue") { - const children = childrenByIssueId.get(topItem.issue.id); - const isExp = children?.length && !collapsedInboxParents.has(topItem.issue.id); - if (isExp) { - for (const c of children) { - childFlatIndex.set(c.id, flatIdx); - flatIdx++; + for (const group of groupedSections) { + for (const topItem of group.displayItems) { + const itemKey = `${group.key}:${getWorkItemKey(topItem)}`; + topFlatIndex.set(itemKey, flatIdx); + flatIdx++; + if (topItem.kind === "issue") { + const children = group.childrenByIssueId.get(topItem.issue.id); + const isExpanded = children?.length && !collapsedInboxParents.has(topItem.issue.id); + if (isExpanded) { + for (const child of children) { + childFlatIndex.set(child.id, flatIdx); + flatIdx++; + } } } } } - return nestedWorkItems.flatMap((item, index) => { - const navIdx = topFlatIndex.get(index) ?? index; - const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => ( -
setSelectedIndex(navIdx)} - > - {child} -
- ); - const todayCutoff = Date.now() - 24 * 60 * 60 * 1000; - const showTodayDivider = - index > 0 && - item.timestamp > 0 && - item.timestamp < todayCutoff && - nestedWorkItems[index - 1].timestamp >= todayCutoff; - const elements: ReactNode[] = []; - if (showTodayDivider) { - elements.push( -
-
- - Earlier - -
, - ); - } - const isSelected = selectedIndex === navIdx; - - if (item.kind === "approval") { - const approvalKey = `approval:${item.approval.id}`; - const isArchiving = archivingNonIssueIds.has(approvalKey); - const row = ( - 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 ? ( - handleArchiveNonIssue(approvalKey)} - > - {row} - - ) : row)); - return elements; - } - - if (item.kind === "failed_run") { - const runKey = `run:${item.run.id}`; - const isArchiving = archivingNonIssueIds.has(runKey); - const row = ( - 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 ? ( - handleArchiveNonIssue(runKey)} - > - {row} - - ) : row)); - return elements; - } - - if (item.kind === "join_request") { - const joinKey = `join:${item.joinRequest.id}`; - const isArchiving = archivingNonIssueIds.has(joinKey); - const row = ( - 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 ? ( - handleArchiveNonIssue(joinKey)} - > - {row} - - ) : row)); - return elements; - } - - const issue = item.issue; - const childIssues = childrenByIssueId.get(issue.id) ?? []; - const hasChildren = childIssues.length > 0; - const isExpanded = hasChildren && !collapsedInboxParents.has(issue.id); - - const renderInboxIssue = (iss: Issue, depth: number, sel: boolean) => { - const isUnread = iss.isUnreadForMe && !fadingOutIssues.has(iss.id); - const isFading = fadingOutIssues.has(iss.id); - const isArch = archivingIssueIds.has(iss.id); - const proj = iss.projectId ? projectById.get(iss.projectId) ?? null : null; + const renderInboxIssue = ({ + issue, + depth, + selected, + hasChildren = false, + isExpanded = false, + childCount = 0, + collapseParentId = null, + }: { + issue: Issue; + depth: number; + selected: boolean; + hasChildren?: boolean; + isExpanded?: boolean; + childCount?: number; + collapseParentId?: string | null; + }) => { + const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); + const isFading = fadingOutIssues.has(issue.id); + const isArchiving = archivingIssueIds.has(issue.id); + const project = issue.projectId ? projectById.get(issue.projectId) ?? null : null; return ( {nestingEnabled ? ( - depth === 0 && hasChildren ? ( + depth === 0 && hasChildren && collapseParentId ? ( ) : undefined } - unreadState={ - isUnread ? "visible" : isFading ? "fading" : "hidden" - } - onMarkRead={() => markReadMutation.mutate(iss.id)} - onArchive={ - canArchiveFromTab - ? () => archiveIssueMutation.mutate(iss.id) - : undefined - } - archiveDisabled={isArch || archiveIssueMutation.isPending} + unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"} + onMarkRead={() => markReadMutation.mutate(issue.id)} + onArchive={canArchiveFromTab ? () => archiveIssueMutation.mutate(issue.id) : undefined} + archiveDisabled={isArchiving || archiveIssueMutation.isPending} desktopTrailing={ visibleTrailingIssueColumns.length > 0 ? ( ) : undefined } @@ -1893,49 +1882,224 @@ export function Inbox() { ); }; - // Render parent issue - const parentRow = renderInboxIssue(issue, 0, isSelected); - elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? ( - archiveIssueMutation.mutate(issue.id)} - > - {parentRow} - - ) : parentRow)); - - // Render children if expanded - if (isExpanded) { - for (const child of childIssues) { - const cNavIdx = childFlatIndex.get(child.id) ?? -1; - const isChildSelected = selectedIndex === cNavIdx; - const childRow = renderInboxIssue(child, 1, isChildSelected); - const isChildArchiving = archivingIssueIds.has(child.id); + let previousTimestamp = Number.POSITIVE_INFINITY; + return groupedSections.flatMap((group, groupIndex) => { + const elements: ReactNode[] = []; + if (group.label) { elements.push(
setSelectedIndex(cNavIdx)} + key={`group-${group.key}`} + className={cn( + "border-b border-border/70 bg-muted/30 px-4 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", + groupIndex > 0 && "border-t border-border", + )} > - {canArchiveFromTab ? ( - archiveIssueMutation.mutate(child.id)} - > - {childRow} - - ) : childRow} + {group.label}
, ); } - } - 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) => ( +
setSelectedIndex(navIdx)} + > + {child} +
+ ); + 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( +
+
+ + Earlier + +
, + ); + } + const isSelected = selectedIndex === navIdx; + + if (item.kind === "approval") { + const approvalKey = `approval:${item.approval.id}`; + const isArchiving = archivingNonIssueIds.has(approvalKey); + const row = ( + 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 ? ( + handleArchiveNonIssue(approvalKey)} + > + {row} + + ) : row)); + continue; + } + + if (item.kind === "failed_run") { + const runKey = `run:${item.run.id}`; + const isArchiving = archivingNonIssueIds.has(runKey); + const row = ( + 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 ? ( + handleArchiveNonIssue(runKey)} + > + {row} + + ) : row)); + continue; + } + + if (item.kind === "join_request") { + const joinKey = `join:${item.joinRequest.id}`; + const isArchiving = archivingNonIssueIds.has(joinKey); + const row = ( + 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 ? ( + handleArchiveNonIssue(joinKey)} + > + {row} + + ) : 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 ? ( + archiveIssueMutation.mutate(issue.id)} + > + {parentRow} + + ) : 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( +
setSelectedIndex(childNavIdx)} + > + {canArchiveFromTab ? ( + archiveIssueMutation.mutate(child.id)} + > + {childRow} + + ) : childRow} +
, + ); + } + } + } + + return elements; + }); })()}
diff --git a/ui/src/pages/IssueChatUxLab.tsx b/ui/src/pages/IssueChatUxLab.tsx index fa9a889b5e..1027ff4c35 100644 --- a/ui/src/pages/IssueChatUxLab.tsx +++ b/ui/src/pages/IssueChatUxLab.tsx @@ -14,6 +14,7 @@ import { issueChatUxReassignOptions, issueChatUxReviewComments, issueChatUxReviewEvents, + issueChatUxSubmittingComments, issueChatUxTranscriptsByRunId, } from "../fixtures/issueChatUxFixtures"; import { cn } from "../lib/utils"; @@ -25,6 +26,7 @@ const highlights = [ "Running assistant replies with streamed text, reasoning, tool cards, and background status notes", "Historical issue events and linked runs rendered inline with the chat timeline", "Queued user messages, settled assistant comments, and feedback controls", + "Submitting (pending) message bubble with Sending... label and reduced opacity", "Empty and disabled-composer states without relying on live backend data", ]; @@ -285,6 +287,26 @@ export function IssueChatUxLab() { /> + + + +
(previousData: T | undefined) { @@ -284,6 +281,87 @@ function IssueChatSkeleton() { ); } +function IssueDetailLoadingState({ + headerSeed, +}: { + headerSeed: ReturnType; +}) { + const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null; + + return ( +
+
+ + +
+ {headerSeed ? ( + <> + + + {identifier ? ( + {identifier} + ) : null} + {headerSeed.originKind === "routine_execution" && headerSeed.originId ? ( + + + Routine + + ) : null} + {headerSeed.projectId ? ( + + + + {headerSeed.projectName ?? headerSeed.projectId.slice(0, 8)} + + + ) : ( + + + No project + + )} + + ) : ( + <> + + + + + + )} +
+ + {headerSeed ? ( + <> +

{headerSeed.title}

+
+ + +
+ + ) : ( + <> + + + + )} +
+ + + +
+
+ + +
+ +
+ + +
+ ); +} + export function IssueDetail() { const { issueId } = useParams<{ issueId: string }>(); const { selectedCompanyId } = useCompany(); @@ -309,10 +387,15 @@ export function IssueDetail() { const [galleryIndex, setGalleryIndex] = useState(0); const [optimisticComments, setOptimisticComments] = useState([]); const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0); + const [issueChatInitialTranscriptReady, setIssueChatInitialTranscriptReady] = useState(false); const fileInputRef = useRef(null); const lastMarkedReadIssueIdRef = useRef(null); const commentComposerRef = useRef(null); + useEffect(() => { + setIssueChatInitialTranscriptReady(false); + }, [issueId]); + const { data: issue, isLoading, error } = useQuery({ queryKey: queryKeys.issues.detail(issueId!), queryFn: () => issuesApi.get(issueId!), @@ -358,6 +441,14 @@ export function IssueDetail() { placeholderData: keepPreviousData, }); + const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({ + queryKey: queryKeys.issues.runs(issueId!), + queryFn: () => activityApi.runsForIssue(issueId!), + enabled: !!issueId, + refetchInterval: 5000, + placeholderData: keepPreviousData, + }); + const { data: linkedApprovals } = useQuery({ queryKey: queryKeys.issues.approvals(issueId!), queryFn: () => issuesApi.listApprovals(issueId!), @@ -376,12 +467,7 @@ export function IssueDetail() { queryKey: queryKeys.issues.liveRuns(issueId!), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!), enabled: !!issueId, - refetchInterval: (query) => { - const data = query.state.data as Array | undefined; - return data && data.length > 0 - ? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS - : IDLE_ISSUE_RUN_POLL_INTERVAL_MS; - }, + refetchInterval: 3000, placeholderData: keepPreviousData, }); @@ -389,25 +475,11 @@ export function IssueDetail() { queryKey: queryKeys.issues.activeRun(issueId!), queryFn: () => heartbeatsApi.activeRunForIssue(issueId!), enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"), - refetchInterval: (query) => - (liveRuns?.length ?? 0) > 0 - ? false - : query.state.data - ? ACTIVE_ISSUE_RUN_POLL_INTERVAL_MS - : IDLE_ISSUE_RUN_POLL_INTERVAL_MS, + refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000, placeholderData: keepPreviousData, }); const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; - const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({ - queryKey: queryKeys.issues.runs(issueId!), - queryFn: () => activityApi.runsForIssue(issueId!), - enabled: !!issueId, - refetchInterval: hasLiveRuns - ? ACTIVE_ISSUE_TIMELINE_POLL_INTERVAL_MS - : IDLE_ISSUE_TIMELINE_POLL_INTERVAL_MS, - placeholderData: keepPreviousData, - }); const runningIssueRun = useMemo( () => ( activeRun?.status === "running" @@ -420,6 +492,10 @@ export function IssueDetail() { () => readIssueDetailLocationState(issueId, location.state, location.search), [issueId, location.state, location.search], ); + const issueHeaderSeed = useMemo( + () => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState), + [location.state, resolvedIssueDetailState], + ); const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" }, [issueId, location.state, location.search], @@ -430,8 +506,14 @@ export function IssueDetail() { const liveIds = new Set(); for (const r of liveRuns ?? []) liveIds.add(r.id); if (activeRun) liveIds.add(activeRun.id); - if (liveIds.size === 0) return linkedRuns ?? []; - return (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId)); + const historicalRuns = liveIds.size === 0 + ? (linkedRuns ?? []) + : (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId)); + return historicalRuns.map((run) => ({ + ...run, + adapterType: run.adapterType, + hasStoredOutput: (run.logBytes ?? 0) > 0, + })); }, [linkedRuns, liveRuns, activeRun]); const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({ @@ -500,6 +582,23 @@ export function IssueDetail() { for (const a of agents ?? []) map.set(a.id, a); return map; }, [agents]); + const transcriptRuns = useMemo( + () => + resolveIssueChatTranscriptRuns({ + linkedRuns: timelineRuns, + liveRuns: liveRuns ?? [], + activeRun, + }), + [activeRun, liveRuns, timelineRuns], + ); + const { + transcriptByRun: issueChatTranscriptByRun, + hasOutputForRun: issueChatHasOutputForRun, + isInitialHydrating: issueChatTranscriptHydrating, + } = useLiveRunTranscripts({ + runs: transcriptRuns, + companyId: issue?.companyId ?? selectedCompanyId, + }); const mentionOptions = useMemo(() => { const options: MentionOption[] = []; @@ -699,6 +798,10 @@ export function IssueDetail() { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); }, [issueId, queryClient]); + const invalidateIssueThreadLazily = useCallback(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), refetchType: "inactive" }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!), refetchType: "inactive" }); + }, [issueId, queryClient]); const invalidateIssueRunState = useCallback(() => { queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }); @@ -885,6 +988,10 @@ export function IssueDetail() { current.filter((entry) => entry.clientId !== context.optimisticCommentId), ); } + queryClient.setQueryData( + queryKeys.issues.detail(issueId!), + (current) => current ? { ...current, updatedAt: comment.createdAt } : current, + ); queryClient.setQueryData>( queryKeys.issues.comments(issueId!), (current) => current ? { @@ -912,7 +1019,7 @@ export function IssueDetail() { }); }, onSettled: (_result, _error, variables) => { - invalidateIssueDetail(); + invalidateIssueThreadLazily(); if (variables.interrupt) { invalidateIssueRunState(); } @@ -1011,7 +1118,7 @@ export function IssueDetail() { }); }, onSettled: (_result, _error, variables) => { - invalidateIssueDetail(); + invalidateIssueThreadLazily(); if (variables.interrupt) { invalidateIssueRunState(); } @@ -1213,53 +1320,6 @@ export function IssueDetail() { }, }); - const handleInterruptQueued = useCallback( - async (runId: string) => { - await interruptQueuedComment.mutateAsync(runId); - }, - [interruptQueuedComment.mutateAsync], - ); - - const handleCommentImageUpload = useCallback( - async (file: File) => { - const attachment = await uploadAttachment.mutateAsync(file); - return attachment.contentPath; - }, - [uploadAttachment.mutateAsync], - ); - - const handleCommentAttachImage = useCallback( - async (file: File) => { - await uploadAttachment.mutateAsync(file); - }, - [uploadAttachment.mutateAsync], - ); - - const handleCommentAdd = useCallback( - async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => { - if (reassignment) { - await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); - return; - } - await addComment.mutateAsync({ body, reopen }); - }, - [addComment.mutateAsync, addCommentAndReassign.mutateAsync], - ); - - const handleCommentVote = useCallback( - async (commentId: string, vote: FeedbackVoteValue, options?: { reason?: string; allowSharing?: boolean }) => { - await feedbackVoteMutation.mutateAsync({ - targetType: "issue_comment", - targetId: commentId, - vote, - reason: options?.reason, - allowSharing: options?.allowSharing, - sharingPreferenceAtSubmit: feedbackDataSharingPreference, - }); - }, - [feedbackVoteMutation.mutateAsync, feedbackDataSharingPreference], - ); - useEffect(() => { const titleLabel = issue?.title ?? issueId ?? "Issue"; setBreadcrumbs([ @@ -1480,18 +1540,26 @@ export function IssueDetail() { setTimeout(() => setCopied(false), 2000); }; - const issueChatInitialLoading = + const issueChatCoreInitialLoading = (commentsLoading && commentPages === undefined) || (activityLoading && activity === undefined) || (linkedRunsLoading && linkedRuns === undefined) || (liveRunsLoading && liveRuns === undefined) || (activeRunLoading && activeRun === undefined); + useEffect(() => { + if (issueChatInitialTranscriptReady) return; + if (issueChatCoreInitialLoading || issueChatTranscriptHydrating) return; + setIssueChatInitialTranscriptReady(true); + }, [issueChatCoreInitialLoading, issueChatInitialTranscriptReady, issueChatTranscriptHydrating]); + const issueChatInitialLoading = + issueChatCoreInitialLoading + || (!issueChatInitialTranscriptReady && issueChatTranscriptHydrating); const activityInitialLoading = (activityLoading && activity === undefined) || (linkedRunsLoading && linkedRuns === undefined); const attachmentsInitialLoading = attachmentsLoading && attachments === undefined; - if (isLoading) return ; + if (isLoading) return ; if (error) return

{error.message}

; if (!issue) return null; @@ -2075,19 +2143,44 @@ export function IssueDetail() { issueStatus={issue.status} agentMap={agentMap} currentUserId={currentUserId} + enableLiveTranscriptPolling={false} + transcriptsByRunId={issueChatTranscriptByRun} + hasOutputForRun={issueChatHasOutputForRun} draftKey={`paperclip:issue-comment-draft:${issue.id}`} enableReassign reassignOptions={commentReassignOptions} currentAssigneeValue={actualAssigneeValue} suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentionOptions} - onInterruptQueued={handleInterruptQueued} - interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} composerDisabledReason={commentComposerDisabledReason} - onVote={handleCommentVote} - onAdd={handleCommentAdd} - imageUploadHandler={handleCommentImageUpload} - onAttachImage={handleCommentAttachImage} + onVote={async (commentId, vote, options) => { + await feedbackVoteMutation.mutateAsync({ + targetType: "issue_comment", + targetId: commentId, + vote, + reason: options?.reason, + allowSharing: options?.allowSharing, + sharingPreferenceAtSubmit: feedbackDataSharingPreference, + }); + }} + onAdd={async (body, reopen, reassignment) => { + if (reassignment) { + await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); + return; + } + await addComment.mutateAsync({ body, reopen }); + }} + imageUploadHandler={async (file) => { + const attachment = await uploadAttachment.mutateAsync(file); + return attachment.contentPath; + }} + onAttachImage={async (file) => { + await uploadAttachment.mutateAsync(file); + }} + onInterruptQueued={async (runId) => { + await interruptQueuedComment.mutateAsync(runId); + }} + interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null} onCancelRun={runningIssueRun ? async () => { await interruptQueuedComment.mutateAsync(runningIssueRun.id); diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 607197856d..e1ecffc456 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -80,8 +80,13 @@ export function Issues() { }, [setBreadcrumbs]); const { data: issues, isLoading, error } = useQuery({ - queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"], - queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }), + queryKey: [ + ...queryKeys.issues.list(selectedCompanyId!), + "participant-agent", + participantAgentId ?? "__all__", + "with-routine-executions", + ], + queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId, includeRoutineExecutions: true }), enabled: !!selectedCompanyId, }); @@ -110,6 +115,7 @@ export function Issues() { initialAssignees={searchParams.get("assignee") ? [searchParams.get("assignee")!] : undefined} initialSearch={initialSearch} onSearchChange={handleSearchChange} + enableRoutineVisibilityFilter onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} searchFilters={participantAgentId ? { participantAgentId } : undefined} /> From 4077ccd343464d651469e80164b9a2b522d422f1 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 9 Apr 2026 14:48:12 -0500 Subject: [PATCH 2/2] Fix signoff stage access and comment wake retries --- .../__tests__/issue-execution-policy.test.ts | 64 ++++++++----------- server/src/services/heartbeat.ts | 23 +++++++ server/src/services/issue-execution-policy.ts | 16 +++-- 3 files changed, 60 insertions(+), 43 deletions(-) diff --git a/server/src/__tests__/issue-execution-policy.test.ts b/server/src/__tests__/issue-execution-policy.test.ts index 7271b49935..3d8a649b7d 100644 --- a/server/src/__tests__/issue-execution-policy.test.ts +++ b/server/src/__tests__/issue-execution-policy.test.ts @@ -413,45 +413,33 @@ describe("issue execution policy transitions", () => { const policy = twoStagePolicy(); const reviewStageId = policy.stages[0].id; - it("non-participant stage updates are coerced back to the active stage", () => { - const result = applyIssueExecutionPolicyTransition({ - issue: { - status: "in_review", - assigneeAgentId: qaAgentId, - assigneeUserId: null, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: reviewStageId, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: qaAgentId }, - returnAssignee: { type: "agent", agentId: coderAgentId }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, + it("non-participant cannot advance the active stage", () => { + expect(() => + applyIssueExecutionPolicyTransition({ + issue: { + status: "in_review", + assigneeAgentId: qaAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: { + status: "pending", + currentStageId: reviewStageId, + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: qaAgentId }, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, }, - }, - policy, - requestedStatus: "done", - requestedAssigneePatch: { assigneeUserId: boardUserId }, - actor: { agentId: coderAgentId }, - commentBody: "Trying to bypass review", - }); - - 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(); + policy, + requestedStatus: "done", + requestedAssigneePatch: { assigneeUserId: boardUserId }, + actor: { agentId: coderAgentId }, + commentBody: "Trying to bypass review", + }), + ).toThrow("Only the active reviewer or approver can advance"); }); it("non-participant can still post non-advancing updates", () => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 954fe51b59..45c5efd224 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -707,6 +707,18 @@ export function shouldResetTaskSessionForWake( return false; } +function shouldRequireIssueCommentForWake( + contextSnapshot: Record | 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) { return { stream: "stdout" as const, @@ -2035,6 +2047,17 @@ export function heartbeatService(db: Db) { 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); if (queuedRun) { await appendRunEvent(run, await nextRunEventSeq(run.id), { diff --git a/server/src/services/issue-execution-policy.ts b/server/src/services/issue-execution-policy.ts index 6f4ba7b555..6a3045a1e0 100644 --- a/server/src/services/issue-execution-policy.ts +++ b/server/src/services/issue-execution-policy.ts @@ -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" || !principalsEqual(currentAssignee, currentParticipant) || - !principalsEqual(existingState?.currentParticipant ?? null, currentParticipant) || - (requestedStatus !== undefined && requestedStatus !== "in_review") || - (requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant)) - ) { + !principalsEqual(existingState?.currentParticipant ?? null, currentParticipant); + + if (attemptedStageAdvance && !stageStateDrifted) { + throw unprocessable("Only the active reviewer or approver can advance the current execution stage"); + } + + if (stageStateDrifted) { buildPendingStagePatch({ patch, previous: existingState,