diff --git a/server/src/__tests__/issue-selected-agent-chat-routes.test.ts b/server/src/__tests__/issue-selected-agent-chat-routes.test.ts index 21b9b40b648..75e54a91d3a 100644 --- a/server/src/__tests__/issue-selected-agent-chat-routes.test.ts +++ b/server/src/__tests__/issue-selected-agent-chat-routes.test.ts @@ -45,6 +45,10 @@ const mockAccessDecide = vi.hoisted(() => vi.fn(async (input: { action?: string const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); +const mockResolveHeartbeatSchedulingSuppression = vi.hoisted(() => + vi.fn((): { suppressed: boolean; reason: string | null } => ({ suppressed: false, reason: null })), +); + vi.mock("../services/index.js", () => ({ companyService: () => ({}), accessService: () => ({ @@ -98,6 +102,7 @@ vi.mock("../services/index.js", () => ({ routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined), }), + resolveHeartbeatSchedulingSuppression: mockResolveHeartbeatSchedulingSuppression, workProductService: () => ({}), })); @@ -253,6 +258,25 @@ describe("selected-agent issue chat backend", () => { ASSIGNEE_AGENT_ID, expect.anything(), ); + expect(res.body.wake).toEqual({ suppressed: false, reason: null }); + }); + + it("reports wake suppression so the chat UI can explain a silent no-reply", async () => { + mockResolveHeartbeatSchedulingSuppression.mockReturnValueOnce({ + suppressed: true, + reason: "worktree_instance", + }); + const db = createDbStub( + [makeAgent({ id: CEO_AGENT_ID, name: "CEO", role: "ceo" })], + [], + ); + + const res = await request(await createApp(db)) + .post(`/api/issues/${ISSUE_ID}/selected-agent-chat/comments`) + .send({ body: "hello?" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(res.body.wake).toEqual({ suppressed: true, reason: "worktree_instance" }); }); it("titles new board chat issues from the first selected-agent message and stores the target agent", async () => { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 5b520dc3bcc..ff6d346a861 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -98,6 +98,7 @@ import { executionWorkspaceService, goalService, heartbeatService, + resolveHeartbeatSchedulingSuppression, issueApprovalService, issueRecoveryActionService, issueThreadInteractionService, @@ -8920,6 +8921,10 @@ export function issueRoutes( agentId: targetAgent.id, }, "failed to wake selected-agent chat target on issue comment")); + // Tell the client whether the wake can actually run here: on worktree + // instances scheduling is suppressed, and a silent no-reply chat reads + // as "sending did nothing" (PAP-13345). + const wakeSuppression = resolveHeartbeatSchedulingSuppression(); res.status(201).json({ comment, targetAgent: { @@ -8928,6 +8933,10 @@ export function issueRoutes( role: targetAgent.role, status: targetAgent.status, }, + wake: { + suppressed: wakeSuppression.suppressed, + reason: wakeSuppression.reason, + }, }); }, ); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 246f7b3bb09..2ecc438111d 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -41,6 +41,11 @@ export type SelectedAgentChatTarget = { export type SelectedAgentChatCommentResponse = { comment: IssueComment; targetAgent: SelectedAgentChatTarget; + /** Whether the target-agent wake could be scheduled for this send. */ + wake?: { + suppressed: boolean; + reason: "worktree_instance" | "database_restore_in_progress" | null; + }; }; export type ResolveRecoveryActionResponse = { diff --git a/ui/src/components/ActivityFeed.tsx b/ui/src/components/ActivityFeed.tsx index 15cd08cb73e..af486dfbef0 100644 --- a/ui/src/components/ActivityFeed.tsx +++ b/ui/src/components/ActivityFeed.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import type { ActivityEvent, Agent } from "@paperclipai/shared"; import { activityApi } from "../api/activity"; import { agentsApi } from "../api/agents"; +import { authApi } from "../api/auth"; import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { useCompany } from "../context/CompanyContext"; @@ -245,11 +246,13 @@ function CollapsedFeedGroup({ agentMap, entityNameMap, entityTitleMap, + boardChatIssueRefs, }: { group: CollapsedGroup; agentMap: Map; entityNameMap: Map; entityTitleMap: Map; + boardChatIssueRefs?: ReadonlyMap; }) { const [expanded, setExpanded] = useState(false); const actor = group.latestEvent.actorType === "agent" @@ -301,6 +304,7 @@ function CollapsedFeedGroup({ agentMap={agentMap} entityNameMap={entityNameMap} entityTitleMap={entityTitleMap} + boardChatIssueRefs={boardChatIssueRefs} isMuted /> ))} @@ -360,6 +364,27 @@ export function ActivityFeed({ className }: ActivityFeedProps) { enabled: !!selectedCompanyId, }); + // Conference Room conversations are board_chat issues hidden from the + // default list above; fetch them separately so their events resolve to an + // identifier and deep-link to the room instead of a broken issue URL. + const { data: boardChatIssues } = useQuery({ + queryKey: [...queryKeys.issues.list(selectedCompanyId ?? ""), "board-chat-refs"], + queryFn: () => + issuesApi.list(selectedCompanyId!, { + originKind: "board_chat", + limit: 100, + sortField: "updated", + sortDir: "desc", + }), + enabled: !!selectedCompanyId, + }); + + const { data: session } = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + }); + const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const agentMap = useMemo(() => { const map = new Map(); for (const a of agents ?? []) map.set(a.id, a); @@ -370,14 +395,22 @@ export function ActivityFeed({ className }: ActivityFeedProps) { const map = new Map(); for (const a of agents ?? []) map.set(`agent:${a.id}`, a.name); for (const i of issues ?? []) map.set(`issue:${i.id}`, i.identifier ?? i.id); + for (const i of boardChatIssues ?? []) map.set(`issue:${i.id}`, i.identifier ?? i.id); return map; - }, [agents, issues]); + }, [agents, issues, boardChatIssues]); const entityTitleMap = useMemo(() => { const map = new Map(); for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title); + for (const i of boardChatIssues ?? []) map.set(`issue:${i.id}`, i.title); return map; - }, [issues]); + }, [issues, boardChatIssues]); + + const boardChatIssueRefs = useMemo(() => { + const map = new Map(); + for (const i of boardChatIssues ?? []) map.set(i.id, i.identifier || i.id); + return map; + }, [boardChatIssues]); const entityStatusMap = useMemo(() => { const map = new Map(); @@ -389,6 +422,16 @@ export function ActivityFeed({ className }: ActivityFeedProps) { const processedItems = useMemo(() => { const events = (activity ?? []) .filter((evt) => { + // The board user's own chat messages are not agent activity — they + // already see them in the thread they just typed into (PAP-13345). + if ( + (evt.action === "issue.commented" || evt.action === "issue.comment_added") + && evt.actorType === "user" + && currentUserId + && evt.actorId === currentUserId + ) { + return false; + } const tier = getEventTier(evt); if (!showAllActivity && tier === 3) return false; return matchesFilter(evt, filter); @@ -396,7 +439,7 @@ export function ActivityFeed({ className }: ActivityFeedProps) { .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return collapseSequential(events); - }, [activity, filter, showAllActivity]); + }, [activity, filter, showAllActivity, currentUserId]); const visibleItems = processedItems.slice(0, visibleCount); const hasMore = processedItems.length > visibleCount; @@ -491,6 +534,7 @@ export function ActivityFeed({ className }: ActivityFeedProps) { agentMap={agentMap} entityNameMap={entityNameMap} entityTitleMap={entityTitleMap} + boardChatIssueRefs={boardChatIssueRefs} /> @@ -513,6 +557,7 @@ export function ActivityFeed({ className }: ActivityFeedProps) { entityNameMap={entityNameMap} entityTitleMap={entityTitleMap} entityStatusMap={entityStatusMap} + boardChatIssueRefs={boardChatIssueRefs} isActive={isActiveHeartbeat} /> @@ -531,6 +576,7 @@ export function ActivityFeed({ className }: ActivityFeedProps) { entityNameMap={entityNameMap} entityTitleMap={entityTitleMap} entityStatusMap={entityStatusMap} + boardChatIssueRefs={boardChatIssueRefs} isActive={isActiveHeartbeat} isMuted /> diff --git a/ui/src/components/AssistantChat.tsx b/ui/src/components/AssistantChat.tsx index c870cc2e8a7..21d2cb4864c 100644 --- a/ui/src/components/AssistantChat.tsx +++ b/ui/src/components/AssistantChat.tsx @@ -27,9 +27,15 @@ import { SelectValue, } from "@/components/ui/select"; import type { MentionOption } from "./MarkdownEditor"; +import { accessApi } from "../api/access"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats"; import { issuesApi } from "../api/issues"; +import { + buildCompanyUserLabelMap, + buildCompanyUserProfileMap, + type CompanyUserProfile, +} from "../lib/company-members"; import type { IssueChatComment, IssueChatTranscriptEntry } from "../lib/issue-chat-messages"; import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; @@ -227,6 +233,8 @@ export interface AssistantChatViewProps { companyId?: string | null; projectId?: string | null; currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; + userProfileMap?: ReadonlyMap | null; backgroundWorkChildren?: IssueRelationIssueSummary[]; suppressIssueStatusNotices?: boolean; companyName?: string | null; @@ -240,6 +248,8 @@ export interface AssistantChatViewProps { loading?: boolean; /** Surface a delivery/transport failure inline (CR8). */ errorText?: string | null; + /** Non-error inline notice (e.g. delivered but the agent can't run here). */ + noticeText?: string | null; onRetry?: () => void; onSend: (body: string) => Promise; onStopRun?: (runId: string) => Promise; @@ -285,6 +295,8 @@ export function AssistantChatView({ companyId, projectId, currentUserId, + userLabelMap, + userProfileMap, backgroundWorkChildren = [], suppressIssueStatusNotices, companyName = null, @@ -296,6 +308,7 @@ export function AssistantChatView({ onAttachImage, loading = false, errorText, + noticeText, onRetry, onSend, onStopRun, @@ -439,6 +452,17 @@ export function AssistantChatView({ ) : null} + {noticeText ? ( +
+ +

{noticeText}

+
+ ) : null} + {loading && comments.length === 0 ? (
(null); + const [noticeText, setNoticeText] = useState(null); const [pendingComments, setPendingComments] = useState([]); + // Resolve human author names/avatars (issue-chat parity: "Dotta", not "You"). + const { data: companyMembers } = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(companyId), + queryFn: () => accessApi.listUserDirectory(companyId), + }); + const userLabelMap = useMemo( + () => buildCompanyUserLabelMap(companyMembers?.users), + [companyMembers?.users], + ); + const userProfileMap = useMemo( + () => buildCompanyUserProfileMap(companyMembers?.users), + [companyMembers?.users], + ); + const liveRunsQuery = useQuery({ queryKey: targetAgentId ? queryKeys.issues.selectedAgentChatLiveRuns(issueId, targetAgentId) @@ -655,6 +698,7 @@ export function AssistantChat({ const handleSend = useCallback( async (body: string) => { setErrorText(null); + setNoticeText(null); const optimistic = createOptimisticComment({ issueId, companyId, @@ -676,6 +720,14 @@ export function AssistantChat({ : pending, ), ); + if (response.wake?.suppressed) { + const agentName = response.targetAgent?.name ?? "The agent"; + setNoticeText( + response.wake.reason === "worktree_instance" + ? `Message saved, but agent runs are disabled on this worktree instance — ${agentName} won't reply here.` + : `Message saved, but agent runs are paused on this instance right now — ${agentName} can't reply yet.`, + ); + } await onMessageSent?.(); invalidateRuns(); } catch (err) { @@ -784,6 +836,8 @@ export function AssistantChat({ companyId={companyId} projectId={projectId} currentUserId={currentUserId} + userLabelMap={userLabelMap} + userProfileMap={userProfileMap} backgroundWorkChildren={backgroundWorkChildren} suppressIssueStatusNotices companyName={companyName} @@ -795,6 +849,7 @@ export function AssistantChat({ onAttachImage={handleAttachFile} loading={commentsQuery.isLoading} errorText={errorText} + noticeText={noticeText} onRetry={errorText ? () => setErrorText(null) : undefined} onSend={handleSend} onStopRun={handleStopRun} diff --git a/ui/src/components/FeedCard.tsx b/ui/src/components/FeedCard.tsx index 099071969a1..0040b6e36c7 100644 --- a/ui/src/components/FeedCard.tsx +++ b/ui/src/components/FeedCard.tsx @@ -287,6 +287,7 @@ function resolveContent( agentMap: Map, entityNameMap: Map, entityTitleMap: Map | undefined, + boardChatIssueRefs: ReadonlyMap | undefined, ): CardContent { const details = event.details as Record | null; const actor = event.actorType === "agent" ? agentMap.get(event.actorId) ?? null : null; @@ -319,9 +320,18 @@ function resolveContent( const approvalAgentName = approvalAgentId ? agentMap.get(approvalAgentId)?.name ?? null : null; const approvalType = details?.type as string | undefined; + // Conference Room conversations are backed by board_chat issues that are + // hidden from the regular issues surface — deep-link them to the room, not + // to /issues/ (PAP-13345). + const boardChatRef = event.entityType === "issue" + ? boardChatIssueRefs?.get(event.entityId) ?? null + : null; + const link = isHeartbeatEvent && heartbeatAgentId ? `/agents/${heartbeatAgentId}/runs/${event.entityId}` - : event.entityType === "issue" + : boardChatRef + ? `/board-chat/${boardChatRef}` + : event.entityType === "issue" ? isDocEvent && docKey ? `/issues/${issueSlug}#document-${encodeURIComponent(docKey)}` : `/issues/${issueSlug}` @@ -401,6 +411,9 @@ interface FeedCardProps { agentMap: Map; entityNameMap: Map; entityTitleMap?: Map; + /** Conference Room issue id → conversation ref; events on these issues + * deep-link to /board-chat/{ref} instead of the hidden issue page. */ + boardChatIssueRefs?: ReadonlyMap; /** Retained for call-site compatibility; no longer read — the collapsed * card always shows the event-derived status. Lifecycle aggregation (a * later pass) will pass the live status through its own wrapper. */ @@ -420,13 +433,14 @@ export function FeedCard({ agentMap, entityNameMap, entityTitleMap, + boardChatIssueRefs, isActive = false, isMuted = false, isPinned = false, className, }: FeedCardProps) { const details = event.details as Record | null; - const content = resolveContent(event, agentMap, entityNameMap, entityTitleMap); + const content = resolveContent(event, agentMap, entityNameMap, entityTitleMap, boardChatIssueRefs); const verb = formatVerb(event.action, details, isPinned ? "pinned" : "chronological"); const iconSpec = getIconSpec(event, details, isActive); diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 0b77e486a8d..66e3d4293d4 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -497,7 +497,7 @@ describe("IssueChatThread", () => { }); }); - it("renders assistant preset turns as compact rows and starts live reasoning collapsed", async () => { + it("renders assistant preset turns with issue-chat density and starts live reasoning collapsed", async () => { const root = createRoot(container); const transcriptsByRunId = new Map([ [ @@ -561,7 +561,8 @@ describe("IssueChatThread", () => { }); const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null; - expect(viewport?.className).toContain("space-y-3"); + // Comfortable (issue-chat) density: full spacing, not the old compact rows. + expect(viewport?.className).toContain("space-y-4"); const workingButton = Array.from(container.querySelectorAll("button")).find( (element) => element.textContent?.includes("Working"), ); diff --git a/ui/src/components/chat-thread-presets.test.ts b/ui/src/components/chat-thread-presets.test.ts index 35019849d65..3660b76ac68 100644 --- a/ui/src/components/chat-thread-presets.test.ts +++ b/ui/src/components/chat-thread-presets.test.ts @@ -33,7 +33,8 @@ describe("resolveIssueChatThreadPreset", () => { it("maps the assistant preset to the lightweight chat defaults", () => { expect(resolveIssueChatThreadPreset("assistant")).toMatchObject({ preset: "assistant", - density: "compact", + // Comfortable = issue-chat message rendering (own messages right/blue). + density: "comfortable", workingIndicator: "collapsed", variant: "full", showComposer: true, diff --git a/ui/src/components/chat-thread-presets.ts b/ui/src/components/chat-thread-presets.ts index 6bfd21ed8ba..f05149f6544 100644 --- a/ui/src/components/chat-thread-presets.ts +++ b/ui/src/components/chat-thread-presets.ts @@ -65,7 +65,9 @@ const ISSUE_CHAT_THREAD_PRESETS: Record