mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
Wire assistant chat preset behaviors
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a30c25a50c
commit
e16b1de5d0
8 files changed
+597
-27
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user