mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-20 04:20:38 +09:00
Add issue detail shortcut for comment composer
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
15b0f11275
commit
1079f21ac4
6 changed files with 164 additions and 44 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue