PAP-12316 add issue chat thread presets

This commit is contained in:
Dotta committed 2026-07-01 23:00:30 +00:00
1 parent dbe241c6b9
commit 611df4ed74
5 files changed
+298 -89

No files matched your search

+102 -85
View File
@@ -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
+8 -2
View File
@@ -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", () => {
+3 -2
View File
@@ -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",
});
});
});
+116
View File
@@ -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,
};
}