Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
@@ -0,0 +1,151 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"
import MockAdapter from "axios-mock-adapter"
import { authApi } from "./auth.api"
import { apiClient } from "@/lib/api/client"
const fakeToken = {
token: "a",
tokenExpirationTime: 0,
refreshToken: "r",
refreshTokenExpirationTime: 0,
tokenType: "bearer",
user: { id: "u", email: "x@x.com", userName: null, role: "USER" as const },
}
function envelope<T>(data: T) {
return {
success: true,
statusCode: 200,
code: null,
message: null,
data,
counts: null,
errors: [] as string[],
timestamp: "2026-01-01T00:00:00Z",
meta: null,
}
}
let mock: MockAdapter
beforeEach(() => {
mock = new MockAdapter(apiClient)
})
afterEach(() => {
mock.restore()
})
describe("authApi", () => {
it("login POST /auth/login → user 반환 (token은 쿠키로 처리)", async () => {
let body: unknown
mock.onPost("/auth/login").reply((config) => {
body = JSON.parse(config.data as string)
return [200, envelope(fakeToken)]
})
const user = await authApi.login({ email: "x@x.com", password: "abcd" })
expect(user).toEqual(fakeToken.user)
expect(body).toEqual({ email: "x@x.com", password: "abcd" })
})
it("refresh POST /auth/refresh — body 없음", async () => {
let body: unknown
mock.onPost("/auth/refresh").reply((config) => {
body = config.data
return [200, envelope(fakeToken)]
})
await authApi.refresh()
expect(body).toBeUndefined()
})
it("logout POST /auth/logout — __skipAuth로 401에서도 인터셉터 우회", async () => {
let called = false
mock.onPost("/auth/logout").reply(() => {
called = true
return [200, envelope(null)]
})
await authApi.logout()
expect(called).toBe(true)
})
it("getMe GET /users/me", async () => {
mock.onGet("/users/me").reply(200, envelope({ ...fakeToken.user, isActive: true }))
const me = await authApi.getMe()
expect(me.email).toBe("x@x.com")
})
})
describe("entraLogin", () => {
it("POST /auth/entra/login 후 응답 user를 반환", async () => {
const user = {
id: "u1",
email: "a@b.com",
userName: "A",
role: "USER" as const,
employeeId: "EMP1",
department: "IT",
authProvider: "entra" as const,
}
mock.onPost("/auth/entra/login").reply(200, {
success: true,
statusCode: 200,
code: null,
message: null,
data: {
token: "t",
tokenExpirationTime: 1,
refreshToken: "r",
refreshTokenExpirationTime: 2,
tokenType: "bearer",
user,
},
counts: null,
errors: [],
timestamp: "",
meta: null,
})
const result = await authApi.entraLogin({ idToken: "ID", graphAccessToken: "G" })
expect(result).toEqual(user)
})
})
describe("getEntraConfig", () => {
it("200 응답 → config 반환", async () => {
const cfg = { clientId: "c", authority: "https://...", tenantId: "t" }
mock.onGet("/auth/entra/config").reply(200, {
success: true,
statusCode: 200,
code: null,
message: null,
data: cfg,
counts: null,
errors: [],
timestamp: "",
meta: null,
})
const result = await authApi.getEntraConfig()
expect(result).toEqual(cfg)
})
it("501 응답 → null 반환 (throw 안 함)", async () => {
mock.onGet("/auth/entra/config").reply(501, {
success: false,
statusCode: 501,
code: "ENTRA_NOT_CONFIGURED",
message: "Entra ID SSO 가 설정되지 않았습니다.",
data: null,
counts: null,
errors: [],
timestamp: "",
meta: null,
})
const result = await authApi.getEntraConfig()
expect(result).toBeNull()
})
it("500 응답 → throw", async () => {
mock.onGet("/auth/entra/config").reply(500)
await expect(authApi.getEntraConfig()).rejects.toThrow()
})
})
@@ -0,0 +1,62 @@
import { apiPost, apiGet, type CallerConfig } from "@/lib/api/client"
import { ApiError } from "@/lib/api/errors"
import type {
LoginRequest,
TokenResponse,
UserResponse,
UserPayload,
EntraLoginRequest,
EntraConfigResponse,
} from "@/types/api"
const SKIP_AUTH: CallerConfig = { __skipAuth: true }
/**
* 인증 API. 모두 axios apiClient 사용 (쿠키 자동 전송).
*
* - login: 응답 body의 user만 사용 (토큰은 Set-Cookie로 처리됨)
* - refresh: 쿠키만으로 진행 (body 없음). 호출자는 결과 신경 X — interceptor에서 자동 처리
* - logout: 인증 불필요. 쿠키 삭제 + 클라 store도 함께 비우기
* - getMe: 마운트 시 서버 진실값으로 user 복원
*/
export const authApi = {
/**
* __skipAuth: 로그인 전이므로 401 응답 시 refresh 인터셉터가 개입하면 안 됨.
* 잘못된 credentials → 백엔드 401 → 인터셉터 refresh 시도 → sessionExpiry 모달 오발사 방지.
*/
login: async (req: LoginRequest): Promise<UserPayload> => {
const tokens = await apiPost<TokenResponse>("/auth/login", req, SKIP_AUTH)
return tokens.user
},
/**
* refresh — 쿠키 기반. 인터셉터의 자동 refresh와 별개로 명시적 호출용(sliding refresh 등).
* 실패 시 ApiError throw. 성공 시 새 쿠키가 Set-Cookie로 갱신됨.
*/
refresh: async (): Promise<void> => {
await apiPost<TokenResponse>("/auth/refresh", undefined)
},
/** 인증 불필요 (`__skipAuth`) — 401에서 모달 안 뜨도록 우회. */
logout: () => apiPost<null>("/auth/logout", undefined, SKIP_AUTH),
getMe: () => apiGet<UserResponse>("/users/me", { __skipSessionExpiry: true }),
/**
* __skipAuth: ENTRA_TOKEN_INVALID(401) 응답 시 refresh 인터셉터가 개입하면 안 됨.
* Microsoft 토큰 검증 실패 → 백엔드 401 → 인터셉터 refresh → sessionExpiry 모달 오발사 방지.
*/
entraLogin: async (req: EntraLoginRequest): Promise<UserPayload> => {
const tokens = await apiPost<TokenResponse>("/auth/entra/login", req, SKIP_AUTH)
return tokens.user
},
getEntraConfig: async (): Promise<EntraConfigResponse | null> => {
try {
return await apiGet<EntraConfigResponse>("/auth/entra/config", SKIP_AUTH)
} catch (e) {
if (e instanceof ApiError && e.status === 501) return null
throw e
}
},
}
@@ -0,0 +1,129 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { toast } from "sonner"
import { AuthError } from "@azure/msal-browser"
import { ApiError } from "@/lib/api/errors"
import { PATHS } from "@/config/routes"
import EntraLoginButton from "./EntraLoginButton"
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
const navigateMock = vi.fn()
const invalidateQueries = vi.fn()
vi.mock("react-router-dom", async (orig) => {
const m: any = await orig()
return { ...m, useNavigate: () => navigateMock }
})
const mutate = vi.fn()
let mutationState = { isPending: false }
vi.mock("../hooks/useEntraLogin", () => ({
useEntraLogin: () => ({
mutate,
get isPending() {
return mutationState.isPending
},
}),
}))
vi.mock("@tanstack/react-query", async (orig) => {
const m: any = await orig()
return { ...m, useQueryClient: () => ({ invalidateQueries }) }
})
function renderBtn(search = "") {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[`/login${search}`]}>
<EntraLoginButton />
</MemoryRouter>
</QueryClientProvider>
)
}
beforeEach(() => {
vi.clearAllMocks()
mutationState = { isPending: false }
})
describe("EntraLoginButton", () => {
it("버튼 렌더 + 텍스트", () => {
renderBtn()
expect(screen.getByRole("button", { name: /Microsoft로 로그인/ })).toBeInTheDocument()
})
it("isPending 동안 disabled + '로그인 중...'", () => {
mutationState.isPending = true
renderBtn()
const btn = screen.getByRole("button")
expect(btn).toBeDisabled()
expect(btn).toHaveTextContent("로그인 중...")
})
it("클릭 → mutate 호출 (from 정상 → onSuccess에서 navigate)", async () => {
renderBtn("?from=/snap/new")
await userEvent.click(screen.getByRole("button"))
expect(mutate).toHaveBeenCalledOnce()
// onSuccess 콜백 실행 시뮬레이션
const [, opts] = mutate.mock.calls[0]
opts.onSuccess({ id: "u" })
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP_NEW, { replace: true })
})
it("from=외부 도메인 → navigate PATHS.SNAP (safeRedirectPath 보호)", async () => {
renderBtn("?from=//evil.com")
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
opts.onSuccess({ id: "u" })
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP, { replace: true })
})
it("onError: user_cancelled → toast 안 띄움", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
opts.onError({ errorCode: "user_cancelled" })
expect(toast.error).not.toHaveBeenCalled()
})
it("onError: popup_window_error → 팝업 차단 토스트", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
opts.onError(new AuthError("popup_window_error"))
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/팝업/))
})
it("onError: ApiError 401 → 인증 실패 안내 토스트", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
const err = new ApiError(401, "Microsoft 인증 실패", [], "AUTH_FAIL")
opts.onError(err)
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/Microsoft 인증에 실패/))
expect(invalidateQueries).not.toHaveBeenCalled()
})
it("onError: ApiError 501 → invalidateQueries + 비활성 안내 토스트", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
const err = new ApiError(501, "Entra 비활성", [], "ENTRA_NOT_CONFIGURED")
opts.onError(err)
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["entra-config"] })
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/비활성화/))
})
it("onError: 모르는 에러 → '로그인 오류: ...' 토스트", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
opts.onError(new Error("???"))
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/로그인 오류.*\?{3}/))
})
})
@@ -0,0 +1,80 @@
import { useNavigate, useSearchParams } from "react-router-dom"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { AuthError } from "@azure/msal-browser"
import { Button } from "@/shared/ui/button"
import { ApiError } from "@/lib/api/errors"
import { safeRedirectPath } from "../utils/safeRedirectPath"
import { useEntraLogin } from "../hooks/useEntraLogin"
export default function EntraLoginButton() {
const login = useEntraLogin()
const navigate = useNavigate()
const [params] = useSearchParams()
const queryClient = useQueryClient()
const onClick = () => {
const from = safeRedirectPath(params.get("from"))
login.mutate(undefined, {
onSuccess: () => navigate(from, { replace: true }),
onError: (err: unknown) => {
console.error("[EntraLogin] error:", err)
// ── MSAL 팝업/브라우저 에러 ──────────────────────────
if (err instanceof AuthError) {
const code = err.errorCode
if (code === "user_cancelled") return
if (code === "popup_window_error") {
toast.error("팝업이 차단되었습니다. 브라우저 설정을 확인하십시오.")
return
}
if (code === "interaction_in_progress") {
toast.error("로그인이 진행 중입니다. 잠시 후 다시 시도하십시오.")
return
}
if (code === "monitor_window_timeout" || code === "empty_window_error") {
toast.error("팝업 창이 닫혔습니다. 다시 시도하십시오.")
return
}
toast.error(`Microsoft 인증 오류: ${err.message}`)
return
}
// ── 구형 방식(errorCode 프로퍼티만 있는 객체) 호환 ──
const legacyCode = (err as { errorCode?: string } | null)?.errorCode
if (legacyCode === "user_cancelled") return
// ── 백엔드 API 에러 ──────────────────────────────────
if (err instanceof ApiError) {
if (err.status === 501) {
queryClient.invalidateQueries({ queryKey: ["entra-config"] })
toast.error("Microsoft 로그인이 서버에서 비활성화되어 있습니다.")
return
}
if (err.status === 401) {
toast.error("Microsoft 인증에 실패했습니다. 다시 시도하십시오.")
return
}
toast.error(err.message || "Microsoft 로그인 실패")
return
}
// ── 네트워크 또는 예상치 못한 에러 ──────────────────
const msg = err instanceof Error ? err.message : String(err)
toast.error(`로그인 오류: ${msg}`)
},
})
}
return (
<Button
type="button"
variant="outline"
onClick={onClick}
disabled={login.isPending}
className="w-full"
>
{login.isPending ? "로그인 중..." : "Microsoft로 로그인"}
</Button>
)
}
@@ -0,0 +1,36 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
import EntraLoginSection from "./EntraLoginSection"
let mockState = { enabled: false, isLoading: false }
vi.mock("../hooks/useEntraEnabled", () => ({
useEntraEnabled: () => mockState,
}))
vi.mock("./EntraLoginButton", () => ({
default: () => <button data-testid="entra-btn">stub</button>,
}))
beforeEach(() => {
mockState = { enabled: false, isLoading: false }
})
describe("EntraLoginSection", () => {
it("enabled=false → null 렌더", () => {
mockState = { enabled: false, isLoading: false }
const { container } = render(<EntraLoginSection />)
expect(container).toBeEmptyDOMElement()
})
it("isLoading=true → null 렌더", () => {
mockState = { enabled: false, isLoading: true }
const { container } = render(<EntraLoginSection />)
expect(container).toBeEmptyDOMElement()
})
it("enabled=true → 버튼 + divider '또는' 같이 노출", () => {
mockState = { enabled: true, isLoading: false }
render(<EntraLoginSection />)
expect(screen.getByTestId("entra-btn")).toBeInTheDocument()
expect(screen.getByText("또는")).toBeInTheDocument()
})
})
@@ -0,0 +1,21 @@
import EntraLoginButton from "./EntraLoginButton"
import { useEntraEnabled } from "../hooks/useEntraEnabled"
export default function EntraLoginSection() {
const { enabled, isLoading } = useEntraEnabled()
if (isLoading || !enabled) return null
return (
<>
<EntraLoginButton />
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2"></span>
</div>
</div>
</>
)
}
@@ -0,0 +1,89 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { fireEvent, render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { toast } from "sonner"
import { PATHS } from "@/config/routes"
import LoginForm from "./LoginForm"
vi.mock("sonner", () => ({ toast: { error: vi.fn(), info: vi.fn() } }))
const navigateMock = vi.fn()
vi.mock("react-router-dom", async (orig) => {
const m: any = await orig()
return { ...m, useNavigate: () => navigateMock }
})
const mutate = vi.fn()
vi.mock("../hooks/useLogin", () => ({
useLogin: () => ({ mutate, isPending: false }),
}))
function renderForm(search = "") {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[`/login${search}`]}>
<LoginForm />
</MemoryRouter>
</QueryClientProvider>
)
}
beforeEach(() => vi.clearAllMocks())
describe("LoginForm 회귀", () => {
it("이메일/비번 입력 + submit → useLogin.mutate 호출", async () => {
renderForm()
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
await userEvent.type(screen.getByLabelText("비번"), "secret")
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
expect(mutate).toHaveBeenCalledOnce()
expect(mutate.mock.calls[0][0]).toMatchObject({ email: "a@b.com", password: "secret" })
})
it("무효 이메일 → 에러 메시지 + mutate 호출 X", async () => {
const { container } = renderForm()
await userEvent.type(screen.getByLabelText("이메일"), "not-email")
await userEvent.type(screen.getByLabelText("비번"), "secret")
// type="email" 의 HTML5 native validation 이 jsdom 에서 submit 을 막아서
// userEvent.click(submit) 으로는 zod 까지 안 감 → form.submit 직접 발사
const form = container.querySelector("form")!
fireEvent.submit(form)
// "올바른 이메일을 입력하십시오" 에러 메시지 — Label "이메일"과 구분되도록 더 구체적인 regex
// zod resolver 가 async 라 findByText (대기) 사용
expect(await screen.findByText(/올바른 이메일/)).toBeInTheDocument()
expect(mutate).not.toHaveBeenCalled()
})
it("?expired=1 → 세션 만료 토스트 1회", async () => {
renderForm("?expired=1")
expect(toast.info).toHaveBeenCalledOnce()
expect(toast.info).toHaveBeenCalledWith(expect.stringMatching(/세션/))
})
it("from=/snap/new + 성공 → navigate('/snap/new')", async () => {
renderForm("?from=/snap/new")
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
await userEvent.type(screen.getByLabelText("비번"), "secret")
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
const [, opts] = mutate.mock.calls[0]
opts.onSuccess({})
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP_NEW, { replace: true })
})
it("from=외부 도메인 → navigate PATHS.SNAP (safeRedirectPath 가드)", async () => {
renderForm("?from=//evil.com")
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
await userEvent.type(screen.getByLabelText("비번"), "secret")
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
const [, opts] = mutate.mock.calls[0]
opts.onSuccess({})
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP, { replace: true })
})
})
@@ -0,0 +1,70 @@
import { useEffect, useRef } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { useNavigate, useSearchParams } from "react-router-dom"
import { toast } from "sonner"
import { Button } from "@/shared/ui/button"
import { Input } from "@/shared/ui/input"
import { Label } from "@/shared/ui/label"
import { loginSchema, type LoginInput } from "../schemas"
import { useLogin } from "../hooks/useLogin"
import { safeRedirectPath } from "../utils/safeRedirectPath"
import { ApiError } from "@/lib/api/errors"
import type { LoginRequest } from "@/types/api"
export default function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
})
const login = useLogin()
const navigate = useNavigate()
const [params] = useSearchParams()
const expiredToastedRef = useRef(false)
// ?expired=1 → 토스트 1회 (StrictMode 이중 렌더 가드)
useEffect(() => {
if (params.get("expired") === "1" && !expiredToastedRef.current) {
expiredToastedRef.current = true
toast.info("세션이 만료되어 로그아웃되었습니다. 다시 로그인하십시오.")
}
}, [params])
const onSubmit = (data: LoginInput) => {
const from = safeRedirectPath(params.get("from"))
login.mutate(data as LoginRequest, {
onSuccess: () => navigate(from, { replace: true }),
onError: (err) => {
const msg = err instanceof ApiError ? err.message : "로그인 실패"
toast.error(msg)
},
})
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-sm space-y-4">
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" type="email" autoComplete="email" {...register("email")} />
{errors.email && <p className="text-destructive text-sm">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
autoComplete="current-password"
{...register("password")}
/>
{errors.password && <p className="text-destructive text-sm">{errors.password.message}</p>}
</div>
<Button type="submit" disabled={login.isPending} className="w-full">
{login.isPending ? "로그인 중..." : "로그인"}
</Button>
</form>
)
}
@@ -0,0 +1,121 @@
import { useNavigate } from "react-router-dom"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Button } from "@/shared/ui/button"
import { Input } from "@/shared/ui/input"
import { Label } from "@/shared/ui/label"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog"
import { ApiError } from "@/lib/api/errors"
import { PATHS } from "@/config/routes"
import type { LoginRequest } from "@/types/api"
import { useSessionExpiryStore } from "../store/sessionExpiryStore"
import { loginSchema, type LoginInput } from "../schemas"
import { useLogin } from "../hooks/useLogin"
import { useAuthStore } from "../store/authStore"
/**
* 세션 만료(401 + refresh 실패) 시 자동으로 뜨는 모달.
*
* - 강제 닫기 불가(인증 안 된 상태로 화면 노출되면 안 되니까)
* - 인라인 재로그인 폼 → 성공하면 큐의 retry 발사 → 모달 닫힘
* - "로그아웃하고 로그인 페이지로" 옵션 — 큐 폐기 후 `/login?expired=1`로 이동
*
* 보호된 팔레트 화면을 감싸는 `PaletteShell`에 1회 마운트.
*/
export function SessionExpiryDialog() {
const open = useSessionExpiryStore((s) => s.open)
const cancel = useSessionExpiryStore((s) => s.cancel)
const closeAndFlush = useSessionExpiryStore((s) => s.closeAndFlush)
const clearUser = useAuthStore((s) => s.clearUser)
const login = useLogin()
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: { email: user?.email ?? "", password: "" },
})
const onSubmit = (data: LoginInput) => {
login.mutate(data as LoginRequest, {
onSuccess: async () => {
reset({ email: user?.email ?? "", password: "" })
await closeAndFlush()
},
onError: (err) => {
const msg = err instanceof ApiError ? err.message : "로그인 실패"
toast.error(msg)
},
})
}
const goToLogin = () => {
cancel()
clearUser()
navigate(`${PATHS.LOGIN}?expired=1`, { replace: true })
}
return (
<Dialog open={open}>
<DialogContent
// 외부 클릭·ESC로 닫기 차단 — 강제 재로그인 또는 명시적 로그아웃 두 길만
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
className="sm:max-w-sm"
>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="session-email"></Label>
<Input
id="session-email"
type="email"
autoComplete="email"
readOnly={!!user?.email}
{...register("email")}
/>
{errors.email && <p className="text-destructive text-sm">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="session-password"></Label>
<Input
id="session-password"
type="password"
autoComplete="current-password"
{...register("password")}
/>
{errors.password && (
<p className="text-destructive text-sm">{errors.password.message}</p>
)}
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="ghost" onClick={goToLogin}>
</Button>
<Button type="submit" disabled={login.isPending}>
{login.isPending ? "로그인 중..." : "다시 로그인"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,46 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ReactNode } from "react"
import { useEntraEnabled } from "./useEntraEnabled"
vi.mock("../api/auth.api", () => ({
authApi: { getEntraConfig: vi.fn() },
}))
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
beforeEach(() => vi.clearAllMocks())
describe("useEntraEnabled", () => {
it("config null → enabled=false, isLoading 결국 false", async () => {
const { authApi } = await import("../api/auth.api")
;(authApi.getEntraConfig as any).mockResolvedValue(null)
const { result } = renderHook(() => useEntraEnabled(), { wrapper })
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.enabled).toBe(false)
})
it("config 있음 → enabled=true", async () => {
const { authApi } = await import("../api/auth.api")
;(authApi.getEntraConfig as any).mockResolvedValue({
clientId: "c",
authority: "https://a",
tenantId: "t",
})
const { result } = renderHook(() => useEntraEnabled(), { wrapper })
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.enabled).toBe(true)
})
it("로딩 중 → isLoading=true, enabled=false", async () => {
const { authApi } = await import("../api/auth.api")
;(authApi.getEntraConfig as any).mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useEntraEnabled(), { wrapper })
expect(result.current.isLoading).toBe(true)
expect(result.current.enabled).toBe(false)
})
})
@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query"
import { authApi } from "../api/auth.api"
/**
* Entra SSO 활성 여부 — `/auth/entra/config` 응답으로 판단.
* - config 객체 있음 → enabled=true
* - config null (501) → enabled=false
* staleTime Infinity: 세션 동안 한 번만 받아옴.
*/
export function useEntraEnabled() {
const q = useQuery({
queryKey: ["entra-config"],
queryFn: () => authApi.getEntraConfig(),
staleTime: Infinity,
retry: false,
})
return {
enabled: q.data !== null && q.data !== undefined,
isLoading: q.isLoading,
}
}
@@ -0,0 +1,109 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { renderHook, waitFor, act } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ReactNode } from "react"
vi.mock("@/lib/auth/msal", () => ({
loginWithMicrosoft: vi.fn(),
}))
vi.mock("../api/auth.api", () => ({
authApi: { entraLogin: vi.fn() },
}))
vi.mock("../store/authStore", () => ({
useAuthStore: vi.fn(),
}))
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
beforeEach(() => vi.clearAllMocks())
describe("useEntraLogin", () => {
it("정상 흐름 → MSAL → entraLogin → setUser 순서 호출", async () => {
const setUser = vi.fn()
const { useAuthStore } = await import("../store/authStore")
;(useAuthStore as any).mockImplementation((sel: any) => sel({ setUser }))
const { loginWithMicrosoft } = await import("@/lib/auth/msal")
;(loginWithMicrosoft as any).mockResolvedValue({
idToken: "ID",
graphAccessToken: "G",
})
const user = {
id: "u1",
email: "a@b.com",
userName: "A",
role: "USER",
employeeId: "E",
department: "D",
authProvider: "entra",
}
const { authApi } = await import("../api/auth.api")
;(authApi.entraLogin as any).mockResolvedValue(user)
const { useEntraLogin } = await import("./useEntraLogin")
const { result } = renderHook(() => useEntraLogin(), { wrapper })
await act(async () => {
await result.current.mutateAsync()
})
expect(loginWithMicrosoft).toHaveBeenCalledOnce()
expect(authApi.entraLogin).toHaveBeenCalledWith({
idToken: "ID",
graphAccessToken: "G",
})
expect(setUser).toHaveBeenCalledWith(user)
})
it("graphAccessToken null → payload에서 graphAccessToken 누락(undefined)", async () => {
const setUser = vi.fn()
const { useAuthStore } = await import("../store/authStore")
;(useAuthStore as any).mockImplementation((sel: any) => sel({ setUser }))
const { loginWithMicrosoft } = await import("@/lib/auth/msal")
;(loginWithMicrosoft as any).mockResolvedValue({
idToken: "ID",
graphAccessToken: null,
})
const { authApi } = await import("../api/auth.api")
;(authApi.entraLogin as any).mockResolvedValue({ id: "u", email: "x" })
const { useEntraLogin } = await import("./useEntraLogin")
const { result } = renderHook(() => useEntraLogin(), { wrapper })
await act(async () => {
await result.current.mutateAsync()
})
expect(authApi.entraLogin).toHaveBeenCalledWith({
idToken: "ID",
graphAccessToken: undefined,
})
})
it("MSAL throw → mutation isError + setUser 호출 X", async () => {
const setUser = vi.fn()
const { useAuthStore } = await import("../store/authStore")
;(useAuthStore as any).mockImplementation((sel: any) => sel({ setUser }))
const { loginWithMicrosoft } = await import("@/lib/auth/msal")
const err: any = new Error("popup_window_error")
err.errorCode = "popup_window_error"
;(loginWithMicrosoft as any).mockRejectedValue(err)
const { useEntraLogin } = await import("./useEntraLogin")
const { result } = renderHook(() => useEntraLogin(), { wrapper })
await act(async () => {
try {
await result.current.mutateAsync()
} catch {
// mutation 실패 의도된 케이스
}
})
await waitFor(() => expect(result.current.isError).toBe(true))
expect(setUser).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,18 @@
import { useMutation } from "@tanstack/react-query"
import { loginWithMicrosoft } from "@/lib/auth/msal"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
export function useEntraLogin() {
const setUser = useAuthStore((s) => s.setUser)
return useMutation({
mutationFn: async () => {
const { idToken, graphAccessToken } = await loginWithMicrosoft()
return authApi.entraLogin({
idToken,
graphAccessToken: graphAccessToken ?? undefined,
})
},
onSuccess: (user) => setUser(user),
})
}
@@ -0,0 +1,12 @@
import { useMutation } from "@tanstack/react-query"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
import type { LoginRequest } from "@/types/api"
export function useLogin() {
const setUser = useAuthStore((s) => s.setUser)
return useMutation({
mutationFn: (input: LoginRequest) => authApi.login(input),
onSuccess: (user) => setUser(user),
})
}
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
/**
* 서버 logout(쿠키 삭제) → 클라 store 초기화 → react-query 캐시 정리.
*
* 서버 호출 실패해도 클라 상태는 비우는 것이 안전 (오프라인·네트워크 끊김 시).
*/
export function useLogout() {
const clearUser = useAuthStore((s) => s.clearUser)
const qc = useQueryClient()
return useMutation({
mutationFn: () => authApi.logout(),
onSettled: () => {
clearUser()
qc.clear()
},
})
}
@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
/**
* 서버 진실값(`/users/me`)으로 현재 user를 동기화.
* persist된 user(localStorage)는 UX 부트스트랩용일 뿐 — 마운트 시 이 훅으로 갱신.
*/
export function useMe(enabled = true) {
const user = useAuthStore((s) => s.user)
return useQuery({
queryKey: ["auth", "me"],
queryFn: () => authApi.getMe(),
enabled: enabled && user !== null,
staleTime: 5 * 60_000,
retry: false,
})
}
@@ -0,0 +1,57 @@
import { useEffect } from "react"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
const COOKIE_NAME = "accessTokenExp"
const REFRESH_BEFORE_MS = 60_000
function readExpCookie(): number | null {
if (typeof document === "undefined") return null
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`))
if (!match) return null
const n = Number(match[1])
return Number.isFinite(n) ? n : null
}
/**
* accessToken 만료 60초 전에 백그라운드 refresh.
*
* - 백엔드가 `accessTokenExp` non-httpOnly 쿠키로 만료 시각(ms epoch)을 노출
* - 인증된 동안만 동작. 로그아웃 시 자동 정리
* - refresh 실패 시 인터셉터가 401 + sessionExpiryStore 흐름으로 처리
*
* 보호된 팔레트 화면을 감싸는 `PaletteShell`에 1회만 마운트.
*/
export function useSlidingRefresh() {
const isAuthed = useAuthStore((s) => s.isAuthenticated())
useEffect(() => {
if (!isAuthed) return
let timerId: ReturnType<typeof setTimeout> | null = null
let cancelled = false
const schedule = () => {
const exp = readExpCookie()
if (exp === null) return // 쿠키 없으면 다음 cycle에서 다시 시도하지 않음
const delay = Math.max(0, exp - Date.now() - REFRESH_BEFORE_MS)
timerId = setTimeout(async () => {
if (cancelled) return
try {
await authApi.refresh()
} catch {
// 인터셉터가 401 → sessionExpiryStore로 이미 처리. 여기선 무시.
return
}
if (!cancelled) schedule()
}, delay)
}
schedule()
return () => {
cancelled = true
if (timerId !== null) clearTimeout(timerId)
}
}, [isAuthed])
}
+2
View File
@@ -0,0 +1,2 @@
export { default as LoginPage } from "./pages/LoginPage"
export { useAuthStore } from "./store/authStore"
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import LoginPage from "./LoginPage"
vi.mock("../components/EntraLoginSection", () => ({
default: () => <div data-testid="entra-section">stub</div>,
}))
vi.mock("../components/LoginForm", () => ({
default: () => <form data-testid="login-form">stub</form>,
}))
function renderPage() {
const qc = new QueryClient()
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<LoginPage />
</MemoryRouter>
</QueryClientProvider>
)
}
describe("LoginPage", () => {
it("EntraLoginSection + LoginForm 둘 다 마운트", () => {
renderPage()
expect(screen.getByTestId("entra-section")).toBeInTheDocument()
expect(screen.getByTestId("login-form")).toBeInTheDocument()
})
it("EntraLoginSection이 LoginForm보다 먼저 옴 (DOM 순서)", () => {
renderPage()
const section = screen.getByTestId("entra-section")
const form = screen.getByTestId("login-form")
expect(section.compareDocumentPosition(form) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it("h1 '로그인' 표시", () => {
renderPage()
expect(screen.getByRole("heading", { level: 1, name: "로그인" })).toBeInTheDocument()
})
it("로그인 입력 영역을 화면 가운데 정렬", () => {
const { container } = renderPage()
expect(container.firstElementChild).toHaveClass(
"flex",
"flex-1",
"items-center",
"justify-center"
)
})
})
@@ -0,0 +1,14 @@
import LoginForm from "../components/LoginForm"
import EntraLoginSection from "../components/EntraLoginSection"
export default function LoginPage() {
return (
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-sm space-y-6">
<h1 className="text-2xl font-semibold"></h1>
<EntraLoginSection />
<LoginForm />
</div>
</div>
)
}
@@ -0,0 +1,7 @@
import { z } from "zod"
export const loginSchema = z.object({
email: z.string().email("올바른 이메일을 입력하십시오"),
password: z.string().min(4, "최소 4자"),
})
export type LoginInput = z.infer<typeof loginSchema>
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach } from "vitest"
import { useAuthStore } from "./authStore"
const sampleUser = {
id: "u1",
email: "x@x.com",
userName: null,
role: "USER" as const,
employeeId: null,
department: null,
authProvider: "local" as const,
}
describe("authStore", () => {
beforeEach(() => {
useAuthStore.setState({ user: null })
localStorage.clear()
})
it("초기 상태: user null, isAuthenticated false", () => {
const s = useAuthStore.getState()
expect(s.user).toBeNull()
expect(s.isAuthenticated()).toBe(false)
})
it("setUser: user 저장 + isAuthenticated true", () => {
useAuthStore.getState().setUser(sampleUser)
expect(useAuthStore.getState().user).toEqual(sampleUser)
expect(useAuthStore.getState().isAuthenticated()).toBe(true)
})
it("clearUser: user null", () => {
useAuthStore.setState({ user: sampleUser })
useAuthStore.getState().clearUser()
expect(useAuthStore.getState().user).toBeNull()
expect(useAuthStore.getState().isAuthenticated()).toBe(false)
})
// anti-pattern 가드: token/accessToken/refreshToken 필드는 절대 추가하지 않음 (httpOnly 쿠키만 사용)
it("[anti-pattern] state에 token 관련 필드가 없음", () => {
const state = useAuthStore.getState() as unknown as Record<string, unknown>
expect(state).not.toHaveProperty("token")
expect(state).not.toHaveProperty("accessToken")
expect(state).not.toHaveProperty("refreshToken")
})
it("[anti-pattern] setUser 후 persist에 token이 포함되지 않음", () => {
useAuthStore.getState().setUser(sampleUser)
const raw = localStorage.getItem("auth-store")
expect(raw).toBeTruthy()
const parsed = JSON.parse(raw!) as { state: Record<string, unknown> }
expect(parsed.state).not.toHaveProperty("token")
expect(parsed.state).not.toHaveProperty("accessToken")
expect(parsed.state).not.toHaveProperty("refreshToken")
expect(parsed.state.user).toEqual(sampleUser)
})
})
@@ -0,0 +1,34 @@
import { create } from "zustand"
import { persist, createJSONStorage } from "zustand/middleware"
import type { UserPayload } from "@/types/api"
/**
* 인증 상태. **토큰은 절대 저장하지 않음** (httpOnly 쿠키만 사용).
*
* `token`/`accessToken`/`refreshToken` 필드 추가 금지 — anti-pattern test로 가드.
*
* `user`만 localStorage에 영속 (UX용. 서버는 매번 쿠키로 인증).
* 마운트 시 `useMe()` 훅으로 서버 진실값과 동기화하면 안전.
*/
interface AuthState {
user: UserPayload | null
isAuthenticated: () => boolean
setUser: (user: UserPayload | null) => void
clearUser: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
isAuthenticated: () => get().user !== null,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
}),
{
name: "auth-store",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ user: state.user }),
}
)
)
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import { useSessionExpiryStore } from "./sessionExpiryStore"
describe("sessionExpiryStore", () => {
beforeEach(() => {
useSessionExpiryStore.setState({ open: false, queue: [] })
})
it("초기 상태: 닫힘 + 큐 비어있음", () => {
const s = useSessionExpiryStore.getState()
expect(s.open).toBe(false)
expect(s.queue).toEqual([])
})
it("openDialog: open=true", () => {
useSessionExpiryStore.getState().openDialog()
expect(useSessionExpiryStore.getState().open).toBe(true)
})
it("pushFailure: retry를 큐에 push + open=true + 호출자 promise 보관", () => {
const retry = vi.fn().mockResolvedValue("ok")
const promise = useSessionExpiryStore.getState().pushFailure(retry)
const state = useSessionExpiryStore.getState()
expect(state.open).toBe(true)
expect(state.queue.length).toBe(1)
expect(retry).not.toHaveBeenCalled() // flush 전엔 호출 안 됨
// dangling promise 정리
state.cancel()
promise.catch(() => {})
})
it("closeAndFlush: 큐에 쌓인 retry 모두 실행 + open=false + queue=[]", async () => {
const retry1 = vi.fn().mockResolvedValue("a")
const retry2 = vi.fn().mockResolvedValue("b")
const p1 = useSessionExpiryStore.getState().pushFailure(retry1)
const p2 = useSessionExpiryStore.getState().pushFailure(retry2)
await useSessionExpiryStore.getState().closeAndFlush()
expect(retry1).toHaveBeenCalledTimes(1)
expect(retry2).toHaveBeenCalledTimes(1)
await expect(p1).resolves.toBe("a")
await expect(p2).resolves.toBe("b")
const state = useSessionExpiryStore.getState()
expect(state.open).toBe(false)
expect(state.queue).toEqual([])
})
it("flush 중 retry 한 건이 실패해도 나머지 진행", async () => {
const retry1 = vi.fn().mockResolvedValue("a")
const retry2 = vi.fn().mockRejectedValue(new Error("boom"))
const retry3 = vi.fn().mockResolvedValue("c")
const p1 = useSessionExpiryStore.getState().pushFailure(retry1)
const p2 = useSessionExpiryStore.getState().pushFailure(retry2)
const p3 = useSessionExpiryStore.getState().pushFailure(retry3)
await useSessionExpiryStore.getState().closeAndFlush()
await expect(p1).resolves.toBe("a")
await expect(p2).rejects.toThrow("boom")
await expect(p3).resolves.toBe("c")
})
it("cancel: open=false + 큐 폐기 (호출자 promise는 dangling)", () => {
const retry = vi.fn()
useSessionExpiryStore.getState().pushFailure(retry)
useSessionExpiryStore.getState().cancel()
const state = useSessionExpiryStore.getState()
expect(state.open).toBe(false)
expect(state.queue).toEqual([])
expect(retry).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,52 @@
import { create } from "zustand"
/**
* 세션 만료(401 + refresh 실패) 감지 시 모달 띄움.
* 만료 시점에 진행 중이던 요청은 큐에 보관 → 재로그인 성공하면 일괄 retry.
*
* 이 store는 user 정보를 가지지 않음. 인증 상태는 `authStore`에 분리.
*/
export type RetryFn = () => Promise<unknown>
interface SessionExpiryState {
open: boolean
/** refresh 실패로 reject 직전에 대기 중인 요청들의 retry 함수 큐. */
queue: RetryFn[]
/** 모달 열기. 동일 사이클에서 여러 401이 와도 1회만 열림. */
openDialog: () => void
/** 재로그인 성공 시: 큐 전부 retry → resolve/reject 각자에게 위임 → 모달 닫음. */
closeAndFlush: () => Promise<void>
/** 사용자가 명시적으로 모달 닫기(취소·로그아웃 등). 큐도 폐기. */
cancel: () => void
/** 401 + refresh 실패 직전에 retry 함수 push. 호출자는 반환된 promise로 결과 받음. */
pushFailure: (retry: RetryFn) => Promise<unknown>
}
export const useSessionExpiryStore = create<SessionExpiryState>((set, get) => ({
open: false,
queue: [],
openDialog: () => set({ open: true }),
cancel: () => set({ open: false, queue: [] }),
closeAndFlush: async () => {
const { queue } = get()
set({ open: false, queue: [] })
// 각 retry는 독립 실행 — 한 건 실패해도 나머지 진행
await Promise.allSettled(queue.map((fn) => fn()))
},
pushFailure: (retry) => {
return new Promise((resolve, reject) => {
const wrapped: RetryFn = async () => {
try {
const result = await retry()
resolve(result)
return result
} catch (err) {
reject(err)
throw err
}
}
set((s) => ({ queue: [...s.queue, wrapped], open: true }))
})
},
}))
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest"
import { safeRedirectPath } from "./safeRedirectPath"
import { PATHS } from "@/config/routes"
describe("safeRedirectPath", () => {
it("null → SNAP", () => {
expect(safeRedirectPath(null)).toBe(PATHS.SNAP)
})
it("빈 문자열 → SNAP", () => {
expect(safeRedirectPath("")).toBe(PATHS.SNAP)
})
it("'/'로 시작 안 함 → SNAP", () => {
expect(safeRedirectPath("snap/new")).toBe(PATHS.SNAP)
})
it("'//' protocol-relative URL → SNAP", () => {
expect(safeRedirectPath("//evil.com/path")).toBe(PATHS.SNAP)
})
it("정상 절대 경로 → 그대로 반환", () => {
expect(safeRedirectPath(PATHS.SNAP_NEW)).toBe(PATHS.SNAP_NEW)
})
it("쿼리스트링 포함된 경로 → 그대로", () => {
expect(safeRedirectPath("/users?page=2")).toBe("/users?page=2")
})
})
@@ -0,0 +1,12 @@
import { PATHS } from "@/config/routes"
/**
* 로그인 후 redirect할 path 검증.
* - 반드시 `/`로 시작하는 상대 경로
* - `//` 시작은 protocol-relative URL이라 외부 도메인으로 빠질 수 있어 차단
*/
export function safeRedirectPath(raw: string | null): string {
if (!raw) return PATHS.SNAP
if (!raw.startsWith("/") || raw.startsWith("//")) return PATHS.SNAP
return raw
}
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useSessionList, useSessionMessages } from "./snap.api"
import * as client from "@/lib/api/client"
vi.mock("@/lib/api/client")
function wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
const SESSION = {
id: "s1",
title: "t",
titleLlm: null,
isGenerating: false,
createdAt: "2026-07-18T00:00:00Z",
updatedAt: "2026-07-18T00:00:00Z",
}
describe("snap.api (real)", () => {
beforeEach(() => vi.clearAllMocks())
it("useSessionList 는 GET /chat/sessions 를 페이지네이션으로 호출", async () => {
vi.mocked(client.apiList).mockResolvedValue({ items: [SESSION], meta: null, counts: 1 })
const { result } = renderHook(() => useSessionList(1), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(client.apiList).toHaveBeenCalledWith("/chat/sessions", {
params: { page: 1, limit: 3 },
})
expect(result.current.data!.items[0].id).toBe("s1")
})
it("useSessionMessages 는 GET /chat/sessions/{id}/messages 를 호출", async () => {
vi.mocked(client.apiGet).mockResolvedValue({ ...SESSION, messages: [] })
const { result } = renderHook(() => useSessionMessages("s1"), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(client.apiGet).toHaveBeenCalledWith("/chat/sessions/s1/messages")
expect(result.current.data!.messages).toEqual([])
})
})
@@ -0,0 +1,56 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"
import { apiGet, apiList, apiPost } from "@/lib/api/client"
import type { SnapMessage, SnapSession, SnapSessionDetail } from "../contract/types"
const PAGE_SIZE = 3 // 첫 화면 "최근 진행 대화"는 3개만 peek — 나머지는 검색으로
const SEARCH_PAGE_SIZE = 20
// 세션 목록 — 서버 페이지네이션. page 단위로 목록 교체(더보기 append 아님).
export function useSessionList(page: number) {
return useQuery({
queryKey: ["snap", "sessions", page],
queryFn: () => apiList<SnapSession>("/chat/sessions", { params: { page, limit: PAGE_SIZE } }),
placeholderData: keepPreviousData,
// 전역 staleTime 30s 끔 — 새 세션 만들고 30초 내 목록 복귀 시 캐시가 그대로 나와
// "방금 만든 세션이 목록에 없음"이 되던 원인. 목록 진입마다 refetch.
staleTime: 0,
})
}
// 메시지 본문 검색 — 서버 /chat/sessions/search (ILIKE, 소유 세션 전체 대상). 매칭된 메시지 반환.
export function useSearchMessages(query: string, page: number) {
return useQuery({
queryKey: ["snap", "search", query, page],
enabled: query.trim().length > 0,
staleTime: 0, // 같은 검색어 재검색 시에도 최신 메시지 반영
queryFn: () =>
apiList<SnapMessage>("/chat/sessions/search", {
params: { query, page, limit: SEARCH_PAGE_SIZE },
}),
placeholderData: keepPreviousData,
})
}
export function useSessionMessages(id: string) {
return useQuery({
queryKey: ["snap", "session", id],
enabled: !!id,
queryFn: (): Promise<SnapSessionDetail> =>
apiGet<SnapSessionDetail>(`/chat/sessions/${id}/messages`),
// 전역 staleTime 30s 를 끔 — 대화 내용은 스트리밍으로 계속 바뀌어서 30초 캐시가
// "재진입하면 빈/옛 대화" 버그의 뿌리였음. 재진입(마운트)마다 무조건 refetch.
staleTime: 0,
// 생성 중이면(스트림을 이 탭에서 잃었어도) 백엔드가 끝내는 순간 답변이 DB 에 뜨므로
// 그때까지 폴링 → 완료되면 isGenerating=false 로 폴링 멈춤. 재진입 시 답변 유실 자가복구.
refetchInterval: (query) => (query.state.data?.isGenerating ? 1500 : false),
})
}
export function useCreateSession() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (): Promise<SnapSession> => apiPost<SnapSession>("/chat/sessions", {}),
// 생성 즉시 목록 캐시 무효화 — 목록으로 돌아가면 새 세션이 바로 보이게.
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["snap", "sessions"] }),
})
}
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { snapStream, cancelStream } from "./snap.stream"
import * as streaming from "@/lib/streaming"
import * as client from "@/lib/api/client"
vi.mock("@/lib/streaming", () => ({ streamLLM: vi.fn() }))
vi.mock("@/lib/api/client", () => ({ apiPost: vi.fn() }))
describe("snapStream (real)", () => {
beforeEach(() => vi.clearAllMocks())
it("streamLLM 을 /chat/stream 계약으로 호출한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockResolvedValue(undefined)
await snapStream({ sessionId: "s1", content: "hi" }, { onToken: vi.fn(), onDone: vi.fn() })
expect(streamLLM).toHaveBeenCalledTimes(1)
const arg = streamLLM.mock.calls[0][0]
expect(arg.path).toBe("/chat/stream")
expect(arg.body).toEqual({ sessionId: "s1", content: "hi" })
})
it("이미지 계약을 요청 body에 그대로 전달한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockResolvedValue(undefined)
const images = [{ mediaType: "image/png" as const, data: "data:image/png;base64,eA==" }]
await snapStream(
{ sessionId: "s1", content: "", images },
{ onToken: vi.fn(), onDone: vi.fn() }
)
expect(streamLLM.mock.calls[0][0].body).toEqual({ sessionId: "s1", content: "", images })
})
it("onToken/onTitle/onDone/onError 를 그대로 배선한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockImplementation(
async (opts: {
handlers: {
onToken: (d: string) => void
onTitle?: (t: string) => void
onDone: (p: object) => void
}
}) => {
opts.handlers.onToken("a")
opts.handlers.onTitle?.("제목")
opts.handlers.onDone({})
}
)
const onToken = vi.fn()
const onTitle = vi.fn()
const onDone = vi.fn()
await snapStream({ sessionId: "s1", content: "x" }, { onToken, onDone, onTitle })
expect(onToken).toHaveBeenCalledWith("a")
expect(onTitle).toHaveBeenCalledWith("제목")
expect(onDone).toHaveBeenCalledTimes(1)
})
})
describe("cancelStream", () => {
beforeEach(() => vi.clearAllMocks())
it("POST /chat/sessions/{id}/cancel 를 호출한다", async () => {
vi.mocked(client.apiPost).mockResolvedValue(null)
await cancelStream("s1")
expect(client.apiPost).toHaveBeenCalledWith("/chat/sessions/s1/cancel", {})
})
it("실패해도 throw 하지 않는다(best-effort)", async () => {
vi.mocked(client.apiPost).mockRejectedValue(new Error("404"))
await expect(cancelStream("s1")).resolves.toBeUndefined()
})
})
@@ -0,0 +1,41 @@
import { streamLLM, type LLMUsagePayload } from "@/lib/streaming"
import { apiPost } from "@/lib/api/client"
import type { SnapStreamRequest } from "../contract/types"
export interface SnapStreamHandlers {
onToken: (delta: string) => void
onDone: () => void
onTitle?: (title: string) => void
onUsage?: (usage: LLMUsagePayload) => void
onError?: (e: Error) => void
}
// base-backend POST /chat/stream (SSE) 직결. token/done/error/title 이벤트 소비.
export function snapStream(
req: SnapStreamRequest,
handlers: SnapStreamHandlers,
opts?: { signal?: AbortSignal }
): Promise<void> {
return streamLLM({
path: "/chat/stream",
body: req,
signal: opts?.signal,
handlers: {
onToken: handlers.onToken,
onDone: () => handlers.onDone(),
onTitle: handlers.onTitle,
onUsage: handlers.onUsage,
onError: handlers.onError,
},
})
}
// 백엔드에 생성 취소를 알린다(best-effort). 엔드포인트 미구현/실패면 조용히 무시 —
// 로컬 stop(abort+freeze)은 호출 측에서 이미 적용됨.
export async function cancelStream(sessionId: string): Promise<void> {
try {
await apiPost(`/chat/sessions/${sessionId}/cancel`, {})
} catch {
// 취소 엔드포인트 아직 없거나 실패 — degrade. 백엔드는 기존대로 끝까지 생성.
}
}
@@ -0,0 +1,54 @@
import { ChevronLeft } from "lucide-react"
import { useShallow } from "zustand/react/shallow"
import { Kbd } from "@/shared/components/Kbd"
import type { SnapSession } from "../contract/types"
import { useSnapChatStore } from "../store/snapChatStore"
import { fmtTokens } from "../lib/format"
interface Props {
session: SnapSession
onBack: () => void
}
export function ChatHeader({ session, onBack }: Props) {
const title = session.titleLlm ?? session.title ?? "새 대화 세션"
const { used, limit } = useSnapChatStore(
useShallow((s) => ({ used: s.sessionUsed, limit: s.sessionLimit }))
)
const ratio = limit > 0 ? Math.min(1, used / limit) : 0
// 게이지 색 — 90%↑ 빨강, 70%↑ 주황, 그 외 기본.
const barColor = ratio >= 0.9 ? "bg-red-500" : ratio >= 0.7 ? "bg-amber-500" : "bg-primary"
return (
<div className="border-border bg-card flex flex-none items-center gap-2 border-b px-3 py-2.5">
<button
type="button"
onClick={onBack}
title="목록으로 (Esc)"
className="border-border bg-background text-muted-foreground hover:border-ring hover:text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[11px]"
>
<ChevronLeft className="size-3" />
<Kbd>Esc</Kbd>
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-serif text-base font-semibold italic">{title}</div>
<div className="text-muted-foreground font-mono text-[9.5px] tracking-wide uppercase">
{(session.tag ?? "SESSION").toUpperCase()} ·
</div>
</div>
{/* 세션 토큰 게이지 — 현재 점유 / 한도 */}
<div
className="flex flex-none flex-col items-end gap-1"
title={`${used.toLocaleString()} / ${limit.toLocaleString()} tokens`}
>
<span className="text-muted-foreground font-mono text-[10px] tabular-nums">
{fmtTokens(used)}
<span className="text-muted-foreground/50"> / {fmtTokens(limit)}</span>
</span>
<div className="bg-border h-1 w-24 overflow-hidden rounded-full">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${ratio * 100}%` }} />
</div>
</div>
</div>
)
}
@@ -0,0 +1,232 @@
import { useEffect, useRef, useState } from "react"
import { get, set } from "idb-keyval"
import { Clipboard, Type } from "lucide-react"
import { toast } from "sonner"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip"
// IndexedDB 에 저장하는 형태 — 이미지는 Blob 그대로(base64 불필요, 용량 절약).
type StoredItem =
| { id: string; kind: "text"; value: string }
| { id: string; kind: "image"; blob: Blob }
// 렌더용 — 이미지는 <img> 에 물릴 object URL 을 얹은 형태.
type ClipItem =
| { id: string; kind: "text"; value: string }
| { id: string; kind: "image"; blob: Blob; url: string }
const STORE_KEY = "snap-clip-history"
const CAP = 30 // 최대 저장 개수. 화면은 스크롤로 ~10개 보이고 나머지는 굴려서.
function newId(): string {
return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : String(Date.now())
}
// 저장형 → 렌더형(이미지에 object URL 부여). 반대는 url 만 떼면 됨.
function toClip(stored: StoredItem[]): ClipItem[] {
return stored.map((i) => (i.kind === "image" ? { ...i, url: URL.createObjectURL(i.blob) } : i))
}
function toStored(items: ClipItem[]): StoredItem[] {
return items.map((i) => (i.kind === "image" ? { id: i.id, kind: "image", blob: i.blob } : i))
}
export function ClipboardHistory() {
const [items, setItems] = useState<ClipItem[]>([])
// 마운트 시 IndexedDB 에서 로드. hydrate 전엔 persist 를 막아 빈 배열로 덮어쓰는 레이스 방지.
const hydrated = useRef(false)
useEffect(() => {
let cancelled = false
get<StoredItem[]>(STORE_KEY)
.then((stored) => {
if (!cancelled && stored?.length) setItems(toClip(stored))
})
.finally(() => {
hydrated.current = true
})
return () => {
cancelled = true
}
}, [])
// 이력 변경 시 IndexedDB 에 통째로 저장(텍스트+이미지 blob 둘 다 로컬 영속).
useEffect(() => {
if (!hydrated.current) return
set(STORE_KEY, toStored(items)).catch(() => {
// storage 실패 — degrade
})
}, [items])
// 새 clip 을 맨 앞에. 직전과 같은 텍스트면 스킵(중복 방지). CAP 초과분은 잘라내며 object URL 정리.
const add = (item: ClipItem) =>
setItems((prev) => {
if (item.kind === "text" && prev[0]?.kind === "text" && prev[0].value === item.value)
return prev
const next = [item, ...prev]
for (const dropped of next.slice(CAP)) {
if (dropped.kind === "image") URL.revokeObjectURL(dropped.url)
}
return next.slice(0, CAP)
})
// 클립보드 소스 2입구(paste 폴백 + 네이티브 postMessage).
// 계약: { type: "clipboard", payload: {kind:"text", value} | {kind:"image", dataUrl} }
const addRef = useRef(add)
addRef.current = add
useEffect(() => {
const imageItem = (blob: Blob): ClipItem => ({
id: newId(),
kind: "image",
blob,
url: URL.createObjectURL(blob),
})
const onPaste = (e: ClipboardEvent) => {
const dt = e.clipboardData
if (!dt) return
for (let i = 0; i < dt.items.length; i++) {
if (dt.items[i].type.startsWith("image/")) {
const blob = dt.items[i].getAsFile()
if (!blob) return
addRef.current(imageItem(blob))
return
}
}
const text = dt.getData("text/plain")
if (text) addRef.current({ id: newId(), kind: "text", value: text })
}
const onMessage = (e: MessageEvent) => {
const d = e.data
if (!d || d.type !== "clipboard") return
const p = d.payload
if (p?.kind === "image" && typeof p.dataUrl === "string") {
// data URL → Blob 로 변환해 저장(paste 와 동일 취급).
fetch(p.dataUrl)
.then((r) => r.blob())
.then((blob) => addRef.current(imageItem(blob)))
.catch(() => {})
} else if (p?.kind === "text" && typeof p.value === "string") {
addRef.current({ id: newId(), kind: "text", value: p.value })
}
}
document.addEventListener("paste", onPaste)
window.addEventListener("message", onMessage)
return () => {
document.removeEventListener("paste", onPaste)
window.removeEventListener("message", onMessage)
}
}, [])
// 언마운트 시 남은 object URL 정리.
const itemsRef = useRef(items)
itemsRef.current = items
useEffect(
() => () => {
for (const it of itemsRef.current) {
if (it.kind === "image") URL.revokeObjectURL(it.url)
}
},
[]
)
// 이력 클릭 → OS 클립보드로 다시 복사(다른 앱에 붙이게).
const copyText = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
toast.success("클립보드에 복사됨")
} catch {
toast.error("복사 실패")
}
}
const copyImage = async (blob: Blob) => {
try {
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])
toast.success("이미지 클립보드에 복사됨")
} catch {
toast.error("이미지 복사 실패")
}
}
return (
<div className="border-border flex max-h-72 flex-none flex-col border-b">
<div className="flex flex-none items-center gap-1.5 px-3 py-2.5">
<Clipboard className="text-muted-foreground size-3" />
<span className="text-muted-foreground font-mono text-[10px] tracking-widest uppercase">
Clipboard
</span>
{items.length > 0 && (
<span className="text-muted-foreground/60 ml-auto font-mono text-[9px]">
{items.length}
</span>
)}
</div>
{items.length === 0 ? (
<p className="border-border text-muted-foreground/60 mx-2.5 mb-2.5 rounded border border-dashed px-2 py-3 text-center font-mono text-[10px] leading-relaxed">
Ctrl+V
<br />
</p>
) : (
<TooltipProvider delayDuration={200}>
<div className="flex flex-col gap-1 overflow-y-auto px-1.5 pb-1.5">
{items.map((it) =>
it.kind === "image" ? (
<Tooltip key={it.id}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => copyImage(it.blob)}
title="클릭하면 이미지 클립보드에 복사"
className="border-border hover:border-ring relative overflow-hidden rounded border"
>
<img
src={it.url}
alt="클립보드 이미지"
className="bg-muted/40 max-h-20 w-full object-contain"
/>
<span className="bg-background/80 text-muted-foreground absolute top-1 right-1 rounded px-1 font-mono text-[8px]">
IMG
</span>
</button>
</TooltipTrigger>
{/* hover 미리보기 — 큰 이미지 */}
<TooltipContent side="right" align="start" className="p-1">
<img
src={it.url}
alt="클립보드 이미지 미리보기"
className="max-h-64 max-w-xs rounded object-contain"
/>
</TooltipContent>
</Tooltip>
) : (
<Tooltip key={it.id}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => copyText(it.value)}
title="클릭하면 클립보드에 복사"
className="hover:bg-accent flex items-start gap-1.5 rounded px-2 py-1.5 text-left"
>
<Type className="text-muted-foreground/70 mt-0.5 size-3 flex-none" />
<span className="text-foreground/80 line-clamp-2 font-mono text-[10px] leading-snug break-words whitespace-pre-wrap">
{it.value.slice(0, 160)}
</span>
</button>
</TooltipTrigger>
{/* hover 미리보기 — 전체 텍스트(길면 스크롤) */}
<TooltipContent
side="right"
align="start"
className="max-h-72 max-w-md overflow-auto"
>
<pre className="font-mono text-[11px] leading-snug break-words whitespace-pre-wrap">
{it.value}
</pre>
</TooltipContent>
</Tooltip>
)
)}
</div>
</TooltipProvider>
)}
</div>
)
}
@@ -0,0 +1,163 @@
import { useMemo, useState } from "react"
import { Check, ClipboardPaste, Copy } from "lucide-react"
import { toast } from "sonner"
import { isWebView, pasteToApp } from "@/lib/bridge/webviewBridge"
import hljs from "highlight.js/lib/core"
import sql from "highlight.js/lib/languages/sql"
import json from "highlight.js/lib/languages/json"
import diff from "highlight.js/lib/languages/diff"
import yaml from "highlight.js/lib/languages/yaml"
import plaintext from "highlight.js/lib/languages/plaintext"
import abap from "./abapHljs"
import "highlight.js/styles/github-dark.css"
import "./abapLight.css"
// 코어 빌드에 필요한 언어만 등록(번들 최소화). ABAP 은 커스텀 문법.
hljs.registerLanguage("sql", sql)
hljs.registerLanguage("json", json)
hljs.registerLanguage("diff", diff)
hljs.registerLanguage("yaml", yaml)
hljs.registerLanguage("plaintext", plaintext)
hljs.registerLanguage("abap", abap)
// 펜스 언어 라벨 → 등록된 문법 매핑.
const ALIAS: Record<string, string> = {
cds: "abap",
ddl: "abap",
abapsql: "abap",
sqlscript: "sql",
text: "plaintext",
txt: "plaintext",
yml: "yaml",
}
function escapeHtml(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c] ?? c)
}
interface Props {
code: string
lang?: string
/** NavRail 점프 대상 식별용 인덱스. */
index?: number
/** 주변 UI가 제목과 액션을 담당하는 독립 프리뷰에서는 코드만 표시함. */
plain?: boolean
}
export function CodeBlock({ code, lang, index, plain = false }: Props) {
const key = (lang ?? "").toLowerCase()
const resolved = ALIAS[key] ?? key
// ABAP 은 SAP 에디터처럼 밝은 배경으로 렌더 — 나머지 언어는 어두운 테마 유지.
const isAbap = resolved === "abap"
// 언어 없는 펜스(ASCII 다이어그램 등)도 밝게 — 어두운 코드 테마는 언어 지정 블록만.
const isLight = isAbap || resolved === ""
const html = useMemo(() => {
if (resolved && hljs.getLanguage(resolved)) {
return hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value
}
return escapeHtml(code)
}, [code, resolved])
const lineCount = useMemo(() => code.replace(/\n$/, "").split("\n").length, [code])
return (
<div
data-code-block={index ?? 0}
data-code-lang={lang ?? "code"}
className={
plain
? `h-full min-h-0 overflow-hidden ${isLight ? "bg-white text-zinc-900" : "bg-[#0d1117] text-zinc-100"}`
: isLight
? "my-2.5 overflow-hidden rounded-lg border border-zinc-300 bg-white text-zinc-900 shadow-sm"
: "my-2.5 overflow-hidden rounded-lg border border-zinc-700/60 bg-[#0d1117] text-zinc-100 shadow-sm"
}
>
{!plain && (
<div
className={
isLight
? "flex items-center gap-2 border-b border-zinc-200 bg-zinc-50 px-3 py-1.5"
: "flex items-center gap-2 border-b border-white/5 bg-white/[0.03] px-3 py-1.5"
}
>
<span className="flex gap-1">
<span className="size-2 rounded-full bg-rose-400/70" />
<span className="size-2 rounded-full bg-amber-400/70" />
<span className="size-2 rounded-full bg-emerald-400/70" />
</span>
<span
className={
isLight
? "ml-1 font-mono text-[10px] tracking-wider text-zinc-500 uppercase"
: "ml-1 font-mono text-[10px] tracking-wider text-zinc-400 uppercase"
}
>
#<span data-code-num>{(index ?? 0) + 1}</span> · {lang ?? "code"}
</span>
<CodeActions code={code} isLight={isLight} />
</div>
)}
<div
className={`flex overflow-auto text-xs leading-relaxed ${plain ? "h-full" : "max-h-96"}`}
>
<div
aria-hidden
className={
isLight
? "flex-none border-r border-zinc-200 px-2.5 py-3 text-right font-mono text-zinc-400 select-none"
: "flex-none border-r border-white/5 px-2.5 py-3 text-right font-mono text-zinc-600 select-none"
}
>
{Array.from({ length: lineCount }, (_, i) => (
<div key={i}>{i + 1}</div>
))}
</div>
<pre className="min-w-0 flex-1 px-3 py-3 font-mono whitespace-pre">
<code
className={isAbap ? "hljs-abap-light" : undefined}
dangerouslySetInnerHTML={{ __html: html }}
/>
</pre>
</div>
</div>
)
}
/** 코드 카드와 스니펫 하단에서 같은 복사·붙여넣기 동작을 재사용함. */
export function CodeActions({ code, isLight = true }: { code: string; isLight?: boolean }) {
const [copied, setCopied] = useState(false)
// 헤더 버튼 공통 스타일(복사·붙여넣기 공유). ml-auto 는 감싸는 컨테이너가 가짐.
const btnClass = isLight
? "inline-flex items-center gap-1 rounded border border-zinc-300 px-2 py-0.5 font-mono text-[10px] text-zinc-600 transition-colors hover:border-zinc-400 hover:text-zinc-900"
: "inline-flex items-center gap-1 rounded border border-zinc-700 px-2 py-0.5 font-mono text-[10px] text-zinc-300 transition-colors hover:border-zinc-500 hover:text-white"
const copy = async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
toast.success("클립보드에 복사됨")
setTimeout(() => setCopied(false), 1400)
} catch {
toast.error("복사 실패")
}
}
return (
<div className="ml-auto flex items-center gap-1.5">
{isWebView() && (
<button
type="button"
onClick={() => pasteToApp(code)}
className={btnClass}
title="런처 소환 직전 앱에 붙여넣기(붙이고 창은 자동으로 숨김)"
>
<ClipboardPaste className="size-3" />
</button>
)}
<button type="button" onClick={copy} className={btnClass}>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? "복사됨" : "복사"}
</button>
</div>
)
}
@@ -0,0 +1,82 @@
import { describe, expect, it, afterEach, vi } from "vitest"
import { render, screen, cleanup, act, fireEvent } from "@testing-library/react"
import { Composer } from "./Composer"
import { initBridgeNavigate, consumePendingCaptureImage } from "@/lib/bridge/bridgeNavigate"
type Listener = (e: MessageEvent) => void
function mockWebview() {
let listener: Listener | undefined
;(window as unknown as { chrome?: unknown }).chrome = {
webview: {
postMessage: () => {},
addEventListener: (type: string, cb: Listener) => {
if (type === "message") listener = cb
},
},
}
return { emit: (data: unknown) => listener?.({ data } as MessageEvent) }
}
describe("Composer 캡쳐 이미지 첨부", () => {
afterEach(() => {
cleanup()
delete (window as unknown as { chrome?: unknown }).chrome
})
// 코드리뷰 Critical 회귀 재현: onCapture 가 이벤트 detail 만 읽고 pendingCaptureImage 를
// 안 비우면, 리마운트(다른 대화 갔다가 새 대화 재진입) 시 마운트 이펙트가 옛 캡쳐를 재소비함.
it("리마운트 시 이미 소비된 캡쳐 이미지를 다시 첨부하지 않음", () => {
const wv = mockWebview()
initBridgeNavigate()
const { unmount } = render(<Composer onSend={() => {}} acceptCapture />)
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,abc" }))
expect(screen.getAllByRole("img")).toHaveLength(1)
unmount()
render(<Composer onSend={() => {}} acceptCapture />) // 다른 대화로 이동했다가 새 대화 재진입 시뮬레이션
expect(screen.queryAllByRole("img")).toHaveLength(0)
})
// 코드리뷰 Important 회귀 재현: 기존 대화방(SessionChatPage, acceptCapture 없음)이 살아있는 채로
// capture.image 가 오면, 리마운트 전에 그 Composer가 pending을 훔쳐가 새 대화 Composer가 못 받음.
it("acceptCapture 없으면 capture 이벤트를 무시하고 pending을 안 건드림(출발지 도둑질 방지)", () => {
const wv = mockWebview()
initBridgeNavigate()
render(<Composer onSend={() => {}} />) // acceptCapture 없음 — 기존 대화방 시뮬레이션
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,xyz" }))
expect(screen.queryAllByRole("img")).toHaveLength(0) // 여기엔 안 붙음
// pending이 안 비워졌어야 — 곧 마운트될 새 대화 Composer가 그대로 소비 가능해야 함
expect(consumePendingCaptureImage()).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==" }))
fireEvent.click(screen.getByRole("button", { name: "전송" }))
expect(onSend).toHaveBeenCalledWith("", [
{ mediaType: "image/png", data: "data:image/png;base64,eA==" },
])
expect(screen.queryAllByRole("img")).toHaveLength(0)
})
it("이미지는 최대 4장까지만 첨부한다", () => {
const wv = mockWebview()
initBridgeNavigate()
render(<Composer onSend={() => {}} acceptCapture />)
for (let i = 0; i < 5; i++) {
act(() => wv.emit({ type: "capture.image", dataUrl: `data:image/png;base64,eA${i}=` }))
}
expect(screen.queryAllByRole("img")).toHaveLength(4)
})
})
@@ -0,0 +1,298 @@
import { useEffect, useRef, useState } from "react"
import { BookOpen, ClipboardPaste, Send, Square, X } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/ui/button"
import { cn } from "@/lib/utils/cn"
import { consumePendingCaptureImage } from "@/lib/bridge/bridgeNavigate"
import { useSnapChatStore } from "../store/snapChatStore"
import type { SnapImageInput, SnapImageMediaType } from "../contract/types"
interface Props {
onSend: (text: string, images: SnapImageInput[]) => void
busy?: boolean
onStop?: () => void
placeholder?: string
// 캡쳐 이미지 첨부를 받을지 — 새 대화(NewChatPage)만 true. 기존 대화방(SessionChatPage)이 켜져
// 있으면 navigate(/snap/new)+capture.image 순서에서 리마운트 전에 여기가 pending을 훔쳐가
// 정작 새 대화 Composer엔 이미지가 안 붙는 레이스가 생김 — 그래서 출발지는 아예 안 건드리게 게이팅.
acceptCapture?: boolean
}
// Blob → data URL(base64). 클립보드 이미지를 chat 계약으로 바꿀 때 씀.
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(r.result as string)
r.onerror = () => reject(r.error)
r.readAsDataURL(blob)
})
}
// 붙여넣기 텍스트가 이 길이 이상이면 입력창에 안 넣고 접힌 칩(첨부)으로 보관.
const PASTE_COLLAPSE = 100
const IMAGE_TYPES = new Set<SnapImageMediaType>(["image/png", "image/jpeg", "image/webp"])
const MAX_IMAGES = 4
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_TOTAL_IMAGE_BYTES = 15 * 1024 * 1024
function imageByteLength(dataUrl: string): number {
const encoded = dataUrl.slice(dataUrl.indexOf(",") + 1)
const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0
return Math.max(0, Math.floor((encoded.length * 3) / 4) - padding)
}
function imageFromDataUrl(data: string): SnapImageInput | null {
const match = /^data:(image\/(?:png|jpeg|webp));base64,/i.exec(data)
if (!match || !IMAGE_TYPES.has(match[1].toLowerCase() as SnapImageMediaType)) return null
return { mediaType: match[1].toLowerCase() as SnapImageMediaType, data }
}
export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: Props) {
const [value, setValue] = useState("")
// 100자↑ 붙여넣기로 접어둔 텍스트들. 전송 시 입력값과 합쳐 보냄.
const [attachments, setAttachments] = useState<string[]>([])
// 캡쳐·클립보드에서 받은 일회성 이미지 첨부. 백엔드는 원본을 저장하지 않음.
const [imageAttachments, setImageAttachments] = useState<SnapImageInput[]>([])
const explain = useSnapChatStore((s) => s.explain)
const setExplain = useSnapChatStore((s) => s.setExplain)
const addImage = (dataUrl: string) => {
const image = imageFromDataUrl(dataUrl)
if (!image) {
toast.error("PNG, JPEG, WebP 이미지만 첨부할 수 있어")
return
}
const bytes = imageByteLength(dataUrl)
if (bytes > MAX_IMAGE_BYTES) {
toast.error("이미지는 한 장당 5 MiB 이하여야 해")
return
}
setImageAttachments((current) => {
if (current.length >= MAX_IMAGES) {
toast.error("이미지는 최대 4장까지 첨부할 수 있어")
return current
}
const total = current.reduce((sum, item) => sum + imageByteLength(item.data), 0) + bytes
if (total > MAX_TOTAL_IMAGE_BYTES) {
toast.error("이미지 전체 크기는 15 MiB 이하여야 해")
return current
}
return [...current, image]
})
}
// 마운트 시(새 대화·지난 대화 진입) 입력창에 커서. rAF로 webview 포커스 안정화 후.
const taRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const raf = requestAnimationFrame(() => taRef.current?.focus())
return () => cancelAnimationFrame(raf)
}, [])
// 캡쳐 이미지 수신 — 마운트 시 놓친 것 consume(네비 직후 이벤트를 놓쳐도 반영) + 이후는 이벤트로 누적(FR-008).
// acceptCapture 아니면 아예 pending을 안 건드림 — 기존 대화방(SessionChatPage)이 새 대화
// 마운트보다 먼저 훔쳐가 이미지가 유실되는 레이스 방지(출발지 게이팅).
useEffect(() => {
if (!acceptCapture) return
const pending = consumePendingCaptureImage()
if (pending) addImage(pending)
const onCapture = () => {
// detail 대신 consume — 같은 동기 스택이라 값은 동일, 이걸로 pending도 같이 비워야
// 나중에 리마운트될 때(다른 대화→새 대화) stale 이미지가 재소비되지 않음.
const dataUrl = consumePendingCaptureImage()
if (dataUrl) addImage(dataUrl)
}
window.addEventListener("bridge:captureImage", onCapture)
return () => window.removeEventListener("bridge:captureImage", onCapture)
}, [acceptCapture])
const removeAttachment = (i: number) => setAttachments((a) => a.filter((_, j) => j !== i))
const removeImageAttachment = (i: number) =>
setImageAttachments((a) => a.filter((_, j) => j !== i))
const submit = () => {
// 접어둔 첨부들 먼저, 그다음 입력값 — 빈 건 빼고 이어붙임.
const combined = [...attachments, value.trim()].filter(Boolean).join("\n\n")
if ((!combined && imageAttachments.length === 0) || busy) return
setValue("")
setAttachments([])
const images = imageAttachments
setImageAttachments([])
onSend(combined, images)
}
// 이 환경이 클립보드 읽기(텍스트+이미지)를 지원하는가 — 비 https·구형 웹뷰면 read 가 없음.
const clipboardSupported =
typeof navigator !== "undefined" && typeof navigator.clipboard?.read === "function"
// 클릭(사용자 제스처)이라 read() 가 먹음. 텍스트·이미지만 지원.
const pasteFromClipboard = async () => {
if (!clipboardSupported) {
toast.error("이 환경은 클립보드 읽기를 지원하지 않아")
return
}
try {
const items = await navigator.clipboard.read()
let gotText = false
let gotImage = false
for (const item of items) {
const imageType = item.types.find((t) => t.startsWith("image/"))
if (imageType) {
const dataUrl = await blobToDataUrl(await item.getType(imageType))
addImage(dataUrl)
gotImage = true
} else if (item.types.includes("text/plain")) {
const text = (await (await item.getType("text/plain")).text()).trim()
if (text) {
// 길면 접힌 칩으로, 짧으면 입력창에 그대로.
if (text.length >= PASTE_COLLAPSE) setAttachments((a) => [...a, text])
else setValue((v) => (v ? `${v}\n${text}` : text))
gotText = true
}
}
}
if (gotImage && !gotText) toast.success("이미지를 질문에 첨부했어")
else if (!gotText && !gotImage) toast.info("클립보드에 텍스트·이미지가 없어")
} catch {
// NotAllowedError 등 — 사용자가 권한을 막았거나 브라우저가 거부.
toast.error("클립보드를 읽을 수 없어 (권한 거부됨)")
}
}
return (
<div className="border-border bg-card flex-none border-t p-3">
<div className="border-border bg-background focus-within:border-ring focus-within:ring-ring/20 rounded-lg border p-2 focus-within:ring-2">
{/* 캡쳐·클립보드 이미지 첨부 — 썸네일 + X 로 제거 */}
{imageAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{imageAttachments.map((image, i) => (
<span
key={i}
className="border-border bg-muted/60 relative flex items-center rounded-md border p-1"
>
<img
src={image.data}
alt={`캡쳐 이미지 ${i + 1}`}
className="h-12 w-12 rounded object-cover"
/>
<button
type="button"
onClick={() => removeImageAttachment(i)}
aria-label="캡쳐 이미지 제거"
className="bg-background border-border text-muted-foreground hover:text-foreground absolute -top-1.5 -right-1.5 rounded-full border p-0.5"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
{/* 100자↑ 붙여넣기 첨부 — 일부만 보이고 X 로 제거 */}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((a, i) => (
<span
key={i}
className="border-border bg-muted/60 text-muted-foreground flex max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]"
>
<ClipboardPaste className="size-3.5 flex-none text-emerald-500" />
<span className="min-w-0 truncate font-mono">
{a.replace(/\s+/g, " ").slice(0, 40)}
</span>
<span className="flex-none opacity-50">{a.length}</span>
<button
type="button"
onClick={() => removeAttachment(i)}
aria-label="첨부 제거"
className="hover:text-foreground flex-none"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<textarea
ref={taRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onPaste={(e) => {
const imageFile = Array.from(e.clipboardData.files).find((file) =>
file.type.startsWith("image/")
)
if (imageFile) {
e.preventDefault()
void blobToDataUrl(imageFile).then(addImage)
return
}
const text = e.clipboardData.getData("text/plain")
if (text.length >= PASTE_COLLAPSE) {
e.preventDefault() // 길면 입력창에 안 넣고 접힌 칩으로
setAttachments((a) => [...a, text])
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
submit()
}
}}
rows={1}
disabled={busy}
placeholder={busy ? "응답 중…" : (placeholder ?? "메시지를 입력하세요…")}
className="placeholder:text-muted-foreground field-sizing-content max-h-32 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
/>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={pasteFromClipboard}
disabled={busy || !clipboardSupported}
title={clipboardSupported ? "클립보드에서 가져오기" : "이 환경은 클립보드 읽기 미지원"}
className="border-border text-muted-foreground hover:border-ring hover:text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[10px] transition-colors disabled:opacity-50"
>
<ClipboardPaste className="size-3" />
</button>
<button
type="button"
onClick={() => setExplain(!explain)}
aria-pressed={explain}
title={explain ? "설명 모드 — 배경·원리까지" : "간결 모드 — 답만"}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[10px] transition-colors",
explain
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-ring hover:text-foreground"
)}
>
<BookOpen className="size-3" />
</button>
<span className="text-muted-foreground font-mono text-[10px] tracking-wide">
Enter · Shift+Enter
</span>
{busy ? (
<Button
type="button"
size="sm"
variant="destructive"
className="ml-auto"
onClick={onStop}
>
<Square className="size-3.5 fill-current" />
</Button>
) : (
<Button
type="button"
size="sm"
className="ml-auto"
disabled={!value.trim() && attachments.length === 0 && imageAttachments.length === 0}
onClick={submit}
>
<Send className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,17 @@
export function Hero() {
return (
<section className="flex flex-col items-center px-6 py-10 text-center">
<div className="bg-primary text-primary-foreground mb-4 grid size-14 place-items-center rounded-xl font-serif text-2xl font-bold italic">
S
</div>
<span className="text-muted-foreground mb-3 inline-flex items-center gap-1.5 font-mono text-[10px] tracking-widest uppercase">
<span className="size-1.5 rounded-full bg-emerald-500" />
New Session
</span>
<h1 className="mb-2 font-serif text-2xl font-semibold italic"> ?</h1>
<p className="text-muted-foreground max-w-sm text-sm leading-relaxed">
ABAP · CDS · HANA · .
</p>
</section>
)
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest"
import { render } from "@testing-library/react"
import { Message } from "./Message"
import type { SnapRole } from "../contract/types"
// JSX 에 role 을 문자열 리터럴로 쓰면 jsx-a11y 가 ARIA role 로 오해함 — 변수로 우회
const assistant: SnapRole = "assistant"
describe("Message 코드펜스 렌더", () => {
it("언어 없는 코드펜스(ASCII 다이어그램)도 CodeBlock 으로 렌더된다", () => {
// 언어 라벨 없는 펜스 — 인라인 code 로 새면 ─── 연속 문자가 말풍선 밖으로 넘침
const content = [
"구조 제안:",
"",
"```",
"┌──────────────────────────────┐",
"│ ABAP Productivity App │",
"└──────────────────────────────┘",
"```",
].join("\n")
const { container } = render(<Message role={assistant} content={content} />)
// 스크롤 컨테이너 있는 CodeBlock 으로 감싸져야 함
const block = container.querySelector("[data-code-block]")
expect(block).not.toBeNull()
// 언어 없는 펜스는 다크 코드 테마가 아니라 라이트로
expect(block?.className).toContain("bg-white")
// 인라인 code 스타일(bg-black/10)로 새지 않아야 함
expect(container.querySelector("code.rounded")).toBeNull()
})
it("언어 있는 코드펜스는 기존대로 CodeBlock + 언어 라벨", () => {
const content = "```sql\nSELECT * FROM t;\n```"
const { container } = render(<Message role={assistant} content={content} />)
expect(container.querySelector('[data-code-lang="sql"]')).not.toBeNull()
})
it("인라인 code 는 그대로 인라인으로 렌더된다", () => {
const { container } = render(<Message role={assistant} content="이건 `SMOINT` 임" />)
expect(container.querySelector("code.rounded")).not.toBeNull()
expect(container.querySelector("[data-code-block]")).toBeNull()
})
})
@@ -0,0 +1,209 @@
import { isValidElement, type ReactElement, type ReactNode } from "react"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import { MoreHorizontal, Copy, Download } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/lib/utils/cn"
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/shared/ui/dropdown-menu"
import type { SnapRole } from "../contract/types"
import { CodeBlock } from "./CodeBlock"
import { fmtElapsed, fmtTokens } from "../lib/format"
interface Props {
role: SnapRole
content: string
/** 이 답변 전체토큰(assistant). 있으면 버블 밑에 표기. */
totalTokens?: number
/** 이 답변 소요시간(ms). 라이브만 있음(과거는 elapsed 미저장). */
elapsedMs?: number
}
// assistant 마크다운 본문 prose 스타일 — 목업이 표/헤딩/리스트/diff 를 섞어 써서 각 요소 명시 스타일링.
const PROSE = cn(
"first:[&>*]:mt-0 last:[&>*]:mb-0",
"[&_p]:my-1.5 [&_p]:leading-[1.9]",
"[&_strong]:font-semibold [&_strong]:text-foreground",
"[&_ul]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5",
"[&_blockquote]:my-1.5 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
"[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
"[&_th]:border [&_th]:border-border [&_th]:bg-foreground/5 [&_th]:px-2.5 [&_th]:py-1 [&_th]:text-left [&_th]:font-semibold",
"[&_td]:border [&_td]:border-border [&_td]:px-2.5 [&_td]:py-1"
)
export function Message({ role, content, totalTokens, elapsedMs }: Props) {
const isUser = role === "user"
let local = 0
// 답변 본문(마크다운) 복사 / .md 다운로드.
const copyMd = async () => {
try {
await navigator.clipboard.writeText(content)
toast.success("복사됨 (마크다운)")
} catch {
toast.error("복사 실패")
}
}
const downloadMd = () => {
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "snap-답변.md"
document.body.appendChild(a) // Firefox/일부 Chrome 은 DOM 에 붙어야 클릭 먹음
a.click()
a.remove()
// 즉시 revoke 하면 다운로드 시작 전에 blob 이 사라져 ERR_FILE_NOT_FOUND — 한 틱 미룸
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
return (
<div className={cn("flex flex-col gap-1", isUser ? "items-end" : "items-start")}>
<div className={cn("flex items-center gap-1.5", !isUser && "w-full")}>
{!isUser && (
<span className="bg-primary text-primary-foreground grid size-4 place-items-center rounded font-serif text-[9px] leading-none font-bold italic">
S
</span>
)}
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
{isUser ? "나 · YOU" : "SNAP MATE"}
</span>
{!isUser && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="답변 메뉴"
className="text-muted-foreground hover:bg-accent hover:text-foreground ml-auto inline-flex size-6 items-center justify-center rounded transition-colors"
>
<MoreHorizontal className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={copyMd}>
<Copy className="size-3.5" />
(MD)
</DropdownMenuItem>
<DropdownMenuItem onClick={downloadMd}>
<Download className="size-3.5" />
(MD)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div
className={cn(
"rounded-lg px-3 py-2 text-sm leading-[1.9]",
isUser
? "bg-secondary text-secondary-foreground max-w-[85%] whitespace-pre-wrap"
: "border-border bg-card text-card-foreground w-full border"
)}
>
{isUser ? (
content
) : (
<div className={PROSE}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// 코드펜스는 언어 유무와 무관하게 전부 CodeBlock 으로.
// pre 를 그냥 언랩하면 언어 없는 펜스(ASCII 다이어그램 등)가
// 인라인 code 로 렌더돼 ─── 연속 문자가 말풍선 밖으로 넘침.
pre: ({ children }) => {
const child = isValidElement(children)
? (children as ReactElement<{ className?: string; children?: ReactNode }>)
: null
if (!child) return <pre>{children}</pre>
const match = /language-(\w+)/.exec(child.props.className ?? "")
const index = local++
return (
<CodeBlock
code={String(child.props.children ?? "").replace(/\n$/, "")}
lang={match?.[1]}
index={index}
/>
)
},
// 헤딩·구분선 여백은 inline style 로 직접 박음(Tailwind 재생성/캐시 이슈 회피).
// top 마진 크게 줘서 앞 섹션이랑 확실히 떨어짐.
h1: ({ children }) => (
<h1
style={{
marginTop: "1.5rem",
marginBottom: "0.5rem",
fontSize: "1.125rem",
fontWeight: 700,
lineHeight: 1.3,
}}
>
{children}
</h1>
),
h2: ({ children }) => (
<h2
style={{
marginTop: "1.5rem",
marginBottom: "0.5rem",
fontSize: "1rem",
fontWeight: 700,
lineHeight: 1.3,
}}
>
{children}
</h2>
),
h3: ({ children }) => (
<h3
style={{
marginTop: "1.25rem",
marginBottom: "0.375rem",
fontSize: "0.9375rem",
fontWeight: 600,
lineHeight: 1.3,
}}
>
{children}
</h3>
),
hr: () => (
<hr
style={{
marginTop: "1rem",
marginBottom: "1rem",
border: 0,
borderTop: "1px solid var(--border)",
}}
/>
),
table: ({ children }) => (
<div className="my-2 overflow-x-auto">
<table className="w-full border-collapse text-xs">{children}</table>
</div>
),
// 블록 코드는 위 pre 에서 다 처리되니 여기 오는 건 인라인뿐.
code: ({ children }) => (
<code className="rounded bg-black/10 px-1 py-0.5 font-mono text-[0.85em]">
{children}
</code>
),
}}
>
{content}
</ReactMarkdown>
</div>
)}
</div>
{!isUser && totalTokens != null && (
<span className="text-muted-foreground/60 px-1 font-mono text-[9px] tabular-nums">
{fmtTokens(totalTokens)} tok
{elapsedMs != null && ` · ${fmtElapsed(elapsedMs)}`}
</span>
)}
</div>
)
}
@@ -0,0 +1,93 @@
import { useState } from "react"
import { Check, Code2, Copy } from "lucide-react"
import { toast } from "sonner"
import { ClipboardHistory } from "./ClipboardHistory"
interface Block {
index: number
lang: string
}
interface Props {
/** 렌더된 코드블럭 목록(DOM 순서, index=data-code-block 과 동일). */
blocks: Block[]
/** 스트림 컨테이너 ref — 코드블럭으로 스크롤 점프. */
containerRef: React.RefObject<HTMLDivElement>
}
export function NavRail({ blocks, containerRef }: Props) {
const [copiedIdx, setCopiedIdx] = useState<number | null>(null)
const jump = (index: number) => {
const el = containerRef.current?.querySelector<HTMLElement>(`[data-code-block="${index}"]`)
el?.scrollIntoView({ behavior: "smooth", block: "center" })
}
// 점프와 같은 방식으로 해당 코드블럭 DOM 을 찾아 실제 코드 텍스트를 복사.
const copy = async (index: number) => {
const el = containerRef.current?.querySelector<HTMLElement>(
`[data-code-block="${index}"] pre code`
)
const text = el?.textContent
if (!text) return
try {
await navigator.clipboard.writeText(text)
setCopiedIdx(index)
toast.success("클립보드에 복사됨")
setTimeout(() => setCopiedIdx(null), 1400)
} catch {
toast.error("복사 실패")
}
}
return (
<aside className="border-border bg-card hidden w-52 flex-none flex-col border-r min-[540px]:flex">
<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-emerald-500" />
<span className="text-muted-foreground font-mono text-[10px] tracking-widest uppercase">
Source Nav
</span>
</div>
{blocks.length === 0 ? (
<div className="text-muted-foreground p-4 font-mono text-[10px] leading-relaxed">
<br />
<br />
.
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-1.5">
{blocks.map((b) => (
<div key={b.index} className="group hover:bg-accent flex items-center rounded">
<button
type="button"
onClick={() => jump(b.index)}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-2 py-1.5 text-left font-mono text-[11px]"
>
<Code2 className="text-muted-foreground size-3 flex-none" />
<span className="text-muted-foreground flex-none tabular-nums">#{b.index + 1}</span>
<span className="truncate">{b.lang}</span>
</button>
<button
type="button"
onClick={() => copy(b.index)}
aria-label="코드 복사"
title="코드 복사"
className="text-muted-foreground/50 hover:text-foreground flex-none rounded p-1.5 transition-colors"
>
{copiedIdx === b.index ? (
<Check className="size-3 text-emerald-500" />
) : (
<Copy className="size-3" />
)}
</button>
</div>
))}
</div>
)}
</aside>
)
}
@@ -0,0 +1,60 @@
import { CornerDownRight, MessageSquare } from "lucide-react"
import type { ReactNode } from "react"
import type { SnapMessage } from "../contract/types"
import { formatRelativeKo } from "@/lib/utils/relativeTime"
interface Props {
hit: SnapMessage
query: string
onOpen: (sessionId: string) => void
/** 키보드 이동으로 선택된 항목 — 하이라이트 + 스크롤 대상. */
selected?: boolean
}
// 매칭 지점 주변만 잘라 보여주고, 검색어를 <mark> 로 강조.
function snippet(text: string, query: string): ReactNode {
const idx = text.toLowerCase().indexOf(query.toLowerCase())
if (idx < 0) return text.slice(0, 120)
const start = Math.max(0, idx - 40)
const end = Math.min(text.length, idx + query.length + 80)
return (
<>
{start > 0 && "…"}
{text.slice(start, idx)}
<mark className="bg-primary/20 text-foreground rounded px-0.5">
{text.slice(idx, idx + query.length)}
</mark>
{text.slice(idx + query.length, end)}
{end < text.length && "…"}
</>
)
}
export function SearchHitCard({ hit, query, onOpen, selected }: Props) {
return (
<button
type="button"
onClick={() => onOpen(hit.sessionId)}
data-snap-selected={selected ? "true" : undefined}
className={`focus-visible:ring-ring flex w-full items-start gap-3 rounded-lg p-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none ${
selected ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<MessageSquare className="text-muted-foreground mt-0.5 size-4 flex-none" />
<span className="flex min-w-0 flex-col">
<span className="line-clamp-2 text-xs leading-relaxed break-words whitespace-pre-wrap">
{snippet(hit.content, query)}
</span>
<span className="text-muted-foreground mt-1.5 flex items-center gap-2 font-mono text-[9.5px]">
<span className="tracking-wide uppercase">
{hit.role === "user" ? "나" : "SNAP MATE"}
</span>
<span>·</span>
<span>{formatRelativeKo(hit.createdAt)}</span>
<CornerDownRight className="size-3" />
<span> </span>
</span>
</span>
</button>
)
}
@@ -0,0 +1,43 @@
import { MessageSquare, ChevronRight } from "lucide-react"
import type { SnapSession } from "../contract/types"
import { formatRelativeKo } from "@/lib/utils/relativeTime"
interface Props {
session: SnapSession
onOpen: (id: string) => void
/** 키보드 이동으로 선택된 항목 — 하이라이트 + 스크롤 대상. */
selected?: boolean
}
export function SessionCard({ session, onOpen, selected }: Props) {
const title = session.titleLlm ?? session.title ?? "제목 없음"
return (
// 컴팩트 1행 — 세로를 1/3로. 제목·태그·시간·상태를 한 줄에, 스니펫은 생략(제목 title 로 노출).
<button
type="button"
onClick={() => onOpen(session.id)}
data-snap-selected={selected ? "true" : undefined}
title={session.snippet ?? title}
className={`focus-visible:ring-ring flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none ${
selected ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<span className="bg-muted text-muted-foreground grid size-8 flex-none place-items-center rounded-lg">
<MessageSquare className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{title}</span>
{session.tag && (
<span className="border-border text-muted-foreground flex-none rounded border px-1.5 font-mono text-[9.5px] tracking-wide uppercase">
{session.tag}
</span>
)}
{session.isGenerating && (
<span className="size-1.5 flex-none rounded-full bg-emerald-500" title="생성 중" />
)}
<span className="text-muted-foreground flex-none font-mono text-[9.5px]">
{formatRelativeKo(session.updatedAt)}
</span>
<ChevronRight className="text-muted-foreground size-3.5 flex-none" />
</button>
)
}
@@ -0,0 +1,47 @@
import { useEffect, useRef } from "react"
import { Search, X } from "lucide-react"
interface Props {
value: string
onChange: (v: string) => void
}
export function SessionSearch({ value, onChange }: Props) {
// 런처 답게 뜨자마자 검색창에 포커스 — 여기서 타이핑=필터, ↑↓=목록 이동, Enter=열기.
// 창이 다시 떠서(핫키 재소환) window 가 포커스 받을 때도 다시 잡아준다.
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
const focus = () => ref.current?.focus()
focus()
window.addEventListener("focus", focus)
return () => window.removeEventListener("focus", focus)
}, [])
return (
<div className="relative flex min-w-0 flex-1 items-center">
<Search className="text-muted-foreground pointer-events-none absolute left-0 size-5" />
<input
ref={ref}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label="대화 내용 검색"
placeholder="대화 내용 검색…"
className="placeholder:text-muted-foreground/75 focus-visible:ring-ring/40 w-full rounded-md bg-transparent py-3 pr-9 pl-9 text-lg outline-none focus-visible:ring-2"
/>
{value && (
<button
type="button"
aria-label="검색어 지우기"
className="text-muted-foreground hover:bg-accent focus-visible:ring-ring absolute right-1 rounded p-1 focus-visible:ring-2"
onClick={() => {
onChange("")
ref.current?.focus()
}}
>
<X className="size-4" />
</button>
)}
</div>
)
}
@@ -0,0 +1,45 @@
import { hostKind } from "@/lib/bridge/transport"
import { useEffect } from "react"
import { Outlet, useNavigate } from "react-router-dom"
import { PATHS } from "@/config/routes"
import { startWindowDrag } from "@/lib/bridge/webviewBridge"
import { SnapUserControls } from "./SnapUserControls"
/** 웹뷰 풀블리드 셸 — .NET 창이 진짜 크롬을 주므로 가짜 타이틀바는 없음. 얇은 브랜드 스트립만. */
export function SnapLayout() {
const navigate = useNavigate()
// Ctrl+N → 새 대화(앱 안 어디서나). 브라우저 기본동작(새 창)은 막음.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.isComposing) return
if (e.ctrlKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "N")) {
e.preventDefault()
navigate(PATHS.SNAP_NEW)
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [navigate])
return (
<div className="bg-background text-foreground flex h-screen flex-col">
{/* 헤더 = 창 드래그 영역(프레임리스 제목표시줄 대체) — title-bar 드래그라 a11y 룰 한 줄 예외 */}
{hostKind() !== "tauri" && (
<header
role="presentation"
onMouseDown={startWindowDrag}
className="border-border flex h-9 flex-none items-center gap-2 border-b px-4 select-none"
>
<span className="font-mono text-[11px] font-semibold tracking-[0.12em]">
Chat Everywhere
</span>
</header>
)}
<SnapUserControls />
<div className="min-h-0 flex-1">
<Outlet />
</div>
</div>
)
}
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { fireEvent, render, screen } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { SnapUserControls } from "./SnapUserControls"
const mutate = vi.fn()
vi.mock("@/features/auth/hooks/useLogout", () => ({
useLogout: () => ({ mutate, isPending: false }),
}))
vi.mock("@/shared/components/ThemeToggle", () => ({
ThemeToggle: () => <button type="button"> </button>,
}))
describe("SnapUserControls", () => {
beforeEach(() => mutate.mockClear())
it("사이드바 없이 테마 변경과 로그아웃을 표시", () => {
render(
<MemoryRouter>
<SnapUserControls />
</MemoryRouter>
)
expect(screen.getByRole("button", { name: "테마 변경" })).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "로그아웃" }))
expect(mutate).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,35 @@
import { LogOut } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { PATHS } from "@/config/routes"
import { useLogout } from "@/features/auth/hooks/useLogout"
import { ThemeToggle } from "@/shared/components/ThemeToggle"
import { Button } from "@/shared/ui/button"
/** 사이드바 없는 Snap 창에서도 테마 변경과 로그아웃에 바로 접근하게 함. */
export function SnapUserControls() {
const navigate = useNavigate()
const logout = useLogout()
const handleLogout = () => {
logout.mutate(undefined, {
onSettled: () => navigate(PATHS.LOGIN, { replace: true }),
})
}
return (
<div className="border-border bg-background/90 fixed top-11 right-3 z-40 flex items-center gap-1 rounded-lg border p-1 shadow-sm backdrop-blur">
<ThemeToggle />
<Button
type="button"
variant="ghost"
size="icon"
aria-label="로그아웃"
title="로그아웃"
disabled={logout.isPending}
onClick={handleLogout}
>
<LogOut className="size-4" aria-hidden="true" />
</Button>
</div>
)
}
@@ -0,0 +1,34 @@
import type { HLJSApi, Language } from "highlight.js"
// highlight.js 코어엔 ABAP 이 없어서 경량 문법을 직접 등록한다.
// ABAP OO + Open SQL + CDS(DDL) + RAP behavior 를 한 문법으로 커버 — 목업 렌더용이라 완벽 파서는 아님.
export default function abap(hljs: HLJSApi): Language {
const KEYWORDS =
"select from into table where and or not as inner left right outer join on group by " +
"order having distinct up to rows loop at endloop do enddo while endwhile if elseif " +
"else endif case when others endcase data types constants field-symbols read append " +
"modify insert update delete clear refresh sort binary search for all entries in " +
"package size cond switch value new corresponding lines of exporting importing changing " +
"returning raising method endmethod class endclass public protected private section " +
"define view entity projection root key managed unmanaged implementation unique strict " +
"behavior persistent lock master authorization instance create determination validation " +
"association service expose annotate with begin end of is initial single"
return {
name: "ABAP",
case_insensitive: true,
keywords: {
keyword: KEYWORDS,
built_in: "sy-subrc sy-tabix sy-index sy-datum sy-uzeit abap_true abap_false",
},
contains: [
hljs.COMMENT("^\\*", "$"), // 전체 줄 주석 (* 로 시작)
hljs.COMMENT('"', "$"), // 인라인 주석 (")
{ className: "string", begin: "'", end: "'" },
{ className: "string", begin: "`", end: "`" },
{ className: "string", begin: "\\|", end: "\\|" }, // 문자열 템플릿 |...|
{ className: "meta", begin: "@[A-Za-z][\\w.]*" }, // @UI.lineItem / @DATA 등
hljs.C_NUMBER_MODE,
],
}
}
@@ -0,0 +1,26 @@
/* SAP ABAP 에디터 룩: 흰 배경 + 파란 키워드 + 검정 본문.
github-dark 의 전역 .hljs-* 색을 ABAP 블록에만 스코프로 덮어쓴다
(.hljs-abap-light .hljs-keyword 는 specificity 로 전역 규칙을 이긴다). */
.hljs-abap-light {
color: #1a1a1a;
}
.hljs-abap-light .hljs-keyword {
color: #0033b3;
font-weight: 600;
}
.hljs-abap-light .hljs-built_in {
color: #0e7490;
}
.hljs-abap-light .hljs-string {
color: #a31515;
}
.hljs-abap-light .hljs-comment {
color: #6b7280;
font-style: italic;
}
.hljs-abap-light .hljs-meta {
color: #7a3e9d;
}
.hljs-abap-light .hljs-number {
color: #098658;
}
@@ -0,0 +1,47 @@
// base-backend modules/chat/schema.py 와 1:1 (camelCase). UI-only 필드는 명시 표기.
export type SnapRole = "user" | "assistant" | "system"
export interface SnapSession {
id: string
title: string | null
titleLlm: string | null
isGenerating: boolean
createdAt: string
updatedAt: string
// --- UI-only: 백엔드에 없음. real 스왑 시 드롭 또는 파생(snippet=마지막메시지) ---
tag?: string
snippet?: string
tokens?: string
}
export interface SnapMessage {
sessionId: string
role: SnapRole
content: string // markdown
createdAt: string
// usage/timing — assistant 행에만 채워짐(user 는 null). 전체토큰 = input+output.
inputTokens?: number | null
outputTokens?: number | null
costUsd?: number | null
elapsedMs?: number | null
}
export interface SnapSessionDetail extends SnapSession {
messages: SnapMessage[]
}
export type SnapImageMediaType = "image/png" | "image/jpeg" | "image/webp"
export interface SnapImageInput {
mediaType: SnapImageMediaType
data: string
}
export interface SnapStreamRequest {
sessionId: string
content: string
images?: SnapImageInput[]
forcedSkill?: string
// 설명 모드 토글 — true면 배경·원리까지, 기본(false)은 간결.
explain?: boolean
}
@@ -0,0 +1,17 @@
import { useEffect, useRef } from "react"
/**
* Esc 전역 핸들러. 다이얼로그(Radix 등)가 이미 처리한 Esc(defaultPrevented)는 건너뛴다 —
* 그건 다이얼로그 닫기 몫. handler 는 매 렌더 바뀌어도 ref 로 잡아 리스너 재등록 안 함.
*/
export function useEscapeKey(handler: () => void) {
const ref = useRef(handler)
ref.current = handler
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) ref.current()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [])
}
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { toast } from "sonner"
import { useSnapChat } from "./useSnapChat"
import * as stream from "../api/snap.stream"
import { useSnapChatStore } from "../store/snapChatStore"
vi.mock("../api/snap.stream")
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
let qc: QueryClient
function wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
beforeEach(() => {
qc = new QueryClient()
useSnapChatStore.getState().reset()
vi.clearAllMocks()
})
describe("useSnapChat", () => {
it("onTitle: 상세 캐시 title 즉시 패치 + 목록 캐시 invalidate", async () => {
qc.setQueryData(["snap", "session", "s1"], {
id: "s1",
title: null,
titleLlm: null,
isGenerating: false,
createdAt: "",
updatedAt: "",
messages: [],
})
const invalidate = vi.spyOn(qc, "invalidateQueries")
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
handlers.onTitle?.("새 제목")
})
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
const detail = qc.getQueryData(["snap", "session", "s1"]) as { title: string | null }
expect(detail.title).toBe("새 제목")
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["snap", "sessions"] })
})
it("409 에러면 '이미 생성 중' 안내 toast", async () => {
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
handlers.onError?.(new Error("SSE open failed: 409"))
})
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("이미 생성 중"))
})
it("open 실패로 snapStream 이 reject 해도 send 는 throw 하지 않는다", async () => {
vi.mocked(stream.snapStream).mockRejectedValue(new Error("SSE open failed: 500"))
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await expect(result.current.send("hi")).resolves.toBeUndefined()
})
it("이미 스트리밍 중이면 두 번째 send 는 무시(중복 가드)", async () => {
useSnapChatStore.getState().setStreaming(true)
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
expect(stream.snapStream).not.toHaveBeenCalled()
})
it("이미지를 chat stream 요청에 포함한다", async () => {
vi.mocked(stream.snapStream).mockResolvedValue(undefined)
const images = [{ mediaType: "image/png" as const, data: "data:image/png;base64,eA==" }]
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("", images)
expect(vi.mocked(stream.snapStream).mock.calls[0][0]).toMatchObject({
sessionId: "s1",
content: "",
images,
})
expect(useSnapChatStore.getState().messages[0].content).toBe("첨부 이미지를 분석해줘.")
})
it("에러로 토큰 0개면 빈 assistant 버블을 남기지 않는다", async () => {
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
handlers.onError?.(new Error("SSE open failed: 409"))
})
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
const msgs = useSnapChatStore.getState().messages
expect(msgs.some((m) => m.role === "assistant" && m.content === "")).toBe(false)
})
it("stop 은 store.stop 후 cancelStream(id) 를 부른다", async () => {
useSnapChatStore.getState().setStreaming(true)
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
result.current.stop()
expect(vi.mocked(stream.cancelStream)).toHaveBeenCalledWith("s1")
expect(useSnapChatStore.getState().isStreaming).toBe(false)
})
it("retry 는 마지막 유저 메시지를 다시 보낸다", async () => {
useSnapChatStore.getState().reset()
useSnapChatStore.getState().addUserMessage("원래 질문")
vi.mocked(stream.snapStream).mockResolvedValue(undefined)
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.retry()
expect(vi.mocked(stream.snapStream)).toHaveBeenCalled()
const req = vi.mocked(stream.snapStream).mock.calls[0][0]
expect(req.content).toBe("원래 질문")
})
})
@@ -0,0 +1,92 @@
import { useCallback } from "react"
import { toast } from "sonner"
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
import { useSnapChatStore } from "../store/snapChatStore"
import { snapStream, cancelStream } from "../api/snap.stream"
import type { SnapImageInput, SnapSessionDetail } from "../contract/types"
// title 이벤트 → 상세 캐시는 즉시 패치(헤더 제목 실시간 반영), 목록은 invalidate.
// 목록 키는 ["snap","sessions",page] 라 페이지별 직접 패치 대신 무효화가 안전(모양도 {items,meta}).
function patchSessionTitle(queryClient: QueryClient, sessionId: string, title: string): void {
void queryClient.invalidateQueries({ queryKey: ["snap", "sessions"] })
queryClient.setQueryData<SnapSessionDetail>(["snap", "session", sessionId], (prev) =>
prev ? { ...prev, title } : prev
)
}
/** store + snapStream 배선 — 전송/중단. sessionId 는 현재 열린 세션. */
export function useSnapChat(sessionId: string) {
const queryClient = useQueryClient()
const send = useCallback(
async (text: string, images: SnapImageInput[] = []) => {
const store = useSnapChatStore.getState()
if (store.isStreaming) return // 이 클라이언트가 이미 생성 중 — 중복 전송 차단
store.currentController?.abort()
store.addUserMessage(text.trim() || "첨부 이미지를 분석해줘.")
store.startAssistantMessage()
store.setStreaming(true)
const ctrl = new AbortController()
store.setController(ctrl)
try {
await snapStream(
{
sessionId,
content: text,
...(images.length > 0 ? { images } : {}),
explain: store.explain,
},
{
onToken: (d) => useSnapChatStore.getState().appendChunk(d),
// done 시점엔 백엔드가 이미 답변을 DB 에 저장함(streaming.py: persist→usage→done).
// detail 캐시를 무효화해 재진입 시 stale 스냅샷 대신 완성본을 받게 함.
onDone: () =>
void queryClient.invalidateQueries({
queryKey: ["snap", "session", sessionId],
}),
onTitle: (title) => patchSessionTitle(queryClient, sessionId, title),
onUsage: (u) =>
useSnapChatStore.getState().applyUsage({
used: u.used,
limit: u.limit,
elapsedMs: u.elapsed_ms,
}),
onError: (e) => {
if (e.message.includes("409")) {
toast.error("이미 생성 중인 세션이야. 잠깐 기다렸다 다시 보내.")
} else {
toast.error(`스트림 오류: ${e.message}`)
}
useSnapChatStore.getState().dropEmptyAssistantTail()
},
},
{ signal: ctrl.signal }
)
} catch {
// sse.ts 는 open 실패(409/5xx) 시 onError 를 부른 뒤 promise 도 reject 한다.
// 오류 표시는 위 onError 에서 이미 함 → 여기선 rejection 만 삼켜 unhandled 방지.
// abort 는 라이브러리가 resolve 처리하므로 여기로 안 옴.
useSnapChatStore.getState().dropEmptyAssistantTail()
} finally {
const s = useSnapChatStore.getState()
if (s.currentController === ctrl) {
s.setController(null)
s.setStreaming(false)
}
}
},
[sessionId, queryClient]
)
const stop = useCallback(() => {
useSnapChatStore.getState().stop()
void cancelStream(sessionId) // 백엔드 취소 통보(best-effort)
}, [sessionId])
const retry = useCallback(() => {
const q = useSnapChatStore.getState().getRetryQuery()
if (q) void send(q)
}, [send])
return { send, stop, retry }
}
@@ -0,0 +1,11 @@
// 토큰 수 축약 — 512 / 3.2k / 1.05M.
export function fmtTokens(n: number): string {
if (n < 1000) return String(n)
if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`
return `${(n / 1_000_000).toFixed(2)}M`
}
// 소요시간(ms) → 초. 4.1s.
export function fmtElapsed(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`
}
@@ -0,0 +1,58 @@
import { useRef, useState } from "react"
import { useNavigate } from "react-router-dom"
import { ChevronLeft } from "lucide-react"
import { PATHS } from "@/config/routes"
import { useCreateSession } from "../api/snap.api"
import { Hero } from "../components/Hero"
import { NavRail } from "../components/NavRail"
import { Composer } from "../components/Composer"
import { useEscapeKey } from "../hooks/useEscapeKey"
import type { SnapImageInput } from "../contract/types"
export default function NewChatPage() {
const navigate = useNavigate()
const createSession = useCreateSession()
const [pending, setPending] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
// 단계적 Esc: 새 대화 화면에선 목록으로.
useEscapeKey(() => navigate(PATHS.SNAP))
// 첫 전송: 세션 생성 → 채팅 페이지로 이동하며 firstMessage 전달(거기서 스트림).
const start = async (text: string, images: SnapImageInput[]) => {
if (pending) return
setPending(true)
const session = await createSession.mutateAsync()
navigate(`/snap/s/${session.id}`, { state: { firstMessage: text, firstImages: images } })
}
// 새 대화도 좌측 레일 유지 — 클립보드 이력은 바로 쓰고, Source Nav 는 코드블럭 없음 상태로 표시.
return (
<div className="flex h-full flex-col">
<div className="border-border bg-card flex flex-none items-center border-b px-3 py-2.5">
<button
type="button"
onClick={() => navigate(PATHS.SNAP)}
className="border-border bg-background text-muted-foreground hover:border-ring hover:text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[11px]"
>
<ChevronLeft className="size-3" />
</button>
</div>
<div className="flex min-h-0 flex-1">
<NavRail blocks={[]} containerRef={scrollRef} />
<div ref={scrollRef} className="min-w-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl">
<Hero />
</div>
</div>
</div>
<Composer
onSend={start}
busy={pending}
placeholder="새 대화를 시작하세요 — 질문을 입력하거나 코드를 붙여넣어봐…"
acceptCapture
/>
</div>
)
}
@@ -0,0 +1,228 @@
import { useEffect, useLayoutEffect, useRef, useState, type WheelEvent } from "react"
import { useParams, useNavigate, useLocation } from "react-router-dom"
import { useShallow } from "zustand/react/shallow"
import { PATHS } from "@/config/routes"
import { StreamingText, StoppedNotice } from "@/lib/streaming"
import { useSessionMessages } from "../api/snap.api"
import { useSnapChatStore } from "../store/snapChatStore"
import { useSnapChat } from "../hooks/useSnapChat"
import { useEscapeKey } from "../hooks/useEscapeKey"
import type { SnapImageInput, SnapSession } from "../contract/types"
import { ChatHeader } from "../components/ChatHeader"
import { NavRail } from "../components/NavRail"
import { Message } from "../components/Message"
import { Composer } from "../components/Composer"
export default function SessionChatPage() {
const { id = "" } = useParams()
const navigate = useNavigate()
const location = useLocation()
const { data: detail } = useSessionMessages(id)
const { send, stop, retry } = useSnapChat(id)
const [navBlocks, setNavBlocks] = useState<{ index: number; lang: string }[]>([])
const scrollRef = useRef<HTMLDivElement>(null)
// 단계적 Esc: 대화창에선 목록/검색 화면으로(창은 목록에서 Esc 로 숨김).
useEscapeKey(() => navigate(PATHS.SNAP))
const { messages, isStreaming, isRevealing } = useSnapChatStore(
useShallow((s) => ({
messages: s.messages,
isStreaming: s.isStreaming,
isRevealing: s.isRevealing,
}))
)
const busy = isStreaming || isRevealing
// 백엔드가 이 세션을 생성 중인데 이 탭에 라이브 스트림이 없음(재진입) —
// 시머로 "생각 중" 표시 + 전송 잠금(보내면 어차피 409). 폴링이 완성본을 곧 가져옴.
const generatingRemotely = !busy && !!detail?.isGenerating && messages.at(-1)?.role === "user"
// 세션 진입: 과거대화 seed. NewChatPage 에서 넘어온 firstMessage 있으면 seed 없이 바로 전송.
const firstRequest = location.state as {
firstMessage?: string
firstImages?: SnapImageInput[]
} | null
const firstMessage = firstRequest?.firstMessage
const firstImages = firstRequest?.firstImages ?? []
useEffect(() => {
const store = useSnapChatStore.getState()
// 라이브(streaming/revealing) 중이면 store 가 유일본 — 절대 안 덮음(seed 는 abort 까지 함).
if (store.sessionId === id && (store.isStreaming || store.isRevealing)) return
if (firstMessage || firstImages.length > 0) {
store.seed(id, [])
void send(firstMessage ?? "", firstImages)
// state 소비 후 제거(새로고침 시 재전송 방지)
navigate(`/snap/s/${id}`, { replace: true, state: null })
} else if (detail) {
// 시머(생성 중 재진입) 응시 중 답변이 폴링으로 도착한 케이스 — seed(즉시 팝) 대신
// 그 답변만 꼬리에 붙여 0부터 타자기로 풀기. 라이브 스트림 봤을 때와 같은 감각.
const tail = detail.messages.at(-1)
if (
store.sessionId === id &&
store.messages.length > 0 &&
store.messages.at(-1)?.role === "user" &&
detail.messages.length === store.messages.length + 1 &&
tail?.role === "assistant"
) {
store.appendRecoveredAssistant(tail)
return
}
// 라이브 아니면 "정보량 많은 쪽" 으로 단조 수렴 — DB 가 store 보다 내용이 많을 때만 seed.
// stale 스냅샷이 완성본을 덮는 것도, 깨진 store(답변 유실)가 pin 되는 것도 다 여기서 걸러짐.
// detail 은 refetch/폴링마다 갱신돼 effect 재실행 → 어느 타이밍에 들어와도 결국 완전본으로 수렴.
const len = (msgs: { content: string }[]) => msgs.reduce((n, m) => n + m.content.length, 0)
if (store.sessionId === id && len(store.messages) >= len(detail.messages)) return
store.seed(id, detail.messages)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, detail])
// 하단 고정(따라가기). 사용자 "제스처"로만 판단 — 강제 점프한 스크롤은 wheel/touch
// 이벤트를 안 쏘니 안 꼬임(onScroll 로 하면 점프가 stick 을 도로 켜서 못 벗어남).
const stickRef = useRef(true)
const restick = () => {
const el = scrollRef.current
if (el) stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80
}
const onWheel = (e: WheelEvent<HTMLDivElement>) => {
if (e.deltaY < 0)
stickRef.current = false // 위로 굴리면 즉시 따라가기 해제
else restick() // 아래로 굴려 바닥 닿으면 재개
}
// 스트리밍 중엔 rAF 로 매 프레임 바닥 고정. 타자기 reveal 이 프레임마다 내용을 늘려서
// messages 변화만으론 못 따라가 튐 — 연속 고정해야 안 번쩍임.
useEffect(() => {
if (!busy) return
let raf = 0
const follow = () => {
const el = scrollRef.current
if (el && stickRef.current) el.scrollTop = el.scrollHeight
raf = requestAnimationFrame(follow)
}
raf = requestAnimationFrame(follow)
return () => cancelAnimationFrame(raf)
}, [busy])
// 유휴 상태 메시지 추가(전송/세션 seed) 시 한 번 바닥으로.
useEffect(() => {
const el = scrollRef.current
if (el && stickRef.current) el.scrollTop = el.scrollHeight
}, [messages])
const session: SnapSession = detail ?? {
id,
title: "새 대화 세션",
titleLlm: null,
isGenerating: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
// 코드블럭 번호를 "실제 렌더된 DOM 순서"로 단일 부여 — 코드블록·NavRail 이 같은 근원을 씀.
// 매 커밋마다 재적용(React 가 prop 값으로 되돌려도 paint 전에 덮음). nav 목록은 바뀔 때만 setState.
useLayoutEffect(() => {
const els = scrollRef.current?.querySelectorAll<HTMLElement>("[data-code-block]")
const list: { index: number; lang: string }[] = []
els?.forEach((el, i) => {
el.dataset.codeBlock = String(i)
const num = el.querySelector("[data-code-num]")
if (num) num.textContent = String(i + 1)
list.push({
index: i,
lang: (el.dataset.codeLang ?? "code").toUpperCase(),
})
})
setNavBlocks((prev) =>
prev.length === list.length &&
prev.every((b, i) => b.index === list[i].index && b.lang === list[i].lang)
? prev
: list
)
})
return (
<div className="flex h-full flex-col">
<ChatHeader session={session} onBack={() => navigate(PATHS.SNAP)} />
<div className="flex min-h-0 flex-1">
<NavRail blocks={navBlocks} containerRef={scrollRef} />
<div
ref={scrollRef}
onWheel={onWheel}
onTouchMove={restick}
className="min-w-0 flex-1 overflow-y-auto"
>
<div className="theme-light mx-auto flex max-w-2xl flex-col gap-4 p-4">
{messages.map((m, i) => {
const isLiveLast =
busy && i === messages.length - 1 && m.role === "assistant" && !m.frozen
if (isLiveLast) {
return (
<div key={m.id} className="flex flex-col items-start gap-1">
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
SNAP MATE
</span>
<div className="border-border bg-card text-card-foreground w-full rounded-lg border px-3 py-2 text-sm leading-[1.9]">
{m.content === "" ? (
// 첫 토큰 오기 전 빈 시간 메움 — 시머 텍스트로 "생각 중" 신호
<span className="shimmer-text text-sm"> </span>
) : (
<StreamingText
text={m.content}
isStreaming={isStreaming}
cps={{ baseCps: 200, maxCps: 200, startEmpty: m.reveal }}
onRevealEnd={() => useSnapChatStore.getState().setRevealing(false)}
/>
)}
</div>
</div>
)
}
if (m.frozen && m.role === "assistant") {
return (
<div key={m.id} className="flex flex-col gap-2">
<Message
role={m.role}
content={m.content}
totalTokens={m.totalTokens}
elapsedMs={m.elapsedMs}
/>
<StoppedNotice onRetry={retry} disabled={busy} />
</div>
)
}
return (
<Message
key={m.id}
role={m.role}
content={m.content}
totalTokens={m.totalTokens}
elapsedMs={m.elapsedMs}
/>
)
})}
{generatingRemotely && (
<div className="flex flex-col items-start gap-1">
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
SNAP MATE
</span>
<div className="border-border bg-card text-card-foreground w-full rounded-lg border px-3 py-2 text-sm leading-[1.9]">
<span className="shimmer-text text-sm"> </span>
</div>
</div>
)}
</div>
</div>
</div>
<Composer
onSend={(t, images) => {
stickRef.current = true // 전송 시 바닥 고정 재개 → 새 질문/답변 따라감
void send(t, images)
}}
busy={busy || generatingRemotely}
onStop={stop}
placeholder="ABAP · CDS · 에러 로그를 붙여넣거나 질문하세요…"
/>
</div>
)
}
@@ -0,0 +1,203 @@
import { useEffect, useRef, useState } from "react"
import { useNavigate } from "react-router-dom"
import { ChevronLeft, ChevronRight, Plus } from "lucide-react"
import { PATHS } from "@/config/routes"
import { useDebounce } from "@/lib/hooks/useDebounce"
import { hideWindow } from "@/lib/bridge/webviewBridge"
import type { SnapSession, SnapMessage } from "../contract/types"
import { Kbd } from "@/shared/components/Kbd"
import { useSessionList, useSearchMessages } from "../api/snap.api"
import { SessionSearch } from "../components/SessionSearch"
import { SessionCard } from "../components/SessionCard"
import { SearchHitCard } from "../components/SearchHitCard"
import { useEscapeKey } from "../hooks/useEscapeKey"
export default function SessionListPage() {
const navigate = useNavigate()
const [q, setQ] = useState("")
const debouncedQ = useDebounce(q, 300)
const searching = debouncedQ.trim().length > 0
// 단계적 Esc 의 끝: 목록/검색 화면에선 검색어 있으면 지우고, 없으면 런처 창을 숨김.
useEscapeKey(() => (q ? setQ("") : hideWindow()))
const [listPage, setListPage] = useState(1)
const [searchPage, setSearchPage] = useState(1)
// 검색어 바뀌면 검색 페이지 1 로 리셋.
useEffect(() => setSearchPage(1), [debouncedQ])
const list = useSessionList(listPage)
const search = useSearchMessages(debouncedQ, searchPage)
const sessions = list.data?.items ?? []
const hits = search.data?.items ?? []
// 검색 여부에 따라 활성 쿼리 스위칭 — 로딩/페이지/개수를 하나로 다룸.
const active = searching ? search : list
const page = searching ? searchPage : listPage
const setPage = searching ? setSearchPage : setListPage
const meta = active.data?.meta
// 목록은 3개 peek 이라 개수는 실제 표시 수로, 검색은 전체 매칭 수로.
const total = searching ? (meta?.totalItems ?? hits.length) : sessions.length
// ── 키보드 목록 이동(raycast식): 검색창 포커스 유지한 채 ↑↓ 이동, Enter 로 열기 ──
const items = searching ? hits : sessions
const [selected, setSelected] = useState(0)
const listRef = useRef<HTMLDivElement>(null)
// 목록/검색 전환·페이지 이동·검색어 변경 시 선택 맨 위로.
useEffect(() => setSelected(0), [searching, page, debouncedQ])
// 항목 수 줄면 선택이 밖으로 안 나가게 클램프.
useEffect(() => setSelected((s) => Math.min(s, Math.max(0, items.length - 1))), [items.length])
// 선택 항목을 보이게 스크롤.
useEffect(() => {
listRef.current
?.querySelector<HTMLElement>('[data-snap-selected="true"]')
?.scrollIntoView({ block: "nearest" })
}, [selected])
// 전역 키 핸들러. 최신 목록/선택은 ref 로 읽어 리스너는 1회만 등록.
const navRef = useRef({ items, searching, selected })
navRef.current = { items, searching, selected }
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.isComposing) return // 다이얼로그 처리분·한글 조합중 무시
const { items, selected, searching } = navRef.current
if (e.key === "ArrowDown") {
if (!items.length) return
e.preventDefault()
setSelected((s) => Math.min(s + 1, items.length - 1))
} else if (e.key === "ArrowUp") {
if (!items.length) return
e.preventDefault()
setSelected((s) => Math.max(s - 1, 0))
} else if (e.key === "Enter") {
const it = items[selected]
if (it)
navigate(`/snap/s/${searching ? (it as SnapMessage).sessionId : (it as SnapSession).id}`)
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [navigate])
return (
<div className="scrollbar-hide mx-auto flex h-full max-w-2xl flex-col gap-4 overflow-y-auto p-6">
<div className="flex items-center gap-3">
<div className="bg-primary text-primary-foreground grid size-9 flex-none place-items-center rounded-md font-serif text-lg font-bold italic">
S
</div>
<div className="flex flex-col">
<span className="font-serif text-base font-semibold italic"> </span>
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
Snap Mate · Workspace
</span>
</div>
<button
type="button"
onClick={() => navigate(PATHS.SNAP_NEW)}
title="새 대화 (Ctrl+N)"
className="bg-primary text-primary-foreground ml-auto grid size-9 flex-none place-items-center rounded-md transition-opacity hover:opacity-90"
>
<Plus className="size-4" />
</button>
</div>
<SessionSearch value={q} onChange={setQ} />
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-mono text-[10px] tracking-wide uppercase">
{searching ? "검색 결과 (본문)" : "최근 진행 대화"}
</span>
<span className="border-border text-muted-foreground rounded-full border px-2 font-mono text-[10px]">
{total}
</span>
</div>
<div ref={listRef} className="flex flex-col gap-2">
{active.isLoading && (
<div className="text-muted-foreground py-8 text-center font-mono text-xs">
</div>
)}
{!active.isLoading && searching && hits.length === 0 && (
<div className="text-muted-foreground py-8 text-center font-mono text-xs">
· NO MATCH
</div>
)}
{!active.isLoading && !searching && sessions.length === 0 && (
<div className="text-muted-foreground py-8 text-center font-mono text-xs">
</div>
)}
{searching
? hits.map((h, i) => (
<SearchHitCard
key={`${h.sessionId}-${h.createdAt}-${i}`}
hit={h}
query={debouncedQ.trim()}
onOpen={(id) => navigate(`/snap/s/${id}`)}
selected={i === selected}
/>
))
: sessions.map((s, i) => (
<SessionCard
key={s.id}
session={s}
onOpen={(id) => navigate(`/snap/s/${id}`)}
selected={i === selected}
/>
))}
{searching && meta && meta.totalPages > 1 && (
<div className="text-muted-foreground mt-1 flex items-center justify-center gap-3 font-mono text-xs">
<button
type="button"
onClick={() => setPage((p) => p - 1)}
disabled={!meta.hasPreviousPage || active.isFetching}
className="border-border hover:border-ring hover:text-foreground disabled:hover:border-border grid size-7 place-items-center rounded-md border transition-colors disabled:opacity-40"
title="이전"
>
<ChevronLeft className="size-4" />
</button>
<span className="tabular-nums">
{page} / {meta.totalPages}
</span>
<button
type="button"
onClick={() => setPage((p) => p + 1)}
disabled={!meta.hasNextPage || active.isFetching}
className="border-border hover:border-ring hover:text-foreground disabled:hover:border-border grid size-7 place-items-center rounded-md border transition-colors disabled:opacity-40"
title="다음"
>
<ChevronRight className="size-4" />
</button>
</div>
)}
</div>
<div className="text-muted-foreground border-border bg-background/95 sticky bottom-0 -mx-6 mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 border-t px-6 pt-2 font-mono text-[10px] backdrop-blur">
<span className="flex items-center gap-1">
<Kbd></Kbd>
<Kbd></Kbd>
</span>
<span className="flex items-center gap-1">
<Kbd></Kbd>
</span>
<span className="flex items-center gap-1">
<Kbd>Ctrl</Kbd>
<Kbd>N</Kbd>
</span>
<span className="flex items-center gap-1">
<Kbd>Esc</Kbd>
</span>
</div>
</div>
)
}
@@ -0,0 +1,153 @@
import { describe, it, expect, beforeEach } from "vitest"
import { useSnapChatStore } from "./snapChatStore"
beforeEach(() => useSnapChatStore.getState().reset())
describe("snapChatStore", () => {
it("seed 로 세션 과거대화를 채운다", () => {
useSnapChatStore
.getState()
.seed("s1", [{ sessionId: "s1", role: "user", content: "안녕", createdAt: "" }])
const s = useSnapChatStore.getState()
expect(s.sessionId).toBe("s1")
expect(s.messages).toHaveLength(1)
expect(s.messages[0].content).toBe("안녕")
})
it("applyUsage: 마지막 assistant 에 전체토큰·시간 붙이고 세션 점유량 갱신", () => {
const st = useSnapChatStore.getState()
st.addUserMessage("q")
st.startAssistantMessage()
st.appendChunk("답변")
st.applyUsage({ used: 3200, limit: 128000, elapsedMs: 4100 })
const s = useSnapChatStore.getState()
expect(s.messages.at(-1)!.totalTokens).toBe(3200)
expect(s.messages.at(-1)!.elapsedMs).toBe(4100)
expect(s.sessionUsed).toBe(3200)
expect(s.sessionLimit).toBe(128000)
})
it("seed: 마지막 assistant 답변 토큰으로 세션 점유량 유도", () => {
useSnapChatStore.getState().seed("s1", [
{ sessionId: "s1", role: "user", content: "q", createdAt: "" },
{
sessionId: "s1",
role: "assistant",
content: "a",
createdAt: "",
inputTokens: 2700,
outputTokens: 500,
elapsedMs: 4100,
},
])
const s = useSnapChatStore.getState()
expect(s.messages.at(-1)!.totalTokens).toBe(3200) // input+output
expect(s.sessionUsed).toBe(3200)
})
it("user 메시지 + assistant placeholder + 청크 누적", () => {
const st = useSnapChatStore.getState()
st.addUserMessage("질문")
st.startAssistantMessage()
st.appendChunk("답")
st.appendChunk("변")
const msgs = useSnapChatStore.getState().messages
expect(msgs.map((m) => m.role)).toEqual(["user", "assistant"])
expect(msgs[1].content).toBe("답변")
})
it("stop 은 마지막 assistant 를 frozen 처리한다", () => {
const st = useSnapChatStore.getState()
st.addUserMessage("q")
st.startAssistantMessage()
st.setStreaming(true)
st.stop()
const last = useSnapChatStore.getState().messages.at(-1)!
expect(last.frozen).toBe(true)
expect(useSnapChatStore.getState().isStreaming).toBe(false)
})
it("dropEmptyAssistantTail: 토큰 0개면 꼬리 빈 assistant 버블 제거", () => {
const s = useSnapChatStore.getState()
s.reset()
s.addUserMessage("hi")
s.startAssistantMessage()
expect(useSnapChatStore.getState().messages).toHaveLength(2)
useSnapChatStore.getState().dropEmptyAssistantTail()
const msgs = useSnapChatStore.getState().messages
expect(msgs).toHaveLength(1)
expect(msgs[0].role).toBe("user")
})
it("dropEmptyAssistantTail: 내용 있으면 안 지움", () => {
const s = useSnapChatStore.getState()
s.reset()
s.addUserMessage("hi")
s.startAssistantMessage()
s.appendChunk("답")
useSnapChatStore.getState().dropEmptyAssistantTail()
expect(useSnapChatStore.getState().messages).toHaveLength(2)
})
it("startAssistantMessage 는 isRevealing 을 켠다", () => {
const s = useSnapChatStore.getState()
s.reset()
s.startAssistantMessage()
expect(useSnapChatStore.getState().isRevealing).toBe(true)
})
it("setRevealing 으로 끌 수 있다", () => {
const s = useSnapChatStore.getState()
s.reset()
s.setRevealing(true)
s.setRevealing(false)
expect(useSnapChatStore.getState().isRevealing).toBe(false)
})
it("stop 은 isStreaming/isRevealing 을 모두 끄고 마지막 assistant 를 frozen 처리", () => {
const s = useSnapChatStore.getState()
s.reset()
s.addUserMessage("hi")
s.startAssistantMessage()
s.appendChunk("부분")
s.setStreaming(true)
useSnapChatStore.getState().stop()
const st = useSnapChatStore.getState()
expect(st.isStreaming).toBe(false)
expect(st.isRevealing).toBe(false)
expect(st.messages.at(-1)!.frozen).toBe(true)
})
it("appendRecoveredAssistant: 완성 답변 꼬리 추가 + reveal 플래그 + 점유량 갱신", () => {
useSnapChatStore
.getState()
.seed("s1", [{ sessionId: "s1", role: "user", content: "q", createdAt: "" }])
useSnapChatStore.getState().appendRecoveredAssistant({
sessionId: "s1",
role: "assistant",
content: "복구된 답변",
createdAt: "",
inputTokens: 3000,
outputTokens: 200,
elapsedMs: 5000,
})
const s = useSnapChatStore.getState()
const last = s.messages.at(-1)!
expect(last.role).toBe("assistant")
expect(last.content).toBe("복구된 답변")
expect(last.reveal).toBe(true)
expect(last.totalTokens).toBe(3200)
expect(last.elapsedMs).toBe(5000)
expect(s.isRevealing).toBe(true) // 타자기 reveal 시작
expect(s.sessionUsed).toBe(3200)
})
it("dropEmptyAssistantTail 은 드롭 시 isRevealing 도 끈다", () => {
const s = useSnapChatStore.getState()
s.reset()
s.addUserMessage("hi")
s.startAssistantMessage() // isRevealing=true, 빈 assistant
useSnapChatStore.getState().dropEmptyAssistantTail()
expect(useSnapChatStore.getState().isRevealing).toBe(false)
})
})
@@ -0,0 +1,173 @@
import { create } from "zustand"
import { randomId } from "@/lib/utils/randomId"
import type { SnapMessage, SnapRole } from "../contract/types"
export interface SnapChatMessage {
id: string
role: SnapRole
content: string
/** stop 으로 스트림을 그 자리에서 동결하면 true. */
frozen?: boolean
/** 완성본이 통째로 도착(폴링 복구)해 0부터 타자기로 풀어야 하면 true. */
reveal?: boolean
/** 이 답변 호출의 전체 토큰(input+output). assistant 만. */
totalTokens?: number
/** 이 답변 소요시간(ms). 라이브는 SSE, 과거는 DB. user 는 없음. */
elapsedMs?: number
}
// 세션 컨텍스트 하드 한도 — 백엔드 settings.llm_context_limit 와 동기(reload 시 기본값).
// 라이브 usage 이벤트가 오면 그 값으로 덮어씀.
// ponytail: 프론트 상수 복제. 백엔드가 한도를 바꾸면 여기도 바꿔야 함. 세션 상세 API 에 실어주면 제거 가능.
const DEFAULT_TOKEN_LIMIT = 128_000
// SnapMessage(DB) → 전체토큰. input/output 둘 다 없으면 undefined.
function totalOf(m: SnapMessage): number | undefined {
if (m.inputTokens == null && m.outputTokens == null) return undefined
return (m.inputTokens ?? 0) + (m.outputTokens ?? 0)
}
interface SnapChatState {
sessionId: string | null
messages: SnapChatMessage[]
isStreaming: boolean
isRevealing: boolean
currentController: AbortController | null
/** 현재 세션 컨텍스트 점유 토큰(직전 답변 total) / 한도. 헤더 게이지용. */
sessionUsed: number
sessionLimit: number
/** 설명 모드 — 켜면 배경·원리까지. 매 전송에 실림. seed 로 안 지워져 새 대화↔세션 유지. */
explain: boolean
setExplain: (v: boolean) => void
seed: (sessionId: string, messages: SnapMessage[]) => void
addUserMessage: (content: string) => void
startAssistantMessage: () => void
/** 폴링 복구로 도착한 완성 답변을 꼬리에 붙이고 타자기 reveal 시작. */
appendRecoveredAssistant: (m: SnapMessage) => void
appendChunk: (chunk: string) => void
setStreaming: (v: boolean) => void
setRevealing: (v: boolean) => void
setController: (c: AbortController | null) => void
/** usage 이벤트 반영 — 세션 점유량 갱신 + 마지막 assistant 에 전체토큰·시간 부착. */
applyUsage: (u: { used?: number; limit?: number; elapsedMs?: number }) => void
stop: () => void
/** 전송 실패(토큰 0개 도착)로 꼬리에 남은 빈 assistant 버블 제거. */
dropEmptyAssistantTail: () => void
reset: () => void
getRetryQuery: () => string | null
}
export const useSnapChatStore = create<SnapChatState>((set, get) => ({
sessionId: null,
messages: [],
isStreaming: false,
isRevealing: false,
currentController: null,
sessionUsed: 0,
sessionLimit: DEFAULT_TOKEN_LIMIT,
explain: false,
setExplain: (v) => set({ explain: v }),
seed: (sessionId, messages) => {
// 다른 세션 열 때 진행 중이던 스트림을 끊는다 — 안 끊으면 이전 세션 토큰이 새 버블에 샌다.
get().currentController?.abort()
const mapped: SnapChatMessage[] = messages.map((m) => ({
id: randomId(),
role: m.role,
content: m.content,
totalTokens: totalOf(m),
elapsedMs: m.elapsedMs ?? undefined,
}))
// 세션 점유량 = 마지막 assistant 답변의 전체토큰(≈ 직전 호출 total). 없으면 0.
const lastAssistant = [...mapped].reverse().find((m) => m.role === "assistant")
set({
sessionId,
messages: mapped,
isStreaming: false,
isRevealing: false,
currentController: null,
sessionUsed: lastAssistant?.totalTokens ?? 0,
sessionLimit: DEFAULT_TOKEN_LIMIT,
})
},
addUserMessage: (content) =>
set((s) => ({
messages: [...s.messages, { id: randomId(), role: "user" as SnapRole, content }],
})),
startAssistantMessage: () =>
set((s) => ({
messages: [...s.messages, { id: randomId(), role: "assistant" as SnapRole, content: "" }],
isRevealing: true,
})),
appendRecoveredAssistant: (m) =>
set((s) => ({
messages: [
...s.messages,
{
id: randomId(),
role: "assistant" as SnapRole,
content: m.content,
totalTokens: totalOf(m),
elapsedMs: m.elapsedMs ?? undefined,
reveal: true,
},
],
isRevealing: true,
sessionUsed: totalOf(m) ?? s.sessionUsed,
})),
appendChunk: (chunk) => {
const last = get().messages.at(-1)
if (!last || last.role !== "assistant") return
set((s) => {
const next = [...s.messages]
next[next.length - 1] = { ...last, content: last.content + chunk }
return { messages: next }
})
},
setStreaming: (v) => set({ isStreaming: v }),
setRevealing: (v) => set({ isRevealing: v }),
setController: (c) => set({ currentController: c }),
applyUsage: (u) =>
set((s) => ({
messages: s.messages.map((m, i) =>
i === s.messages.length - 1 && m.role === "assistant"
? { ...m, totalTokens: u.used ?? m.totalTokens, elapsedMs: u.elapsedMs ?? m.elapsedMs }
: m
),
sessionUsed: u.used ?? s.sessionUsed,
sessionLimit: u.limit ?? s.sessionLimit,
})),
stop: () => {
if (!get().isStreaming && !get().isRevealing) return
get().currentController?.abort()
set((s) => ({
messages: s.messages.map((m, i) =>
i === s.messages.length - 1 && m.role === "assistant" ? { ...m, frozen: true } : m
),
isStreaming: false,
isRevealing: false,
currentController: null,
}))
},
dropEmptyAssistantTail: () => {
const last = get().messages.at(-1)
if (!last || last.role !== "assistant" || last.content !== "") return
set((s) => ({ messages: s.messages.slice(0, -1), isRevealing: false }))
},
reset: () => {
get().currentController?.abort()
set({
sessionId: null,
messages: [],
isStreaming: false,
isRevealing: false,
currentController: null,
sessionUsed: 0,
sessionLimit: DEFAULT_TOKEN_LIMIT,
})
},
getRetryQuery: () => {
const msgs = get().messages
for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].role === "user") return msgs[i].content
return null
},
}))
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from "vitest"
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks"
import { snippetsApi } from "./snippets.api"
describe("snippetsApi", () => {
afterEach(() => clearMocks())
it("list — snippets_list 응답을 Snippet[]로 돌려준다", async () => {
const data = [{ name: "A", desc: "", body: "b", category: "코드", usageCount: 0, lastUsed: 0 }]
mockIPC((command) => {
expect(command).toBe("snippets_list")
return data
})
await expect(snippetsApi.list()).resolves.toEqual(data)
})
it("recordUse — snippets_record_use에 name을 넘긴다", async () => {
const data = { name: "FOO", usageCount: 3, lastUsed: 123 }
mockIPC((command, payload) => {
expect(command).toBe("snippets_record_use")
expect(payload).toEqual({ name: "FOO" })
return data
})
await expect(snippetsApi.recordUse("FOO")).resolves.toEqual(data)
})
it("command 오류를 Error로 reject한다", async () => {
mockIPC(() => Promise.reject("디비 오류"))
await expect(snippetsApi.list()).rejects.toThrow("디비 오류")
})
it("create — snippets_create에 snippet을 넘기고 저장 결과를 돌려준다", async () => {
const input = { name: "FOO", desc: "d", body: "b", category: "코드" }
const data = { ...input, usageCount: 0, lastUsed: 0 }
mockIPC((command, payload) => {
expect(command).toBe("snippets_create")
expect(payload).toEqual({ snippet: input })
return data
})
await expect(snippetsApi.create(input)).resolves.toEqual(data)
})
it("create — 중복 이름 오류를 그대로 reject한다", async () => {
mockIPC(() => Promise.reject("이미 있는 이름임: FOO"))
await expect(
snippetsApi.create({ name: "FOO", desc: "", body: "b", category: "코드" })
).rejects.toThrow("이미 있는 이름임: FOO")
})
it("update — snippets_update에 snippet을 넘기고 갱신 결과를 돌려준다", async () => {
const input = { name: "FOO", desc: "새 설명", body: "새 본문", category: "기타" }
const data = { ...input, usageCount: 2, lastUsed: 100 }
mockIPC((command, payload) => {
expect(command).toBe("snippets_update")
expect(payload).toEqual({ snippet: input })
return data
})
await expect(snippetsApi.update(input)).resolves.toEqual(data)
})
it("remove — snippets_delete에 name을 넘기고 성공하면 undefined를 돌려준다", async () => {
mockIPC((command, payload) => {
expect(command).toBe("snippets_delete")
expect(payload).toEqual({ name: "FOO" })
return { name: "FOO" }
})
await expect(snippetsApi.remove("FOO")).resolves.toBeUndefined()
})
})
@@ -0,0 +1,15 @@
// feature 유일한 데이터 진입점(contracts/snippet-data-interface.md). 뒤는 브릿지(SQLite) — @/lib/api/client 안 씀(정당 편차).
import { request } from "@/lib/bridge/snippetBridge"
import type { Snippet, SnippetInput } from "../types"
export const snippetsApi = {
list: () => request<Snippet[]>("snippets.list"),
create: (input: SnippetInput) => request<Snippet>("snippets.create", { snippet: input }),
update: (input: SnippetInput) => request<Snippet>("snippets.update", { snippet: input }),
remove: (name: string) =>
request<{ name: string }>("snippets.delete", { name }).then(() => undefined),
recordUse: (name: string) =>
request<{ name: string; usageCount: number; lastUsed: number }>("snippets.recordUse", {
name,
}),
}
@@ -0,0 +1,30 @@
// category 칩 바 — US3 FR-020. "전체" + 존재하는 category. 키보드 우선(Ctrl+←/→는 페이지에서 처리),
// 클릭도 되지만 주된 조작은 아님. Raycast 결로 은은하게.
interface Props {
categories: string[] // "전체" 포함, 페이지에서 도출해 넘김
selected: string
onSelect: (category: string) => void
}
export function CategoryChips({ categories, selected, onSelect }: Props) {
return (
<div className="border-border scrollbar-hide flex flex-none items-center gap-1.5 overflow-x-auto border-b px-4 py-2">
{categories.map((c) => (
<button
key={c}
type="button"
tabIndex={-1}
onClick={() => onSelect(c)}
data-category-selected={c === selected ? "true" : undefined}
className={`flex-none rounded-full border px-2.5 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
c === selected
? "bg-accent text-accent-foreground border-accent"
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50"
}`}
>
{c}
</button>
))}
</div>
)
}
@@ -0,0 +1,24 @@
import { afterEach, expect, it, vi } from "vitest"
import { cleanup, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { EditDialog } from "./EditDialog"
afterEach(cleanup)
it("팔레트 재소환으로 편집기를 닫으면 삭제 확인도 사라짐", async () => {
const user = userEvent.setup()
const props = {
mode: "edit" as const,
snippet: { name: "A", desc: "", body: "first", category: "코드", usageCount: 0, lastUsed: 0 },
onClose: vi.fn(),
onSave: vi.fn(),
onDelete: vi.fn(),
isSaving: false,
}
const { rerender } = render(<EditDialog {...props} open />)
await user.click(screen.getByRole("button", { name: "삭제" }))
expect(screen.getByRole("alertdialog")).toBeInTheDocument()
rerender(<EditDialog {...props} open={false} snippet={undefined} />)
await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument())
expect(props.onDelete).not.toHaveBeenCalled()
})
@@ -0,0 +1,213 @@
// 생성/편집 공용 다이얼로그(T030). react-hook-form+zod를 사용하되
// shadcn Dialog(중앙 모달)로 — 팔레트 위에 뜨는 Raycast 결 유지.
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog"
import { Button } from "@/shared/ui/button"
import { Input } from "@/shared/ui/input"
import { Textarea } from "@/shared/ui/textarea"
import { Label } from "@/shared/ui/label"
import type { Snippet, SnippetInput } from "../types"
const DEFAULT_CATEGORY = "코드"
const schema = z.object({
name: z.string().min(1, "이름을 입력해야 함"),
desc: z.string(),
body: z.string().min(1, "내용을 입력해야 함"),
category: z.string(),
})
type FormValues = z.infer<typeof schema>
interface EditDialogProps {
open: boolean
mode: "create" | "edit"
snippet?: Snippet
onClose: () => void
onSave: (input: SnippetInput) => void
onDelete?: (name: string) => void
isSaving: boolean
isDeleting?: boolean
}
export function EditDialog({
open,
mode,
snippet,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
}: EditDialogProps) {
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: "", desc: "", body: "", category: DEFAULT_CATEGORY },
})
const [confirmDelete, setConfirmDelete] = useState(false)
// 열릴 때마다 폼 동기화. 생성 모드면 클립보드로 body 프리필(FR-015) — 읽기 실패해도 그냥 빈 값.
useEffect(() => {
setConfirmDelete(false)
if (!open) return
if (mode === "edit" && snippet) {
reset({
name: snippet.name,
desc: snippet.desc,
body: snippet.body,
category: snippet.category,
})
return
}
reset({ name: "", desc: "", body: "", category: DEFAULT_CATEGORY })
// body 필드만 채움 — 통째 reset 쓰면 readText 느릴 때 그새 사용자가 친 name 등이 날아감(NEEDS-FIX #2).
navigator.clipboard
?.readText()
.then((text) => setValue("body", text))
.catch(() => {
/* 클립보드 접근 실패 — 빈 body 로 둠(FR-015 fallback) */
})
}, [open, mode, snippet, reset, setValue])
function onSubmit(data: FormValues) {
onSave({
name: data.name,
desc: data.desc,
body: data.body,
category: data.category || DEFAULT_CATEGORY,
})
}
return (
<>
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{mode === "create" ? "새 스니펫" : "스니펫 편집"}</DialogTitle>
<DialogDescription className="sr-only">
, .
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="snippet-name">
<span className="text-destructive">*</span>
</Label>
<Input
id="snippet-name"
placeholder="예: GIT_COMMIT"
readOnly={mode === "edit"}
className={mode === "edit" ? "bg-muted text-muted-foreground" : undefined}
{...register("name")}
/>
{errors.name && <p className="text-destructive text-xs">{errors.name.message}</p>}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="snippet-desc"></Label>
<Input id="snippet-desc" placeholder="짧은 설명" {...register("desc")} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="snippet-category"></Label>
<Input
id="snippet-category"
placeholder={DEFAULT_CATEGORY}
{...register("category")}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="snippet-body">
<span className="text-destructive">*</span>
</Label>
<Textarea
id="snippet-body"
rows={8}
className="font-mono text-xs"
{...register("body")}
/>
{errors.body && <p className="text-destructive text-xs">{errors.body.message}</p>}
</div>
<DialogFooter className="items-center sm:justify-between">
<div>
{mode === "edit" && onDelete && snippet && (
<Button
type="button"
variant="destructive"
onClick={() => setConfirmDelete(true)}
disabled={isDeleting}
>
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onClose}>
</Button>
<Button type="submit" disabled={isSaving}>
{mode === "create" ? "생성" : "저장"}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={open && confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> ?</AlertDialogTitle>
<AlertDialogDescription>
{snippet && (
<span className="bg-muted mt-2 block rounded px-3 py-2 text-xs">
{snippet.name}
</span>
)}
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => snippet && onDelete?.(snippet.name)}
disabled={isDeleting}
className="bg-destructive hover:bg-destructive/90 text-white"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,12 @@
import type { Snippet } from "../types"
import { CodeBlock } from "@/features/snap/components/CodeBlock"
interface Props {
snippet: Snippet | undefined
}
/** 메타·액션은 목록과 하단에 맡기고 코드만 보여줌. */
export function PreviewPane({ snippet }: Props) {
if (!snippet) return null
return <CodeBlock code={snippet.body} lang="abap" plain />
}
@@ -0,0 +1,44 @@
// NEEDS-FIX #1 검증용 — 단일클릭=선택만(붙여넣기 X), 더블클릭=onEdit.
// userEvent.dblClick 은 실제 브라우저처럼 click 이벤트 2번 + dblclick 순으로 쏴서 회귀 방지에 유효
// (fireEvent.doubleClick 은 dblclick 만 단독 발생시켜 이 시나리오를 못 잡음).
import { describe, it, expect, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { SnippetRow } from "./SnippetRow"
import type { Snippet } from "../types"
const snippet: Snippet = {
name: "FOO",
desc: "설명",
body: "body",
category: "코드",
usageCount: 0,
lastUsed: 0,
}
describe("SnippetRow 클릭 동작", () => {
it("단일클릭 — onSelect만 호출, onEdit은 안 불림(붙여넣기 부작용 없음)", async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onEdit = vi.fn()
render(<SnippetRow snippet={snippet} onSelect={onSelect} onEdit={onEdit} />)
await user.click(screen.getByRole("button"))
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(snippet)
expect(onEdit).not.toHaveBeenCalled()
})
it("더블클릭 — onEdit 호출됨(선택은 부수적으로 여러 번 와도 상관없음)", async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onEdit = vi.fn()
render(<SnippetRow snippet={snippet} onSelect={onSelect} onEdit={onEdit} />)
await user.dblClick(screen.getByRole("button"))
expect(onEdit).toHaveBeenCalledTimes(1)
expect(onEdit).toHaveBeenCalledWith(snippet)
})
})
@@ -0,0 +1,34 @@
import type { Snippet } from "../types"
interface Props {
snippet: Snippet
selected?: boolean
/** 단일클릭 = 선택과 미리보기. 복사는 Enter 전용. */
onSelect: (snippet: Snippet) => void
/** F2·더블클릭 편집 진입(US2 FR-017). */
onEdit?: (snippet: Snippet) => void
}
/** 테두리 없는 결과 행. 직접 선택한 항목만 강조함. */
export function SnippetRow({ snippet, selected, onSelect, onEdit }: Props) {
return (
<button
type="button"
onClick={() => onSelect(snippet)}
onDoubleClick={() => onEdit?.(snippet)}
data-snippet-selected={selected ? "true" : undefined}
aria-pressed={selected ?? false}
title={snippet.desc || snippet.name}
className={`focus-visible:outline-ring flex w-full items-center gap-2 rounded-md px-3 py-2.5 text-left transition-colors focus-visible:outline-2 focus-visible:outline-offset-[-2px] ${
selected ? "bg-accent text-accent-foreground" : "hover:bg-muted/60"
}`}
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{snippet.name}</span>
{snippet.desc && (
<span className="text-muted-foreground truncate text-xs">{snippet.desc}</span>
)}
</span>
</button>
)
}
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest"
import { rankSnippets } from "./ranking"
import type { Snippet } from "../types"
function s(over: Partial<Snippet>): Snippet {
return { name: "", desc: "", body: "", category: "코드", usageCount: 0, lastUsed: 0, ...over }
}
describe("rankSnippets", () => {
it("usageCount 내림차순으로 먼저 정렬", () => {
const items = [s({ name: "A", usageCount: 1 }), s({ name: "B", usageCount: 5 })]
expect(rankSnippets(items).map((i) => i.name)).toEqual(["B", "A"])
})
it("usageCount 같으면 lastUsed 내림차순", () => {
const items = [
s({ name: "A", usageCount: 2, lastUsed: 100 }),
s({ name: "B", usageCount: 2, lastUsed: 200 }),
]
expect(rankSnippets(items).map((i) => i.name)).toEqual(["B", "A"])
})
it("usageCount·lastUsed 둘 다 같으면 name 오름차순", () => {
const items = [
s({ name: "ZEBRA", usageCount: 0, lastUsed: 0 }),
s({ name: "APPLE", usageCount: 0, lastUsed: 0 }),
]
expect(rankSnippets(items).map((i) => i.name)).toEqual(["APPLE", "ZEBRA"])
})
it("원본 배열을 변경하지 않음", () => {
const items = [s({ name: "B", usageCount: 1 }), s({ name: "A", usageCount: 5 })]
const original = [...items]
rankSnippets(items)
expect(items).toEqual(original)
})
})
@@ -0,0 +1,10 @@
// 랭킹 순수함수 — data-model.md §랭킹. FR-007. 정렬 키: count desc, lastUsed desc, name asc.
import type { Snippet } from "../types"
export function rankSnippets(snippets: Snippet[]): Snippet[] {
return [...snippets].sort((a, b) => {
if (a.usageCount !== b.usageCount) return b.usageCount - a.usageCount
if (a.lastUsed !== b.lastUsed) return b.lastUsed - a.lastUsed
return a.name.localeCompare(b.name)
})
}
@@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest"
import { searchSnippets } from "./search"
import type { Snippet } from "../types"
function s(over: Partial<Snippet>): Snippet {
return { name: "", desc: "", body: "", category: "코드", usageCount: 0, lastUsed: 0, ...over }
}
describe("searchSnippets", () => {
const items: Snippet[] = [
s({
name: "GIT_COMMIT",
desc: "커밋 메시지 템플릿",
body: "본문에만 있는 XYZKEYWORD",
category: "코드",
}),
s({ name: "DOCKER_RUN", desc: "도커 실행 커맨드", category: "코드" }),
s({ name: "MEMO", desc: "회의 메모 양식", category: "기타" }),
]
it("빈 쿼리면 전체를 돌려줌", () => {
expect(searchSnippets(items, "")).toHaveLength(3)
})
it("키워드 하나 — name+desc(대소문자 무시)에 포함되면 통과", () => {
const result = searchSnippets(items, "커밋")
expect(result.map((i) => i.name)).toEqual(["GIT_COMMIT"])
})
it("공백으로 나눈 키워드 전부(AND) 만족해야 함", () => {
const result = searchSnippets(items, "도커 커맨드")
expect(result.map((i) => i.name)).toEqual(["DOCKER_RUN"])
})
it("키워드 중 하나라도 안 맞으면 제외", () => {
const result = searchSnippets(items, "도커 없는말")
expect(result).toHaveLength(0)
})
it("대소문자 무시(영문 name 검색)", () => {
const result = searchSnippets(items, "docker")
expect(result.map((i) => i.name)).toEqual(["DOCKER_RUN"])
})
it("body는 검색 대상이 아님 — body에만 있는 키워드는 매칭 안 됨", () => {
const result = searchSnippets(items, "XYZKEYWORD")
expect(result).toHaveLength(0)
})
it("category 지정 시 그 분류로 먼저 제한", () => {
const result = searchSnippets(items, "", "기타")
expect(result.map((i) => i.name)).toEqual(["MEMO"])
})
it("category가 전체면 제한 없음", () => {
expect(searchSnippets(items, "", "전체")).toHaveLength(3)
})
})
@@ -0,0 +1,20 @@
// 검색 순수함수 — data-model.md §검색. FR-005/006 그대로.
import type { Snippet } from "../types"
/**
* category 로 먼저 제한(없거나 "전체"면 무제한) 후, 쿼리를 공백으로 나눈 키워드 전부(AND)가
* name+desc 합친 문자열(대소문자 무시)에 포함되는 것만 남긴다. body 는 검색 대상 아님. 퍼지 없음.
* 빈 쿼리 → (category 제한된) 전체.
*/
export function searchSnippets(snippets: Snippet[], query: string, category?: string): Snippet[] {
const inCategory =
!category || category === "전체" ? snippets : snippets.filter((s) => s.category === category)
const keywords = query.trim().toUpperCase().split(/\s+/).filter(Boolean)
if (keywords.length === 0) return inCategory
return inCategory.filter((s) => {
const haystack = `${s.name} ${s.desc}`.toUpperCase()
return keywords.every((k) => haystack.includes(k))
})
}
@@ -0,0 +1,64 @@
// react-query 배선. contracts/snippet-data-interface.md §react-query 계약을 따름.
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { snippetsApi } from "../api/snippets.api"
import type { SnippetInput } from "../types"
export const SNIPPETS_KEY = "snippets"
// 브릿지 실패는 항상 Error(reject 메시지에 이미 한글 사유가 담김) — HTTP ApiError 가 아니라 이 형태로 분기.
function toastBridgeError(err: unknown, fallback: string) {
toast.error(err instanceof Error ? err.message : fallback)
}
export function useSnippets() {
return useQuery({
queryKey: [SNIPPETS_KEY],
queryFn: () => snippetsApi.list(),
})
}
export function useCreateSnippet() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: SnippetInput) => snippetsApi.create(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [SNIPPETS_KEY] })
toast.success("스니펫 생성됨", { duration: 1000 })
},
onError: (err) => toastBridgeError(err, "스니펫 생성 실패함"),
})
}
export function useUpdateSnippet() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: SnippetInput) => snippetsApi.update(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [SNIPPETS_KEY] })
toast.success("저장됨")
},
onError: (err) => toastBridgeError(err, "저장 실패함"),
})
}
export function useDeleteSnippet() {
const qc = useQueryClient()
return useMutation({
mutationFn: (name: string) => snippetsApi.remove(name),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [SNIPPETS_KEY] })
toast.success("삭제됨")
},
onError: (err) => toastBridgeError(err, "삭제 실패함"),
})
}
/** 붙여넣기 성공 후 usage 기록 — 랭킹 갱신용이라 조용히(토스트 없음). */
export function useRecordUse() {
const qc = useQueryClient()
return useMutation({
mutationFn: (name: string) => snippetsApi.recordUse(name),
onSuccess: () => qc.invalidateQueries({ queryKey: [SNIPPETS_KEY] }),
})
}
@@ -0,0 +1,273 @@
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"
import { toast } from "sonner"
import { hideWindow, isWebView, pasteToApp } from "@/lib/bridge/webviewBridge"
import SnippetPalettePage from "./SnippetPalettePage"
const { createSnippet, recordUse } = vi.hoisted(() => ({
createSnippet: vi.fn(),
recordUse: vi.fn(),
}))
vi.mock("@/lib/bridge/webviewBridge", () => ({
hideWindow: vi.fn(),
startWindowDrag: vi.fn(),
isWebView: vi.fn(() => true),
pasteToApp: vi.fn(),
}))
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock("../components/PreviewPane", () => ({
PreviewPane: ({ snippet }: { snippet?: { body: string } }) =>
snippet ? <pre>{snippet.body}</pre> : null,
}))
vi.mock("../components/EditDialog", () => ({
EditDialog: ({ open, onSave }: { open: boolean; onSave: (input: object) => void }) =>
open ? (
<div role="dialog">
<button onClick={() => onSave({ name: "NEW", desc: "", body: "body", category: "코드" })}>
</button>
</div>
) : null,
}))
vi.mock("../hooks/useSnippets", () => ({
useSnippets: () => ({
data: [
{ name: "A", desc: "", body: "first", category: "코드", usageCount: 0, lastUsed: 0 },
{
name: "B",
desc: "",
body: " 한글\r\n본문\t",
category: "코드",
usageCount: 0,
lastUsed: 0,
},
],
isLoading: false,
}),
useRecordUse: () => ({ mutate: recordUse }),
useCreateSnippet: () => ({ mutate: createSnippet, isPending: false }),
useUpdateSnippet: () => ({}),
useDeleteSnippet: () => ({}),
}))
beforeEach(() => {
userEvent.setup()
vi.mocked(isWebView).mockReturnValue(true)
Element.prototype.scrollIntoView = vi.fn()
})
afterEach(() => {
vi.useRealTimers()
cleanup()
vi.restoreAllMocks()
vi.clearAllMocks()
})
describe("새 스니펫 단축키", () => {
it("Ctrl+N으로 생성창을 열고 기존 Ctrl+2는 무시함", () => {
render(<SnippetPalettePage />)
fireEvent.keyDown(window, { key: "2", ctrlKey: true })
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
fireEvent.keyDown(window, { key: "n", ctrlKey: true })
expect(screen.getByRole("dialog")).toHaveTextContent("새 스니펫")
})
it("생성 성공 뒤 입력창을 1초 유지한 다음 기본 검색 화면으로 돌아감", () => {
vi.useFakeTimers()
createSnippet.mockImplementation((_input, options) => options.onSuccess())
render(<SnippetPalettePage />)
const search = screen.getByRole("textbox")
fireEvent.change(search, { target: { value: "기존 검색" } })
fireEvent.keyDown(window, { key: "n", ctrlKey: true })
fireEvent.click(screen.getByRole("button", { name: "테스트 생성" }))
expect(screen.getByRole("dialog")).toBeInTheDocument()
act(() => vi.advanceTimersByTime(999))
expect(screen.getByRole("dialog")).toBeInTheDocument()
act(() => vi.advanceTimersByTime(1))
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
expect(search).toHaveValue("")
})
})
describe("스니펫 Enter 복사", () => {
it("선택한 본문을 원문 그대로 복사하고 완료 후에만 사용 기록·창 숨김", async () => {
let finish!: () => void
const writeText = vi.fn(
() =>
new Promise<void>((resolve) => {
finish = resolve
})
)
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText)
render(<SnippetPalettePage />)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } })
fireEvent.click(screen.getByText("B"))
fireEvent.keyDown(window, { key: "Enter" })
expect(writeText).toHaveBeenCalledWith(" 한글\r\n본문\t")
expect(hideWindow).not.toHaveBeenCalled()
expect(recordUse).not.toHaveBeenCalled()
fireEvent.keyDown(window, { key: "Enter", repeat: true })
expect(writeText).toHaveBeenCalledTimes(1)
finish()
await waitFor(() => expect(recordUse).toHaveBeenCalledWith("B"))
await waitFor(() => expect(hideWindow).toHaveBeenCalledTimes(1), { timeout: 2000 })
expect(recordUse).toHaveBeenCalledWith("B")
})
it("복사 실패 시 창과 사용 기록을 유지하고 오류 표시", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("denied"))
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText)
render(<SnippetPalettePage />)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } })
fireEvent.keyDown(window, { key: "ArrowDown" })
fireEvent.keyDown(window, { key: "Enter", isComposing: true })
expect(writeText).not.toHaveBeenCalled()
fireEvent.keyDown(window, { key: "Enter" })
await waitFor(() => expect(toast.error).toHaveBeenCalled())
expect(hideWindow).not.toHaveBeenCalled()
expect(recordUse).not.toHaveBeenCalled()
})
it("하단 복사는 클릭과 Enter 모두 원문을 복사하고 팔레트를 열린 채로 둠", async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } })
await user.click(screen.getByRole("button", { name: "B" }))
const copy = screen.getByRole("button", { name: "복사" })
await user.click(copy)
expect(writeText).toHaveBeenCalledWith(" 한글\r\n본문\t")
await user.keyboard("{Enter}")
expect(writeText).toHaveBeenCalledTimes(2)
expect(recordUse).not.toHaveBeenCalled()
expect(hideWindow).not.toHaveBeenCalled()
expect(screen.getByLabelText("코드 미리보기")).toBeInTheDocument()
})
})
describe("Ctrl Enter 앱에 붙여넣기", () => {
it("선택한 원문을 붙여넣고 일반 복사를 실행하지 않으며 키 반복을 무시함", async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
await user.type(screen.getByRole("textbox"), "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, repeat: true })
expect(pasteToApp).toHaveBeenCalledTimes(1)
expect(pasteToApp).toHaveBeenCalledWith(" 한글\r\n본문\t")
expect(writeText).not.toHaveBeenCalled()
expect(hideWindow).not.toHaveBeenCalled()
expect(recordUse).not.toHaveBeenCalled()
})
it("복사 버튼에 포커스가 있어도 Ctrl Enter는 붙여넣고 일반 Enter는 복사함", async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
await user.type(screen.getByRole("textbox"), "B")
await user.click(screen.getByRole("button", { name: "B" }))
screen.getByRole("button", { name: "복사" }).focus()
await user.keyboard("{Control>}{Enter}{/Control}")
expect(pasteToApp).toHaveBeenCalledTimes(1)
expect(pasteToApp).toHaveBeenCalledWith(" 한글\r\n본문\t")
expect(writeText).not.toHaveBeenCalled()
expect(hideWindow).not.toHaveBeenCalled()
await user.keyboard("{Enter}")
expect(writeText).toHaveBeenCalledTimes(1)
expect(writeText).toHaveBeenCalledWith(" 한글\r\n본문\t")
expect(pasteToApp).toHaveBeenCalledTimes(1)
expect(hideWindow).not.toHaveBeenCalled()
expect(recordUse).not.toHaveBeenCalled()
})
it("미선택·조합 중·다른 modifier·편집 중에는 붙여넣지 않음", () => {
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
const input = screen.getByRole("textbox")
fireEvent.change(input, { target: { value: "B" } })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true })
expect(pasteToApp).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: "ArrowDown" })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true, isComposing: true })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true, shiftKey: true })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true, altKey: true })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true, metaKey: true })
fireEvent.keyDown(input, { key: "F2" })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true })
expect(pasteToApp).not.toHaveBeenCalled()
expect(writeText).not.toHaveBeenCalled()
expect(hideWindow).not.toHaveBeenCalled()
})
it("브라우저의 Ctrl Enter는 복사나 붙여넣기로 바뀌지 않음", () => {
vi.mocked(isWebView).mockReturnValue(false)
const writeText = vi.spyOn(navigator.clipboard, "writeText")
render(<SnippetPalettePage />)
const input = screen.getByRole("textbox")
fireEvent.change(input, { target: { value: "B" } })
fireEvent.keyDown(input, { key: "ArrowDown" })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true })
expect(pasteToApp).not.toHaveBeenCalled()
expect(writeText).not.toHaveBeenCalled()
})
})
describe("검색 후 선택할 때만 미리보기", () => {
it("빈 검색 → 결과 → 직접 선택 → 검색 변경과 지우기 순서로 화면이 접힘", () => {
render(<SnippetPalettePage />)
const input = screen.getByRole("textbox")
expect(screen.queryByText("A")).not.toBeInTheDocument()
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
fireEvent.keyDown(window, { key: "ArrowDown" })
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
fireEvent.change(input, { target: { value: "A" } })
expect(screen.getByText("A")).toBeInTheDocument()
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
fireEvent.keyDown(window, { key: "ArrowDown" })
expect(screen.getByLabelText("코드 미리보기")).toHaveTextContent("first")
fireEvent.change(input, { target: { value: "B" } })
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
fireEvent.click(screen.getByText("B"))
expect(screen.getByLabelText("코드 미리보기")).toHaveTextContent("한글")
fireEvent.change(input, { target: { value: " " } })
expect(screen.queryByText("B")).not.toBeInTheDocument()
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
})
it("검색 결과만 보일 때 Enter는 복사하지 않고 재소환은 검색창으로 돌아감", () => {
const writeText = vi.spyOn(navigator.clipboard, "writeText")
render(<SnippetPalettePage />)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } })
fireEvent.keyDown(window, { key: "Enter" })
expect(writeText).not.toHaveBeenCalled()
fireEvent.keyDown(window, { key: "ArrowUp" })
expect(screen.getByLabelText("코드 미리보기")).toHaveTextContent("first")
fireEvent(window, new CustomEvent("bridge:navigate", { detail: { path: "/snippet" } }))
return waitFor(() => {
expect(screen.getByRole("textbox")).toHaveValue("")
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
})
})
it("마우스로 결과를 선택한 뒤 Esc를 누르면 검색창에서 바로 다시 입력할 수 있음", async () => {
const user = userEvent.setup()
render(<SnippetPalettePage />)
const input = screen.getByRole("textbox")
await waitFor(() => expect(input).toHaveFocus())
await user.type(input, "A")
await user.click(screen.getByRole("button", { name: "A" }))
await user.keyboard("{Escape}")
expect(input).toHaveValue("")
expect(input).toHaveFocus()
await user.keyboard("B")
expect(screen.getByText("B")).toBeInTheDocument()
})
})
@@ -0,0 +1,407 @@
import { hostKind, send } from "@/lib/bridge/transport"
// 검색 → 결과 → 직접 선택한 코드 순서로 화면과 데스크톱 창을 펼침.
import { useEffect, useMemo, useRef, useState } from "react"
import { Check, Search } from "lucide-react"
import { hideWindow, isWebView, pasteToApp, startWindowDrag } from "@/lib/bridge/webviewBridge"
import { toast } from "sonner"
import { useEscapeKey } from "@/features/snap/hooks/useEscapeKey"
import { CodeActions } from "@/features/snap/components/CodeBlock"
import { searchSnippets } from "../core/search"
import { rankSnippets } from "../core/ranking"
import {
useSnippets,
useRecordUse,
useCreateSnippet,
useUpdateSnippet,
useDeleteSnippet,
} from "../hooks/useSnippets"
import { SnippetRow } from "../components/SnippetRow"
import { PreviewPane } from "../components/PreviewPane"
import { EditDialog } from "../components/EditDialog"
import { CategoryChips } from "../components/CategoryChips"
import type { Snippet, SnippetInput } from "../types"
const ALL_CATEGORY = "전체"
export default function SnippetPalettePage() {
const [q, setQ] = useState("")
const { data: snippets = [], isLoading, error } = useSnippets()
const recordUse = useRecordUse()
const createSnippet = useCreateSnippet()
const updateSnippet = useUpdateSnippet()
const deleteSnippet = useDeleteSnippet()
// 생성/편집 다이얼로그 — null 이면 닫힘. 열려있는 동안엔 팔레트 전역 단축키 비활성(아래 keydown 가드).
const [dialogState, setDialogState] = useState<{
mode: "create" | "edit"
snippet?: Snippet
} | null>(null)
const openCreate = () => setDialogState({ mode: "create" })
const openEdit = (snippet: Snippet) => setDialogState({ mode: "edit", snippet })
const closeDialog = () => setDialogState(null)
const createDoneTimerRef = useRef<number | null>(null)
function returnToSearch() {
setQ("")
setCategory(ALL_CATEGORY)
setSelection(null)
closeDialog()
requestAnimationFrame(() => inputRef.current?.focus())
}
function saveSnippet(input: SnippetInput) {
if (dialogState?.mode === "create") {
createSnippet.mutate(input, {
// 성공 toast가 입력창 위에서 1초 보인 뒤 기본 검색 화면으로 돌아감.
onSuccess: () => {
createDoneTimerRef.current = window.setTimeout(returnToSearch, 1000)
},
})
return
}
updateSnippet.mutate(input, { onSuccess: closeDialog })
}
function removeSnippet(name: string) {
deleteSnippet.mutate(name, { onSuccess: closeDialog })
}
// category 칩(US3 FR-020) — 목록에서 존재하는 category 도출 + 맨 앞 "전체".
const categories = useMemo(() => {
const found = Array.from(new Set(snippets.map((s) => s.category))).sort()
return [ALL_CATEGORY, ...found]
}, [snippets])
const [category, setCategory] = useState(ALL_CATEGORY)
// 선택 중이던 category의 스니펫이 다 사라지면(삭제 등) "전체"로 복귀.
useEffect(() => {
if (!categories.includes(category)) setCategory(ALL_CATEGORY)
}, [categories, category])
// 검색·랭킹은 로컬 순수함수라 서버 왕복 없음 — debounce 불필요, 매 타이핑 즉시 반영.
const hasQuery = q.trim().length > 0
const results = useMemo(
() => (hasQuery ? rankSnippets(searchSnippets(snippets, q, category)) : []),
[snippets, q, category, hasQuery]
)
const [selection, setSelection] = useState<{
name: string
query: string
category: string
} | null>(null)
const selected =
selection?.query === q && selection.category === category
? results.findIndex((snippet) => snippet.name === selection.name)
: -1
const preview = results[selected]
const stage = dialogState ? "editor" : preview ? "preview" : hasQuery ? "results" : "search"
useEffect(() => {
if (hostKind() === "tauri") send({ type: "window.snippetLayout", stage })
}, [stage])
const listRef = useRef<HTMLDivElement>(null)
// 리스트/프리뷰 분할 비율(%) — 가운데 핸들 드래그(또는 ←/→)로 조절, localStorage 저장. 기본 3:7.
const splitRef = useRef<HTMLDivElement>(null)
const draggingRef = useRef(false)
const [listPct, setListPct] = useState(() => {
const saved = Number(localStorage.getItem("snippet.splitPct"))
return saved >= 20 && saved <= 70 ? saved : 30 // 기본 리스트 30%(프리뷰 70% = 3:7)
})
useEffect(() => {
localStorage.setItem("snippet.splitPct", String(Math.round(listPct)))
}, [listPct])
useEffect(() => {
const onMove = (e: MouseEvent) => {
if (!draggingRef.current || !splitRef.current) return
const rect = splitRef.current.getBoundingClientRect()
const pct = ((e.clientX - rect.left) / rect.width) * 100
setListPct(Math.min(70, Math.max(20, pct))) // 20~70%로 클램프
}
const onUp = () => {
draggingRef.current = false
document.body.style.userSelect = ""
}
window.addEventListener("mousemove", onMove)
window.addEventListener("mouseup", onUp)
return () => {
window.removeEventListener("mousemove", onMove)
window.removeEventListener("mouseup", onUp)
document.body.style.userSelect = ""
}
}, [])
useEffect(() => {
listRef.current
?.querySelector<HTMLElement>('[data-snippet-selected="true"]')
?.scrollIntoView({ block: "nearest" })
}, [selected])
// 단계적 Esc: 검색어 있으면 비우고, 없으면 팔레트(런처 창) 숨김(FR-013).
// 다이얼로그 떠있는 동안엔 Radix 자체 Escape 처리(닫기)에 맡기고 팔레트 쪽은 아무것도 안 함.
useEscapeKey(() => {
if (dialogState) return
if (q) {
setQ("")
setSelection(null)
inputRef.current?.focus()
} else hideWindow()
})
// 뜨자마자 검색창 포커스 + 재소환에도 다시 잡음.
// rAF로 webview/DOM 안정화 후 focus(마운트·네비 직후 즉시 focus가 씹히는 것 대비).
// window focus(창 재표시) + bridge:navigate(Ctrl+Shift+7 소환, 같은 route여도 매번) 둘 다 청취.
const inputRef = useRef<HTMLInputElement>(null)
useEffect(
() => () => {
if (createDoneTimerRef.current !== null) window.clearTimeout(createDoneTimerRef.current)
},
[]
)
useEffect(() => {
const focus = () => requestAnimationFrame(() => inputRef.current?.focus())
focus()
const onSummon = (e: Event) => {
if ((e as CustomEvent<{ path?: string }>).detail?.path === "/snippet") {
setQ("")
setCategory(ALL_CATEGORY)
setSelection(null)
setDialogState(null)
focus()
}
}
window.addEventListener("focus", focus)
window.addEventListener("bridge:navigate", onSummon)
return () => {
window.removeEventListener("focus", focus)
window.removeEventListener("bridge:navigate", onSummon)
}
}, [])
const copyingRef = useRef(false)
async function choose(snippet: Snippet) {
if (copyingRef.current) return
copyingRef.current = true
try {
await navigator.clipboard.writeText(snippet.body)
recordUse.mutate(snippet.name)
toast.success("복사됨!", {
duration: 1000,
icon: (
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-green-600 text-white">
<Check className="size-3.5" strokeWidth={3} aria-hidden="true" />
</span>
),
})
await new Promise((resolve) => window.setTimeout(resolve, 1000))
hideWindow()
} catch {
toast.error("클립보드에 복사하지 못했어. 다시 시도해줘.")
} finally {
copyingRef.current = false
}
}
// 전역 키 핸들러: ↑↓ 이동 + Enter 복사 + Ctrl+Enter 붙여넣기 + F2 편집 + Ctrl+N 생성 + Ctrl+←/→ category 이동.
// 최신 목록/선택/다이얼로그/category 상태는 ref 로 읽어 리스너 1회만 등록.
const stateRef = useRef({ results, selected, dialogOpen: false, categories, category, q })
stateRef.current = {
results,
selected,
dialogOpen: dialogState !== null,
categories,
category,
q,
}
function selectIndex(index: number) {
const { results, q, category } = stateRef.current
const item = results[index]
setSelection(item ? { name: item.name, query: q, category } : null)
}
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.isComposing) return // IME 조합 중엔 Enter로 복사 안 함
const { results, selected, dialogOpen, categories, category } = stateRef.current
if (dialogOpen) return // 다이얼로그 입력 중엔 팔레트 전역 단축키(↑↓/Enter/F2 등) 비활성 — 폼이 처리
if (e.key === "ArrowDown") {
if (!results.length) return
e.preventDefault()
selectIndex(Math.min(selected + 1, results.length - 1))
} else if (e.key === "ArrowUp") {
if (!results.length) return
e.preventDefault()
selectIndex(selected < 0 ? results.length - 1 : Math.max(selected - 1, 0))
} else if (e.key === "Enter" && e.ctrlKey) {
// 버튼의 기본 Enter보다 먼저 처리하고, 브라우저·다른 조합도 일반 복사로 빠지지 않게 막음.
e.preventDefault()
if (!isWebView() || e.repeat || e.shiftKey || e.altKey || e.metaKey) return
const item = results[selected]
if (item) pasteToApp(item.body)
} else if (e.key === "Enter") {
// 실제 버튼의 Enter는 해당 버튼을 실행함. 검색창·선택 행에서만 즉시 복사.
if (
e.target instanceof HTMLElement &&
e.target.closest("button") &&
!e.target.closest('[data-snippet-selected="true"]')
)
return
e.preventDefault()
const item = results[selected]
if (item && !e.repeat) void choose(item)
} else if (e.key === "F2") {
// 선택된 행 편집(FR-017).
e.preventDefault()
const item = results[selected]
if (item) openEdit(item)
} else if (
e.ctrlKey &&
!e.altKey &&
!e.shiftKey &&
!e.metaKey &&
(e.key === "n" || e.key === "N")
) {
// 현재 화면의 새 항목 단축키: 스니펫 생성(FR-015).
e.preventDefault()
openCreate()
} else if (e.ctrlKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
// category 칩 좌우 이동, 끝에서 clamp(FR-020).
e.preventDefault()
const idx = categories.indexOf(category)
const nextIdx =
e.key === "ArrowLeft" ? Math.max(idx - 1, 0) : Math.min(idx + 1, categories.length - 1)
setSelection(null)
setCategory(categories[nextIdx])
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="bg-background text-foreground flex h-screen flex-col">
{/* 브랜드 헤더 — snap(Chat Everywhere)과 동일 + 창 드래그 영역(프레임리스 제목표시줄 대체) */}
{hostKind() !== "tauri" && (
<header
role="presentation"
onMouseDown={startWindowDrag}
className="border-border flex h-9 flex-none items-center gap-2 border-b px-4 select-none"
>
<span className="font-mono text-[11px] font-semibold tracking-[0.12em]">
Snippet Everywhere
</span>
</header>
)}
{/* 상단 검색 입력 */}
<div className="border-border flex h-14 flex-none items-center gap-3 border-b px-4">
<Search className="text-muted-foreground size-5 flex-none" />
<input
ref={inputRef}
type="text"
aria-label="스니펫 검색"
value={q}
onChange={(e) => {
setQ(e.target.value)
setSelection(null)
}}
placeholder="스니펫 검색…"
className="placeholder:text-muted-foreground w-full bg-transparent text-base outline-none"
/>
</div>
{/* category 칩 바 — 2개 이상(전체 포함)일 때만 보여줌 */}
{hasQuery && categories.length > 1 && (
<CategoryChips
categories={categories}
selected={category}
onSelect={(next) => {
setCategory(next)
setSelection(null)
}}
/>
)}
{/* 본문: 결과 리스트(좌) + 프리뷰(우) */}
{hasQuery && (
<div ref={splitRef} className="flex min-h-0 flex-1">
<div
ref={listRef}
style={{ width: preview ? `${listPct}%` : "100%" }}
className="scrollbar-hide min-w-0 flex-none space-y-1 overflow-y-auto p-2"
>
{isLoading && (
<div className="text-muted-foreground py-8 text-center text-xs"> </div>
)}
{error && (
<div role="alert" className="text-destructive py-8 text-center text-xs">
: {error.message}
</div>
)}
{!isLoading && !error && snippets.length === 0 && (
<div className="text-muted-foreground flex flex-col items-center gap-1 py-10 text-center text-xs">
<span> </span>
<span> </span>
</div>
)}
{!isLoading && snippets.length > 0 && results.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-xs"> </div>
)}
{results.map((r, i) => (
<SnippetRow
key={r.name}
snippet={r}
selected={i === selected}
onSelect={() => selectIndex(i)}
onEdit={openEdit}
/>
))}
</div>
{/* 드래그 핸들(button=네이티브 인터랙티브) — 드래그 또는 포커스 후 ←/→ 로 비율 조절 */}
{preview && (
<>
<button
type="button"
aria-label="미리보기 크기 조절 (드래그 또는 ←/→)"
onMouseDown={() => {
draggingRef.current = true
document.body.style.userSelect = "none"
}}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") setListPct((p) => Math.max(20, p - 2))
else if (e.key === "ArrowRight") setListPct((p) => Math.min(70, p + 2))
}}
className="bg-border/60 hover:bg-accent focus-visible:bg-accent w-1.5 flex-none cursor-col-resize p-0 outline-none"
/>
<aside aria-label="코드 미리보기" className="min-h-0 min-w-0 flex-1 overflow-hidden">
<PreviewPane snippet={preview} />
</aside>
</>
)}
</div>
)}
{hasQuery && (
<footer className="border-border text-muted-foreground flex min-h-9 shrink-0 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t px-4 py-1 text-xs">
<span role="status">{isLoading ? "검색 준비 중" : `${results.length}개 결과`}</span>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span>
{preview ? "Enter 복사 후 닫기 · F2 편집" : "↑ ↓ 선택해서 미리보기"} · Esc
</span>
{preview && isWebView() && <span>Ctrl+Enter </span>}
{preview && <CodeActions key={preview.name} code={preview.body} />}
</div>
</footer>
)}
<EditDialog
open={dialogState !== null}
mode={dialogState?.mode ?? "create"}
snippet={dialogState?.snippet}
onClose={closeDialog}
onSave={saveSnippet}
onDelete={removeSnippet}
isSaving={createSnippet.isPending || updateSnippet.isPending}
isDeleting={deleteSnippet.isPending}
/>
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
// 로컬 엔티티 — 서버 DTO 아님(뒤가 C# 브릿지/SQLite). src/types/api.ts 에 안 넣음.
// data-model.md §프론트 타입 그대로.
export interface Snippet {
name: string
desc: string
body: string
category: string
usageCount: number // usage.count (없으면 0)
lastUsed: number // usage.last_used unix초 (없으면 0)
}
export interface SnippetInput {
// 생성/수정 입력
name: string
desc: string
body: string
category: string
}