mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
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:
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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user