mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
PAP-12316 add issue chat thread presets
This commit is contained in:
1 parent
dbe241c6b9
commit
611df4ed74
5 files changed
+298
-89
No files matched your search
@@ -174,6 +174,10 @@ import { IssueRecoveryActionCard, type RecoveryResolveOutcome } from "./IssueRec
|
||||
import { SourceTrustBadge } from "./SourceTrustBadge";
|
||||
import { AnimatedPaperclipIcon } from "./AnimatedPaperclipIcon";
|
||||
import { BoardChatBackgroundWorkCard } from "./BoardChatBackgroundWorkCard";
|
||||
import {
|
||||
resolveIssueChatThreadPreset,
|
||||
type IssueChatThreadPresetName,
|
||||
} from "./chat-thread-presets";
|
||||
|
||||
interface IssueChatMessageContext {
|
||||
feedbackDataSharingPreference: FeedbackDataSharingPreference;
|
||||
@@ -356,6 +360,7 @@ interface IssueChatComposerProps {
|
||||
}
|
||||
|
||||
interface IssueChatThreadProps {
|
||||
preset?: IssueChatThreadPresetName;
|
||||
comments: IssueChatComment[];
|
||||
interactions?: IssueThreadInteraction[];
|
||||
feedbackVotes?: FeedbackVote[];
|
||||
@@ -415,6 +420,7 @@ interface IssueChatThreadProps {
|
||||
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
|
||||
showComposer?: boolean;
|
||||
showJumpToLatest?: boolean;
|
||||
showBackgroundWorkChildren?: boolean;
|
||||
autoScrollToLatestOnInitialLoad?: boolean;
|
||||
emptyMessage?: string;
|
||||
emptyState?: ReactNode;
|
||||
@@ -4118,82 +4124,94 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
);
|
||||
});
|
||||
|
||||
export function IssueChatThread({
|
||||
comments,
|
||||
interactions = [],
|
||||
feedbackVotes = [],
|
||||
feedbackDataSharingPreference = "prompt",
|
||||
feedbackTermsUrl = null,
|
||||
linkedRuns = [],
|
||||
timelineEvents = [],
|
||||
liveRuns = [],
|
||||
activeRun = null,
|
||||
issueId = null,
|
||||
blockedBy = [],
|
||||
backgroundWorkChildren = [],
|
||||
blockerAttention = null,
|
||||
successfulRunHandoff = null,
|
||||
scheduledRetry = null,
|
||||
recoveryAction = null,
|
||||
onResolveRecoveryAction,
|
||||
canFalsePositiveRecoveryAction = false,
|
||||
legacyRecoverySourceIssue = null,
|
||||
companyId,
|
||||
projectId,
|
||||
issueStatus,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
onVote,
|
||||
onAdd,
|
||||
onCancelRun,
|
||||
onStopRun,
|
||||
stopRunLabel,
|
||||
stoppingRunLabel,
|
||||
stopRunVariant,
|
||||
runFinalizationActions,
|
||||
imageUploadHandler,
|
||||
onAttachImage,
|
||||
draftKey,
|
||||
enableReassign = false,
|
||||
reassignOptions = [],
|
||||
currentAssigneeValue = "",
|
||||
suggestedAssigneeValue,
|
||||
mentions = [],
|
||||
composerDisabledReason = null,
|
||||
composerHint = null,
|
||||
suppressIssueStatusNotices = false,
|
||||
showComposer = true,
|
||||
showJumpToLatest,
|
||||
autoScrollToLatestOnInitialLoad = true,
|
||||
emptyMessage,
|
||||
emptyState,
|
||||
footer,
|
||||
variant = "full",
|
||||
enableLiveTranscriptPolling = true,
|
||||
transcriptsByRunId,
|
||||
hasOutputForRun: hasOutputForRunOverride,
|
||||
includeSucceededRunsWithoutOutput = false,
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
onDeleteComment,
|
||||
interruptingQueuedRunId = null,
|
||||
stoppingRunId = null,
|
||||
onImageClick,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
composerRef,
|
||||
issueWorkMode,
|
||||
onWorkModeChange,
|
||||
onRefreshLatestComments,
|
||||
assigneeUserId = null,
|
||||
onResumeFromBacklog,
|
||||
resumeFromBacklogPending = false,
|
||||
externalReferences,
|
||||
}: IssueChatThreadProps) {
|
||||
export function IssueChatThread(props: IssueChatThreadProps) {
|
||||
const presetConfig = resolveIssueChatThreadPreset(props.preset, {
|
||||
variant: props.variant,
|
||||
showComposer: props.showComposer,
|
||||
showJumpToLatest: props.showJumpToLatest,
|
||||
showBackgroundWorkChildren: props.showBackgroundWorkChildren,
|
||||
autoScrollToLatestOnInitialLoad: props.autoScrollToLatestOnInitialLoad,
|
||||
emptyMessage: props.emptyMessage,
|
||||
suppressIssueStatusNotices: props.suppressIssueStatusNotices,
|
||||
enableReassign: props.enableReassign,
|
||||
});
|
||||
const {
|
||||
comments,
|
||||
interactions = [],
|
||||
feedbackVotes = [],
|
||||
feedbackDataSharingPreference = "prompt",
|
||||
feedbackTermsUrl = null,
|
||||
linkedRuns = [],
|
||||
timelineEvents = [],
|
||||
liveRuns = [],
|
||||
activeRun = null,
|
||||
issueId = null,
|
||||
blockedBy = [],
|
||||
backgroundWorkChildren = [],
|
||||
blockerAttention = null,
|
||||
successfulRunHandoff = null,
|
||||
scheduledRetry = null,
|
||||
recoveryAction = null,
|
||||
onResolveRecoveryAction,
|
||||
canFalsePositiveRecoveryAction = false,
|
||||
legacyRecoverySourceIssue = null,
|
||||
companyId,
|
||||
projectId,
|
||||
issueStatus,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
onVote,
|
||||
onAdd,
|
||||
onCancelRun,
|
||||
onStopRun,
|
||||
stopRunLabel,
|
||||
stoppingRunLabel,
|
||||
stopRunVariant,
|
||||
runFinalizationActions,
|
||||
imageUploadHandler,
|
||||
onAttachImage,
|
||||
draftKey,
|
||||
enableReassign = presetConfig.enableReassign,
|
||||
reassignOptions = [],
|
||||
currentAssigneeValue = "",
|
||||
suggestedAssigneeValue,
|
||||
mentions = [],
|
||||
composerDisabledReason = null,
|
||||
composerHint = null,
|
||||
suppressIssueStatusNotices = presetConfig.suppressIssueStatusNotices,
|
||||
showComposer = presetConfig.showComposer,
|
||||
showJumpToLatest = presetConfig.showJumpToLatest,
|
||||
showBackgroundWorkChildren = presetConfig.showBackgroundWorkChildren,
|
||||
autoScrollToLatestOnInitialLoad = presetConfig.autoScrollToLatestOnInitialLoad,
|
||||
emptyMessage = presetConfig.emptyMessage,
|
||||
emptyState,
|
||||
footer,
|
||||
variant = presetConfig.variant,
|
||||
enableLiveTranscriptPolling = true,
|
||||
transcriptsByRunId,
|
||||
hasOutputForRun: hasOutputForRunOverride,
|
||||
includeSucceededRunsWithoutOutput = false,
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
onDeleteComment,
|
||||
interruptingQueuedRunId = null,
|
||||
stoppingRunId = null,
|
||||
onImageClick,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
composerRef,
|
||||
issueWorkMode,
|
||||
onWorkModeChange,
|
||||
onRefreshLatestComments,
|
||||
assigneeUserId = null,
|
||||
onResumeFromBacklog,
|
||||
resumeFromBacklogPending = false,
|
||||
externalReferences,
|
||||
} = props;
|
||||
const location = useLocation();
|
||||
const lastScrolledHashRef = useRef<string | null>(null);
|
||||
const virtualizedThreadRef = useRef<VirtualizedIssueChatThreadListHandle | null>(null);
|
||||
@@ -4759,11 +4777,8 @@ export function IssueChatThread({
|
||||
],
|
||||
);
|
||||
|
||||
const resolvedShowJumpToLatest = showJumpToLatest ?? variant === "full";
|
||||
const resolvedEmptyMessage = emptyMessage
|
||||
?? (variant === "embedded"
|
||||
? "No run output yet."
|
||||
: "This task conversation is empty. Start with a message below.");
|
||||
const resolvedShowJumpToLatest = showJumpToLatest;
|
||||
const resolvedEmptyMessage = emptyMessage;
|
||||
const previousErrorBoundaryMessagesRef = useRef<readonly ThreadMessage[] | null>(null);
|
||||
const errorBoundaryResetVersionRef = useRef(0);
|
||||
if (previousErrorBoundaryMessagesRef.current !== messages) {
|
||||
@@ -4838,10 +4853,12 @@ export function IssueChatThread({
|
||||
)}
|
||||
{showComposer ? (
|
||||
<div data-testid="issue-chat-thread-notices" className="space-y-2">
|
||||
<BoardChatBackgroundWorkCard
|
||||
childrenIssues={backgroundWorkChildren}
|
||||
agentMap={agentMap}
|
||||
/>
|
||||
{showBackgroundWorkChildren ? (
|
||||
<BoardChatBackgroundWorkCard
|
||||
childrenIssues={backgroundWorkChildren}
|
||||
agentMap={agentMap}
|
||||
/>
|
||||
) : null}
|
||||
{!suppressIssueStatusNotices ? (
|
||||
<>
|
||||
<IssueAssignedBacklogNotice
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { IssueChatComment } from "../lib/issue-chat-messages";
|
||||
// switcher, error/loading states, send pass-through) is what we assert.
|
||||
vi.mock("./IssueChatThread", () => ({
|
||||
IssueChatThread: (props: {
|
||||
preset?: string;
|
||||
emptyMessage?: string;
|
||||
emptyState?: ReactNode;
|
||||
showJumpToLatest?: boolean;
|
||||
@@ -26,12 +27,15 @@ vi.mock("./IssueChatThread", () => ({
|
||||
onAttachImage?: (file: File) => Promise<unknown>;
|
||||
}) => (
|
||||
<div data-testid="issue-chat-thread">
|
||||
<span data-testid="thread-preset">{props.preset}</span>
|
||||
<span data-testid="empty-message">{props.emptyState ? "" : props.emptyMessage}</span>
|
||||
<div data-testid="empty-state">{props.emptyState}</div>
|
||||
<span data-testid="jump-to-latest">{String(props.showJumpToLatest)}</span>
|
||||
<span data-testid="jump-to-latest">
|
||||
{String(props.showJumpToLatest ?? (props.preset === "assistant" ? false : true))}
|
||||
</span>
|
||||
<span data-testid="background-work-count">{props.backgroundWorkChildren?.length ?? 0}</span>
|
||||
<span data-testid="status-notices">
|
||||
{props.suppressIssueStatusNotices ? "suppressed" : "visible"}
|
||||
{(props.suppressIssueStatusNotices ?? props.preset === "assistant") ? "suppressed" : "visible"}
|
||||
</span>
|
||||
<span data-testid="composer-hint">{props.composerHint}</span>
|
||||
<span data-testid="image-upload-enabled">{String(Boolean(props.imageUploadHandler))}</span>
|
||||
@@ -263,6 +267,8 @@ describe("SelectedAgentChatView", () => {
|
||||
"Send Sarah a message to start the conversation.",
|
||||
);
|
||||
expect(container.querySelector('[data-testid="jump-to-latest"]')?.textContent).toBe("false");
|
||||
expect(container.querySelector('[data-testid="thread-preset"]')?.textContent).toBe("assistant");
|
||||
expect(container.querySelector('[data-testid="status-notices"]')?.textContent).toBe("suppressed");
|
||||
});
|
||||
|
||||
it("forwards attachment handlers to the shared issue chat composer", () => {
|
||||
|
||||
@@ -174,7 +174,7 @@ export function SelectedAgentChatView({
|
||||
projectId,
|
||||
currentUserId,
|
||||
backgroundWorkChildren = [],
|
||||
suppressIssueStatusNotices = false,
|
||||
suppressIssueStatusNotices,
|
||||
conferenceRoomMode = false,
|
||||
companyName = null,
|
||||
composerHint = null,
|
||||
@@ -216,6 +216,7 @@ export function SelectedAgentChatView({
|
||||
);
|
||||
|
||||
const targetName = targetAgent?.name ?? "Assistant";
|
||||
const threadPreset = conferenceRoomMode ? "assistant" : "task";
|
||||
const targetRole = targetAgent ? roleLabels[targetAgent.role] ?? targetAgent.role : null;
|
||||
const canSwitch = showAgentSwitcher && invokableAgents.length > 1 && !!onTargetAgentChange;
|
||||
const starterPrompts = useMemo(
|
||||
@@ -333,6 +334,7 @@ export function SelectedAgentChatView({
|
||||
className="min-w-0 px-4 pb-4 pt-3"
|
||||
>
|
||||
<IssueChatThread
|
||||
preset={threadPreset}
|
||||
variant="full"
|
||||
composerRef={composerRef}
|
||||
comments={comments}
|
||||
@@ -362,7 +364,6 @@ export function SelectedAgentChatView({
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
showJumpToLatest={!conferenceRoomMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveIssueChatThreadPreset } from "./chat-thread-presets";
|
||||
|
||||
describe("resolveIssueChatThreadPreset", () => {
|
||||
it("treats an omitted preset exactly like the task preset", () => {
|
||||
expect(resolveIssueChatThreadPreset()).toEqual(resolveIssueChatThreadPreset("task"));
|
||||
});
|
||||
|
||||
it("defaults to the current task thread behavior", () => {
|
||||
expect(resolveIssueChatThreadPreset()).toMatchObject({
|
||||
preset: "task",
|
||||
density: "comfortable",
|
||||
workingIndicator: "expanded",
|
||||
variant: "full",
|
||||
showComposer: true,
|
||||
showJumpToLatest: true,
|
||||
suppressIssueStatusNotices: false,
|
||||
enableReassign: false,
|
||||
showBackgroundWorkChildren: true,
|
||||
showRunFinalizationActions: true,
|
||||
composerSubmitKey: "mod-enter",
|
||||
composerSingleLine: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps task defaults variant-aware", () => {
|
||||
expect(resolveIssueChatThreadPreset("task", { variant: "embedded" })).toMatchObject({
|
||||
variant: "embedded",
|
||||
showJumpToLatest: false,
|
||||
emptyMessage: "No run output yet.",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the assistant preset to the lightweight chat defaults", () => {
|
||||
expect(resolveIssueChatThreadPreset("assistant")).toMatchObject({
|
||||
preset: "assistant",
|
||||
density: "compact",
|
||||
workingIndicator: "collapsed",
|
||||
variant: "full",
|
||||
showComposer: true,
|
||||
showJumpToLatest: false,
|
||||
suppressIssueStatusNotices: true,
|
||||
enableReassign: false,
|
||||
showBackgroundWorkChildren: false,
|
||||
showRunFinalizationActions: false,
|
||||
composerSubmitKey: "enter",
|
||||
composerSingleLine: true,
|
||||
composerMentions: "optional",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets explicit props override preset defaults", () => {
|
||||
expect(resolveIssueChatThreadPreset("assistant", {
|
||||
showJumpToLatest: true,
|
||||
suppressIssueStatusNotices: false,
|
||||
showBackgroundWorkChildren: true,
|
||||
showComposer: false,
|
||||
enableReassign: true,
|
||||
emptyMessage: "Custom empty state",
|
||||
})).toMatchObject({
|
||||
showJumpToLatest: true,
|
||||
suppressIssueStatusNotices: false,
|
||||
showBackgroundWorkChildren: true,
|
||||
showComposer: false,
|
||||
enableReassign: true,
|
||||
emptyMessage: "Custom empty state",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
export type IssueChatThreadPresetName = "task" | "assistant";
|
||||
|
||||
export type IssueChatThreadVariant = "full" | "embedded";
|
||||
|
||||
export type IssueChatThreadDensity = "comfortable" | "compact";
|
||||
|
||||
export type IssueChatThreadWorkingIndicator = "expanded" | "collapsed";
|
||||
|
||||
export type IssueChatThreadComposerSubmitKey = "mod-enter" | "enter";
|
||||
|
||||
export interface IssueChatThreadPresetConfig {
|
||||
preset: IssueChatThreadPresetName;
|
||||
density: IssueChatThreadDensity;
|
||||
workingIndicator: IssueChatThreadWorkingIndicator;
|
||||
variant: IssueChatThreadVariant;
|
||||
showComposer: boolean;
|
||||
showJumpToLatest: boolean;
|
||||
autoScrollToLatestOnInitialLoad: boolean;
|
||||
emptyMessage: string;
|
||||
suppressIssueStatusNotices: boolean;
|
||||
enableReassign: boolean;
|
||||
showBackgroundWorkChildren: boolean;
|
||||
showRunFinalizationActions: boolean;
|
||||
composerSubmitKey: IssueChatThreadComposerSubmitKey;
|
||||
composerSingleLine: boolean;
|
||||
composerMentions: "enabled" | "optional";
|
||||
}
|
||||
|
||||
export interface IssueChatThreadPresetOverrides {
|
||||
variant?: IssueChatThreadVariant;
|
||||
showComposer?: boolean;
|
||||
showJumpToLatest?: boolean;
|
||||
autoScrollToLatestOnInitialLoad?: boolean;
|
||||
emptyMessage?: string;
|
||||
suppressIssueStatusNotices?: boolean;
|
||||
enableReassign?: boolean;
|
||||
showBackgroundWorkChildren?: boolean;
|
||||
}
|
||||
|
||||
type IssueChatThreadPresetDefinition = Omit<
|
||||
IssueChatThreadPresetConfig,
|
||||
"preset" | "variant" | "showJumpToLatest" | "emptyMessage"
|
||||
> & {
|
||||
variant: IssueChatThreadVariant;
|
||||
showJumpToLatest: (variant: IssueChatThreadVariant) => boolean;
|
||||
emptyMessage: (variant: IssueChatThreadVariant) => string;
|
||||
};
|
||||
|
||||
const taskEmptyMessage = (variant: IssueChatThreadVariant) =>
|
||||
variant === "embedded"
|
||||
? "No run output yet."
|
||||
: "This task conversation is empty. Start with a message below.";
|
||||
|
||||
const ISSUE_CHAT_THREAD_PRESETS: Record<IssueChatThreadPresetName, IssueChatThreadPresetDefinition> = {
|
||||
task: {
|
||||
density: "comfortable",
|
||||
workingIndicator: "expanded",
|
||||
variant: "full",
|
||||
showComposer: true,
|
||||
showJumpToLatest: (variant) => variant === "full",
|
||||
autoScrollToLatestOnInitialLoad: true,
|
||||
emptyMessage: taskEmptyMessage,
|
||||
suppressIssueStatusNotices: false,
|
||||
enableReassign: false,
|
||||
showBackgroundWorkChildren: true,
|
||||
showRunFinalizationActions: true,
|
||||
composerSubmitKey: "mod-enter",
|
||||
composerSingleLine: false,
|
||||
composerMentions: "enabled",
|
||||
},
|
||||
assistant: {
|
||||
density: "compact",
|
||||
workingIndicator: "collapsed",
|
||||
variant: "full",
|
||||
showComposer: true,
|
||||
showJumpToLatest: () => false,
|
||||
autoScrollToLatestOnInitialLoad: true,
|
||||
emptyMessage: () => "Send a message to start the conversation.",
|
||||
suppressIssueStatusNotices: true,
|
||||
enableReassign: false,
|
||||
showBackgroundWorkChildren: false,
|
||||
showRunFinalizationActions: false,
|
||||
composerSubmitKey: "enter",
|
||||
composerSingleLine: true,
|
||||
composerMentions: "optional",
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveIssueChatThreadPreset(
|
||||
preset: IssueChatThreadPresetName = "task",
|
||||
overrides: IssueChatThreadPresetOverrides = {},
|
||||
): IssueChatThreadPresetConfig {
|
||||
const definition = ISSUE_CHAT_THREAD_PRESETS[preset];
|
||||
const variant = overrides.variant ?? definition.variant;
|
||||
|
||||
return {
|
||||
preset,
|
||||
density: definition.density,
|
||||
workingIndicator: definition.workingIndicator,
|
||||
variant,
|
||||
showComposer: overrides.showComposer ?? definition.showComposer,
|
||||
showJumpToLatest: overrides.showJumpToLatest ?? definition.showJumpToLatest(variant),
|
||||
autoScrollToLatestOnInitialLoad:
|
||||
overrides.autoScrollToLatestOnInitialLoad ?? definition.autoScrollToLatestOnInitialLoad,
|
||||
emptyMessage: overrides.emptyMessage ?? definition.emptyMessage(variant),
|
||||
suppressIssueStatusNotices:
|
||||
overrides.suppressIssueStatusNotices ?? definition.suppressIssueStatusNotices,
|
||||
enableReassign: overrides.enableReassign ?? definition.enableReassign,
|
||||
showBackgroundWorkChildren:
|
||||
overrides.showBackgroundWorkChildren ?? definition.showBackgroundWorkChildren,
|
||||
showRunFinalizationActions: definition.showRunFinalizationActions,
|
||||
composerSubmitKey: definition.composerSubmitKey,
|
||||
composerSingleLine: definition.composerSingleLine,
|
||||
composerMentions: definition.composerMentions,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user