feat(app): 활성 창 캡쳐·캡쳐 목적지 선택·단축키 Ctrl+Alt 재배치 + UX 손질

- Ctrl+Alt+A/Z 맨 앞 창 통째 캡쳐(DWM 확장 프레임 경계), S/X 드래그 캡쳐. A/S 새 대화, Z/X 마지막 대화
- 캡쳐 후 창을 확실히 앞으로(AttachThreadInput), capture.image 에 목적지 path 실어 그 Composer 만 소비
- 드래그 선택영역 원본 밝기·더블버퍼(번쩍임) — code-assistant-v2 복사본에서 이식
- 단축키 Ctrl+Alt+Q/W: Eclipse·ADT·SAP GUI 바인딩 목록 대조해 비어 있는 조합으로
- 버튼 cursor:pointer(Tailwind v4), 가운데 스피너, 스니펫→챗 버튼, 스니펫 검색창 높이 84→88

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-17 16:34:48 +09:00
co-authored by Claude Fable 5.1
parent 2869985139
commit 47d31dbe06
27 changed files with 607 additions and 142 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ export default function App() {
return () => setBridgeNavigate(null)
}, [navigate])
// route 바뀔 때마다 호스트에 보고 — C#가 마지막 챗봇 위치 기억(Ctrl+Shift+8 복귀용).
// route 바뀔 때마다 호스트에 보고 — C#가 마지막 챗봇 위치 기억(Ctrl+Alt+W 복귀용).
useEffect(() => {
reportRoute(location.pathname)
}, [location.pathname])
@@ -30,35 +30,50 @@ describe("Composer 캡쳐 이미지 첨부", () => {
const wv = mockWebview()
initBridgeNavigate()
const { unmount } = render(<Composer onSend={() => {}} acceptCapture />)
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,abc" }))
const { unmount } = render(<Composer onSend={() => {}} capturePath="/snap/new" />)
act(() =>
wv.emit({
type: "capture.image",
path: "/snap/new",
dataUrl: "data:image/png;base64,abc",
})
)
expect(screen.getAllByRole("img")).toHaveLength(1)
unmount()
render(<Composer onSend={() => {}} acceptCapture />) // 다른 대화로 이동했다가 새 대화 재진입 시뮬레이션
render(<Composer onSend={() => {}} capturePath="/snap/new" />)
expect(screen.queryAllByRole("img")).toHaveLength(0)
})
// 코드리뷰 Important 회귀 재현: 기존 대화방(SessionChatPage, acceptCapture 없음)이 살아있는 채로
// capture.image 가 오면, 리마운트 전에 그 Composer가 pending을 훔쳐가 새 대화 Composer가 못 받음.
it("acceptCapture 없으면 capture 이벤트를 무시하고 pending을 안 건드림(출발지 도둑질 방지)", () => {
it("다른 대화용 캡쳐는 소비하지 않음", () => {
const wv = mockWebview()
initBridgeNavigate()
render(<Composer onSend={() => {}} />) // acceptCapture 없음 — 기존 대화방 시뮬레이션
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,xyz" }))
render(<Composer onSend={() => {}} capturePath="/snap/s/current" />)
act(() =>
wv.emit({
type: "capture.image",
path: "/snap/s/previous",
dataUrl: "data:image/png;base64,xyz",
})
)
expect(screen.queryAllByRole("img")).toHaveLength(0) // 여기엔 안 붙음
// pending이 안 비워졌어야 — 곧 마운트될 새 대화 Composer가 그대로 소비 가능해야 함
expect(consumePendingCaptureImage()).toBe("data:image/png;base64,xyz")
expect(screen.queryAllByRole("img")).toHaveLength(0)
expect(consumePendingCaptureImage("/snap/s/previous")).toBe("data:image/png;base64,xyz")
})
it("캡쳐 이미지만 있어도 이미지 계약으로 전송한다", () => {
const wv = mockWebview()
initBridgeNavigate()
const onSend = vi.fn()
render(<Composer onSend={onSend} acceptCapture />)
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,eA==" }))
render(<Composer onSend={onSend} capturePath="/snap/new" />)
act(() =>
wv.emit({
type: "capture.image",
path: "/snap/new",
dataUrl: "data:image/png;base64,eA==",
})
)
fireEvent.click(screen.getByRole("button", { name: "전송" }))
@@ -71,10 +86,16 @@ describe("Composer 캡쳐 이미지 첨부", () => {
it("이미지는 최대 4장까지만 첨부한다", () => {
const wv = mockWebview()
initBridgeNavigate()
render(<Composer onSend={() => {}} acceptCapture />)
render(<Composer onSend={() => {}} capturePath="/snap/new" />)
for (let i = 0; i < 5; i++) {
act(() => wv.emit({ type: "capture.image", dataUrl: `data:image/png;base64,eA${i}=` }))
act(() =>
wv.emit({
type: "capture.image",
path: "/snap/new",
dataUrl: `data:image/png;base64,eA${i}=`,
})
)
}
expect(screen.queryAllByRole("img")).toHaveLength(4)
@@ -12,10 +12,8 @@ interface Props {
busy?: boolean
onStop?: () => void
placeholder?: string
// 캡쳐 이미지 첨부를 받을지 — 새 대화(NewChatPage)만 true. 기존 대화방(SessionChatPage)이 켜져
// 있으면 navigate(/snap/new)+capture.image 순서에서 리마운트 전에 여기가 pending을 훔쳐가
// 정작 새 대화 Composer엔 이미지가 안 붙는 레이스가 생김 — 그래서 출발지는 아예 안 건드리게 게이팅.
acceptCapture?: boolean
// 이 Composer가 받을 캡처 대상 route. 대상이 다르면 pending을 남겨 목적지 리마운트가 소비함.
capturePath?: string
}
// Blob → data URL(base64). 클립보드 이미지를 chat 계약으로 바꿀 때 씀.
@@ -47,7 +45,7 @@ function imageFromDataUrl(data: string): SnapImageInput | null {
return { mediaType: match[1].toLowerCase() as SnapImageMediaType, data }
}
export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: Props) {
export function Composer({ onSend, busy, onStop, placeholder, capturePath }: Props) {
const [value, setValue] = useState("")
// 100자↑ 붙여넣기로 접어둔 텍스트들. 전송 시 입력값과 합쳐 보냄.
const [attachments, setAttachments] = useState<string[]>([])
@@ -88,22 +86,18 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P
return () => cancelAnimationFrame(raf)
}, [])
// 캡 이미지 수신 — 마운트 시 놓친 것 consume(네비 직후 이벤트를 놓쳐도 반영) + 이후는 이벤트로 누적(FR-008).
// acceptCapture 아니면 아예 pending을 안 건드림 — 기존 대화방(SessionChatPage)이 새 대화
// 마운트보다 먼저 훔쳐가 이미지가 유실되는 레이스 방지(출발지 게이팅).
// 캡 이미지 수신 — 목적지 route만 pending을 소비해 네비게이션 리마운트 race를 막음.
useEffect(() => {
if (!acceptCapture) return
const pending = consumePendingCaptureImage()
if (!capturePath) return
const pending = consumePendingCaptureImage(capturePath)
if (pending) addImage(pending)
const onCapture = () => {
// detail 대신 consume — 같은 동기 스택이라 값은 동일, 이걸로 pending도 같이 비워야
// 나중에 리마운트될 때(다른 대화→새 대화) stale 이미지가 재소비되지 않음.
const dataUrl = consumePendingCaptureImage()
const dataUrl = consumePendingCaptureImage(capturePath)
if (dataUrl) addImage(dataUrl)
}
window.addEventListener("bridge:captureImage", onCapture)
return () => window.removeEventListener("bridge:captureImage", onCapture)
}, [acceptCapture])
}, [capturePath])
const removeAttachment = (i: number) => setAttachments((a) => a.filter((_, j) => j !== i))
const removeImageAttachment = (i: number) =>
@@ -45,7 +45,7 @@ export function NavRail({ blocks, containerRef }: Props) {
<ClipboardHistory />
<div className="border-border flex flex-none items-center gap-1.5 border-b px-3 py-2.5">
<span className="size-1.5 rounded-full bg-success" />
<span className="bg-success size-1.5 rounded-full" />
<span className="text-muted-foreground font-mono text-[10px] tracking-widest uppercase">
Source Nav
</span>
@@ -79,7 +79,7 @@ export function NavRail({ blocks, containerRef }: Props) {
className="text-muted-foreground/50 hover:text-foreground flex-none rounded p-1.5 transition-colors"
>
{copiedIdx === b.index ? (
<Check className="size-3 text-success" />
<Check className="text-success size-3" />
) : (
<Copy className="size-3" />
)}
@@ -46,7 +46,7 @@ export default function NewChatPage() {
onSend={start}
busy={pending}
placeholder="질문을 입력하거나 코드·에러 로그를 붙여넣어봐…"
acceptCapture
capturePath={PATHS.SNAP_NEW}
/>
</div>
)
@@ -223,6 +223,7 @@ export default function SessionChatPage() {
busy={busy || generatingRemotely}
onStop={stop}
placeholder="ABAP · CDS · 에러 로그를 붙여넣거나 질문하세요…"
capturePath={`/snap/s/${id}`}
/>
</div>
</div>
@@ -1,3 +1,4 @@
import { MemoryRouter } from "react-router-dom"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import userEvent from "@testing-library/user-event"
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
@@ -67,7 +68,11 @@ afterEach(() => {
describe("새 스니펫 단축키", () => {
it("Ctrl+N으로 생성창을 열고 기존 Ctrl+2는 무시함", () => {
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.keyDown(window, { key: "2", ctrlKey: true })
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
@@ -79,7 +84,11 @@ describe("새 스니펫 단축키", () => {
it("생성 성공 뒤 입력창을 1초 유지한 다음 기본 검색 화면으로 돌아감", () => {
vi.useFakeTimers()
createSnippet.mockImplementation((_input, options) => options.onSuccess())
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const search = screen.getByRole("textbox")
fireEvent.change(search, { target: { value: "기존 검색" } })
fireEvent.keyDown(window, { key: "n", ctrlKey: true })
@@ -104,7 +113,11 @@ describe("스니펫 Enter 복사", () => {
})
)
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText)
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } })
fireEvent.click(screen.getByText("B"))
fireEvent.keyDown(window, { key: "Enter" })
@@ -122,7 +135,11 @@ describe("스니펫 Enter 복사", () => {
it("복사 실패 시 창과 사용 기록을 유지하고 오류 표시", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("denied"))
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText)
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } })
fireEvent.keyDown(window, { key: "ArrowDown" })
fireEvent.keyDown(window, { key: "Enter", isComposing: true })
@@ -136,7 +153,11 @@ describe("스니펫 Enter 복사", () => {
it("하단 복사는 클릭과 Enter 모두 원문을 복사하고 팔레트를 열린 채로 둠", async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } })
await user.click(screen.getByRole("button", { name: "B" }))
const copy = screen.getByRole("button", { name: "복사" })
@@ -154,7 +175,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
it("선택한 원문을 붙여넣고 일반 복사를 실행하지 않으며 키 반복을 무시함", async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
await user.type(screen.getByRole("textbox"), "B")
await user.click(screen.getByRole("button", { name: "B" }))
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter", ctrlKey: true })
@@ -169,7 +194,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
it("복사 버튼에 포커스가 있어도 Ctrl Enter는 붙여넣고 일반 Enter는 복사함", async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
await user.type(screen.getByRole("textbox"), "B")
await user.click(screen.getByRole("button", { name: "B" }))
screen.getByRole("button", { name: "복사" }).focus()
@@ -188,7 +217,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
it("미선택·조합 중·다른 modifier·편집 중에는 붙여넣지 않음", () => {
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const input = screen.getByRole("textbox")
fireEvent.change(input, { target: { value: "B" } })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true })
@@ -208,7 +241,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
it("브라우저의 Ctrl Enter는 복사나 붙여넣기로 바뀌지 않음", () => {
vi.mocked(isWebView).mockReturnValue(false)
const writeText = vi.spyOn(navigator.clipboard, "writeText")
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const input = screen.getByRole("textbox")
fireEvent.change(input, { target: { value: "B" } })
fireEvent.keyDown(input, { key: "ArrowDown" })
@@ -220,7 +257,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
describe("검색 후 선택할 때만 미리보기", () => {
it("빈 검색 → 결과 → 직접 선택 → 검색 변경과 지우기 순서로 화면이 접힘", () => {
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const input = screen.getByRole("textbox")
expect(screen.queryByText("A")).not.toBeInTheDocument()
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
@@ -244,7 +285,11 @@ describe("검색 후 선택할 때만 미리보기", () => {
it("검색 결과만 보일 때 Enter는 복사하지 않고 재소환은 검색창으로 돌아감", () => {
const writeText = vi.spyOn(navigator.clipboard, "writeText")
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } })
fireEvent.keyDown(window, { key: "Enter" })
expect(writeText).not.toHaveBeenCalled()
@@ -259,7 +304,11 @@ describe("검색 후 선택할 때만 미리보기", () => {
it("마우스로 결과를 선택한 뒤 Esc를 누르면 검색창에서 바로 다시 입력할 수 있음", async () => {
const user = userEvent.setup()
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const input = screen.getByRole("textbox")
await waitFor(() => expect(input).toHaveFocus())
await user.type(input, "A")
@@ -2,7 +2,10 @@ import { hostKind, send } from "@/lib/bridge/transport"
import { LogoMark } from "@/shared/components/LogoMark"
// 검색 → 결과 → 직접 선택한 코드 순서로 화면과 데스크톱 창을 펼침.
import { useEffect, useMemo, useRef, useState } from "react"
import { Check, Search } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { Check, MessageSquare, Search } from "lucide-react"
import { PATHS } from "@/config/routes"
import { Kbd } from "@/shared/components/Kbd"
import { hideWindow, isWebView, pasteToApp, startWindowDrag } from "@/lib/bridge/webviewBridge"
import { toast } from "sonner"
import { useEscapeKey } from "@/features/snap/hooks/useEscapeKey"
@@ -25,6 +28,7 @@ import type { Snippet, SnippetInput } from "../types"
const ALL_CATEGORY = "전체"
export default function SnippetPalettePage() {
const navigate = useNavigate()
const [q, setQ] = useState("")
const { data: snippets = [], isLoading, error } = useSnippets()
const recordUse = useRecordUse()
@@ -307,6 +311,14 @@ export default function SnippetPalettePage() {
placeholder="스니펫 검색…"
className="placeholder:text-muted-foreground w-full bg-transparent text-base outline-none"
/>
{/* 챗으로 전환 — 같은 창에서 route 만 바뀜(Ctrl+Alt+W 와 동일) */}
<button
type="button"
onClick={() => navigate(PATHS.SNAP)}
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring inline-flex h-7 flex-none items-center gap-1 rounded-md px-2 text-[11.5px] whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<MessageSquare className="size-3.5" aria-hidden="true" /><Kbd>Ctrl+Alt+W</Kbd>
</button>
</div>
{/* category 칩 바 — 2개 이상(전체 포함)일 때만 보여줌 */}
@@ -29,15 +29,20 @@ describe("bridgeNavigate", () => {
await emit("bridge", { type: "navigate", path: "/snippet" })
await emit("bridge", { type: "paste.target", name: "메모장", app: "notepad" })
await emit("bridge", { type: "capture.image", dataUrl: "data:image/png;base64,abc" })
await emit("bridge", {
type: "capture.image",
path: "/snap/new",
dataUrl: "data:image/png;base64,abc",
})
expect(navigate).toHaveBeenCalledWith("/snippet")
expect(onNavigate).toHaveBeenCalledTimes(1)
expect(getLastPasteTarget()).toEqual({ name: "메모장", app: "notepad" })
expect(onPasteTarget).toHaveBeenCalledTimes(1)
expect(onCapture).toHaveBeenCalledTimes(1)
expect(consumePendingCaptureImage()).toBe("data:image/png;base64,abc")
expect(consumePendingCaptureImage()).toBe("")
expect(consumePendingCaptureImage("/snap/s/old")).toBe("")
expect(consumePendingCaptureImage("/snap/new")).toBe("data:image/png;base64,abc")
expect(consumePendingCaptureImage("/snap/new")).toBe("")
window.removeEventListener("bridge:navigate", onNavigate)
window.removeEventListener("bridge:pasteTarget", onPasteTarget)
+9 -10
View File
@@ -9,7 +9,7 @@ export interface PasteTarget {
let navigateFn: NavigateFn | null = null
let lastPasteTarget: PasteTarget = { name: "", app: "" }
let pendingCaptureImage = ""
let pendingCaptureImage: { path: string; dataUrl: string } | null = null
/** 앱 루트에서 useNavigate() 감싼 콜백을 등록함. */
export function setBridgeNavigate(fn: NavigateFn | null) {
@@ -21,10 +21,11 @@ export function getLastPasteTarget(): PasteTarget {
return lastPasteTarget
}
/** 마지막 캡처 이미지를 한 번만 소비함. */
export function consumePendingCaptureImage(): string {
const dataUrl = pendingCaptureImage
pendingCaptureImage = ""
/** 지정한 대화에 도착한 마지막 캡처 이미지를 한 번만 소비함. */
export function consumePendingCaptureImage(path: string): string {
if (pendingCaptureImage?.path !== path) return ""
const { dataUrl } = pendingCaptureImage
pendingCaptureImage = null
return dataUrl
}
@@ -41,11 +42,9 @@ function handleBridgeMessage(data: {
return
}
if (data.type === "capture.image") {
pendingCaptureImage = data.dataUrl ?? ""
window.dispatchEvent(
new CustomEvent("bridge:captureImage", { detail: { dataUrl: pendingCaptureImage } })
)
if (data.type === "capture.image" && data.path) {
pendingCaptureImage = { path: data.path, dataUrl: data.dataUrl ?? "" }
window.dispatchEvent(new CustomEvent("bridge:captureImage", { detail: pendingCaptureImage }))
return
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense } from "react"
import { CenterSpinner } from "@/shared/components/CenterSpinner"
import { Navigate, type RouteObject } from "react-router-dom"
import Layout from "@/shared/components/Layout"
import ProtectedRoute from "@/shared/components/ProtectedRoute"
@@ -12,7 +13,7 @@ const NewChatPage = lazy(() => import("@/features/snap/pages/NewChatPage"))
const SessionChatPage = lazy(() => import("@/features/snap/pages/SessionChatPage"))
const SnippetPalettePage = lazy(() => import("@/features/snippets/pages/SnippetPalettePage"))
const fallback = <div className="text-muted-foreground">...</div>
const fallback = <CenterSpinner />
const wrap = (el: React.ReactNode) => <Suspense fallback={fallback}>{el}</Suspense>
@@ -0,0 +1,10 @@
import { Loader2 } from "lucide-react"
/** 화면 정가운데 도는 스피너 — 페이지 lazy 로딩·로그인 확인 같은 전체 화면 대기용. */
export function CenterSpinner({ label = "로딩 중" }: { label?: string }) {
return (
<div role="status" aria-label={label} className="flex h-dvh w-full items-center justify-center">
<Loader2 className="text-muted-foreground size-6 animate-spin" />
</div>
)
}
@@ -3,6 +3,7 @@ import { Navigate, Outlet, useLocation } from "react-router-dom"
import { useMe } from "@/features/auth/hooks/useMe"
import { useAuthStore } from "@/features/auth/store/authStore"
import { PATHS } from "@/config/routes"
import { CenterSpinner } from "@/shared/components/CenterSpinner"
/**
* 저장된 user를 서버에서 확인한 뒤 보호 화면을 연다.
@@ -25,7 +26,7 @@ export default function ProtectedRoute() {
return <Navigate to={`${PATHS.LOGIN}${query}`} replace />
}
if (me.isLoading) {
return <div className="text-muted-foreground p-6"> ...</div>
return <CenterSpinner label="로그인 확인 중" />
}
return <Outlet />
}
+9
View File
@@ -146,6 +146,15 @@
"rlig" 1,
"calt" 1;
}
/* Tailwind v4 가 버튼 기본 cursor 를 default 로 바꿔서 — 클릭되는 건 전부 손가락 모양 */
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]),
a[href],
label[for],
select:not(:disabled),
summary {
cursor: pointer;
}
/* 스크롤바·선택·캐럿 — ABAP_OPENCODE 와 같은 얇은 오버레이 스타일 */
* {
scrollbar-width: thin;