mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 02:20:38 +09:00
Polish issue chat layout and add UX lab
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
73abe4c76e
commit
3fea60c04c
5 changed files with 724 additions and 25 deletions
123
ui/src/components/IssueChatThread.test.tsx
Normal file
123
ui/src/components/IssueChatThread.test.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueChatThread } from "./IssueChatThread";
|
||||
|
||||
vi.mock("@assistant-ui/react", () => ({
|
||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ThreadPrimitive: {
|
||||
Root: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div data-testid="thread-root" className={className}>{children}</div>
|
||||
),
|
||||
Viewport: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div data-testid="thread-viewport" className={className}>{children}</div>
|
||||
),
|
||||
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Messages: () => <div data-testid="thread-messages" />,
|
||||
},
|
||||
MessagePrimitive: {
|
||||
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Content: () => null,
|
||||
},
|
||||
useAui: () => ({ thread: () => ({ append: vi.fn() }) }),
|
||||
useAuiState: () => false,
|
||||
useMessage: () => ({
|
||||
id: "message",
|
||||
role: "assistant",
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
content: [],
|
||||
metadata: { custom: {} },
|
||||
status: { type: "complete" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./transcript/useLiveRunTranscripts", () => ({
|
||||
useLiveRunTranscripts: () => ({
|
||||
transcriptByRun: new Map(),
|
||||
hasOutputForRun: () => false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownEditor", () => ({
|
||||
MarkdownEditor: () => <textarea aria-label="Issue chat editor" />,
|
||||
}));
|
||||
|
||||
vi.mock("./InlineEntitySelector", () => ({
|
||||
InlineEntitySelector: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./Identity", () => ({
|
||||
Identity: ({ name }: { name: string }) => <span>{name}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("./OutputFeedbackButtons", () => ({
|
||||
OutputFeedbackButtons: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./AgentIconPicker", () => ({
|
||||
AgentIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./StatusBadge", () => ({
|
||||
StatusBadge: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/usePaperclipIssueRuntime", () => ({
|
||||
usePaperclipIssueRuntime: () => ({}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("IssueChatThread", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("drops the count heading and does not use an internal scrollbox", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Jump to latest");
|
||||
expect(container.textContent).not.toContain("Chat (");
|
||||
|
||||
const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null;
|
||||
expect(viewport).not.toBeNull();
|
||||
expect(viewport?.className).not.toContain("overflow-y-auto");
|
||||
expect(viewport?.className).not.toContain("max-h-[70vh]");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
buildIssueChatMessages,
|
||||
type IssueChatComment,
|
||||
type IssueChatLinkedRun,
|
||||
type IssueChatTranscriptEntry,
|
||||
} from "../lib/issue-chat-messages";
|
||||
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -70,6 +71,10 @@ interface IssueChatThreadProps {
|
|||
suggestedAssigneeValue?: string;
|
||||
mentions?: MentionOption[];
|
||||
composerDisabledReason?: string | null;
|
||||
showComposer?: boolean;
|
||||
enableLiveTranscriptPolling?: boolean;
|
||||
transcriptsByRunId?: ReadonlyMap<string, readonly IssueChatTranscriptEntry[]>;
|
||||
hasOutputForRun?: (runId: string) => boolean;
|
||||
}
|
||||
|
||||
const DRAFT_DEBOUNCE_MS = 800;
|
||||
|
|
@ -735,9 +740,14 @@ export function IssueChatThread({
|
|||
suggestedAssigneeValue,
|
||||
mentions = [],
|
||||
composerDisabledReason = null,
|
||||
showComposer = true,
|
||||
enableLiveTranscriptPolling = true,
|
||||
transcriptsByRunId,
|
||||
hasOutputForRun: hasOutputForRunOverride,
|
||||
}: IssueChatThreadProps) {
|
||||
const location = useLocation();
|
||||
const hasScrolledRef = useRef(false);
|
||||
const bottomAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
const displayLiveRuns = useMemo(() => {
|
||||
const deduped = new Map<string, LiveRunForIssue>();
|
||||
for (const run of liveRuns) {
|
||||
|
|
@ -759,7 +769,12 @@ export function IssueChatThread({
|
|||
}
|
||||
return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
}, [activeRun, liveRuns]);
|
||||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs: displayLiveRuns, companyId });
|
||||
const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({
|
||||
runs: enableLiveTranscriptPolling ? displayLiveRuns : [],
|
||||
companyId,
|
||||
});
|
||||
const resolvedTranscriptByRun = transcriptsByRunId ?? transcriptByRun;
|
||||
const resolvedHasOutputForRun = hasOutputForRunOverride ?? hasOutputForRun;
|
||||
|
||||
const messages = useMemo(
|
||||
() =>
|
||||
|
|
@ -769,8 +784,8 @@ export function IssueChatThread({
|
|||
linkedRuns,
|
||||
liveRuns,
|
||||
activeRun,
|
||||
transcriptsByRunId: transcriptByRun,
|
||||
hasOutputForRun,
|
||||
transcriptsByRunId: resolvedTranscriptByRun,
|
||||
hasOutputForRun: resolvedHasOutputForRun,
|
||||
companyId,
|
||||
projectId,
|
||||
agentMap,
|
||||
|
|
@ -782,8 +797,8 @@ export function IssueChatThread({
|
|||
linkedRuns,
|
||||
liveRuns,
|
||||
activeRun,
|
||||
transcriptByRun,
|
||||
hasOutputForRun,
|
||||
resolvedTranscriptByRun,
|
||||
resolvedHasOutputForRun,
|
||||
companyId,
|
||||
projectId,
|
||||
agentMap,
|
||||
|
|
@ -819,6 +834,10 @@ export function IssueChatThread({
|
|||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, [location.hash, messages]);
|
||||
|
||||
function handleJumpToLatest() {
|
||||
bottomAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}
|
||||
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
UserMessage: () => <IssueChatUserMessage companyId={companyId} projectId={projectId} />,
|
||||
|
|
@ -845,38 +864,44 @@ export function IssueChatThread({
|
|||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">Chat ({messages.length})</h3>
|
||||
<ThreadPrimitive.ScrollToBottom className="text-xs text-muted-foreground hover:text-foreground">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleJumpToLatest}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Jump to latest
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ThreadPrimitive.Root className="rounded-2xl border border-border bg-background shadow-sm">
|
||||
<ThreadPrimitive.Viewport className="max-h-[70vh] space-y-4 overflow-y-auto px-4 py-4">
|
||||
<ThreadPrimitive.Root className="rounded-[28px] border border-border/70 bg-[linear-gradient(180deg,rgba(15,23,42,0.02),transparent_22%),var(--background)] px-4 py-4 shadow-sm">
|
||||
<ThreadPrimitive.Viewport className="space-y-4">
|
||||
<ThreadPrimitive.Empty>
|
||||
<div className="rounded-2xl border border-dashed border-border bg-card px-6 py-10 text-center text-sm text-muted-foreground">
|
||||
This issue conversation is empty. Start with a message below.
|
||||
</div>
|
||||
</ThreadPrimitive.Empty>
|
||||
<ThreadPrimitive.Messages components={components} />
|
||||
<div ref={bottomAnchorRef} />
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
|
||||
<IssueChatComposer
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentions}
|
||||
agentMap={agentMap}
|
||||
composerDisabledReason={composerDisabledReason}
|
||||
issueStatus={issueStatus}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
{showComposer ? (
|
||||
<IssueChatComposer
|
||||
onImageUpload={imageUploadHandler}
|
||||
onAttachImage={onAttachImage}
|
||||
draftKey={draftKey}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentions}
|
||||
agentMap={agentMap}
|
||||
composerDisabledReason={composerDisabledReason}
|
||||
issueStatus={issueStatus}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue