Add issue detail shortcut for comment composer

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-08 09:24:32 -05:00
parent 15b0f11275
commit 1079f21ac4
6 changed files with 164 additions and 44 deletions

View file

@ -1,12 +1,16 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { act } from "react"; import { act, createRef, forwardRef, useImperativeHandle } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread"; import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread";
const { markdownEditorFocusMock } = vi.hoisted(() => ({
markdownEditorFocusMock: vi.fn(),
}));
vi.mock("@assistant-ui/react", () => ({ vi.mock("@assistant-ui/react", () => ({
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>, AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ThreadPrimitive: { ThreadPrimitive: {
@ -48,7 +52,7 @@ vi.mock("./MarkdownBody", () => ({
})); }));
vi.mock("./MarkdownEditor", () => ({ vi.mock("./MarkdownEditor", () => ({
MarkdownEditor: ({ MarkdownEditor: forwardRef(({
value = "", value = "",
onChange, onChange,
placeholder, placeholder,
@ -60,7 +64,12 @@ vi.mock("./MarkdownEditor", () => ({
placeholder?: string; placeholder?: string;
className?: string; className?: string;
contentClassName?: string; contentClassName?: string;
}) => ( }, ref) => {
useImperativeHandle(ref, () => ({
focus: markdownEditorFocusMock,
}));
return (
<textarea <textarea
aria-label="Issue chat editor" aria-label="Issue chat editor"
data-class-name={className} data-class-name={className}
@ -69,7 +78,8 @@ vi.mock("./MarkdownEditor", () => ({
value={value} value={value}
onChange={(event) => onChange?.(event.target.value)} onChange={(event) => onChange?.(event.target.value)}
/> />
), );
}),
})); }));
vi.mock("./InlineEntitySelector", () => ({ vi.mock("./InlineEntitySelector", () => ({
@ -111,6 +121,7 @@ describe("IssueChatThread", () => {
afterEach(() => { afterEach(() => {
container.remove(); container.remove();
vi.useRealTimers(); vi.useRealTimers();
markdownEditorFocusMock.mockReset();
}); });
it("drops the count heading and does not use an internal scrollbox", () => { it("drops the count heading and does not use an internal scrollbox", () => {
@ -279,6 +290,52 @@ describe("IssueChatThread", () => {
}); });
}); });
it("exposes a composer focus handle that forwards to the editor", () => {
const root = createRoot(container);
const composerRef = createRef<{ focus: () => void }>();
const requestAnimationFrameMock = vi
.spyOn(window, "requestAnimationFrame")
.mockImplementation((callback: FrameRequestCallback) => {
callback(0);
return 1;
});
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
composerRef={composerRef}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
expect(composerRef.current).not.toBeNull();
expect(composer).not.toBeNull();
const scrollIntoViewMock = vi.fn();
composer!.scrollIntoView = scrollIntoViewMock;
act(() => {
composerRef.current?.focus();
});
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth", block: "end" });
expect(markdownEditorFocusMock).toHaveBeenCalledTimes(1);
requestAnimationFrameMock.mockRestore();
act(() => {
root.unmount();
});
});
it("folds chain-of-thought when the same message transitions from running to complete", () => { it("folds chain-of-thought when the same message transitions from running to complete", () => {
expect(resolveAssistantMessageFoldedState({ expect(resolveAssistantMessageFoldedState({
messageId: "message-1", messageId: "message-1",

View file

@ -8,7 +8,18 @@ import {
useMessage, useMessage,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import type { ToolCallMessagePart } from "@assistant-ui/react"; import type { ToolCallMessagePart } from "@assistant-ui/react";
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import {
createContext,
forwardRef,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ChangeEvent,
type Ref,
} from "react";
import { Link, useLocation } from "@/lib/router"; import { Link, useLocation } from "@/lib/router";
import type { import type {
Agent, Agent,
@ -145,6 +156,24 @@ interface CommentReassignment {
assigneeUserId: string | null; assigneeUserId: string | null;
} }
export interface IssueChatComposerHandle {
focus: () => void;
}
interface IssueChatComposerProps {
onImageUpload?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<void>;
draftKey?: string;
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[];
agentMap?: Map<string, Agent>;
composerDisabledReason?: string | null;
issueStatus?: string;
}
interface IssueChatThreadProps { interface IssueChatThreadProps {
comments: IssueChatComment[]; comments: IssueChatComment[];
feedbackVotes?: FeedbackVote[]; feedbackVotes?: FeedbackVote[];
@ -186,6 +215,7 @@ interface IssueChatThreadProps {
onInterruptQueued?: (runId: string) => Promise<void>; onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null; interruptingQueuedRunId?: string | null;
onImageClick?: (src: string) => void; onImageClick?: (src: string) => void;
composerRef?: Ref<IssueChatComposerHandle>;
} }
const DRAFT_DEBOUNCE_MS = 800; const DRAFT_DEBOUNCE_MS = 800;
@ -1361,7 +1391,7 @@ function IssueChatSystemMessage() {
return null; return null;
} }
function IssueChatComposer({ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerProps>(function IssueChatComposer({
onImageUpload, onImageUpload,
onAttachImage, onAttachImage,
draftKey, draftKey,
@ -1373,19 +1403,7 @@ function IssueChatComposer({
agentMap, agentMap,
composerDisabledReason = null, composerDisabledReason = null,
issueStatus, issueStatus,
}: { }, forwardedRef) {
onImageUpload?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<void>;
draftKey?: string;
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string;
suggestedAssigneeValue?: string;
mentions?: MentionOption[];
agentMap?: Map<string, Agent>;
composerDisabledReason?: string | null;
issueStatus?: string;
}) {
const api = useAui(); const api = useAui();
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled"); const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled");
@ -1395,6 +1413,7 @@ function IssueChatComposer({
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
const attachInputRef = useRef<HTMLInputElement | null>(null); const attachInputRef = useRef<HTMLInputElement | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null); const editorRef = useRef<MarkdownEditorRef>(null);
const composerContainerRef = useRef<HTMLDivElement | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
@ -1420,6 +1439,15 @@ function IssueChatComposer({
setReassignTarget(effectiveSuggestedAssigneeValue); setReassignTarget(effectiveSuggestedAssigneeValue);
}, [effectiveSuggestedAssigneeValue]); }, [effectiveSuggestedAssigneeValue]);
useImperativeHandle(forwardedRef, () => ({
focus: () => {
composerContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
requestAnimationFrame(() => {
editorRef.current?.focus();
});
},
}), []);
async function handleSubmit() { async function handleSubmit() {
const trimmed = body.trim(); const trimmed = body.trim();
if (!trimmed || submitting) return; if (!trimmed || submitting) return;
@ -1489,6 +1517,7 @@ function IssueChatComposer({
return ( return (
<div <div
ref={composerContainerRef}
data-testid="issue-chat-composer" data-testid="issue-chat-composer"
className="sticky bottom-0 z-10 bg-gradient-to-t from-background via-background/95 to-background/70 pt-4 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] backdrop-blur supports-[backdrop-filter]:from-background/92 supports-[backdrop-filter]:via-background/82" className="sticky bottom-0 z-10 bg-gradient-to-t from-background via-background/95 to-background/70 pt-4 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] backdrop-blur supports-[backdrop-filter]:from-background/92 supports-[backdrop-filter]:via-background/82"
> >
@ -1584,7 +1613,7 @@ function IssueChatComposer({
</div> </div>
</div> </div>
); );
} });
export function IssueChatThread({ export function IssueChatThread({
comments, comments,
@ -1623,6 +1652,7 @@ export function IssueChatThread({
onInterruptQueued, onInterruptQueued,
interruptingQueuedRunId = null, interruptingQueuedRunId = null,
onImageClick, onImageClick,
composerRef,
}: IssueChatThreadProps) { }: IssueChatThreadProps) {
const location = useLocation(); const location = useLocation();
const hasScrolledRef = useRef(false); const hasScrolledRef = useRef(false);
@ -1815,6 +1845,7 @@ export function IssueChatThread({
{showComposer ? ( {showComposer ? (
<IssueChatComposer <IssueChatComposer
ref={composerRef}
onImageUpload={imageUploadHandler} onImageUpload={imageUploadHandler}
onAttachImage={onAttachImage} onAttachImage={onAttachImage}
draftKey={draftKey} draftKey={draftKey}

View file

@ -28,6 +28,7 @@ const sections: ShortcutSection[] = [
shortcuts: [ shortcuts: [
{ keys: ["y"], label: "Quick-archive back to inbox" }, { keys: ["y"], label: "Quick-archive back to inbox" },
{ keys: ["g", "i"], label: "Go to inbox" }, { keys: ["g", "i"], label: "Go to inbox" },
{ keys: ["g", "c"], label: "Focus comment composer" },
], ],
}, },
{ {

View file

@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
import { import {
hasBlockingShortcutDialog, hasBlockingShortcutDialog,
isKeyboardShortcutTextInputTarget, isKeyboardShortcutTextInputTarget,
resolveGoToInboxKeyAction, resolveIssueDetailGoKeyAction,
resolveInboxQuickArchiveKeyAction, resolveInboxQuickArchiveKeyAction,
} from "./keyboardShortcuts"; } from "./keyboardShortcuts";
@ -114,7 +114,7 @@ describe("keyboardShortcuts helpers", () => {
it("arms go-to-inbox on a clean g press", () => { it("arms go-to-inbox on a clean g press", () => {
const button = document.createElement("button"); const button = document.createElement("button");
expect(resolveGoToInboxKeyAction({ expect(resolveIssueDetailGoKeyAction({
armed: false, armed: false,
defaultPrevented: false, defaultPrevented: false,
key: "g", key: "g",
@ -129,7 +129,7 @@ describe("keyboardShortcuts helpers", () => {
it("navigates to inbox on i after g", () => { it("navigates to inbox on i after g", () => {
const button = document.createElement("button"); const button = document.createElement("button");
expect(resolveGoToInboxKeyAction({ expect(resolveIssueDetailGoKeyAction({
armed: true, armed: true,
defaultPrevented: false, defaultPrevented: false,
key: "i", key: "i",
@ -138,13 +138,28 @@ describe("keyboardShortcuts helpers", () => {
altKey: false, altKey: false,
target: button, target: button,
hasOpenDialog: false, hasOpenDialog: false,
})).toBe("navigate"); })).toBe("navigate_inbox");
});
it("focuses the comment composer on c after g", () => {
const button = document.createElement("button");
expect(resolveIssueDetailGoKeyAction({
armed: true,
defaultPrevented: false,
key: "c",
metaKey: false,
ctrlKey: false,
altKey: false,
target: button,
hasOpenDialog: false,
})).toBe("focus_comment");
}); });
it("disarms go-to-inbox instead of firing from an editor", () => { it("disarms go-to-inbox instead of firing from an editor", () => {
const input = document.createElement("textarea"); const input = document.createElement("textarea");
expect(resolveGoToInboxKeyAction({ expect(resolveIssueDetailGoKeyAction({
armed: true, armed: true,
defaultPrevented: false, defaultPrevented: false,
key: "i", key: "i",

View file

@ -11,7 +11,7 @@ export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]); const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm"; export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
export type GoToInboxKeyAction = "ignore" | "arm" | "navigate" | "disarm"; export type IssueDetailGoKeyAction = "ignore" | "arm" | "navigate_inbox" | "focus_comment" | "disarm";
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean { export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false; if (!(target instanceof HTMLElement)) return false;
@ -54,7 +54,7 @@ export function resolveInboxQuickArchiveKeyAction({
return "ignore"; return "ignore";
} }
export function resolveGoToInboxKeyAction({ export function resolveIssueDetailGoKeyAction({
armed, armed,
defaultPrevented, defaultPrevented,
key, key,
@ -72,7 +72,7 @@ export function resolveGoToInboxKeyAction({
altKey: boolean; altKey: boolean;
target: EventTarget | null; target: EventTarget | null;
hasOpenDialog: boolean; hasOpenDialog: boolean;
}): GoToInboxKeyAction { }): IssueDetailGoKeyAction {
if (defaultPrevented) return armed ? "disarm" : "ignore"; if (defaultPrevented) return armed ? "disarm" : "ignore";
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore"; if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) { if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) {
@ -81,7 +81,8 @@ export function resolveGoToInboxKeyAction({
const normalizedKey = key.toLowerCase(); const normalizedKey = key.toLowerCase();
if (!armed) return normalizedKey === "g" ? "arm" : "ignore"; if (!armed) return normalizedKey === "g" ? "arm" : "ignore";
if (normalizedKey === "i") return "navigate"; if (normalizedKey === "i") return "navigate_inbox";
if (normalizedKey === "c") return "focus_comment";
if (normalizedKey === "g") return "arm"; if (normalizedKey === "g") return "arm";
return "disarm"; return "disarm";
} }

View file

@ -27,7 +27,7 @@ import {
} from "../lib/issueDetailBreadcrumb"; } from "../lib/issueDetailBreadcrumb";
import { import {
hasBlockingShortcutDialog, hasBlockingShortcutDialog,
resolveGoToInboxKeyAction, resolveIssueDetailGoKeyAction,
resolveInboxQuickArchiveKeyAction, resolveInboxQuickArchiveKeyAction,
} from "../lib/keyboardShortcuts"; } from "../lib/keyboardShortcuts";
import { import {
@ -46,7 +46,7 @@ import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { ApprovalCard } from "../components/ApprovalCard"; import { ApprovalCard } from "../components/ApprovalCard";
import { InlineEditor } from "../components/InlineEditor"; import { InlineEditor } from "../components/InlineEditor";
import { IssueChatThread } from "../components/IssueChatThread"; import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssueProperties } from "../components/IssueProperties"; import { IssueProperties } from "../components/IssueProperties";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
@ -322,8 +322,10 @@ export function IssueDetail() {
const [galleryOpen, setGalleryOpen] = useState(false); const [galleryOpen, setGalleryOpen] = useState(false);
const [galleryIndex, setGalleryIndex] = useState(0); const [galleryIndex, setGalleryIndex] = useState(0);
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]); const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null); const lastMarkedReadIssueIdRef = useRef<string | null>(null);
const commentComposerRef = useRef<IssueChatComposerHandle | null>(null);
const { data: issue, isLoading, error } = useQuery({ const { data: issue, isLoading, error } = useQuery({
queryKey: queryKeys.issues.detail(issueId!), queryKey: queryKeys.issues.detail(issueId!),
@ -1310,7 +1312,7 @@ export function IssueDetail() {
}; };
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
const action = resolveGoToInboxKeyAction({ const action = resolveIssueDetailGoKeyAction({
armed: goToInboxShortcutArmedRef.current, armed: goToInboxShortcutArmedRef.current,
defaultPrevented: event.defaultPrevented, defaultPrevented: event.defaultPrevented,
key: event.key, key: event.key,
@ -1328,10 +1330,16 @@ export function IssueDetail() {
} }
disarm(); disarm();
if (action !== "navigate") return; if (action === "navigate_inbox") {
event.preventDefault(); event.preventDefault();
navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox"); navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox");
return;
}
if (action === "focus_comment") {
event.preventDefault();
setDetailTab("chat");
setPendingCommentComposerFocusKey((current) => current + 1);
}
}; };
document.addEventListener("pointerdown", handlePointerDown, true); document.addEventListener("pointerdown", handlePointerDown, true);
@ -1345,6 +1353,12 @@ export function IssueDetail() {
}; };
}, [keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href]); }, [keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href]);
useEffect(() => {
if (pendingCommentComposerFocusKey === 0) return;
if (detailTab !== "chat") return;
commentComposerRef.current?.focus();
}, [detailTab, pendingCommentComposerFocusKey]);
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/"); const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
const attachmentList = attachments ?? []; const attachmentList = attachments ?? [];
const imageAttachments = attachmentList.filter(isImageAttachment); const imageAttachments = attachmentList.filter(isImageAttachment);
@ -1933,6 +1947,7 @@ export function IssueDetail() {
<TabsContent value="chat"> <TabsContent value="chat">
<IssueChatThread <IssueChatThread
composerRef={commentComposerRef}
comments={commentsWithRunMeta} comments={commentsWithRunMeta}
feedbackVotes={feedbackVotes} feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference} feedbackDataSharingPreference={feedbackDataSharingPreference}