diff --git a/ui/src/components/AssistantChat.tsx b/ui/src/components/AssistantChat.tsx index 47cb989b848..abee6b83b66 100644 --- a/ui/src/components/AssistantChat.tsx +++ b/ui/src/components/AssistantChat.tsx @@ -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; 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} diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 9d0244ae4b8..37f0aaec46c 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -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( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + 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([ + [ + "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( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + 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[] = [ diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 0bf2e030592..1cbb812be91 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -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({ @@ -233,6 +238,8 @@ const IssueChatCtx = createContext({ 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 | null; composerDisabledReason?: string | null; composerHint?: string | null; + composerSubmitKey?: IssueChatThreadComposerSubmitKey; + composerSingleLine?: boolean; issueStatus?: string; issueWorkMode?: IssueWorkMode; onWorkModeChange?: (workMode: IssueWorkMode) => Promise | void; @@ -426,6 +435,8 @@ interface IssueChatThreadProps { emptyState?: ReactNode; footer?: ReactNode; variant?: "full" | "embedded"; + density?: IssueChatThreadDensity; + workingIndicator?: IssueChatThreadWorkingIndicator; enableLiveTranscriptPolling?: boolean; transcriptsByRunId?: ReadonlyMap; 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; 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({
+ {canDeleteComment ? ( + + ) : null} + + ) : null} +
+ {queued ? ( +
+ + {queueBadgeLabel} + + {queueTargetRunId && onInterruptQueued ? ( + + ) : null} + {onCancelQueued ? ( + + ) : null} +
+ ) : null} + {deleted ? ( +
Comment deleted
+ ) : ( +
+ +
+ )} + {pending ? ( +
Sending...
+ ) : null} + + + + + + + Delete comment? + + This will replace the comment with a deleted-comment marker. + + + + + + + + + + ); + } const messageBody = (
@@ -1585,6 +1739,7 @@ function IssueChatAssistantMessage({ stoppingRunLabel = "Stopping...", stopRunVariant = "stop", runFinalizationActions = [], + density, } = useContext(IssueChatCtx); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; @@ -1771,6 +1926,121 @@ function IssueChatAssistantMessage({
); + if (density === "compact") { + const compactActions = ( + + + {commentId && onVote ? ( + + ) : null} + {canStopRun && onStopRun && runId ? ( + + ) : null} + + ); + + return ( +
+
+ {agentAvatar} +
+
+ {authorName} + {message.createdAt ? ( + + {commentDateLabel(message.createdAt)} + + ) : null} + + {followUpRequested ? ( + + Follow-up + + ) : null} + {isRunning ? ( + + + Running + + ) : null} + {!deleted ? compactActions : null} +
+ {deleted ? ( +
Comment deleted
+ ) : ( +
+ + {message.content.length === 0 && waitingText ? ( +
+ + {waitingText} +
+ ) : null} + {notices.length > 0 ? ( +
+ {notices.map((notice, index) => ( +
+ {notice} +
+ ))} +
+ ) : null} +
+ )} +
+
+
+ ); + } + // 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 {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 ( -
+
{resolvedShowJumpToLatest ? (