세션 화면 디자인 개편
This commit is contained in:
parent
04a5f485ed
commit
12bdc59497
13 changed files with 1430 additions and 386 deletions
|
|
@ -673,7 +673,8 @@ test.describe("full sweep — counseling session", () => {
|
|||
await routeSessionFixtureApi(page);
|
||||
|
||||
await startFixtureSession(page);
|
||||
await expect(page.locator(".sx-signal__rest")).toContainText("아직 표시할 신호가 없어요");
|
||||
await expect(page.locator(".sx-signal__rest")).toHaveCount(0);
|
||||
await expect(page.locator(".sx-coach-card")).toBeVisible();
|
||||
|
||||
await page.getByLabel("학습자 발화 입력").fill(learnerText);
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
|
|
@ -702,6 +703,7 @@ test.describe("full sweep — counseling session", () => {
|
|||
test("shows the coach nudge for a pending turn and requests coaching immediately when opened", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1366, height: 640 });
|
||||
const api = await routeSessionFixtureApi(page);
|
||||
|
||||
await startFixtureSession(page);
|
||||
|
|
@ -716,12 +718,175 @@ test.describe("full sweep — counseling session", () => {
|
|||
expect(api.liveCoachRequests).toHaveLength(0);
|
||||
|
||||
await nudge.click();
|
||||
await expect(page.locator(".sx-coach-card").getByText("감정 반영이 선명합니다")).toBeVisible();
|
||||
const coachCard = page.locator(".sx-coach-card");
|
||||
await expect(coachCard.getByText("감정 반영이 선명합니다")).toBeVisible();
|
||||
await expect(page.locator(".sx-signal__wave")).toHaveCount(0);
|
||||
|
||||
const railLayout = await page.evaluate(() => {
|
||||
const panel = document.querySelector<HTMLElement>(".sx-signal");
|
||||
const coach = document.querySelector<HTMLElement>(".sx-coach-card");
|
||||
const signal = document.querySelector<HTMLElement>(".sx-signal__one");
|
||||
if (!panel || !coach || !signal) return null;
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const coachRect = coach.getBoundingClientRect();
|
||||
const signalRect = signal.getBoundingClientRect();
|
||||
return {
|
||||
coachBeforeSignal: coachRect.top < signalRect.top,
|
||||
coachInsidePanel:
|
||||
coachRect.top >= panelRect.top - 1 && coachRect.bottom <= panelRect.bottom + 1,
|
||||
coachInsideViewport: coachRect.bottom <= window.innerHeight + 1,
|
||||
};
|
||||
});
|
||||
expect(railLayout).toEqual({
|
||||
coachBeforeSignal: true,
|
||||
coachInsidePanel: true,
|
||||
coachInsideViewport: true,
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1024, height: 640 });
|
||||
await expect(coachCard).toBeVisible();
|
||||
await expect(page.locator(".sx-page--active .sx-col-right")).toBeVisible();
|
||||
await expect(page.locator(".sx-signal__one")).toBeHidden();
|
||||
const compactCoachInsideViewport = await coachCard.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.top >= -1 && rect.bottom <= window.innerHeight + 1;
|
||||
});
|
||||
expect(compactCoachInsideViewport).toBe(true);
|
||||
|
||||
await expect(nudge).toHaveCount(0);
|
||||
expect(api.liveCoachRequests).toHaveLength(1);
|
||||
expect(api.liveCoachRequests[0]).toMatchObject({ learner_text: learnerText });
|
||||
});
|
||||
|
||||
test("uses the global light theme tokens throughout an active session", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("vignette.theme", "light");
|
||||
});
|
||||
await routeSessionFixtureApi(page);
|
||||
|
||||
await startFixtureSession(page);
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||||
await page.getByRole("button", { name: /코칭 모드, 남은 기회/ }).click();
|
||||
await expect(page.locator(".sx-coach-bubble")).toBeVisible();
|
||||
|
||||
const themeState = await page.evaluate(() => {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const session = document.querySelector<HTMLElement>(".sx-page--active");
|
||||
const coachBubble = document.querySelector<HTMLElement>(".sx-coach-bubble");
|
||||
if (!session || !coachBubble) return null;
|
||||
const sessionStyle = getComputedStyle(session);
|
||||
const coachStyle = getComputedStyle(coachBubble);
|
||||
return {
|
||||
rootSurface: rootStyle.getPropertyValue("--bg-surface").trim(),
|
||||
sessionSurface: sessionStyle.getPropertyValue("--bg-surface").trim(),
|
||||
rootText: rootStyle.getPropertyValue("--text-strong").trim(),
|
||||
sessionText: sessionStyle.getPropertyValue("--text-strong").trim(),
|
||||
colorScheme: sessionStyle.colorScheme,
|
||||
coachInsetSurface:
|
||||
coachBubble.classList.contains("vg-surface--inset") &&
|
||||
coachStyle.getPropertyValue("--glass-surface-inset").trim() ===
|
||||
rootStyle.getPropertyValue("--glass-surface-inset").trim(),
|
||||
};
|
||||
});
|
||||
|
||||
expect(themeState).not.toBeNull();
|
||||
expect(themeState!.sessionSurface).toBe(themeState!.rootSurface);
|
||||
expect(themeState!.sessionText).toBe(themeState!.rootText);
|
||||
expect(themeState!.colorScheme).toBe("light");
|
||||
expect(themeState!.coachInsetSurface).toBe(true);
|
||||
});
|
||||
|
||||
test("matches the 1536px botanical session workspace composition", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1536, height: 1024 });
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("vignette.theme", "light");
|
||||
});
|
||||
await routeSessionFixtureApi(page);
|
||||
|
||||
await startFixtureSession(page);
|
||||
await expect(page.getByText("라이브 코칭", { exact: true })).toBeVisible();
|
||||
await expect(page.locator(".sx-coach-card")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "상태 신호" })).toHaveClass(/is-on/);
|
||||
|
||||
const layout = await page.evaluate(async () => {
|
||||
const pageRoot = document.querySelector<HTMLElement>(".sx-page--active");
|
||||
const bar = document.querySelector<HTMLElement>(".sx-sessionbar");
|
||||
const grid = document.querySelector<HTMLElement>(".sx-grid");
|
||||
const left = document.querySelector<HTMLElement>(".sx-col-left");
|
||||
const center = document.querySelector<HTMLElement>(".sx-col-center");
|
||||
const right = document.querySelector<HTMLElement>(".sx-col-right");
|
||||
const controls = document.querySelector<HTMLElement>(".sx-controlbar");
|
||||
const mic = document.querySelector<HTMLElement>(".sx-mic");
|
||||
const micText = document.querySelector<HTMLElement>(".sx-mic-block__ms");
|
||||
if (!pageRoot || !bar || !grid || !left || !center || !right || !controls || !mic || !micText) {
|
||||
return null;
|
||||
}
|
||||
const rect = (element: HTMLElement) => {
|
||||
const value = element.getBoundingClientRect();
|
||||
return {
|
||||
left: Math.round(value.left),
|
||||
top: Math.round(value.top),
|
||||
right: Math.round(value.right),
|
||||
bottom: Math.round(value.bottom),
|
||||
width: Math.round(value.width),
|
||||
height: Math.round(value.height),
|
||||
};
|
||||
};
|
||||
const leafResponse = await fetch("/session-botanical/leaf-1.webp");
|
||||
return {
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
page: rect(pageRoot),
|
||||
bar: rect(bar),
|
||||
grid: rect(grid),
|
||||
left: rect(left),
|
||||
center: rect(center),
|
||||
right: rect(right),
|
||||
controls: rect(controls),
|
||||
mic: rect(mic),
|
||||
micText: rect(micText),
|
||||
pageLeaf: getComputedStyle(pageRoot, "::before").backgroundImage,
|
||||
stageLeaf: getComputedStyle(
|
||||
document.querySelector<HTMLElement>(".sx-orb-wrap")!,
|
||||
"::before",
|
||||
).backgroundImage,
|
||||
leafAsset: {
|
||||
ok: leafResponse.ok,
|
||||
contentType: leafResponse.headers.get("content-type"),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout).not.toBeNull();
|
||||
expect(layout!.viewport).toEqual({ width: 1536, height: 1024 });
|
||||
expect(layout!.page).toMatchObject({ left: 0, top: 0, width: 1536, height: 1024 });
|
||||
expect(layout!.bar.width).toBeGreaterThanOrEqual(1400);
|
||||
expect(layout!.bar.width).toBeLessThanOrEqual(1410);
|
||||
expect(layout!.grid.width).toBeGreaterThanOrEqual(1400);
|
||||
expect(layout!.grid.width).toBeLessThanOrEqual(1410);
|
||||
expect(layout!.left.width).toBeGreaterThanOrEqual(324);
|
||||
expect(layout!.left.width).toBeLessThanOrEqual(328);
|
||||
expect(layout!.right.width).toBeGreaterThanOrEqual(355);
|
||||
expect(layout!.right.width).toBeLessThanOrEqual(359);
|
||||
expect(layout!.center.left).toBeGreaterThan(layout!.left.right);
|
||||
expect(layout!.right.left).toBeGreaterThan(layout!.center.right);
|
||||
expect(layout!.controls.width).toBeGreaterThanOrEqual(1460);
|
||||
expect(layout!.controls.width).toBeLessThanOrEqual(1470);
|
||||
expect(layout!.controls.bottom).toBeLessThanOrEqual(1024);
|
||||
expect(layout!.micText.left).toBeGreaterThanOrEqual(layout!.mic.right + 8);
|
||||
expect(layout!.pageLeaf).toContain("leaf-5.webp");
|
||||
expect(layout!.stageLeaf).toContain("leaf-1.webp");
|
||||
expect(layout!.leafAsset).toEqual({ ok: true, contentType: "image/webp" });
|
||||
|
||||
const screenshotPath = testInfo.outputPath("session-botanical-1536x1024.png");
|
||||
await page.screenshot({ path: screenshotPath, animations: "disabled" });
|
||||
await testInfo.attach("session-botanical-1536x1024", {
|
||||
path: screenshotPath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
});
|
||||
|
||||
// checklist: session-meters-collapse
|
||||
// 모바일(≤880px) 압축 레이아웃은 관찰 게이지 패널을 의도적으로 숨긴다(session.css) —
|
||||
// 데스크톱 전용 UI라 desktop 프로젝트에서만 검증한다.
|
||||
|
|
|
|||
BIN
apps/web/public/session-botanical/leaf-1.webp
Normal file
BIN
apps/web/public/session-botanical/leaf-1.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
apps/web/public/session-botanical/leaf-2.webp
Normal file
BIN
apps/web/public/session-botanical/leaf-2.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/web/public/session-botanical/leaf-3.webp
Normal file
BIN
apps/web/public/session-botanical/leaf-3.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/web/public/session-botanical/leaf-4.webp
Normal file
BIN
apps/web/public/session-botanical/leaf-4.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
apps/web/public/session-botanical/leaf-5.webp
Normal file
BIN
apps/web/public/session-botanical/leaf-5.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
|
|
@ -9,6 +9,9 @@ import type { ReactNode, SVGProps } from "react";
|
|||
|
||||
export type IconName =
|
||||
| "home"
|
||||
| "clock"
|
||||
| "hourglass"
|
||||
| "spark"
|
||||
| "session"
|
||||
| "review"
|
||||
| "users"
|
||||
|
|
@ -50,6 +53,24 @@ export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
|
|||
|
||||
// 각 아이콘의 path/children (24x24 viewBox 기준). google 만 멀티컬러 예외.
|
||||
const PATHS: Record<IconName, ReactNode> = {
|
||||
clock: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</>
|
||||
),
|
||||
hourglass: (
|
||||
<>
|
||||
<path d="M6 3h12M6 21h12" />
|
||||
<path d="M7 3c0 4 1.5 6.2 5 9-3.5 2.8-5 5-5 9M17 3c0 4-1.5 6.2-5 9 3.5 2.8 5 5 5 9" />
|
||||
</>
|
||||
),
|
||||
spark: (
|
||||
<>
|
||||
<path d="m12 3 1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z" />
|
||||
<path d="m19 15 .8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15Z" />
|
||||
</>
|
||||
),
|
||||
refresh: (
|
||||
<>
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
|
||||
|
|
|
|||
|
|
@ -1959,8 +1959,8 @@ export default function Session() {
|
|||
voiceAvailable,
|
||||
micOn,
|
||||
});
|
||||
const elapsedLabel = formatElapsed(elapsed);
|
||||
const remainingLabel = formatElapsed(remainingSeconds);
|
||||
const elapsedLabel = formatTimecode(elapsed);
|
||||
const remainingLabel = formatTimecode(remainingSeconds);
|
||||
const limitMinutesLabel = Math.round(sessionLimitSeconds / 60);
|
||||
const warningMinutesLabel = Math.max(1, Math.round(sessionWarningSeconds / 60));
|
||||
const selectedTheoryOption =
|
||||
|
|
@ -2544,15 +2544,24 @@ export default function Session() {
|
|||
<div className="sx-session-progress__grid">
|
||||
<span>
|
||||
<b>{elapsedLabel}</b>
|
||||
<small>회기 시간</small>
|
||||
<small>
|
||||
<Icon name="clock" size={18} />
|
||||
경과 시간
|
||||
</small>
|
||||
</span>
|
||||
<span className={inWarningWindow || timeUp ? "is-warning" : ""}>
|
||||
<b>{timeUp ? "정리 시간" : remainingLabel}</b>
|
||||
<small>{limitMinutesLabel}분 회기 · 남은 시간</small>
|
||||
<small>
|
||||
<Icon name="hourglass" size={18} />
|
||||
남은 시간
|
||||
</small>
|
||||
</span>
|
||||
<span>
|
||||
<b>{turnCount}턴</b>
|
||||
<small>저장된 발화</small>
|
||||
<small>
|
||||
<Icon name="review" size={18} />
|
||||
저장된 발화
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
{goalsAchieved && !timeUp && !sessionEnded ? (
|
||||
|
|
@ -2610,6 +2619,7 @@ export default function Session() {
|
|||
</div>
|
||||
|
||||
<div className="sx-stage__now">
|
||||
<Icon name="users" size={20} />
|
||||
<span>지금: {stageStateText[avatarState]}</span>
|
||||
<small>{avatarExpressionLabel}</small>
|
||||
</div>
|
||||
|
|
@ -2783,8 +2793,7 @@ export default function Session() {
|
|||
{feedbackMode !== "immersive" ? (
|
||||
<section className={surfaceClassName("sx-panel sx-signal")}>
|
||||
<div className="sx-signal__head">
|
||||
{/* D1 — 신호 상태는 아래 sx-signal__one-dot 이 색으로 전달 → 라벨 dot 제거 */}
|
||||
<Kicker dot={false}>라이브 신호</Kicker>
|
||||
<Kicker dot={false}>라이브 코칭</Kicker>
|
||||
</div>
|
||||
|
||||
{liveSignal ? (
|
||||
|
|
@ -2811,148 +2820,135 @@ export default function Session() {
|
|||
<span className="sx-signal__one-when">방금</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className={surfaceClassName("sx-signal__rest", { variant: "inset", flat: true })}>
|
||||
아직 표시할 신호가 없어요. 대화가 이어지면 여기에 짧게 비춰 드릴게요.
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div className="sx-signal__wave" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
|
||||
<div className="sx-signal__rows">
|
||||
<div className="sx-signal__status" aria-label="연결 상태">
|
||||
<span>
|
||||
<b>음성 입력</b>
|
||||
<Icon name="mic" size={23} />
|
||||
<b>음성</b>
|
||||
<small>{voiceInputStatus}</small>
|
||||
</span>
|
||||
<span>
|
||||
<b>응답 상태</b>
|
||||
<Icon name="spark" size={23} />
|
||||
<b>AI 응답</b>
|
||||
<small>{responseStatus}</small>
|
||||
</span>
|
||||
<span>
|
||||
<b>화면 모드</b>
|
||||
<small>
|
||||
{feedbackMode === "ambient"
|
||||
? "상태 신호"
|
||||
: feedbackMode === "coached"
|
||||
? "코칭"
|
||||
: "몰입"}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="sx-signal__seq">
|
||||
<span className="sx-signal__seq-label">최근 흐름</span>
|
||||
<span className="sx-signal__seq-dots">
|
||||
{signalSeq.map((t, i) => (
|
||||
<i
|
||||
key={i}
|
||||
className={
|
||||
(t === "pos" ? "is-pos" : t === "warn" ? "is-warn" : "") +
|
||||
(i === signalSeq.length - 1 ? " is-now" : "")
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{feedbackMode === "coached" ? (
|
||||
<div
|
||||
className={
|
||||
"sx-coach-card" +
|
||||
(coachTone === "pos" ? " is-pos" : coachTone === "warn" ? " is-warn" : "") +
|
||||
(coachLoading ? " is-loading" : "") +
|
||||
(coachIsDegraded ? " is-degraded" : "")
|
||||
}
|
||||
>
|
||||
<div className="sx-coach-avatar" aria-hidden="true">
|
||||
<span className="sx-coach-avatar__lens" />
|
||||
<span className="sx-coach-avatar__face">
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</div>
|
||||
<div className={surfaceClassName("sx-coach-bubble", { variant: "inset", flat: true })}>
|
||||
<div className="sx-coach-bubble__meta">
|
||||
<b>{coachIsDegraded ? "대체 코칭" : "AI 코치"}</b>
|
||||
<span>{coachStatusText}</span>
|
||||
</div>
|
||||
{coachIsDegraded ? (
|
||||
<p className="sx-coach-degraded-note">{coachDegradedNote}</p>
|
||||
) : null}
|
||||
{coachSyncWarning ? (
|
||||
<p
|
||||
{signalSeq.length > 0 ? (
|
||||
<div className="sx-signal__seq">
|
||||
<span className="sx-signal__seq-label">최근 흐름</span>
|
||||
<span className="sx-signal__seq-dots">
|
||||
{signalSeq.map((t, i) => (
|
||||
<i
|
||||
key={i}
|
||||
className={
|
||||
"sx-coach-persistence-note" +
|
||||
(coachPersistenceSource === "runtime" ? " is-runtime" : "")
|
||||
(t === "pos" ? "is-pos" : t === "warn" ? "is-warn" : "") +
|
||||
(i === signalSeq.length - 1 ? " is-now" : "")
|
||||
}
|
||||
role="status"
|
||||
>
|
||||
{coachSyncWarning}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="sx-coach-quota" aria-live="polite">
|
||||
<span>코칭 기회</span>
|
||||
<span className="sx-coach-quota__dots" aria-hidden="true">
|
||||
{coachQuotaSlots.map((slot) => (
|
||||
<i
|
||||
key={slot}
|
||||
className={slot < coachQuotaRemaining ? "is-filled" : ""}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<b>
|
||||
{coachQuotaRemaining}/{coachQuotaMax}
|
||||
</b>
|
||||
</div>
|
||||
{coachCreditPulseText ? (
|
||||
<div
|
||||
className={
|
||||
"sx-coach-credit-pulse" +
|
||||
(coachCreditPulse?.event_type === "recharge" ? " is-recharge" : "")
|
||||
}
|
||||
aria-live="polite"
|
||||
>
|
||||
{coachCreditPulseText}
|
||||
</div>
|
||||
) : null}
|
||||
{coachLoading ? (
|
||||
<p>방금 턴과 근거 자료를 대조하는 중입니다.</p>
|
||||
) : coachError ? (
|
||||
<p>{coachError}</p>
|
||||
) : coachSuggestion ? (
|
||||
<>
|
||||
<strong>{coachSuggestion.title}</strong>
|
||||
<p>{coachSuggestion.message}</p>
|
||||
{coachSuggestion.next_utterance ? (
|
||||
<blockquote>{coachSuggestion.next_utterance}</blockquote>
|
||||
) : null}
|
||||
<div className="sx-coach-bubble__actions">
|
||||
<button type="button" onClick={() => setCoachEvidenceOpen(true)}>
|
||||
근거 보기
|
||||
</button>
|
||||
<button type="button" onClick={() => openCoachHistory(null)}>
|
||||
이력 보기
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : coachQuotaRemaining <= 0 ? (
|
||||
<p>코칭 기회를 모두 사용했습니다. 좋은 발화로 내담자가 열리면 다시 1개가 충전됩니다.</p>
|
||||
) : (
|
||||
<p>코칭 모드에서는 방금 발화의 강점과 조정점을 근거와 함께 바로 짚습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={
|
||||
"sx-coach-card" +
|
||||
(coachTone === "pos" ? " is-pos" : coachTone === "warn" ? " is-warn" : "") +
|
||||
(coachLoading ? " is-loading" : "") +
|
||||
(coachIsDegraded ? " is-degraded" : "")
|
||||
}
|
||||
>
|
||||
<div className="sx-coach-avatar" aria-hidden="true">
|
||||
<span className="sx-coach-avatar__lens" />
|
||||
<span className="sx-coach-avatar__face">
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</div>
|
||||
<div className="sx-coach-summary">
|
||||
<div className="sx-coach-bubble__meta">
|
||||
<b>{coachIsDegraded ? "대체 코칭" : "AI 코치"}</b>
|
||||
<span>{coachStatusText}</span>
|
||||
</div>
|
||||
<div className="sx-coach-quota" aria-live="polite">
|
||||
<span>코칭 기회</span>
|
||||
<span className="sx-coach-quota__dots" aria-hidden="true">
|
||||
{coachQuotaSlots.map((slot) => (
|
||||
<i
|
||||
key={slot}
|
||||
className={slot < coachQuotaRemaining ? "is-filled" : ""}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<b>
|
||||
{coachQuotaRemaining}/{coachQuotaMax}
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={surfaceClassName("sx-coach-bubble", {
|
||||
variant: "inset",
|
||||
flat: true,
|
||||
})}
|
||||
>
|
||||
{coachIsDegraded ? (
|
||||
<p className="sx-coach-degraded-note">{coachDegradedNote}</p>
|
||||
) : null}
|
||||
{coachSyncWarning ? (
|
||||
<p
|
||||
className={
|
||||
"sx-coach-persistence-note" +
|
||||
(coachPersistenceSource === "runtime" ? " is-runtime" : "")
|
||||
}
|
||||
role="status"
|
||||
>
|
||||
{coachSyncWarning}
|
||||
</p>
|
||||
) : null}
|
||||
{coachCreditPulseText ? (
|
||||
<div
|
||||
className={
|
||||
"sx-coach-credit-pulse" +
|
||||
(coachCreditPulse?.event_type === "recharge" ? " is-recharge" : "")
|
||||
}
|
||||
aria-live="polite"
|
||||
>
|
||||
{coachCreditPulseText}
|
||||
</div>
|
||||
) : null}
|
||||
{coachLoading ? (
|
||||
<p>방금 턴과 근거 자료를 대조하는 중입니다.</p>
|
||||
) : coachError ? (
|
||||
<p>{coachError}</p>
|
||||
) : coachSuggestion ? (
|
||||
<>
|
||||
<strong>{coachSuggestion.title}</strong>
|
||||
<p>{coachSuggestion.message}</p>
|
||||
{coachSuggestion.next_utterance ? (
|
||||
<blockquote>{coachSuggestion.next_utterance}</blockquote>
|
||||
) : null}
|
||||
<div className="sx-coach-bubble__actions">
|
||||
<button type="button" onClick={() => setCoachEvidenceOpen(true)}>
|
||||
근거 보기
|
||||
</button>
|
||||
<button type="button" onClick={() => openCoachHistory(null)}>
|
||||
이력 보기
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : coachQuotaRemaining <= 0 ? (
|
||||
<p>
|
||||
코칭 기회를 모두 사용했습니다. 좋은 발화로 내담자가 열리면 다시 1개가
|
||||
충전됩니다.
|
||||
</p>
|
||||
) : (
|
||||
<p>코칭 모드에서는 방금 발화의 강점과 조정점을 근거와 함께 바로 짚습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sx-signal__defer">
|
||||
{feedbackMode === "coached" ? (
|
||||
<>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue