Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 99 additions & 17 deletions apps/desktop/src/components/EditorAIAssistantDock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,33 @@ const assistantEntry: AITaskEntryPoint = {

function renderDock(overrides?: Partial<ComponentProps<typeof EditorAIAssistantDock>>) {
const onSubmit = overrides?.onSubmit ?? vi.fn();
const props: ComponentProps<typeof EditorAIAssistantDock> = {
canApplyDrafts: true,
canSubmit: true,
currentDocumentSource: "# 현재 본문",
entries: [assistantEntry],
isOpen: true,
isPending: false,
isVisible: true,
messages: [],
onApplyDraft: vi.fn(),
onClose: vi.fn(),
onCopyDraft: vi.fn(),
onPromptBlur: vi.fn(),
onPromptChange: vi.fn(),
onSelectEntry: vi.fn(),
onSubmit,
onToggleCompare: vi.fn(),
onToggleOpen: vi.fn(),
onUndoDraftApply: vi.fn(),
prompt: "한글 입력",
selectedEntry: assistantEntry,
...overrides,
};

render(
<FloatingDockProvider>
<EditorAIAssistantDock
canSubmit
entries={[assistantEntry]}
isOpen
isPending={false}
isVisible
messages={[]}
onClose={vi.fn()}
onPromptBlur={vi.fn()}
onPromptChange={vi.fn()}
onSelectEntry={vi.fn()}
onSubmit={onSubmit}
onToggleOpen={vi.fn()}
prompt="한글 입력"
selectedEntry={assistantEntry}
{...overrides}
/>
<EditorAIAssistantDock {...props} />
</FloatingDockProvider>,
);

Expand Down Expand Up @@ -102,4 +109,79 @@ describe("EditorAIAssistantDock", () => {

expect(screen.getByText("길어진 답변").parentElement?.className).toContain("overflow-y-auto");
});

it("renders proposal actions for assistant messages with an applyable draft", () => {
const onCopyDraft = vi.fn();

renderDock({
messages: [
{
id: "assistant-1",
role: "assistant",
content: "## Recommendation\n\n요약",
format: "markdown",
proposal: {
recommendation: "문서 구조를 더 명확히 정리합니다.",
draftMarkdown: "## Architecture\n\n정리된 본문",
notes: null,
},
isStreaming: false,
},
],
onCopyDraft,
});

expect(screen.getByRole("button", { name: "초안 적용" })).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "복사" }));
expect(screen.getByRole("button", { name: "비교 보기" })).toBeTruthy();
expect(onCopyDraft).toHaveBeenCalledWith("assistant-1");
});

it("disables the apply action when the edit lock is unavailable", () => {
renderDock({
canApplyDrafts: false,
messages: [
{
id: "assistant-1",
role: "assistant",
content: "## Recommendation\n\n요약",
format: "markdown",
proposal: {
recommendation: "문서 구조를 더 명확히 정리합니다.",
draftMarkdown: "## Architecture\n\n정리된 본문",
notes: null,
},
isStreaming: false,
},
],
});

expect(screen.getByRole("button", { name: "초안 적용" }).hasAttribute("disabled")).toBe(true);
expect(screen.getByText("편집 잠금을 보유 중일 때만 초안을 적용할 수 있습니다.")).toBeTruthy();
});

it("toggles compare content when requested by the parent", () => {
renderDock({
messages: [
{
id: "assistant-1",
role: "assistant",
content: "## Recommendation\n\n요약",
format: "markdown",
proposal: {
recommendation: "문서 구조를 더 명확히 정리합니다.",
draftMarkdown: "## Architecture\n\n정리된 본문",
notes: "메모",
},
isStreaming: false,
isCompareOpen: true,
},
],
});

expect(screen.getByText("현재 본문")).toBeTruthy();
expect(screen.getByText("제안 초안")).toBeTruthy();
expect(screen.getAllByText("메모").length).toBe(2);
expect(screen.getByRole("button", { name: "비교 닫기" })).toBeTruthy();
});
});
119 changes: 117 additions & 2 deletions apps/desktop/src/components/EditorAIAssistantDock.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRef, type FocusEventHandler, type KeyboardEventHandler } from "react";
import { Bot, LoaderCircle, SendHorizontal, X } from "lucide-react";
import { Bot, Copy, LoaderCircle, RefreshCcw, SendHorizontal, WandSparkles, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useFloatingDockItem } from "@/components/FloatingDockProvider";
Expand All @@ -8,7 +8,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/lib/utils";
import type { AITaskEntryPoint } from "../types/domain-ui";
import type { AITaskEntryPoint, EditorAIDraftProposal } from "../types/domain-ui";

