style(frontend): ABAP_OPENCODE 디자인 시스템으로 맞춤 + 화면 재구성
DESIGN_SYSTEM.md 의 Dark Workspace 토큰을 shadcn 토큰으로 이식(다크 기본, radius 8px, success/warning/shadow-float), 시스템 산세리프, PwC 로고(LogoMark). 목록·새 대화·대화· 로그인 화면을 가운데 760px 열로 통일하고, 떠 있던 테마·로그아웃 상자는 상단 바로, 말풍선은 다크 토큰(AI 답변은 상자 없이), ABAP 흰 에디터 룩은 라이트 모드에서만. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,7 @@ describe("LoginForm 회귀", () => {
|
||||
it("이메일/비번 입력 + submit → useLogin.mutate 호출", async () => {
|
||||
renderForm()
|
||||
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
|
||||
await userEvent.type(screen.getByLabelText("비번"), "secret")
|
||||
await userEvent.type(screen.getByLabelText("비밀번호"), "secret")
|
||||
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
|
||||
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
@@ -47,7 +47,7 @@ describe("LoginForm 회귀", () => {
|
||||
it("무효 이메일 → 에러 메시지 + mutate 호출 X", async () => {
|
||||
const { container } = renderForm()
|
||||
await userEvent.type(screen.getByLabelText("이메일"), "not-email")
|
||||
await userEvent.type(screen.getByLabelText("비번"), "secret")
|
||||
await userEvent.type(screen.getByLabelText("비밀번호"), "secret")
|
||||
// type="email" 의 HTML5 native validation 이 jsdom 에서 submit 을 막아서
|
||||
// userEvent.click(submit) 으로는 zod 까지 안 감 → form.submit 직접 발사
|
||||
const form = container.querySelector("form")!
|
||||
@@ -68,7 +68,7 @@ describe("LoginForm 회귀", () => {
|
||||
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.type(screen.getByLabelText("비밀번호"), "secret")
|
||||
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
|
||||
|
||||
const [, opts] = mutate.mock.calls[0]
|
||||
@@ -79,7 +79,7 @@ describe("LoginForm 회귀", () => {
|
||||
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.type(screen.getByLabelText("비밀번호"), "secret")
|
||||
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
|
||||
|
||||
const [, opts] = mutate.mock.calls[0]
|
||||
|
||||
@@ -36,9 +36,9 @@ describe("LoginPage", () => {
|
||||
expect(section.compareDocumentPosition(form) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it("h1 '로그인' 표시", () => {
|
||||
it("h1 'CodeAssist' 브랜드 제목 표시", () => {
|
||||
renderPage()
|
||||
expect(screen.getByRole("heading", { level: 1, name: "로그인" })).toBeInTheDocument()
|
||||
expect(screen.getByRole("heading", { level: 1, name: "CodeAssist" })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("로그인 입력 영역을 화면 가운데 정렬", () => {
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
import { LogoMark } from "@/shared/components/LogoMark"
|
||||
import LoginForm from "../components/LoginForm"
|
||||
import EntraLoginSection from "../components/EntraLoginSection"
|
||||
|
||||
/** 로그인 — ABAP_OPENCODE 로그인 카드와 같은 구성(로고 · 제목 · 부제 · 폼 · 안내). */
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center px-4 py-6">
|
||||
<div className="bg-card border-border flex w-full max-w-[414px] flex-col gap-3.5 rounded-2xl border px-8 pt-9 pb-7 shadow-[var(--shadow-float)]">
|
||||
<div className="mb-1 flex justify-center">
|
||||
<LogoMark width={52} />
|
||||
</div>
|
||||
<h1 className="text-center text-[19px] font-semibold">CodeAssist</h1>
|
||||
<p className="text-muted-foreground mb-3 text-center text-[13px]">
|
||||
SAP GUI · Eclipse 옆에서 바로 쓰는 ABAP 코드 도우미
|
||||
</p>
|
||||
<EntraLoginSection />
|
||||
<LoginForm />
|
||||
<p className="text-muted-foreground mt-1 text-center text-xs">
|
||||
계정이 필요하면 관리자에게 문의해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,20 +19,20 @@ export function ChatHeader({ session, onBack }: Props) {
|
||||
// 게이지 색 — 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">
|
||||
<div className="border-border flex flex-none items-center gap-2 border-b px-3 py-2">
|
||||
<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]"
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11.5px] transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
<ChevronLeft className="size-3.5" aria-hidden="true" />
|
||||
목록
|
||||
<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">
|
||||
<div className="truncate text-[15px] font-semibold">{title}</div>
|
||||
<div className="text-muted-foreground text-[10px] font-medium tracking-wide uppercase">
|
||||
{(session.tag ?? "SESSION").toUpperCase()} · 대화 세션
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@ export function ChatHeader({ session, onBack }: Props) {
|
||||
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">
|
||||
<span className="text-muted-foreground text-[10.5px] tabular-nums">
|
||||
{fmtTokens(used)}
|
||||
<span className="text-muted-foreground/50"> / {fmtTokens(limit)}</span>
|
||||
</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react"
|
||||
import { Check, ClipboardPaste, Copy } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { isWebView, pasteToApp } from "@/lib/bridge/webviewBridge"
|
||||
import { useThemeStore } from "@/shared/store/themeStore"
|
||||
import hljs from "highlight.js/lib/core"
|
||||
import sql from "highlight.js/lib/languages/sql"
|
||||
import json from "highlight.js/lib/languages/json"
|
||||
@@ -47,10 +48,11 @@ interface Props {
|
||||
export function CodeBlock({ code, lang, index, plain = false }: Props) {
|
||||
const key = (lang ?? "").toLowerCase()
|
||||
const resolved = ALIAS[key] ?? key
|
||||
// ABAP 은 SAP 에디터처럼 밝은 배경으로 렌더 — 나머지 언어는 어두운 테마 유지.
|
||||
// ABAP 은 SAP 에디터처럼 밝은 배경으로 — 단, 앱이 라이트 모드일 때만. 다크 캔버스 위 흰 상자는 튐.
|
||||
const isAbap = resolved === "abap"
|
||||
// 언어 없는 펜스(ASCII 다이어그램 등)도 밝게 — 어두운 코드 테마는 언어 지정 블록만.
|
||||
const isLight = isAbap || resolved === ""
|
||||
const appLight = useThemeStore((s) => s.resolved) === "light"
|
||||
// 언어 없는 펜스(ASCII 다이어그램 등)도 같은 규칙.
|
||||
const isLight = appLight && (isAbap || resolved === "")
|
||||
const html = useMemo(() => {
|
||||
if (resolved && hljs.getLanguage(resolved)) {
|
||||
return hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value
|
||||
@@ -83,7 +85,7 @@ export function CodeBlock({ code, lang, index, plain = false }: Props) {
|
||||
<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 className="bg-success/70 size-2 rounded-full" />
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
@@ -114,7 +116,7 @@ export function CodeBlock({ code, lang, index, plain = false }: Props) {
|
||||
</div>
|
||||
<pre className="min-w-0 flex-1 px-3 py-3 font-mono whitespace-pre">
|
||||
<code
|
||||
className={isAbap ? "hljs-abap-light" : undefined}
|
||||
className={isAbap && isLight ? "hljs-abap-light" : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</pre>
|
||||
|
||||
@@ -157,8 +157,9 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P
|
||||
}
|
||||
}
|
||||
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">
|
||||
// 목록·새 대화와 같은 가운데 열(760px). 테두리 상자 하나에 입력 + 도구 줄.
|
||||
<div className="flex-none px-5 pt-2 pb-3">
|
||||
<div className="border-border bg-card focus-within:border-ring mx-auto w-full max-w-[760px] rounded-lg border px-3 pt-2.5 pb-2 transition-colors">
|
||||
{/* 캡쳐·클립보드 이미지 첨부 — 썸네일 + X 로 제거 */}
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
@@ -192,7 +193,7 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P
|
||||
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" />
|
||||
<ClipboardPaste className="text-success size-3.5 flex-none" />
|
||||
<span className="min-w-0 truncate font-mono">
|
||||
{a.replace(/\s+/g, " ").slice(0, 40)}…
|
||||
</span>
|
||||
@@ -237,17 +238,17 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P
|
||||
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"
|
||||
className="placeholder:text-muted-foreground/70 field-sizing-content max-h-40 min-h-9 w-full resize-none bg-transparent text-[13.5px] leading-relaxed outline-none"
|
||||
/>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="mt-1.5 flex items-center gap-1">
|
||||
<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"
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11px] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ClipboardPaste className="size-3" />
|
||||
<ClipboardPaste className="size-3.5" aria-hidden="true" />
|
||||
클립보드
|
||||
</button>
|
||||
<button
|
||||
@@ -256,16 +257,16 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P
|
||||
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",
|
||||
"inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11px] transition-colors",
|
||||
explain
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-ring hover:text-foreground"
|
||||
? "bg-primary/12 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<BookOpen className="size-3" />
|
||||
<BookOpen className="size-3.5" aria-hidden="true" />
|
||||
설명
|
||||
</button>
|
||||
<span className="text-muted-foreground font-mono text-[10px] tracking-wide">
|
||||
<span className="text-muted-foreground/70 ml-1 hidden text-[10.5px] sm:inline">
|
||||
Enter 전송 · Shift+Enter 줄바꿈
|
||||
</span>
|
||||
{busy ? (
|
||||
@@ -273,17 +274,17 @@ export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: P
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="ml-auto"
|
||||
className="ml-auto h-7 px-2.5 text-[12px]"
|
||||
onClick={onStop}
|
||||
>
|
||||
<Square className="size-3.5 fill-current" />
|
||||
<Square className="size-3 fill-current" />
|
||||
중단
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
className="ml-auto h-7 px-2.5 text-[12px]"
|
||||
disabled={!value.trim() && attachments.length === 0 && imageAttachments.length === 0}
|
||||
onClick={submit}
|
||||
>
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
export function Hero() {
|
||||
import { LogoMark } from "@/shared/components/LogoMark"
|
||||
|
||||
interface Props {
|
||||
/** 예시 질문 칩을 누르면 그 문장으로 바로 대화 시작. */
|
||||
onPick?: (text: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// 실제로 자주 묻는 것들 — 누르면 바로 답이 오는 걸 보여주는 게 빈 화면 설명보다 낫다.
|
||||
const EXAMPLES = [
|
||||
"MARA 에서 자재번호로 자재유형(MTART) 읽는 SELECT SINGLE",
|
||||
"셀렉션 화면 있는 ALV 리포트 뼈대 (CL_SALV_TABLE)",
|
||||
"내부 테이블 LOOP 에서 그룹 소계 구하는 관용구",
|
||||
]
|
||||
|
||||
export function Hero({ onPick, disabled }: Props) {
|
||||
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 · 에러 분석까지 — 코드나 로그를 붙여넣거나 질문을 입력해봐.
|
||||
<section className="flex w-full max-w-[560px] flex-col items-center py-6 text-center">
|
||||
<LogoMark width={56} className="mb-5" />
|
||||
<h1 className="mb-1.5 text-[21px] font-semibold text-balance">무엇을 도와드릴까요?</h1>
|
||||
<p className="text-muted-foreground mb-6 text-[13px] leading-relaxed">
|
||||
ABAP · CDS 뷰 · HANA · 에러 분석. 코드나 로그를 붙여넣거나 질문을 입력하면 바로 답해.
|
||||
</p>
|
||||
{onPick && (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{EXAMPLES.map((text) => (
|
||||
<button
|
||||
key={text}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onPick(text)}
|
||||
className="border-border bg-card text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring inline-flex items-center rounded-full border px-3 py-1.5 text-[12px] transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ describe("Message 코드펜스 렌더", () => {
|
||||
// 스크롤 컨테이너 있는 CodeBlock 으로 감싸져야 함
|
||||
const block = container.querySelector("[data-code-block]")
|
||||
expect(block).not.toBeNull()
|
||||
// 언어 없는 펜스는 다크 코드 테마가 아니라 라이트로
|
||||
expect(block?.className).toContain("bg-white")
|
||||
// 코드 상자 색은 앱 테마를 따름 — 기본(다크)에선 어두운 코드 상자, 라이트 모드면 흰 에디터 룩
|
||||
expect(block?.className).toContain("bg-[#0d1117]")
|
||||
// 인라인 code 스타일(bg-black/10)로 새지 않아야 함
|
||||
expect(container.querySelector("code.rounded")).toBeNull()
|
||||
})
|
||||
|
||||
@@ -65,12 +65,12 @@ export function Message({ role, content, totalTokens, elapsedMs }: Props) {
|
||||
<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 className="bg-primary text-primary-foreground grid size-4 place-items-center rounded text-[8px] leading-none font-semibold">
|
||||
AI
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
|
||||
{isUser ? "나 · YOU" : "SNAP MATE"}
|
||||
{isUser ? "나 · YOU" : "CODEASSIST"}
|
||||
</span>
|
||||
{!isUser && (
|
||||
<DropdownMenu>
|
||||
@@ -99,9 +99,10 @@ export function Message({ role, content, totalTokens, elapsedMs }: Props) {
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm leading-[1.9]",
|
||||
// 질문 = 오른쪽 연한 칩, 답변 = 상자 없이 본문 글자(ABAP_OPENCODE 대화 톤). 코드블럭은 자기 상자를 가짐.
|
||||
isUser
|
||||
? "bg-secondary text-secondary-foreground max-w-[85%] whitespace-pre-wrap"
|
||||
: "border-border bg-card text-card-foreground w-full border"
|
||||
: "text-foreground w-full px-0 py-0"
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
|
||||
@@ -45,7 +45,7 @@ export function NavRail({ blocks, containerRef }: Props) {
|
||||
<ClipboardHistory />
|
||||
|
||||
<div className="border-border flex flex-none items-center gap-1.5 border-b px-3 py-2.5">
|
||||
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="size-1.5 rounded-full bg-success" />
|
||||
<span className="text-muted-foreground font-mono text-[10px] tracking-widest uppercase">
|
||||
Source Nav
|
||||
</span>
|
||||
@@ -79,7 +79,7 @@ export function NavRail({ blocks, containerRef }: Props) {
|
||||
className="text-muted-foreground/50 hover:text-foreground flex-none rounded p-1.5 transition-colors"
|
||||
>
|
||||
{copiedIdx === b.index ? (
|
||||
<Check className="size-3 text-emerald-500" />
|
||||
<Check className="size-3 text-success" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CornerDownRight, MessageSquare } from "lucide-react"
|
||||
import { MessageSquare } from "lucide-react"
|
||||
import type { ReactNode } from "react"
|
||||
import type { SnapMessage } from "../contract/types"
|
||||
import { formatRelativeKo } from "@/lib/utils/relativeTime"
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
|
||||
interface Props {
|
||||
hit: SnapMessage
|
||||
@@ -11,8 +12,17 @@ interface Props {
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
// 마크다운 코드펜스 기호는 빼고 한 줄로 — 목록에서 ```abap 같은 게 보이면 안 되니까.
|
||||
function plain(text: string): string {
|
||||
return text
|
||||
.replace(/```[a-z0-9_-]*/gi, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
// 매칭 지점 주변만 잘라 보여주고, 검색어를 <mark> 로 강조.
|
||||
function snippet(text: string, query: string): ReactNode {
|
||||
function snippet(raw: string, query: string): ReactNode {
|
||||
const text = plain(raw)
|
||||
const idx = text.toLowerCase().indexOf(query.toLowerCase())
|
||||
if (idx < 0) return text.slice(0, 120)
|
||||
const start = Math.max(0, idx - 40)
|
||||
@@ -30,29 +40,35 @@ function snippet(text: string, query: string): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
/** 검색 결과 한 행 — SessionCard 와 같은 행 규칙(블록이 테두리, 행은 배경만). */
|
||||
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"
|
||||
}`}
|
||||
className={cn(
|
||||
"focus-visible:bg-accent flex w-full items-start gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none",
|
||||
selected ? "bg-accent text-accent-foreground" : "hover:bg-accent/60"
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"mt-0.5 size-3.5 flex-none",
|
||||
selected ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="line-clamp-2 text-[12.5px] 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 className="text-muted-foreground flex items-center gap-1.5 text-[10.5px]">
|
||||
<span className="font-semibold tracking-wide uppercase">
|
||||
{hit.role === "user" ? "나" : "CodeAssist"}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatRelativeKo(hit.createdAt)}</span>
|
||||
<CornerDownRight className="size-3" />
|
||||
<span>세션 열기</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="tabular-nums">{formatRelativeKo(hit.createdAt)}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MessageSquare, ChevronRight } from "lucide-react"
|
||||
import { MessageSquare } from "lucide-react"
|
||||
import type { SnapSession } from "../contract/types"
|
||||
import { formatRelativeKo } from "@/lib/utils/relativeTime"
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
|
||||
interface Props {
|
||||
session: SnapSession
|
||||
@@ -9,35 +10,39 @@ interface Props {
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
/** 목록 한 행. 테두리는 목록 블록이 갖고, 행은 배경 한 단계(호버)·선택색(sel-bg)만 바뀜. */
|
||||
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"
|
||||
}`}
|
||||
className={cn(
|
||||
"focus-visible:bg-accent flex h-10 w-full items-center gap-2.5 px-3 text-left transition-colors focus-visible:outline-none",
|
||||
selected ? "bg-accent text-accent-foreground" : "hover:bg-accent/60"
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<MessageSquare
|
||||
className={cn("size-3.5 flex-none", selected ? "text-primary" : "text-muted-foreground")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] 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">
|
||||
<span className="bg-secondary text-muted-foreground flex-none rounded px-1.5 py-0.5 text-[10px] font-semibold tracking-wide uppercase">
|
||||
{session.tag}
|
||||
</span>
|
||||
)}
|
||||
{session.isGenerating && (
|
||||
<span className="size-1.5 flex-none rounded-full bg-emerald-500" title="생성 중" />
|
||||
<span className="text-success flex items-center gap-1 text-[10.5px]" title="생성 중">
|
||||
<span className="bg-success size-1.5 rounded-full" />
|
||||
생성 중
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground flex-none font-mono text-[9.5px]">
|
||||
<span className="text-muted-foreground flex-none text-[11px] tabular-nums">
|
||||
{formatRelativeKo(session.updatedAt)}
|
||||
</span>
|
||||
<ChevronRight className="text-muted-foreground size-3.5 flex-none" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ interface Props {
|
||||
onChange: (v: string) => void
|
||||
}
|
||||
|
||||
/** 런처 검색 입력. 테두리·포커스 표시는 부모 검색 행이 맡고(focus-within), 여긴 입력만. */
|
||||
export function SessionSearch({ value, onChange }: Props) {
|
||||
// 런처 답게 뜨자마자 검색창에 포커스 — 여기서 타이핑=필터, ↑↓=목록 이동, Enter=열기.
|
||||
// 창이 다시 떠서(핫키 재소환) window 가 포커스 받을 때도 다시 잡아준다.
|
||||
@@ -18,8 +19,8 @@ export function SessionSearch({ value, onChange }: Props) {
|
||||
}, [])
|
||||
|
||||
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" />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Search className="text-muted-foreground size-4 flex-none" aria-hidden="true" />
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
@@ -27,19 +28,19 @@ export function SessionSearch({ value, onChange }: Props) {
|
||||
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"
|
||||
className="placeholder:text-muted-foreground/70 h-full min-w-0 flex-1 bg-transparent text-[14px] outline-none"
|
||||
/>
|
||||
{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"
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring flex size-6 flex-none items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
onChange("")
|
||||
ref.current?.focus()
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Outlet, useNavigate } from "react-router-dom"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import { startWindowDrag } from "@/lib/bridge/webviewBridge"
|
||||
import { SnapUserControls } from "./SnapUserControls"
|
||||
import { LogoMark } from "@/shared/components/LogoMark"
|
||||
|
||||
/** 웹뷰 풀블리드 셸 — .NET 창이 진짜 크롬을 주므로 가짜 타이틀바는 없음. 얇은 브랜드 스트립만. */
|
||||
/** Snap 셸. 웹에선 얇은 브랜드 바(드래그 영역)에 테마·로그아웃까지 넣고,
|
||||
* Tauri 에선 창 제목줄(DesktopWindowFrame)이 같은 역할을 하므로 여기선 안 그림. */
|
||||
export function SnapLayout() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -24,19 +26,20 @@ export function SnapLayout() {
|
||||
|
||||
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 className="border-border flex h-9 flex-none items-center gap-2 border-b pr-2 pl-3 select-none">
|
||||
{/* 로고 쪽 = 창 드래그 영역(프레임리스 제목표시줄 대체) — title-bar 드래그라 a11y 룰 한 줄 예외 */}
|
||||
<div
|
||||
role="presentation"
|
||||
onMouseDown={startWindowDrag}
|
||||
className="flex h-full min-w-0 flex-1 items-center gap-2"
|
||||
>
|
||||
<LogoMark width={26} />
|
||||
<span className="text-[11px] font-semibold tracking-wide">CodeAssist</span>
|
||||
</div>
|
||||
<SnapUserControls />
|
||||
</header>
|
||||
)}
|
||||
<SnapUserControls />
|
||||
<div className="min-h-0 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useLogout } from "@/features/auth/hooks/useLogout"
|
||||
import { ThemeToggle } from "@/shared/components/ThemeToggle"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
/** 사이드바 없는 Snap 창에서도 테마 변경과 로그아웃에 바로 접근하게 함. */
|
||||
/** 테마 변경 + 로그아웃. 상단 바(웹: SnapLayout 헤더, Tauri: 창 제목줄) 오른쪽에 인라인으로 들어감. */
|
||||
export function SnapUserControls() {
|
||||
const navigate = useNavigate()
|
||||
const logout = useLogout()
|
||||
@@ -17,18 +17,19 @@ export function SnapUserControls() {
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground size-7"
|
||||
aria-label="로그아웃"
|
||||
title="로그아웃"
|
||||
disabled={logout.isPending}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="size-4" aria-hidden="true" />
|
||||
<LogOut className="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { useRef, useState } from "react"
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import { Kbd } from "@/shared/components/Kbd"
|
||||
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"
|
||||
|
||||
/** 새 대화 — 목록과 같은 가운데 열(760px). 코드블럭 레일은 대화가 생긴 뒤(SessionChatPage)부터. */
|
||||
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))
|
||||
@@ -26,31 +26,26 @@ export default function NewChatPage() {
|
||||
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">
|
||||
<div className="mx-auto flex w-full max-w-[760px] flex-none items-center px-5 pt-3">
|
||||
<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]"
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring -ml-2 inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11.5px] transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
<ChevronLeft className="size-3.5" aria-hidden="true" />
|
||||
목록
|
||||
<Kbd>Esc</Kbd>
|
||||
</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 className="grid min-h-0 flex-1 place-items-center overflow-y-auto px-5">
|
||||
<Hero onPick={(text) => void start(text, [])} disabled={pending} />
|
||||
</div>
|
||||
<Composer
|
||||
onSend={start}
|
||||
busy={pending}
|
||||
placeholder="새 대화를 시작하세요 — 질문을 입력하거나 코드를 붙여넣어봐…"
|
||||
placeholder="질문을 입력하거나 코드·에러 로그를 붙여넣어봐…"
|
||||
acceptCapture
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -146,83 +146,86 @@ export default function SessionChatPage() {
|
||||
<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 className="flex min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onWheel={onWheel}
|
||||
onTouchMove={restick}
|
||||
className="min-h-0 min-w-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-[760px] flex-col gap-5 px-5 py-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">
|
||||
CODEASSIST
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (m.frozen && m.role === "assistant") {
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<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>
|
||||
<Message
|
||||
key={m.id}
|
||||
role={m.role}
|
||||
content={m.content}
|
||||
totalTokens={m.totalTokens}
|
||||
elapsedMs={m.elapsedMs}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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>
|
||||
})}
|
||||
{generatingRemotely && (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
|
||||
CODEASSIST
|
||||
</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>
|
||||
</div>
|
||||
<Composer
|
||||
onSend={(t, images) => {
|
||||
stickRef.current = true // 전송 시 바닥 고정 재개 → 새 질문/답변 따라감
|
||||
void send(t, images)
|
||||
}}
|
||||
busy={busy || generatingRemotely}
|
||||
onStop={stop}
|
||||
placeholder="ABAP · CDS · 에러 로그를 붙여넣거나 질문하세요…"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { ChevronLeft, ChevronRight, Plus } from "lucide-react"
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import { useDebounce } from "@/lib/hooks/useDebounce"
|
||||
import { hideWindow } from "@/lib/bridge/webviewBridge"
|
||||
@@ -83,53 +84,51 @@ export default function SessionListPage() {
|
||||
}, [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>
|
||||
<div className="mx-auto flex h-full w-full max-w-[760px] flex-col gap-3 px-5 pt-6 pb-5">
|
||||
{/* 검색 행 = 런처의 명령줄. 오른쪽에 새 대화. */}
|
||||
<div className="border-border bg-card focus-within:border-ring flex h-11 flex-none items-center gap-2 rounded-lg border pr-1.5 pl-3 transition-colors">
|
||||
<SessionSearch value={q} onChange={setQ} />
|
||||
<span className="bg-border h-5 w-px flex-none" aria-hidden="true" />
|
||||
<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"
|
||||
className="bg-primary text-primary-foreground focus-visible:ring-ring flex h-7 flex-none items-center gap-1 rounded-md px-2.5 text-[12px] font-semibold transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
<Plus className="size-3.5" aria-hidden="true" />새 대화
|
||||
</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}
|
||||
<div className="flex items-center justify-between px-0.5">
|
||||
<span className="text-muted-foreground text-[11px] font-semibold tracking-wide">
|
||||
{searching ? "검색 결과" : "최근 대화"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">{total}</span>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="flex flex-col gap-2">
|
||||
{/* 목록 블록 — 테두리는 여기 한 번, 행 사이는 옅은 구분선(ABAP_OPENCODE 콜랩스 블록 규칙) */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="border-border bg-card divide-border/70 scrollbar-hide min-h-0 flex-none divide-y overflow-y-auto rounded-lg border"
|
||||
>
|
||||
{active.isLoading && (
|
||||
<div className="text-muted-foreground py-8 text-center font-mono text-xs">
|
||||
불러오는 중…
|
||||
</div>
|
||||
<div className="text-muted-foreground py-10 text-center 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 className="text-muted-foreground flex flex-col items-center gap-1 py-10 text-center text-xs">
|
||||
<span>"{debouncedQ.trim()}" 에 맞는 대화가 없어</span>
|
||||
<span className="text-muted-foreground/70">
|
||||
다른 단어로 찾아보거나 Esc 로 검색어를 지워봐
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!active.isLoading && !searching && sessions.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center font-mono text-xs">
|
||||
아직 대화가 없습니다
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-1 py-10 text-center text-xs">
|
||||
<span>아직 대화가 없어</span>
|
||||
<span className="text-muted-foreground/70">
|
||||
새 대화(Ctrl+N)를 열고 코드나 에러를 붙여넣어봐
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -153,15 +152,18 @@ export default function SessionListPage() {
|
||||
))}
|
||||
|
||||
{searching && meta && meta.totalPages > 1 && (
|
||||
<div className="text-muted-foreground mt-1 flex items-center justify-center gap-3 font-mono text-xs">
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-3 py-2 text-[11px]">
|
||||
<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"
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-foreground grid size-6 place-items-center rounded-md transition-colors",
|
||||
"disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
)}
|
||||
title="이전"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<ChevronLeft className="size-3.5" />
|
||||
</button>
|
||||
<span className="tabular-nums">
|
||||
{page} / {meta.totalPages}
|
||||
@@ -170,16 +172,20 @@ export default function SessionListPage() {
|
||||
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"
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-foreground grid size-6 place-items-center rounded-md transition-colors",
|
||||
"disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
)}
|
||||
title="다음"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
<ChevronRight className="size-3.5" />
|
||||
</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">
|
||||
{/* 키 안내 — 한 줄, 조용하게 */}
|
||||
<div className="text-muted-foreground mt-auto flex flex-none flex-wrap items-center gap-x-4 gap-y-1 px-0.5 pt-3 text-[10.5px]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>↓</Kbd>
|
||||
@@ -195,7 +201,7 @@ export default function SessionListPage() {
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Kbd>Esc</Kbd>
|
||||
닫기
|
||||
{q ? "검색 지우기" : "닫기"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { hostKind, send } from "@/lib/bridge/transport"
|
||||
import { LogoMark } from "@/shared/components/LogoMark"
|
||||
// 검색 → 결과 → 직접 선택한 코드 순서로 화면과 데스크톱 창을 펼침.
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Check, Search } from "lucide-react"
|
||||
@@ -287,9 +288,8 @@ export default function SnippetPalettePage() {
|
||||
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>
|
||||
<LogoMark width={28} />
|
||||
<span className="text-[11px] font-semibold tracking-wide">CodeAssist · Snippet</span>
|
||||
</header>
|
||||
)}
|
||||
{/* 상단 검색 입력 */}
|
||||
|
||||
Reference in New Issue
Block a user