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:
2026-09-16 21:24:17 +09:00
co-authored by Claude Fable 5.1
parent 506fbf601d
commit 9c6b28782d
32 changed files with 490 additions and 353 deletions
@@ -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>
)}
{/* 상단 검색 입력 */}