export type EditorAIAssistantMessage = {
id: string;
Expand All @@ -18,6 +18,10 @@ export type EditorAIAssistantMessage = {
format?: "markdown" | "plain";
isStreaming?: boolean;
tone?: "default" | "warning" | "danger";
proposal?: EditorAIDraftProposal | null;
isCompareOpen?: boolean;
canUndoApply?: boolean;
statusText?: string | null;
};

function normalizeStreamingMarkdown(content: string, isStreaming: boolean) {
Expand All @@ -39,33 +43,45 @@ export function EditorAIAssistantDock({
isPending,
isVisible,
messages,
currentDocumentSource,
prompt,
promptError,
selectedEntry,
canSubmit,
canApplyDrafts,
onClose,
onApplyDraft,
onCopyDraft,
onPromptBlur,
onPromptChange,
onSelectEntry,
onSubmit,
onToggleCompare,
onToggleOpen,
onUndoDraftApply,
}: {
entries: AITaskEntryPoint[];
emptyStateMessage?: string;
isOpen: boolean;
isPending: boolean;
isVisible: boolean;
messages: EditorAIAssistantMessage[];
currentDocumentSource: string;
prompt: string;
promptError?: string | null;
selectedEntry: AITaskEntryPoint | null;
canSubmit: boolean;
canApplyDrafts: boolean;
onClose: () => void;
onApplyDraft: (messageId: string) => void;
onCopyDraft: (messageId: string) => void;
onPromptBlur: FocusEventHandler<HTMLTextAreaElement>;
onPromptChange: (value: string) => void;
onSelectEntry: (id: string) => void;
onSubmit: () => void;
onToggleCompare: (messageId: string) => void;
onToggleOpen: () => void;
onUndoDraftApply: (messageId: string) => void;
}) {
const isPromptComposingRef = useRef(false);

Expand Down Expand Up @@ -215,6 +231,105 @@ export function EditorAIAssistantDock({
</div>
)}
</div>
{message.role === "assistant" &&
message.proposal &&
!message.isStreaming ? (
<div className="mt-4 space-y-3 border-t border-[var(--border)]/80 pt-3">
{message.proposal.recommendation ? (
<div className="rounded-[calc(var(--radius)-2px)] bg-[var(--secondary)]/55 px-3 py-2 text-xs leading-5 text-[var(--muted-foreground)]">
{message.proposal.recommendation}
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button
className="h-8"
clientLog="AI 초안 적용"
disabled={!canApplyDrafts || isPending}
onClick={() => onApplyDraft(message.id)}
size="sm"
type="button"
variant="softOutline"
>
<WandSparkles className="size-3.5" />
초안 적용
</Button>
<Button
className="h-8"
clientLog="AI 초안 복사"
onClick={() => onCopyDraft(message.id)}
size="sm"
type="button"
variant="quiet"
>
<Copy className="size-3.5" />
복사
</Button>
<Button
className="h-8"
clientLog="AI 초안 비교 보기"
onClick={() => onToggleCompare(message.id)}
size="sm"
type="button"
variant="quiet"
>
{message.isCompareOpen ? "비교 닫기" : "비교 보기"}
</Button>
{message.canUndoApply ? (
<Button
className="h-8"
clientLog="AI 초안 적용 되돌리기"
onClick={() => onUndoDraftApply(message.id)}
size="sm"
type="button"
variant="quiet"
>
<RefreshCcw className="size-3.5" />
되돌리기
</Button>
) : null}
</div>
{!canApplyDrafts ? (
<p className="text-xs text-[var(--muted-foreground)]">
편집 잠금을 보유 중일 때만 초안을 적용할 수 있습니다.
</p>
) : null}
{message.statusText ? (
<p className="text-xs text-[var(--muted-foreground)]">
{message.statusText}
</p>
) : null}
{message.isCompareOpen ? (
<div className="grid gap-3 rounded-[calc(var(--radius)-2px)] border border-[var(--border)] bg-[var(--secondary)]/35 p-3">
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--muted-foreground)]">
현재 본문
</p>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap rounded-[calc(var(--radius)-4px)] bg-[var(--background)] px-3 py-2 text-xs leading-5 text-[var(--foreground)]">
{currentDocumentSource}
</pre>
</div>
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--muted-foreground)]">
제안 초안
</p>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap rounded-[calc(var(--radius)-4px)] bg-[var(--background)] px-3 py-2 text-xs leading-5 text-[var(--foreground)]">
{message.proposal.draftMarkdown}
</pre>
</div>
{message.proposal.notes ? (
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--muted-foreground)]">
메모
</p>
<div className="rounded-[calc(var(--radius)-4px)] bg-[var(--background)] px-3 py-2 text-xs leading-5 text-[var(--muted-foreground)]">
{message.proposal.notes}
</div>
</div>
) : null}
</div>
) : null}
</div>
) : null}
</div>
))}
{isPending ? (
Expand Down
53 changes: 53 additions & 0 deletions apps/desktop/src/lib/aiDraftProposal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { parseEditorAIDraftProposal } from "./aiDraftProposal";

describe("parseEditorAIDraftProposal", () => {
it("parses recommendation, draft markdown, and notes from a well-formed response", () => {
const proposal = parseEditorAIDraftProposal(`
## Recommendation

Keep the architecture summary concise and make the contract boundary explicit.

## Draft Markdown

\`\`\`md
## Architecture

The desktop app initiates the publish flow.
\`\`\`

## Notes

Preserve the existing review terminology.
`);

expect(proposal).toEqual({
recommendation:
"Keep the architecture summary concise and make the contract boundary explicit.",
draftMarkdown: "## Architecture\n\nThe desktop app initiates the publish flow.",
notes: "Preserve the existing review terminology.",
});
});

it("returns null when the response does not include a draft markdown section", () => {
expect(
parseEditorAIDraftProposal(`
## Recommendation

Only explain the issue.
`),
).toBeNull();
});

it("returns null when the draft section is not fenced", () => {
expect(
parseEditorAIDraftProposal(`
## Draft Markdown

## Contracts

Document the persistence invariants.
`),
).toBeNull();
});
});
Loading
Loading