Wire assistant chat preset behaviors

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-01 23:41:46 +00:00
1 parent a30c25a50c
commit e16b1de5d0
8 files changed
+597 -27

No files matched your search

+4 -1
View File
@@ -30,7 +30,7 @@ import type { MentionOption } from "./MarkdownEditor";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import type { IssueChatComment } from "../lib/issue-chat-messages";
import type { IssueChatComment, IssueChatTranscriptEntry } from "../lib/issue-chat-messages";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
@@ -221,6 +221,7 @@ export interface AssistantChatViewProps {
interactions?: IssueThreadInteraction[];
liveRuns?: LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
transcriptsByRunId?: ReadonlyMap<string, readonly IssueChatTranscriptEntry[]>;
feedbackVotes?: FeedbackVote[];
issueId?: string | null;
companyId?: string | null;
@@ -278,6 +279,7 @@ export function AssistantChatView({
interactions,
liveRuns,
activeRun,
transcriptsByRunId,
feedbackVotes,
issueId,
companyId,
@@ -460,6 +462,7 @@ export function AssistantChatView({
interactions={interactions}
liveRuns={liveRuns}
activeRun={activeRun}
transcriptsByRunId={transcriptsByRunId}
feedbackVotes={feedbackVotes}
issueId={issueId}
companyId={companyId}
+163
View File
@@ -105,6 +105,8 @@ vi.mock("./MarkdownEditor", () => ({
className,
contentClassName,
fileDropTarget,
onSubmit,
submitKey = "mod-enter",
}: {
value?: string;
onChange?: (value: string) => void;
@@ -112,6 +114,8 @@ vi.mock("./MarkdownEditor", () => ({
className?: string;
contentClassName?: string;
fileDropTarget?: "editor" | "parent";
onSubmit?: () => void;
submitKey?: "mod-enter" | "enter";
}, ref) => {
useImperativeHandle(ref, () => ({
focus: markdownEditorFocusMock,
@@ -123,9 +127,24 @@ vi.mock("./MarkdownEditor", () => ({
data-class-name={className}
data-content-class-name={contentClassName}
data-file-drop-target={fileDropTarget}
data-submit-key={submitKey}
placeholder={placeholder}
value={value}
onChange={(event) => onChange?.(event.target.value)}
onKeyDown={(event) => {
const shouldSubmit =
onSubmit
&& event.key === "Enter"
&& (
submitKey === "enter"
? !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey
: event.metaKey || event.ctrlKey
);
if (shouldSubmit) {
event.preventDefault();
onSubmit();
}
}}
/>
);
}),
@@ -397,6 +416,150 @@ describe("IssueChatThread", () => {
});
});
it("wires the assistant preset to Enter-to-send and keeps Shift+Enter for new lines", async () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
preset="assistant"
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
expect(editor).not.toBeNull();
expect(editor?.dataset.submitKey).toBe("enter");
expect(container.textContent).toContain("Enter to send · Shift+Enter for a new line");
act(() => {
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(editor, "Assistant reply");
editor?.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
editor?.dispatchEvent(new KeyboardEvent("keydown", {
key: "Enter",
shiftKey: true,
bubbles: true,
cancelable: true,
}));
});
expect(appendMock).not.toHaveBeenCalled();
await act(async () => {
editor?.dispatchEvent(new KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
});
expect(appendMock).toHaveBeenCalledTimes(1);
expect(appendMock).toHaveBeenCalledWith(expect.objectContaining({
content: [{ type: "text", text: "Assistant reply" }],
}));
act(() => {
root.unmount();
});
});
it("renders assistant preset turns as compact rows and starts live reasoning collapsed", async () => {
const root = createRoot(container);
const transcriptsByRunId = new Map<string, readonly IssueChatTranscriptEntry[]>([
[
"run-active",
[
{
kind: "thinking",
ts: "2026-04-06T12:00:01.000Z",
text: "Checking the active run transcript before reporting back.",
},
{
kind: "tool_call",
ts: "2026-04-06T12:00:02.000Z",
name: "read_file",
toolUseId: "tool-active-1",
input: { path: "ui/src/components/IssueChatThread.tsx" },
},
],
],
]);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
preset="assistant"
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[{
id: "run-active",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-06T12:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-06T12:00:00.000Z",
agentId: "agent-1",
agentName: "Agent One",
adapterType: "codex_local",
}]}
activeRun={{
id: "run-active",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-06T12:00:00.000Z",
finishedAt: null,
createdAt: "2026-04-06T12:00:00.000Z",
agentId: "agent-1",
agentName: "Agent One",
adapterType: "codex_local",
issueId: "issue-1",
}}
transcriptsByRunId={transcriptsByRunId}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null;
expect(viewport?.className).toContain("space-y-3");
const workingButton = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.includes("Working"),
);
expect(workingButton).toBeDefined();
expect(workingButton?.getAttribute("aria-expanded")).toBe("false");
expect(container.textContent).not.toContain("Checking the active run transcript");
await act(async () => {
workingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Checking the active run transcript");
act(() => {
root.unmount();
});
});
it("labels operator-interrupted cancelled runs as interrupted while preserving plain cancelled runs", () => {
const root = createRoot(container);
const linkedRuns: IssueChatLinkedRun[] = [
+302 -11
View File
@@ -176,7 +176,10 @@ import { AnimatedPaperclipIcon } from "./AnimatedPaperclipIcon";
import { BoardChatBackgroundWorkCard } from "./BoardChatBackgroundWorkCard";
import {
resolveIssueChatThreadPreset,
type IssueChatThreadComposerSubmitKey,
type IssueChatThreadDensity,
type IssueChatThreadPresetName,
type IssueChatThreadWorkingIndicator,
} from "./chat-thread-presets";
interface IssueChatMessageContext {
@@ -226,6 +229,8 @@ interface IssueChatMessageContext {
issueStatus?: string;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
externalReferences?: MarkdownExternalReferenceMap;
density: IssueChatThreadDensity;
workingIndicator: IssueChatThreadWorkingIndicator;
}
const IssueChatCtx = createContext<IssueChatMessageContext>({
@@ -233,6 +238,8 @@ const IssueChatCtx = createContext<IssueChatMessageContext>({
feedbackTermsUrl: null,
issueStatus: undefined,
successfulRunHandoff: null,
density: "comfortable",
workingIndicator: "expanded",
});
const AGENT_COMMENT_BUBBLE_WIDTH_CLASS = "max-w-[calc(100%-0.5rem)] sm:max-w-[85%]";
@@ -354,6 +361,8 @@ interface IssueChatComposerProps {
userLabelMap?: ReadonlyMap<string, string> | null;
composerDisabledReason?: string | null;
composerHint?: string | null;
composerSubmitKey?: IssueChatThreadComposerSubmitKey;
composerSingleLine?: boolean;
issueStatus?: string;
issueWorkMode?: IssueWorkMode;
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
@@ -426,6 +435,8 @@ interface IssueChatThreadProps {
emptyState?: ReactNode;
footer?: ReactNode;
variant?: "full" | "embedded";
density?: IssueChatThreadDensity;
workingIndicator?: IssueChatThreadWorkingIndicator;
enableLiveTranscriptPolling?: boolean;
transcriptsByRunId?: ReadonlyMap<string, readonly IssueChatTranscriptEntry[]>;
hasOutputForRun?: (runId: string) => boolean;
@@ -862,7 +873,7 @@ function IssueChatChainOfThought({
message: ThreadMessage;
cotParts: readonly IssueChatCoTPart[];
}) {
const { agentMap } = useContext(IssueChatCtx);
const { agentMap, workingIndicator } = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>;
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null;
@@ -894,12 +905,16 @@ function IssueChatChainOfThought({
segmentIndex: myIndex,
segmentCount: rawSegments.length,
});
const [expanded, setExpanded] = useState(isActive);
const [expanded, setExpanded] = useState(isActive && workingIndicator !== "collapsed");
const liveElapsed = useLiveElapsed(segmentTiming?.startMs, isActive);
useEffect(() => {
if (workingIndicator === "collapsed") {
if (!isActive) setExpanded(false);
return;
}
if (isActive) setExpanded(true);
}, [isActive]);
}, [isActive, workingIndicator]);
let headerVerb: string;
let headerSuffix: string | null = null;
@@ -922,13 +937,26 @@ function IssueChatChainOfThought({
<div>
<button
type="button"
className="group flex w-full items-start gap-2.5 rounded-lg px-1 py-2 text-left transition-colors hover:bg-accent/5"
aria-expanded={expanded}
className={cn(
"group flex w-full items-start gap-2.5 text-left transition-colors hover:bg-accent/5",
workingIndicator === "collapsed" && isActive
? "rounded-full px-1 py-1"
: "rounded-lg px-1 py-2",
)}
onClick={() => hasContent && setExpanded((v) => !v)}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2.5">
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
{isActive ? (
<span className={cn(
"inline-flex items-center gap-2 font-medium text-foreground/80",
workingIndicator === "collapsed" && isActive
? "rounded-full border border-border/70 bg-muted/30 px-2 py-0.5 text-xs"
: "text-sm",
)}>
{workingIndicator === "collapsed" && isActive ? (
<span className="h-1.5 w-1.5 rounded-full bg-cyan-500" />
) : isActive ? (
<AnimatedPaperclipIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
) : agentIcon ? (
<AgentIcon icon={agentIcon} className="h-4 w-4 shrink-0" />
@@ -1351,6 +1379,7 @@ function IssueChatUserMessage({
onDeleteComment,
currentUserId,
userProfileMap,
density,
} = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>;
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
@@ -1394,6 +1423,131 @@ function IssueChatUserMessage({
setDeleteDialogOpen(false);
void onDeleteComment?.(commentId);
};
if (density === "compact") {
return (
<>
<div id={anchorId}>
<div className="group flex items-start gap-2.5 py-1">
{authorAvatar}
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">{resolvedAuthorName}</span>
{message.createdAt ? (
<a
href={anchorId ? `#${anchorId}` : undefined}
className="shrink-0 text-[10px] text-muted-foreground/60 hover:text-foreground hover:underline"
>
{commentDateLabel(message.createdAt)}
</a>
) : null}
<SourceTrustBadge sourceTrust={sourceTrust} artifactLabel="comment" />
{followUpRequested ? (
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
Follow-up
</Badge>
) : null}
{!deleted ? (
<span className="ml-auto flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy message"
aria-label="Copy message"
onClick={() => {
const text = message.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n\n");
void copyTextToClipboard(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch((error) => {
toastActions?.pushToast({
title: "Copy failed",
body: error instanceof Error ? error.message : "Unable to copy message",
tone: "error",
});
});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
{canDeleteComment ? (
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-destructive"
title="Delete comment"
aria-label="Delete comment"
onClick={handleDeleteComment}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</span>
) : null}
</div>
{queued ? (
<div className="mb-1.5 flex items-center gap-2">
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
{queueBadgeLabel}
</span>
{queueTargetRunId && onInterruptQueued ? (
<Button
size="sm"
variant="outline"
className="h-6 border-red-300 px-2 text-[11px] text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
disabled={isInterruptingQueuedRun}
onClick={() => void onInterruptQueued(queueTargetRunId)}
>
{isInterruptingQueuedRun ? "Interrupting..." : "Interrupt"}
</Button>
) : null}
{onCancelQueued ? (
<Button
size="sm"
variant="outline"
className="h-6 border-amber-300 px-2 text-[11px] text-amber-900 hover:bg-amber-100/80 hover:text-amber-950 dark:border-amber-500/40 dark:text-amber-100 dark:hover:bg-amber-500/10"
onClick={() => onCancelQueued(commentId)}
>
Cancel
</Button>
) : null}
</div>
) : null}
{deleted ? (
<div className="text-sm italic text-muted-foreground">Comment deleted</div>
) : (
<div className={cn("min-w-0 max-w-full space-y-2 text-sm leading-6 text-foreground", pending && "opacity-80")}>
<IssueChatTextParts message={message} />
</div>
)}
{pending ? (
<div className="mt-1 text-[11px] text-muted-foreground">Sending...</div>
) : null}
</div>
</div>
</div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete comment?</DialogTitle>
<DialogDescription>
This will replace the comment with a deleted-comment marker.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDeleteComment}>
Delete comment
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
const messageBody = (
<div className={cn("flex min-w-0 max-w-[85%] flex-col", isCurrentUser && "items-end")}>
<div className={cn("mb-1 flex items-center gap-2 px-1", isCurrentUser ? "justify-end" : "justify-start")}>
@@ -1585,6 +1739,7 @@ function IssueChatAssistantMessage({
stoppingRunLabel = "Stopping...",
stopRunVariant = "stop",
runFinalizationActions = [],
density,
} = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>;
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
@@ -1771,6 +1926,121 @@ function IssueChatAssistantMessage({
</div>
);
if (density === "compact") {
const compactActions = (
<span className="ml-auto flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Copy message"
aria-label="Copy message"
onClick={() => {
void copyTextToClipboard(copyText).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch((error) => {
toastActions?.pushToast({
title: "Copy failed",
body: error instanceof Error ? error.message : "Unable to copy message",
tone: "error",
});
});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
{commentId && onVote ? (
<IssueChatFeedbackButtons
activeVote={activeVote}
sharingPreference={feedbackDataSharingPreference}
termsUrl={feedbackTermsUrl ?? null}
onVote={handleVote}
/>
) : null}
{canStopRun && onStopRun && runId ? (
<button
type="button"
disabled={isStoppingRun}
className={cn(
"inline-flex h-6 w-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50",
stopRunVariant === "pause"
? "hover:text-amber-700 dark:hover:text-amber-300"
: "hover:text-red-700 dark:hover:text-red-300",
)}
title={isStoppingRun ? stoppingRunLabel : stopRunLabel}
aria-label={isStoppingRun ? stoppingRunLabel : stopRunLabel}
onClick={() => void onStopRun(runId)}
>
{stopRunVariant === "pause" ? (
<PauseCircle className="h-3.5 w-3.5" />
) : (
<Square className="h-3.5 w-3.5 fill-current" />
)}
</button>
) : null}
</span>
);
return (
<div id={anchorId}>
<div className="group flex items-start gap-2.5 py-1">
{agentAvatar}
<div className="min-w-0 flex-1">
<div className="mb-0.5 flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">{authorName}</span>
{message.createdAt ? (
<a
href={anchorId ? `#${anchorId}` : undefined}
className="shrink-0 text-[10px] text-muted-foreground/60 hover:text-foreground hover:underline"
>
{commentDateLabel(message.createdAt)}
</a>
) : null}
<SourceTrustBadge sourceTrust={sourceTrust} artifactLabel="comment" />
{followUpRequested ? (
<Badge variant="outline" className="text-[10px] uppercase tracking-[0.14em]">
Follow-up
</Badge>
) : null}
{isRunning ? (
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-400/40 bg-cyan-500/10 px-1.5 py-0.5 text-[10px] font-medium text-cyan-700 dark:text-cyan-200">
<span className="h-1.5 w-1.5 rounded-full bg-current" />
Running
</span>
) : null}
{!deleted ? compactActions : null}
</div>
{deleted ? (
<div className="text-sm italic text-muted-foreground">Comment deleted</div>
) : (
<div className="min-w-0 max-w-full space-y-2 text-sm leading-6 text-foreground">
<IssueChatAssistantParts message={message} hasCoT={hasCoT} />
{message.content.length === 0 && waitingText ? (
<div className="flex min-w-0 items-center gap-2 py-1 text-sm text-muted-foreground">
<AnimatedPaperclipIcon className="h-4 w-4 shrink-0" />
<span className="shimmer-text">{waitingText}</span>
</div>
) : null}
{notices.length > 0 ? (
<div className="space-y-2">
{notices.map((notice, index) => (
<div
key={`${message.id}:notice:${index}`}
className="rounded-sm bg-accent/20 px-3 py-2 text-sm text-muted-foreground"
>
{notice}
</div>
))}
</div>
) : null}
</div>
)}
</div>
</div>
</div>
);
}
// Genuine agent comment → neutral left-aligned bubble (mirror of the human
// blue bubble in IssueChatUserMessage). See PAP-95 rev 6.
if (isGenuineComment) {
@@ -3228,7 +3498,10 @@ const VirtualizedIssueChatThreadListInner = forwardRef<
};
}, [mode]);
const gap = variant === "embedded"
const { density } = useContext(IssueChatCtx);
const gap = density === "compact"
? 12
: variant === "embedded"
? VIRTUALIZED_THREAD_GAP_EMBEDDED_PX
: VIRTUALIZED_THREAD_GAP_FULL_PX;
@@ -3469,6 +3742,8 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
userLabelMap = null,
composerDisabledReason = null,
composerHint = null,
composerSubmitKey = "mod-enter",
composerSingleLine = false,
issueStatus,
issueWorkMode,
onWorkModeChange,
@@ -3880,10 +4155,16 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
placeholder="Reply"
mentions={mentions}
onSubmit={handleSubmit}
submitKey={composerSubmitKey}
imageUploadHandler={onImageUpload}
fileDropTarget="parent"
bordered={false}
contentClassName="max-h-[28dvh] overflow-y-auto pr-1 pb-2 text-sm scrollbar-auto-hide"
contentClassName={cn(
"overflow-y-auto pr-1 pb-2 text-sm scrollbar-auto-hide",
composerSingleLine
? "min-h-[2rem] max-h-[7.5rem]"
: "max-h-[28dvh]",
)}
/>
{coachVisible && plainNameCandidate ? (
@@ -4179,7 +4460,9 @@ export function IssueChatThread(props: IssueChatThreadProps) {
suggestedAssigneeValue,
mentions = [],
composerDisabledReason = null,
composerHint = null,
composerHint = presetConfig.composerSubmitKey === "enter"
? "Enter to send · Shift+Enter for a new line"
: null,
suppressIssueStatusNotices = presetConfig.suppressIssueStatusNotices,
showComposer = presetConfig.showComposer,
showJumpToLatest = presetConfig.showJumpToLatest,
@@ -4189,6 +4472,8 @@ export function IssueChatThread(props: IssueChatThreadProps) {
emptyState,
footer,
variant = presetConfig.variant,
density = presetConfig.density,
workingIndicator = presetConfig.workingIndicator,
enableLiveTranscriptPolling = true,
transcriptsByRunId,
hasOutputForRun: hasOutputForRunOverride,
@@ -4748,6 +5033,8 @@ export function IssueChatThread(props: IssueChatThreadProps) {
issueStatus,
successfulRunHandoff,
externalReferences,
density,
workingIndicator,
}),
[
feedbackDataSharingPreference,
@@ -4774,6 +5061,8 @@ export function IssueChatThread(props: IssueChatThreadProps) {
issueStatus,
successfulRunHandoff,
externalReferences,
density,
workingIndicator,
],
);
@@ -4790,7 +5079,7 @@ export function IssueChatThread(props: IssueChatThreadProps) {
return (
<AssistantRuntimeProvider runtime={runtime}>
<IssueChatCtx.Provider value={chatCtx}>
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
<div className={cn(density === "compact" ? "space-y-3" : variant === "embedded" ? "space-y-3" : "space-y-4")}>
{resolvedShowJumpToLatest ? (
<div className="flex justify-end">
<button
@@ -4813,7 +5102,7 @@ export function IssueChatThread(props: IssueChatThreadProps) {
<div data-testid="thread-root">
<div
data-testid="thread-viewport"
className={variant === "embedded" ? "space-y-3" : "space-y-4"}
className={density === "compact" ? "space-y-3" : variant === "embedded" ? "space-y-3" : "space-y-4"}
>
{messages.length === 0 ? (
emptyState ?? (
@@ -4959,6 +5248,8 @@ export function IssueChatThread(props: IssueChatThreadProps) {
userLabelMap={userLabelMap}
composerDisabledReason={composerDisabledReason}
composerHint={composerHint}
composerSubmitKey={presetConfig.composerSubmitKey}
composerSingleLine={presetConfig.composerSingleLine}
issueStatus={issueStatus}
issueWorkMode={issueWorkMode}
onWorkModeChange={onWorkModeChange}
+73
View File
@@ -258,6 +258,79 @@ describe("MarkdownEditor", () => {
});
});
it("defaults to Cmd/Ctrl+Enter submit but can opt into plain Enter submit", async () => {
const root = createRoot(container);
const onDefaultSubmit = vi.fn();
const onEnterSubmit = vi.fn();
await act(async () => {
root.render(
<MarkdownEditor
value="Ready"
onChange={() => {}}
onSubmit={onDefaultSubmit}
/>,
);
});
const defaultEditor = container.querySelector('[data-testid="mdx-editor"]') as HTMLDivElement | null;
expect(defaultEditor).not.toBeNull();
await act(async () => {
defaultEditor?.dispatchEvent(new KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
});
expect(onDefaultSubmit).not.toHaveBeenCalled();
await act(async () => {
defaultEditor?.dispatchEvent(new KeyboardEvent("keydown", {
key: "Enter",
metaKey: true,
bubbles: true,
cancelable: true,
}));
});
expect(onDefaultSubmit).toHaveBeenCalledTimes(1);
await act(async () => {
root.render(
<MarkdownEditor
value="Ready"
onChange={() => {}}
onSubmit={onEnterSubmit}
submitKey="enter"
/>,
);
});
const enterEditor = container.querySelector('[data-testid="mdx-editor"]') as HTMLDivElement | null;
await act(async () => {
enterEditor?.dispatchEvent(new KeyboardEvent("keydown", {
key: "Enter",
shiftKey: true,
bubbles: true,
cancelable: true,
}));
});
expect(onEnterSubmit).not.toHaveBeenCalled();
await act(async () => {
enterEditor?.dispatchEvent(new KeyboardEvent("keydown", {
key: "Enter",
bubbles: true,
cancelable: true,
}));
});
expect(onEnterSubmit).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
});
});
it("keeps the external value when the unfocused editor emits an empty mount reset", async () => {
mdxEditorMockState.emitMountEmptyReset = true;
const handleChange = vi.fn();
+21 -3
View File
@@ -84,6 +84,8 @@ interface MarkdownEditorProps {
mentions?: MentionOption[];
/** Called on Cmd/Ctrl+Enter */
onSubmit?: () => void;
/** Controls which Enter gesture submits when onSubmit is provided. */
submitKey?: "mod-enter" | "enter";
/** Render the rich editor without allowing edits. */
readOnly?: boolean;
}
@@ -598,6 +600,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
bordered = true,
mentions,
onSubmit,
submitKey = "mod-enter",
readOnly = false,
}: MarkdownEditorProps, forwardedRef) {
const editorValue = useMemo(() => prepareMarkdownForEditor(value), [value]);
@@ -1134,7 +1137,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
}}
onBlur={() => onBlur?.()}
onKeyDown={(event) => {
if (onSubmit && event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
const shouldSubmit =
onSubmit
&& event.key === "Enter"
&& (
submitKey === "enter"
? !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey
: event.metaKey || event.ctrlKey
);
if (shouldSubmit) {
event.preventDefault();
onSubmit();
}
@@ -1159,8 +1170,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
)}
onKeyDownCapture={(e) => {
if (readOnly) return;
// Cmd/Ctrl+Enter to submit
if (onSubmit && e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
const shouldSubmit =
onSubmit
&& e.key === "Enter"
&& (
submitKey === "enter"
? !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey
: e.metaKey || e.ctrlKey
);
if (shouldSubmit) {
e.preventDefault();
e.stopPropagation();
onSubmit();
@@ -17,7 +17,6 @@ describe("resolveIssueChatThreadPreset", () => {
suppressIssueStatusNotices: false,
enableReassign: false,
showBackgroundWorkChildren: true,
showRunFinalizationActions: true,
composerSubmitKey: "mod-enter",
composerSingleLine: false,
});
@@ -42,10 +41,8 @@ describe("resolveIssueChatThreadPreset", () => {
suppressIssueStatusNotices: true,
enableReassign: false,
showBackgroundWorkChildren: false,
showRunFinalizationActions: false,
composerSubmitKey: "enter",
composerSingleLine: true,
composerMentions: "optional",
});
});
-8
View File
@@ -20,10 +20,8 @@ export interface IssueChatThreadPresetConfig {
suppressIssueStatusNotices: boolean;
enableReassign: boolean;
showBackgroundWorkChildren: boolean;
showRunFinalizationActions: boolean;
composerSubmitKey: IssueChatThreadComposerSubmitKey;
composerSingleLine: boolean;
composerMentions: "enabled" | "optional";
}
export interface IssueChatThreadPresetOverrides {
@@ -63,10 +61,8 @@ const ISSUE_CHAT_THREAD_PRESETS: Record<IssueChatThreadPresetName, IssueChatThre
suppressIssueStatusNotices: false,
enableReassign: false,
showBackgroundWorkChildren: true,
showRunFinalizationActions: true,
composerSubmitKey: "mod-enter",
composerSingleLine: false,
composerMentions: "enabled",
},
assistant: {
density: "compact",
@@ -79,10 +75,8 @@ const ISSUE_CHAT_THREAD_PRESETS: Record<IssueChatThreadPresetName, IssueChatThre
suppressIssueStatusNotices: true,
enableReassign: false,
showBackgroundWorkChildren: false,
showRunFinalizationActions: false,
composerSubmitKey: "enter",
composerSingleLine: true,
composerMentions: "optional",
},
};
@@ -108,9 +102,7 @@ export function resolveIssueChatThreadPreset(
enableReassign: overrides.enableReassign ?? definition.enableReassign,
showBackgroundWorkChildren:
overrides.showBackgroundWorkChildren ?? definition.showBackgroundWorkChildren,
showRunFinalizationActions: definition.showRunFinalizationActions,
composerSubmitKey: definition.composerSubmitKey,
composerSingleLine: definition.composerSingleLine,
composerMentions: definition.composerMentions,
};
}
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Agent, IssueComment } from "@paperclipai/shared";
import type { ActiveRunForIssue, LiveRunForIssue } from "@/api/heartbeats";
import { AssistantChatView } from "@/components/AssistantChat";
import type { IssueChatComment } from "@/lib/issue-chat-messages";
import type { IssueChatComment, IssueChatTranscriptEntry } from "@/lib/issue-chat-messages";
import { pendingSuggestedTasksInteraction } from "@/fixtures/issueThreadInteractionFixtures";
import { storybookAgents } from "../fixtures/paperclipData";
@@ -94,6 +94,38 @@ const runningLiveRun: LiveRunForIssue = {
issueId,
};
const runningTranscriptsByRunId = new Map<string, readonly IssueChatTranscriptEntry[]>([
[
runningActiveRun.id,
[
{
kind: "thinking",
ts: "2026-06-14T15:05:03.000Z",
text: "Checking recent connector status, open task blockers, and whether the Sheets API key is still the gating item.",
},
{
kind: "tool_call",
ts: "2026-06-14T15:05:07.000Z",
name: "search_issues",
toolUseId: "tool-ceo-active-1",
input: { q: "MCP connector Sheets blocked" },
},
{
kind: "tool_result",
ts: "2026-06-14T15:05:09.000Z",
toolUseId: "tool-ceo-active-1",
content: "Found two connector tasks complete and one Sheets task blocked on missing Google API credentials.",
isError: false,
},
{
kind: "assistant",
ts: "2026-06-14T15:05:12.000Z",
text: "I am checking the team status now and will summarize the blocker and recommended next step.",
},
],
],
]);
function Frame({ children }: { children: React.ReactNode }) {
return (
<div className="paperclip-story__frame mx-auto flex h-[640px] w-full max-w-3xl flex-col overflow-hidden">
@@ -158,6 +190,7 @@ export const ActiveRun: Story = {
comments={[conversationComments[0]!]}
activeRun={runningActiveRun}
liveRuns={[runningLiveRun]}
transcriptsByRunId={runningTranscriptsByRunId}
/>
),
};