Fix conference room chat scroll, identity, feed links (PAP-13345)

- Give the assistant chat body its own scroll container so loading or
  scrolling a chat never moves the page or the sidebar.
- Render the conference room with issue-chat density: the board user's
  messages are right-aligned blue bubbles, and the company user directory
  now resolves real names/avatars instead of "You".
- Report heartbeat scheduling suppression from the selected-agent-chat
  comment route and surface it as an inline notice, so a send on a
  worktree instance no longer looks like it silently did nothing.
- Deep-link Agent Feed events on board_chat issues to /board-chat/{ref}
  (they are hidden from the issues list, so the old link was a broken
  /issues/<uuid>), and stop showing the board user's own comment events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Fable 5 committed 2026-07-10 00:36:19 +00:00
1 parent 1c2d4f5ca5
commit 048e79f094
9 files changed
+167 -10

No files matched your search

@@ -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 () => {
+9
View File
@@ -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,
},
});
},
);
+5
View File
@@ -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 = {
+49 -3
View File
@@ -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<string, Agent>;
entityNameMap: Map<string, string>;
entityTitleMap: Map<string, string>;
boardChatIssueRefs?: ReadonlyMap<string, string>;
}) {
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<string, Agent>();
for (const a of agents ?? []) map.set(a.id, a);
@@ -370,14 +395,22 @@ export function ActivityFeed({ className }: ActivityFeedProps) {
const map = new Map<string, string>();
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<string, string>();
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<string, string>();
for (const i of boardChatIssues ?? []) map.set(i.id, i.identifier || i.id);
return map;
}, [boardChatIssues]);
const entityStatusMap = useMemo(() => {
const map = new Map<string, string>();
@@ -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}
/>
</div>
</div>
@@ -513,6 +557,7 @@ export function ActivityFeed({ className }: ActivityFeedProps) {
entityNameMap={entityNameMap}
entityTitleMap={entityTitleMap}
entityStatusMap={entityStatusMap}
boardChatIssueRefs={boardChatIssueRefs}
isActive={isActiveHeartbeat}
/>
</div>
@@ -531,6 +576,7 @@ export function ActivityFeed({ className }: ActivityFeedProps) {
entityNameMap={entityNameMap}
entityTitleMap={entityTitleMap}
entityStatusMap={entityStatusMap}
boardChatIssueRefs={boardChatIssueRefs}
isActive={isActiveHeartbeat}
isMuted
/>
+56 -1
View File
@@ -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<string, string> | null;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | 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<void>;
onStopRun?: (runId: string) => Promise<void>;
@@ -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({
</div>
) : null}
{noticeText ? (
<div
role="status"
data-testid="assistant-chat-notice"
className="mx-4 mt-3 flex items-start gap-2 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2 text-sm text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<p className="min-w-0 flex-1 break-words">{noticeText}</p>
</div>
) : null}
{loading && comments.length === 0 ? (
<div
data-testid="assistant-chat-skeleton"
@@ -452,7 +476,9 @@ export function AssistantChatView({
) : (
<div
data-testid="selected-agent-chat-body"
className="min-w-0 px-4 pb-4 pt-3"
// Own scroll container: chat scrolling (including the initial
// scroll-to-latest) must never move the page or the sidebar.
className="min-h-0 min-w-0 flex-1 overflow-y-auto px-4 pb-4 pt-3"
>
<IssueChatThread
preset="assistant"
@@ -469,6 +495,8 @@ export function AssistantChatView({
projectId={projectId}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
userProfileMap={userProfileMap}
backgroundWorkChildren={backgroundWorkChildren}
suppressIssueStatusNotices={suppressIssueStatusNotices}
mentions={mentions}
@@ -558,8 +586,23 @@ export function AssistantChat({
}, [agents, preferredTargetAgentId, targetAgentId]);
const [errorText, setErrorText] = useState<string | null>(null);
const [noticeText, setNoticeText] = useState<string | null>(null);
const [pendingComments, setPendingComments] = useState<PendingAssistantChatComment[]>([]);
// 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}
+16 -2
View File
@@ -287,6 +287,7 @@ function resolveContent(
agentMap: Map<string, Agent>,
entityNameMap: Map<string, string>,
entityTitleMap: Map<string, string> | undefined,
boardChatIssueRefs: ReadonlyMap<string, string> | undefined,
): CardContent {
const details = event.details as Record<string, unknown> | 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/<uuid> (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<string, Agent>;
entityNameMap: Map<string, string>;
entityTitleMap?: Map<string, string>;
/** Conference Room issue id → conversation ref; events on these issues
* deep-link to /board-chat/{ref} instead of the hidden issue page. */
boardChatIssueRefs?: ReadonlyMap<string, string>;
/** 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<string, unknown> | 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);
+3 -2
View File
@@ -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<string, readonly IssueChatTranscriptEntry[]>([
[
@@ -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"),
);
@@ -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,
+3 -1
View File
@@ -65,7 +65,9 @@ const ISSUE_CHAT_THREAD_PRESETS: Record<IssueChatThreadPresetName, IssueChatThre
composerSingleLine: false,
},
assistant: {
density: "compact",
// Comfortable density matches the issue-chat message rendering: the
// board user's own messages are right-aligned blue bubbles (PAP-13345).
density: "comfortable",
workingIndicator: "collapsed",
variant: "full",
showComposer: true,