feat(chat): selected-agent conference-room chat surface and live issue-thread updates

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-23 13:33:56 +00:00
1 parent 12b18be5d6
commit 495c3662eb
13 files changed
+831 -174

No files matched your search

@@ -12,6 +12,7 @@ import {
VIRTUALIZED_THREAD_ROW_THRESHOLD,
canStopIssueChatRun,
findLatestCommentMessageIndex,
getVirtualizedMeasurementScrollAdjustment,
resolveAssistantMessageFoldedState,
resolveIssueChatHumanAuthor,
} from "./IssueChatThread";
@@ -674,6 +675,51 @@ describe("IssueChatThread", () => {
});
});
it("cycles work modes and prevents default when iOS leaves the keydown code empty", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
issueWorkMode="standard"
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
expect(composer).not.toBeNull();
expect(composer?.getAttribute("data-pending-work-mode")).toBe("standard");
// iOS Safari with a hardware keyboard frequently reports an empty `code` for
// cmd-period. Without a `key` fallback the handler returns early, never calls
// preventDefault, and Safari's default cancel/dismiss closes the view.
const evt = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
code: "",
key: ".",
metaKey: true,
});
act(() => {
composer?.dispatchEvent(evt);
});
expect(evt.defaultPrevented).toBe(true);
expect(composer?.getAttribute("data-pending-work-mode")).not.toBe("standard");
act(() => {
root.unmount();
});
});
it("virtualizes long merged threads so only a windowed slice mounts", () => {
const root = createRoot(container);
const totalMergedRows =
@@ -1343,6 +1389,31 @@ describe("IssueChatThread", () => {
});
});
it("keeps the viewport anchored when virtualized rows above it remeasure", () => {
expect(getVirtualizedMeasurementScrollAdjustment({
itemStart: 200,
previousSize: 220,
nextSize: 360,
viewportStart: 480,
})).toBe(140);
expect(getVirtualizedMeasurementScrollAdjustment({
itemStart: 200,
previousSize: 360,
nextSize: 180,
viewportStart: 620,
})).toBe(-180);
});
it("does not scroll-anchor virtualized measurement changes inside the viewport", () => {
expect(getVirtualizedMeasurementScrollAdjustment({
itemStart: 420,
previousSize: 220,
nextSize: 360,
viewportStart: 480,
})).toBe(0);
});
it("renders virtualized rows with the same role/kind metadata as the direct path", () => {
const root = createRoot(container);
+39 -4
View File
@@ -51,6 +51,7 @@ import { copyTextToClipboard } from "../lib/clipboard";
import {
buildIssueChatMessages,
formatDurationWords,
isCoTSegmentActive,
stabilizeThreadMessages,
type IssueChatComment,
type IssueChatLinkedRun,
@@ -870,13 +871,16 @@ function IssueChatChainOfThought({
(p): p is ToolCallMessagePart => p.type === "tool-call",
);
const isActive = isMessageRunning;
const [expanded, setExpanded] = useState(isActive);
const rawSegments = Array.isArray(custom.chainOfThoughtSegments)
? (custom.chainOfThoughtSegments as SegmentTiming[])
: [];
const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null;
const isActive = isCoTSegmentActive({
isMessageRunning,
segmentIndex: myIndex,
segmentCount: rawSegments.length,
});
const [expanded, setExpanded] = useState(isActive);
const liveElapsed = useLiveElapsed(segmentTiming?.startMs, isActive);
useEffect(() => {
@@ -2941,6 +2945,18 @@ type SimpleVirtualItem = {
size: number;
};
export function getVirtualizedMeasurementScrollAdjustment(args: {
itemStart: number;
previousSize: number;
nextSize: number;
viewportStart: number;
}) {
const { itemStart, previousSize, nextSize, viewportStart } = args;
const previousEnd = itemStart + previousSize;
if (previousEnd > viewportStart) return 0;
return nextSize - previousSize;
}
function useIssueThreadVirtualizer({
count,
estimateSize,
@@ -3055,7 +3071,20 @@ function useIssueThreadVirtualizer({
const key = getItemKey(index);
const previousSize = measuredSizeByKeyRef.current.get(key) ?? estimatedSize;
if (Math.abs(previousSize - measuredSize) < 1) return;
const scrollAdjustment = getVirtualizedMeasurementScrollAdjustment({
itemStart: itemStarts[index] ?? scrollMargin,
previousSize,
nextSize: measuredSize,
viewportStart: Math.max(scrollMargin, scrollOffset()),
});
measuredSizeByKeyRef.current.set(key, measuredSize);
if (Math.abs(scrollAdjustment) >= 1) {
if (mode.kind === "window") {
window.scrollBy({ top: scrollAdjustment, behavior: "auto" });
} else {
mode.element.scrollBy({ top: scrollAdjustment, behavior: "auto" });
}
}
rerender((value) => value + 1);
},
};
@@ -3746,7 +3775,13 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
const PendingWorkModeIcon = pendingWorkModeMeta.icon;
function handleComposerKeyDown(evt: ReactKeyboardEvent<HTMLDivElement>) {
if (!(evt.metaKey || evt.ctrlKey) || evt.code !== "Period") return;
// Match the period via both `code` and `key`: iOS Safari with a hardware
// keyboard often leaves `code` empty for cmd-period, so relying on it alone
// lets the event fall through and triggers Safari's default cancel/dismiss
// (which closes the view). Catching `key === "."` keeps the shortcut working
// on iOS while preserving desktop behavior.
const isPeriod = evt.code === "Period" || evt.key === ".";
if (!(evt.metaKey || evt.ctrlKey) || !isPeriod) return;
evt.preventDefault();
setPendingWorkMode((current) => nextWorkMode(current, true));
}
+14 -4
View File
@@ -61,6 +61,7 @@ import { copyTextToClipboard } from "../lib/clipboard";
import {
buildIssueChatMessages,
formatDurationWords,
isCoTSegmentActive,
stabilizeThreadMessages,
type IssueChatComment,
type IssueChatLinkedRun,
@@ -849,13 +850,16 @@ function IssueChatChainOfThought({
(p): p is ToolCallMessagePart => p.type === "tool-call",
);
const isActive = isMessageRunning;
const [expanded, setExpanded] = useState(isActive);
const rawSegments = Array.isArray(custom.chainOfThoughtSegments)
? (custom.chainOfThoughtSegments as SegmentTiming[])
: [];
const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null;
const isActive = isCoTSegmentActive({
isMessageRunning,
segmentIndex: myIndex,
segmentCount: rawSegments.length,
});
const [expanded, setExpanded] = useState(isActive);
const liveElapsed = useLiveElapsed(segmentTiming?.startMs, isActive);
useEffect(() => {
@@ -3587,7 +3591,13 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
const PendingWorkModeIcon = pendingWorkModeMeta.icon;
function handleComposerKeyDown(evt: ReactKeyboardEvent<HTMLDivElement>) {
if (!(evt.metaKey || evt.ctrlKey) || evt.code !== "Period") return;
// Match the period via both `code` and `key`: iOS Safari with a hardware
// keyboard often leaves `code` empty for cmd-period, so relying on it alone
// lets the event fall through and triggers Safari's default cancel/dismiss
// (which closes the view). Catching `key === "."` keeps the shortcut working
// on iOS while preserving desktop behavior.
const isPeriod = evt.code === "Period" || evt.key === ".";
if (!(evt.metaKey || evt.ctrlKey) || !isPeriod) return;
evt.preventDefault();
setPendingWorkMode((current) => nextWorkMode(current, false));
}
@@ -139,6 +139,9 @@ describe("LiveUpdatesProvider issue invalidation", () => {
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.documentRevisions("issue-1", "plan"),
});
expect(invalidations).toContainEqual({
queryKey: ["issues", "document-annotations", "issue-1", "plan"],
});
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.documents("PAP-9403"),
});
@@ -148,6 +151,9 @@ describe("LiveUpdatesProvider issue invalidation", () => {
expect(invalidations).toContainEqual({
queryKey: queryKeys.issues.documentRevisions("PAP-9403", "plan"),
});
expect(invalidations).toContainEqual({
queryKey: ["issues", "document-annotations", "PAP-9403", "plan"],
});
expect(invalidations).not.toContainEqual({
queryKey: queryKeys.issues.documents("issue-1"),
refetchType: "inactive",
@@ -186,6 +192,83 @@ describe("LiveUpdatesProvider issue invalidation", () => {
expect(invalidations).toContainEqual({
queryKey: ["issues", "document-revisions", "issue-1"],
});
expect(invalidations).toContainEqual({
queryKey: ["issues", "document-annotations", "issue-1"],
});
});
it("refreshes document annotation caches when annotation activity arrives", () => {
const invalidations: unknown[] = [];
const queryClient = {
invalidateQueries: (input: unknown) => {
invalidations.push(input);
},
getQueryData: () => undefined,
};
__liveUpdatesTestUtils.invalidateActivityQueries(
queryClient as never,
"company-1",
{
entityType: "issue",
entityId: "issue-1",
action: "issue.document_annotation_comment_added",
actorType: "user",
actorId: "user-2",
details: {
identifier: "PAP-9403",
documentKey: "plan",
threadId: "thread-1",
commentId: "comment-1",
},
},
{ userId: "user-1", agentId: null },
);
expect(invalidations).toContainEqual({
queryKey: ["issues", "document-annotations", "issue-1", "plan"],
});
expect(invalidations).toContainEqual({
queryKey: ["issues", "document-annotations", "PAP-9403", "plan"],
});
expect(invalidations).not.toContainEqual({
queryKey: queryKeys.issues.documents("issue-1"),
});
});
it("refreshes routine description annotation caches when routine annotation activity arrives", () => {
const invalidations: unknown[] = [];
const queryClient = {
invalidateQueries: (input: unknown) => {
invalidations.push(input);
},
getQueryData: () => undefined,
};
__liveUpdatesTestUtils.invalidateActivityQueries(
queryClient as never,
"company-1",
{
entityType: "routine",
entityId: "routine-1",
action: "routine.document_annotation_comment_added",
actorType: "user",
actorId: "user-2",
details: {
documentKey: "description",
threadId: "thread-1",
commentId: "comment-1",
},
},
{ userId: "user-1", agentId: null },
);
expect(invalidations).toContainEqual({
queryKey: ["routines"],
});
expect(invalidations).toContainEqual({
queryKey: ["routines", "document-annotations", "routine-1", "description"],
});
});
it("keeps self-authored comment events from refetching the active issue tree", () => {
+32
View File
@@ -415,6 +415,20 @@ const ISSUE_DOCUMENT_ACTIVITY_ACTIONS = new Set([
"issue.document_restored",
"issue.document_deleted",
]);
const ISSUE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([
"issue.document_annotation_thread_created",
"issue.document_annotation_comment_added",
"issue.document_annotation_thread_resolved",
"issue.document_annotation_thread_reopened",
"issue.document_annotation_remapped",
]);
const ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([
"routine.document_annotation_thread_created",
"routine.document_annotation_comment_added",
"routine.document_annotation_thread_resolved",
"routine.document_annotation_thread_reopened",
"routine.document_annotation_remapped",
]);
const AGENT_TOAST_STATUSES = new Set(["error"]);
const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]);
@@ -710,6 +724,18 @@ function invalidateActivityQueries(
queryClient.invalidateQueries({ queryKey: ["issues", "document-revisions", ref], ...invalidationOptions });
}
}
if (
action &&
(ISSUE_DOCUMENT_ACTIVITY_ACTIONS.has(action) || ISSUE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action))
) {
const documentKey = readString(details?.key) ?? readString(details?.documentKey);
queryClient.invalidateQueries({
queryKey: documentKey
? ["issues", "document-annotations", ref, documentKey]
: ["issues", "document-annotations", ref],
...invalidationOptions,
});
}
if (action?.startsWith("issue.thread_interaction_")) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref), ...invalidationOptions });
}
@@ -761,6 +787,12 @@ function invalidateActivityQueries(
if (entityType === "routine" || entityType === "routine_trigger" || entityType === "routine_run") {
queryClient.invalidateQueries({ queryKey: ["routines"] });
if (entityType === "routine" && action && ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action) && entityId) {
const documentKey = readString(details?.key) ?? readString(details?.documentKey) ?? "description";
queryClient.invalidateQueries({
queryKey: ["routines", "document-annotations", entityId, documentKey],
});
}
return;
}
+19
View File
@@ -3,6 +3,7 @@ import type { Agent } from "@paperclipai/shared";
import {
buildAssistantPartsFromTranscript,
buildIssueChatMessages,
isCoTSegmentActive,
stabilizeThreadMessages,
type IssueChatComment,
type IssueChatLinkedRun,
@@ -238,6 +239,24 @@ describe("buildAssistantPartsFromTranscript", () => {
}]);
});
it("marks only the latest chain-of-thought segment active while a run is live", () => {
expect(isCoTSegmentActive({
isMessageRunning: true,
segmentIndex: 0,
segmentCount: 2,
})).toBe(false);
expect(isCoTSegmentActive({
isMessageRunning: true,
segmentIndex: 1,
segmentCount: 2,
})).toBe(true);
expect(isCoTSegmentActive({
isMessageRunning: false,
segmentIndex: 1,
segmentCount: 2,
})).toBe(false);
});
it("keeps run errors while suppressing init and system transcript noise", () => {
const result = buildAssistantPartsFromTranscript([
{
+11
View File
@@ -538,6 +538,17 @@ export interface SegmentTiming {
endMs: number;
}
export function isCoTSegmentActive(args: {
isMessageRunning: boolean;
segmentIndex: number;
segmentCount: number;
}) {
const { isMessageRunning, segmentIndex, segmentCount } = args;
if (!isMessageRunning) return false;
if (segmentCount <= 0 || segmentIndex < 0) return true;
return segmentIndex === segmentCount - 1;
}
function computeSegmentTimings(entries: readonly IssueChatTranscriptEntry[]): SegmentTiming[] {
const timings: SegmentTiming[] = [];
let inSegment = false;