From 03dff1a29afaebe8705027e17926175aec9ec5bc Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 9 Apr 2026 10:26:17 -0500 Subject: [PATCH] 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 0235fdbc..86ee374d 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 190cb077..4f8d9fc6 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 00000000..c6bf9177 --- /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 344c262c..6ffd524e 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 a86d7f68..62e99530 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 d94922a0..954fe51b 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 b1f43d49..46f887ae 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 26c969e3..feca6161 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 062cbcf7..02e0d4b4 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 076b9702..b39b959c 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 884517b4..1313b8e7 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 117e5b13..99f0cbd1 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 26db7266..c3f0a9d7 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 09df3f03..13488489 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 16e6faf5..96ea92e8 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 198a1a16..576a9ede 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 c4e02e49..ba89d1cd 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 b5809f46..461d429c 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 fa9a889b..1027ff4c 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 60719785..e1ecffc4 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} />