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
+4
View File
@@ -0,0 +1,4 @@
# 빌드 산출·환경별 값
2_frontend/tsconfig.tsbuildinfo
2_frontend/.env.production
+1 -1
View File
@@ -18,7 +18,7 @@ export default function App() {
return () => setBridgeNavigate(null) return () => setBridgeNavigate(null)
}, [navigate]) }, [navigate])
// route 바뀔 때마다 호스트에 보고 — C#가 마지막 챗봇 위치 기억(Ctrl+Shift+8 복귀용). // route 바뀔 때마다 호스트에 보고 — C#가 마지막 챗봇 위치 기억(Ctrl+Alt+W 복귀용).
useEffect(() => { useEffect(() => {
reportRoute(location.pathname) reportRoute(location.pathname)
}, [location.pathname]) }, [location.pathname])
@@ -30,35 +30,50 @@ describe("Composer 캡쳐 이미지 첨부", () => {
const wv = mockWebview() const wv = mockWebview()
initBridgeNavigate() initBridgeNavigate()
const { unmount } = render(<Composer onSend={() => {}} acceptCapture />) const { unmount } = render(<Composer onSend={() => {}} capturePath="/snap/new" />)
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,abc" })) act(() =>
wv.emit({
type: "capture.image",
path: "/snap/new",
dataUrl: "data:image/png;base64,abc",
})
)
expect(screen.getAllByRole("img")).toHaveLength(1) expect(screen.getAllByRole("img")).toHaveLength(1)
unmount() unmount()
render(<Composer onSend={() => {}} acceptCapture />) // 다른 대화로 이동했다가 새 대화 재진입 시뮬레이션 render(<Composer onSend={() => {}} capturePath="/snap/new" />)
expect(screen.queryAllByRole("img")).toHaveLength(0) expect(screen.queryAllByRole("img")).toHaveLength(0)
}) })
// 코드리뷰 Important 회귀 재현: 기존 대화방(SessionChatPage, acceptCapture 없음)이 살아있는 채로 it("다른 대화용 캡쳐는 소비하지 않음", () => {
// capture.image 가 오면, 리마운트 전에 그 Composer가 pending을 훔쳐가 새 대화 Composer가 못 받음.
it("acceptCapture 없으면 capture 이벤트를 무시하고 pending을 안 건드림(출발지 도둑질 방지)", () => {
const wv = mockWebview() const wv = mockWebview()
initBridgeNavigate() initBridgeNavigate()
render(<Composer onSend={() => {}} />) // acceptCapture 없음 — 기존 대화방 시뮬레이션 render(<Composer onSend={() => {}} capturePath="/snap/s/current" />)
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,xyz" })) act(() =>
wv.emit({
type: "capture.image",
path: "/snap/s/previous",
dataUrl: "data:image/png;base64,xyz",
})
)
expect(screen.queryAllByRole("img")).toHaveLength(0) // 여기엔 안 붙음 expect(screen.queryAllByRole("img")).toHaveLength(0)
// pending이 안 비워졌어야 — 곧 마운트될 새 대화 Composer가 그대로 소비 가능해야 함 expect(consumePendingCaptureImage("/snap/s/previous")).toBe("data:image/png;base64,xyz")
expect(consumePendingCaptureImage()).toBe("data:image/png;base64,xyz")
}) })
it("캡쳐 이미지만 있어도 이미지 계약으로 전송한다", () => { it("캡쳐 이미지만 있어도 이미지 계약으로 전송한다", () => {
const wv = mockWebview() const wv = mockWebview()
initBridgeNavigate() initBridgeNavigate()
const onSend = vi.fn() const onSend = vi.fn()
render(<Composer onSend={onSend} acceptCapture />) render(<Composer onSend={onSend} capturePath="/snap/new" />)
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,eA==" })) act(() =>
wv.emit({
type: "capture.image",
path: "/snap/new",
dataUrl: "data:image/png;base64,eA==",
})
)
fireEvent.click(screen.getByRole("button", { name: "전송" })) fireEvent.click(screen.getByRole("button", { name: "전송" }))
@@ -71,10 +86,16 @@ describe("Composer 캡쳐 이미지 첨부", () => {
it("이미지는 최대 4장까지만 첨부한다", () => { it("이미지는 최대 4장까지만 첨부한다", () => {
const wv = mockWebview() const wv = mockWebview()
initBridgeNavigate() initBridgeNavigate()
render(<Composer onSend={() => {}} acceptCapture />) render(<Composer onSend={() => {}} capturePath="/snap/new" />)
for (let i = 0; i < 5; i++) { 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) expect(screen.queryAllByRole("img")).toHaveLength(4)
@@ -12,10 +12,8 @@ interface Props {
busy?: boolean busy?: boolean
onStop?: () => void onStop?: () => void
placeholder?: string placeholder?: string
// 캡쳐 이미지 첨부를 받을지 — 새 대화(NewChatPage)만 true. 기존 대화방(SessionChatPage)이 켜져 // 이 Composer가 받을 캡처 대상 route. 대상이 다르면 pending을 남겨 목적지 리마운트가 소비함.
// 있으면 navigate(/snap/new)+capture.image 순서에서 리마운트 전에 여기가 pending을 훔쳐가 capturePath?: string
// 정작 새 대화 Composer엔 이미지가 안 붙는 레이스가 생김 — 그래서 출발지는 아예 안 건드리게 게이팅.
acceptCapture?: boolean
} }
// Blob → data URL(base64). 클립보드 이미지를 chat 계약으로 바꿀 때 씀. // Blob → data URL(base64). 클립보드 이미지를 chat 계약으로 바꿀 때 씀.
@@ -47,7 +45,7 @@ function imageFromDataUrl(data: string): SnapImageInput | null {
return { mediaType: match[1].toLowerCase() as SnapImageMediaType, data } 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("") const [value, setValue] = useState("")
// 100자↑ 붙여넣기로 접어둔 텍스트들. 전송 시 입력값과 합쳐 보냄. // 100자↑ 붙여넣기로 접어둔 텍스트들. 전송 시 입력값과 합쳐 보냄.
const [attachments, setAttachments] = useState<string[]>([]) const [attachments, setAttachments] = useState<string[]>([])
@@ -88,22 +86,18 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P
return () => cancelAnimationFrame(raf) return () => cancelAnimationFrame(raf)
}, []) }, [])
// 캡 이미지 수신 — 마운트 시 놓친 것 consume(네비 직후 이벤트를 놓쳐도 반영) + 이후는 이벤트로 누적(FR-008). // 캡 이미지 수신 — 목적지 route만 pending을 소비해 네비게이션 리마운트 race를 막음.
// acceptCapture 아니면 아예 pending을 안 건드림 — 기존 대화방(SessionChatPage)이 새 대화
// 마운트보다 먼저 훔쳐가 이미지가 유실되는 레이스 방지(출발지 게이팅).
useEffect(() => { useEffect(() => {
if (!acceptCapture) return if (!capturePath) return
const pending = consumePendingCaptureImage() const pending = consumePendingCaptureImage(capturePath)
if (pending) addImage(pending) if (pending) addImage(pending)
const onCapture = () => { const onCapture = () => {
// detail 대신 consume — 같은 동기 스택이라 값은 동일, 이걸로 pending도 같이 비워야 const dataUrl = consumePendingCaptureImage(capturePath)
// 나중에 리마운트될 때(다른 대화→새 대화) stale 이미지가 재소비되지 않음.
const dataUrl = consumePendingCaptureImage()
if (dataUrl) addImage(dataUrl) if (dataUrl) addImage(dataUrl)
} }
window.addEventListener("bridge:captureImage", onCapture) window.addEventListener("bridge:captureImage", onCapture)
return () => window.removeEventListener("bridge:captureImage", onCapture) return () => window.removeEventListener("bridge:captureImage", onCapture)
}, [acceptCapture]) }, [capturePath])
const removeAttachment = (i: number) => setAttachments((a) => a.filter((_, j) => j !== i)) const removeAttachment = (i: number) => setAttachments((a) => a.filter((_, j) => j !== i))
const removeImageAttachment = (i: number) => const removeImageAttachment = (i: number) =>
@@ -45,7 +45,7 @@ export function NavRail({ blocks, containerRef }: Props) {
<ClipboardHistory /> <ClipboardHistory />
<div className="border-border flex flex-none items-center gap-1.5 border-b px-3 py-2.5"> <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"> <span className="text-muted-foreground font-mono text-[10px] tracking-widest uppercase">
Source Nav Source Nav
</span> </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" className="text-muted-foreground/50 hover:text-foreground flex-none rounded p-1.5 transition-colors"
> >
{copiedIdx === b.index ? ( {copiedIdx === b.index ? (
<Check className="size-3 text-success" /> <Check className="text-success size-3" />
) : ( ) : (
<Copy className="size-3" /> <Copy className="size-3" />
)} )}
@@ -46,7 +46,7 @@ export default function NewChatPage() {
onSend={start} onSend={start}
busy={pending} busy={pending}
placeholder="질문을 입력하거나 코드·에러 로그를 붙여넣어봐…" placeholder="질문을 입력하거나 코드·에러 로그를 붙여넣어봐…"
acceptCapture capturePath={PATHS.SNAP_NEW}
/> />
</div> </div>
) )
@@ -223,6 +223,7 @@ export default function SessionChatPage() {
busy={busy || generatingRemotely} busy={busy || generatingRemotely}
onStop={stop} onStop={stop}
placeholder="ABAP · CDS · 에러 로그를 붙여넣거나 질문하세요…" placeholder="ABAP · CDS · 에러 로그를 붙여넣거나 질문하세요…"
capturePath={`/snap/s/${id}`}
/> />
</div> </div>
</div> </div>
@@ -1,3 +1,4 @@
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 userEvent from "@testing-library/user-event" import userEvent from "@testing-library/user-event"
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react" import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
@@ -67,7 +68,11 @@ afterEach(() => {
describe("새 스니펫 단축키", () => { describe("새 스니펫 단축키", () => {
it("Ctrl+N으로 생성창을 열고 기존 Ctrl+2는 무시함", () => { it("Ctrl+N으로 생성창을 열고 기존 Ctrl+2는 무시함", () => {
render(<SnippetPalettePage />) render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.keyDown(window, { key: "2", ctrlKey: true }) fireEvent.keyDown(window, { key: "2", ctrlKey: true })
expect(screen.queryByRole("dialog")).not.toBeInTheDocument() expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
@@ -79,7 +84,11 @@ describe("새 스니펫 단축키", () => {
it("생성 성공 뒤 입력창을 1초 유지한 다음 기본 검색 화면으로 돌아감", () => { it("생성 성공 뒤 입력창을 1초 유지한 다음 기본 검색 화면으로 돌아감", () => {
vi.useFakeTimers() vi.useFakeTimers()
createSnippet.mockImplementation((_input, options) => options.onSuccess()) createSnippet.mockImplementation((_input, options) => options.onSuccess())
render(<SnippetPalettePage />) render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const search = screen.getByRole("textbox") const search = screen.getByRole("textbox")
fireEvent.change(search, { target: { value: "기존 검색" } }) fireEvent.change(search, { target: { value: "기존 검색" } })
fireEvent.keyDown(window, { key: "n", ctrlKey: true }) fireEvent.keyDown(window, { key: "n", ctrlKey: true })
@@ -104,7 +113,11 @@ describe("스니펫 Enter 복사", () => {
}) })
) )
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText) vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText)
render(<SnippetPalettePage />) render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } }) fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } })
fireEvent.click(screen.getByText("B")) fireEvent.click(screen.getByText("B"))
fireEvent.keyDown(window, { key: "Enter" }) fireEvent.keyDown(window, { key: "Enter" })
@@ -122,7 +135,11 @@ describe("스니펫 Enter 복사", () => {
it("복사 실패 시 창과 사용 기록을 유지하고 오류 표시", async () => { it("복사 실패 시 창과 사용 기록을 유지하고 오류 표시", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("denied")) const writeText = vi.fn().mockRejectedValue(new Error("denied"))
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText) vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText)
render(<SnippetPalettePage />) render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } }) fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } })
fireEvent.keyDown(window, { key: "ArrowDown" }) fireEvent.keyDown(window, { key: "ArrowDown" })
fireEvent.keyDown(window, { key: "Enter", isComposing: true }) fireEvent.keyDown(window, { key: "Enter", isComposing: true })
@@ -136,7 +153,11 @@ describe("스니펫 Enter 복사", () => {
it("하단 복사는 클릭과 Enter 모두 원문을 복사하고 팔레트를 열린 채로 둠", async () => { it("하단 복사는 클릭과 Enter 모두 원문을 복사하고 팔레트를 열린 채로 둠", async () => {
const user = userEvent.setup() const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue() const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />) render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } }) fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } })
await user.click(screen.getByRole("button", { name: "B" })) await user.click(screen.getByRole("button", { name: "B" }))
const copy = screen.getByRole("button", { name: "복사" }) const copy = screen.getByRole("button", { name: "복사" })
@@ -154,7 +175,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
it("선택한 원문을 붙여넣고 일반 복사를 실행하지 않으며 키 반복을 무시함", async () => { it("선택한 원문을 붙여넣고 일반 복사를 실행하지 않으며 키 반복을 무시함", async () => {
const user = userEvent.setup() const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue() const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />) render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
await user.type(screen.getByRole("textbox"), "B") await user.type(screen.getByRole("textbox"), "B")
await user.click(screen.getByRole("button", { name: "B" })) await user.click(screen.getByRole("button", { name: "B" }))
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter", ctrlKey: true }) fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter", ctrlKey: true })
@@ -169,7 +194,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
it("복사 버튼에 포커스가 있어도 Ctrl Enter는 붙여넣고 일반 Enter는 복사함", async () => { it("복사 버튼에 포커스가 있어도 Ctrl Enter는 붙여넣고 일반 Enter는 복사함", async () => {
const user = userEvent.setup() const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue() const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />) render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
await user.type(screen.getByRole("textbox"), "B") await user.type(screen.getByRole("textbox"), "B")
await user.click(screen.getByRole("button", { name: "B" })) await user.click(screen.getByRole("button", { name: "B" }))
screen.getByRole("button", { name: "복사" }).focus() screen.getByRole("button", { name: "복사" }).focus()
@@ -188,7 +217,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
it("미선택·조합 중·다른 modifier·편집 중에는 붙여넣지 않음", () => { it("미선택·조합 중·다른 modifier·편집 중에는 붙여넣지 않음", () => {
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue() const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />) render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const input = screen.getByRole("textbox") const input = screen.getByRole("textbox")
fireEvent.change(input, { target: { value: "B" } }) fireEvent.change(input, { target: { value: "B" } })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true }) fireEvent.keyDown(input, { key: "Enter", ctrlKey: true })
@@ -208,7 +241,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
it("브라우저의 Ctrl Enter는 복사나 붙여넣기로 바뀌지 않음", () => { it("브라우저의 Ctrl Enter는 복사나 붙여넣기로 바뀌지 않음", () => {
vi.mocked(isWebView).mockReturnValue(false) vi.mocked(isWebView).mockReturnValue(false)
const writeText = vi.spyOn(navigator.clipboard, "writeText") const writeText = vi.spyOn(navigator.clipboard, "writeText")
render(<SnippetPalettePage />) render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const input = screen.getByRole("textbox") const input = screen.getByRole("textbox")
fireEvent.change(input, { target: { value: "B" } }) fireEvent.change(input, { target: { value: "B" } })
fireEvent.keyDown(input, { key: "ArrowDown" }) fireEvent.keyDown(input, { key: "ArrowDown" })
@@ -220,7 +257,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
describe("검색 후 선택할 때만 미리보기", () => { describe("검색 후 선택할 때만 미리보기", () => {
it("빈 검색 → 결과 → 직접 선택 → 검색 변경과 지우기 순서로 화면이 접힘", () => { it("빈 검색 → 결과 → 직접 선택 → 검색 변경과 지우기 순서로 화면이 접힘", () => {
render(<SnippetPalettePage />) render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const input = screen.getByRole("textbox") const input = screen.getByRole("textbox")
expect(screen.queryByText("A")).not.toBeInTheDocument() expect(screen.queryByText("A")).not.toBeInTheDocument()
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument() expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
@@ -244,7 +285,11 @@ describe("검색 후 선택할 때만 미리보기", () => {
it("검색 결과만 보일 때 Enter는 복사하지 않고 재소환은 검색창으로 돌아감", () => { it("검색 결과만 보일 때 Enter는 복사하지 않고 재소환은 검색창으로 돌아감", () => {
const writeText = vi.spyOn(navigator.clipboard, "writeText") const writeText = vi.spyOn(navigator.clipboard, "writeText")
render(<SnippetPalettePage />) render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } }) fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } })
fireEvent.keyDown(window, { key: "Enter" }) fireEvent.keyDown(window, { key: "Enter" })
expect(writeText).not.toHaveBeenCalled() expect(writeText).not.toHaveBeenCalled()
@@ -259,7 +304,11 @@ describe("검색 후 선택할 때만 미리보기", () => {
it("마우스로 결과를 선택한 뒤 Esc를 누르면 검색창에서 바로 다시 입력할 수 있음", async () => { it("마우스로 결과를 선택한 뒤 Esc를 누르면 검색창에서 바로 다시 입력할 수 있음", async () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<SnippetPalettePage />) render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const input = screen.getByRole("textbox") const input = screen.getByRole("textbox")
await waitFor(() => expect(input).toHaveFocus()) await waitFor(() => expect(input).toHaveFocus())
await user.type(input, "A") await user.type(input, "A")
@@ -2,7 +2,10 @@ import { hostKind, send } from "@/lib/bridge/transport"
import { LogoMark } from "@/shared/components/LogoMark" import { LogoMark } from "@/shared/components/LogoMark"
// 검색 → 결과 → 직접 선택한 코드 순서로 화면과 데스크톱 창을 펼침. // 검색 → 결과 → 직접 선택한 코드 순서로 화면과 데스크톱 창을 펼침.
import { useEffect, useMemo, useRef, useState } from "react" 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 { hideWindow, isWebView, pasteToApp, startWindowDrag } from "@/lib/bridge/webviewBridge"
import { toast } from "sonner" import { toast } from "sonner"
import { useEscapeKey } from "@/features/snap/hooks/useEscapeKey" import { useEscapeKey } from "@/features/snap/hooks/useEscapeKey"
@@ -25,6 +28,7 @@ import type { Snippet, SnippetInput } from "../types"
const ALL_CATEGORY = "전체" const ALL_CATEGORY = "전체"
export default function SnippetPalettePage() { export default function SnippetPalettePage() {
const navigate = useNavigate()
const [q, setQ] = useState("") const [q, setQ] = useState("")
const { data: snippets = [], isLoading, error } = useSnippets() const { data: snippets = [], isLoading, error } = useSnippets()
const recordUse = useRecordUse() const recordUse = useRecordUse()
@@ -307,6 +311,14 @@ export default function SnippetPalettePage() {
placeholder="스니펫 검색…" placeholder="스니펫 검색…"
className="placeholder:text-muted-foreground w-full bg-transparent text-base outline-none" 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> </div>
{/* category 칩 바 — 2개 이상(전체 포함)일 때만 보여줌 */} {/* category 칩 바 — 2개 이상(전체 포함)일 때만 보여줌 */}
@@ -29,15 +29,20 @@ describe("bridgeNavigate", () => {
await emit("bridge", { type: "navigate", path: "/snippet" }) await emit("bridge", { type: "navigate", path: "/snippet" })
await emit("bridge", { type: "paste.target", name: "메모장", app: "notepad" }) 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(navigate).toHaveBeenCalledWith("/snippet")
expect(onNavigate).toHaveBeenCalledTimes(1) expect(onNavigate).toHaveBeenCalledTimes(1)
expect(getLastPasteTarget()).toEqual({ name: "메모장", app: "notepad" }) expect(getLastPasteTarget()).toEqual({ name: "메모장", app: "notepad" })
expect(onPasteTarget).toHaveBeenCalledTimes(1) expect(onPasteTarget).toHaveBeenCalledTimes(1)
expect(onCapture).toHaveBeenCalledTimes(1) expect(onCapture).toHaveBeenCalledTimes(1)
expect(consumePendingCaptureImage()).toBe("data:image/png;base64,abc") expect(consumePendingCaptureImage("/snap/s/old")).toBe("")
expect(consumePendingCaptureImage()).toBe("") expect(consumePendingCaptureImage("/snap/new")).toBe("data:image/png;base64,abc")
expect(consumePendingCaptureImage("/snap/new")).toBe("")
window.removeEventListener("bridge:navigate", onNavigate) window.removeEventListener("bridge:navigate", onNavigate)
window.removeEventListener("bridge:pasteTarget", onPasteTarget) window.removeEventListener("bridge:pasteTarget", onPasteTarget)
+9 -10
View File
@@ -9,7 +9,7 @@ export interface PasteTarget {
let navigateFn: NavigateFn | null = null let navigateFn: NavigateFn | null = null
let lastPasteTarget: PasteTarget = { name: "", app: "" } let lastPasteTarget: PasteTarget = { name: "", app: "" }
let pendingCaptureImage = "" let pendingCaptureImage: { path: string; dataUrl: string } | null = null
/** 앱 루트에서 useNavigate() 감싼 콜백을 등록함. */ /** 앱 루트에서 useNavigate() 감싼 콜백을 등록함. */
export function setBridgeNavigate(fn: NavigateFn | null) { export function setBridgeNavigate(fn: NavigateFn | null) {
@@ -21,10 +21,11 @@ export function getLastPasteTarget(): PasteTarget {
return lastPasteTarget return lastPasteTarget
} }
/** 마지막 캡처 이미지를 한 번만 소비함. */ /** 지정한 대화에 도착한 마지막 캡처 이미지를 한 번만 소비함. */
export function consumePendingCaptureImage(): string { export function consumePendingCaptureImage(path: string): string {
const dataUrl = pendingCaptureImage if (pendingCaptureImage?.path !== path) return ""
pendingCaptureImage = "" const { dataUrl } = pendingCaptureImage
pendingCaptureImage = null
return dataUrl return dataUrl
} }
@@ -41,11 +42,9 @@ function handleBridgeMessage(data: {
return return
} }
if (data.type === "capture.image") { if (data.type === "capture.image" && data.path) {
pendingCaptureImage = data.dataUrl ?? "" pendingCaptureImage = { path: data.path, dataUrl: data.dataUrl ?? "" }
window.dispatchEvent( window.dispatchEvent(new CustomEvent("bridge:captureImage", { detail: pendingCaptureImage }))
new CustomEvent("bridge:captureImage", { detail: { dataUrl: pendingCaptureImage } })
)
return return
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense } from "react" import { lazy, Suspense } from "react"
import { CenterSpinner } from "@/shared/components/CenterSpinner"
import { Navigate, type RouteObject } from "react-router-dom" import { Navigate, type RouteObject } from "react-router-dom"
import Layout from "@/shared/components/Layout" import Layout from "@/shared/components/Layout"
import ProtectedRoute from "@/shared/components/ProtectedRoute" 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 SessionChatPage = lazy(() => import("@/features/snap/pages/SessionChatPage"))
const SnippetPalettePage = lazy(() => import("@/features/snippets/pages/SnippetPalettePage")) 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> 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 { useMe } from "@/features/auth/hooks/useMe"
import { useAuthStore } from "@/features/auth/store/authStore" import { useAuthStore } from "@/features/auth/store/authStore"
import { PATHS } from "@/config/routes" import { PATHS } from "@/config/routes"
import { CenterSpinner } from "@/shared/components/CenterSpinner"
/** /**
* 저장된 user를 서버에서 확인한 뒤 보호 화면을 연다. * 저장된 user를 서버에서 확인한 뒤 보호 화면을 연다.
@@ -25,7 +26,7 @@ export default function ProtectedRoute() {
return <Navigate to={`${PATHS.LOGIN}${query}`} replace /> return <Navigate to={`${PATHS.LOGIN}${query}`} replace />
} }
if (me.isLoading) { if (me.isLoading) {
return <div className="text-muted-foreground p-6"> ...</div> return <CenterSpinner label="로그인 확인 중" />
} }
return <Outlet /> return <Outlet />
} }
+9
View File
@@ -146,6 +146,15 @@
"rlig" 1, "rlig" 1,
"calt" 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 와 같은 얇은 오버레이 스타일 */ /* 스크롤바·선택·캐럿 — ABAP_OPENCODE 와 같은 얇은 오버레이 스타일 */
* { * {
scrollbar-width: thin; scrollbar-width: thin;
+1
View File
@@ -29,6 +29,7 @@ png = "0.17"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { version = "0.61.3", features = [ windows = { version = "0.61.3", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi", "Win32_Graphics_Gdi",
"Win32_System_LibraryLoader", "Win32_System_LibraryLoader",
"Win32_System_DataExchange", "Win32_System_DataExchange",
@@ -18,6 +18,8 @@ use crate::shell::{paste, paste::PasteState, window};
pub struct Routes { pub struct Routes {
pub current: String, pub current: String,
pub last_snap: String, pub last_snap: String,
/// 마지막으로 열었던 기존 대화(`/snap/s/<id>`). 캡쳐를 기존 대화에 붙일 때 씀.
pub last_session: Option<String>,
} }
impl Default for Routes { impl Default for Routes {
@@ -26,6 +28,7 @@ impl Default for Routes {
Self { Self {
current: "/snap".into(), current: "/snap".into(),
last_snap: "/snap".into(), last_snap: "/snap".into(),
last_session: None,
} }
} }
} }
@@ -35,6 +38,9 @@ impl Routes {
if path == "/snap" || path.starts_with("/snap/") { if path == "/snap" || path.starts_with("/snap/") {
path.clone_into(&mut self.last_snap); path.clone_into(&mut self.last_snap);
} }
if path.starts_with("/snap/s/") {
self.last_session = Some(path.to_string());
}
path.clone_into(&mut self.current); path.clone_into(&mut self.current);
} }
} }
@@ -67,7 +73,8 @@ pub enum SnippetStage {
#[tauri::command] #[tauri::command]
pub fn window_snippet_layout(app: AppHandle, stage: SnippetStage) -> Result<(), String> { pub fn window_snippet_layout(app: AppHandle, stage: SnippetStage) -> Result<(), String> {
let (width, height) = match stage { let (width, height) = match stage {
SnippetStage::Search => (640.0, 84.0), // 제목줄 h-8(32) + 검색줄 h-14(56) = 88. 이보다 작으면 프레임 overflow-auto 가 스크롤바를 띄움.
SnippetStage::Search => (640.0, 88.0),
SnippetStage::Results => (640.0, 440.0), SnippetStage::Results => (640.0, 440.0),
SnippetStage::Preview => (840.0, 520.0), SnippetStage::Preview => (840.0, 520.0),
SnippetStage::Editor => (960.0, 600.0), SnippetStage::Editor => (960.0, 600.0),
+2 -1
View File
@@ -30,9 +30,10 @@ pub enum Push {
#[serde(rename = "paste.target")] #[serde(rename = "paste.target")]
PasteTarget { name: String, app: String }, PasteTarget { name: String, app: String },
/// 영역 캡쳐 PNG를 프론트의 새 대화 Composer에 붙임. /// 영역 캡쳐 PNG를 지정한 대화 Composer에 붙임.
#[serde(rename = "capture.image")] #[serde(rename = "capture.image")]
CaptureImage { CaptureImage {
path: String,
#[serde(rename = "dataUrl")] #[serde(rename = "dataUrl")]
data_url: String, data_url: String,
}, },
+80 -19
View File
@@ -18,15 +18,29 @@ use shell::window;
const HOTKEYS: &[HotkeySpec] = &[ const HOTKEYS: &[HotkeySpec] = &[
HotkeySpec { HotkeySpec {
id: "snap", id: "snap",
accelerator: "Ctrl+Shift+8", accelerator: "Ctrl+Alt+W",
}, },
HotkeySpec { HotkeySpec {
id: "snippet", id: "snippet",
accelerator: "Ctrl+Shift+7", accelerator: "Ctrl+Alt+Q",
}, },
// 캡쳐 4종: 윗줄(A/S) = 새 대화, 아랫줄(Z/X) = 마지막 기존 대화(없으면 새 대화).
// 왼쪽(A/Z) = 맨 앞 창 통째, 오른쪽(S/X) = 드래그 영역.
HotkeySpec { HotkeySpec {
id: "capture", id: "capture",
accelerator: "Ctrl+Shift+9", accelerator: "Ctrl+Alt+A",
},
HotkeySpec {
id: "capture_area",
accelerator: "Ctrl+Alt+S",
},
HotkeySpec {
id: "capture_last",
accelerator: "Ctrl+Alt+Z",
},
HotkeySpec {
id: "capture_area_last",
accelerator: "Ctrl+Alt+X",
}, },
]; ];
@@ -149,6 +163,35 @@ fn show_palette(app: &AppHandle) -> tauri::Result<()> {
Ok(()) Ok(())
} }
/// 캡쳐 핫키 id 로 목적지 route 결정. `*_last` 는 마지막 기존 대화, 없으면 새 대화.
fn capture_target(id: &str, last_session: Option<&str>) -> String {
if id.ends_with("_last") {
last_session.unwrap_or("/snap/new").to_string()
} else {
"/snap/new".to_string()
}
}
/// 캡쳐 결과 공통 처리 — 팔레트 소환 → 목적지 대화 화면 → 그 화면 Composer 에 이미지 첨부.
fn on_captured(app: AppHandle, target: String, data_url: String) {
if let Err(error) = show_palette(&app) {
eprintln!("[capture] 창 소환 실패: {error}");
}
bridge::push(
&app,
Push::Navigate {
path: target.clone(),
},
);
bridge::push(
&app,
Push::CaptureImage {
path: target,
data_url,
},
);
}
/// 표준 전역 단축키가 실행할 CodeAssist 동작을 한 곳에서 정함. /// 표준 전역 단축키가 실행할 CodeAssist 동작을 한 곳에서 정함.
fn on_hotkey(app: &AppHandle, id: &str) { fn on_hotkey(app: &AppHandle, id: &str) {
match id { match id {
@@ -162,17 +205,20 @@ fn on_hotkey(app: &AppHandle, id: &str) {
); );
} }
"capture" => { "capture" | "capture_area" | "capture_last" | "capture_area_last" => {
shell::capture::start(app.clone(), |app, data_url| { let target = app
let _ = show_palette(&app); .state::<commands::RouteState>()
bridge::push( .lock()
&app, .map(|routes| capture_target(id, routes.last_session.as_deref()))
Push::Navigate { .unwrap_or_else(|_| "/snap/new".to_string());
path: "/snap/new".into(), let deliver =
}, move |app: AppHandle, data_url: String| on_captured(app, target, data_url);
); if id.starts_with("capture_area") {
bridge::push(&app, Push::CaptureImage { data_url }); shell::capture::start(app.clone(), deliver);
}); } else {
let own_hwnd = window::native_handle(app);
shell::capture::start_foreground_window(app.clone(), own_hwnd, deliver);
}
} }
// 챗봇 토글: 이미 챗봇 화면이 떠 있으면 숨기고, 아니면 마지막 챗봇 위치로 데려온다. // 챗봇 토글: 이미 챗봇 화면이 떠 있으면 숨기고, 아니면 마지막 챗봇 위치로 데려온다.
@@ -199,7 +245,7 @@ fn on_hotkey(app: &AppHandle, id: &str) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{chat_toggle_target, commands::Routes}; use super::{capture_target, chat_toggle_target, commands::Routes};
#[test] #[test]
fn _토글은_현재_화면과_창_표시에_따라_결정한다() { fn _토글은_현재_화면과_창_표시에_따라_결정한다() {
@@ -219,13 +265,28 @@ mod tests {
} }
#[test] #[test]
fn _챗봇_위치는_챗봇_화면에서만_바뀐() { fn _기존_대화는_새_대화나_다른_화면으로_가도_유지된() {
let mut routes = Routes::default(); let mut routes = Routes::default();
routes.report("/snap/session-1"); routes.report("/snap/s/session-1");
routes.report("/snippet"); routes.report("/snap/new");
routes.report("/files"); routes.report("/files");
assert_eq!(routes.current, "/files"); assert_eq!(routes.current, "/files");
assert_eq!(routes.last_snap, "/snap/session-1"); assert_eq!(routes.last_snap, "/snap/new");
assert_eq!(routes.last_session.as_deref(), Some("/snap/s/session-1"));
}
#[test]
fn _핫키에_따라_새_대화나_마지막_기존_대화를_고른다() {
assert_eq!(capture_target("capture", Some("/snap/s/last")), "/snap/new");
assert_eq!(
capture_target("capture_area", Some("/snap/s/last")),
"/snap/new"
);
assert_eq!(
capture_target("capture_last", Some("/snap/s/last")),
"/snap/s/last"
);
assert_eq!(capture_target("capture_area_last", None), "/snap/new");
} }
} }
+204 -40
View File
@@ -1,5 +1,6 @@
//! 화면 영역 선택 + PNG 캡쳐. legacy `CaptureOverlayWindow`/`ScreenCapture`의 Tauri 이식판. //! 화면 영역 선택 + PNG 캡쳐. legacy `CaptureOverlayWindow`/`ScreenCapture`의 Tauri 이식판.
use std::ffi::c_void;
use std::io::Cursor; use std::io::Cursor;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc; use std::sync::mpsc;
@@ -8,27 +9,30 @@ use base64::Engine;
use tauri::AppHandle; use tauri::AppHandle;
use windows::core::{w, PCWSTR}; use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM}; use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, RECT, WPARAM};
use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS};
use windows::Win32::Graphics::Gdi::{ use windows::Win32::Graphics::Gdi::{
BeginPaint, BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, CreateSolidBrush, DeleteDC, BeginPaint, BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, CreateSolidBrush, DeleteDC,
DeleteObject, EndPaint, FillRect, FrameRect, GetDC, GetDIBits, InvalidateRect, ReleaseDC, DeleteObject, EndPaint, FillRect, FrameRect, GetDC, GetDIBits, InvalidateRect, ReleaseDC,
SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HGDIOBJ, PAINTSTRUCT, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HBITMAP, HDC, HGDIOBJ,
SRCCOPY, PAINTSTRUCT, SRCCOPY,
}; };
use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture, SetFocus}; use windows::Win32::UI::Input::KeyboardAndMouse::{ReleaseCapture, SetCapture, SetFocus};
use windows::Win32::UI::WindowsAndMessaging::{ use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetClientRect, GetMessageW, CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, GetForegroundWindow,
GetSystemMetrics, GetWindowLongPtrW, LoadCursorW, PostQuitMessage, RegisterClassW, GetMessageW, GetSystemMetrics, GetWindowLongPtrW, LoadCursorW, PostQuitMessage, RegisterClassW,
SetForegroundWindow, SetLayeredWindowAttributes, SetWindowLongPtrW, ShowWindow, SetForegroundWindow, SetLayeredWindowAttributes, SetWindowLongPtrW, ShowWindow,
TranslateMessage, CREATESTRUCTW, CS_HREDRAW, CS_VREDRAW, GWLP_USERDATA, IDC_CROSS, LWA_ALPHA, TranslateMessage, CREATESTRUCTW, CS_HREDRAW, CS_VREDRAW, GWLP_USERDATA, IDC_CROSS, LWA_ALPHA,
MSG, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SW_SHOW, LWA_COLORKEY, MSG, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
WA_INACTIVE, WINDOW_EX_STYLE, WM_ACTIVATE, WM_DESTROY, WM_ERASEBKGND, WM_KEYDOWN, SM_YVIRTUALSCREEN, SW_SHOW, WA_INACTIVE, WINDOW_EX_STYLE, WM_ACTIVATE, WM_DESTROY,
WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_NCCREATE, WM_NCDESTROY, WM_PAINT, WNDCLASSW, WM_ERASEBKGND, WM_KEYDOWN, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE, WM_NCCREATE,
WS_EX_LAYERED, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP, WM_NCDESTROY, WM_PAINT, WNDCLASSW, WS_EX_LAYERED, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_POPUP,
}; };
const MIN_SELECTION: i32 = 4; const MIN_SELECTION: i32 = 4;
const OVERLAY_ALPHA: u8 = 115; const OVERLAY_ALPHA: u8 = 115;
// 선택 영역에만 칠해 layered window에서 완전히 투명하게 만드는 색.
const SELECTION_COLOR_KEY: COLORREF = COLORREF(0x00ff00ff);
// Samsung Blue #1428A0. COLORREF는 0x00BBGGRR 순서임. // Samsung Blue #1428A0. COLORREF는 0x00BBGGRR 순서임.
const CAPTURE_BORDER_COLOR: COLORREF = COLORREF(0x00a02814); const CAPTURE_BORDER_COLOR: COLORREF = COLORREF(0x00a02814);
static CAPTURING: AtomicBool = AtomicBool::new(false); static CAPTURING: AtomicBool = AtomicBool::new(false);
@@ -54,17 +58,36 @@ fn selection_rect(start: (i32, i32), end: (i32, i32), origin: (i32, i32)) -> Opt
}) })
} }
/// 이미 캡쳐 중이면 두 번째 핫키는 무시함. /// 드래그로 영역 골라서 캡쳐. 이미 캡쳐 중이면 두 번째 핫키는 무시함.
pub fn start<F>(app: AppHandle, on_captured: F) pub fn start<F>(app: AppHandle, on_captured: F)
where where
F: FnOnce(AppHandle, String) + Send + 'static, F: FnOnce(AppHandle, String) + Send + 'static,
{
spawn_capture(app, on_captured, run_overlay);
}
/// 핫키 누른 순간 맨 앞에 떠 있는 창을 통째로 캡쳐. 앱 종류 안 가림(SAP·Eclipse·브라우저 다 됨).
/// 자기 창(팔레트)이 맨 앞이면 아무것도 안 함.
pub fn start_foreground_window<F>(app: AppHandle, excluded_hwnd: Option<isize>, on_captured: F)
where
F: FnOnce(AppHandle, String) + Send + 'static,
{
spawn_capture(app, on_captured, move || {
foreground_window_rect(excluded_hwnd)
});
}
fn spawn_capture<F, P>(app: AppHandle, on_captured: F, pick_rect: P)
where
F: FnOnce(AppHandle, String) + Send + 'static,
P: FnOnce() -> Result<Option<CaptureRect>, String> + Send + 'static,
{ {
if CAPTURING.swap(true, Ordering::AcqRel) { if CAPTURING.swap(true, Ordering::AcqRel) {
return; return;
} }
std::thread::spawn(move || { std::thread::spawn(move || {
let result = run_overlay().and_then(|rect| match rect { let result = pick_rect().and_then(|rect| match rect {
Some(rect) => capture_png_data_url(rect), Some(rect) => capture_png_data_url(rect),
None => Ok(None), None => Ok(None),
}); });
@@ -77,10 +100,59 @@ where
}); });
} }
struct PaintBuffer {
dc: HDC,
bitmap: HBITMAP,
previous: HGDIOBJ,
}
impl PaintBuffer {
unsafe fn new(hwnd: HWND, width: i32, height: i32) -> Option<Self> {
let window_dc = GetDC(Some(hwnd));
if window_dc.is_invalid() {
return None;
}
let dc = CreateCompatibleDC(Some(window_dc));
let bitmap = CreateCompatibleBitmap(window_dc, width, height);
let _ = ReleaseDC(Some(hwnd), window_dc);
if dc.is_invalid() || bitmap.is_invalid() {
if !dc.is_invalid() {
let _ = DeleteDC(dc);
}
if !bitmap.is_invalid() {
let _ = DeleteObject(HGDIOBJ(bitmap.0));
}
return None;
}
let previous = SelectObject(dc, HGDIOBJ(bitmap.0));
if previous.is_invalid() {
let _ = DeleteObject(HGDIOBJ(bitmap.0));
let _ = DeleteDC(dc);
return None;
}
Some(Self {
dc,
bitmap,
previous,
})
}
}
impl Drop for PaintBuffer {
fn drop(&mut self) {
unsafe {
SelectObject(self.dc, self.previous);
let _ = DeleteObject(HGDIOBJ(self.bitmap.0));
let _ = DeleteDC(self.dc);
}
}
}
struct OverlayState { struct OverlayState {
origin: (i32, i32), origin: (i32, i32),
start: Option<(i32, i32)>, start: Option<(i32, i32)>,
current: Option<(i32, i32)>, current: Option<(i32, i32)>,
buffer: Option<PaintBuffer>,
sender: mpsc::Sender<Option<CaptureRect>>, sender: mpsc::Sender<Option<CaptureRect>>,
finished: bool, finished: bool,
} }
@@ -106,6 +178,24 @@ fn mouse_point(lparam: LPARAM) -> (i32, i32) {
((value & 0xffff) as i16 as i32, (value >> 16) as i16 as i32) ((value & 0xffff) as i16 as i32, (value >> 16) as i16 as i32)
} }
fn selection_client_rect(start: (i32, i32), end: (i32, i32)) -> RECT {
RECT {
left: start.0.min(end.0),
top: start.1.min(end.1),
right: start.0.max(end.0),
bottom: start.1.max(end.1),
}
}
fn union_rect(a: RECT, b: RECT) -> RECT {
RECT {
left: a.left.min(b.left),
top: a.top.min(b.top),
right: a.right.max(b.right),
bottom: a.bottom.max(b.bottom),
}
}
fn inset_rect(rect: RECT, amount: i32) -> Option<RECT> { fn inset_rect(rect: RECT, amount: i32) -> Option<RECT> {
let inset = RECT { let inset = RECT {
left: rect.left + amount, left: rect.left + amount,
@@ -116,6 +206,28 @@ fn inset_rect(rect: RECT, amount: i32) -> Option<RECT> {
(inset.left < inset.right && inset.top < inset.bottom).then_some(inset) (inset.left < inset.right && inset.top < inset.bottom).then_some(inset)
} }
unsafe fn draw_overlay(dc: HDC, background: &RECT, selected: Option<RECT>) {
let shade = CreateSolidBrush(COLORREF(0x000000));
FillRect(dc, background, shade);
let _ = DeleteObject(HGDIOBJ(shade.0));
let Some(selected) = selected else { return };
let clear = CreateSolidBrush(SELECTION_COLOR_KEY);
FillRect(dc, &selected, clear);
let _ = DeleteObject(HGDIOBJ(clear.0));
let white = CreateSolidBrush(COLORREF(0x00ffffff));
let accent = CreateSolidBrush(CAPTURE_BORDER_COLOR);
FrameRect(dc, &selected, white);
for amount in 1..4 {
if let Some(border) = inset_rect(selected, amount) {
FrameRect(dc, &border, accent);
}
}
let _ = DeleteObject(HGDIOBJ(white.0));
let _ = DeleteObject(HGDIOBJ(accent.0));
}
unsafe extern "system" fn overlay_proc( unsafe extern "system" fn overlay_proc(
hwnd: HWND, hwnd: HWND,
message: u32, message: u32,
@@ -141,9 +253,13 @@ unsafe extern "system" fn overlay_proc(
} }
WM_MOUSEMOVE => { WM_MOUSEMOVE => {
if let Some(state) = state { if let Some(state) = state {
if state.start.is_some() { if let Some(start) = state.start {
state.current = Some(mouse_point(lparam)); let current = mouse_point(lparam);
let _ = InvalidateRect(Some(hwnd), None, false); let old = selection_client_rect(start, state.current.unwrap_or(start));
let new = selection_client_rect(start, current);
state.current = Some(current);
let dirty = union_rect(old, new);
let _ = InvalidateRect(Some(hwnd), Some(&dirty), false);
} }
} }
LRESULT(0) LRESULT(0)
@@ -172,33 +288,34 @@ unsafe extern "system" fn overlay_proc(
WM_PAINT => { WM_PAINT => {
let mut paint = PAINTSTRUCT::default(); let mut paint = PAINTSTRUCT::default();
let dc = BeginPaint(hwnd, &mut paint); let dc = BeginPaint(hwnd, &mut paint);
let mut client = RECT::default(); let selected = state.as_ref().and_then(|state| {
let _ = GetClientRect(hwnd, &mut client); state
let shade = CreateSolidBrush(COLORREF(0x000000)); .start
FillRect(dc, &client, shade); .zip(state.current)
if let Some(state) = state { .map(|(start, end)| selection_client_rect(start, end))
if let (Some(start), Some(end)) = (state.start, state.current) { });
let selected = RECT {
left: start.0.min(end.0), if let Some(buffer) = state.as_ref().and_then(|state| state.buffer.as_ref()) {
top: start.1.min(end.1), draw_overlay(buffer.dc, &paint.rcPaint, selected);
right: start.0.max(end.0), let width = paint.rcPaint.right - paint.rcPaint.left;
bottom: start.1.max(end.1), let height = paint.rcPaint.bottom - paint.rcPaint.top;
}; if width > 0 && height > 0 {
// 반투명 오버레이에서도 경계가 묻히지 않게 흰색 외곽선과 let _ = BitBlt(
// 포인트색 안쪽선을 겹쳐 4px 테두리로 그림. dc,
let white = CreateSolidBrush(COLORREF(0x00ffffff)); paint.rcPaint.left,
let accent = CreateSolidBrush(CAPTURE_BORDER_COLOR); paint.rcPaint.top,
FrameRect(dc, &selected, white); width,
for amount in 1..4 { height,
if let Some(border) = inset_rect(selected, amount) { Some(buffer.dc),
FrameRect(dc, &border, accent); paint.rcPaint.left,
paint.rcPaint.top,
SRCCOPY,
);
} }
} else {
draw_overlay(dc, &paint.rcPaint, selected);
} }
let _ = DeleteObject(HGDIOBJ(white.0));
let _ = DeleteObject(HGDIOBJ(accent.0));
}
}
let _ = DeleteObject(HGDIOBJ(shade.0));
let _ = EndPaint(hwnd, &paint); let _ = EndPaint(hwnd, &paint);
LRESULT(0) LRESULT(0)
} }
@@ -246,6 +363,7 @@ fn run_overlay() -> Result<Option<CaptureRect>, String> {
origin: (x, y), origin: (x, y),
start: None, start: None,
current: None, current: None,
buffer: None,
sender, sender,
finished: false, finished: false,
}); });
@@ -270,8 +388,13 @@ fn run_overlay() -> Result<Option<CaptureRect>, String> {
return Err(error.to_string()); return Err(error.to_string());
} }
}; };
if let Err(error) = SetLayeredWindowAttributes(hwnd, COLORREF(0), OVERLAY_ALPHA, LWA_ALPHA) (*state_ptr).buffer = PaintBuffer::new(hwnd, width, height);
{ if let Err(error) = SetLayeredWindowAttributes(
hwnd,
SELECTION_COLOR_KEY,
OVERLAY_ALPHA,
LWA_ALPHA | LWA_COLORKEY,
) {
let _ = DestroyWindow(hwnd); let _ = DestroyWindow(hwnd);
return Err(error.to_string()); return Err(error.to_string());
} }
@@ -288,6 +411,30 @@ fn run_overlay() -> Result<Option<CaptureRect>, String> {
Ok(receiver.recv().ok().flatten()) Ok(receiver.recv().ok().flatten())
} }
/// 맨 앞 창의 화면 좌표. Win11 은 GetWindowRect 가 그림자 여백까지 잡아서 DWM 확장 프레임 경계를 씀.
fn foreground_window_rect(excluded_hwnd: Option<isize>) -> Result<Option<CaptureRect>, String> {
let window = unsafe { GetForegroundWindow() };
if window.0.is_null() || excluded_hwnd == Some(window.0 as isize) {
return Ok(None);
}
let mut bounds = RECT::default();
unsafe {
DwmGetWindowAttribute(
window,
DWMWA_EXTENDED_FRAME_BOUNDS,
&mut bounds as *mut RECT as *mut c_void,
std::mem::size_of::<RECT>() as u32,
)
}
.map_err(|error| format!("창 경계를 못 얻었어: {error}"))?;
Ok(Some(CaptureRect {
x: bounds.left,
y: bounds.top,
width: bounds.right - bounds.left,
height: bounds.bottom - bounds.top,
}))
}
fn capture_png_data_url(rect: CaptureRect) -> Result<Option<String>, String> { fn capture_png_data_url(rect: CaptureRect) -> Result<Option<String>, String> {
if rect.width <= 0 || rect.height <= 0 { if rect.width <= 0 || rect.height <= 0 {
return Ok(None); return Ok(None);
@@ -413,4 +560,21 @@ mod tests {
assert_eq!((inset.right, inset.bottom), (27, 47)); assert_eq!((inset.right, inset.bottom), (27, 47));
assert!(inset_rect(rect, 10).is_none()); assert!(inset_rect(rect, 10).is_none());
} }
#[test]
fn _선택_영역의_합집합만_다시_그린다() {
let old = selection_client_rect((100, 100), (300, 250));
let new = selection_client_rect((100, 100), (320, 220));
let dirty = union_rect(old, new);
assert_eq!(
dirty,
RECT {
left: 100,
top: 100,
right: 320,
bottom: 250,
}
);
}
} }
@@ -8,3 +8,10 @@ where
{ {
eprintln!("[capture] 영역 캡처는 Windows 에서만 됨"); eprintln!("[capture] 영역 캡처는 Windows 에서만 됨");
} }
pub fn start_foreground_window<F>(_app: AppHandle, _excluded_hwnd: Option<isize>, _on_captured: F)
where
F: FnOnce(AppHandle, String) + Send + 'static,
{
eprintln!("[capture] 창 캡처는 Windows 에서만 됨");
}
+47 -2
View File
@@ -11,6 +11,17 @@ use std::sync::Mutex;
use tauri::{ use tauri::{
AppHandle, LogicalSize, Manager, PhysicalPosition, PhysicalRect, PhysicalSize, WebviewWindow, AppHandle, LogicalSize, Manager, PhysicalPosition, PhysicalRect, PhysicalSize, WebviewWindow,
}; };
#[cfg(windows)]
use windows::Win32::Foundation::HWND;
#[cfg(windows)]
use windows::Win32::System::Threading::{AttachThreadInput, GetCurrentThreadId};
#[cfg(windows)]
use windows::Win32::UI::Input::KeyboardAndMouse::SetFocus;
#[cfg(windows)]
use windows::Win32::UI::WindowsAndMessaging::{
BringWindowToTop, GetForegroundWindow, GetWindowThreadProcessId, SetForegroundWindow,
ShowWindow, SW_RESTORE,
};
/// tauri.conf.json 의 창 label 과 같아야 함. /// tauri.conf.json 의 창 label 과 같아야 함.
const MAIN: &str = "main"; const MAIN: &str = "main";
@@ -19,7 +30,6 @@ pub fn main_window(app: &AppHandle) -> Option<WebviewWindow> {
app.get_webview_window(MAIN) app.get_webview_window(MAIN)
} }
/// 창 표시 + 포커스. (핫키/트레이 소환용)
/// 붙여넣기 대상에서 우리 창을 빼거나 포커스 비교할 때 쓰는 네이티브 창 핸들(HWND). /// 붙여넣기 대상에서 우리 창을 빼거나 포커스 비교할 때 쓰는 네이티브 창 핸들(HWND).
/// Windows 에서만 값이 있고 다른 OS 는 None — 호출부가 알아서 "대상 없음"으로 처리함. /// Windows 에서만 값이 있고 다른 OS 는 None — 호출부가 알아서 "대상 없음"으로 처리함.
pub fn native_handle(app: &AppHandle) -> Option<isize> { pub fn native_handle(app: &AppHandle) -> Option<isize> {
@@ -36,10 +46,45 @@ pub fn native_handle(app: &AppHandle) -> Option<isize> {
} }
} }
/// 창 표시 + 포커스. (핫키/트레이 소환용)
/// Windows 에선 캡처 오버레이가 돌려준 foreground 를 현재 입력 큐에 잠시 붙여
/// 이미 보이던 창도 뒤에 남지 않게 직접 활성화함(Tauri set_focus 만으론 외부 앱이 다시 가져감).
pub fn show(app: &AppHandle) -> tauri::Result<()> { pub fn show(app: &AppHandle) -> tauri::Result<()> {
if let Some(w) = main_window(app) { if let Some(w) = main_window(app) {
w.show()?; w.show()?;
w.set_focus()?; let _ = w.set_focus();
#[cfg(windows)]
force_foreground(&w)?;
}
Ok(())
}
#[cfg(windows)]
fn force_foreground(w: &WebviewWindow) -> tauri::Result<()> {
let raw = w.hwnd()?;
let hwnd = HWND(raw.0 as *mut std::ffi::c_void);
unsafe {
let foreground = GetForegroundWindow();
let current_thread = GetCurrentThreadId();
let foreground_thread = if foreground.0.is_null() {
0
} else {
GetWindowThreadProcessId(foreground, None)
};
let attached = foreground_thread != 0
&& foreground_thread != current_thread
&& AttachThreadInput(current_thread, foreground_thread, true).as_bool();
let _ = ShowWindow(hwnd, SW_RESTORE);
let _ = BringWindowToTop(hwnd);
if !SetForegroundWindow(hwnd).as_bool() {
eprintln!("[window] foreground 활성화 실패");
}
let _ = SetFocus(Some(hwnd));
if attached {
let _ = AttachThreadInput(current_thread, foreground_thread, false);
}
} }
Ok(()) Ok(())
} }
@@ -0,0 +1,20 @@
# 2026-09-17 단축키는 왼손 한손으로
## 원문
> 단축키를 숫자로 하지말고 왼손 한손으로 끝내고 싶음. 지금은 컨트롤 쉬프트는 왼손 숫자는 오른손이라 불편함
## 왜 틀렸나
`Ctrl+Shift+7/8/9` 는 왼손으로 modifier 잡고 오른손으로 숫자 눌러야 해서 두 손이 필요함. SAP GUI·Eclipse 에서 마우스 잡은 채로 호출하는 게 이 앱의 핵심 흐름이라, 한손(왼손)으로 끝나야 함.
## 앞으로
- 전역 단축키는 **왼손 범위 글자**(Q W E R A S D F Z X C V)만 씀. 숫자·오른손 글자 금지.
- modifier 는 `Ctrl+Alt`. 2026-09-17 실제 목록으로 확인한 결과:
- `Ctrl+Shift+글자`: Eclipse/ADT 가 거의 다 씀(A 오브젝트 열기, R/E/W/F/L/G…).
- `Alt+Shift+글자`: 역시 Eclipse 가 씀 — A 블록선택, S Source, W Show In, Z Surround With, X Run As, D Debug As, R Rename, T Refactor, Q Show View. SAP GUI 도 Alt+Shift+V(디버거 변수).
- `Ctrl+Alt+글자`: Eclipse 는 G/H/M/N/B 만, SAP GUI 는 L/M/P 만 씀 → Q/W/A/S/Z/X 는 비어 있음.
- 새 키 잡을 땐 Eclipse 에서 `Ctrl+Shift+L` 로 실제 바인딩 목록 보고 겹치는지 확인할 것. 기억으로 판단 금지.
- 현재 배정: `Ctrl+Alt+Q` 스니펫 / `Ctrl+Alt+W` 챗 토글 / `Ctrl+Alt+A` 맨 앞 창 캡쳐→새 대화 / `Ctrl+Alt+S` 드래그 캡쳐→새 대화 / `Ctrl+Alt+Z` 맨 앞 창 캡쳐→마지막 대화 / `Ctrl+Alt+X` 드래그 캡쳐→마지막 대화. 윗줄 = 여는 것, A/S = 새 대화에 찍기, Z/X = 기존 대화에 찍기.
- 진실원천은 `4_rust_tauri/src-tauri/src/lib.rs``HOTKEYS`. 새 단축키 추가할 때도 이 규칙 따름.
@@ -0,0 +1,25 @@
# 캡처가 뒤쪽 창에 붙고 대상 대화를 고를 수 없음
## 증상
- 새 대화 화면이 이미 열려 있어도 캡처 완료 뒤 CodeAssist가 앞으로 오지 않고 뒤에서 이미지만 첨부됐음.
- 캡처 이미지는 항상 새 대화로 보내져 기존 대화에 붙일 방법이 없었음.
## 원인
- 캡처 완료 흐름이 `window.show()`와 Tauri `set_focus()` 결과에만 기대고 있었음. Win32 캡처 오버레이가 닫힌 뒤 외부 앱이 다시 foreground를 가져가면 이미 보이는 CodeAssist 창의 활성화가 보장되지 않았음.
- native 흐름이 대상 route를 무조건 `/snap/new`로 보냈음.
- 프론트의 pending 캡처는 대상 route 정보가 없어 `NewChatPage`만 소비하도록 막아둔 상태였음.
## 수정
- 창을 소환할 때 현재 입력 thread를 foreground 입력 thread에 잠시 연결하고 Win32 `BringWindowToTop`, `SetForegroundWindow`, `SetFocus`로 메인 창을 직접 활성화함.
- `Ctrl+Shift+9`는 새 대화, `Ctrl+Shift+0`은 마지막 기존 대화를 대상으로 함. 기존 대화가 없으면 새 대화로 대체함.
- `capture.image`에 대상 `path`를 추가하고, path가 같은 Composer만 pending 이미지를 소비하게 바꿈.
## 확인
- 대상 route 선택과 route별 pending 소비 회귀 테스트를 RED→GREEN으로 확인함.
- frontend 전체 217개 테스트, lint, build와 Rust 13개 테스트, build를 실행함.
- 데스크톱 앱에서 `/snap/new` 도착과 캡처 첨부 노출을 확인함.
- 자동화 환경이 Windows 창 focus 전환을 지원하지 않아 외부 앱 위로 실제 foreground가 바뀌는 장면은 자동 확인하지 못했음. 해당 한 항목은 실제 단축키 수동 확인이 남음.
@@ -0,0 +1,25 @@
# 캡처 선택 영역이 드래그 중 번쩍임
## 증상
- 캡처 범위를 드래그하면 원래 화면으로 보이는 선택 영역이 계속 번쩍였음.
- 캡처 결과가 아니라 드래그 중 오버레이 표시에서만 발생했음.
## 원인
- `WM_MOUSEMOVE`마다 `InvalidateRect`에 영역을 주지 않아 전체 가상 화면을 다시 그렸음.
- `WM_PAINT`가 실제 화면 DC에 검정 배경, 투명 color key, 테두리를 차례로 직접 그려 중간 상태가 노출됐음.
- 멀티모니터 전체 면적을 매번 다시 칠해 불필요한 GDI 작업도 컸음.
## 수정
- 이전 선택 사각형과 새 선택 사각형의 합집합만 무효화함.
- 오버레이 크기의 호환 bitmap과 memory DC를 한 번 만들고 창이 닫힐 때 해제함.
- memory DC에서 배경·투명 영역·테두리를 완성한 뒤 dirty 영역만 `BitBlt` 한 번으로 화면에 복사함.
- memory DC를 만들지 못하면 기존 화면 DC 그리기로 대체함.
## 확인
- 역방향 드래그를 포함한 선택 영역 합집합 계산 회귀 테스트를 추가함.
- Rust 테스트 14개와 build를 통과함.
- 요청에 따라 computer-use는 사용하지 않았고, 실제 드래그 화면 확인은 사용자 수동 확인으로 남김.
@@ -6,10 +6,11 @@
| 방향 | 메시지 | 처리 | | 방향 | 메시지 | 처리 |
|---|---|---| |---|---|---|
| C#→JS | `{type:"capture.image", dataUrl}` | 프론트가 캡쳐 이미지를 챗 입력창 첨부로 받음 | | C#→JS | `{type:"capture.image", path, dataUrl}` | 프론트가 캡쳐 이미지를 지정된 챗 입력창 첨부로 받음 |
- `path`: 캡쳐를 받을 route(`/snap/new` 또는 `/snap/s/:id`).
- `dataUrl`: `data:image/png;base64,...` (PNG). - `dataUrl`: `data:image/png;base64,...` (PNG).
- App 캡쳐 완료 순서: `ShowPalette()``NavigateApp("/snap/new")``SendCaptureImage(dataUrl)`. (navigate·소환은 기존 배선.) - App 캡쳐 완료 순서: 창 소환 → 대상 route로 이동 → `{type:"capture.image", path, dataUrl}` 전송.
- 기존 메시지(navigate·paste.target·window.*·snippets.* 등)와 같은 리스너 허브에서 `type`으로 분기. - 기존 메시지(navigate·paste.target·window.*·snippets.* 등)와 같은 리스너 허브에서 `type`으로 분기.
## C# 표면 (신규/확장) ## C# 표면 (신규/확장)
@@ -22,14 +23,15 @@
## 프론트 계약 (`bridgeNavigate.ts`) ## 프론트 계약 (`bridgeNavigate.ts`)
```ts ```ts
// 마지막 캡쳐 이미지 dataUrl 을 1회 소비(마운트 레이스 대비). 없으면 "". // path가 일치하는 마지막 캡쳐 이미지를 1회 소비하고, 다르면 pending을 유지함.
export function consumePendingCaptureImage(): string export function consumePendingCaptureImage(path: string): string
// capture.image 수신 → pendingCaptureImage 갱신 + window.dispatchEvent(new CustomEvent("bridge:captureImage", { detail: { dataUrl } })) // capture.image 수신 → {path, dataUrl} 보관 + bridge:captureImage 이벤트 발생
``` ```
## 프론트 계약 (`Composer.tsx`) ## 프론트 계약 (`Composer.tsx`)
- 상태 `imageAttachments: string[]`(dataUrl). 마운트 시 `consumePendingCaptureImage()` push + `bridge:captureImage` 구독 push. X 제거. (data-model.md 규칙.) - `capturePath`와 메시지 `path`가 같은 Composer만 pending 이미지를 소비함. 네비게이션 직전의 다른 Composer가 이미지를 훔치지 않는다.
- 새 대화와 기존 대화 모두 캡쳐 이미지를 누적하고 X로 제거할 수 있다.
- v1 전송: 텍스트만. 이미지 첨부는 미전송 + 전송 후 정리. - v1 전송: 텍스트만. 이미지 첨부는 미전송 + 전송 후 정리.
## 순수 브라우저(dev) ## 순수 브라우저(dev)
+13 -12
View File
@@ -14,7 +14,7 @@
작업 중 화면 어디든 전역 핫키로 사각형을 그려 그 영역을 캡쳐하면, 그 이미지가 곧바로 챗 입력창에 첨부로 붙는다. 나중에 "이미지 놓고 AI랑 얘기"하려는 건데, **이번 v1은 캡쳐해서 챗에 붙이는 데까지**만 한다. 실제 분석(비전 LLM)·웹서치는 백엔드가 준비되면 그 위에 얹는다. 작업 중 화면 어디든 전역 핫키로 사각형을 그려 그 영역을 캡쳐하면, 그 이미지가 곧바로 챗 입력창에 첨부로 붙는다. 나중에 "이미지 놓고 AI랑 얘기"하려는 건데, **이번 v1은 캡쳐해서 챗에 붙이는 데까지**만 한다. 실제 분석(비전 LLM)·웹서치는 백엔드가 준비되면 그 위에 얹는다.
**한 줄 흐름**: 핫키 → 화면에 캡쳐 오버레이 → 사각형 드래그 → 그 영역 이미지 → 챗 새 대화에 이미지 첨부(썸네일+X) → (사용자가 질문 타이핑). **한 줄 흐름**: 새 대화 핫키(`Ctrl+Shift+9`) 또는 마지막 대화 핫키(`Ctrl+Shift+0`) → 화면에 캡쳐 오버레이 → 사각형 드래그 → 그 영역 이미지 → 선택한 챗 입력창에 이미지 첨부(썸네일+X) → (사용자가 질문 타이핑).
--- ---
@@ -22,7 +22,7 @@
### User Story 1 - 화면 캡쳐해서 챗에 첨부 (Priority: P1) ### User Story 1 - 화면 캡쳐해서 챗에 첨부 (Priority: P1)
에디터·브라우저·SAP GUI 등 아무 화면을 보다가 전역 핫키(`Ctrl+Shift+9`)를 누르면 화면이 어둑해지며 캡쳐 모드가 뜬다. 원하는 영역을 마우스로 드래그해 사각형으로 잡고 놓으면, 앱 창이 떠서 새 대화 화면으로 가고 그 캡쳐 이미지가 입력창에 첨부(썸네일)로 붙어 있다. 이제 질문을 타이핑하면 된다. 에디터·브라우저·SAP GUI 등 아무 화면을 보다가 전역 핫키를 누르면 화면이 어둑해지며 캡쳐 모드가 뜬다. `Ctrl+Shift+9`는 새 대화, `Ctrl+Shift+0`은 마지막 기존 대화에 붙이고 기존 대화가 없으면 새 대화로 간다. 영역을 마우스로 드래그해 놓으면 앱 창이 앞으로 뜨고 선택한 대화 입력창에 캡쳐 이미지가 첨부돼 있다.
**Why this priority**: 이게 이 기능의 전부(v1). 캡쳐→첨부가 매끄럽게 되면 나중에 백엔드만 붙이면 "이미지로 AI와 대화"가 완성됨. **Why this priority**: 이게 이 기능의 전부(v1). 캡쳐→첨부가 매끄럽게 되면 나중에 백엔드만 붙이면 "이미지로 AI와 대화"가 완성됨.
@@ -31,11 +31,12 @@
**Acceptance Scenarios**: **Acceptance Scenarios**:
1. **Given** 아무 창이 떠있음, **When** `Ctrl+Shift+9`, **Then** 모든 모니터를 덮는 어두운 캡쳐 오버레이가 뜬다. 1. **Given** 아무 창이 떠있음, **When** `Ctrl+Shift+9`, **Then** 모든 모니터를 덮는 어두운 캡쳐 오버레이가 뜬다.
2. **Given** 캡쳐 오버레이가 떠있음, **When** 영역을 드래그해 놓음, **Then** 오버레이가 닫히고 앱 창이 떠서 새 대화 화면으로 이동하며, 그 영역 이미지가 입력창에 썸네일 첨부로 붙는다. 2. **Given** 캡쳐 오버레이에서 영역을 드래그 중임, **When** 선택 사각형이 바뀜, **Then** 사각형 내부는 원래 화면 밝기로 보이고 바깥만 어둡게 유지된다.
3. **Given** 캡쳐 오버레이가 떠있음, **When** `Esc`(또는 드래그 없이 클릭만/영역 너무 작음), **Then** 아무것도 캡쳐하지 않고 오버레이힌다(부작용 없음). 3. **Given** 캡쳐 오버레이가 떠있음, **When** 영역을 드래그해 놓음, **Then** 오버레이히고 앱 창이 떠서 새 대화 화면으로 이동하며, 그 영역 이미지가 입력창에 썸네일 첨부로 붙는다.
4. **Given** 이미지가 첨부됨, **When** 첨부의 X를 누름, **Then** 그 첨부가 제거된다. 4. **Given** 캡쳐 오버레이가 떠있음, **When** `Esc`(또는 드래그 없이 클릭만/영역 너무 작음), **Then** 아무것도 캡쳐하지 않고 오버레이만 닫힌다(부작용 없음).
5. **Given** 모니터가 여러 개, **When** 보조 모니터 영역을 드래그, **Then**모니터의 해당 영역이 정확히 캡쳐된다. 5. **Given** 이미지가 첨부됨, **When** 첨부의 X를 누름, **Then**첨부가 제거된다.
6. **Given** 이미 이미지가 첨부돼 있음, **When** 한 번 더 캡쳐, **Then** 새 이미지가 첨부로 추가된다(기존 것 유지). 6. **Given** 모니터가 여러 개, **When** 보조 모니터 영역을 드래그, **Then** 그 모니터의 해당 영역이 정확히 캡쳐된다.
7. **Given** 이미 이미지가 첨부돼 있음, **When** 한 번 더 캡쳐, **Then** 새 이미지가 첨부로 추가된다(기존 것 유지).
--- ---
@@ -56,12 +57,13 @@
**캡쳐 트리거·오버레이** **캡쳐 트리거·오버레이**
- **FR-001**: 전역 핫키 `Ctrl+Shift+9`로 화면 캡쳐 모드를 켤 수 있어야 한다. 기존 핫키(스니펫 `Ctrl+Shift+7`, 챗 `Ctrl+Shift+8`)와 별개다. - **FR-001**: 전역 핫키 `Ctrl+Shift+9`로 화면 캡쳐 모드를 켤 수 있어야 한다. 기존 핫키(스니펫 `Ctrl+Shift+7`, 챗 `Ctrl+Shift+8`)와 별개다.
- **FR-002**: 캡쳐 모드는 **모든 모니터를 덮는** 어두운 오버레이를 띄우고, 그 위에서 마우스 드래그 선택 사각형을 그린다. - **FR-002**: 캡쳐 모드는 **모든 모니터를 덮는** 어두운 오버레이를 띄우고, 마우스 드래그 선택 사각형 내부는 원래 화면 밝기로 투명하게 보여준다.
- **FR-003**: `Esc`, 또는 유효하지 않은 선택(드래그 없이 클릭/너무 작은 영역)이면 아무것도 캡쳐하지 않고 오버레이를 닫는다. - **FR-003**: `Esc`, 또는 유효하지 않은 선택(드래그 없이 클릭/너무 작은 영역)이면 아무것도 캡쳐하지 않고 오버레이를 닫는다.
**캡쳐·전달** **캡쳐·전달**
- **FR-004**: 드래그로 확정된 영역의 화면 픽셀을 이미지로 캡쳐한다. - **FR-004**: 드래그로 확정된 영역의 화면 픽셀을 이미지로 캡쳐한다.
- **FR-005**: 캡쳐 완료 시 앱 창을 소환하고, **새 대화 화면**으로 이동시킨 뒤 그 이미지를 챗 입력창에 전달한다. - **FR-005**: 캡쳐 완료 시 앱 창을 앞으로 소환하고, 사용한 핫키에 맞는 대화로 이동시킨 뒤 그 이미지를 챗 입력창에 전달한다.
- **FR-005a**: `Ctrl+Shift+9`는 새 대화, `Ctrl+Shift+0`은 마지막 기존 대화를 대상으로 하며 기존 대화가 없으면 새 대화로 대체한다.
**챗 첨부(끝점)** **챗 첨부(끝점)**
- **FR-006**: 전달된 이미지는 챗 입력창에 **썸네일 첨부**로 표시된다. - **FR-006**: 전달된 이미지는 챗 입력창에 **썸네일 첨부**로 표시된다.
@@ -90,7 +92,7 @@
## Assumptions ## Assumptions
- **v1 범위 = 캡쳐 파이프라인만.** 이미지를 실은 메시지를 멀티모달 LLM 백엔드로 보내 **비전 분석·응답**하는 것, **웹서치** 툴은 다음 phase(백엔드 계약 나올 때). **전송-with-이미지 payload 배선도 v1 밖** — v1의 전송은 기존 텍스트 그대로. - **v1 범위 = 캡쳐 파이프라인만.** 이미지를 실은 메시지를 멀티모달 LLM 백엔드로 보내 **비전 분석·응답**하는 것, **웹서치** 툴은 다음 phase(백엔드 계약 나올 때). **전송-with-이미지 payload 배선도 v1 밖** — v1의 전송은 기존 텍스트 그대로.
- **캡쳐 도착지 = 새 대화 화면.** 캡쳐하면 새 챗을 열고 거기에 붙인다(기존 세션에 붙이는 건 v1 밖). - **캡쳐 도착지**: `Ctrl+Shift+9`는 새 대화, `Ctrl+Shift+0`은 마지막 기존 대화. 캡쳐 메시지에 대상 route를 실어 해당 Composer만 이미지를 소비한다.
- **재사용**: 기존 멀티핫키 등록, C#→JS 브릿지(SendToWeb/PostWebMessageAsJson), 팔레트 창 소환·navigate, 브릿지 이벤트 패턴(pasteTarget/bridgeNavigate 식 — 마운트 레이스 대비 모듈 보관+이벤트), Composer의 첨부 칩 UI 패턴을 최대한 얹는다. 캡쳐 오버레이 창과 캡쳐 로직만 실질 신규. - **재사용**: 기존 멀티핫키 등록, C#→JS 브릿지(SendToWeb/PostWebMessageAsJson), 팔레트 창 소환·navigate, 브릿지 이벤트 패턴(pasteTarget/bridgeNavigate 식 — 마운트 레이스 대비 모듈 보관+이벤트), Composer의 첨부 칩 UI 패턴을 최대한 얹는다. 캡쳐 오버레이 창과 캡쳐 로직만 실질 신규.
- **현재 이미지 처리와의 관계**: 지금 이미지 붙여넣기는 클립보드 이력 사이드바로만 감(챗에 "보내는 첨부" 개념 없음). 이 기능이 **챗 입력창 이미지 첨부**를 처음 도입한다. - **현재 이미지 처리와의 관계**: 지금 이미지 붙여넣기는 클립보드 이력 사이드바로만 감(챗에 "보내는 첨부" 개념 없음). 이 기능이 **챗 입력창 이미지 첨부**를 처음 도입한다.
- **ponytail 천장(추후 보완)**: ① 고DPI/모니터별 배율 좌표 보정, ② 큰 영역 캡쳐 시 base64가 커지면 임시파일/blob 전달로 최적화. - **ponytail 천장(추후 보완)**: ① 고DPI/모니터별 배율 좌표 보정, ② 큰 영역 캡쳐 시 base64가 커지면 임시파일/blob 전달로 최적화.
@@ -99,5 +101,4 @@
- 이미지 비전 분석·AI 판단·응답(멀티모달 백엔드). - 이미지 비전 분석·AI 판단·응답(멀티모달 백엔드).
- 웹서치. - 웹서치.
- 이미지를 실은 메시지의 백엔드 전송(payload 포맷 포함). - 캡쳐 이미지 편집(자르기·주석), 캡쳐 히스토리.
- 기존(진행 중) 세션에 캡쳐 붙이기, 캡쳐 이미지 편집(자르기·주석), 캡쳐 히스토리.