Files
2026-09-16 17:22:14 +09:00

2832 lines
93 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Snap Mate Client</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&family=Noto+Serif+KR:ital,wght@1,500;1,600&display=swap"
rel="stylesheet"
/>
<style>
/* ============================================================
Snap Mate Client — editorial-terminal design system
Single self-contained file: home · chat · new-chat views.
============================================================ */
:root {
--desktop: #d7cebc;
--bg: #efe9dd;
--panel: #f4efe5;
--surface: #fbf8f2;
--fg: #2b2620;
--muted: #8c8477;
--faint: #a89f8f;
--border: #e0d8c8;
--border-strong: #cfc6b3;
--ink: #2a2219;
--ink-hi: #3a3025;
--on-ink: #f4efe5;
--taupe: #8a7c66;
--sage: #6f7d54;
--amber: #b1793a;
--code-bg: #26201a;
--code-fg: #e9e2d3;
--code-kw: #c7b07a;
--code-str: #b98d63;
--code-com: #7c7261;
--serif: "Iowan Old Style", "Charter", "Noto Serif KR", Georgia, "Times New Roman", serif;
--sans:
"Noto Sans KR", "Malgun Gothic", "Apple SD Gothic Neo", system-ui, -apple-system,
"Segoe UI", sans-serif;
--mono: "JetBrains Mono", "SF Mono", "Consolas", ui-monospace, Menlo, monospace;
--r: 8px;
--r-sm: 6px;
--win-w: 480px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
background:
radial-gradient(1200px 800px at 20% -10%, #e3dbca 0%, transparent 60%),
radial-gradient(900px 700px at 110% 120%, #cfc4af 0%, transparent 55%), var(--desktop);
font-family: var(--sans);
color: var(--fg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
display: flex;
align-items: center;
justify-content: center;
padding: 28px 16px;
}
.window {
width: var(--win-w);
max-width: 100%;
max-height: calc(100vh - 40px);
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 14px;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.55) inset,
0 24px 60px -20px rgba(40, 32, 22, 0.45),
0 8px 22px -12px rgba(40, 32, 22, 0.35);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.window[hidden] {
display: none;
} /* view router: only one window visible */
.titlebar {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 8px 0 14px;
background: linear-gradient(#f0ebe0, #e8e1d3);
border-bottom: 1px solid var(--border);
user-select: none;
flex: none;
}
.tb-mark {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.12em;
font-weight: 600;
color: var(--fg);
}
.tb-no {
font-family: var(--mono);
font-size: 10px;
color: var(--faint);
letter-spacing: 0.08em;
}
.tb-spacer {
flex: 1;
}
.tb-live {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.1em;
color: var(--sage);
}
.tb-live .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--sage);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--sage) 22%, transparent);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.win-ctrls {
display: flex;
gap: 2px;
margin-left: 4px;
}
.win-ctrls button {
width: 30px;
height: 26px;
border: 0;
background: transparent;
color: var(--muted);
border-radius: 5px;
cursor: pointer;
display: grid;
place-items: center;
transition:
background 0.12s,
color 0.12s;
}
.win-ctrls button:hover {
background: rgba(42, 34, 25, 0.08);
color: var(--fg);
}
.win-ctrls button.close:hover {
background: #b23b2e;
color: #fff;
}
.win-ctrls svg {
width: 11px;
height: 11px;
}
.keystrip {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: var(--panel);
border-bottom: 1px solid var(--border);
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--faint);
flex: none;
}
.keystrip kbd {
font-family: var(--mono);
font-size: 10px;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 1px 5px;
color: var(--muted);
}
.win-body {
flex: 1;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
.win-body::-webkit-scrollbar {
width: 8px;
}
.win-body::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 8px;
}
.eyebrow {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 6px;
}
.eyebrow .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--sage);
}
.serif-title {
font-family: var(--serif);
font-style: italic;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--fg);
line-height: 1.1;
}
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.05em;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 3px 8px;
}
.badge.click {
cursor: pointer;
transition:
border-color 0.12s,
color 0.12s;
}
.badge.click:hover {
border-color: var(--taupe);
color: var(--fg);
}
.tag {
font-family: var(--mono);
font-size: 9.5px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--taupe);
border: 1px solid color-mix(in oklch, var(--taupe) 40%, var(--border));
border-radius: 4px;
padding: 1px 6px;
}
.btn {
font-family: var(--sans);
font-size: 13px;
font-weight: 600;
border-radius: var(--r);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
transition:
background 0.14s,
transform 0.06s,
border-color 0.14s;
border: 1px solid transparent;
}
.btn:active {
transform: translateY(1px);
}
.btn-ghost {
background: var(--surface);
color: var(--fg);
border: 1px solid var(--border-strong);
padding: 9px 12px;
}
.btn-ghost:hover {
border-color: var(--taupe);
}
.field {
width: 100%;
font-family: var(--sans);
font-size: 13px;
color: var(--fg);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--r);
padding: 10px 12px 10px 34px;
outline: none;
transition:
border-color 0.14s,
box-shadow 0.14s;
}
.field::placeholder {
color: var(--faint);
}
.field:focus {
border-color: var(--taupe);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--taupe) 16%, transparent);
}
.search-wrap {
position: relative;
}
.search-wrap svg {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: var(--faint);
width: 15px;
height: 15px;
}
.pad {
padding: 16px;
}
.ws-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.ws-avatar {
width: 34px;
height: 34px;
border-radius: var(--r-sm);
background: var(--ink);
color: var(--on-ink);
display: grid;
place-items: center;
font-family: var(--serif);
font-style: italic;
font-weight: 700;
font-size: 16px;
flex: none;
}
.ws-name {
font-family: var(--serif);
font-style: italic;
font-weight: 600;
font-size: 16px;
}
.new-icon {
margin-left: auto;
flex: none;
width: 34px;
height: 34px;
border-radius: var(--r-sm);
background: var(--ink);
color: var(--on-ink);
border: 0;
cursor: pointer;
display: grid;
place-items: center;
text-decoration: none;
transition:
background 0.14s,
transform 0.06s;
}
.new-icon:hover {
background: var(--ink-hi);
}
.new-icon:active {
transform: translateY(1px);
}
.ws-sub {
font-family: var(--mono);
font-size: 9.5px;
letter-spacing: 0.14em;
color: var(--faint);
text-transform: uppercase;
}
.list-label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.08em;
color: var(--faint);
text-transform: uppercase;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.list-label .pill {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
padding: 1px 8px;
color: var(--muted);
}
.sessions {
display: flex;
flex-direction: column;
gap: 8px;
}
.session {
display: grid;
grid-template-columns: 22px 1fr 12px;
gap: 10px;
align-items: start;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 12px;
cursor: pointer;
text-align: left;
width: 100%;
font-family: var(--sans);
text-decoration: none;
color: var(--fg);
transition:
border-color 0.12s,
background 0.12s,
transform 0.06s;
}
.session:hover {
border-color: var(--border-strong);
}
.session[data-active="true"] {
border-color: var(--taupe);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--taupe) 14%, transparent);
}
.session:active {
transform: translateY(1px);
}
.session .ico {
color: var(--taupe);
margin-top: 1px;
}
.session .ico svg {
width: 15px;
height: 15px;
}
.session .s-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.session .s-title {
display: block;
font-size: 13.5px;
font-weight: 600;
color: var(--fg);
margin-bottom: 3px;
}
.session .s-snip {
font-size: 11.5px;
color: var(--muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.session .s-snip mark {
background: color-mix(in oklch, var(--amber) 30%, transparent);
color: var(--fg);
border-radius: 2px;
padding: 0 1px;
}
.session .s-foot {
display: flex;
align-items: center;
gap: 8px;
margin-top: 7px;
}
.session .s-time {
font-family: var(--mono);
font-size: 9.5px;
color: var(--faint);
}
.session .chev {
color: var(--faint);
align-self: center;
}
.session .chev svg {
width: 12px;
height: 12px;
}
.empty {
text-align: center;
color: var(--muted);
font-size: 12.5px;
padding: 28px 12px;
font-family: var(--mono);
letter-spacing: 0.04em;
}
.chat-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
background: var(--panel);
flex: none;
}
.back {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 6px 9px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
text-decoration: none;
transition: border-color 0.12s;
}
.back:hover {
border-color: var(--taupe);
color: var(--fg);
}
.chat-title {
flex: 1;
min-width: 0;
}
.chat-title .t {
font-family: var(--serif);
font-style: italic;
font-weight: 600;
font-size: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-title .m {
font-family: var(--mono);
font-size: 9.5px;
letter-spacing: 0.1em;
color: var(--faint);
text-transform: uppercase;
}
.clip-banner {
display: none;
align-items: center;
gap: 10px;
margin: 10px 14px 0;
padding: 10px 12px;
background: color-mix(in oklch, var(--amber) 12%, var(--surface));
border: 1px solid color-mix(in oklch, var(--amber) 45%, var(--border));
border-radius: var(--r);
font-size: 12px;
color: var(--fg);
}
.clip-banner.show {
display: flex;
animation: slideDown 0.22s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: none;
}
}
.clip-banner .ico {
color: var(--amber);
flex: none;
}
.clip-banner .txt {
flex: 1;
line-height: 1.35;
}
.clip-banner .txt b {
font-weight: 700;
}
.clip-banner .acts {
display: flex;
gap: 6px;
}
.clip-banner button {
font-family: var(--sans);
font-size: 11px;
font-weight: 600;
border-radius: 6px;
padding: 5px 9px;
cursor: pointer;
border: 1px solid transparent;
}
.clip-banner .accept {
background: var(--ink);
color: var(--on-ink);
}
.clip-banner .dismiss {
background: transparent;
color: var(--muted);
border-color: var(--border-strong);
}
.stream {
padding: 16px 14px 8px;
display: flex;
flex-direction: column;
gap: 16px;
}
.msg {
display: flex;
flex-direction: column;
gap: 6px;
}
.msg .who {
font-family: var(--mono);
font-size: 9.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--faint);
display: flex;
align-items: center;
gap: 6px;
}
.msg.user .who {
color: var(--taupe);
}
.msg .body {
font-size: 13.5px;
line-height: 1.55;
color: var(--fg);
border-radius: var(--r);
padding: 12px 13px;
}
.msg.assistant .body {
background: var(--surface);
border: 1px solid var(--border);
}
.msg.user .body {
background: color-mix(in oklch, var(--taupe) 12%, var(--surface));
border: 1px solid color-mix(in oklch, var(--taupe) 30%, var(--border));
}
.msg .body p + p {
margin-top: 9px;
}
.msg .body ul {
margin: 8px 0 0;
padding-left: 18px;
}
.msg .body li {
margin: 3px 0;
}
.code {
margin-top: 10px;
background: var(--code-bg);
border-radius: var(--r);
border: 1px solid #171310;
outline: none;
max-height: 320px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: #4a4034 var(--code-bg);
}
.code::-webkit-scrollbar {
width: 9px;
}
.code::-webkit-scrollbar-thumb {
background: #4a4034;
border-radius: 8px;
border: 2px solid var(--code-bg);
}
.code:focus-visible {
box-shadow: 0 0 0 3px color-mix(in oklch, var(--taupe) 45%, transparent);
}
.code-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
background: #1d1813;
border-bottom: 1px solid #141009;
position: sticky;
top: 0;
z-index: 2;
}
.code-bar .lang {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.1em;
color: #b6a988;
text-transform: uppercase;
}
.code-bar .copy {
margin-left: auto;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.04em;
color: #c9bd9d;
background: transparent;
border: 1px solid #3a3227;
border-radius: 5px;
padding: 3px 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
transition: all 0.12s;
}
.code-bar .copy:hover {
border-color: var(--taupe);
color: #f0e8d4;
}
.code-bar .copy.ok {
color: var(--sage);
border-color: var(--sage);
}
.code pre {
margin: 0;
padding: 12px 13px;
overflow-x: auto;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
color: var(--code-fg);
scrollbar-width: thin;
}
.code .kw {
color: var(--code-kw);
}
.code .st {
color: var(--code-str);
}
.code .cm {
color: var(--code-com);
font-style: italic;
}
.code .fn {
color: #d8c9a0;
}
.code .cl {
display: block;
min-height: 1.28em;
width: max-content;
min-width: 100%;
}
.code .cl.hit {
background: color-mix(in oklch, var(--code-kw) 22%, transparent);
border-radius: 2px;
}
.composer {
flex: none;
border-top: 1px solid var(--border);
background: var(--panel);
padding: 12px 14px 14px;
}
.composer-box {
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--r);
padding: 10px 12px;
transition:
border-color 0.14s,
box-shadow 0.14s;
}
.composer-box:focus-within {
border-color: var(--taupe);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--taupe) 14%, transparent);
}
.composer textarea {
width: 100%;
border: 0;
outline: 0;
resize: none;
background: transparent;
font-family: var(--sans);
font-size: 13.5px;
color: var(--fg);
line-height: 1.5;
max-height: 120px;
}
.composer textarea::placeholder {
color: var(--faint);
}
.composer-foot {
display: flex;
align-items: center;
margin-top: 8px;
}
.composer-foot .hint {
font-family: var(--mono);
font-size: 10px;
color: var(--faint);
letter-spacing: 0.04em;
}
.composer-foot .send {
margin-left: auto;
background: var(--ink);
color: var(--on-ink);
border: 0;
border-radius: var(--r-sm);
padding: 7px 13px;
font-family: var(--sans);
font-size: 12px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background 0.14s;
}
.composer-foot .send:hover {
background: var(--ink-hi);
}
.composer-foot .send:disabled {
opacity: 0.45;
cursor: default;
}
.window.wide {
width: 700px;
max-width: 100%;
}
.chat-main {
display: flex;
flex: 1;
min-height: 0;
}
.nav-rail {
width: 210px;
flex: none;
display: flex;
flex-direction: column;
background: var(--panel);
border-right: 1px solid var(--border);
}
.nav-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.nav-hint {
font-family: var(--mono);
font-size: 9px;
color: var(--faint);
}
.nav-hint kbd,
.nav-foot kbd {
font-family: var(--mono);
font-size: 9px;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: 3px;
padding: 0 4px;
color: var(--muted);
}
.nav-list {
flex: 1;
overflow-y: auto;
padding: 6px;
outline: none;
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-list:focus-visible {
box-shadow: inset 0 0 0 2px color-mix(in oklch, var(--taupe) 32%, transparent);
border-radius: 6px;
}
.nav-item {
width: 100%;
text-align: left;
display: flex;
align-items: center;
gap: 7px;
font-family: var(--sans);
font-size: 11.5px;
color: var(--fg);
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
padding: 6px 8px;
cursor: pointer;
transition:
background 0.1s,
border-color 0.1s;
}
.nav-item.block {
font-weight: 600;
}
.nav-item:hover {
background: rgba(42, 34, 25, 0.05);
}
.nav-item[data-active="true"] {
background: color-mix(in oklch, var(--taupe) 16%, var(--surface));
border-color: color-mix(in oklch, var(--taupe) 40%, var(--border));
}
.nav-item .ni-ico {
color: var(--taupe);
display: inline-flex;
flex: none;
}
.nav-item .ni-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-item .ni-meta {
font-family: var(--mono);
font-size: 9px;
color: var(--faint);
flex: none;
}
.nav-foot {
padding: 8px 10px;
border-top: 1px solid var(--border);
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.02em;
color: var(--faint);
line-height: 1.7;
}
.nav-empty {
padding: 18px 12px;
font-family: var(--mono);
font-size: 10px;
line-height: 1.7;
color: var(--faint);
letter-spacing: 0.02em;
}
.chat-hero {
padding: 40px 22px 20px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
transition:
opacity 0.25s,
transform 0.25s;
}
.chat-hero.leaving {
opacity: 0;
transform: translateY(-8px);
}
.chat-hero .hero-avatar {
width: 54px;
height: 54px;
border-radius: 14px;
background: var(--ink);
color: var(--on-ink);
display: grid;
place-items: center;
font-family: var(--serif);
font-style: italic;
font-weight: 700;
font-size: 27px;
margin-bottom: 16px;
box-shadow: 0 8px 20px -12px rgba(40, 32, 22, 0.5);
}
.chat-hero .eyebrow {
margin-bottom: 12px;
}
.chat-hero h1 {
font-family: var(--serif);
font-style: italic;
font-weight: 600;
font-size: 28px;
color: var(--fg);
margin-bottom: 8px;
letter-spacing: -0.01em;
}
.chat-hero p {
font-size: 13.5px;
color: var(--muted);
max-width: 36ch;
line-height: 1.55;
margin-bottom: 24px;
}
.suggest-label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--faint);
margin-bottom: 12px;
}
.suggest-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
width: 100%;
max-width: 520px;
}
.suggest {
text-align: left;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 13px 14px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 5px;
transition:
border-color 0.12s,
transform 0.06s,
box-shadow 0.12s;
}
.suggest:hover {
border-color: var(--taupe);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--taupe) 12%, transparent);
}
.suggest:active {
transform: translateY(1px);
}
.suggest .s-tag {
font-family: var(--mono);
font-size: 9.5px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--taupe);
}
.suggest .s-t {
font-size: 13.5px;
font-weight: 600;
color: var(--fg);
}
.suggest .s-d {
font-size: 11.5px;
color: var(--muted);
line-height: 1.4;
}
@media (max-width: 560px) {
.suggest-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.window.wide {
width: 100%;
}
.nav-rail {
display: none;
}
}
.overlay {
position: absolute;
inset: 0;
background: rgba(30, 24, 17, 0.32);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
z-index: 20;
}
.overlay.show {
opacity: 1;
pointer-events: auto;
}
.detail-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 84%;
max-width: 380px;
background: var(--bg);
border-left: 1px solid var(--border-strong);
box-shadow: -20px 0 40px -20px rgba(40, 32, 22, 0.4);
transform: translateX(100%);
transition: transform 0.24s cubic-bezier(0.2, 0.7, 0.2, 1);
z-index: 21;
display: flex;
flex-direction: column;
}
.detail-panel.show {
transform: none;
}
.detail-panel .dp-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 14px;
border-bottom: 1px solid var(--border);
}
.detail-panel .dp-head .eyebrow {
font-size: 10px;
}
.detail-panel .dp-close {
background: transparent;
border: 0;
cursor: pointer;
color: var(--muted);
font-size: 16px;
padding: 2px 6px;
border-radius: 5px;
}
.detail-panel .dp-close:hover {
background: rgba(42, 34, 25, 0.08);
color: var(--fg);
}
.detail-panel .dp-body {
padding: 16px 14px;
overflow-y: auto;
}
.detail-panel .dp-title {
font-family: var(--serif);
font-style: italic;
font-weight: 600;
font-size: 20px;
margin: 8px 0 14px;
}
.meta-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.meta-row .k {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
}
.meta-row .v {
font-size: 13px;
font-weight: 600;
color: var(--fg);
}
.meta-row .v.mono {
font-family: var(--mono);
font-size: 12px;
}
.dp-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 14px;
}
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%) translateY(10px);
background: var(--ink);
color: var(--on-ink);
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.04em;
padding: 8px 14px;
border-radius: 20px;
opacity: 0;
pointer-events: none;
transition:
opacity 0.18s,
transform 0.18s;
z-index: 40;
white-space: nowrap;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
@media (max-width: 460px) {
body {
padding: 0;
}
.window {
border-radius: 0;
height: 100vh;
max-height: 100vh;
border: 0;
}
}
</style>
</head>
<body>
<!-- ============================ HOME ============================ -->
<main
class="window wide"
id="view-home"
data-od-id="home-window"
role="application"
aria-label="Snap Mate Client"
>
<header class="titlebar">
<span class="tb-mark">SNAP MATE</span>
<span class="tb-no">N°19 · V1.2.4</span>
<span class="tb-spacer"></span>
<span class="tb-live"><span class="dot"></span>HANA-LIVE</span>
<div class="win-ctrls" aria-label="창 제어">
<button title="최소화" aria-label="최소화">
<svg viewBox="0 0 12 12">
<rect x="1" y="9" width="10" height="1.3" fill="currentColor" />
</svg>
</button>
<button title="최대화" aria-label="최대화">
<svg viewBox="0 0 12 12">
<rect
x="1.5"
y="1.5"
width="9"
height="9"
fill="none"
stroke="currentColor"
stroke-width="1.3"
/>
</svg>
</button>
<button class="close" title="닫기" aria-label="닫기">
<svg viewBox="0 0 12 12">
<path d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" stroke-width="1.4" />
</svg>
</button>
</div>
</header>
<div class="keystrip">
<span>[F1] KEYBOARD MAP</span><kbd></kbd><kbd></kbd> 탐색 <kbd>Enter</kbd> 입장
<kbd>Ctrl</kbd>+<kbd>N</kbd> 새 세션 <kbd>Esc</kbd> 검색 해제
</div>
<div class="win-body" id="home-body">
<section class="pad" data-od-id="workspace">
<div class="ws-head" style="margin-bottom: 14px">
<div class="ws-avatar">S</div>
<div>
<div class="ws-name">지난 대화</div>
<div class="ws-sub">Snap Mate · Workspace</div>
</div>
<button
class="new-icon"
id="new-session"
title="새 대화 세션 (Ctrl+N)"
aria-label="새 대화 세션"
data-od-id="new-session-btn"
>
<svg viewBox="0 0 16 16" width="15" height="15" fill="none">
<path
d="M8 2v12M2 8h12"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div class="search-wrap" data-od-id="session-search">
<svg viewBox="0 0 20 20" fill="none">
<circle cx="9" cy="9" r="6" stroke="currentColor" stroke-width="1.6" />
<path d="M14 14 L18 18" stroke="currentColor" stroke-width="1.6" />
</svg>
<input
class="field"
id="search"
type="text"
placeholder="대화 세션 내용 검색…"
autocomplete="off"
aria-label="대화 세션 검색"
/>
</div>
<div class="list-label" style="margin-top: 16px">
<span id="list-caption">최근 진행 대화</span>
<span class="pill" id="list-total">10</span>
</div>
<div class="sessions" id="session-list" role="listbox" aria-label="세션 목록"></div>
<div class="empty" id="home-empty" hidden>검색 결과가 없습니다 · NO MATCH</div>
</section>
</div>
</main>
<!-- ============================ CHAT ============================ -->
<main
class="window wide"
id="view-chat"
hidden
data-od-id="chat-window"
role="application"
aria-label="Snap Mate 세션"
>
<header class="titlebar">
<span class="tb-mark">SNAP MATE</span>
<span class="tb-no" id="tb-session">N°19 · SESSION</span>
<span class="tb-spacer"></span>
<span class="tb-live"><span class="dot"></span>HANA-LIVE</span>
<div class="win-ctrls" aria-label="창 제어">
<button title="최소화" aria-label="최소화">
<svg viewBox="0 0 12 12">
<rect x="1" y="9" width="10" height="1.3" fill="currentColor" />
</svg>
</button>
<button title="최대화" aria-label="최대화">
<svg viewBox="0 0 12 12">
<rect
x="1.5"
y="1.5"
width="9"
height="9"
fill="none"
stroke="currentColor"
stroke-width="1.3"
/>
</svg>
</button>
<button class="close" title="닫기" aria-label="닫기">
<svg viewBox="0 0 12 12">
<path d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" stroke-width="1.4" />
</svg>
</button>
</div>
</header>
<div class="chat-head">
<button class="back" id="chat-back" data-od-id="back-link">
<svg viewBox="0 0 12 12" width="11" height="11" fill="none">
<path
d="M8 2L4 6l4 4"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/></svg
>목록
</button>
<div class="chat-title">
<div class="t" id="ch-title">Generate CDS View</div>
<div class="m" id="ch-sub">CDS VIEWS · 대화 세션</div>
</div>
<span class="badge click" id="badge-tokens" title="세션 상세 열기" data-od-id="badge-tokens"
><span id="ch-tokens">1.2K</span></span
>
</div>
<div class="clip-banner" id="clip-banner" role="status">
<span class="ico"
><svg viewBox="0 0 20 20" width="18" height="18" fill="none">
<rect
x="5"
y="3"
width="10"
height="14"
rx="2"
stroke="currentColor"
stroke-width="1.5"
/>
<path d="M8 3.5h4" stroke="currentColor" stroke-width="1.5" /></svg
></span>
<span class="txt"
>클립보드에서 <b>ABAP 코드</b>가 감지되었습니다. 세션에 붙여넣을까요?</span
>
<span class="acts"
><button class="accept" id="clip-accept">붙여넣기</button
><button class="dismiss" id="clip-dismiss">무시</button></span
>
</div>
<div class="chat-main">
<aside
class="nav-rail"
id="nav-rail"
data-od-id="source-navigator"
aria-label="소스코드 네비게이터"
>
<div class="nav-head">
<span class="eyebrow"><span class="dot"></span>Source Nav</span
><span class="nav-hint"><kbd>Ctrl</kbd><kbd>J</kbd></span>
</div>
<div
class="nav-list"
id="nav-list"
role="listbox"
tabindex="0"
aria-label="코드 탐색"
></div>
<div class="nav-foot">
<kbd></kbd><kbd></kbd> 블록 이동 · <kbd>Enter</kbd> 점프 · <kbd>Esc</kbd> 입력창
</div>
</aside>
<div class="win-body" id="chat-body">
<div class="stream" id="stream" data-od-id="message-stream" aria-live="polite"></div>
</div>
</div>
<div class="composer">
<div class="composer-box">
<textarea
id="input"
rows="1"
placeholder="ABAP · CDS · 에러 로그를 붙여넣거나 질문을 입력하세요…"
aria-label="메시지 입력"
></textarea>
<div class="composer-foot">
<span class="hint"
><kbd>Enter</kbd> 전송 · <kbd>Shift+Enter</kbd> 줄바꿈 · <kbd>Tab</kbd> 코드 순환 ·
<kbd>Ctrl+Shift+C</kbd> 복사</span
>
<button class="send" id="send" disabled>
<svg viewBox="0 0 16 16" width="13" height="13" fill="none">
<path
d="M2 8l12-5-4.5 12L7 9 2 8z"
stroke="currentColor"
stroke-width="1.4"
stroke-linejoin="round"
/></svg
>전송
</button>
</div>
</div>
</div>
<div class="overlay" id="overlay"></div>
<aside class="detail-panel" id="detail-panel" aria-hidden="true" data-od-id="session-detail">
<div class="dp-head">
<span class="eyebrow"><span class="dot"></span>Session Detail</span
><button class="dp-close" id="dp-close" aria-label="닫기"></button>
</div>
<div class="dp-body">
<div class="eyebrow" id="dp-tag">CDS VIEWS</div>
<div class="dp-title" id="dp-title">Generate CDS View</div>
<div class="meta-row">
<span class="k">세션 번호</span><span class="v mono">N°19</span>
</div>
<div class="meta-row">
<span class="k">메시지</span><span class="v" id="dp-count">4개</span>
</div>
<div class="meta-row">
<span class="k">누적 토큰</span><span class="v mono" id="dp-tokens">1.2K</span>
</div>
<div class="meta-row">
<span class="k">모델</span><span class="v mono">snap-hana-live</span>
</div>
<div class="meta-row"><span class="k">생성</span><span class="v mono">방금 전</span></div>
<div class="meta-row">
<span class="k">상태</span><span class="v" style="color: var(--sage)">● Active</span>
</div>
<div class="dp-tags" id="dp-tags"></div>
<div style="height: 18px"></div>
<button class="btn btn-ghost" id="dp-copy-all" style="width: 100%">세션 전체 복사</button>
</div>
</aside>
</main>
<!-- ============================ NEW CHAT ============================ -->
<main
class="window wide"
id="view-new"
hidden
data-od-id="newchat-window"
role="application"
aria-label="Snap Mate 새 대화"
>
<header class="titlebar">
<span class="tb-mark">SNAP MATE</span>
<span class="tb-no">N°20 · NEW SESSION</span>
<span class="tb-spacer"></span>
<span class="tb-live"><span class="dot"></span>HANA-LIVE</span>
<div class="win-ctrls" aria-label="창 제어">
<button title="최소화" aria-label="최소화">
<svg viewBox="0 0 12 12">
<rect x="1" y="9" width="10" height="1.3" fill="currentColor" />
</svg>
</button>
<button title="최대화" aria-label="최대화">
<svg viewBox="0 0 12 12">
<rect
x="1.5"
y="1.5"
width="9"
height="9"
fill="none"
stroke="currentColor"
stroke-width="1.3"
/>
</svg>
</button>
<button class="close" title="닫기" aria-label="닫기">
<svg viewBox="0 0 12 12">
<path d="M2 2 L10 10 M10 2 L2 10" stroke="currentColor" stroke-width="1.4" />
</svg>
</button>
</div>
</header>
<div class="chat-head">
<button class="back" id="new-back">
<svg viewBox="0 0 12 12" width="11" height="11" fill="none">
<path
d="M8 2L4 6l4 4"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/></svg
>목록
</button>
<div class="chat-title">
<div class="t">새 대화 세션</div>
<div class="m">NEW · 준비됨</div>
</div>
<span class="badge">◈ 0 TOKENS</span>
</div>
<div class="chat-main">
<aside class="nav-rail" aria-label="소스코드 네비게이터">
<div class="nav-head">
<span class="eyebrow"><span class="dot"></span>Source Nav</span
><span class="nav-hint"><kbd>Ctrl</kbd><kbd>J</kbd></span>
</div>
<div class="nav-empty">코드블럭 없음<br />대화를 시작하면<br />여기에 표시됩니다.</div>
</aside>
<div class="win-body" id="new-body">
<div class="stream" id="n-stream">
<section class="chat-hero" id="n-hero" data-od-id="new-hero">
<div class="hero-avatar">S</div>
<span class="eyebrow"><span class="dot"></span>New Session · N°20</span>
<h1>무엇을 도와드릴까요?</h1>
<p>
ABAP · CDS 뷰 · HANA · 에러 분석까지 — 코드나 로그를 붙여넣거나 아래에서 시작해
보세요.
</p>
<div class="suggest-label">빠른 시작</div>
<div class="suggest-grid">
<button
class="suggest"
data-prompt="VBAK/VBAP를 조인한 CDS 뷰 골격을 만들어줘."
data-od-id="suggest-cds"
>
<span class="s-tag">CDS Views</span><span class="s-t">CDS 뷰 생성</span
><span class="s-d">VBAK/VBAP 헤더·아이템 조인 뷰 골격</span>
</button>
<button
class="suggest"
data-prompt="이 ABAP 리포트의 SELECT 루프 성능을 리뷰해줘."
data-od-id="suggest-abap"
>
<span class="s-tag">ABAP</span><span class="s-t">코드 리뷰</span
><span class="s-d">SELECT 루프·내부 테이블 성능 점검</span>
</button>
<button
class="suggest"
data-prompt="CX_SY_OPEN_SQL_DB Short Dump 원인을 분석해줘."
data-od-id="suggest-dump"
>
<span class="s-tag">Error</span><span class="s-t">Short Dump 분석</span
><span class="s-d">ST22 런타임 오류 원인 진단</span>
</button>
<button
class="suggest"
data-prompt="AMDP 프로시저를 셋 기반 SQLScript로 튜닝해줘."
data-od-id="suggest-amdp"
>
<span class="s-tag">HANA</span><span class="s-t">AMDP 튜닝</span
><span class="s-d">스칼라 UDF 제거·셋 기반 재작성</span>
</button>
</div>
</section>
</div>
</div>
</div>
<div class="composer">
<div class="composer-box">
<textarea
id="n-input"
rows="1"
placeholder="새 대화를 시작하세요 — ABAP · CDS · 에러 로그를 붙여넣거나 질문을 입력하세요…"
aria-label="메시지 입력"
></textarea>
<div class="composer-foot">
<span class="hint"><kbd>Enter</kbd> 전송 · <kbd>Shift+Enter</kbd> 줄바꿈</span>
<button class="send" id="n-send" disabled>
<svg viewBox="0 0 16 16" width="13" height="13" fill="none">
<path
d="M2 8l12-5-4.5 12L7 9 2 8z"
stroke="currentColor"
stroke-width="1.4"
stroke-linejoin="round"
/></svg
>전송
</button>
</div>
</div>
</div>
</main>
<div class="toast" id="toast"></div>
<script>
/* ===================== shared ===================== */
function safeGet(k) {
try {
return localStorage.getItem(k)
} catch (e) {
return null
}
}
function safeSet(k, v) {
try {
localStorage.setItem(k, v)
} catch (e) {}
}
function esc(s) {
return s.replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]
)
}
let activeView = "home"
function showView(name) {
activeView = name
document.getElementById("view-home").hidden = name !== "home"
document.getElementById("view-chat").hidden = name !== "chat"
document.getElementById("view-new").hidden = name !== "new"
}
const toastEl = document.getElementById("toast")
function toast(msg) {
toastEl.textContent = msg
toastEl.classList.add("show")
clearTimeout(toast._t)
toast._t = setTimeout(() => toastEl.classList.remove("show"), 1600)
}
/* ===================== HOME ===================== */
const SESSIONS = [
{
id: "review-abap",
title: "Review ABAP Code",
tag: "ABAP",
time: "방금 전",
count: 6,
tokens: "2.1K",
snip: "사용자 정의 ABAP 코드를 분석할 준비가 됐습니다. SELECT 루프 내부의 성능 문제를 검토합니다.",
},
{
id: "cds-view",
title: "Generate CDS View",
tag: "CDS Views",
time: "방금 전",
count: 4,
tokens: "1.2K",
snip: "SAP 개발과 관련해 무엇을 도와드릴까요? ABAP 로직, CDS 뷰, 또는 에러 분석 중 어떤 것이든 시작해 보세요.",
},
{
id: "schema-qa",
title: "SAP Schema Q&A",
tag: "Architecture",
time: "방금 전",
count: 8,
tokens: "3.4K",
snip: "S/4HANA 테이블 스키마와 연관 관계에 대해 질의합니다. VBAK / VBAP 헤더-아이템 조인을 설명합니다.",
},
{
id: "dump-analysis",
title: "Short Dump 분석",
tag: "Error",
time: "12분 전",
count: 5,
tokens: "1.8K",
snip: "런타임 오류 CX_SY_OPEN_SQL_DB — WHERE 절 없는 대량 SELECT로 인한 타임아웃을 진단합니다.",
},
{
id: "amdp-perf",
title: "AMDP 성능 튜닝",
tag: "HANA",
time: "34분 전",
count: 7,
tokens: "2.9K",
snip: "AMDP 프로시저에서 스칼라 UDF 호출을 제거하고 셋 기반 로직으로 재작성해 응답시간을 단축합니다.",
},
{
id: "oo-refactor",
title: "OO ABAP 리팩터링",
tag: "ABAP",
time: "1시간 전",
count: 9,
tokens: "4.0K",
snip: "절차형 리포트를 로컬 클래스 + 인터페이스 구조로 분리하고 단위 테스트 가능한 형태로 만듭니다.",
},
{
id: "rap-model",
title: "RAP 비즈니스 객체",
tag: "RAP",
time: "2시간 전",
count: 9,
tokens: "5.2K",
snip: "Travel BO — CDS·프로젝션·@UI·BDL·핸들러 클래스·서비스 정의 등 6개 소스를 한 번에 생성합니다.",
},
{
id: "batch-job",
title: "백그라운드 잡 설계",
tag: "BASIS",
time: "어제",
count: 3,
tokens: "900",
snip: "대량 마이그레이션 잡을 패키지 단위 COMMIT WORK로 분할해 롤백 세그먼트 초과를 방지합니다.",
},
{
id: "idoc-map",
title: "IDoc 필드 매핑",
tag: "Integration",
time: "어제",
count: 4,
tokens: "1.5K",
snip: "ORDERS05 IDoc 세그먼트를 내부 주문 구조로 매핑하는 규칙과 예외 처리를 정리합니다.",
},
{
id: "cds-annot",
title: "CDS 애노테이션 검토",
tag: "CDS Views",
time: "2일 전",
count: 5,
tokens: "1.7K",
snip: "@UI 및 @Analytics 애노테이션을 점검해 Fiori Elements 리스트 리포트 노출을 조정합니다.",
},
]
const listEl = document.getElementById("session-list")
const searchEl = document.getElementById("search")
const homeEmpty = document.getElementById("home-empty")
const homeBody = document.getElementById("home-body")
let homeCursor = 0,
homeVisible = []
function highlight(text, q) {
if (!q) return esc(text)
const i = text.toLowerCase().indexOf(q.toLowerCase())
if (i < 0) return esc(text)
return (
esc(text.slice(0, i)) +
"<mark>" +
esc(text.slice(i, i + q.length)) +
"</mark>" +
esc(text.slice(i + q.length))
)
}
function homeRender() {
const q = searchEl.value.trim()
homeVisible = SESSIONS.filter(
(s) =>
!q || (s.title + " " + s.snip + " " + s.tag).toLowerCase().includes(q.toLowerCase())
)
document.getElementById("list-total").textContent = homeVisible.length
document.getElementById("list-caption").textContent = q ? "검색 결과" : "최근 진행 대화"
listEl.innerHTML = ""
if (!homeVisible.length) {
homeEmpty.hidden = false
return
}
homeEmpty.hidden = true
homeVisible.forEach((s, idx) => {
const el = document.createElement("button")
el.className = "session"
el.type = "button"
el.setAttribute("role", "option")
el.setAttribute("data-od-id", "session-" + s.id)
el.innerHTML = `
<span class="ico"><svg viewBox="0 0 20 20" fill="none"><path d="M3 5h14v9H8l-4 3v-3H3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg></span>
<span class="s-main">
<span class="s-title">${highlight(s.title, q)}</span>
<span class="s-snip">${highlight(s.snip, q)}</span>
<span class="s-foot"><span class="tag">${esc(s.tag)}</span><span class="s-time">${esc(s.time)}</span></span>
</span>
<span class="chev"><svg viewBox="0 0 12 12" fill="none"><path d="M4 2l5 4-5 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></span>`
el.addEventListener("click", () => openSession(s.id))
el.addEventListener("mouseenter", () => {
homeCursor = idx
homeSync()
})
listEl.appendChild(el)
})
homeCursor = Math.min(homeCursor, homeVisible.length - 1)
homeSync()
}
function homeSync() {
;[...listEl.children].forEach((el, i) => (el.dataset.active = i === homeCursor))
}
function homeScrollTo(i) {
const el = listEl.children[i]
if (!el) return
const er = el.getBoundingClientRect(),
sr = homeBody.getBoundingClientRect()
if (er.bottom > sr.bottom) homeBody.scrollTop += er.bottom - sr.bottom + 12
else if (er.top < sr.top) homeBody.scrollTop -= sr.top - er.top + 12
}
searchEl.addEventListener("input", () => {
homeCursor = 0
homeRender()
})
document.getElementById("new-session").addEventListener("click", showNewChat)
document.addEventListener("keydown", (e) => {
if (activeView !== "home") return
if (e.key === "ArrowDown") {
e.preventDefault()
homeCursor = Math.min(homeCursor + 1, homeVisible.length - 1)
homeSync()
homeScrollTo(homeCursor)
} else if (e.key === "ArrowUp") {
e.preventDefault()
homeCursor = Math.max(homeCursor - 1, 0)
homeSync()
homeScrollTo(homeCursor)
} else if (e.key === "Enter") {
if (homeVisible[homeCursor]) openSession(homeVisible[homeCursor].id)
} else if (e.key === "Escape") {
if (searchEl.value) {
searchEl.value = ""
homeCursor = 0
homeRender()
}
} else if ((e.ctrlKey || e.metaKey) && (e.key === "n" || e.key === "N")) {
e.preventDefault()
showNewChat()
}
})
/* ===================== CHAT: data ===================== */
const META = {
"cds-view": {
title: "Generate CDS View",
tag: "CDS Views",
tokens: "1.2K",
count: 4,
tags: ["CDS", "DDL", "S/4HANA"],
},
"review-abap": {
title: "Review ABAP Code",
tag: "ABAP",
tokens: "2.1K",
count: 6,
tags: ["ABAP", "Perf", "SELECT"],
},
"schema-qa": {
title: "SAP Schema Q&A",
tag: "Architecture",
tokens: "3.4K",
count: 8,
tags: ["VBAK", "VBAP", "Join"],
},
"dump-analysis": {
title: "Short Dump 분석",
tag: "Error",
tokens: "1.8K",
count: 5,
tags: ["Runtime", "OpenSQL", "ST22"],
},
"amdp-perf": {
title: "AMDP 성능 튜닝",
tag: "HANA",
tokens: "2.9K",
count: 7,
tags: ["AMDP", "SQLScript"],
},
"oo-refactor": {
title: "OO ABAP 리팩터링",
tag: "ABAP",
tokens: "4.0K",
count: 9,
tags: ["OO", "Unit Test"],
},
"rap-model": {
title: "RAP 비즈니스 객체",
tag: "RAP",
tokens: "5.2K",
count: 9,
tags: ["RAP", "CDS", "BDL", "ABAP", "6 sources"],
},
"batch-job": {
title: "백그라운드 잡 설계",
tag: "BASIS",
tokens: "900",
count: 3,
tags: ["Batch", "COMMIT"],
},
"idoc-map": {
title: "IDoc 필드 매핑",
tag: "Integration",
tokens: "1.5K",
count: 4,
tags: ["IDoc", "ORDERS05"],
},
"cds-annot": {
title: "CDS 애노테이션 검토",
tag: "CDS Views",
tokens: "1.7K",
count: 5,
tags: ["@UI", "Fiori"],
},
}
const cds = [
'<span class="cm">// I_SalesDocItemJoin — 판매 문서 헤더/아이템 조인 뷰</span>',
'<span class="kw">define view entity</span> ZI_SalesDocItemJoin',
' <span class="kw">as select from</span> vbak <span class="kw">as</span> Header',
' <span class="kw">inner join</span> vbap <span class="kw">as</span> Item',
' <span class="kw">on</span> Header.vbeln = Item.vbeln',
"{",
' <span class="kw">key</span> Header.vbeln <span class="kw">as</span> SalesDocument,',
' <span class="kw">key</span> Item.posnr <span class="kw">as</span> Item,',
' Header.kunnr <span class="kw">as</span> SoldToParty,',
' Header.audat <span class="kw">as</span> DocumentDate,',
' Item.matnr <span class="kw">as</span> Material,',
' Item.kwmeng <span class="kw">as</span> OrderQuantity,',
' Item.netwr <span class="kw">as</span> NetAmount,',
' Header.waerk <span class="kw">as</span> Currency',
"}",
].join("\n")
const cdsAgg = [
'<span class="cm">// 헤더 단위 총액 집계 뷰 (@Analytics 노출)</span>',
'<span class="st">@Analytics.dataCategory:</span> #CUBE',
'<span class="kw">define view entity</span> ZI_SalesDocTotal',
' <span class="kw">as select from</span> ZI_SalesDocItemJoin',
"{",
' <span class="kw">key</span> SalesDocument,',
" SoldToParty,",
" Currency,",
' <span class="fn">@Aggregation.default:</span> #SUM',
' NetAmount <span class="kw">as</span> TotalNetAmount,',
' <span class="fn">@Aggregation.default:</span> #SUM',
' OrderQuantity <span class="kw">as</span> TotalQuantity',
"}",
'<span class="kw">group by</span> SalesDocument, SoldToParty, Currency',
].join("\n")
const dump = [
'<span class="cm">* ST22 Short Dump — CX_SY_OPEN_SQL_DB</span>',
'<span class="cm">* 원인: WHERE 절 없는 전체 테이블 SELECT → DB 타임아웃</span>',
'<span class="kw">SELECT</span> * <span class="kw">FROM</span> vbap <span class="kw">INTO TABLE</span> @lt_items. <span class="cm">" ✗ 위험</span>',
"",
'<span class="cm">* 권장: 키 기반 제한 + 필요한 필드만</span>',
'<span class="kw">SELECT</span> vbeln posnr matnr kwmeng',
' <span class="kw">FROM</span> vbap <span class="kw">INTO TABLE</span> @lt_items',
' <span class="kw">WHERE</span> vbeln <span class="kw">IN</span> @s_vbeln',
' <span class="kw">AND</span> erdat &gt;= @lv_from.',
].join("\n")
function escCode(s) {
return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c])
}
const ABAP_KW =
/\b(REPORT|TYPES|BEGIN|END|OF|DATA|SELECT-OPTIONS|SELECT|FROM|INNER|JOIN|ON|INTO|CORRESPONDING|FIELDS|TABLE|WHERE|AND|ELSE|ENDIF|IF|ENDFORM|FORM|PERFORM|MESSAGE|TYPE|LIKE|DISPLAY|NEW|TRY|CATCH|ENDTRY|IMPORTING|CHANGING|EXPORTING|STANDARD|ENDLOOP|LOOP|CLASS|METHODS|abap_true|abap_false)\b/g
function hlABAP(raw) {
return escCode(raw)
.replace(/^(\*.*)$/gm, '<span class="cm">$1</span>')
.replace(/(START-OF-SELECTION)/g, '<span class="kw">$1</span>')
.replace(ABAP_KW, '<span class="kw">$1</span>')
.replace(/(cl_salv_table|cl_salv_msg|sy-subrc|sy-datum)/g, '<span class="fn">$1</span>')
.replace(/&#39;([^&]*?)&#39;/g, '<span class="st">&#39;$1&#39;</span>')
.replace(/'([^']*)'/g, "<span class=\"st\">'$1'</span>")
}
function hlDump(raw) {
return escCode(raw)
.replace(/^(-{5,})$/gm, '<span class="cm">$1</span>')
.replace(/(CX_SY_OPEN_SQL_DB)/g, '<span class="st">$1</span>')
.replace(/\b(SY-\w+)\b/g, '<span class="kw">$1</span>')
.replace(/\b(ZR_SALES_ORDER_REPORT|FETCH_DATA)\b/g, '<span class="fn">$1</span>')
}
function hlSrc(raw, kw) {
const re = new RegExp("\\b(" + kw.join("|") + ")\\b", "g")
return escCode(raw)
.replace(/^(\s*(?:\/\/|\*).*)$/gm, '<span class="cm">$1</span>')
.replace(re, '<span class="kw">$1</span>')
.replace(/(@[\w.]+)/g, '<span class="st">$1</span>')
.replace(/'([^']*)'/g, "<span class=\"st\">'$1'</span>")
}
const KW_CDS = [
"define",
"root",
"view",
"entity",
"as",
"select",
"from",
"projection",
"on",
"provider",
"contract",
"transactional_query",
"key",
"association",
"composition",
"to",
"of",
"annotate",
"with",
"service",
"expose",
]
const KW_BDL = [
"managed",
"unmanaged",
"implementation",
"in",
"class",
"unique",
"strict",
"define",
"behavior",
"for",
"alias",
"persistent",
"table",
"lock",
"master",
"authorization",
"instance",
"create",
"update",
"delete",
"field",
"readonly",
"mandatory",
"determination",
"validation",
"on",
"modify",
"save",
"mapping",
]
const KW_ABAP = [
"CLASS",
"DEFINITION",
"INHERITING",
"FROM",
"PUBLIC",
"PROTECTED",
"PRIVATE",
"SECTION",
"METHODS",
"METHOD",
"FOR",
"DETERMINE",
"VALIDATE",
"IMPORTING",
"EXPORTING",
"CHANGING",
"RETURNING",
"ENDCLASS",
"IMPLEMENTATION",
"MODIFY",
"ENTITIES",
"ENTITY",
"OF",
"IN",
"LOCAL",
"MODE",
"UPDATE",
"FIELDS",
"WITH",
"VALUE",
"READ",
"RESULT",
"DATA",
"LOOP",
"AT",
"INTO",
"IF",
"ELSE",
"ENDIF",
"APPEND",
"TO",
"ENDLOOP",
"ENDMETHOD",
"SAVE",
"CORRESPONDING",
]
const RAP_DDL = `@AccessControl.authorizationCheck: #NOT_REQUIRED
define root view entity ZI_Travel
as select from ztravel
{
key travel_id as TravelId,
agency_id as AgencyId,
customer_id as CustomerId,
begin_date as BeginDate,
end_date as EndDate,
booking_fee as BookingFee,
total_price as TotalPrice,
currency_code as CurrencyCode,
overall_status as OverallStatus
}`
const RAP_PROJ = `@Metadata.allowExtensions: true
@Search.searchable: true
define root view entity ZC_Travel
provider contract transactional_query
as projection on ZI_Travel
{
key TravelId,
AgencyId,
CustomerId,
BeginDate,
EndDate,
@Semantics.amount.currencyCode: 'CurrencyCode'
TotalPrice,
CurrencyCode,
OverallStatus
}`
const RAP_META = `@Metadata.layer: #CORE
annotate entity ZC_Travel with
{
@UI.facet: [ { id: 'Travel', purpose: #STANDARD,
type: #IDENTIFICATION_REFERENCE, label: 'Travel', position: 10 } ]
@UI: { lineItem: [ { position: 10 } ], identification: [ { position: 10 } ] }
TravelId;
@UI.lineItem: [ { position: 20 } ]
AgencyId;
@UI.lineItem: [ { position: 30 } ]
OverallStatus;
}`
const RAP_BDL = `managed implementation in class zbp_i_travel unique;
strict ( 2 );
define behavior for ZI_Travel alias Travel
persistent table ztravel
lock master
authorization master ( instance )
{
create;
update;
delete;
field ( readonly ) TravelId;
field ( mandatory ) AgencyId, CustomerId;
determination setInitialStatus on modify { create; }
validation validateDates on save { field BeginDate, EndDate; }
mapping for ztravel { TravelId = travel_id; AgencyId = agency_id; }
}`
const RAP_IMPL = `CLASS lhc_travel DEFINITION INHERITING FROM cl_abap_behavior_handler.
PRIVATE SECTION.
METHODS setInitialStatus FOR DETERMINE ON MODIFY
IMPORTING keys FOR Travel~setInitialStatus.
METHODS validateDates FOR VALIDATE ON SAVE
IMPORTING keys FOR Travel~validateDates.
ENDCLASS.
CLASS lhc_travel IMPLEMENTATION.
METHOD setInitialStatus.
MODIFY ENTITIES OF ZI_Travel IN LOCAL MODE
ENTITY Travel UPDATE FIELDS ( OverallStatus )
WITH VALUE #( FOR key IN keys ( %tky = key-%tky
OverallStatus = 'O' ) ).
ENDMETHOD.
METHOD validateDates.
READ ENTITIES OF ZI_Travel IN LOCAL MODE
ENTITY Travel FIELDS ( BeginDate EndDate )
WITH CORRESPONDING #( keys ) RESULT DATA(travels).
LOOP AT travels INTO DATA(travel).
IF travel-EndDate < travel-BeginDate.
APPEND VALUE #( %tky = travel-%tky ) TO failed-travel.
ENDIF.
ENDLOOP.
ENDMETHOD.
ENDCLASS.`
const RAP_SRV = `define service ZUI_Travel {
expose ZC_Travel as Travel;
expose ZI_Agency as Agency;
expose ZI_Customer as Customer;
}`
const LONG_ABAP = `*&---------------------------------------------------------------------*
*& Report ZR_SALES_ORDER_REPORT
*& 판매 문서 헤더/아이템 조인 후 ALV 출력 (성능 가드 포함)
*&---------------------------------------------------------------------*
REPORT zr_sales_order_report.
TYPES: BEGIN OF ty_out,
vbeln TYPE vbak-vbeln,
posnr TYPE vbap-posnr,
matnr TYPE vbap-matnr,
kwmeng TYPE vbap-kwmeng,
netwr TYPE vbap-netwr,
waerk TYPE vbak-waerk,
END OF ty_out.
DATA: gt_out TYPE STANDARD TABLE OF ty_out,
gs_out TYPE ty_out.
SELECT-OPTIONS: s_vbeln FOR gs_out-vbeln,
s_erdat FOR sy-datum.
START-OF-SELECTION.
PERFORM fetch_data.
PERFORM display_alv.
FORM fetch_data.
SELECT h~vbeln i~posnr i~matnr i~kwmeng i~netwr h~waerk
FROM vbak AS h
INNER JOIN vbap AS i ON i~vbeln = h~vbeln
INTO CORRESPONDING FIELDS OF TABLE @gt_out
WHERE h~vbeln IN @s_vbeln
AND h~erdat IN @s_erdat.
IF sy-subrc <> 0.
MESSAGE 'No sales orders found' TYPE 'S' DISPLAY LIKE 'W'.
ENDIF.
ENDFORM.
FORM display_alv.
DATA(lo_alv) = NEW cl_salv_table( ).
TRY.
cl_salv_table=>factory(
IMPORTING r_salv_table = lo_alv
CHANGING t_table = gt_out ).
lo_alv->get_columns( )->set_optimize( abap_true ).
lo_alv->display( ).
CATCH cx_salv_msg INTO DATA(lx_msg).
MESSAGE lx_msg->get_text( ) TYPE 'E'.
ENDTRY.
ENDFORM.`
const LONG_DUMP = `Runtime Errors CX_SY_OPEN_SQL_DB
Exception CX_SY_OPEN_SQL_DB
Date and Time 16.07.2026 14:22:09
--------------------------------------------------------------------
Short text
The SQL statement was terminated by the database system.
--------------------------------------------------------------------
What happened?
The database system detected a lock wait timeout (SQL 1205)
and rolled back the current transaction.
--------------------------------------------------------------------
Error analysis
An exception occurred that is explained in detail below.
The exception CX_SY_OPEN_SQL_DB was not caught in procedure
"FETCH_DATA" and therefore caused a runtime error.
--------------------------------------------------------------------
How to correct the error
Add a restrictive WHERE clause and read only required fields.
Avoid full scans on large tables (VBAP ~ 42M rows).
--------------------------------------------------------------------
Information on where terminated
Program ZR_SALES_ORDER_REPORT
Include ZR_SALES_ORDER_REPORT
Row 34
Module type FORM
Module name FETCH_DATA
--------------------------------------------------------------------
Contents of system fields
SY-SUBRC 0
SY-INDEX 0
SY-TABIX 0
SY-DBCNT 1048576
SY-DATUM 20260716
SY-UZEIT 142209
--------------------------------------------------------------------
Active calls / events
FORM FETCH_DATA ZR_SALES_ORDER_REPORT
EVENT START-OF-SELECTION ZR_SALES_ORDER_REPORT`
const CONVOS = {
"cds-view": [
{
r: "assistant",
html: "<p>SAP 개발과 관련해 무엇을 도와드릴까요? ABAP 로직, CDS 뷰, 또는 에러 분석 중 어떤 것이든 시작해 보세요.</p>",
},
{
r: "user",
html: "<p>판매 문서 헤더/아이템을 조인한 CDS 뷰 하나 만들어줘. VBAK, VBAP 기준으로.</p>",
},
{
r: "assistant",
html: "<p><b>VBAK</b>(헤더)와 <b>VBAP</b>(아이템)를 <code>vbeln</code>으로 내부 조인한 뷰 엔티티입니다. 키는 문서번호+아이템번호로 잡았습니다.</p>",
code: { lang: "CDS · DDL", body: cds },
},
{
r: "assistant",
html: "<ul><li>필요한 필드만 노출해 성능을 확보했습니다.</li><li>Fiori 노출이 필요하면 <code>@UI</code> 애노테이션을 추가하세요.</li></ul>",
},
{ r: "user", html: "<p>여기서 문서 단위 총액도 집계로 넣어줘.</p>" },
{
r: "assistant",
html: "<p>위 조인 뷰를 소스로 <code>SUM</code> 집계 큐브 뷰를 얹었습니다. <code>@Analytics.dataCategory: #CUBE</code>로 분석 소비가 가능합니다.</p>",
code: { lang: "CDS · Aggregation", body: cdsAgg },
},
{
r: "user",
html: "<p>이 뷰를 소비하는 실제 리포트 프로그램 전체도 보여줘. 길어도 괜찮아.</p>",
},
{
r: "assistant",
html: "<p>ALV 출력까지 포함한 전체 리포트 골격입니다. 코드가 길어 <b>블록 안에서만 스크롤</b>되고, 상단 바(언어·복사)는 고정됩니다.</p>",
code: { lang: "ABAP · Report · 52 lines", body: hlABAP(LONG_ABAP) },
},
],
"dump-analysis": [
{
r: "assistant",
html: "<p>런타임 오류 로그를 붙여넣어 주세요. ST22 short dump를 분석해 원인과 수정안을 제시합니다.</p>",
},
{
r: "user",
html: "<p><code>CX_SY_OPEN_SQL_DB</code> 덤프가 떴어. 배치 실행 중에 타임아웃 난 것 같아. 전문 붙여넣을게.</p>",
code: { lang: "ST22 · Short Dump", body: hlDump(LONG_DUMP) },
},
{
r: "assistant",
html: "<p>전형적인 <b>WHERE 절 없는 대량 SELECT</b> 패턴입니다. VBAP 전체를 메모리로 읽어 DB 세션이 타임아웃됐습니다. 키 범위로 제한하고 필요한 컬럼만 조회하세요.</p>",
code: { lang: "ABAP · Fix", body: dump },
},
{
r: "assistant",
html: "<ul><li>패키지 단위 처리로 롤백 세그먼트 부담을 줄이세요.</li><li><code>SELECT *</code> 대신 명시 필드 목록을 사용하세요.</li></ul>",
},
],
"rap-model": [
{
r: "assistant",
html: "<p>RAP 비즈니스 객체 하나에는 <b>여러 소스</b>가 함께 필요합니다. Travel BO를 예로 6개 아티팩트를 생성했어요. 좌측 <b>소스 네비게이터(Ctrl+J)</b>로 소스 간 이동해 보세요.</p>",
},
{
r: "user",
html: "<p>관리형(managed) RAP로 Travel BO 만들어줘. 데이터 정의부터 서비스 노출까지 전부.</p>",
},
{
r: "assistant",
html: "<p><b>1) 데이터 정의</b> — 영속 테이블 <code>ztravel</code> 기반 인터페이스 루트 뷰입니다.</p>",
code: { lang: "CDS · Data Definition", body: hlSrc(RAP_DDL, KW_CDS) },
},
{
r: "assistant",
html: "<p><b>2) 프로젝션 뷰</b> — 트랜잭션 쿼리 계약을 노출하는 소비 뷰입니다.</p>",
code: { lang: "CDS · Projection", body: hlSrc(RAP_PROJ, KW_CDS) },
},
{
r: "assistant",
html: "<p><b>3) 메타데이터 확장</b> — Fiori Elements용 <code>@UI</code> 애노테이션을 분리했습니다.</p>",
code: { lang: "CDS · Metadata Ext", body: hlSrc(RAP_META, KW_CDS) },
},
{
r: "assistant",
html: "<p><b>4) 비헤이비어 정의(BDL)</b> — CRUD·잠금·결정·검증을 선언합니다.</p>",
code: { lang: "RAP · Behavior Def", body: hlSrc(RAP_BDL, KW_BDL) },
},
{
r: "assistant",
html: "<p><b>5) 비헤이비어 구현</b> — 결정/검증 로직을 담은 로컬 핸들러 클래스입니다.</p>",
code: { lang: "ABAP · Behavior Impl", body: hlSrc(RAP_IMPL, KW_ABAP) },
},
{
r: "assistant",
html: "<p><b>6) 서비스 정의</b> — OData 노출용 서비스입니다. 이후 서비스 바인딩만 활성화하면 끝입니다.</p>",
code: { lang: "CDS · Service Def", body: hlSrc(RAP_SRV, KW_CDS) },
},
{
r: "assistant",
html: "<ul><li>네비게이터에 6개 소스가 모두 잡힙니다 — <kbd>Alt+↑↓</kbd>로 소스 간 점프하세요.</li><li>다음 단계로 <code>zbp_i_travel</code> 클래스 풀 구현을 이어갈 수 있습니다.</li></ul>",
},
],
}
function genericConvo(m) {
return [
{
r: "assistant",
html: `<p><b>${m.title}</b> 세션을 시작합니다. ${m.tag} 관련 코드나 로그를 붙여넣어 주세요.</p>`,
},
{ r: "user", html: "<p>표준 패턴 예시부터 보여줘.</p>" },
{
r: "assistant",
html: "<p>재사용 가능한 골격입니다. 프로젝트 네이밍 규칙에 맞게 조정하세요.</p>",
code: {
lang: "ABAP",
body: [
'<span class="kw">CLASS</span> lcl_handler <span class="kw">DEFINITION</span>.',
' <span class="kw">PUBLIC SECTION</span>.',
' <span class="kw">METHODS</span> run <span class="kw">IMPORTING</span> iv_key <span class="kw">TYPE</span> vbeln.',
'<span class="kw">ENDCLASS</span>.',
].join("\n"),
},
},
]
}
/* ===================== CHAT: refs + render ===================== */
const stream = document.getElementById("stream")
const chatBody = document.getElementById("chat-body")
const input = document.getElementById("input")
const send = document.getElementById("send")
const banner = document.getElementById("clip-banner")
const overlay = document.getElementById("overlay")
const panel = document.getElementById("detail-panel")
const navList = document.getElementById("nav-list")
const navRail = document.getElementById("nav-rail")
let curMeta = META["cds-view"]
let codeCount = 0
let bannerTimer = null
let navItems = [],
navIdx = 0,
blockCursor = 0
function wrapLines(body) {
return body
.split("\n")
.map((l) => `<span class="cl">${l === "" ? " " : l}</span>`)
.join("")
}
function addMessage(m, animate) {
const el = document.createElement("div")
el.className = "msg " + m.r
const who = m.r === "user" ? "나 · YOU" : "SNAP MATE"
let codeHtml = ""
if (m.code) {
const cid = "code-" + codeCount++
codeHtml = `<div class="code" tabindex="0" id="${cid}" data-od-id="${cid}"><div class="code-bar"><span class="lang">${m.code.lang}</span><button class="copy" data-copy="${cid}"><svg viewBox="0 0 14 14" width="11" height="11" fill="none"><rect x="4" y="4" width="8" height="8" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M10 4V3a1.5 1.5 0 0 0-1.5-1.5H3A1.5 1.5 0 0 0 1.5 3v5.5A1.5 1.5 0 0 0 3 10h1" stroke="currentColor" stroke-width="1.3"/></svg>복사</button></div><pre>${wrapLines(m.code.body)}</pre></div>`
}
el.innerHTML = `<div class="who">${who}</div><div class="body">${m.html}${codeHtml}</div>`
stream.appendChild(el)
if (animate) {
el.style.opacity = 0
el.style.transform = "translateY(6px)"
requestAnimationFrame(() => {
el.style.transition = "opacity .25s, transform .25s"
el.style.opacity = 1
el.style.transform = "none"
})
}
return el
}
function renderChat(sid) {
curMeta = META[sid] || META["cds-view"]
const convo = CONVOS[sid] || genericConvo(curMeta)
document.getElementById("ch-title").textContent = curMeta.title
document.getElementById("ch-sub").textContent = curMeta.tag.toUpperCase() + " · 대화 세션"
document.getElementById("ch-tokens").textContent = curMeta.tokens
document.getElementById("tb-session").textContent = "N°19 · " + curMeta.tag.toUpperCase()
document.getElementById("dp-title").textContent = curMeta.title
document.getElementById("dp-tag").textContent = curMeta.tag.toUpperCase()
document.getElementById("dp-count").textContent = curMeta.count + "개"
document.getElementById("dp-tokens").textContent = curMeta.tokens
document.getElementById("dp-tags").innerHTML = curMeta.tags
.map((t) => `<span class="tag">${t}</span>`)
.join("")
stream.innerHTML = ""
convo.forEach((m) => addMessage(m, false))
bindCopyButtons()
buildNav()
input.value = ""
input.style.height = "auto"
send.disabled = true
closePanel()
banner.classList.remove("show")
clearTimeout(bannerTimer)
bannerTimer = setTimeout(() => {
if (activeView === "chat") banner.classList.add("show")
}, 2600)
chatBody.scrollTop = 0
}
function openSession(id) {
safeSet("sm.last", id)
renderChat(id)
showView("chat")
}
document.getElementById("chat-back").addEventListener("click", () => {
showView("home")
homeRender()
})
/* copy */
function copyText(text) {
const done = () => toast("클립보드에 복사됨 · COPIED")
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(text)
.then(done)
.catch(() => fallbackCopy(text, done))
} else fallbackCopy(text, done)
}
function fallbackCopy(text, done) {
const ta = document.createElement("textarea")
ta.value = text
ta.style.position = "fixed"
ta.style.opacity = "0"
document.body.appendChild(ta)
ta.select()
try {
document.execCommand("copy")
done()
} catch (e) {
toast("복사 실패")
}
document.body.removeChild(ta)
}
function codeTextOf(codeEl) {
return codeEl.querySelector("pre").innerText
}
function bindCopyButtons() {
stream.querySelectorAll(".copy").forEach((btn) => {
btn.onclick = () => {
const codeEl = document.getElementById(btn.dataset.copy)
copyText(codeTextOf(codeEl))
btn.classList.add("ok")
btn.textContent = "✓ 복사됨"
setTimeout(() => {
btn.classList.remove("ok")
btn.innerHTML =
'<svg viewBox="0 0 14 14" width="11" height="11" fill="none"><rect x="4" y="4" width="8" height="8" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M10 4V3a1.5 1.5 0 0 0-1.5-1.5H3A1.5 1.5 0 0 0 1.5 3v5.5A1.5 1.5 0 0 0 3 10h1" stroke="currentColor" stroke-width="1.3"/></svg>복사'
}, 1400)
}
})
}
function flash(el) {
el.style.transition = "box-shadow .15s"
el.style.boxShadow = "0 0 0 3px color-mix(in oklch, var(--sage) 55%, transparent)"
setTimeout(() => (el.style.boxShadow = ""), 500)
}
let lastCode = null
document.addEventListener("focusin", (e) => {
if (e.target.classList && e.target.classList.contains("code")) lastCode = e.target
})
/* chat composer */
input.addEventListener("input", () => {
input.style.height = "auto"
input.style.height = Math.min(input.scrollHeight, 120) + "px"
send.disabled = !input.value.trim()
})
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
chatSubmit()
}
})
send.addEventListener("click", chatSubmit)
function chatSubmit() {
const text = input.value.trim()
if (!text) return
addMessage({ r: "user", html: "<p>" + esc(text) + "</p>" }, true)
input.value = ""
input.style.height = "auto"
send.disabled = true
chatScrollBottom()
setTimeout(() => {
addMessage(
{
r: "assistant",
html:
"<p>요청을 분석하는 중입니다… 관련 ABAP/CDS 컨텍스트를 <b>" +
curMeta.tag +
"</b> 세션에서 불러옵니다.</p>",
},
true
)
bindCopyButtons()
buildNav()
chatScrollBottom()
}, 520)
}
function chatScrollBottom() {
requestAnimationFrame(() => {
chatBody.scrollTop = chatBody.scrollHeight
})
}
/* detail panel */
function openPanel() {
overlay.classList.add("show")
panel.classList.add("show")
panel.setAttribute("aria-hidden", "false")
}
function closePanel() {
overlay.classList.remove("show")
panel.classList.remove("show")
panel.setAttribute("aria-hidden", "true")
}
document.getElementById("badge-tokens").addEventListener("click", openPanel)
document.getElementById("dp-close").addEventListener("click", closePanel)
overlay.addEventListener("click", closePanel)
document.getElementById("dp-copy-all").addEventListener("click", () => {
const all = [...stream.querySelectorAll(".msg")]
.map(
(m) =>
(m.classList.contains("user") ? "나" : "Snap Mate") +
": " +
m.querySelector(".body").innerText
)
.join("\n\n")
copyText(all)
})
/* clipboard banner */
document
.getElementById("clip-dismiss")
.addEventListener("click", () => banner.classList.remove("show"))
document.getElementById("clip-accept").addEventListener("click", () => {
banner.classList.remove("show")
input.value = "SELECT * FROM vbap INTO TABLE @lt_items."
input.dispatchEvent(new Event("input"))
input.focus()
toast("클립보드 코드 붙여넣음")
})
/* navigator */
function buildNav() {
const codes = [...stream.querySelectorAll(".code")]
navItems = []
if (!codes.length) {
navRail.style.display = "none"
navList.innerHTML = ""
return
}
navRail.style.display = ""
codes.forEach((codeEl, bi) => {
const lang = (codeEl.querySelector(".lang")?.textContent || "BLOCK " + (bi + 1)).trim()
const lines = [...codeEl.querySelectorAll(".cl")]
navItems.push({ codeEl, label: lang, meta: lines.length + " L" })
})
blockCursor = 0
navIdx = 0
renderNav()
}
function renderNav() {
navList.innerHTML = ""
navItems.forEach((it, i) => {
const b = document.createElement("button")
b.type = "button"
b.className = "nav-item block"
b.setAttribute("role", "option")
b.dataset.i = i
b.innerHTML = `<span class="ni-ico"><svg viewBox="0 0 16 16" width="12" height="12" fill="none"><path d="M6 3.5L2 8l4 4.5M10 3.5L14 8l-4 4.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="ni-label">${it.label}</span><span class="ni-meta">${it.meta}</span>`
b.addEventListener("click", () => {
navIdx = i
blockCursor = i
selectNav(false)
jumpTo(it)
})
navList.appendChild(b)
})
navIdx = Math.max(0, Math.min(navIdx, navItems.length - 1))
selectNav(false)
}
function selectNav(focus) {
;[...navList.children].forEach((el, i) => (el.dataset.active = i === navIdx))
if (focus) {
const el = navList.children[navIdx]
if (el) el.focus()
}
}
function jumpTo(it) {
if (!it) return
const cr = it.codeEl.getBoundingClientRect(),
br = chatBody.getBoundingClientRect()
chatBody.scrollTop += cr.top - br.top - 14
requestAnimationFrame(() => {
flash(it.codeEl)
it.codeEl.focus({ preventScroll: true })
})
}
navList.addEventListener("keydown", (e) => {
if (!navItems.length) return
if (e.key === "ArrowDown") {
e.preventDefault()
navIdx = Math.min(navIdx + 1, navItems.length - 1)
blockCursor = navIdx
selectNav(true)
} else if (e.key === "ArrowUp") {
e.preventDefault()
navIdx = Math.max(navIdx - 1, 0)
blockCursor = navIdx
selectNav(true)
} else if (e.key === "Enter") {
e.preventDefault()
jumpTo(navItems[navIdx])
} else if (e.key === "Escape") {
e.preventDefault()
input.focus()
}
})
/* chat-scoped global keys */
document.addEventListener("keydown", (e) => {
if (activeView !== "chat") return
if (
(e.ctrlKey || e.metaKey) &&
e.shiftKey &&
(e.key === "c" || e.key === "C" || e.key === "ㄷ")
) {
e.preventDefault()
const target =
document.activeElement &&
document.activeElement.classList &&
document.activeElement.classList.contains("code")
? document.activeElement
: lastCode || stream.querySelector(".code")
if (target) {
copyText(codeTextOf(target))
flash(target)
}
}
if (e.key === "Tab" && !e.shiftKey) {
const stops = [...stream.querySelectorAll(".code"), input]
const i = stops.indexOf(document.activeElement)
if (i > -1 && i < stops.length - 1) {
e.preventDefault()
stops[i + 1].focus()
}
}
if ((e.ctrlKey || e.metaKey) && (e.key === "j" || e.key === "J" || e.key === "ㅓ")) {
e.preventDefault()
if (navItems.length) {
navIdx = 0
selectNav(true)
}
}
if (e.altKey && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
if (!navItems.length) return
e.preventDefault()
blockCursor =
e.key === "ArrowDown"
? Math.min(blockCursor + 1, navItems.length - 1)
: Math.max(blockCursor - 1, 0)
navIdx = blockCursor
selectNav(false)
jumpTo(navItems[blockCursor])
}
if (e.key === "Escape") {
closePanel()
}
})
/* ===================== NEW CHAT ===================== */
const nStream = document.getElementById("n-stream")
const nInput = document.getElementById("n-input")
const nSend = document.getElementById("n-send")
const newBody = document.getElementById("new-body")
const NEW_HERO = document.getElementById("n-hero").outerHTML
let nStarted = false
function bindSuggests() {
nStream.querySelectorAll(".suggest").forEach((btn) => {
btn.onclick = () => {
nInput.value = btn.dataset.prompt
nInput.dispatchEvent(new Event("input"))
nInput.focus()
}
})
}
function resetNew() {
nStream.innerHTML = NEW_HERO
nStarted = false
nInput.value = ""
nInput.style.height = "auto"
nSend.disabled = true
bindSuggests()
}
function showNewChat() {
resetNew()
showView("new")
setTimeout(() => nInput.focus(), 30)
}
function nAddMessage(role, html) {
const el = document.createElement("div")
el.className = "msg " + role
el.innerHTML = `<div class="who">${role === "user" ? "나 · YOU" : "SNAP MATE"}</div><div class="body">${html}</div>`
el.style.opacity = 0
el.style.transform = "translateY(6px)"
nStream.appendChild(el)
requestAnimationFrame(() => {
el.style.transition = "opacity .25s, transform .25s"
el.style.opacity = 1
el.style.transform = "none"
})
}
function leaveHero() {
const h = document.getElementById("n-hero")
if (!nStarted && h) {
h.classList.add("leaving")
setTimeout(() => {
if (h.parentNode) h.remove()
}, 260)
}
nStarted = true
}
function nScrollBottom() {
requestAnimationFrame(() => {
newBody.scrollTop = newBody.scrollHeight
})
}
nInput.addEventListener("input", () => {
nInput.style.height = "auto"
nInput.style.height = Math.min(nInput.scrollHeight, 120) + "px"
nSend.disabled = !nInput.value.trim()
})
nInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
nSubmit()
}
})
nSend.addEventListener("click", nSubmit)
function nSubmit() {
const text = nInput.value.trim()
if (!text) return
leaveHero()
nAddMessage("user", "<p>" + esc(text) + "</p>")
nInput.value = ""
nInput.style.height = "auto"
nSend.disabled = true
nScrollBottom()
setTimeout(() => {
nAddMessage(
"assistant",
"<p>좋습니다. <b>" +
esc(text.length > 40 ? text.slice(0, 40) + "…" : text) +
"</b> 요청을 처리할게요. 관련 코드/로그가 있으면 붙여넣어 주세요 — 분석해서 수정안을 제시합니다.</p>"
)
nScrollBottom()
}, 520)
}
document.getElementById("new-back").addEventListener("click", () => {
showView("home")
homeRender()
})
/* ===================== init ===================== */
bindSuggests()
homeRender()
showView("home")
</script>
</body>
</html>