케이스 이어하기 UI와 공통 탭·이미지 복구를 반영
This commit is contained in:
parent
72353ecd82
commit
f1b80676c1
38 changed files with 3581 additions and 455 deletions
|
|
@ -14,4 +14,13 @@
|
||||||
국소 구현 소유자는 `src/pages/Admin.tsx`, `src/pages/admin/admin-console.css`, `src/pages/SessionReview.tsx`, `src/pages/session-review/`다. 공통 토큰과 앱 셸은 각각 `src/styles/tokens.css`, `src/components/shell/`의 소유권을 유지한다.
|
국소 구현 소유자는 `src/pages/Admin.tsx`, `src/pages/admin/admin-console.css`, `src/pages/SessionReview.tsx`, `src/pages/session-review/`다. 공통 토큰과 앱 셸은 각각 `src/styles/tokens.css`, `src/components/shell/`의 소유권을 유지한다.
|
||||||
|
|
||||||
- 2026-08-28 · `/learn` 성장 지표의 라포 헤더에서 중첩 inset surface를 제거해 같은 패널의 일반 헤더 위계와 정렬했다. 토큰 변경 없음.
|
- 2026-08-28 · `/learn` 성장 지표의 라포 헤더에서 중첩 inset surface를 제거해 같은 패널의 일반 헤더 위계와 정렬했다. 토큰 변경 없음.
|
||||||
|
- 2026-08-29 · 온보딩 최상단에 현재 Google 이메일과 기존 secondary 계정 전환 동선을 표시해 잘못 선택한 계정에서 즉시 로그아웃할 수 있게 했다. 토큰 변경 없음.
|
||||||
- 2026-08-29 · `/teach` 검토 큐와 교수자 요약 카드를 `--sp-3` 간격으로 분리해 두 표면의 위계를 복원했다. 토큰 변경 없음.
|
- 2026-08-29 · `/teach` 검토 큐와 교수자 요약 카드를 `--sp-3` 간격으로 분리해 두 표면의 위계를 복원했다. 토큰 변경 없음.
|
||||||
|
- 2026-08-29 · 페이지 탭과 연속 목록 행은 콘텐츠 `Surface`를 사용하지 않는다. 공통 `Tabs`가 ARIA 연결·roving focus·방향키를 소유하고, 페이지 CSS는 투명 배치와 도메인별 trigger 표현만 소유한다. 토큰 변경 없음.
|
||||||
|
- 2026-08-29 · 사용자 아바타는 안전한 웹 URL을 정상 디코딩한 뒤에만 이미지를 노출한다. 빈 값·금지 scheme·404·디코딩 실패는 모든 셸·설정·온보딩에서 동일한 이니셜 fallback으로 닫힌다. first-party 페르소나 아트는 이 계약의 대상이 아니다.
|
||||||
|
- 2026-08-30 · 활성 회기의 텍스트 컴포저는 입력창·AI 코칭·전송의 하단선을 공유한다. 반응형 높이 차이는 유지하고, 토큰 변경 없이 중첩 액션 행만 하단 정렬한다.
|
||||||
|
- 2026-08-31 · `/learn/practice`는 같은 NPC라도 `완전히 새로 시작`과 선택 사례 `이어서 진행`을 별도 native radio로 제공한다. 이어가기에는 사례별 누적 회기·대화 턴·시간을 보이고, 복수 사례는 select로 정확한 case를 고른다. 내담자 기억은 기본 닫힌 native foldout에서만 lazy 로드하며 기존 surface·spacing token을 재사용한다.
|
||||||
|
- 2026-08-30 · `/learn/session` 시작 화면은 사례 맥락 → 한 가지 첫 발화 초점 → 준비·대화·리뷰·재연습 흐름으로 읽힌다. 기존 1~4개 목표 선택, 처방 재연습, 동의·음성 URL 계약과 실패 폐쇄 동작은 유지하고 새 색·글꼴·장식 모션은 추가하지 않는다.
|
||||||
|
- 2026-08-30 · 목표 선택은 `핵심 1개 · 보조 0~3개`를 명시하고, 보조 목표를 제거하지 않고 핵심 순서만 바꿀 수 있게 한다. 짧은 데스크톱 높이에서는 같은 선택을 native select로 압축하고 시작 행동을 안전한 sticky bar에 둬 4개 목표와 CTA가 겹치지 않게 한다. 모든 조작은 44px 이상이며 토큰·기존 payload 계약은 유지한다.
|
||||||
|
- 2026-08-30 · 모바일은 DOM과 같은 사례 → 학습 → 재연습 순서로 읽고, 시작 CTA는 bottom bar와 safe area 위에 고정한다. 키보드 단축키 도움말과 코치 근거/이력은 열 때 포커스를 받고 Tab 순환·닫은 뒤 호출자 복원을 보장한다.
|
||||||
|
- 2026-08-30 · 공통 셸의 첫 키보드 정지는 `본문으로 건너뛰기`다. 링크는 포커스될 때만 보이고, 어느 역할·화면에서도 식별 가능한 main으로 포커스를 옮긴다.
|
||||||
|
|
|
||||||
157
apps/web/e2e/image-resilience.spec.ts
Normal file
157
apps/web/e2e/image-resilience.spec.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const PNG_1X1_BASE64 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
function jsonRoute(body: unknown, status = 200) {
|
||||||
|
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installOnboardingFixtures(page: Page, avatarUrl: string) {
|
||||||
|
await page.route("**/api/**", (route) =>
|
||||||
|
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
||||||
|
);
|
||||||
|
await page.route("**/api/auth/me", (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
user_id: "e2e-image-resilience",
|
||||||
|
email: "avatar.fixture@hs.ac.kr",
|
||||||
|
role: "learner",
|
||||||
|
display_name: "온보딩 사용자",
|
||||||
|
admin_access: false,
|
||||||
|
super_admin: false,
|
||||||
|
account_status: "approved",
|
||||||
|
approval_required: false,
|
||||||
|
cohort_ids: [],
|
||||||
|
consent_at: null,
|
||||||
|
onboarding_completed_at: null,
|
||||||
|
nickname: "온보딩 사용자",
|
||||||
|
self_introduction: "",
|
||||||
|
avatar_url: avatarUrl,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/users/me", (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
user_id: "e2e-image-resilience",
|
||||||
|
email: "avatar.fixture@hs.ac.kr",
|
||||||
|
role: "learner",
|
||||||
|
display_name: "온보딩 사용자",
|
||||||
|
legal_name: "",
|
||||||
|
affiliation: "한신대학교",
|
||||||
|
department: "",
|
||||||
|
grade_level: "",
|
||||||
|
phone: "",
|
||||||
|
contact_address: "",
|
||||||
|
nickname: "온보딩 사용자",
|
||||||
|
self_introduction: "",
|
||||||
|
avatar_url: avatarUrl,
|
||||||
|
cohort_ids: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/users/legal-docs", (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
terms: {
|
||||||
|
kind: "terms",
|
||||||
|
status: "draft",
|
||||||
|
title: "서비스 이용약관",
|
||||||
|
version: "image-resilience-fixture",
|
||||||
|
body: "테스트 약관",
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
kind: "privacy",
|
||||||
|
status: "draft",
|
||||||
|
title: "개인정보 처리방침",
|
||||||
|
version: "image-resilience-fixture",
|
||||||
|
body: "테스트 개인정보 처리방침",
|
||||||
|
},
|
||||||
|
source_note: "이미지 회복성 전용 fixture",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("사용자 이미지 회복성", () => {
|
||||||
|
test("대문자 외부 HTTPS URL도 안전하게 정규화해 실제 이미지로 전환한다", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const externalAvatar = "HTTPS://images.example.test/profile/avatar.png";
|
||||||
|
await installOnboardingFixtures(page, externalAvatar);
|
||||||
|
await page.route("https://images.example.test/profile/avatar.png", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "image/png",
|
||||||
|
body: Buffer.from(PNG_1X1_BASE64, "base64"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/onboarding");
|
||||||
|
|
||||||
|
const preview = page.locator(".ob-avatar__preview");
|
||||||
|
const image = preview.locator("img");
|
||||||
|
await expect(image).toBeVisible();
|
||||||
|
await expect(image).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"https://images.example.test/profile/avatar.png",
|
||||||
|
);
|
||||||
|
await expect(preview.locator("[data-image-fallback]")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("온보딩 아바타 파일이 404여도 깨진 아이콘 대신 닉네임 이니셜을 표시한다", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const staleAvatar = "/uploads/profile-avatars/stale-onboarding-avatar.jpg";
|
||||||
|
await installOnboardingFixtures(page, staleAvatar);
|
||||||
|
await page.route("**/api/uploads/profile-avatars/stale-onboarding-avatar.jpg", (route) =>
|
||||||
|
route.fulfill(jsonRoute({ detail: "Not Found" }, 404)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/onboarding");
|
||||||
|
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
|
||||||
|
|
||||||
|
const preview = page.locator(".ob-avatar__preview");
|
||||||
|
await expect(preview.locator("[data-image-fallback='failed']")).toHaveText("온");
|
||||||
|
await expect(preview.locator("img")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("지연된 이미지 응답 중에도 브라우저의 깨진 이미지 박스를 노출하지 않는다", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const delayedAvatar = "/uploads/profile-avatars/delayed-onboarding-avatar.jpg";
|
||||||
|
let releaseImage: (() => void) | undefined;
|
||||||
|
const imageGate = new Promise<void>((resolve) => {
|
||||||
|
releaseImage = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
await installOnboardingFixtures(page, delayedAvatar);
|
||||||
|
await page.route("**/api/uploads/profile-avatars/delayed-onboarding-avatar.jpg", async (route) => {
|
||||||
|
await imageGate;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "image/png",
|
||||||
|
body: Buffer.from(PNG_1X1_BASE64, "base64"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
const preview = page.locator(".ob-avatar__preview");
|
||||||
|
await expect(preview.locator("[data-image-fallback='loading']")).toHaveText("온");
|
||||||
|
const pendingImage = preview.locator("img");
|
||||||
|
await expect(pendingImage).toHaveAttribute("data-image-state", "loading");
|
||||||
|
await expect(pendingImage).toBeHidden();
|
||||||
|
await expect(pendingImage).toHaveCSS("display", "none");
|
||||||
|
expect(await pendingImage.evaluate((image) => image.getBoundingClientRect().toJSON())).toMatchObject({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
releaseImage?.();
|
||||||
|
await expect(pendingImage).toBeVisible();
|
||||||
|
await expect(pendingImage).toHaveAttribute("data-image-state", "ready");
|
||||||
|
await expect(preview.locator("[data-image-fallback]")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -605,10 +605,10 @@ test.describe("layout visual gate @single-run", () => {
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
const plan = page.locator(".sx-prestart__plan");
|
const plan = page.locator(".sx-prestart__plan");
|
||||||
await expect(plan).toContainText("시작 과업");
|
await expect(plan).toContainText("준비");
|
||||||
await expect(plan).toContainText("선택 접근");
|
await expect(plan).toContainText("대화");
|
||||||
await expect(plan).toContainText("이번 목표");
|
await expect(plan).toContainText("리뷰·재연습");
|
||||||
await expect(plan).toContainText("운영 기준");
|
await expect(plan).toContainText("위험 신호는 대화보다 안전 확인을 우선합니다.");
|
||||||
|
|
||||||
const widths = await page.evaluate(() => {
|
const widths = await page.evaluate(() => {
|
||||||
const head = document.querySelector<HTMLElement>(".sx-page--prestart .sx-head");
|
const head = document.querySelector<HTMLElement>(".sx-page--prestart .sx-head");
|
||||||
|
|
|
||||||
|
|
@ -1053,8 +1053,9 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
||||||
);
|
);
|
||||||
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute(
|
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute(
|
||||||
"aria-pressed",
|
"aria-pressed",
|
||||||
"true",
|
"false",
|
||||||
);
|
);
|
||||||
|
await goals.getByRole("button", { name: /탐색/ }).click();
|
||||||
await goals.getByRole("button", { name: /개입/ }).click();
|
await goals.getByRole("button", { name: /개입/ }).click();
|
||||||
await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute(
|
await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute(
|
||||||
"aria-pressed",
|
"aria-pressed",
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,46 @@ async function expectMainControlsUnclipped(page: Page) {
|
||||||
expect(failures, `Main session controls are clipped: ${JSON.stringify(failures)}`).toEqual([]);
|
expect(failures, `Main session controls are clipped: ${JSON.stringify(failures)}`).toEqual([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectComposeControlsBottomAligned(page: Page) {
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const selectors = [
|
||||||
|
".sx-compose textarea",
|
||||||
|
".sx-coach-trigger-btn",
|
||||||
|
".sx-compose .vg-btn",
|
||||||
|
];
|
||||||
|
const controls = selectors.map((selector) => {
|
||||||
|
const element = document.querySelector<HTMLElement>(selector);
|
||||||
|
if (!element) return null;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
selector,
|
||||||
|
top: rect.top,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
height: rect.height,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (controls.some((control) => control === null)) return null;
|
||||||
|
|
||||||
|
const resolvedControls = controls as Array<NonNullable<(typeof controls)[number]>>;
|
||||||
|
const bottoms = resolvedControls.map((control) => control.bottom);
|
||||||
|
return {
|
||||||
|
delta: Math.max(...bottoms) - Math.min(...bottoms),
|
||||||
|
controls: resolvedControls.map((control) => ({
|
||||||
|
selector: control.selector,
|
||||||
|
top: Math.round(control.top),
|
||||||
|
bottom: Math.round(control.bottom),
|
||||||
|
height: Math.round(control.height),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result, "Expected text composer controls to be present").not.toBeNull();
|
||||||
|
expect(
|
||||||
|
result!.delta,
|
||||||
|
`Expected text composer control bottoms to align: ${JSON.stringify(result!.controls)}`,
|
||||||
|
).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
async function expectRightPanelDoesNotIntersectSessionCore(page: Page) {
|
async function expectRightPanelDoesNotIntersectSessionCore(page: Page) {
|
||||||
const result = await page.evaluate(() => {
|
const result = await page.evaluate(() => {
|
||||||
const right = document.querySelector<HTMLElement>(".sx-page--active .sx-col-right");
|
const right = document.querySelector<HTMLElement>(".sx-page--active .sx-col-right");
|
||||||
|
|
@ -500,6 +540,7 @@ test.describe("learner session full-screen layout", () => {
|
||||||
await expectSessionControlsInsideViewport(page);
|
await expectSessionControlsInsideViewport(page);
|
||||||
await expectNoVisibleSessionPanelOverlap(page);
|
await expectNoVisibleSessionPanelOverlap(page);
|
||||||
await expectMainControlsUnclipped(page);
|
await expectMainControlsUnclipped(page);
|
||||||
|
await expectComposeControlsBottomAligned(page);
|
||||||
await expectSessionPageHeightToMatchViewport(page);
|
await expectSessionPageHeightToMatchViewport(page);
|
||||||
await expectActiveSessionUsableLayout(page);
|
await expectActiveSessionUsableLayout(page);
|
||||||
await expectRightPanelDoesNotIntersectSessionCore(page);
|
await expectRightPanelDoesNotIntersectSessionCore(page);
|
||||||
|
|
|
||||||
|
|
@ -689,6 +689,7 @@ test.describe("session persistence", () => {
|
||||||
const persona = await fetchAvailablePersona(page, 1);
|
const persona = await fetchAvailablePersona(page, 1);
|
||||||
|
|
||||||
await page.goto(`/learn/session/${persona.code}`);
|
await page.goto(`/learn/session/${persona.code}`);
|
||||||
|
await page.locator("details.sx-prestart__settings > summary").click();
|
||||||
const cbtButton = page.locator(".sx-theory").getByRole("button", { name: /CBT/ });
|
const cbtButton = page.locator(".sx-theory").getByRole("button", { name: /CBT/ });
|
||||||
await expect(cbtButton).toBeVisible();
|
await expect(cbtButton).toBeVisible();
|
||||||
await cbtButton.click();
|
await cbtButton.click();
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,10 @@ test.describe("session review", () => {
|
||||||
growthPoints: [],
|
growthPoints: [],
|
||||||
degraded: true,
|
degraded: true,
|
||||||
reviewReady: false,
|
reviewReady: false,
|
||||||
|
evaluationFailure: {
|
||||||
|
code: "timeout",
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
teacherReview: {
|
teacherReview: {
|
||||||
status: "pending",
|
status: "pending",
|
||||||
note: "",
|
note: "",
|
||||||
|
|
@ -440,6 +444,9 @@ test.describe("session review", () => {
|
||||||
page.getByText("저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다."),
|
page.getByText("저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다."),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByText("engine_error: evaluator timeout")).toHaveCount(0);
|
await expect(page.getByText("engine_error: evaluator timeout")).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByText("평가 생성이 제한 시간 안에 끝나지 않았습니다. 축어록은 보존됐으며 다시 시도할 수 있습니다."),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled();
|
await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled();
|
||||||
|
|
||||||
|
|
@ -474,6 +481,10 @@ test.describe("session review", () => {
|
||||||
growthPoints: [],
|
growthPoints: [],
|
||||||
degraded: true,
|
degraded: true,
|
||||||
reviewReady: false,
|
reviewReady: false,
|
||||||
|
evaluationFailure: {
|
||||||
|
code: "timeout",
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
teacherReview: {
|
teacherReview: {
|
||||||
status: "pending",
|
status: "pending",
|
||||||
note: "",
|
note: "",
|
||||||
|
|
@ -503,13 +514,110 @@ test.describe("session review", () => {
|
||||||
await page.getByRole("button", { name: "AI 평가 재시도" }).click();
|
await page.getByRole("button", { name: "AI 평가 재시도" }).click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요."),
|
page.getByText("AI 평가가 제한 시간 안에 끝나지 않았습니다. 최신 상태를 다시 불러왔습니다."),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByText("engine_error: retry timeout")).toHaveCount(0);
|
await expect(page.getByText("engine_error: retry timeout")).toHaveCount(0);
|
||||||
await expect(page.getByText("평가 실패")).toBeVisible();
|
await expect(page.getByText("평가 실패")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeEnabled();
|
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeEnabled();
|
||||||
expect(reevaluateRequests).toBe(1);
|
expect(reevaluateRequests).toBe(1);
|
||||||
expect(reviewRequests).toBe(reviewRequestsBeforeRetry);
|
await expect.poll(() => reviewRequests).toBeGreaterThan(reviewRequestsBeforeRetry);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not offer a futile retry when the evaluation input is too large", async ({ page }) => {
|
||||||
|
await routeSupervisorCapableLearner(page);
|
||||||
|
await routePrepostMeasures(page);
|
||||||
|
|
||||||
|
const sessionId = "review-failed-input-too-large";
|
||||||
|
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||||
|
const ready = filledReviewResponse(sessionId);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
...ready,
|
||||||
|
supervisorState: "평가 실패",
|
||||||
|
summary:
|
||||||
|
"저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. 평가 입력 경로를 조정한 뒤 다시 생성해야 합니다.",
|
||||||
|
rubric: [],
|
||||||
|
goodMoments: [],
|
||||||
|
growthPoints: [],
|
||||||
|
degraded: true,
|
||||||
|
reviewReady: false,
|
||||||
|
evaluationFailure: {
|
||||||
|
code: "prompt_too_large",
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
teacherReview: {
|
||||||
|
status: "pending",
|
||||||
|
note: "",
|
||||||
|
reviewedAt: null,
|
||||||
|
reviewerId: null,
|
||||||
|
updatedAt: null,
|
||||||
|
worksheetStatus: "pending",
|
||||||
|
worksheetNote: "",
|
||||||
|
worksheetReviewedAt: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/teach/session/${sessionId}/review`);
|
||||||
|
await openReviewTab(page, "피드백");
|
||||||
|
|
||||||
|
await expect(page.getByText("평가 실패")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("평가 입력이 허용 크기를 넘어섰습니다. 같은 재시도 대신 입력 경로를 조정해야 합니다."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offers recovery retry for a legacy Windows argv-limit evaluation", async ({ page }) => {
|
||||||
|
await routeSupervisorCapableLearner(page);
|
||||||
|
await routePrepostMeasures(page);
|
||||||
|
|
||||||
|
const sessionId = "review-legacy-windows-argv-limit";
|
||||||
|
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||||
|
const ready = filledReviewResponse(sessionId);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
...ready,
|
||||||
|
supervisorState: "평가 실패",
|
||||||
|
summary:
|
||||||
|
"저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다.",
|
||||||
|
rubric: [],
|
||||||
|
goodMoments: [],
|
||||||
|
growthPoints: [],
|
||||||
|
degraded: true,
|
||||||
|
reviewReady: false,
|
||||||
|
evaluationFailure: {
|
||||||
|
code: "legacy_argv_limit",
|
||||||
|
retryable: true,
|
||||||
|
},
|
||||||
|
teacherReview: {
|
||||||
|
status: "pending",
|
||||||
|
note: "",
|
||||||
|
reviewedAt: null,
|
||||||
|
reviewerId: null,
|
||||||
|
updatedAt: null,
|
||||||
|
worksheetStatus: "pending",
|
||||||
|
worksheetNote: "",
|
||||||
|
worksheetReviewedAt: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/teach/session/${sessionId}/review`);
|
||||||
|
await openReviewTab(page, "피드백");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
"이전 Windows 입력 한도에 걸린 평가입니다. 축어록은 보존됐으며 현재 입력 경로로 다시 시도할 수 있습니다.",
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders server review data without legacy transcript fixtures", async ({ page }) => {
|
test("renders server review data without legacy transcript fixtures", async ({ page }) => {
|
||||||
|
|
|
||||||
138
apps/web/e2e/tabs-behavior.spec.ts
Normal file
138
apps/web/e2e/tabs-behavior.spec.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||||
|
import { signInAsAdmin, signInAsTeacher } from "./support";
|
||||||
|
|
||||||
|
async function expectConnectedTabPanel(tab: Locator, panel: Locator) {
|
||||||
|
const tabId = await tab.getAttribute("id");
|
||||||
|
const panelId = await panel.getAttribute("id");
|
||||||
|
expect(tabId).toBeTruthy();
|
||||||
|
expect(panelId).toBeTruthy();
|
||||||
|
await expect(tab).toHaveAttribute("aria-controls", panelId!);
|
||||||
|
await expect(panel).toHaveAttribute("aria-labelledby", tabId!);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectKeyboardSelection(
|
||||||
|
page: Page,
|
||||||
|
tablistName: string,
|
||||||
|
startName: string,
|
||||||
|
key: "ArrowRight" | "ArrowLeft" | "Home" | "End",
|
||||||
|
expectedName: string,
|
||||||
|
) {
|
||||||
|
const tablist = page.getByRole("tablist", { name: tablistName });
|
||||||
|
const start = tablist.getByRole("tab", { name: startName });
|
||||||
|
const expected = tablist.getByRole("tab", { name: expectedName });
|
||||||
|
const panel = page.getByRole("tabpanel");
|
||||||
|
|
||||||
|
await start.focus();
|
||||||
|
await start.press(key);
|
||||||
|
await expect(expected).toBeFocused();
|
||||||
|
await expect(expected).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(expected).toHaveAttribute("tabindex", "0");
|
||||||
|
await expect(start).toHaveAttribute("tabindex", startName === expectedName ? "0" : "-1");
|
||||||
|
await expectConnectedTabPanel(expected, panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("공통 Tabs behavior", () => {
|
||||||
|
test("관리자 사용자·접근 권한 탭이 패널 연결과 roving keyboard를 공유한다 @single-run", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsAdmin(page);
|
||||||
|
|
||||||
|
await page.goto("/admin/users");
|
||||||
|
const userTabs = page.getByRole("tablist", { name: "사용자 관리 탭" });
|
||||||
|
const approval = userTabs.getByRole("tab", { name: /가입 승인/ });
|
||||||
|
const userPanel = page.getByRole("tabpanel");
|
||||||
|
await expect(userTabs).toBeVisible();
|
||||||
|
await expect(userTabs).toHaveAttribute("id", "admin-users-tabs");
|
||||||
|
await expect(userPanel).toHaveAttribute("id", "admin-users-tabs-panel");
|
||||||
|
await expect(userTabs).not.toHaveClass(/vg-surface/);
|
||||||
|
await expectConnectedTabPanel(approval, userPanel);
|
||||||
|
|
||||||
|
await expectKeyboardSelection(
|
||||||
|
page,
|
||||||
|
"사용자 관리 탭",
|
||||||
|
"가입 승인",
|
||||||
|
"ArrowRight",
|
||||||
|
"사용자 목록",
|
||||||
|
);
|
||||||
|
await expectKeyboardSelection(
|
||||||
|
page,
|
||||||
|
"사용자 관리 탭",
|
||||||
|
"사용자 목록",
|
||||||
|
"End",
|
||||||
|
"활동 요약",
|
||||||
|
);
|
||||||
|
await expectKeyboardSelection(
|
||||||
|
page,
|
||||||
|
"사용자 관리 탭",
|
||||||
|
"활동 요약",
|
||||||
|
"Home",
|
||||||
|
"가입 승인",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto("/admin/access");
|
||||||
|
const accessTabs = page.getByRole("tablist", { name: "접근 권한 탭" });
|
||||||
|
const roles = accessTabs.getByRole("tab", { name: "역할", exact: true });
|
||||||
|
const accessPanel = page.getByRole("tabpanel");
|
||||||
|
await expect(accessTabs).toBeVisible();
|
||||||
|
await expect(accessTabs).toHaveAttribute("id", "admin-access-tabs");
|
||||||
|
await expect(accessPanel).toHaveAttribute("id", "admin-access-tabs-panel");
|
||||||
|
await expect(accessTabs).not.toHaveClass(/vg-surface/);
|
||||||
|
await expectConnectedTabPanel(roles, accessPanel);
|
||||||
|
await expectKeyboardSelection(
|
||||||
|
page,
|
||||||
|
"접근 권한 탭",
|
||||||
|
"역할",
|
||||||
|
"End",
|
||||||
|
"상담 프로토콜",
|
||||||
|
);
|
||||||
|
await expectKeyboardSelection(
|
||||||
|
page,
|
||||||
|
"접근 권한 탭",
|
||||||
|
"상담 프로토콜",
|
||||||
|
"Home",
|
||||||
|
"역할",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("페르소나 저작 탭이 같은 패널 연결과 방향키 계약을 사용한다 @single-run", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await signInAsTeacher(page);
|
||||||
|
await page.route("**/api/personas", async (route) => {
|
||||||
|
if (route.request().method() !== "GET") return route.fallback();
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/personas/review", async (route) => {
|
||||||
|
if (route.request().method() !== "GET") return route.fallback();
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify([]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
|
||||||
|
const tablist = page.getByRole("tablist", { name: "저작 섹션" });
|
||||||
|
const overview = tablist.getByRole("tab", { name: "개요", exact: true });
|
||||||
|
const panel = page.getByRole("tabpanel");
|
||||||
|
await expect(tablist).toBeVisible();
|
||||||
|
await expect(tablist).toHaveAttribute("id", "persona-authoring-tabs");
|
||||||
|
await expect(panel).toHaveAttribute("id", "persona-authoring-tabs-panel");
|
||||||
|
await expect(tablist).not.toHaveClass(/vg-surface/);
|
||||||
|
await expectConnectedTabPanel(overview, panel);
|
||||||
|
|
||||||
|
await expectKeyboardSelection(
|
||||||
|
page,
|
||||||
|
"저작 섹션",
|
||||||
|
"개요",
|
||||||
|
"ArrowRight",
|
||||||
|
"임상",
|
||||||
|
);
|
||||||
|
await expectKeyboardSelection(page, "저작 섹션", "임상", "End", "프롬프트");
|
||||||
|
await expectKeyboardSelection(page, "저작 섹션", "프롬프트", "Home", "개요");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -401,12 +401,22 @@ test.describe("uc learner practice/history", () => {
|
||||||
card.getByRole("button", { name: item.action, exact: true }),
|
card.getByRole("button", { name: item.action, exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
}
|
}
|
||||||
// 진행 중 카드에는 보관 버튼이 없고, 종료 카드에는 있다.
|
// 진행 중 카드에는 보관·다음 회기 버튼이 없고, 종료 카드에는 있다.
|
||||||
await expect(
|
await expect(
|
||||||
cards.nth(0).getByRole("button", { name: "보관", exact: true }),
|
cards.nth(0).getByRole("button", { name: "보관", exact: true }),
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
cards
|
||||||
|
.nth(0)
|
||||||
|
.getByRole("button", { name: "다음 회기 이어가기", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
await expect(
|
await expect(
|
||||||
cards.nth(2).getByRole("button", { name: "보관", exact: true }),
|
cards.nth(2).getByRole("button", { name: "보관", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
cards
|
||||||
|
.nth(2)
|
||||||
|
.getByRole("button", { name: "다음 회기 이어가기", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
cards.nth(3).getByRole("button", { name: "복원", exact: true }),
|
cards.nth(3).getByRole("button", { name: "복원", exact: true }),
|
||||||
|
|
@ -428,8 +438,8 @@ test.describe("uc learner practice/history", () => {
|
||||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
|
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
|
||||||
});
|
});
|
||||||
|
|
||||||
// usecase: 종료된 기록 카드에서 '다시 연습'을 눌러 같은 내담자로 새 회기를 연다.
|
// usecase: 종료된 기록 카드에서 다음 회기를 열어 같은 내담자의 맥락을 이어간다.
|
||||||
test("기록 카드의 다시 연습 버튼이 같은 내담자 새 회기로 이동한다", async ({
|
test("종료 기록의 다음 회기 이어가기 버튼이 같은 내담자 새 회기로 이동한다", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await installFixtures(page);
|
await installFixtures(page);
|
||||||
|
|
@ -438,7 +448,7 @@ test.describe("uc learner practice/history", () => {
|
||||||
const endCard = page.locator(".lh-session-card", { hasText: "김도윤" });
|
const endCard = page.locator(".lh-session-card", { hasText: "김도윤" });
|
||||||
await expect(endCard).toHaveCount(1);
|
await expect(endCard).toHaveCount(1);
|
||||||
await endCard
|
await endCard
|
||||||
.getByRole("button", { name: "다시 연습", exact: true })
|
.getByRole("button", { name: "다음 회기 이어가기", exact: true })
|
||||||
.click();
|
.click();
|
||||||
await expect(page).toHaveURL(/\/learn\/session\/P2$/);
|
await expect(page).toHaveURL(/\/learn\/session\/P2$/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
373
apps/web/e2e/uc-session-case-launch.spec.ts
Normal file
373
apps/web/e2e/uc-session-case-launch.spec.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
/* =====================================================================
|
||||||
|
uc-session-case-launch.spec.ts — 새 사례 / 이어서 진행 시작 계약
|
||||||
|
|
||||||
|
목적: 같은 NPC라도 학습자가 완전히 새 사례를 시작할지, 기존 사례를
|
||||||
|
이어갈지 명시적으로 선택한다. 이어가기는 누적 회기·턴·시간을 먼저
|
||||||
|
보여 주고, 기억 요약은 접힌 상태에서 사용자가 열 때만 요청한다.
|
||||||
|
|
||||||
|
전부 route fixture 기반이다. 실제 DB·AI 엔진의 사례 기억 생성은
|
||||||
|
검증하지 않으며, Home의 URL launch intent와 Session의 POST 계약만
|
||||||
|
고정한다.
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const CONTINUE_CASE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const PREVIOUS_CASE_ID = "55555555-5555-4555-8555-555555555555";
|
||||||
|
const FRESH_CASE_ID = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const CONTINUE_SESSION_ID = "33333333-3333-4333-8333-333333333333";
|
||||||
|
const FRESH_SESSION_ID = "44444444-4444-4444-8444-444444444444";
|
||||||
|
|
||||||
|
function jsonRoute(body: unknown, status = 200) {
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERSONA = {
|
||||||
|
code: "P1",
|
||||||
|
display_name: "민서(청소년 우울)",
|
||||||
|
difficulty: "hard",
|
||||||
|
theory_target: ["humanistic"],
|
||||||
|
demographics: { age_band: "10대" },
|
||||||
|
presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습",
|
||||||
|
voice_preset: "soft-young-fem",
|
||||||
|
source: "database",
|
||||||
|
degraded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function dashboardFixture() {
|
||||||
|
return {
|
||||||
|
message: "학습 현황 요약",
|
||||||
|
source: "runtime",
|
||||||
|
overview: {
|
||||||
|
total_sessions: 3,
|
||||||
|
active_sessions: 0,
|
||||||
|
review_ready_sessions: 0,
|
||||||
|
completed_sessions: 3,
|
||||||
|
archived_sessions: 0,
|
||||||
|
learner_turns: 9,
|
||||||
|
client_turns: 9,
|
||||||
|
last_practiced_at: "2026-08-31T09:00:00+09:00",
|
||||||
|
},
|
||||||
|
growth: {
|
||||||
|
trend: "insufficient",
|
||||||
|
avg_rapport: null,
|
||||||
|
avg_score: null,
|
||||||
|
evaluated_sessions: 0,
|
||||||
|
latest_score: null,
|
||||||
|
first_score: null,
|
||||||
|
score_delta: null,
|
||||||
|
top_techniques: [],
|
||||||
|
points: [],
|
||||||
|
},
|
||||||
|
recent_feedback: [],
|
||||||
|
persona_progress: [],
|
||||||
|
achievements: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentCaseFixture() {
|
||||||
|
return {
|
||||||
|
cases: [
|
||||||
|
{
|
||||||
|
case_id: CONTINUE_CASE_ID,
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "민서",
|
||||||
|
last_session_no: 3,
|
||||||
|
progress: {
|
||||||
|
total_sessions: 3,
|
||||||
|
completed_sessions: 3,
|
||||||
|
total_turns: 18,
|
||||||
|
total_duration_seconds: 5_400,
|
||||||
|
active_session_id: null,
|
||||||
|
active_session_no: null,
|
||||||
|
active_started_at: null,
|
||||||
|
last_activity_at: "2026-08-31T09:00:00+09:00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
case_id: PREVIOUS_CASE_ID,
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "민서",
|
||||||
|
last_session_no: 2,
|
||||||
|
progress: {
|
||||||
|
total_sessions: 2,
|
||||||
|
completed_sessions: 2,
|
||||||
|
total_turns: 10,
|
||||||
|
total_duration_seconds: 3_600,
|
||||||
|
active_session_id: null,
|
||||||
|
active_session_no: null,
|
||||||
|
active_started_at: null,
|
||||||
|
last_activity_at: "2026-08-20T09:00:00+09:00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryFixture() {
|
||||||
|
return {
|
||||||
|
case_id: CONTINUE_CASE_ID,
|
||||||
|
memory_available: true,
|
||||||
|
latest_session_digest: "지난 회기에서 수면 문제를 먼저 다뤘습니다.",
|
||||||
|
case_digest: "학업 부담과 무기력의 연결을 탐색 중입니다.",
|
||||||
|
open_threads: ["수면 리듬을 어떻게 조절할지"],
|
||||||
|
pinned_facts: ["기말고사 기간에는 불안이 커집니다."],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LaunchFixture {
|
||||||
|
memoryRequestCount: () => number;
|
||||||
|
startRequests: Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** catch-all을 먼저 등록하고, 실제 fixture endpoint를 뒤에 두어 LIFO 우선순위를 고정한다. */
|
||||||
|
async function installLaunchFixture(page: Page): Promise<LaunchFixture> {
|
||||||
|
let memoryRequests = 0;
|
||||||
|
const startRequests: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
await page.route("**/api/**", (route) =>
|
||||||
|
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
||||||
|
);
|
||||||
|
await page.route("**/api/auth/me", (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
user_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
email: "case.launch@hs.ac.kr",
|
||||||
|
display_name: "사례 시작 검증 학습자",
|
||||||
|
role: "learner",
|
||||||
|
admin_access: false,
|
||||||
|
super_admin: false,
|
||||||
|
account_status: "approved",
|
||||||
|
approval_required: false,
|
||||||
|
cohort_ids: [],
|
||||||
|
consent_at: "2026-08-01T00:00:00+09:00",
|
||||||
|
onboarding_completed_at: "2026-08-01T00:00:00+09:00",
|
||||||
|
nickname: "사례 시작 검증 학습자",
|
||||||
|
self_introduction: "",
|
||||||
|
avatar_url: "",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/personas", (route) => route.fulfill(jsonRoute([PERSONA])));
|
||||||
|
await page.route("**/api/sessions", async (route) => {
|
||||||
|
if (route.request().method() !== "POST") {
|
||||||
|
await route.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const request = (route.request().postDataJSON() ?? {}) as Record<string, unknown>;
|
||||||
|
startRequests.push(request);
|
||||||
|
const isFresh = request.start_mode === "fresh";
|
||||||
|
await route.fulfill(
|
||||||
|
jsonRoute(
|
||||||
|
{
|
||||||
|
session_id: isFresh ? FRESH_SESSION_ID : CONTINUE_SESSION_ID,
|
||||||
|
case_id: isFresh ? FRESH_CASE_ID : CONTINUE_CASE_ID,
|
||||||
|
persona_id: "persona-p1",
|
||||||
|
persona_version: 1,
|
||||||
|
session_no: isFresh ? 1 : 4,
|
||||||
|
stage: "라포",
|
||||||
|
effective_openness: 0.2,
|
||||||
|
recall_summary: isFresh ? null : "압축된 기존 사례 기억",
|
||||||
|
degraded: false,
|
||||||
|
started_at: "2026-08-31T10:00:00+09:00",
|
||||||
|
goal_stages: request.goal_stages ?? ["라포"],
|
||||||
|
duration_limit_seconds: 3_600,
|
||||||
|
warning_before_end_seconds: 600,
|
||||||
|
learner_feedback_enabled: true,
|
||||||
|
start_mode: isFresh ? "fresh" : "continue",
|
||||||
|
},
|
||||||
|
201,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.route("**/api/sessions/dashboard", (route) =>
|
||||||
|
route.fulfill(jsonRoute(dashboardFixture())),
|
||||||
|
);
|
||||||
|
await page.route("**/api/sessions/cases?persona_code=P1", (route) =>
|
||||||
|
route.fulfill(jsonRoute(currentCaseFixture())),
|
||||||
|
);
|
||||||
|
await page.route(`**/api/sessions/cases/${CONTINUE_CASE_ID}/memory`, (route) => {
|
||||||
|
memoryRequests += 1;
|
||||||
|
return route.fulfill(jsonRoute(memoryFixture()));
|
||||||
|
});
|
||||||
|
await page.route("**/api/users/me/prepost-measures**", (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
pilot_id: "phase3-pilot-draft",
|
||||||
|
instrument_version: "fixture",
|
||||||
|
measures: [],
|
||||||
|
complete_pre_count: 0,
|
||||||
|
complete_post_count: 0,
|
||||||
|
updated_at: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/voice/health", (route) =>
|
||||||
|
route.fulfill(jsonRoute({ available: true, reason: null })),
|
||||||
|
);
|
||||||
|
await page.route(`**/api/sessions/${CONTINUE_SESSION_ID}`, (route) =>
|
||||||
|
route.fulfill(sessionDetailFixture(CONTINUE_SESSION_ID, CONTINUE_CASE_ID, 4)),
|
||||||
|
);
|
||||||
|
await page.route(`**/api/sessions/${FRESH_SESSION_ID}`, (route) =>
|
||||||
|
route.fulfill(sessionDetailFixture(FRESH_SESSION_ID, FRESH_CASE_ID, 1)),
|
||||||
|
);
|
||||||
|
await page.route(`**/api/sessions/${CONTINUE_SESSION_ID}/alliance-pulses`, (route) =>
|
||||||
|
route.fulfill(jsonRoute({ items: [] })),
|
||||||
|
);
|
||||||
|
await page.route(`**/api/sessions/${FRESH_SESSION_ID}/alliance-pulses`, (route) =>
|
||||||
|
route.fulfill(jsonRoute({ items: [] })),
|
||||||
|
);
|
||||||
|
await page.route("**/api/sessions/*/live-coach", (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
source: "database",
|
||||||
|
quota: { remaining: 3, max: 3 },
|
||||||
|
credit_events: [],
|
||||||
|
events: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { memoryRequestCount: () => memoryRequests, startRequests };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionDetailFixture(sessionId: string, caseId: string, sessionNo: number) {
|
||||||
|
return jsonRoute({
|
||||||
|
session_id: sessionId,
|
||||||
|
case_id: caseId,
|
||||||
|
persona_id: "persona-p1",
|
||||||
|
persona_version: 1,
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "민서",
|
||||||
|
session_no: sessionNo,
|
||||||
|
status: "active",
|
||||||
|
stage: "라포",
|
||||||
|
theory_mode: "humanistic",
|
||||||
|
effective_openness: 0.2,
|
||||||
|
started_at: "2026-08-31T10:00:00+09:00",
|
||||||
|
ended_at: null,
|
||||||
|
review_ready: false,
|
||||||
|
turns: [],
|
||||||
|
goal_stages: ["라포"],
|
||||||
|
duration_limit_seconds: 3_600,
|
||||||
|
warning_before_end_seconds: 600,
|
||||||
|
progress: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchButton(page: Page, label: string) {
|
||||||
|
return page
|
||||||
|
.locator(".lh-actions")
|
||||||
|
.getByRole("button", { name: label, exact: true })
|
||||||
|
.last();
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchModeLabel(page: Page, label: string) {
|
||||||
|
return page.locator(".lh-launch-mode label").filter({ hasText: label });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("uc session case launch", () => {
|
||||||
|
test("이어가기 통계와 접힌 기억을 확인하고 두 방식의 launch URL을 구분한다", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const fixture = await installLaunchFixture(page);
|
||||||
|
await page.goto("/learn/practice");
|
||||||
|
|
||||||
|
const freshMode = page.getByRole("radio", { name: "완전히 새로 시작" });
|
||||||
|
const continueMode = page.getByRole("radio", { name: "이어서 진행" });
|
||||||
|
const continuity = page.locator(".lh-continuity");
|
||||||
|
|
||||||
|
// 기본은 새 사례다. 기존 기억 endpoint는 foldout을 열기 전까지 요청하지 않는다.
|
||||||
|
await expect(freshMode).toBeChecked();
|
||||||
|
await expect(continuity).toContainText("새 사례");
|
||||||
|
await expect(continuity.locator("details.lh-continuity__memory")).toHaveCount(0);
|
||||||
|
expect(fixture.memoryRequestCount()).toBe(0);
|
||||||
|
|
||||||
|
await expect(continueMode).toBeEnabled();
|
||||||
|
await launchModeLabel(page, "이어서 진행").click();
|
||||||
|
await expect(continueMode).toBeChecked();
|
||||||
|
await expect(continuity).toContainText("다음 4회기");
|
||||||
|
await expect(continuity).toContainText("3회기");
|
||||||
|
await expect(continuity).toContainText("18턴");
|
||||||
|
await expect(continuity).toContainText("1시간 30분");
|
||||||
|
|
||||||
|
const memory = continuity.locator("details.lh-continuity__memory");
|
||||||
|
await expect(memory).not.toHaveAttribute("open", "");
|
||||||
|
expect(fixture.memoryRequestCount()).toBe(0);
|
||||||
|
|
||||||
|
await memory.locator("summary").click();
|
||||||
|
await expect(memory).toHaveAttribute("open", "");
|
||||||
|
await expect(memory).toContainText("지난 회기에서 수면 문제를 먼저 다뤘습니다.");
|
||||||
|
await expect(memory).toContainText("수면 리듬을 어떻게 조절할지");
|
||||||
|
await expect.poll(fixture.memoryRequestCount).toBe(1);
|
||||||
|
|
||||||
|
await launchButton(page, "이어서 진행").click();
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/learn/session/P1\\?continuity=continue&case=${CONTINUE_CASE_ID}$`),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page.locator(".lh-continuity")).toBeVisible();
|
||||||
|
await launchModeLabel(page, "완전히 새로 시작").click();
|
||||||
|
await expect(freshMode).toBeChecked();
|
||||||
|
await expect(continuity.locator("details.lh-continuity__memory")).toHaveCount(0);
|
||||||
|
|
||||||
|
await launchButton(page, "완전히 새로 시작").click();
|
||||||
|
await expect(page).toHaveURL(/\/learn\/session\/P1\?continuity=fresh$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("복수의 종료 사례에서는 고른 사례의 통계와 exact case URL을 사용한다", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installLaunchFixture(page);
|
||||||
|
await page.goto("/learn/practice");
|
||||||
|
|
||||||
|
await launchModeLabel(page, "이어서 진행").click();
|
||||||
|
const continuity = page.locator(".lh-continuity");
|
||||||
|
const caseSelect = continuity.getByRole("combobox", {
|
||||||
|
name: "이어서 진행할 사례",
|
||||||
|
});
|
||||||
|
await expect(caseSelect).toHaveValue(CONTINUE_CASE_ID);
|
||||||
|
await caseSelect.selectOption(PREVIOUS_CASE_ID);
|
||||||
|
await expect(caseSelect).toHaveValue(PREVIOUS_CASE_ID);
|
||||||
|
await expect(continuity).toContainText("다음 3회기");
|
||||||
|
await expect(continuity).toContainText("2회기");
|
||||||
|
await expect(continuity).toContainText("10턴");
|
||||||
|
await expect(continuity).toContainText("1시간");
|
||||||
|
|
||||||
|
await launchButton(page, "이어서 진행").click();
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/learn/session/P1\\?continuity=continue&case=${PREVIOUS_CASE_ID}$`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const launch of [
|
||||||
|
{
|
||||||
|
label: "새 사례",
|
||||||
|
search: "?continuity=fresh",
|
||||||
|
expected: { start_mode: "fresh", case_id: null },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "이어서 진행",
|
||||||
|
search: `?continuity=continue&case=${CONTINUE_CASE_ID}`,
|
||||||
|
expected: { start_mode: "continue", case_id: CONTINUE_CASE_ID },
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
test(`${launch.label} 선택값을 회기 시작 POST 본문으로 전달한다`, async ({ page }) => {
|
||||||
|
const fixture = await installLaunchFixture(page);
|
||||||
|
await page.goto(`/learn/session/P1${launch.search}`);
|
||||||
|
await expect(page.locator(".sx-page--prestart")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "회기 시작", exact: true }).click();
|
||||||
|
await expect.poll(() => fixture.startRequests.length).toBe(1);
|
||||||
|
expect(fixture.startRequests[0]).toMatchObject({
|
||||||
|
persona_code: "P1",
|
||||||
|
...launch.expected,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
135
apps/web/e2e/uc-session-continuity-guard.spec.ts
Normal file
135
apps/web/e2e/uc-session-continuity-guard.spec.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
/* =====================================================================
|
||||||
|
uc-session-continuity-guard.spec.ts — 같은 내담자 활성 회기 보호
|
||||||
|
|
||||||
|
목적: 종료된 회기가 아닌 같은 내담자의 진행 중 회기가 있을 때 새 회기를
|
||||||
|
중복 생성하지 않고, 서버가 돌려준 기존 회기로 학습자를 이동시킨다.
|
||||||
|
모든 응답은 route fixture이며 실제 AI 엔진/DB를 쓰지 않는다.
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const ACTIVE_SESSION_ID = "77777777-7777-4777-8777-777777777777";
|
||||||
|
|
||||||
|
function jsonRoute(body: unknown, status = 200) {
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installFixture(page: Page) {
|
||||||
|
let startAttempts = 0;
|
||||||
|
|
||||||
|
await page.route("**/api/**", (route) =>
|
||||||
|
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
||||||
|
);
|
||||||
|
await page.route("**/api/auth/me", (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
user_id: "00000000-0000-0000-0000-00000uccg301",
|
||||||
|
email: "continuity.guard@hs.ac.kr",
|
||||||
|
display_name: "연속성 검증 학습자",
|
||||||
|
role: "learner",
|
||||||
|
admin_access: false,
|
||||||
|
super_admin: false,
|
||||||
|
account_status: "approved",
|
||||||
|
approval_required: false,
|
||||||
|
cohort_ids: [],
|
||||||
|
consent_at: Math.floor(Date.now() / 1000),
|
||||||
|
onboarding_completed_at: Math.floor(Date.now() / 1000),
|
||||||
|
nickname: "연속성 검증 학습자",
|
||||||
|
self_introduction: "",
|
||||||
|
avatar_url: "",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/personas", (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
jsonRoute([
|
||||||
|
{
|
||||||
|
code: "P1",
|
||||||
|
display_name: "민서(청소년 우울)",
|
||||||
|
difficulty: "hard",
|
||||||
|
theory_target: ["humanistic"],
|
||||||
|
demographics: { age_band: "10대" },
|
||||||
|
presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습",
|
||||||
|
voice_preset: "soft-young-fem",
|
||||||
|
source: "database",
|
||||||
|
degraded: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/users/me/prepost-measures**", (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
pilot_id: "phase3-pilot-draft",
|
||||||
|
instrument_version: "test",
|
||||||
|
measures: [],
|
||||||
|
complete_pre_count: 0,
|
||||||
|
complete_post_count: 0,
|
||||||
|
updated_at: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.route("**/api/sessions", async (route) => {
|
||||||
|
if (route.request().method() !== "POST") {
|
||||||
|
await route.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startAttempts += 1;
|
||||||
|
await route.fulfill(
|
||||||
|
jsonRoute(
|
||||||
|
{
|
||||||
|
detail: {
|
||||||
|
code: "active_session_exists",
|
||||||
|
session_id: ACTIVE_SESSION_ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
409,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.route(`**/api/sessions/${ACTIVE_SESSION_ID}`, (route) =>
|
||||||
|
route.fulfill(
|
||||||
|
jsonRoute({
|
||||||
|
session_id: ACTIVE_SESSION_ID,
|
||||||
|
case_id: "uc-continuity-case-001",
|
||||||
|
persona_code: "P1",
|
||||||
|
persona_name: "민서",
|
||||||
|
session_no: 1,
|
||||||
|
status: "active",
|
||||||
|
stage: "라포",
|
||||||
|
theory_mode: "humanistic",
|
||||||
|
effective_openness: 0.21,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
ended_at: null,
|
||||||
|
review_ready: false,
|
||||||
|
turns: [],
|
||||||
|
goal_stages: ["라포"],
|
||||||
|
progress: null,
|
||||||
|
duration_limit_seconds: 3600,
|
||||||
|
warning_before_end_seconds: 600,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { startAttempts: () => startAttempts };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("uc session continuity guard", () => {
|
||||||
|
test("같은 내담자의 활성 회기 충돌은 기존 회기로 이어간다", async ({ page }) => {
|
||||||
|
const fixture = await installFixture(page);
|
||||||
|
await page.goto("/learn/session/P1");
|
||||||
|
await expect(page.locator(".sx-page--prestart")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/learn/session/${ACTIVE_SESSION_ID}$`),
|
||||||
|
);
|
||||||
|
await expect(page.getByText("민서").first()).toBeVisible();
|
||||||
|
expect(fixture.startAttempts()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -471,6 +471,34 @@ test.describe("회기 대화 — 텍스트 턴과 SSE 스트림", () => {
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(stream.texts[0]).toHaveLength(2000);
|
expect(stream.texts[0]).toHaveLength(2000);
|
||||||
expect(stream.texts[0]).toBe(longText);
|
expect(stream.texts[0]).toBe(longText);
|
||||||
|
|
||||||
|
const bubbleMetrics = await learnerUtterance(page)
|
||||||
|
.locator(".sx-utt__line")
|
||||||
|
.evaluate((element) => {
|
||||||
|
const bubble = element as HTMLElement;
|
||||||
|
const utterance = bubble.closest<HTMLElement>(".sx-utt");
|
||||||
|
const body = bubble.closest<HTMLElement>(".sx-utt__body");
|
||||||
|
if (!utterance || !body) return null;
|
||||||
|
|
||||||
|
const controls = Array.from(body.children).filter((child) => child !== bubble);
|
||||||
|
const controlWidth = controls.reduce(
|
||||||
|
(total, child) => total + (child as HTMLElement).getBoundingClientRect().width,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const gap = Number.parseFloat(getComputedStyle(body).gap) || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
availableWidth:
|
||||||
|
utterance.getBoundingClientRect().width - controlWidth - gap * controls.length,
|
||||||
|
bubbleWidth: bubble.getBoundingClientRect().width,
|
||||||
|
bubbleClientWidth: bubble.clientWidth,
|
||||||
|
bubbleScrollWidth: bubble.scrollWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bubbleMetrics).not.toBeNull();
|
||||||
|
expect(bubbleMetrics!.bubbleWidth).toBeGreaterThanOrEqual(bubbleMetrics!.availableWidth - 1);
|
||||||
|
expect(bubbleMetrics!.bubbleScrollWidth).toBeLessThanOrEqual(bubbleMetrics!.bubbleClientWidth + 1);
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -255,69 +255,75 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// usecase: 학습자가 기본 선택된 회기 설정 요약(이론모드·목표)을 읽는다
|
// usecase: 학습자가 기본 회기 설정 요약과 첫 발화의 한 가지 초점을 읽는다
|
||||||
test("기본 회기 설정 요약이 인간중심 이론과 라포·탐색 목표를 보여준다", async ({
|
test("기본 회기 설정 요약이 인간중심 이론과 라포 핵심 초점을 보여준다", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}) => {
|
||||||
await routePrestartFixture(page);
|
await routePrestartFixture(page);
|
||||||
await gotoPrestart(page);
|
await gotoPrestart(page);
|
||||||
|
|
||||||
const settingsSummary = page.locator("details.sx-prestart__settings > summary");
|
const settingsSummary = page.locator("details.sx-prestart__settings > summary");
|
||||||
await expect(settingsSummary).toContainText("인간중심, 라포 · 탐색");
|
await expect(settingsSummary).toContainText("인간중심 · 라포");
|
||||||
|
await expect(page.locator("details.sx-prestart__settings")).not.toHaveAttribute("open", "");
|
||||||
|
|
||||||
const goals = goalsGroup(page);
|
const goals = goalsGroup(page);
|
||||||
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
||||||
"aria-pressed",
|
"aria-pressed",
|
||||||
"true",
|
"true",
|
||||||
);
|
);
|
||||||
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute(
|
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute("aria-pressed", "false");
|
||||||
"aria-pressed",
|
|
||||||
"true",
|
|
||||||
);
|
|
||||||
await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute(
|
await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute(
|
||||||
"aria-pressed",
|
"aria-pressed",
|
||||||
"false",
|
"false",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!testInfo.project.name.includes("mobile")) {
|
|
||||||
const plan = page.locator(".sx-prestart__plan");
|
const plan = page.locator(".sx-prestart__plan");
|
||||||
await expect(plan).toContainText("2개 선택");
|
await expect(plan).toContainText("준비");
|
||||||
await expect(plan).toContainText("라포 · 탐색");
|
await expect(plan).toContainText("대화");
|
||||||
await expect(plan).toContainText("60분 회기");
|
await expect(plan).toContainText("리뷰·재연습");
|
||||||
await expect(plan).toContainText("종료 10분 전 알림");
|
await expect(plan).toContainText("라포에 집중");
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// usecase: 키보드 사용자는 전역 탐색을 지나 곧바로 회기 준비 본문으로 이동한다.
|
||||||
|
test("첫 Tab의 본문 건너뛰기가 회기 준비 본문에 포커스를 둔다", async ({ page }) => {
|
||||||
|
await routePrestartFixture(page);
|
||||||
|
await gotoPrestart(page);
|
||||||
|
|
||||||
|
const skipLink = page.getByRole("link", { name: "본문으로 건너뛰기" });
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await expect(skipLink).toBeFocused();
|
||||||
|
await expect(skipLink).toBeVisible();
|
||||||
|
|
||||||
|
await skipLink.press("Enter");
|
||||||
|
await expect(page.locator("main#vg-main-content")).toBeFocused();
|
||||||
});
|
});
|
||||||
|
|
||||||
// usecase: 학습자가 목표를 전부 해제해 보면 시작 버튼이 잠기고 안내를 받는다
|
// usecase: 학습자가 목표를 전부 해제해 보면 시작 버튼이 잠기고 안내를 받는다
|
||||||
test("목표를 모두 해제하면 회기 시작이 차단되고 선택 안내가 남는다", async ({
|
test("목표를 모두 해제하면 회기 시작이 차단되고 선택 안내가 남는다", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}) => {
|
||||||
await routePrestartFixture(page);
|
await routePrestartFixture(page);
|
||||||
await gotoPrestart(page);
|
await gotoPrestart(page);
|
||||||
|
|
||||||
const goals = goalsGroup(page);
|
const goals = goalsGroup(page);
|
||||||
await expect(startButton(page)).toBeEnabled();
|
await expect(startButton(page)).toBeEnabled();
|
||||||
await goals.getByRole("button", { name: /라포/ }).click();
|
await goals.getByRole("button", { name: /라포/ }).click();
|
||||||
await goals.getByRole("button", { name: /탐색/ }).click();
|
|
||||||
|
|
||||||
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
||||||
"aria-pressed",
|
"aria-pressed",
|
||||||
"false",
|
"false",
|
||||||
);
|
);
|
||||||
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute(
|
|
||||||
"aria-pressed",
|
|
||||||
"false",
|
|
||||||
);
|
|
||||||
await expect(startButton(page)).toBeDisabled();
|
await expect(startButton(page)).toBeDisabled();
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("details.sx-prestart__settings > summary"),
|
page.locator("details.sx-prestart__settings > summary"),
|
||||||
).toContainText("목표 선택 필요");
|
).toContainText("핵심 초점 선택 필요");
|
||||||
if (!testInfo.project.name.includes("mobile")) {
|
await expect(page.getByRole("heading", { name: "초점 선택 필요" })).toBeVisible();
|
||||||
|
await expect(page.locator(".sx-prestart__plan")).toContainText(
|
||||||
|
"목표 하나를 고르면 첫 발화의 기준이 여기에 표시됩니다.",
|
||||||
|
);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("이번 회기 목표를 1개 이상 선택하면 시작할 수 있어요."),
|
page.getByText("오늘의 핵심 초점을 1개 이상 선택하면 시작할 수 있어요."),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.locator(".sx-prestart__plan")).toContainText("선택 필요");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// usecase: 목표 0개 상태에서 학습자가 목표 하나를 고르면 다시 시작할 수 있다
|
// usecase: 목표 0개 상태에서 학습자가 목표 하나를 고르면 다시 시작할 수 있다
|
||||||
|
|
@ -327,7 +333,6 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
||||||
|
|
||||||
const goals = goalsGroup(page);
|
const goals = goalsGroup(page);
|
||||||
await goals.getByRole("button", { name: /라포/ }).click();
|
await goals.getByRole("button", { name: /라포/ }).click();
|
||||||
await goals.getByRole("button", { name: /탐색/ }).click();
|
|
||||||
await expect(startButton(page)).toBeDisabled();
|
await expect(startButton(page)).toBeDisabled();
|
||||||
|
|
||||||
await goals.getByRole("button", { name: /개입/ }).click();
|
await goals.getByRole("button", { name: /개입/ }).click();
|
||||||
|
|
@ -338,11 +343,11 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
||||||
await expect(startButton(page)).toBeEnabled();
|
await expect(startButton(page)).toBeEnabled();
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("details.sx-prestart__settings > summary"),
|
page.locator("details.sx-prestart__settings > summary"),
|
||||||
).toContainText("인간중심, 개입");
|
).toContainText("인간중심 · 개입");
|
||||||
});
|
});
|
||||||
|
|
||||||
// usecase: 학습자가 네 단계 목표를 전부 선택한다 — 선택지는 4개가 전부라 초과 선택은 불가능하다
|
// usecase: 학습자가 네 단계 목표를 전부 선택한다 — 선택지는 4개가 전부라 초과 선택은 불가능하다
|
||||||
test("목표 4개를 모두 선택할 수 있고 선택지는 4개가 전부다", async ({ page }, testInfo) => {
|
test("목표 4개를 모두 선택할 수 있고 선택지는 4개가 전부다", async ({ page }) => {
|
||||||
await routePrestartFixture(page);
|
await routePrestartFixture(page);
|
||||||
await gotoPrestart(page);
|
await gotoPrestart(page);
|
||||||
|
|
||||||
|
|
@ -351,6 +356,7 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
||||||
// 회기 단계는 라포·탐색·개입·정리 4개뿐 — 4개 초과 선택 자체가 불가능한 UI 계약.
|
// 회기 단계는 라포·탐색·개입·정리 4개뿐 — 4개 초과 선택 자체가 불가능한 UI 계약.
|
||||||
await expect(goalButtons).toHaveCount(4);
|
await expect(goalButtons).toHaveCount(4);
|
||||||
|
|
||||||
|
await goals.getByRole("button", { name: /탐색/ }).click();
|
||||||
await goals.getByRole("button", { name: /개입/ }).click();
|
await goals.getByRole("button", { name: /개입/ }).click();
|
||||||
await goals.getByRole("button", { name: /정리/ }).click();
|
await goals.getByRole("button", { name: /정리/ }).click();
|
||||||
for (const name of [/라포/, /탐색/, /개입/, /정리/]) {
|
for (const name of [/라포/, /탐색/, /개입/, /정리/]) {
|
||||||
|
|
@ -363,9 +369,114 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
||||||
page.locator("details.sx-prestart__settings > summary"),
|
page.locator("details.sx-prestart__settings > summary"),
|
||||||
).toContainText("라포 · 탐색 · 개입 · 정리");
|
).toContainText("라포 · 탐색 · 개입 · 정리");
|
||||||
await expect(startButton(page)).toBeEnabled();
|
await expect(startButton(page)).toBeEnabled();
|
||||||
if (!testInfo.project.name.includes("mobile")) {
|
});
|
||||||
await expect(page.locator(".sx-prestart__plan")).toContainText("4개 선택");
|
|
||||||
|
// usecase: 보조 목표가 생겨도 '먼저 선택한 것'에 묶이지 않고, 학습자가 핵심 초점을 명시적으로 바꾼다.
|
||||||
|
test("보조 목표를 핵심 초점으로 바꾸면 저장 순서도 함께 바뀐다", async ({ page }) => {
|
||||||
|
const fixture = await routePrestartFixture(page);
|
||||||
|
await gotoPrestart(page);
|
||||||
|
|
||||||
|
const goals = goalsGroup(page);
|
||||||
|
await goals.getByRole("button", { name: /탐색/ }).click();
|
||||||
|
|
||||||
|
const primarySwitch = page.getByRole("group", { name: "핵심 초점 바꾸기" });
|
||||||
|
await expect(page.locator(".sx-prestart__primary-switch")).toContainText("보조 목표");
|
||||||
|
await primarySwitch.getByRole("button", { name: "탐색을 핵심 초점으로 설정" }).click();
|
||||||
|
|
||||||
|
await expect(page.locator(".sx-prestart__focus").getByRole("heading")).toHaveText("탐색");
|
||||||
|
await expect(
|
||||||
|
page.locator("details.sx-prestart__settings > summary"),
|
||||||
|
).toContainText("인간중심 · 탐색 · 라포");
|
||||||
|
|
||||||
|
await startButton(page).click();
|
||||||
|
expect(fixture.startRequests).toHaveLength(1);
|
||||||
|
expect(fixture.startRequests[0]).toMatchObject({ goal_stages: ["탐색", "라포"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// usecase: 높이가 짧은 데스크톱에서도 1~4개 목표의 핵심 전환과 시작 행동이 서로 가려지지 않는다.
|
||||||
|
test("짧은 데스크톱에서 4개 목표와 회기 시작 행동이 겹치지 않는다", async ({ page }, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name.includes("mobile"), "데스크톱 중앙 열의 짧은 높이 회귀를 검증한다.");
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
const fixture = await routePrestartFixture(page);
|
||||||
|
await gotoPrestart(page);
|
||||||
|
|
||||||
|
const goals = goalsGroup(page);
|
||||||
|
await goals.getByRole("button", { name: /탐색/ }).click();
|
||||||
|
await goals.getByRole("button", { name: /개입/ }).click();
|
||||||
|
await goals.getByRole("button", { name: /정리/ }).click();
|
||||||
|
|
||||||
|
// 짧은 높이에서는 세 개의 큰 보조 버튼 대신 같은 선택지를 가진 native select를 쓴다.
|
||||||
|
const compactPrimarySelect = page.locator(".sx-prestart__primary-select select");
|
||||||
|
await expect(compactPrimarySelect).toBeVisible();
|
||||||
|
await compactPrimarySelect.selectOption("탐색");
|
||||||
|
await expect(page.locator(".sx-prestart__focus").getByRole("heading")).toHaveText("탐색");
|
||||||
|
|
||||||
|
const geometry = await page.evaluate(() => {
|
||||||
|
const main = document.querySelector<HTMLElement>(".vg-main");
|
||||||
|
const actions = document.querySelector<HTMLElement>(".sx-prestart__actions");
|
||||||
|
const cta = actions?.querySelector<HTMLElement>(".vg-btn");
|
||||||
|
const primaryControl = document.querySelector<HTMLElement>(".sx-prestart__primary-select select");
|
||||||
|
if (!main || !actions || !cta || !primaryControl) throw new Error("prestart geometry targets missing");
|
||||||
|
main.scrollTo({ top: 0 });
|
||||||
|
const rect = (element: HTMLElement) => {
|
||||||
|
const { top, bottom } = element.getBoundingClientRect();
|
||||||
|
return { top, bottom };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
main: rect(main),
|
||||||
|
actions: rect(actions),
|
||||||
|
cta: rect(cta),
|
||||||
|
primaryControl: rect(primaryControl),
|
||||||
|
hasHorizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.cta.bottom).toBeLessThanOrEqual(geometry.main.bottom);
|
||||||
|
expect(geometry.primaryControl.bottom).toBeLessThanOrEqual(geometry.actions.top);
|
||||||
|
expect(geometry.hasHorizontalOverflow).toBe(false);
|
||||||
|
|
||||||
|
await startButton(page).click();
|
||||||
|
expect(fixture.startRequests).toHaveLength(1);
|
||||||
|
expect(fixture.startRequests[0]).toMatchObject({
|
||||||
|
goal_stages: ["탐색", "라포", "개입", "정리"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// usecase: 모바일에서는 읽는 순서와 bottom bar 위의 시작 행동을 동시에 보장한다.
|
||||||
|
test("모바일에서 사례·학습·재연습 순서와 시작 CTA 안전 간격을 지킨다", async ({ page }, testInfo) => {
|
||||||
|
test.skip(!testInfo.project.name.includes("mobile"), "모바일 문서 순서와 bottom bar 관계를 검증한다.");
|
||||||
|
await routePrestartFixture(page);
|
||||||
|
await gotoPrestart(page);
|
||||||
|
|
||||||
|
const geometry = await page.evaluate(() => {
|
||||||
|
const main = document.querySelector<HTMLElement>(".vg-main");
|
||||||
|
const caseCard = document.querySelector<HTMLElement>(".sx-prestart__visual");
|
||||||
|
const learning = document.querySelector<HTMLElement>(".sx-prestart__learning");
|
||||||
|
const map = document.querySelector<HTMLElement>(".sx-prestart__plan");
|
||||||
|
const actions = document.querySelector<HTMLElement>(".sx-prestart__actions");
|
||||||
|
const cta = actions?.querySelector<HTMLElement>(".vg-btn");
|
||||||
|
const bottomNav = document.querySelector<HTMLElement>('nav[aria-label="주 메뉴"]');
|
||||||
|
if (!main || !caseCard || !learning || !map || !actions || !cta || !bottomNav) {
|
||||||
|
throw new Error("mobile prestart geometry targets missing");
|
||||||
}
|
}
|
||||||
|
main.scrollTo({ top: 0 });
|
||||||
|
const rect = (element: HTMLElement) => {
|
||||||
|
const { top, bottom } = element.getBoundingClientRect();
|
||||||
|
return { top, bottom };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
caseCard: rect(caseCard),
|
||||||
|
learning: rect(learning),
|
||||||
|
map: rect(map),
|
||||||
|
actions: rect(actions),
|
||||||
|
cta: rect(cta),
|
||||||
|
bottomNav: rect(bottomNav),
|
||||||
|
hasHorizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.caseCard.top).toBeLessThan(geometry.learning.top);
|
||||||
|
expect(geometry.learning.top).toBeLessThan(geometry.map.top);
|
||||||
|
expect(geometry.cta.bottom).toBeLessThanOrEqual(geometry.bottomNav.top - 8);
|
||||||
|
expect(geometry.hasHorizontalOverflow).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// usecase: 학습자가 선택돼 있던 목표 하나를 다시 눌러 해제한다
|
// usecase: 학습자가 선택돼 있던 목표 하나를 다시 눌러 해제한다
|
||||||
|
|
@ -375,13 +486,16 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
||||||
|
|
||||||
const goals = goalsGroup(page);
|
const goals = goalsGroup(page);
|
||||||
const exploreGoal = goals.getByRole("button", { name: /탐색/ });
|
const exploreGoal = goals.getByRole("button", { name: /탐색/ });
|
||||||
|
await expect(exploreGoal).toHaveAttribute("aria-pressed", "false");
|
||||||
|
|
||||||
|
await exploreGoal.click();
|
||||||
await expect(exploreGoal).toHaveAttribute("aria-pressed", "true");
|
await expect(exploreGoal).toHaveAttribute("aria-pressed", "true");
|
||||||
|
|
||||||
await exploreGoal.click();
|
await exploreGoal.click();
|
||||||
await expect(exploreGoal).toHaveAttribute("aria-pressed", "false");
|
await expect(exploreGoal).toHaveAttribute("aria-pressed", "false");
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("details.sx-prestart__settings > summary"),
|
page.locator("details.sx-prestart__settings > summary"),
|
||||||
).toContainText("인간중심, 라포");
|
).toContainText("인간중심 · 라포");
|
||||||
// 나머지 기본 목표는 그대로 유지된다.
|
// 나머지 기본 목표는 그대로 유지된다.
|
||||||
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
||||||
"aria-pressed",
|
"aria-pressed",
|
||||||
|
|
@ -393,10 +507,11 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
||||||
// usecase: 학습자가 이론모드를 CBT로 바꿔 회기 접근을 조정한다
|
// usecase: 학습자가 이론모드를 CBT로 바꿔 회기 접근을 조정한다
|
||||||
test("이론모드를 CBT로 바꾸면 선택 상태와 진행 초점이 갱신된다", async ({
|
test("이론모드를 CBT로 바꾸면 선택 상태와 진행 초점이 갱신된다", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}) => {
|
||||||
await routePrestartFixture(page);
|
await routePrestartFixture(page);
|
||||||
await gotoPrestart(page);
|
await gotoPrestart(page);
|
||||||
|
|
||||||
|
await page.locator("details.sx-prestart__settings > summary").click();
|
||||||
const theory = page.getByRole("group", { name: "이론모드 선택" });
|
const theory = page.getByRole("group", { name: "이론모드 선택" });
|
||||||
await expect(theory.getByRole("button", { name: /인간중심/ })).toHaveAttribute(
|
await expect(theory.getByRole("button", { name: /인간중심/ })).toHaveAttribute(
|
||||||
"aria-pressed",
|
"aria-pressed",
|
||||||
|
|
@ -414,12 +529,10 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("details.sx-prestart__settings > summary"),
|
page.locator("details.sx-prestart__settings > summary"),
|
||||||
).toContainText("CBT, 라포 · 탐색");
|
).toContainText("CBT · 라포");
|
||||||
if (!testInfo.project.name.includes("mobile")) {
|
await expect(page.locator(".sx-prestart__settings-note")).toContainText(
|
||||||
await expect(page.locator(".sx-prestart__plan")).toContainText(
|
|
||||||
"상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.",
|
"상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.",
|
||||||
);
|
);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// usecase: 학습자가 회기 시작을 눌러 active 회기로 넘어간다 — 고른 목표·이론이 요청에 실린다
|
// usecase: 학습자가 회기 시작을 눌러 active 회기로 넘어간다 — 고른 목표·이론이 요청에 실린다
|
||||||
|
|
@ -429,8 +542,7 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
||||||
const fixture = await routePrestartFixture(page);
|
const fixture = await routePrestartFixture(page);
|
||||||
await gotoPrestart(page);
|
await gotoPrestart(page);
|
||||||
|
|
||||||
// 기본 선택에서 탐색을 해제해 라포 1개만 목표로 남긴다(자유 선택 검증).
|
// 기본 선택인 라포 한 가지 초점으로 시작한다.
|
||||||
await goalsGroup(page).getByRole("button", { name: /탐색/ }).click();
|
|
||||||
await startButton(page).click();
|
await startButton(page).click();
|
||||||
|
|
||||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
||||||
|
|
@ -465,6 +577,29 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
||||||
await expect(startButton(page)).toBeEnabled();
|
await expect(startButton(page)).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// usecase: 단축키 도움말은 모달 선언과 같은 포커스 규칙을 지키며, 닫으면 호출자로 돌아간다.
|
||||||
|
test("단축키 도움말은 포커스를 가두고 닫으면 시작 행동으로 돌려준다", async ({ page }) => {
|
||||||
|
await routePrestartFixture(page);
|
||||||
|
await gotoPrestart(page);
|
||||||
|
|
||||||
|
const start = startButton(page);
|
||||||
|
await start.focus();
|
||||||
|
await expect(start).toBeFocused();
|
||||||
|
// Playwright에서는 punctuation chord보다 명시한 Ctrl+/ 계약이 키보드 레이아웃에 독립적이다.
|
||||||
|
await start.press("Control+/");
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog", { name: "키보드 단축키 안내" });
|
||||||
|
const confirm = dialog.getByRole("button", { name: "확인 (Esc)" });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(confirm).toBeFocused();
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await expect(confirm).toBeFocused();
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(start).toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
// usecase: 리뷰 처방(G4 deliberate)에서 넘어온 학습자가 처방 맥락을 프리스타트에서 확인한다
|
// usecase: 리뷰 처방(G4 deliberate)에서 넘어온 학습자가 처방 맥락을 프리스타트에서 확인한다
|
||||||
test("G4 처방 연습 launch intent가 프리스타트에 난도 사다리 맥락으로 표시된다", async ({
|
test("G4 처방 연습 launch intent가 프리스타트에 난도 사다리 맥락으로 표시된다", async ({
|
||||||
page,
|
page,
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,14 @@ async function mountSettingsFixtures(
|
||||||
|
|
||||||
await routeUnmockedApi(page);
|
await routeUnmockedApi(page);
|
||||||
|
|
||||||
|
await page.route("**/api/static/avatars/*.png", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "image/png",
|
||||||
|
body: Buffer.from(PNG_1X1_BASE64, "base64"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await page.route("**/api/auth/me", (route) =>
|
await page.route("**/api/auth/me", (route) =>
|
||||||
route.fulfill(
|
route.fulfill(
|
||||||
jsonRoute({
|
jsonRoute({
|
||||||
|
|
@ -471,6 +479,44 @@ test.describe("설정·동의 관리 유스케이스", () => {
|
||||||
await expect(account.locator(".vg-set__saved")).toHaveCount(0);
|
await expect(account.locator(".vg-set__saved")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// usecase: DB에 남은 아바타 경로의 실제 파일이 사라져 404여도 깨진 이미지 아이콘 대신 이니셜을 유지한다
|
||||||
|
test("아바타 파일 404 시 톱바와 설정 미리보기 모두 이니셜 폴백을 유지한다", async ({ page }) => {
|
||||||
|
const staleAvatar = "/uploads/profile-avatars/stale-avatar.jpg";
|
||||||
|
await mountSettingsFixtures(page, { avatarUrl: staleAvatar });
|
||||||
|
await page.route("**/api/uploads/profile-avatars/stale-avatar.jpg", (route) =>
|
||||||
|
route.fulfill(jsonRoute({ detail: "Not Found" }, 404)),
|
||||||
|
);
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
|
||||||
|
const avatarFrames = page.locator(
|
||||||
|
".vg-topbar__avatar, .vg-set__rail-avatar, .vg-set__avatar",
|
||||||
|
);
|
||||||
|
await expect(avatarFrames).toHaveCount(3);
|
||||||
|
for (const frame of await avatarFrames.all()) {
|
||||||
|
await expect(frame.locator("[data-image-fallback='failed']")).toHaveText("설");
|
||||||
|
await expect(frame.locator("img")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// usecase: 저장값이 웹 이미지가 아닌 scheme이면 요청하지 않고 즉시 안전한 이니셜 상태로 닫힌다
|
||||||
|
test("빈 URL과 비웹 scheme은 네트워크 요청 없이 이니셜 폴백으로 정규화한다", async ({ page }) => {
|
||||||
|
let unsafeRequestCount = 0;
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (/javascript:|data:|file:/i.test(request.url())) unsafeRequestCount += 1;
|
||||||
|
});
|
||||||
|
await mountSettingsFixtures(page, { avatarUrl: " javascript:alert(1) " });
|
||||||
|
await openLoadedSettings(page);
|
||||||
|
|
||||||
|
const avatarFrames = page.locator(
|
||||||
|
".vg-topbar__avatar, .vg-set__rail-avatar, .vg-set__avatar",
|
||||||
|
);
|
||||||
|
for (const frame of await avatarFrames.all()) {
|
||||||
|
await expect(frame.locator("[data-image-fallback='empty']")).toHaveText("설");
|
||||||
|
await expect(frame.locator("img")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
expect(unsafeRequestCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
// usecase: 학습자가 표시 이름을 빈값으로 저장 시도했다가 서버 거절을 받는다
|
// usecase: 학습자가 표시 이름을 빈값으로 저장 시도했다가 서버 거절을 받는다
|
||||||
test("표시 이름을 빈값으로 저장하면 거짓 저장됨 표시 없이 입력 상태가 유지된다", async ({
|
test("표시 이름을 빈값으로 저장하면 거짓 저장됨 표시 없이 입력 상태가 유지된다", async ({
|
||||||
page,
|
page,
|
||||||
|
|
|
||||||
|
|
@ -20,17 +20,23 @@ function countRawColors(source) {
|
||||||
|
|
||||||
const shellPath = "src/components/shell/shell.css";
|
const shellPath = "src/components/shell/shell.css";
|
||||||
const surfacePath = "src/components/ui/ui.css";
|
const surfacePath = "src/components/ui/ui.css";
|
||||||
|
const tabsPath = "src/components/ui/Tabs.tsx";
|
||||||
const settingsPath = "src/pages/settings/settings.css";
|
const settingsPath = "src/pages/settings/settings.css";
|
||||||
const sessionPath = "src/pages/Session.tsx";
|
const sessionPath = "src/pages/Session.tsx";
|
||||||
const learnerHomePath = "src/pages/LearnerHome.tsx";
|
const learnerHomePath = "src/pages/LearnerHome.tsx";
|
||||||
|
const adminPath = "src/pages/Admin.tsx";
|
||||||
|
const personaStudioPath = "src/pages/PersonaStudio.tsx";
|
||||||
const personaViewModelPath = "src/lib/personaViewModel.ts";
|
const personaViewModelPath = "src/lib/personaViewModel.ts";
|
||||||
const appPath = "src/App.tsx";
|
const appPath = "src/App.tsx";
|
||||||
const designDocPath = "../../docs/DESIGN_CONCEPT.md";
|
const designDocPath = "../../docs/DESIGN_CONCEPT.md";
|
||||||
const shell = await read(shellPath);
|
const shell = await read(shellPath);
|
||||||
const surface = await read(surfacePath);
|
const surface = await read(surfacePath);
|
||||||
|
const tabs = await read(tabsPath);
|
||||||
const settings = await read(settingsPath);
|
const settings = await read(settingsPath);
|
||||||
const session = await read(sessionPath);
|
const session = await read(sessionPath);
|
||||||
const learnerHome = await read(learnerHomePath);
|
const learnerHome = await read(learnerHomePath);
|
||||||
|
const admin = await read(adminPath);
|
||||||
|
const personaStudio = await read(personaStudioPath);
|
||||||
const personaViewModel = await read(personaViewModelPath);
|
const personaViewModel = await read(personaViewModelPath);
|
||||||
const app = await read(appPath);
|
const app = await read(appPath);
|
||||||
const designDoc = await read(designDocPath);
|
const designDoc = await read(designDocPath);
|
||||||
|
|
@ -95,6 +101,26 @@ forbid(
|
||||||
/#[0-9a-f]{3,8}|rgba?\(/i,
|
/#[0-9a-f]{3,8}|rgba?\(/i,
|
||||||
"공통 UI 색은 tokens.css 의미 토큰으로만 표현해야 합니다",
|
"공통 UI 색은 tokens.css 의미 토큰으로만 표현해야 합니다",
|
||||||
);
|
);
|
||||||
|
forbid(
|
||||||
|
tabsPath,
|
||||||
|
tabs,
|
||||||
|
/surfaceClassName|vg-surface/,
|
||||||
|
"Tabs behavior primitive는 콘텐츠 Surface를 만들면 안 됩니다",
|
||||||
|
);
|
||||||
|
for (const required of [
|
||||||
|
'role="tablist"',
|
||||||
|
'role="tab"',
|
||||||
|
"aria-controls",
|
||||||
|
"aria-labelledby",
|
||||||
|
"tabIndex",
|
||||||
|
'"ArrowRight"',
|
||||||
|
'"Home"',
|
||||||
|
'"End"',
|
||||||
|
]) {
|
||||||
|
if (!tabs.includes(required)) {
|
||||||
|
failures.push(`${tabsPath}: Tabs 접근성 계약 누락 (${required})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
forbid(
|
forbid(
|
||||||
shellPath,
|
shellPath,
|
||||||
shell,
|
shell,
|
||||||
|
|
@ -113,6 +139,21 @@ forbid(
|
||||||
/#[0-9a-f]{3,8}|rgba?\(/i,
|
/#[0-9a-f]{3,8}|rgba?\(/i,
|
||||||
"학습자 화면의 아바타 팔레트는 personaViewModel SSOT를 우회하면 안 됩니다",
|
"학습자 화면의 아바타 팔레트는 personaViewModel SSOT를 우회하면 안 됩니다",
|
||||||
);
|
);
|
||||||
|
for (const [relativePath, source, className, owner] of [
|
||||||
|
[adminPath, admin, "vgops-tabs", "관리자 탭 목록"],
|
||||||
|
[personaStudioPath, personaStudio, "ps-tabs", "페르소나 제작 탭 목록"],
|
||||||
|
[adminPath, admin, "vgops-approval", "가입 승인 연속 목록 행"],
|
||||||
|
[adminPath, admin, "vgops-ticket", "지원 티켓 연속 목록 행"],
|
||||||
|
]) {
|
||||||
|
forbid(
|
||||||
|
relativePath,
|
||||||
|
source,
|
||||||
|
new RegExp(
|
||||||
|
`surfaceClassName\\(["'](?:[^"']*\\s)?${className}(?:\\s[^"']*)?["']`,
|
||||||
|
),
|
||||||
|
`${owner}은 콘텐츠 Surface가 아니라 공통 컨트롤 또는 외곽 목록 프레임이 소유해야 합니다`,
|
||||||
|
);
|
||||||
|
}
|
||||||
forbid(
|
forbid(
|
||||||
settingsPath,
|
settingsPath,
|
||||||
settings,
|
settings,
|
||||||
|
|
|
||||||
106
apps/web/src/components/avatar/ResilientImage.tsx
Normal file
106
apps/web/src/components/avatar/ResilientImage.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { useEffect, useState, type ImgHTMLAttributes, type ReactNode } from "react";
|
||||||
|
import { apiUrl } from "../../lib/api";
|
||||||
|
|
||||||
|
const CONTROL_CHARACTER = /[\u0000-\u001F\u007F]/;
|
||||||
|
const EXPLICIT_SCHEME = /^([a-z][a-z\d+.-]*):/i;
|
||||||
|
const SAFE_IMAGE_PROTOCOLS = new Set(["http:", "https:"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 상대경로와 외부 http(s) 이미지만 브라우저가 읽을 수 있는 절대 URL로 정규화한다.
|
||||||
|
* 빈 값, 제어문자, 자격 증명 내장 URL, data/javascript/file 등 비웹 scheme은 실패 폐쇄한다.
|
||||||
|
*/
|
||||||
|
export function normalizeImageSource(source?: string | null): string {
|
||||||
|
const value = (source ?? "").trim();
|
||||||
|
if (!value || CONTROL_CHARACTER.test(value)) return "";
|
||||||
|
|
||||||
|
const scheme = EXPLICIT_SCHEME.exec(value)?.[1]?.toLowerCase();
|
||||||
|
if (scheme && scheme !== "http" && scheme !== "https") return "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const browserOrigin =
|
||||||
|
typeof window === "undefined" ? "http://localhost" : window.location.origin;
|
||||||
|
const candidate = scheme
|
||||||
|
? value
|
||||||
|
: value.startsWith("//")
|
||||||
|
? `${typeof window === "undefined" ? "https:" : window.location.protocol}${value}`
|
||||||
|
: apiUrl(value);
|
||||||
|
const parsed = new URL(candidate, browserOrigin);
|
||||||
|
|
||||||
|
if (!SAFE_IMAGE_PROTOCOLS.has(parsed.protocol)) return "";
|
||||||
|
if (parsed.username || parsed.password) return "";
|
||||||
|
return parsed.href;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResilientImageProps
|
||||||
|
extends Omit<
|
||||||
|
ImgHTMLAttributes<HTMLImageElement>,
|
||||||
|
"alt" | "onError" | "onLoad" | "src"
|
||||||
|
> {
|
||||||
|
src?: string | null;
|
||||||
|
/** 장식 이미지면 빈 문자열. 의미가 있으면 구체적인 대체 텍스트. */
|
||||||
|
alt: string;
|
||||||
|
fallback: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공적으로 디코딩되기 전까지 fallback을 유지해 깨진 이미지 아이콘을 노출하지 않는다.
|
||||||
|
* src가 바뀌면 새 URL을 다시 시도하고, 같은 실패 URL은 재렌더링마다 재요청하지 않는다.
|
||||||
|
*/
|
||||||
|
export function ResilientImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
fallback,
|
||||||
|
decoding = "async",
|
||||||
|
style,
|
||||||
|
...imageProps
|
||||||
|
}: ResilientImageProps) {
|
||||||
|
const normalizedSrc = normalizeImageSource(src);
|
||||||
|
const [loadedSrc, setLoadedSrc] = useState("");
|
||||||
|
const [failedSrc, setFailedSrc] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadedSrc("");
|
||||||
|
setFailedSrc("");
|
||||||
|
}, [normalizedSrc]);
|
||||||
|
|
||||||
|
const failed = Boolean(normalizedSrc) && failedSrc === normalizedSrc;
|
||||||
|
const loaded = Boolean(normalizedSrc) && loadedSrc === normalizedSrc && !failed;
|
||||||
|
const fallbackState = !normalizedSrc ? "empty" : failed ? "failed" : "loading";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!loaded ? (
|
||||||
|
<span
|
||||||
|
data-image-fallback={fallbackState}
|
||||||
|
role={alt ? "img" : undefined}
|
||||||
|
aria-label={alt || undefined}
|
||||||
|
aria-hidden={alt ? undefined : true}
|
||||||
|
>
|
||||||
|
{fallback}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{normalizedSrc && !failed ? (
|
||||||
|
<img
|
||||||
|
{...imageProps}
|
||||||
|
src={normalizedSrc}
|
||||||
|
alt={alt}
|
||||||
|
decoding={decoding}
|
||||||
|
hidden={!loaded}
|
||||||
|
style={loaded ? style : { ...style, display: "none" }}
|
||||||
|
data-image-state={loaded ? "ready" : "loading"}
|
||||||
|
onLoad={() => {
|
||||||
|
setFailedSrc("");
|
||||||
|
setLoadedSrc(normalizedSrc);
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
setLoadedSrc("");
|
||||||
|
setFailedSrc(normalizedSrc);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -162,6 +162,13 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
>
|
>
|
||||||
|
<a
|
||||||
|
className="vg-skip-link"
|
||||||
|
href="#vg-main-content"
|
||||||
|
onClick={() => window.requestAnimationFrame(() => mainRef.current?.focus())}
|
||||||
|
>
|
||||||
|
본문으로 건너뛰기
|
||||||
|
</a>
|
||||||
{hideTopbar ? null : <Topbar contextLabel={contextLabel} />}
|
{hideTopbar ? null : <Topbar contextLabel={contextLabel} />}
|
||||||
<div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}>
|
<div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}>
|
||||||
{showNav ? (
|
{showNav ? (
|
||||||
|
|
@ -170,7 +177,7 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi
|
||||||
showAdminEntry={canAccessRole(user, "admin")}
|
showAdminEntry={canAccessRole(user, "admin")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<main ref={mainRef} className={mainClassName}>
|
<main id="vg-main-content" ref={mainRef} className={mainClassName} tabIndex={-1}>
|
||||||
<div className="vg-main__inner">{children}</div>
|
<div className="vg-main__inner">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { Icon } from "../ui/Icon";
|
import { Icon } from "../ui/Icon";
|
||||||
|
import { ResilientImage } from "../avatar/ResilientImage";
|
||||||
import { accessibleRolesFor, canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth";
|
import { accessibleRolesFor, canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth";
|
||||||
import { useTheme } from "../../lib/useTheme";
|
import { useTheme } from "../../lib/useTheme";
|
||||||
|
|
||||||
|
|
@ -101,7 +102,11 @@ export function Topbar({ contextLabel }: TopbarProps) {
|
||||||
<>
|
<>
|
||||||
<span className="vg-topbar__user">
|
<span className="vg-topbar__user">
|
||||||
<span className="vg-topbar__avatar" aria-hidden="true">
|
<span className="vg-topbar__avatar" aria-hidden="true">
|
||||||
{user.avatarUrl ? <img src={user.avatarUrl} alt="" /> : initials(user.name)}
|
<ResilientImage
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
fallback={initials(user.name)}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="vg-topbar__uname">{user.name}</span>
|
<span className="vg-topbar__uname">{user.name}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,29 @@
|
||||||
.vg-shell--fullscreen {
|
.vg-shell--fullscreen {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
.vg-skip-link {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
top: var(--sp-2);
|
||||||
|
left: var(--sp-2);
|
||||||
|
min-height: 44px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--sp-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--accent-deep);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transform: translateY(calc(-100% - var(--sp-3)));
|
||||||
|
transition: transform var(--dur-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.vg-skip-link:focus {
|
||||||
|
transform: translateY(0);
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 톱바 ── */
|
/* ── 톱바 ── */
|
||||||
.vg-topbar {
|
.vg-topbar {
|
||||||
|
|
|
||||||
125
apps/web/src/components/ui/Tabs.tsx
Normal file
125
apps/web/src/components/ui/Tabs.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import {
|
||||||
|
useRef,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export interface TabItem<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabsProps<T extends string> {
|
||||||
|
id: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
items: readonly TabItem<T>[];
|
||||||
|
value: T;
|
||||||
|
onValueChange: (value: T) => void;
|
||||||
|
listClassName?: string;
|
||||||
|
panelClassName?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabToken(value: string) {
|
||||||
|
return encodeURIComponent(value).replaceAll("%", "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tabs — 탭과 현재 패널의 ARIA 연결·roving focus·키보드 이동 단일 소유자.
|
||||||
|
* 시각적 표면을 만들지 않으며, 도메인별 배치는 listClassName/panelClassName이 맡는다.
|
||||||
|
*/
|
||||||
|
export function Tabs<T extends string>({
|
||||||
|
id,
|
||||||
|
ariaLabel,
|
||||||
|
items,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
listClassName,
|
||||||
|
panelClassName,
|
||||||
|
children,
|
||||||
|
}: TabsProps<T>) {
|
||||||
|
const triggerRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||||
|
const activeIndex = items.findIndex((item) => item.value === value);
|
||||||
|
const activeItem = items[activeIndex];
|
||||||
|
|
||||||
|
if (!activeItem) {
|
||||||
|
throw new Error(`Tabs(${id})에 등록되지 않은 값입니다: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelId = `${id}-panel`;
|
||||||
|
const triggerId = (itemValue: T) => `${id}-tab-${tabToken(itemValue)}`;
|
||||||
|
|
||||||
|
const moveFocus = (
|
||||||
|
event: KeyboardEvent<HTMLButtonElement>,
|
||||||
|
currentIndex: number,
|
||||||
|
) => {
|
||||||
|
const enabledIndices = items
|
||||||
|
.map((item, index) => (item.disabled ? -1 : index))
|
||||||
|
.filter((index) => index >= 0);
|
||||||
|
if (enabledIndices.length === 0) return;
|
||||||
|
|
||||||
|
const position = enabledIndices.indexOf(currentIndex);
|
||||||
|
let nextIndex: number | undefined;
|
||||||
|
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
||||||
|
nextIndex = enabledIndices[(position + 1) % enabledIndices.length];
|
||||||
|
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
||||||
|
nextIndex =
|
||||||
|
enabledIndices[(position - 1 + enabledIndices.length) % enabledIndices.length];
|
||||||
|
} else if (event.key === "Home") {
|
||||||
|
nextIndex = enabledIndices[0];
|
||||||
|
} else if (event.key === "End") {
|
||||||
|
nextIndex = enabledIndices[enabledIndices.length - 1];
|
||||||
|
}
|
||||||
|
if (nextIndex === undefined) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const nextItem = items[nextIndex];
|
||||||
|
onValueChange(nextItem.value);
|
||||||
|
triggerRefs.current[nextIndex]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
className={["vg-tabs", listClassName ?? ""].filter(Boolean).join(" ")}
|
||||||
|
role="tablist"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const selected = value === item.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={(element) => {
|
||||||
|
triggerRefs.current[index] = element;
|
||||||
|
}}
|
||||||
|
id={triggerId(item.value)}
|
||||||
|
type="button"
|
||||||
|
key={item.value}
|
||||||
|
role="tab"
|
||||||
|
aria-controls={panelId}
|
||||||
|
aria-selected={selected}
|
||||||
|
tabIndex={selected ? 0 : -1}
|
||||||
|
disabled={item.disabled}
|
||||||
|
className={`vg-tabs__trigger${selected ? " is-active" : ""}`}
|
||||||
|
onClick={() => onValueChange(item.value)}
|
||||||
|
onKeyDown={(event) => moveFocus(event, index)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id={panelId}
|
||||||
|
className={["vg-tabs__panel", panelClassName ?? ""].filter(Boolean).join(" ")}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={triggerId(activeItem.value)}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,9 @@ export type { PanelProps } from "./Panel";
|
||||||
export { Surface, surfaceClassName } from "./Surface";
|
export { Surface, surfaceClassName } from "./Surface";
|
||||||
export type { SurfaceProps, SurfaceOptions, SurfaceVariant } from "./Surface";
|
export type { SurfaceProps, SurfaceOptions, SurfaceVariant } from "./Surface";
|
||||||
|
|
||||||
|
export { Tabs } from "./Tabs";
|
||||||
|
export type { TabsProps, TabItem } from "./Tabs";
|
||||||
|
|
||||||
export { Kicker } from "./Kicker";
|
export { Kicker } from "./Kicker";
|
||||||
export type { KickerProps } from "./Kicker";
|
export type { KickerProps } from "./Kicker";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,16 @@
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tabs behavior primitive. 시각 표면은 만들지 않는다. ── */
|
||||||
|
.vg-tabs,
|
||||||
|
.vg-tabs__panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.vg-tabs__trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Card / Panel 크기 계약. 표면은 위 Surface만 소유한다. ── */
|
/* ── Card / Panel 크기 계약. 표면은 위 Surface만 소유한다. ── */
|
||||||
.vg-card {
|
.vg-card {
|
||||||
padding: var(--sp-5);
|
padding: var(--sp-5);
|
||||||
|
|
|
||||||
|
|
@ -728,7 +728,8 @@ export interface paths {
|
||||||
* @description 회기 전체 deep-loop 재평가(슈퍼바이저 rationale/critique + 개선점 + 대안발화).
|
* @description 회기 전체 deep-loop 재평가(슈퍼바이저 rationale/critique + 개선점 + 대안발화).
|
||||||
*
|
*
|
||||||
* 저장된 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다.
|
* 저장된 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다.
|
||||||
* 엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출).
|
* 엔진 장애는 503으로 변환한다. provider 예외 원문은 durable 기록에만 남기고 HTTP에는
|
||||||
|
* 안전한 안내만 반환한다.
|
||||||
*/
|
*/
|
||||||
post: operations["reevaluate_session_eval_sessions__session_id__reevaluate_post"];
|
post: operations["reevaluate_session_eval_sessions__session_id__reevaluate_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
|
|
@ -1667,6 +1668,46 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/sessions/cases": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* List Learner Cases
|
||||||
|
* @description Return complete case-local progress for one NPC, not a capped history slice.
|
||||||
|
*/
|
||||||
|
get: operations["list_learner_cases_sessions_cases_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/sessions/cases/{case_id}/memory": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get Learner Case Memory Preview
|
||||||
|
* @description Load only the learner-safe compact memory when its foldout is opened.
|
||||||
|
*/
|
||||||
|
get: operations["get_learner_case_memory_preview_sessions_cases__case_id__memory_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/sessions/dashboard": {
|
"/sessions/dashboard": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -4083,6 +4124,61 @@ export interface components {
|
||||||
/** Transfer Suites */
|
/** Transfer Suites */
|
||||||
transfer_suites: components["schemas"]["TransferSuiteItem"][];
|
transfer_suites: components["schemas"]["TransferSuiteItem"][];
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* CaseMemoryPreview
|
||||||
|
* @description 접힌 learner UI에만 쓰는 최소·마스킹된 사례 기억 투영.
|
||||||
|
*/
|
||||||
|
CaseMemoryPreview: {
|
||||||
|
/** Case Digest */
|
||||||
|
case_digest?: string | null;
|
||||||
|
/** Case Id */
|
||||||
|
case_id: string;
|
||||||
|
/** Latest Session Digest */
|
||||||
|
latest_session_digest?: string | null;
|
||||||
|
/**
|
||||||
|
* Memory Available
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
memory_available: boolean;
|
||||||
|
/** Open Threads */
|
||||||
|
open_threads?: string[];
|
||||||
|
/** Pinned Facts */
|
||||||
|
pinned_facts?: string[];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* CaseProgressStats
|
||||||
|
* @description 한 사례 안에서만 누적한 learner-visible 진행 수치.
|
||||||
|
*/
|
||||||
|
CaseProgressStats: {
|
||||||
|
/** Active Session Id */
|
||||||
|
active_session_id?: string | null;
|
||||||
|
/** Active Session No */
|
||||||
|
active_session_no?: number | null;
|
||||||
|
/** Active Started At */
|
||||||
|
active_started_at?: string | null;
|
||||||
|
/**
|
||||||
|
* Completed Sessions
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
completed_sessions: number;
|
||||||
|
/** Last Activity At */
|
||||||
|
last_activity_at?: string | null;
|
||||||
|
/**
|
||||||
|
* Total Duration Seconds
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
total_duration_seconds: number;
|
||||||
|
/**
|
||||||
|
* Total Sessions
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
total_sessions: number;
|
||||||
|
/**
|
||||||
|
* Total Turns
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
total_turns: number;
|
||||||
|
};
|
||||||
/** CatalogEntryView */
|
/** CatalogEntryView */
|
||||||
CatalogEntryView: {
|
CatalogEntryView: {
|
||||||
/** Catalog Entry Id */
|
/** Catalog Entry Id */
|
||||||
|
|
@ -5106,7 +5202,7 @@ export interface components {
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* EvaluationSummary
|
* EvaluationSummary
|
||||||
* @description 회기 평가 조회 응답(분포 + deep 결과 합본).
|
* @description 회기 평가 조회 응답(분포 + deep 결과 합본, provider 오류 원문 제외).
|
||||||
*/
|
*/
|
||||||
EvaluationSummary: {
|
EvaluationSummary: {
|
||||||
/** Deep */
|
/** Deep */
|
||||||
|
|
@ -5120,6 +5216,7 @@ export interface components {
|
||||||
durable: boolean;
|
durable: boolean;
|
||||||
/** Error */
|
/** Error */
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
failure?: components["schemas"]["ReviewEvaluationFailure"] | null;
|
||||||
/** Session Id */
|
/** Session Id */
|
||||||
session_id: string;
|
session_id: string;
|
||||||
/** Stage */
|
/** Stage */
|
||||||
|
|
@ -5843,6 +5940,33 @@ export interface components {
|
||||||
/** Uncertainty */
|
/** Uncertainty */
|
||||||
uncertainty: number;
|
uncertainty: number;
|
||||||
};
|
};
|
||||||
|
/** LearnerCaseListResponse */
|
||||||
|
LearnerCaseListResponse: {
|
||||||
|
/** Cases */
|
||||||
|
cases?: components["schemas"]["LearnerCaseSummary"][];
|
||||||
|
/**
|
||||||
|
* Source
|
||||||
|
* @default database
|
||||||
|
* @constant
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
source: "database";
|
||||||
|
};
|
||||||
|
/** LearnerCaseSummary */
|
||||||
|
LearnerCaseSummary: {
|
||||||
|
/** Case Id */
|
||||||
|
case_id: string;
|
||||||
|
/**
|
||||||
|
* Last Session No
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
last_session_no: number;
|
||||||
|
/** Persona Code */
|
||||||
|
persona_code: string;
|
||||||
|
/** Persona Name */
|
||||||
|
persona_name: string;
|
||||||
|
progress?: components["schemas"]["CaseProgressStats"];
|
||||||
|
};
|
||||||
/** LearnerDashboardAchievement */
|
/** LearnerDashboardAchievement */
|
||||||
LearnerDashboardAchievement: {
|
LearnerDashboardAchievement: {
|
||||||
/** Detail */
|
/** Detail */
|
||||||
|
|
@ -6117,6 +6241,11 @@ export interface components {
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
/** Archived At */
|
/** Archived At */
|
||||||
archived_at?: string | null;
|
archived_at?: string | null;
|
||||||
|
/**
|
||||||
|
* Case Id
|
||||||
|
* @default
|
||||||
|
*/
|
||||||
|
case_id: string;
|
||||||
/** Client Turn Count */
|
/** Client Turn Count */
|
||||||
client_turn_count: number;
|
client_turn_count: number;
|
||||||
/** Ended At */
|
/** Ended At */
|
||||||
|
|
@ -8899,6 +9028,19 @@ export interface components {
|
||||||
/** Persona */
|
/** Persona */
|
||||||
persona: string;
|
persona: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* ReviewEvaluationFailure
|
||||||
|
* @description 교수자용 deep-loop 실패 분류. 원문 예외·축어록은 절대 응답에 넣지 않는다.
|
||||||
|
*/
|
||||||
|
ReviewEvaluationFailure: {
|
||||||
|
/**
|
||||||
|
* Code
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
code: "timeout" | "engine_unavailable" | "legacy_argv_limit" | "prompt_too_large" | "invalid_structured_output" | "missing_evaluation" | "unknown";
|
||||||
|
/** Retryable */
|
||||||
|
retryable: boolean;
|
||||||
|
};
|
||||||
/** ReviewFirstSessionChecklist */
|
/** ReviewFirstSessionChecklist */
|
||||||
ReviewFirstSessionChecklist: {
|
ReviewFirstSessionChecklist: {
|
||||||
/**
|
/**
|
||||||
|
|
@ -9546,6 +9688,7 @@ export interface components {
|
||||||
durationLabel: string;
|
durationLabel: string;
|
||||||
/** Durationseconds */
|
/** Durationseconds */
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
|
evaluationFailure?: components["schemas"]["ReviewEvaluationFailure"] | null;
|
||||||
firstSessionChecklist?: components["schemas"]["ReviewFirstSessionChecklist"] | null;
|
firstSessionChecklist?: components["schemas"]["ReviewFirstSessionChecklist"] | null;
|
||||||
/** Goodmoments */
|
/** Goodmoments */
|
||||||
goodMoments?: components["schemas"]["ReviewPoint"][];
|
goodMoments?: components["schemas"]["ReviewPoint"][];
|
||||||
|
|
@ -9640,6 +9783,8 @@ export interface components {
|
||||||
};
|
};
|
||||||
/** SessionStartRequest */
|
/** SessionStartRequest */
|
||||||
SessionStartRequest: {
|
SessionStartRequest: {
|
||||||
|
/** Case Id */
|
||||||
|
case_id?: string | null;
|
||||||
/** Goal Stages */
|
/** Goal Stages */
|
||||||
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
|
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
|
||||||
/**
|
/**
|
||||||
|
|
@ -9647,6 +9792,12 @@ export interface components {
|
||||||
* @example P1
|
* @example P1
|
||||||
*/
|
*/
|
||||||
persona_code: string;
|
persona_code: string;
|
||||||
|
/**
|
||||||
|
* Start Mode
|
||||||
|
* @default continue
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
start_mode: "continue" | "fresh";
|
||||||
/**
|
/**
|
||||||
* Theory Mode
|
* Theory Mode
|
||||||
* @default humanistic
|
* @default humanistic
|
||||||
|
|
@ -9692,6 +9843,12 @@ export interface components {
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
stage: "라포" | "탐색" | "개입" | "정리";
|
stage: "라포" | "탐색" | "개입" | "정리";
|
||||||
|
/**
|
||||||
|
* Start Mode
|
||||||
|
* @default continue
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
start_mode: "continue" | "fresh";
|
||||||
/**
|
/**
|
||||||
* Started At
|
* Started At
|
||||||
* @default
|
* @default
|
||||||
|
|
@ -14486,6 +14643,74 @@ export interface operations {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
list_learner_cases_sessions_cases_get: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
persona_code: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: {
|
||||||
|
"__Host-vignette_sid"?: string | null;
|
||||||
|
vignette_sid?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["LearnerCaseListResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_learner_case_memory_preview_sessions_cases__case_id__memory_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
case_id: string;
|
||||||
|
};
|
||||||
|
cookie?: {
|
||||||
|
"__Host-vignette_sid"?: string | null;
|
||||||
|
vignette_sid?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CaseMemoryPreview"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
learner_dashboard_sessions_dashboard_get: {
|
learner_dashboard_sessions_dashboard_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,7 @@ export type PersonaDraftGenerateResponse = ApiSchema<"PersonaDraftGenerateRespon
|
||||||
|
|
||||||
/** POST /sessions — sessions.py SessionStartResponse */
|
/** POST /sessions — sessions.py SessionStartResponse */
|
||||||
export type SessionStartResponse = ApiSchema<"SessionStartResponse">;
|
export type SessionStartResponse = ApiSchema<"SessionStartResponse">;
|
||||||
|
export type SessionStartMode = ApiSchema<"SessionStartRequest">["start_mode"];
|
||||||
/** P2 단계 누적 게이지·상세 수치 — session_read_model.SessionProgress */
|
/** P2 단계 누적 게이지·상세 수치 — session_read_model.SessionProgress */
|
||||||
export type SessionProgress = ApiSchema<"SessionProgress">;
|
export type SessionProgress = ApiSchema<"SessionProgress">;
|
||||||
export type SessionStageProgress = ApiSchema<"SessionStageProgress">;
|
export type SessionStageProgress = ApiSchema<"SessionStageProgress">;
|
||||||
|
|
@ -248,6 +249,10 @@ export type LearnerSessionSummary = ApiSchema<"LearnerSessionSummary">;
|
||||||
export type SessionArchiveResponse = ApiSchema<"SessionArchiveResponse">;
|
export type SessionArchiveResponse = ApiSchema<"SessionArchiveResponse">;
|
||||||
|
|
||||||
export type LearnerSessionsResponse = ApiSchema<"LearnerSessionsResponse">;
|
export type LearnerSessionsResponse = ApiSchema<"LearnerSessionsResponse">;
|
||||||
|
export type CaseProgressStats = ApiSchema<"CaseProgressStats">;
|
||||||
|
export type LearnerCaseSummary = ApiSchema<"LearnerCaseSummary">;
|
||||||
|
export type LearnerCaseListResponse = ApiSchema<"LearnerCaseListResponse">;
|
||||||
|
export type CaseMemoryPreview = ApiSchema<"CaseMemoryPreview">;
|
||||||
export type LearnerDashboardResponse = ApiSchema<"LearnerDashboardResponse">;
|
export type LearnerDashboardResponse = ApiSchema<"LearnerDashboardResponse">;
|
||||||
export type LearnerDashboardPersonaProgress = ApiSchema<"LearnerDashboardPersonaProgress">;
|
export type LearnerDashboardPersonaProgress = ApiSchema<"LearnerDashboardPersonaProgress">;
|
||||||
export type LearnerDashboardAchievement = ApiSchema<"LearnerDashboardAchievement">;
|
export type LearnerDashboardAchievement = ApiSchema<"LearnerDashboardAchievement">;
|
||||||
|
|
@ -269,6 +274,7 @@ export type ReviewPhaseSegment = ApiSchema<"ReviewPhaseSegment">;
|
||||||
export type ReviewValencePoint = ApiSchema<"ReviewValencePoint">;
|
export type ReviewValencePoint = ApiSchema<"ReviewValencePoint">;
|
||||||
export type ReviewRubricRow = ApiSchema<"ReviewRubricRow">;
|
export type ReviewRubricRow = ApiSchema<"ReviewRubricRow">;
|
||||||
export type ReviewPoint = ApiSchema<"ReviewPoint">;
|
export type ReviewPoint = ApiSchema<"ReviewPoint">;
|
||||||
|
export type ReviewEvaluationFailure = ApiSchema<"ReviewEvaluationFailure">;
|
||||||
export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">;
|
export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">;
|
||||||
|
|
||||||
export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">;
|
export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">;
|
||||||
|
|
@ -537,12 +543,27 @@ export const sessionApi = {
|
||||||
},
|
},
|
||||||
get: (sessionId: string) =>
|
get: (sessionId: string) =>
|
||||||
api.get<SessionDetailResponse>(`/sessions/${encodeURIComponent(sessionId)}`),
|
api.get<SessionDetailResponse>(`/sessions/${encodeURIComponent(sessionId)}`),
|
||||||
|
cases: (personaCode: string) =>
|
||||||
|
api.get<LearnerCaseListResponse>(
|
||||||
|
`/sessions/cases?persona_code=${encodeURIComponent(personaCode)}`,
|
||||||
|
),
|
||||||
|
caseMemory: (caseId: string) =>
|
||||||
|
api.get<CaseMemoryPreview>(
|
||||||
|
`/sessions/cases/${encodeURIComponent(caseId)}/memory`,
|
||||||
|
),
|
||||||
start: (
|
start: (
|
||||||
persona_code: string,
|
persona_code: string,
|
||||||
theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic",
|
theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic",
|
||||||
goal_stages: SessionStage[] = [],
|
goal_stages: SessionStage[] = [],
|
||||||
|
options: { startMode?: SessionStartMode; caseId?: string } = {},
|
||||||
) =>
|
) =>
|
||||||
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode, goal_stages }),
|
api.post<SessionStartResponse>("/sessions", {
|
||||||
|
persona_code,
|
||||||
|
theory_mode,
|
||||||
|
goal_stages,
|
||||||
|
start_mode: options.startMode ?? "continue",
|
||||||
|
case_id: options.caseId ?? null,
|
||||||
|
}),
|
||||||
turn: (sessionId: string, text: string) =>
|
turn: (sessionId: string, text: string) =>
|
||||||
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
|
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
|
||||||
liveCoach: (sessionId: string, payload: LiveCoachRequest) =>
|
liveCoach: (sessionId: string, payload: LiveCoachRequest) =>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
AUTH_EXPIRED_EVENT,
|
AUTH_EXPIRED_EVENT,
|
||||||
ApiError,
|
ApiError,
|
||||||
api,
|
api,
|
||||||
apiUrl,
|
|
||||||
authApi,
|
authApi,
|
||||||
type MeResponse,
|
type MeResponse,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
@ -172,7 +171,8 @@ function userFromMe(me: MeResponse): AuthUser {
|
||||||
onboardingCompletedAt: onboarding.onboarding_completed_at ?? null,
|
onboardingCompletedAt: onboarding.onboarding_completed_at ?? null,
|
||||||
nickname,
|
nickname,
|
||||||
selfIntroduction: (me.self_introduction ?? "").trim(),
|
selfIntroduction: (me.self_introduction ?? "").trim(),
|
||||||
avatarUrl: avatarUrl ? apiUrl(avatarUrl) : "",
|
// 이미지 URL의 scheme·상대경로 검증은 실제 렌더 경계(ResilientImage)가 소유한다.
|
||||||
|
avatarUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
Kicker,
|
Kicker,
|
||||||
ProgressBar,
|
ProgressBar,
|
||||||
surfaceClassName,
|
surfaceClassName,
|
||||||
|
Tabs,
|
||||||
} from "../components/ui";
|
} from "../components/ui";
|
||||||
import {
|
import {
|
||||||
adminApi,
|
adminApi,
|
||||||
|
|
@ -2076,25 +2077,28 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
|
|
||||||
{usersError ? <InlineError message={usersError} /> : null}
|
{usersError ? <InlineError message={usersError} /> : null}
|
||||||
|
|
||||||
<TabBar
|
<Tabs<UserTab>
|
||||||
|
id="admin-users-tabs"
|
||||||
ariaLabel="사용자 관리 탭"
|
ariaLabel="사용자 관리 탭"
|
||||||
items={[
|
items={[
|
||||||
[
|
{
|
||||||
"approval",
|
value: "approval",
|
||||||
`가입 승인${accountCounts.pending ? ` ${accountCounts.pending}` : ""}`,
|
label: `가입 승인${accountCounts.pending ? ` ${accountCounts.pending}` : ""}`,
|
||||||
],
|
},
|
||||||
["manage", "사용자 목록"],
|
{ value: "manage", label: "사용자 목록" },
|
||||||
["register", "외부 연구참여자 사전등록"],
|
{ value: "register", label: "외부 연구참여자 사전등록" },
|
||||||
["activity", "활동 요약"],
|
{ value: "activity", label: "활동 요약" },
|
||||||
]}
|
]}
|
||||||
value={userTab}
|
value={userTab}
|
||||||
onChange={setUserTab}
|
onValueChange={setUserTab}
|
||||||
/>
|
listClassName="vgops-tabs"
|
||||||
|
panelClassName="vgops-tabs__panel"
|
||||||
|
>
|
||||||
{userTab === "approval" ? renderApprovalQueue() : null}
|
{userTab === "approval" ? renderApprovalQueue() : null}
|
||||||
{userTab === "manage" ? renderUserList() : null}
|
{userTab === "manage" ? renderUserList() : null}
|
||||||
{userTab === "register" ? renderUserCreate() : null}
|
{userTab === "register" ? renderUserCreate() : null}
|
||||||
{userTab === "activity" ? renderUserActivity() : null}
|
{userTab === "activity" ? renderUserActivity() : null}
|
||||||
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -2171,10 +2175,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
{pendingUsers.length > 0 ? (
|
{pendingUsers.length > 0 ? (
|
||||||
<div className={surfaceClassName("vgops-approval-list")}>
|
<div className={surfaceClassName("vgops-approval-list")}>
|
||||||
{pendingUsers.map((user) => (
|
{pendingUsers.map((user) => (
|
||||||
<article
|
<article className="vgops-approval" key={user.user_id}>
|
||||||
className={surfaceClassName("vgops-approval", { variant: "inset" })}
|
|
||||||
key={user.user_id}
|
|
||||||
>
|
|
||||||
<div className="vgops-user__id">
|
<div className="vgops-user__id">
|
||||||
<span aria-hidden="true">{initialOf(user)}</span>
|
<span aria-hidden="true">{initialOf(user)}</span>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -2869,18 +2870,20 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
기준을 먼저 고정합니다.
|
기준을 먼저 고정합니다.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<TabBar
|
<Tabs<AccessTab>
|
||||||
|
id="admin-access-tabs"
|
||||||
ariaLabel="접근 권한 탭"
|
ariaLabel="접근 권한 탭"
|
||||||
items={[
|
items={[
|
||||||
["roles", "역할"],
|
{ value: "roles", label: "역할" },
|
||||||
["groups", "그룹"],
|
{ value: "groups", label: "그룹" },
|
||||||
["matrix", "권한 매트릭스"],
|
{ value: "matrix", label: "권한 매트릭스" },
|
||||||
["protocols", "상담 프로토콜"],
|
{ value: "protocols", label: "상담 프로토콜" },
|
||||||
]}
|
]}
|
||||||
value={accessTab}
|
value={accessTab}
|
||||||
onChange={setAccessTab}
|
onValueChange={setAccessTab}
|
||||||
/>
|
listClassName="vgops-tabs"
|
||||||
|
panelClassName="vgops-tabs__panel"
|
||||||
|
>
|
||||||
{accessTab === "roles" ? (
|
{accessTab === "roles" ? (
|
||||||
<section className="vgops-policy-grid">
|
<section className="vgops-policy-grid">
|
||||||
{ROLE_POLICIES.map((policy) => (
|
{ROLE_POLICIES.map((policy) => (
|
||||||
|
|
@ -2954,6 +2957,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{accessTab === "protocols" ? renderProtocols() : null}
|
{accessTab === "protocols" ? renderProtocols() : null}
|
||||||
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -3125,12 +3129,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
duplicateParentId !== ticket.ticket_id &&
|
duplicateParentId !== ticket.ticket_id &&
|
||||||
duplicateParentId !== ticket.parent_ticket_id;
|
duplicateParentId !== ticket.parent_ticket_id;
|
||||||
return (
|
return (
|
||||||
<article
|
<article className="vgops-ticket" key={ticket.ticket_id}>
|
||||||
className={surfaceClassName("vgops-ticket", {
|
|
||||||
variant: "inset",
|
|
||||||
})}
|
|
||||||
key={ticket.ticket_id}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
{/* 담당 그룹·처리 이력은 아래 <small> 에서 가운뎃점으로 이어 붙었었다.
|
{/* 담당 그룹·처리 이력은 아래 <small> 에서 가운뎃점으로 이어 붙었었다.
|
||||||
칸 구분이 있는 meta 행으로 옮겨 <small> 은 가운뎃점 1개만 남긴다. */}
|
칸 구분이 있는 meta 행으로 옮겨 <small> 은 가운뎃점 1개만 남긴다. */}
|
||||||
|
|
@ -3212,9 +3211,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
||||||
>
|
>
|
||||||
{recentResolvedTickets.map((ticket) => (
|
{recentResolvedTickets.map((ticket) => (
|
||||||
<article
|
<article
|
||||||
className={surfaceClassName("vgops-ticket vgops-ticket--history", {
|
className="vgops-ticket vgops-ticket--history"
|
||||||
variant: "inset",
|
|
||||||
})}
|
|
||||||
key={ticket.ticket_id}
|
key={ticket.ticket_id}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -3291,39 +3288,6 @@ function EmptyState({ title, body }: { title: string; body: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabBar<T extends string>({
|
|
||||||
ariaLabel,
|
|
||||||
items,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
ariaLabel: string;
|
|
||||||
items: Array<[T, string]>;
|
|
||||||
value: T;
|
|
||||||
onChange: (value: T) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={surfaceClassName("vgops-tabs", { variant: "inset", flat: true })}
|
|
||||||
role="tablist"
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
>
|
|
||||||
{items.map(([id, label]) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={id}
|
|
||||||
role="tab"
|
|
||||||
aria-selected={value === id}
|
|
||||||
className={value === id ? "is-active" : ""}
|
|
||||||
onClick={() => onChange(id)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RoleMeter({
|
function RoleMeter({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,14 @@ import { Button, Icon, Kicker, surfaceClassName } from "../components/ui";
|
||||||
import {
|
import {
|
||||||
personaApi,
|
personaApi,
|
||||||
sessionApi,
|
sessionApi,
|
||||||
|
type CaseMemoryPreview,
|
||||||
type LearnerDashboardPersonaProgress,
|
type LearnerDashboardPersonaProgress,
|
||||||
type LearnerDashboardResponse,
|
type LearnerDashboardResponse,
|
||||||
|
type LearnerCaseSummary,
|
||||||
type LearnerSessionSummary,
|
type LearnerSessionSummary,
|
||||||
type PersonaSummary,
|
type PersonaSummary,
|
||||||
type SessionDetailResponse,
|
type SessionDetailResponse,
|
||||||
|
type SessionStartMode,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { displayPiiSafeText } from "../lib/piiDisplay";
|
import { displayPiiSafeText } from "../lib/piiDisplay";
|
||||||
import {
|
import {
|
||||||
|
|
@ -71,6 +74,7 @@ import {
|
||||||
import "./learner-home.css";
|
import "./learner-home.css";
|
||||||
|
|
||||||
type LoadState = "loading" | "ready" | "error";
|
type LoadState = "loading" | "ready" | "error";
|
||||||
|
type MemoryLoadState = "idle" | LoadState;
|
||||||
export type LearnerHomeView = "dashboard" | "practice" | "history";
|
export type LearnerHomeView = "dashboard" | "practice" | "history";
|
||||||
|
|
||||||
const DASHBOARD_TABS = [
|
const DASHBOARD_TABS = [
|
||||||
|
|
@ -110,6 +114,14 @@ function oneMiddot(text: string): string {
|
||||||
return `${parts[0]} · ${parts.slice(1).join(", ")}`;
|
return `${parts[0]} · ${parts.slice(1).join(", ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCaseDuration(totalSeconds: number): string {
|
||||||
|
const safeSeconds = Math.max(0, Math.floor(totalSeconds));
|
||||||
|
const hours = Math.floor(safeSeconds / 3600);
|
||||||
|
const minutes = Math.floor((safeSeconds % 3600) / 60);
|
||||||
|
if (hours > 0) return `${hours}시간 ${minutes}분`;
|
||||||
|
return `${minutes}분`;
|
||||||
|
}
|
||||||
|
|
||||||
interface LearnerHomeProps {
|
interface LearnerHomeProps {
|
||||||
view?: LearnerHomeView;
|
view?: LearnerHomeView;
|
||||||
}
|
}
|
||||||
|
|
@ -142,6 +154,20 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [recapLoadState, setRecapLoadState] = useState<LoadState>("loading");
|
const [recapLoadState, setRecapLoadState] = useState<LoadState>("loading");
|
||||||
|
// 새 사례와 이어지는 사례는 같은 NPC라도 case_id가 다르다. Home은 시작 전
|
||||||
|
// 선택만 담당하고, 기억 본문은 사용자가 foldout을 열 때까지 요청하지 않는다.
|
||||||
|
const [launchMode, setLaunchMode] = useState<SessionStartMode>("fresh");
|
||||||
|
const [continuityCases, setContinuityCases] = useState<LearnerCaseSummary[]>([]);
|
||||||
|
const [selectedContinuityCaseId, setSelectedContinuityCaseId] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [continuityLoadState, setContinuityLoadState] =
|
||||||
|
useState<LoadState>("loading");
|
||||||
|
const [memoryPreview, setMemoryPreview] = useState<CaseMemoryPreview | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [memoryLoadState, setMemoryLoadState] =
|
||||||
|
useState<MemoryLoadState>("idle");
|
||||||
const [historyFilter, setHistoryFilter] = useState<HistoryFilter>("all");
|
const [historyFilter, setHistoryFilter] = useState<HistoryFilter>("all");
|
||||||
const [historyQuery, setHistoryQuery] = useState("");
|
const [historyQuery, setHistoryQuery] = useState("");
|
||||||
const [archiveBusyId, setArchiveBusyId] = useState<string | null>(null);
|
const [archiveBusyId, setArchiveBusyId] = useState<string | null>(null);
|
||||||
|
|
@ -270,6 +296,101 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
null,
|
null,
|
||||||
[selectedCode, usablePersonas],
|
[selectedCode, usablePersonas],
|
||||||
);
|
);
|
||||||
|
const caseModeAvailable =
|
||||||
|
view === "practice" && !voicePracticeRequested && !practiceLaunchRequested;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLaunchMode("fresh");
|
||||||
|
setSelectedContinuityCaseId(null);
|
||||||
|
setMemoryPreview(null);
|
||||||
|
setMemoryLoadState("idle");
|
||||||
|
}, [selected?.code]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
if (!caseModeAvailable || !selected || !isUsablePersona(selected)) {
|
||||||
|
setContinuityCases([]);
|
||||||
|
setContinuityLoadState("ready");
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setContinuityLoadState("loading");
|
||||||
|
setMemoryPreview(null);
|
||||||
|
setMemoryLoadState("idle");
|
||||||
|
sessionApi
|
||||||
|
.cases(selected.code)
|
||||||
|
.then((response) => {
|
||||||
|
if (!alive) return;
|
||||||
|
const nextCases = response.cases ?? [];
|
||||||
|
setContinuityCases(nextCases);
|
||||||
|
setSelectedContinuityCaseId((current) =>
|
||||||
|
current && nextCases.some((item) => item.case_id === current)
|
||||||
|
? current
|
||||||
|
: (nextCases[0]?.case_id ?? null),
|
||||||
|
);
|
||||||
|
setContinuityLoadState("ready");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!alive) return;
|
||||||
|
console.warn("[learner-home] failed to load case continuity", error);
|
||||||
|
setContinuityCases([]);
|
||||||
|
setSelectedContinuityCaseId(null);
|
||||||
|
setContinuityLoadState("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [caseModeAvailable, selected]);
|
||||||
|
|
||||||
|
const activeContinuityCase =
|
||||||
|
continuityCases.find((item) => item.progress?.active_session_id != null) ??
|
||||||
|
null;
|
||||||
|
const selectedContinuityCase =
|
||||||
|
activeContinuityCase ??
|
||||||
|
continuityCases.find((item) => item.case_id === selectedContinuityCaseId) ??
|
||||||
|
continuityCases[0] ??
|
||||||
|
null;
|
||||||
|
const selectedContinuityProgress = selectedContinuityCase?.progress ?? {
|
||||||
|
total_sessions: 0,
|
||||||
|
completed_sessions: 0,
|
||||||
|
total_turns: 0,
|
||||||
|
total_duration_seconds: 0,
|
||||||
|
active_session_id: null,
|
||||||
|
active_session_no: null,
|
||||||
|
active_started_at: null,
|
||||||
|
last_activity_at: null,
|
||||||
|
};
|
||||||
|
const activeCaseSessionId =
|
||||||
|
activeContinuityCase?.progress?.active_session_id ?? null;
|
||||||
|
const continuationAvailable =
|
||||||
|
continuityLoadState === "ready" &&
|
||||||
|
selectedContinuityCase != null &&
|
||||||
|
activeCaseSessionId == null;
|
||||||
|
|
||||||
|
const loadContinuityMemory = () => {
|
||||||
|
if (!selectedContinuityCase || memoryLoadState === "loading") return;
|
||||||
|
if (memoryPreview?.case_id === selectedContinuityCase.case_id) return;
|
||||||
|
setMemoryLoadState("loading");
|
||||||
|
sessionApi
|
||||||
|
.caseMemory(selectedContinuityCase.case_id)
|
||||||
|
.then((response) => {
|
||||||
|
setMemoryPreview(response);
|
||||||
|
setMemoryLoadState("ready");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn("[learner-home] failed to load case memory preview", error);
|
||||||
|
setMemoryPreview(null);
|
||||||
|
setMemoryLoadState("error");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const selectContinuityCase = (caseId: string) => {
|
||||||
|
setSelectedContinuityCaseId(caseId);
|
||||||
|
setMemoryPreview(null);
|
||||||
|
setMemoryLoadState("idle");
|
||||||
|
};
|
||||||
const sortedSessions = useMemo(
|
const sortedSessions = useMemo(
|
||||||
() => [...sessions].sort((a, b) => sessionSortTime(b) - sessionSortTime(a)),
|
() => [...sessions].sort((a, b) => sessionSortTime(b) - sessionSortTime(a)),
|
||||||
[sessions],
|
[sessions],
|
||||||
|
|
@ -477,12 +598,23 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
if (selected && isUsablePersona(selected)) {
|
if (selected && isUsablePersona(selected)) {
|
||||||
if (voicePracticeRequested && !voicePracticeContext) return;
|
if (voicePracticeRequested && !voicePracticeContext) return;
|
||||||
if (practiceLaunchRequested && !practiceLaunchIntent) return;
|
if (practiceLaunchRequested && !practiceLaunchIntent) return;
|
||||||
const suffix = practiceLaunchIntent
|
if (caseModeAvailable && activeCaseSessionId) {
|
||||||
? `?${practiceLaunchSearch(practiceLaunchIntent)}`
|
navigate(`/learn/session/${encodeURIComponent(activeCaseSessionId)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = practiceLaunchIntent
|
||||||
|
? new URLSearchParams(practiceLaunchSearch(practiceLaunchIntent))
|
||||||
: voicePracticeContext
|
: voicePracticeContext
|
||||||
? `?${voicePracticeSearch(voicePracticeContext)}`
|
? new URLSearchParams(voicePracticeSearch(voicePracticeContext))
|
||||||
: "";
|
: new URLSearchParams();
|
||||||
navigate(`/learn/session/${selected.code}${suffix}`);
|
if (caseModeAvailable) {
|
||||||
|
params.set("continuity", launchMode);
|
||||||
|
if (launchMode === "continue" && selectedContinuityCase) {
|
||||||
|
params.set("case", selectedContinuityCase.case_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const suffix = params.toString();
|
||||||
|
navigate(`/learn/session/${selected.code}${suffix ? `?${suffix}` : ""}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -847,15 +979,22 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
: "기록"}
|
: "기록"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{session.status === "ended" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="lh-review-link lh-review-link--ghost"
|
className="lh-review-link lh-review-link--ghost"
|
||||||
onClick={() =>
|
title="새 회기를 만들고 이전 회기의 맥락을 이어갑니다."
|
||||||
navigate(`/learn/session/${session.persona_code}`)
|
onClick={() => {
|
||||||
}
|
const params = new URLSearchParams({ continuity: "continue" });
|
||||||
|
if (session.case_id) params.set("case", session.case_id);
|
||||||
|
navigate(
|
||||||
|
`/learn/session/${session.persona_code}?${params.toString()}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
다시 연습
|
다음 회기 이어가기
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
{session.status === "ended" ? (
|
{session.status === "ended" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1258,7 +1397,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div role="radiogroup" aria-label="회기 방식">
|
||||||
<span>상담 진행</span>
|
<span>상담 진행</span>
|
||||||
<b>
|
<b>
|
||||||
{spotlightSession ? `${recapProgress}%` : "0%"}
|
{spotlightSession ? `${recapProgress}%` : "0%"}
|
||||||
|
|
@ -1823,9 +1962,47 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lh-actions">
|
<div className="lh-actions">
|
||||||
|
{caseModeAvailable ? (
|
||||||
|
<fieldset className="lh-launch-mode">
|
||||||
|
<legend>회기 방식</legend>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="session-launch-mode"
|
||||||
|
checked={launchMode === "fresh"}
|
||||||
|
disabled={activeCaseSessionId != null}
|
||||||
|
onChange={() => setLaunchMode("fresh")}
|
||||||
|
/>
|
||||||
|
<span>완전히 새로 시작</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="session-launch-mode"
|
||||||
|
checked={launchMode === "continue"}
|
||||||
|
disabled={
|
||||||
|
activeCaseSessionId != null || !continuationAvailable
|
||||||
|
}
|
||||||
|
onChange={() => setLaunchMode("continue")}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{continuityLoadState === "loading"
|
||||||
|
? "기록 확인 중"
|
||||||
|
: "이어서 진행"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
) : null}
|
||||||
<span className="lh-actions__note">
|
<span className="lh-actions__note">
|
||||||
회기 종료 후 저장된 대화 기록으로 리뷰와 코칭이
|
{activeCaseSessionId
|
||||||
생성됩니다.
|
? "진행 중인 회기가 있습니다. 같은 내담자의 새 사례는 이 회기를 마친 뒤 시작할 수 있습니다."
|
||||||
|
: caseModeAvailable && launchMode === "fresh"
|
||||||
|
? "새 사례는 이전 회기의 기억과 누적 기록을 가져오지 않습니다."
|
||||||
|
: caseModeAvailable
|
||||||
|
? "선택한 사례의 압축 기억과 누적 기록을 이어받습니다."
|
||||||
|
: "회기 종료 후 저장된 대화 기록으로 리뷰와 코칭이 생성됩니다."}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
@ -1833,15 +2010,189 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
||||||
disabled={
|
disabled={
|
||||||
!selected ||
|
!selected ||
|
||||||
(voicePracticeRequested && !voicePracticeContext) ||
|
(voicePracticeRequested && !voicePracticeContext) ||
|
||||||
(practiceLaunchRequested && !practiceLaunchIntent)
|
(practiceLaunchRequested && !practiceLaunchIntent) ||
|
||||||
|
(caseModeAvailable &&
|
||||||
|
activeCaseSessionId == null &&
|
||||||
|
launchMode === "continue" &&
|
||||||
|
!continuationAvailable)
|
||||||
}
|
}
|
||||||
trailing={<Icon name="chevron-right" size={18} />}
|
trailing={<Icon name="chevron-right" size={18} />}
|
||||||
>
|
>
|
||||||
새 회기 시작
|
{activeCaseSessionId
|
||||||
|
? "진행 중인 회기 이어가기"
|
||||||
|
: caseModeAvailable && launchMode === "continue"
|
||||||
|
? "이어서 진행"
|
||||||
|
: "완전히 새로 시작"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{caseModeAvailable ? (
|
||||||
|
<section
|
||||||
|
className="lh-continuity"
|
||||||
|
aria-label="회기 연속성"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{activeCaseSessionId ? (
|
||||||
|
<div className="lh-continuity__notice">
|
||||||
|
<Kicker>진행 중인 사례</Kicker>
|
||||||
|
<p>
|
||||||
|
현재 {selected?.code} 회기가 열려 있습니다. 위 버튼으로
|
||||||
|
그 회기를 이어가면 됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!activeCaseSessionId && launchMode === "fresh" ? (
|
||||||
|
<div className="lh-continuity__notice">
|
||||||
|
<Kicker>새 사례</Kicker>
|
||||||
|
<p>
|
||||||
|
이전 회기의 요약, 미해결 주제, 고정 기억을 가져오지 않고
|
||||||
|
1회기부터 시작합니다. 기존 사례 기록은 보존됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{(activeCaseSessionId || launchMode === "continue") &&
|
||||||
|
continuityLoadState === "loading" ? (
|
||||||
|
<p className="lh-continuity__status">
|
||||||
|
이 내담자의 이어지는 사례를 확인하고 있습니다.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{(activeCaseSessionId || launchMode === "continue") &&
|
||||||
|
continuityLoadState === "error" ? (
|
||||||
|
<p className="lh-continuity__status is-error" role="status">
|
||||||
|
연속 사례 통계를 확인하지 못했습니다. 이어가기는 열지 않았고,
|
||||||
|
완전히 새로 시작은 계속할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{(activeCaseSessionId || launchMode === "continue") &&
|
||||||
|
continuityLoadState === "ready" &&
|
||||||
|
selectedContinuityCase ? (
|
||||||
|
<>
|
||||||
|
{continuityCases.length > 1 ? (
|
||||||
|
<label className="lh-continuity__case-select">
|
||||||
|
<span>이어서 진행할 사례</span>
|
||||||
|
<select
|
||||||
|
aria-label="이어서 진행할 사례"
|
||||||
|
value={selectedContinuityCase.case_id}
|
||||||
|
disabled={activeCaseSessionId != null}
|
||||||
|
onChange={(event) =>
|
||||||
|
selectContinuityCase(event.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{continuityCases.map((item, index) => {
|
||||||
|
const progress = item.progress ?? selectedContinuityProgress;
|
||||||
|
return (
|
||||||
|
<option key={item.case_id} value={item.case_id}>
|
||||||
|
{index === 0 ? "가장 최근 사례" : `이전 사례 ${index}`} · {progress.total_sessions}회기 · {progress.total_turns}턴
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<div className="lh-continuity__head">
|
||||||
|
<div>
|
||||||
|
<Kicker>이어서 진행할 사례</Kicker>
|
||||||
|
<p>
|
||||||
|
이 사례에서 쌓인 기록만 다음 회기에 이어집니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span>다음 {selectedContinuityCase.last_session_no + 1}회기</span>
|
||||||
|
</div>
|
||||||
|
<div className="lh-continuity__stats">
|
||||||
|
<span>
|
||||||
|
<b>{selectedContinuityProgress.total_sessions}회기</b>
|
||||||
|
<small>총 회기</small>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<b>{selectedContinuityProgress.total_turns}턴</b>
|
||||||
|
<small>대화 턴</small>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<b>
|
||||||
|
{formatCaseDuration(
|
||||||
|
selectedContinuityProgress.total_duration_seconds,
|
||||||
|
)}
|
||||||
|
</b>
|
||||||
|
<small>누적 회기 시간</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<details
|
||||||
|
className="lh-continuity__memory"
|
||||||
|
onToggle={(event) => {
|
||||||
|
if (event.currentTarget.open) loadContinuityMemory();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<summary>
|
||||||
|
<span>내담자 기억</span>
|
||||||
|
<b>필요할 때만 펼쳐 보기</b>
|
||||||
|
<Icon name="chevron-right" size={16} />
|
||||||
|
</summary>
|
||||||
|
<div className="lh-continuity__memory-body">
|
||||||
|
{memoryLoadState === "loading" ||
|
||||||
|
memoryPreview?.case_id !== selectedContinuityCase.case_id ? (
|
||||||
|
<p>기억 요약을 불러오고 있습니다.</p>
|
||||||
|
) : memoryLoadState === "error" ? (
|
||||||
|
<p role="status">
|
||||||
|
기억 요약을 불러오지 못했습니다. 원문 대화는 표시하지
|
||||||
|
않습니다.
|
||||||
|
</p>
|
||||||
|
) : memoryPreview?.memory_available ? (
|
||||||
|
<dl>
|
||||||
|
{memoryPreview.latest_session_digest ? (
|
||||||
|
<div>
|
||||||
|
<dt>지난 회기 요약</dt>
|
||||||
|
<dd>{memoryPreview.latest_session_digest}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{memoryPreview.case_digest ? (
|
||||||
|
<div>
|
||||||
|
<dt>사례의 큰 흐름</dt>
|
||||||
|
<dd>{memoryPreview.case_digest}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{(memoryPreview.open_threads ?? []).length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<dt>이어 볼 주제</dt>
|
||||||
|
<dd>
|
||||||
|
<ul>
|
||||||
|
{(memoryPreview.open_threads ?? []).map((thread) => (
|
||||||
|
<li key={thread}>{thread}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{(memoryPreview.pinned_facts ?? []).length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<dt>계속 기억하는 내용</dt>
|
||||||
|
<dd>
|
||||||
|
<ul>
|
||||||
|
{(memoryPreview.pinned_facts ?? []).map((fact) => (
|
||||||
|
<li key={fact}>{fact}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
이 사례에는 다음 회기에 넘길 압축 기억이 아직 없습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
) : (activeCaseSessionId || launchMode === "continue") &&
|
||||||
|
continuityLoadState === "ready" ? (
|
||||||
|
<p className="lh-continuity__status">
|
||||||
|
이어갈 사례가 없습니다. 완전히 새로 시작할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={surfaceClassName("lh-summary", {
|
className={surfaceClassName("lh-summary", {
|
||||||
variant: "inset",
|
variant: "inset",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "../components/ui";
|
import { Button, Icon } from "../components/ui";
|
||||||
import { surfaceClassName } from "../components/ui/Surface";
|
import { surfaceClassName } from "../components/ui/Surface";
|
||||||
import { AuthShell } from "../components/auth/AuthShell";
|
import { AuthShell } from "../components/auth/AuthShell";
|
||||||
|
import { ResilientImage } from "../components/avatar/ResilientImage";
|
||||||
import { roleHomePath, useAuth } from "../lib/auth";
|
import { roleHomePath, useAuth } from "../lib/auth";
|
||||||
import { apiUrl, userApi, type LegalDocumentsResponse, type UserProfileResponse } from "../lib/api";
|
import { userApi, type LegalDocumentsResponse, type UserProfileResponse } from "../lib/api";
|
||||||
import "./onboarding.css";
|
import "./onboarding.css";
|
||||||
|
|
||||||
interface OnboardingForm {
|
interface OnboardingForm {
|
||||||
|
|
@ -59,7 +60,7 @@ function profileToForm(profile: UserProfileResponse | null): OnboardingForm {
|
||||||
|
|
||||||
export default function Onboarding() {
|
export default function Onboarding() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, refresh } = useAuth();
|
const { user, logout, refresh } = useAuth();
|
||||||
const [docs, setDocs] = useState<LegalDocumentsResponse | null>(null);
|
const [docs, setDocs] = useState<LegalDocumentsResponse | null>(null);
|
||||||
const [form, setForm] = useState<OnboardingForm>(EMPTY_FORM);
|
const [form, setForm] = useState<OnboardingForm>(EMPTY_FORM);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -105,12 +106,17 @@ export default function Onboarding() {
|
||||||
[form],
|
[form],
|
||||||
);
|
);
|
||||||
|
|
||||||
const avatarSrc = form.avatar_url ? apiUrl(form.avatar_url) : "";
|
const avatarSrc = form.avatar_url;
|
||||||
|
|
||||||
const update = <K extends keyof OnboardingForm>(key: K, value: OnboardingForm[K]) => {
|
const update = <K extends keyof OnboardingForm>(key: K, value: OnboardingForm[K]) => {
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const signOut = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
const uploadAvatar = async (file: File | null) => {
|
const uploadAvatar = async (file: File | null) => {
|
||||||
if (!file || uploadingAvatar) return;
|
if (!file || uploadingAvatar) return;
|
||||||
setUploadingAvatar(true);
|
setUploadingAvatar(true);
|
||||||
|
|
@ -162,6 +168,21 @@ export default function Onboarding() {
|
||||||
<>
|
<>
|
||||||
<AuthShell className="ob-page">
|
<AuthShell className="ob-page">
|
||||||
<section className={surfaceClassName("ob-shell")} aria-label="가입 정보 입력">
|
<section className={surfaceClassName("ob-shell")} aria-label="가입 정보 입력">
|
||||||
|
<div className="ob-account" aria-label="현재 로그인 계정">
|
||||||
|
<div className="ob-account__identity">
|
||||||
|
<span>현재 Google 계정</span>
|
||||||
|
<strong>{user?.email ?? "계정 확인 중"}</strong>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
leading={<Icon name="logout" size={16} />}
|
||||||
|
onClick={() => void signOut()}
|
||||||
|
>
|
||||||
|
다른 계정으로 로그인
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<header className="ob-head">
|
<header className="ob-head">
|
||||||
<p>Vignette 가입 설정</p>
|
<p>Vignette 가입 설정</p>
|
||||||
<h1>가입 정보를 입력합니다.</h1>
|
<h1>가입 정보를 입력합니다.</h1>
|
||||||
|
|
@ -180,7 +201,11 @@ export default function Onboarding() {
|
||||||
</div>
|
</div>
|
||||||
<div className="ob-avatar">
|
<div className="ob-avatar">
|
||||||
<div className="ob-avatar__preview" aria-hidden="true">
|
<div className="ob-avatar__preview" aria-hidden="true">
|
||||||
{avatarSrc ? <img src={avatarSrc} alt="" /> : <span>{form.nickname.trim().slice(0, 1) || "V"}</span>}
|
<ResilientImage
|
||||||
|
src={avatarSrc}
|
||||||
|
alt=""
|
||||||
|
fallback={form.nickname.trim().slice(0, 1) || "V"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ob-avatar__body">
|
<div className="ob-avatar__body">
|
||||||
<span>상징 아바타</span>
|
<span>상징 아바타</span>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Icon,
|
Icon,
|
||||||
Kicker,
|
Kicker,
|
||||||
surfaceClassName,
|
surfaceClassName,
|
||||||
|
Tabs,
|
||||||
} from "../components/ui";
|
} from "../components/ui";
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
|
|
@ -2758,32 +2759,23 @@ export default function PersonaStudio() {
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div
|
<Tabs<StudioTab>
|
||||||
className={surfaceClassName("ps-tabs", {
|
id="persona-authoring-tabs"
|
||||||
variant: "inset",
|
ariaLabel="저작 섹션"
|
||||||
flat: true,
|
items={STUDIO_TABS.map((tab) => ({
|
||||||
})}
|
value: tab.key,
|
||||||
role="tablist"
|
label: tab.label,
|
||||||
aria-label="저작 섹션"
|
}))}
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
listClassName="ps-tabs"
|
||||||
|
panelClassName="ps-tabs__panel"
|
||||||
>
|
>
|
||||||
{STUDIO_TABS.map((tab) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={tab.key}
|
|
||||||
className={activeTab === tab.key ? "is-active" : ""}
|
|
||||||
onClick={() => setActiveTab(tab.key)}
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === tab.key}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="ps-edit-panel">
|
<Card className="ps-edit-panel">
|
||||||
<GuidancePanel tab={activeTab} />
|
<GuidancePanel tab={activeTab} />
|
||||||
{renderTab()}
|
{renderTab()}
|
||||||
</Card>
|
</Card>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{formError ? (
|
{formError ? (
|
||||||
<p className="ps-status is-error" role="alert">
|
<p className="ps-status is-error" role="alert">
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import {
|
||||||
type SessionDetailResponse,
|
type SessionDetailResponse,
|
||||||
type SessionProgress,
|
type SessionProgress,
|
||||||
type SessionStage,
|
type SessionStage,
|
||||||
|
type SessionStartMode,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { useAuth } from "../lib/auth";
|
import { useAuth } from "../lib/auth";
|
||||||
import { formatElapsed, formatTimecode, clamp01 } from "../lib/format";
|
import { formatElapsed, formatTimecode, clamp01 } from "../lib/format";
|
||||||
|
|
@ -135,6 +136,13 @@ const SESSION_PHASES: PhaseInfo[] = [
|
||||||
{ key: "정리", desc: "오늘의 대화 정리와 다음 약속" },
|
{ key: "정리", desc: "오늘의 대화 정리와 다음 약속" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function primaryGoalActionLabel(goal: SessionStage): string {
|
||||||
|
const lastCode = goal.charCodeAt(goal.length - 1);
|
||||||
|
const hasFinalConsonant =
|
||||||
|
lastCode >= 0xac00 && lastCode <= 0xd7a3 && (lastCode - 0xac00) % 28 !== 0;
|
||||||
|
return `${goal}${hasFinalConsonant ? "을" : "를"} 핵심 초점으로 설정`;
|
||||||
|
}
|
||||||
|
|
||||||
const THEORY_MODE_OPTIONS: {
|
const THEORY_MODE_OPTIONS: {
|
||||||
value: TheoryMode;
|
value: TheoryMode;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -366,6 +374,22 @@ export default function Session() {
|
||||||
() => parsePracticeLaunchIntent(searchParams),
|
() => parsePracticeLaunchIntent(searchParams),
|
||||||
[searchParams],
|
[searchParams],
|
||||||
);
|
);
|
||||||
|
// Home에서 고른 사례 방식만 REST 시작 요청으로 전달한다. 음성 재연습/처방은
|
||||||
|
// 출처 URL 계약을 우선하므로 이 선택기를 덮어쓰지 않는다.
|
||||||
|
const startMode = useMemo<SessionStartMode>(
|
||||||
|
() =>
|
||||||
|
!voicePracticeRequested &&
|
||||||
|
!practiceLaunchRequested &&
|
||||||
|
searchParams.get("continuity") === "fresh"
|
||||||
|
? "fresh"
|
||||||
|
: "continue",
|
||||||
|
[practiceLaunchRequested, searchParams, voicePracticeRequested],
|
||||||
|
);
|
||||||
|
const selectedCaseId = useMemo(() => {
|
||||||
|
if (startMode !== "continue") return undefined;
|
||||||
|
const candidate = searchParams.get("case")?.trim();
|
||||||
|
return candidate || undefined;
|
||||||
|
}, [searchParams, startMode]);
|
||||||
const practiceContextSearch = useMemo(
|
const practiceContextSearch = useMemo(
|
||||||
() =>
|
() =>
|
||||||
practiceLaunchIntent
|
practiceLaunchIntent
|
||||||
|
|
@ -416,8 +440,10 @@ export default function Session() {
|
||||||
const [consentChecked, setConsentChecked] = useState(false);
|
const [consentChecked, setConsentChecked] = useState(false);
|
||||||
const [consentBusy, setConsentBusy] = useState(false);
|
const [consentBusy, setConsentBusy] = useState(false);
|
||||||
const [selectedTheoryMode, setSelectedTheoryMode] = useState<TheoryMode>("humanistic");
|
const [selectedTheoryMode, setSelectedTheoryMode] = useState<TheoryMode>("humanistic");
|
||||||
// 이번 회기 목표(2026-07-13 회의 P1): 4단계 전부가 아니라 1~2개를 고르고 시작한다.
|
// 새 회기는 한 가지 수행 초점에서 시작한다. 처방 재연습은 검증된 라포·탐색 과업을 유지한다.
|
||||||
const [selectedGoals, setSelectedGoals] = useState<SessionStage[]>(["라포", "탐색"]);
|
const [selectedGoals, setSelectedGoals] = useState<SessionStage[]>(
|
||||||
|
practiceLaunchIntent ? ["라포", "탐색"] : ["라포"],
|
||||||
|
);
|
||||||
|
|
||||||
// ── 회기/대화 상태 ──
|
// ── 회기/대화 상태 ──
|
||||||
const [stage, setStage] = useState<SessionStage>("라포");
|
const [stage, setStage] = useState<SessionStage>("라포");
|
||||||
|
|
@ -504,6 +530,9 @@ export default function Session() {
|
||||||
const [leftPanelCollapsed, setLeftPanelCollapsed] = useState(false);
|
const [leftPanelCollapsed, setLeftPanelCollapsed] = useState(false);
|
||||||
const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false);
|
const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false);
|
||||||
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
|
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
|
||||||
|
const shortcutHelpPanelRef = useRef<HTMLElement>(null);
|
||||||
|
const shortcutHelpConfirmRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const shortcutHelpPreviousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
const [ctxCollapsed, setCtxCollapsed] = useState(true);
|
const [ctxCollapsed, setCtxCollapsed] = useState(true);
|
||||||
const [metersCollapsed, setMetersCollapsed] = useState(false);
|
const [metersCollapsed, setMetersCollapsed] = useState(false);
|
||||||
const [safetyCollapsed, setSafetyCollapsed] = useState(true);
|
const [safetyCollapsed, setSafetyCollapsed] = useState(true);
|
||||||
|
|
@ -533,6 +562,10 @@ export default function Session() {
|
||||||
const pendingVoiceLearnerTextRef = useRef<string>("");
|
const pendingVoiceLearnerTextRef = useRef<string>("");
|
||||||
const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null);
|
const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null);
|
||||||
const coachHistoryCloseRef = useRef<HTMLButtonElement>(null);
|
const coachHistoryCloseRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const coachEvidencePanelRef = useRef<HTMLElement>(null);
|
||||||
|
const coachHistoryPanelRef = useRef<HTMLElement>(null);
|
||||||
|
const coachEvidencePreviousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
const coachHistoryPreviousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
const coachCreditSeenRef = useRef<Set<string>>(new Set());
|
const coachCreditSeenRef = useRef<Set<string>>(new Set());
|
||||||
const coachCreditPulseTimerRef = useRef<number | null>(null);
|
const coachCreditPulseTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
|
@ -929,15 +962,27 @@ export default function Session() {
|
||||||
[applyCoachCreditEvents, liveSessionId],
|
[applyCoachCreditEvents, liveSessionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openCoachEvidence = useCallback(() => {
|
||||||
|
if (!coachEvidenceOpen) {
|
||||||
|
coachEvidencePreviousFocusRef.current =
|
||||||
|
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
}
|
||||||
|
setCoachEvidenceOpen(true);
|
||||||
|
}, [coachEvidenceOpen]);
|
||||||
|
|
||||||
const openCoachHistory = useCallback(
|
const openCoachHistory = useCallback(
|
||||||
(turnSeq: number | null = null) => {
|
(turnSeq: number | null = null) => {
|
||||||
|
if (!coachHistoryOpen) {
|
||||||
|
coachHistoryPreviousFocusRef.current =
|
||||||
|
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
}
|
||||||
setCoachHistoryTurnSeq(turnSeq);
|
setCoachHistoryTurnSeq(turnSeq);
|
||||||
setCoachHistoryError(null);
|
setCoachHistoryError(null);
|
||||||
setCoachEvidenceOpen(false);
|
setCoachEvidenceOpen(false);
|
||||||
setCoachHistoryOpen(true);
|
setCoachHistoryOpen(true);
|
||||||
void refreshCoachHistory(liveSessionId, { surfaceErrors: true });
|
void refreshCoachHistory(liveSessionId, { surfaceErrors: true });
|
||||||
},
|
},
|
||||||
[liveSessionId, refreshCoachHistory],
|
[coachHistoryOpen, liveSessionId, refreshCoachHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
const requestLiveCoach = useCallback(
|
const requestLiveCoach = useCallback(
|
||||||
|
|
@ -1294,13 +1339,17 @@ export default function Session() {
|
||||||
};
|
};
|
||||||
}, [navigate, pushSignal, routeIsSessionId, routeParam, voicePracticeContext]);
|
}, [navigate, pushSignal, routeIsSessionId, routeParam, voicePracticeContext]);
|
||||||
|
|
||||||
// 이번 회기 목표 토글 — 1~4개 자유 선택(소유자 지시 2026-07-15).
|
// 이번 회기 목표 — 핵심 1개 + 보조 0~3개를 유지한다(소유자 지시 2026-07-15).
|
||||||
const toggleGoal = useCallback((goal: SessionStage) => {
|
const toggleGoal = useCallback((goal: SessionStage) => {
|
||||||
setSelectedGoals((prev) =>
|
setSelectedGoals((prev) =>
|
||||||
prev.includes(goal) ? prev.filter((g) => g !== goal) : [...prev, goal],
|
prev.includes(goal) ? prev.filter((g) => g !== goal) : [...prev, goal],
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const makePrimaryGoal = useCallback((goal: SessionStage) => {
|
||||||
|
setSelectedGoals((prev) => [goal, ...prev.filter((selected) => selected !== goal)]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
/* ── 세션 시작 ───────────────────────────────────────────────────── */
|
/* ── 세션 시작 ───────────────────────────────────────────────────── */
|
||||||
const handleStart = useCallback(async () => {
|
const handleStart = useCallback(async () => {
|
||||||
if (voicePracticeRequested && !voicePracticeContext) {
|
if (voicePracticeRequested && !voicePracticeContext) {
|
||||||
|
|
@ -1320,7 +1369,10 @@ export default function Session() {
|
||||||
setResumedSessionLoaded(false);
|
setResumedSessionLoaded(false);
|
||||||
setClientReplyPending(false);
|
setClientReplyPending(false);
|
||||||
try {
|
try {
|
||||||
const res = await sessionApi.start(personaCode, selectedTheoryMode, selectedGoals);
|
const res = await sessionApi.start(personaCode, selectedTheoryMode, selectedGoals, {
|
||||||
|
startMode,
|
||||||
|
caseId: selectedCaseId,
|
||||||
|
});
|
||||||
setLiveSessionId(res.session_id); // ★ 진짜 세션 id 저장 — turn 이 이걸 써야 라이브
|
setLiveSessionId(res.session_id); // ★ 진짜 세션 id 저장 — turn 이 이걸 써야 라이브
|
||||||
setSessionEnded(false);
|
setSessionEnded(false);
|
||||||
setReviewReady(false);
|
setReviewReady(false);
|
||||||
|
|
@ -1385,6 +1437,25 @@ export default function Session() {
|
||||||
setStartError(
|
setStartError(
|
||||||
"이 페르소나는 아직 승인되지 않았거나 공개 목록에서 제외됐습니다. 승인 상태를 확인한 뒤 목록에서 다시 선택해 주세요.",
|
"이 페르소나는 아직 승인되지 않았거나 공개 목록에서 제외됐습니다. 승인 상태를 확인한 뒤 목록에서 다시 선택해 주세요.",
|
||||||
);
|
);
|
||||||
|
} else if (err instanceof ApiError && err.status === 409) {
|
||||||
|
const detail = (
|
||||||
|
err.body as {
|
||||||
|
detail?: { code?: unknown; session_id?: unknown };
|
||||||
|
}
|
||||||
|
)?.detail;
|
||||||
|
if (
|
||||||
|
detail?.code === "active_session_exists" &&
|
||||||
|
typeof detail.session_id === "string"
|
||||||
|
) {
|
||||||
|
pushSignal("neutral", "진행 중인 회기 이어가기");
|
||||||
|
navigate(`/learn/session/${encodeURIComponent(detail.session_id)}`, {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStartError(
|
||||||
|
"같은 내담자의 진행 중인 회기가 있습니다. 그 회기를 이어서 마무리한 뒤 다음 회기를 시작해 주세요.",
|
||||||
|
);
|
||||||
} else if (
|
} else if (
|
||||||
err instanceof ApiError &&
|
err instanceof ApiError &&
|
||||||
err.detail === "session_persistence_unavailable"
|
err.detail === "session_persistence_unavailable"
|
||||||
|
|
@ -1398,7 +1469,7 @@ export default function Session() {
|
||||||
} finally {
|
} finally {
|
||||||
setStarting(false);
|
setStarting(false);
|
||||||
}
|
}
|
||||||
}, [navigate, personaCode, personaSummary, practiceContextSearch, practiceLaunchIntent, practiceLaunchRequested, pushSignal, selectedGoals, selectedTheoryMode, voicePracticeContext, voicePracticeRequested]);
|
}, [navigate, personaCode, personaSummary, practiceContextSearch, practiceLaunchIntent, practiceLaunchRequested, pushSignal, selectedCaseId, selectedGoals, selectedTheoryMode, startMode, voicePracticeContext, voicePracticeRequested]);
|
||||||
|
|
||||||
const handleAlliancePreGateChange = useCallback((blocked: boolean) => {
|
const handleAlliancePreGateChange = useCallback((blocked: boolean) => {
|
||||||
setAlliancePreGateBlocked(blocked);
|
setAlliancePreGateBlocked(blocked);
|
||||||
|
|
@ -2373,9 +2444,13 @@ export default function Session() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 단축키 도움말: ? (입력창 밖) 또는 Ctrl+/ or Alt+/
|
// 2. 단축키 도움말: ? (입력창 밖) 또는 Ctrl+/ or Alt+/
|
||||||
|
// 브라우저·키보드 레이아웃에 따라 Shift+/가 key="?" 대신 key="/"+shiftKey로
|
||||||
|
// 들어올 수 있으므로 두 형태를 모두 같은 단축키로 취급한다.
|
||||||
|
const isQuestionMarkShortcut = e.key === "?" || (e.shiftKey && e.key === "/");
|
||||||
if (
|
if (
|
||||||
((e.ctrlKey || e.metaKey || e.altKey) && e.key === "/") ||
|
((e.ctrlKey || e.metaKey || e.altKey) && e.key === "/") ||
|
||||||
(e.key === "?" && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement))
|
(isQuestionMarkShortcut &&
|
||||||
|
!(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement))
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShortcutHelpOpen((prev) => !prev);
|
setShortcutHelpOpen((prev) => !prev);
|
||||||
|
|
@ -2471,7 +2546,7 @@ export default function Session() {
|
||||||
// Alt+E: AI 코칭 근거
|
// Alt+E: AI 코칭 근거
|
||||||
if (isAlt && e.key.toLowerCase() === "e") {
|
if (isAlt && e.key.toLowerCase() === "e") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setCoachEvidenceOpen(true);
|
openCoachEvidence();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2504,6 +2579,7 @@ export default function Session() {
|
||||||
rightPanelCollapsed,
|
rightPanelCollapsed,
|
||||||
sessionEnded,
|
sessionEnded,
|
||||||
jumpToLatest,
|
jumpToLatest,
|
||||||
|
openCoachEvidence,
|
||||||
openCoachHistory,
|
openCoachHistory,
|
||||||
shortcutHelpOpen,
|
shortcutHelpOpen,
|
||||||
coachEvidenceOpen,
|
coachEvidenceOpen,
|
||||||
|
|
@ -2554,12 +2630,63 @@ export default function Session() {
|
||||||
voiceConsentPreviousFocusRef.current = null;
|
voiceConsentPreviousFocusRef.current = null;
|
||||||
}, [voiceConsentDialogOpen]);
|
}, [voiceConsentDialogOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shortcutHelpOpen) return;
|
||||||
|
shortcutHelpPreviousFocusRef.current =
|
||||||
|
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
window.setTimeout(() => shortcutHelpConfirmRef.current?.focus(), 0);
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const controls = Array.from(
|
||||||
|
shortcutHelpPanelRef.current?.querySelectorAll<HTMLElement>(
|
||||||
|
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
|
||||||
|
) ?? [],
|
||||||
|
).filter((control) => control.getClientRects().length > 0);
|
||||||
|
if (controls.length === 0) return;
|
||||||
|
const first = controls[0];
|
||||||
|
const last = controls[controls.length - 1];
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [shortcutHelpOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shortcutHelpOpen) return;
|
||||||
|
const previous = shortcutHelpPreviousFocusRef.current;
|
||||||
|
if (previous?.isConnected) previous.focus();
|
||||||
|
shortcutHelpPreviousFocusRef.current = null;
|
||||||
|
}, [shortcutHelpOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!coachEvidenceOpen) return;
|
if (!coachEvidenceOpen) return;
|
||||||
window.setTimeout(() => coachEvidenceCloseRef.current?.focus(), 0);
|
window.setTimeout(() => coachEvidenceCloseRef.current?.focus(), 0);
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
setCoachEvidenceOpen(false);
|
setCoachEvidenceOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const controls = Array.from(
|
||||||
|
coachEvidencePanelRef.current?.querySelectorAll<HTMLElement>(
|
||||||
|
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
|
||||||
|
) ?? [],
|
||||||
|
).filter((control) => control.getClientRects().length > 0);
|
||||||
|
if (controls.length === 0) return;
|
||||||
|
const first = controls[0];
|
||||||
|
const last = controls[controls.length - 1];
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
|
|
@ -2572,12 +2699,43 @@ export default function Session() {
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
setCoachHistoryOpen(false);
|
setCoachHistoryOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const controls = Array.from(
|
||||||
|
coachHistoryPanelRef.current?.querySelectorAll<HTMLElement>(
|
||||||
|
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
|
||||||
|
) ?? [],
|
||||||
|
).filter((control) => control.getClientRects().length > 0);
|
||||||
|
if (controls.length === 0) return;
|
||||||
|
const first = controls[0];
|
||||||
|
const last = controls[controls.length - 1];
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [coachHistoryOpen]);
|
}, [coachHistoryOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (coachEvidenceOpen || coachHistoryOpen) return;
|
||||||
|
const previous = coachEvidencePreviousFocusRef.current;
|
||||||
|
if (previous?.isConnected) previous.focus();
|
||||||
|
coachEvidencePreviousFocusRef.current = null;
|
||||||
|
}, [coachEvidenceOpen, coachHistoryOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (coachHistoryOpen) return;
|
||||||
|
const previous = coachHistoryPreviousFocusRef.current;
|
||||||
|
if (previous?.isConnected) previous.focus();
|
||||||
|
coachHistoryPreviousFocusRef.current = null;
|
||||||
|
}, [coachHistoryOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!endDialogOpen) return;
|
if (!endDialogOpen) return;
|
||||||
endPreviousFocusRef.current =
|
endPreviousFocusRef.current =
|
||||||
|
|
@ -2705,16 +2863,14 @@ export default function Session() {
|
||||||
!sessionEnded;
|
!sessionEnded;
|
||||||
const elapsedLabel = timeUp && !sessionEnded ? "시간 만료" : formatTimecode(elapsed);
|
const elapsedLabel = timeUp && !sessionEnded ? "시간 만료" : formatTimecode(elapsed);
|
||||||
const remainingLabel = formatTimecode(remainingSeconds);
|
const remainingLabel = formatTimecode(remainingSeconds);
|
||||||
const limitMinutesLabel = Math.round(sessionLimitSeconds / 60);
|
|
||||||
const warningMinutesLabel = Math.max(1, Math.round(sessionWarningSeconds / 60));
|
|
||||||
const selectedTheoryOption =
|
const selectedTheoryOption =
|
||||||
THEORY_MODE_OPTIONS.find((option) => option.value === selectedTheoryMode) ??
|
THEORY_MODE_OPTIONS.find((option) => option.value === selectedTheoryMode) ??
|
||||||
THEORY_MODE_OPTIONS[0];
|
THEORY_MODE_OPTIONS[0];
|
||||||
const selectedGoalSummary = SESSION_PHASES.filter((phase) =>
|
const selectedGoalPhases = selectedGoals
|
||||||
selectedGoals.includes(phase.key),
|
.map((goal) => SESSION_PHASES.find((phase) => phase.key === goal))
|
||||||
)
|
.filter((phase): phase is PhaseInfo => phase != null);
|
||||||
.map((phase) => phase.key)
|
const primaryGoal = selectedGoalPhases[0];
|
||||||
.join(" · ");
|
const selectedGoalSummary = selectedGoalPhases.map((phase) => phase.key).join(" · ");
|
||||||
const turnCount = utterances.filter((utterance) => !utterance.partial && !utterance.failed).length;
|
const turnCount = utterances.filter((utterance) => !utterance.partial && !utterance.failed).length;
|
||||||
const latestClientUtterance = [...utterances]
|
const latestClientUtterance = [...utterances]
|
||||||
.reverse()
|
.reverse()
|
||||||
|
|
@ -2839,17 +2995,28 @@ export default function Session() {
|
||||||
<Kicker>상담 연습 · {personaCode}</Kicker>
|
<Kicker>상담 연습 · {personaCode}</Kicker>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="sx-head__title">
|
<h1 className="sx-head__title">
|
||||||
|
{started ? (
|
||||||
|
<>
|
||||||
지금은 <em>{stage} 단계</em>입니다.
|
지금은 <em>{stage} 단계</em>입니다.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
첫 발화의 <em>기준</em>을 정합니다.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="sx-head__sub">
|
<div className="sx-head__sub">
|
||||||
{clientName}의 말에 귀를 기울이세요. 정밀 평가는 회기가 끝난 뒤 리뷰에서 함께 봅니다.
|
{started
|
||||||
|
? `${clientName}의 말에 귀를 기울이세요. 정밀 평가는 회기가 끝난 뒤 리뷰에서 함께 봅니다.`
|
||||||
|
: `${clientName}의 사례를 읽고, 첫 반응에서 해볼 한 가지를 정한 뒤 시작하세요.`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{started ? (
|
||||||
<div className="sx-phases" aria-label="회기 진행 단계">
|
<div className="sx-phases" aria-label="회기 진행 단계">
|
||||||
{SESSION_PHASES.map((p, i) => {
|
{SESSION_PHASES.map((p, i) => {
|
||||||
const state = i < stageIdx ? "is-done" : i === stageIdx ? "is-cur" : "";
|
const state = i < stageIdx ? "is-done" : i === stageIdx ? "is-cur" : "";
|
||||||
const isGoal = started && goalStages.includes(p.key);
|
const isGoal = goalStages.includes(p.key);
|
||||||
return (
|
return (
|
||||||
<div key={p.key} style={{ display: "flex", alignItems: "center" }}>
|
<div key={p.key} style={{ display: "flex", alignItems: "center" }}>
|
||||||
{i > 0 ? (
|
{i > 0 ? (
|
||||||
|
|
@ -2875,6 +3042,7 @@ export default function Session() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{started ? (
|
{started ? (
|
||||||
|
|
@ -2949,13 +3117,18 @@ export default function Session() {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!started ? (
|
{!started ? (
|
||||||
/* ── 시작 전: 준비 화면(한 화면 한 의도 = 세션 시작) ── */
|
<>
|
||||||
<div
|
{/* ── 시작 전: 사례 맥락 → 한 가지 수행 초점 → 근거 기반 복기 ── */}
|
||||||
|
<section
|
||||||
className={surfaceClassName(
|
className={surfaceClassName(
|
||||||
`sx-prestart${practiceLaunchIntent ? " sx-prestart--prescribed" : ""}`,
|
`sx-prestart${practiceLaunchIntent ? " sx-prestart--prescribed" : ""}`,
|
||||||
)}
|
)}
|
||||||
|
aria-label="회기 시작 전 준비"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
className={surfaceClassName("sx-prestart__visual", { variant: "inset" })}
|
||||||
|
aria-labelledby="sx-prestart-case-title"
|
||||||
>
|
>
|
||||||
<div className={surfaceClassName("sx-prestart__visual", { variant: "inset" })}>
|
|
||||||
<ClientAvatar
|
<ClientAvatar
|
||||||
persona={personaUi.avatar}
|
persona={personaUi.avatar}
|
||||||
state="idle"
|
state="idle"
|
||||||
|
|
@ -2966,29 +3139,41 @@ export default function Session() {
|
||||||
showCaption={false}
|
showCaption={false}
|
||||||
showMeta={false}
|
showMeta={false}
|
||||||
/>
|
/>
|
||||||
{/* 아바타 카드는 인물 그림 + 이름 + 한 줄 소개만 맡는다.
|
<div className="sx-prestart__case">
|
||||||
호소·대상·난도 목록은 본문 .sx-prestart__facts 와 똑같은 내용이라
|
<Kicker>오늘 만날 내담자</Kicker>
|
||||||
(3열·2열 모두에서 두 번 노출) 여기서는 제거했다. 이름 이니셜 원도
|
<b id="sx-prestart-case-title">{personaUi.context.name}</b>
|
||||||
바로 위 초상과 이름을 중복하는 장식이라 함께 걷어냈다. */}
|
|
||||||
<div className="sx-prestart__case" aria-label="내담자 요약">
|
|
||||||
<b>{personaUi.context.name}</b>
|
|
||||||
{/* D3 — 한 줄에 · 가 3개 몰리던 소개를 줄당 1개로 나눠 표시 */}
|
|
||||||
<p>
|
<p>
|
||||||
{clientMetaLines.map((line, i) => (
|
{clientMetaLines.map((line, i) => (
|
||||||
<span key={`${i}-${line}`}>{line}</span>
|
<span key={`${i}-${line}`}>{line}</span>
|
||||||
))}
|
))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<dl className="sx-prestart__facts">
|
||||||
|
{personaUi.context.rows.map((row) => (
|
||||||
|
<div key={row.l}>
|
||||||
|
<dt>{row.l}</dt>
|
||||||
|
<dd>{row.v}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<div className="sx-prestart__chips" aria-label="내담자 태그">
|
||||||
|
{personaUi.context.chips.map((chip) => (
|
||||||
|
<span className={chip.clay ? "is-clay" : ""} key={chip.t}>
|
||||||
|
{chip.t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div className="sx-prestart__main">
|
<div className="sx-prestart__main sx-prestart__learning">
|
||||||
{/* D4 — eyebrow "○○ 단계 준비" 는 바로 위 페이지 제목 "지금은 ○○ 단계입니다."
|
<header className="sx-prestart__intro">
|
||||||
와 같은 말이라 제거했다(단계 정보는 상단 제목·단계 레일·진행 초점에 이미 있다). */}
|
<Kicker>시작 전 1분</Kicker>
|
||||||
<h2 className="sx-prestart__title">{prestartTitle}</h2>
|
<h2 className="sx-prestart__title">{prestartTitle}</h2>
|
||||||
<p className="sx-prestart__desc">
|
<p className="sx-prestart__desc">
|
||||||
짧은 첫 인사로 안전감을 만들고, 정밀 평가는 회기가 끝난 뒤 리뷰에서 함께
|
사례를 모두 해결하려고 하기보다, 첫 반응에서 해볼 한 가지를 정하세요. 대화가
|
||||||
확인합니다.
|
끝나면 리뷰의 근거 턴을 보고 같은 장면을 다시 연습합니다.
|
||||||
</p>
|
</p>
|
||||||
|
</header>
|
||||||
{voicePracticeRequested ? (
|
{voicePracticeRequested ? (
|
||||||
voicePracticeContext ? (
|
voicePracticeContext ? (
|
||||||
<section className="sx-voice-practice-context" aria-labelledby="sx-voice-practice-title">
|
<section className="sx-voice-practice-context" aria-labelledby="sx-voice-practice-title">
|
||||||
|
|
@ -3041,33 +3226,97 @@ export default function Session() {
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
<details
|
<section className="sx-prestart__focus" aria-labelledby="sx-prestart-focus-title">
|
||||||
className={`sx-prestart__settings${practiceLaunchIntent ? " is-prescribed" : ""}`}
|
<div className="sx-prestart__focus-head">
|
||||||
open={practiceLaunchIntent ? undefined : true}
|
<div>
|
||||||
|
<Kicker>오늘의 핵심 초점</Kicker>
|
||||||
|
<h3 id="sx-prestart-focus-title">{primaryGoal?.key ?? "초점 선택 필요"}</h3>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{primaryGoal?.desc ?? "목표 하나를 고르면 첫 발화에서 해볼 반응을 안내합니다."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<fieldset className="sx-goals" aria-label="이번 회기 목표 선택">
|
||||||
|
<legend>
|
||||||
|
이번 회기 목표 <small>핵심 1개 · 보조 0~3개</small>
|
||||||
|
</legend>
|
||||||
|
<div className="sx-goals__grid">
|
||||||
|
{SESSION_PHASES.map((phase) => {
|
||||||
|
const selected = selectedGoals.includes(phase.key);
|
||||||
|
const isPrimary = selected && primaryGoal?.key === phase.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={phase.key}
|
||||||
|
className={
|
||||||
|
(selected ? "is-selected" : "") + (isPrimary ? " is-primary" : "")
|
||||||
|
}
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={
|
||||||
|
isPrimary
|
||||||
|
? `${phase.key} 핵심 초점, ${phase.desc}`
|
||||||
|
: `${phase.key}, ${phase.desc}`
|
||||||
|
}
|
||||||
|
onClick={() => toggleGoal(phase.key)}
|
||||||
>
|
>
|
||||||
|
<span>{phase.key}</span>
|
||||||
|
<small>{phase.desc}</small>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="sx-goals__hint">
|
||||||
|
{primaryGoal ? (
|
||||||
|
<>
|
||||||
|
첫 발화의 기준은 <b>{primaryGoal.key}</b>입니다. 보조 목표는 회기 전체에
|
||||||
|
남고, 회기는 {Math.round((durationLimitSeconds > 0 ? durationLimitSeconds : 3600) / 60)}분 기준으로 이어집니다.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"목표 하나를 고르면 첫 발화의 기준이 표시되고 회기를 시작할 수 있습니다."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
{selectedGoalPhases.length > 1 ? (
|
||||||
|
<div className="sx-prestart__primary-switch">
|
||||||
|
<p>
|
||||||
|
<b>보조 목표</b>는 유지한 채 첫 발화의 기준만 바꿀 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<div role="group" aria-label="핵심 초점 바꾸기">
|
||||||
|
{selectedGoalPhases.slice(1).map((phase) => (
|
||||||
|
<button
|
||||||
|
key={phase.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => makePrimaryGoal(phase.key)}
|
||||||
|
>
|
||||||
|
{primaryGoalActionLabel(phase.key)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="sx-prestart__primary-select">
|
||||||
|
<span>첫 발화 핵심</span>
|
||||||
|
<select
|
||||||
|
value={primaryGoal?.key ?? ""}
|
||||||
|
onChange={(event) => makePrimaryGoal(event.target.value as SessionStage)}
|
||||||
|
>
|
||||||
|
{selectedGoalPhases.map((phase) => (
|
||||||
|
<option key={phase.key} value={phase.key}>
|
||||||
|
{primaryGoalActionLabel(phase.key)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
<details className={`sx-prestart__settings${practiceLaunchIntent ? " is-prescribed" : ""}`}>
|
||||||
<summary>
|
<summary>
|
||||||
<span>선택한 회기 설정</span>
|
<span>연습 방식과 추가 설정</span>
|
||||||
<b>
|
<b>
|
||||||
{selectedTheoryOption.label}, {selectedGoalSummary || "목표 선택 필요"}
|
{selectedTheoryOption.label} · {selectedGoalSummary || "핵심 초점 선택 필요"}
|
||||||
</b>
|
</b>
|
||||||
<Icon name="chevron-right" size={16} />
|
<Icon name="chevron-right" size={16} />
|
||||||
</summary>
|
</summary>
|
||||||
<div className="sx-prestart__settings-body">
|
<div className="sx-prestart__settings-body">
|
||||||
<dl className="sx-prestart__facts">
|
|
||||||
{personaUi.context.rows.map((row) => (
|
|
||||||
<div key={row.l}>
|
|
||||||
<dt>{row.l}</dt>
|
|
||||||
<dd>{row.v}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
<div className="sx-prestart__chips" aria-label="내담자 태그">
|
|
||||||
{personaUi.context.chips.map((chip) => (
|
|
||||||
<span className={chip.clay ? "is-clay" : ""} key={chip.t}>
|
|
||||||
{chip.t}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<fieldset className="sx-theory" aria-label="이론모드 선택">
|
<fieldset className="sx-theory" aria-label="이론모드 선택">
|
||||||
<legend>이론모드</legend>
|
<legend>이론모드</legend>
|
||||||
<div className="sx-theory__seg">
|
<div className="sx-theory__seg">
|
||||||
|
|
@ -3085,33 +3334,9 @@ export default function Session() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset className="sx-goals" aria-label="이번 회기 목표 선택">
|
<p className="sx-prestart__settings-note">
|
||||||
<legend>
|
{selectedTheoryOption.focus}
|
||||||
이번 회기 목표 <small>1~4개 선택</small>
|
|
||||||
</legend>
|
|
||||||
<div className="sx-goals__grid">
|
|
||||||
{SESSION_PHASES.map((phase) => {
|
|
||||||
const selected = selectedGoals.includes(phase.key);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={phase.key}
|
|
||||||
className={selected ? "is-selected" : ""}
|
|
||||||
aria-pressed={selected}
|
|
||||||
onClick={() => toggleGoal(phase.key)}
|
|
||||||
>
|
|
||||||
<span>{phase.key}</span>
|
|
||||||
<small>{phase.desc}</small>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="sx-goals__hint">
|
|
||||||
실제 상담처럼 한 회기에 모든 단계를 이루지 않아도 됩니다. 회기는{" "}
|
|
||||||
{Math.round((durationLimitSeconds > 0 ? durationLimitSeconds : 3600) / 60)}분
|
|
||||||
기준으로 진행되고, 목표를 이뤄도 시간이 남으면 계속 이어갈 수 있어요.
|
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{personaStatusMessage ? (
|
{personaStatusMessage ? (
|
||||||
|
|
@ -3156,52 +3381,114 @@ export default function Session() {
|
||||||
</Button>
|
</Button>
|
||||||
<span>
|
<span>
|
||||||
{selectedGoals.length === 0
|
{selectedGoals.length === 0
|
||||||
? "이번 회기 목표를 1개 이상 선택하면 시작할 수 있어요."
|
? "오늘의 핵심 초점을 1개 이상 선택하면 시작할 수 있어요."
|
||||||
: "실시간에는 대화 흐름만 낮은 강도로 표시됩니다."}
|
: "실시간에는 대화 흐름만 낮은 강도로 표시되고, 상세 피드백은 리뷰에서 확인합니다."}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<aside
|
||||||
className={surfaceClassName("sx-prestart__plan", { variant: "inset" })}
|
className={surfaceClassName("sx-prestart__plan sx-prestart__learning-map", { variant: "inset" })}
|
||||||
aria-label="시작 전 초점"
|
aria-labelledby="sx-prestart-learning-map-title"
|
||||||
>
|
>
|
||||||
<Kicker>진행 초점</Kicker>
|
<Kicker>학습 흐름</Kicker>
|
||||||
<p className="sx-prestart__plan-summary">
|
<h3 id="sx-prestart-learning-map-title">시도한 장면을 다시 씁니다.</h3>
|
||||||
선택한 회기 설정을 첫 발화에서 바로 쓸 수 있는 기준으로 정리했습니다.
|
<ol className="sx-prestart__learning-loop">
|
||||||
|
<li>
|
||||||
|
<span>준비</span>
|
||||||
|
<div>
|
||||||
|
<b>한 가지 반응을 정합니다.</b>
|
||||||
|
<p>
|
||||||
|
{primaryGoal
|
||||||
|
? `첫 발화의 기준으로 ${primaryGoal.key}에 집중하고 시작합니다.`
|
||||||
|
: "목표 하나를 고르면 첫 발화의 기준이 여기에 표시됩니다."}
|
||||||
</p>
|
</p>
|
||||||
<dl className="sx-prestart__plan-list">
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>대화</span>
|
||||||
<div>
|
<div>
|
||||||
<dt>시작 과업</dt>
|
<b>먼저 말해 본 뒤 신호를 봅니다.</b>
|
||||||
<dd>
|
<p>실시간에는 대화 흐름만 낮은 강도로 표시합니다.</p>
|
||||||
<b>{currentPhase.key}</b>
|
</div>
|
||||||
{currentPhase.desc}
|
</li>
|
||||||
</dd>
|
<li>
|
||||||
|
<span>리뷰·재연습</span>
|
||||||
|
<div>
|
||||||
|
<b>근거 턴으로 다음 시도를 고릅니다.</b>
|
||||||
|
<p>첫 회기라면 라포 반영과 한 초점 개방질문을 근거와 함께 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p className="sx-prestart__boundary">
|
||||||
|
위험 신호는 대화보다 안전 확인을 우선합니다.
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{shortcutHelpOpen ? (
|
||||||
|
<div
|
||||||
|
className="sx-coach-modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget) setShortcutHelpOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
ref={shortcutHelpPanelRef}
|
||||||
|
className="sx-coach-modal sx-shortcut-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="sx-shortcut-modal-title"
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="sx-coach-modal__head">
|
||||||
|
<span className="sx-coach-modal__avatar" aria-hidden="true">
|
||||||
|
<Icon name="spark" size={20} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 id="sx-shortcut-modal-title">키보드 단축키 안내</h2>
|
||||||
|
<p>시작 전에는 목표를 고르고 회기를 여는 데만 집중하세요.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sx-coach-modal__body sx-shortcut-modal__body">
|
||||||
|
<div className="sx-shortcut-group">
|
||||||
|
<h3>시작 전 조작</h3>
|
||||||
|
<dl className="sx-shortcut-list">
|
||||||
|
<div>
|
||||||
|
<dt><kbd>Space</kbd> 또는 <kbd>Enter</kbd></dt>
|
||||||
|
<dd>포커스한 회기 목표 선택/해제</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>선택 접근</dt>
|
<dt><kbd>Ctrl</kbd>+<kbd>Enter</kbd></dt>
|
||||||
<dd>
|
<dd>선택한 목표로 회기 시작</dd>
|
||||||
<b>{selectedTheoryOption.label}</b>
|
|
||||||
{selectedTheoryOption.focus}
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>이번 목표</dt>
|
<dt><kbd>?</kbd> 또는 <kbd>Ctrl</kbd>+<kbd>/</kbd></dt>
|
||||||
<dd>
|
<dd>단축키 도움말 열기/닫기</dd>
|
||||||
<b>{selectedGoals.length > 0 ? `${selectedGoals.length}개 선택` : "선택 필요"}</b>
|
|
||||||
{selectedGoalSummary || "목표를 1개 이상 선택하면 시작할 수 있습니다."}
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>운영 기준</dt>
|
<dt><kbd>Escape</kbd></dt>
|
||||||
<dd>
|
<dd>이 도움말 닫기</dd>
|
||||||
<b>{limitMinutesLabel}분 회기</b>
|
|
||||||
종료 {warningMinutesLabel}분 전 알림 · 위험 신호 시 안전 확인 우선
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="sx-coach-modal__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={shortcutHelpConfirmRef}
|
||||||
|
onClick={() => setShortcutHelpOpen(false)}
|
||||||
|
>
|
||||||
|
확인 (Esc)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* ── 시간 알람 바(회의 P1): 10분 전 경고 + 시간 만료 정리 유도 — 강제 노출 ── */}
|
{/* ── 시간 알람 바(회의 P1): 10분 전 경고 + 시간 만료 정리 유도 — 강제 노출 ── */}
|
||||||
|
|
@ -4109,7 +4396,7 @@ export default function Session() {
|
||||||
<blockquote>{coachSuggestion.next_utterance}</blockquote>
|
<blockquote>{coachSuggestion.next_utterance}</blockquote>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="sx-coach-bubble__actions">
|
<div className="sx-coach-bubble__actions">
|
||||||
<button type="button" onClick={() => setCoachEvidenceOpen(true)}>
|
<button type="button" onClick={openCoachEvidence}>
|
||||||
근거 보기
|
근거 보기
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => openCoachHistory(null)}>
|
<button type="button" onClick={() => openCoachHistory(null)}>
|
||||||
|
|
@ -4432,6 +4719,7 @@ export default function Session() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
|
ref={coachHistoryPanelRef}
|
||||||
className="sx-coach-history__panel"
|
className="sx-coach-history__panel"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|
@ -4558,6 +4846,7 @@ export default function Session() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
|
ref={coachEvidencePanelRef}
|
||||||
className="sx-coach-modal__panel"
|
className="sx-coach-modal__panel"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|
@ -4775,6 +5064,7 @@ export default function Session() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
|
ref={shortcutHelpPanelRef}
|
||||||
className="sx-coach-modal sx-shortcut-modal"
|
className="sx-coach-modal sx-shortcut-modal"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|
@ -4876,6 +5166,7 @@ export default function Session() {
|
||||||
<div className="sx-coach-modal__actions">
|
<div className="sx-coach-modal__actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={shortcutHelpConfirmRef}
|
||||||
onClick={() => setShortcutHelpOpen(false)}
|
onClick={() => setShortcutHelpOpen(false)}
|
||||||
>
|
>
|
||||||
확인 (Esc)
|
확인 (Esc)
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ import {
|
||||||
REVIEW_READY_POLL_INTERVAL_MS,
|
REVIEW_READY_POLL_INTERVAL_MS,
|
||||||
REVIEW_READY_POLL_LIMIT,
|
REVIEW_READY_POLL_LIMIT,
|
||||||
displayEvaluationRetryError,
|
displayEvaluationRetryError,
|
||||||
|
evaluationFailureMessage,
|
||||||
displayGeneratedReviewText,
|
displayGeneratedReviewText,
|
||||||
displayReviewSummary,
|
displayReviewSummary,
|
||||||
displayTranscriptText,
|
displayTranscriptText,
|
||||||
|
|
@ -1108,7 +1109,6 @@ export default function SessionReview() {
|
||||||
if (resultError) {
|
if (resultError) {
|
||||||
throw new Error(resultError);
|
throw new Error(resultError);
|
||||||
}
|
}
|
||||||
setReviewReloadSeq((seq) => seq + 1);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message =
|
||||||
err instanceof ApiError
|
err instanceof ApiError
|
||||||
|
|
@ -1119,6 +1119,9 @@ export default function SessionReview() {
|
||||||
setEvaluationRetryError(displayEvaluationRetryError(message));
|
setEvaluationRetryError(displayEvaluationRetryError(message));
|
||||||
} finally {
|
} finally {
|
||||||
setEvaluationRetrying(false);
|
setEvaluationRetrying(false);
|
||||||
|
// 재시도 결과가 error여도 서버가 최신 durable 상태를 저장할 수 있다. 성공/실패
|
||||||
|
// 어느 쪽이든 다시 읽어야 교수자가 오래된 실패 원인을 보지 않는다.
|
||||||
|
setReviewReloadSeq((seq) => seq + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1253,7 +1256,9 @@ export default function SessionReview() {
|
||||||
canReviewEndedSession &&
|
canReviewEndedSession &&
|
||||||
hasTranscript &&
|
hasTranscript &&
|
||||||
!data.reviewReady &&
|
!data.reviewReady &&
|
||||||
data.supervisorState === "평가 실패";
|
data.supervisorState === "평가 실패" &&
|
||||||
|
(data.evaluationFailure?.retryable ?? true);
|
||||||
|
const evaluationFailure = isSupervisorView ? data.evaluationFailure : null;
|
||||||
const reviewReadiness = hasTranscript
|
const reviewReadiness = hasTranscript
|
||||||
? `${turns.length}개 발화 기반`
|
? `${turns.length}개 발화 기반`
|
||||||
: "축어록 저장 후 생성";
|
: "축어록 저장 후 생성";
|
||||||
|
|
@ -1447,6 +1452,17 @@ export default function SessionReview() {
|
||||||
{evaluationRetrying ? "AI 평가 실행 중" : "AI 평가 재시도"}
|
{evaluationRetrying ? "AI 평가 실행 중" : "AI 평가 재시도"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{evaluationFailure ? (
|
||||||
|
<p
|
||||||
|
className={surfaceClassName(
|
||||||
|
`sr-share-note sr-evaluation-failure${evaluationFailure.retryable ? "" : " sr-share-note--error"}`,
|
||||||
|
{ variant: "inset", flat: true },
|
||||||
|
)}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{evaluationFailureMessage(evaluationFailure)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AppShell } from "../components/shell/AppShell";
|
import { AppShell } from "../components/shell/AppShell";
|
||||||
|
import { ResilientImage } from "../components/avatar/ResilientImage";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
|
@ -15,7 +16,6 @@ import { roleLabel, useAuth } from "../lib/auth";
|
||||||
import {
|
import {
|
||||||
adminApi,
|
adminApi,
|
||||||
adminEngineApi,
|
adminEngineApi,
|
||||||
apiUrl,
|
|
||||||
userApi,
|
userApi,
|
||||||
type AdminHealthResponse,
|
type AdminHealthResponse,
|
||||||
type AdminEngineConfigResponse,
|
type AdminEngineConfigResponse,
|
||||||
|
|
@ -653,8 +653,8 @@ export default function Settings() {
|
||||||
const accountName = displayName || profile?.email || user?.name || user?.email || "사용자";
|
const accountName = displayName || profile?.email || user?.name || user?.email || "사용자";
|
||||||
const accountEmail = profile?.email ?? user?.email ?? "";
|
const accountEmail = profile?.email ?? user?.email ?? "";
|
||||||
const initials = accountName.trim().slice(0, 1).toUpperCase();
|
const initials = accountName.trim().slice(0, 1).toUpperCase();
|
||||||
// 프로필이 먼저 도착하면 프로필 값을, 아니면 auth 스토어의 절대화된 URL을 쓴다.
|
// 프로필이 먼저 도착하면 원본 값을 쓰고, 공통 이미지 경계에서 URL을 검증·절대화한다.
|
||||||
const avatarSrc = profile?.avatar_url ? apiUrl(profile.avatar_url) : user?.avatarUrl ?? "";
|
const avatarSrc = profile?.avatar_url || user?.avatarUrl || "";
|
||||||
const isLearner = role === "learner";
|
const isLearner = role === "learner";
|
||||||
const consentAt = user?.consentAt ?? null;
|
const consentAt = user?.consentAt ?? null;
|
||||||
const engineModels = engineCapabilities?.models ?? [];
|
const engineModels = engineCapabilities?.models ?? [];
|
||||||
|
|
@ -702,7 +702,7 @@ export default function Settings() {
|
||||||
|
|
||||||
<div className={surfaceClassName("vg-set__rail-card", { variant: "inset" })} aria-label="계정 요약">
|
<div className={surfaceClassName("vg-set__rail-card", { variant: "inset" })} aria-label="계정 요약">
|
||||||
<div className="vg-set__rail-avatar" aria-hidden="true">
|
<div className="vg-set__rail-avatar" aria-hidden="true">
|
||||||
{avatarSrc ? <img src={avatarSrc} alt="" /> : initials}
|
<ResilientImage src={avatarSrc} alt="" fallback={initials} />
|
||||||
</div>
|
</div>
|
||||||
<div className="vg-set__rail-copy">
|
<div className="vg-set__rail-copy">
|
||||||
<div className="vg-set__rail-name">{accountName}</div>
|
<div className="vg-set__rail-name">{accountName}</div>
|
||||||
|
|
@ -753,7 +753,7 @@ export default function Settings() {
|
||||||
|
|
||||||
<div className={surfaceClassName("vg-set__profile", { variant: "inset", flat: true })}>
|
<div className={surfaceClassName("vg-set__profile", { variant: "inset", flat: true })}>
|
||||||
<div className="vg-set__avatar" aria-hidden="true">
|
<div className="vg-set__avatar" aria-hidden="true">
|
||||||
{avatarSrc ? <img src={avatarSrc} alt="" /> : initials}
|
<ResilientImage src={avatarSrc} alt="" fallback={initials} />
|
||||||
</div>
|
</div>
|
||||||
<div className="vg-set__profile-meta">
|
<div className="vg-set__profile-meta">
|
||||||
<div className="n">{accountName}</div>
|
<div className="n">{accountName}</div>
|
||||||
|
|
|
||||||
|
|
@ -606,6 +606,12 @@
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vgops-tabs__panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
.vgops-tabs button {
|
.vgops-tabs button {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
|
|
|
||||||
|
|
@ -94,27 +94,32 @@
|
||||||
padding:8px 12px;
|
padding:8px 12px;
|
||||||
border:1px solid transparent;
|
border:1px solid transparent;
|
||||||
border-radius:var(--radius-pill);
|
border-radius:var(--radius-pill);
|
||||||
background:transparent;
|
background-color:transparent;
|
||||||
color:var(--text-body);
|
color:var(--text-body);
|
||||||
font-size:var(--fs-sm);
|
font-size:var(--fs-sm);
|
||||||
font-weight:650;
|
font-weight:650;
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
min-height:42px;
|
min-height:44px;
|
||||||
transition:all var(--dur-fast) var(--ease-spring);
|
transition:
|
||||||
|
background-color var(--dur-fast) var(--ease-spring),
|
||||||
|
border-color var(--dur-fast) var(--ease-spring),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-spring),
|
||||||
|
color var(--dur-fast) var(--ease-spring),
|
||||||
|
transform var(--dur-fast) var(--ease-spring);
|
||||||
}
|
}
|
||||||
.lh-tabs button:active{
|
.lh-tabs button:active{
|
||||||
transform:scale(0.96);
|
transform:scale(0.96);
|
||||||
}
|
}
|
||||||
.lh-tabs button:hover{
|
.lh-tabs button:hover{
|
||||||
color:var(--text-strong);
|
color:var(--text-strong);
|
||||||
background:color-mix(in srgb,var(--text-strong) 5%,transparent);
|
background-color:color-mix(in srgb,var(--text-strong) 5%,transparent);
|
||||||
}
|
}
|
||||||
.lh-tabs button:focus-visible{
|
.lh-tabs button:focus-visible{
|
||||||
outline:2px solid var(--focus-ring);
|
outline:2px solid var(--focus-ring);
|
||||||
outline-offset:1px;
|
outline-offset:1px;
|
||||||
}
|
}
|
||||||
.lh-tabs button.is-active{
|
.lh-tabs button.is-active{
|
||||||
background:var(--bg-surface);
|
background-color:var(--bg-surface);
|
||||||
border-color:var(--border-strong);
|
border-color:var(--border-strong);
|
||||||
color:var(--text-strong);
|
color:var(--text-strong);
|
||||||
box-shadow:var(--shadow-sm);
|
box-shadow:var(--shadow-sm);
|
||||||
|
|
@ -1019,10 +1024,12 @@
|
||||||
.lh-practice-launch-intent dl{
|
.lh-practice-launch-intent dl{
|
||||||
grid-template-columns:repeat(auto-fit,minmax(128px,1fr));
|
grid-template-columns:repeat(auto-fit,minmax(128px,1fr));
|
||||||
}
|
}
|
||||||
.lh-practice-launch-intent__details{
|
.lh-practice-launch-intent__details,
|
||||||
|
.lh-continuity__memory{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
}
|
}
|
||||||
.lh-practice-launch-intent__details summary{
|
.lh-practice-launch-intent__details summary,
|
||||||
|
.lh-continuity__memory summary{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
min-height:44px;
|
min-height:44px;
|
||||||
display:grid;
|
display:grid;
|
||||||
|
|
@ -1037,15 +1044,18 @@
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
list-style:none;
|
list-style:none;
|
||||||
}
|
}
|
||||||
.lh-practice-launch-intent__details summary::-webkit-details-marker{
|
.lh-practice-launch-intent__details summary::-webkit-details-marker,
|
||||||
|
.lh-continuity__memory summary::-webkit-details-marker{
|
||||||
display:none;
|
display:none;
|
||||||
}
|
}
|
||||||
.lh-practice-launch-intent__details summary span{
|
.lh-practice-launch-intent__details summary span,
|
||||||
|
.lh-continuity__memory summary span{
|
||||||
color:var(--accent-deep);
|
color:var(--accent-deep);
|
||||||
font-size:var(--fs-xs);
|
font-size:var(--fs-xs);
|
||||||
font-weight:760;
|
font-weight:760;
|
||||||
}
|
}
|
||||||
.lh-practice-launch-intent__details summary b{
|
.lh-practice-launch-intent__details summary b,
|
||||||
|
.lh-continuity__memory summary b{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
overflow:hidden;
|
overflow:hidden;
|
||||||
color:var(--text-strong);
|
color:var(--text-strong);
|
||||||
|
|
@ -1054,19 +1064,62 @@
|
||||||
text-overflow:ellipsis;
|
text-overflow:ellipsis;
|
||||||
white-space:nowrap;
|
white-space:nowrap;
|
||||||
}
|
}
|
||||||
.lh-practice-launch-intent__details summary svg{
|
.lh-practice-launch-intent__details summary svg,
|
||||||
|
.lh-continuity__memory summary svg{
|
||||||
transition:transform var(--dur-base) var(--ease-out);
|
transition:transform var(--dur-base) var(--ease-out);
|
||||||
}
|
}
|
||||||
.lh-practice-launch-intent__details[open] summary svg{
|
.lh-practice-launch-intent__details[open] summary svg,
|
||||||
|
.lh-continuity__memory[open] summary svg{
|
||||||
transform:rotate(90deg);
|
transform:rotate(90deg);
|
||||||
}
|
}
|
||||||
.lh-practice-launch-intent__details summary:focus-visible{
|
.lh-practice-launch-intent__details summary:focus-visible,
|
||||||
|
.lh-continuity__memory summary:focus-visible{
|
||||||
outline:2px solid var(--border-focus);
|
outline:2px solid var(--border-focus);
|
||||||
outline-offset:2px;
|
outline-offset:2px;
|
||||||
}
|
}
|
||||||
.lh-practice-launch-intent__details dl{
|
.lh-practice-launch-intent__details dl{
|
||||||
margin-top:var(--sp-2);
|
margin-top:var(--sp-2);
|
||||||
}
|
}
|
||||||
|
.lh-continuity__memory-body{
|
||||||
|
margin-top:var(--sp-2);
|
||||||
|
}
|
||||||
|
.lh-continuity__memory-body > p{
|
||||||
|
margin:0;
|
||||||
|
color:var(--text-body);
|
||||||
|
font-size:var(--fs-sm);
|
||||||
|
line-height:1.55;
|
||||||
|
word-break:keep-all;
|
||||||
|
}
|
||||||
|
.lh-continuity__memory-body dl{
|
||||||
|
display:grid;
|
||||||
|
gap:var(--sp-2);
|
||||||
|
margin:0;
|
||||||
|
}
|
||||||
|
.lh-continuity__memory-body dl > div{
|
||||||
|
min-width:0;
|
||||||
|
padding-top:var(--sp-2);
|
||||||
|
border-top:1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.lh-continuity__memory-body dt{
|
||||||
|
color:var(--text-muted);
|
||||||
|
font-size:var(--fs-xs);
|
||||||
|
font-weight:760;
|
||||||
|
}
|
||||||
|
.lh-continuity__memory-body dd{
|
||||||
|
margin:5px 0 0;
|
||||||
|
color:var(--text-body);
|
||||||
|
font-size:var(--fs-sm);
|
||||||
|
line-height:1.55;
|
||||||
|
white-space:pre-wrap;
|
||||||
|
word-break:keep-all;
|
||||||
|
overflow-wrap:anywhere;
|
||||||
|
}
|
||||||
|
.lh-continuity__memory-body ul{
|
||||||
|
display:grid;
|
||||||
|
gap:4px;
|
||||||
|
margin:0;
|
||||||
|
padding-left:18px;
|
||||||
|
}
|
||||||
.lh-practice-layout{
|
.lh-practice-layout{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
display:grid;
|
display:grid;
|
||||||
|
|
@ -1357,6 +1410,139 @@
|
||||||
text-align:right;
|
text-align:right;
|
||||||
word-break:keep-all;
|
word-break:keep-all;
|
||||||
}
|
}
|
||||||
|
.lh-launch-mode{
|
||||||
|
width:min(100%,340px);
|
||||||
|
margin:0;
|
||||||
|
padding:0;
|
||||||
|
border:0;
|
||||||
|
}
|
||||||
|
.lh-launch-mode legend{
|
||||||
|
margin:0 0 6px;
|
||||||
|
color:var(--text-muted);
|
||||||
|
font-size:var(--fs-xs);
|
||||||
|
font-weight:760;
|
||||||
|
}
|
||||||
|
.lh-launch-mode > div{
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||||
|
gap:8px;
|
||||||
|
}
|
||||||
|
.lh-launch-mode label{
|
||||||
|
position:relative;
|
||||||
|
min-width:0;
|
||||||
|
display:block;
|
||||||
|
}
|
||||||
|
.lh-launch-mode input{
|
||||||
|
position:absolute;
|
||||||
|
width:1px;
|
||||||
|
height:1px;
|
||||||
|
margin:-1px;
|
||||||
|
overflow:hidden;
|
||||||
|
clip:rect(0 0 0 0);
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
.lh-launch-mode label > span{
|
||||||
|
min-width:0;
|
||||||
|
min-height:44px;
|
||||||
|
display:grid;
|
||||||
|
place-items:center;
|
||||||
|
padding:8px 10px;
|
||||||
|
border:1px solid var(--border-subtle);
|
||||||
|
border-radius:var(--radius-sm);
|
||||||
|
background:var(--bg-surface-2);
|
||||||
|
color:var(--text-body);
|
||||||
|
font-size:var(--fs-sm);
|
||||||
|
font-weight:740;
|
||||||
|
line-height:1.25;
|
||||||
|
word-break:keep-all;
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
.lh-launch-mode label:hover input:not(:disabled) + span{
|
||||||
|
border-color:var(--border-strong);
|
||||||
|
background:var(--bg-surface);
|
||||||
|
color:var(--text-strong);
|
||||||
|
}
|
||||||
|
.lh-launch-mode input:checked + span{
|
||||||
|
border-color:var(--accent);
|
||||||
|
background:var(--accent-tint);
|
||||||
|
color:var(--accent-deep);
|
||||||
|
box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent) 12%,transparent);
|
||||||
|
}
|
||||||
|
.lh-launch-mode input:focus-visible + span{
|
||||||
|
outline:2px solid var(--focus-ring);
|
||||||
|
outline-offset:2px;
|
||||||
|
}
|
||||||
|
.lh-launch-mode input:disabled + span{
|
||||||
|
cursor:not-allowed;
|
||||||
|
opacity:.58;
|
||||||
|
}
|
||||||
|
.lh-continuity{
|
||||||
|
min-width:0;
|
||||||
|
display:grid;
|
||||||
|
gap:var(--sp-3);
|
||||||
|
padding:var(--sp-4);
|
||||||
|
border:1px solid var(--border-subtle);
|
||||||
|
border-radius:var(--radius);
|
||||||
|
background:var(--bg-surface-2);
|
||||||
|
}
|
||||||
|
.lh-continuity__notice,
|
||||||
|
.lh-continuity__head{
|
||||||
|
min-width:0;
|
||||||
|
}
|
||||||
|
.lh-continuity__notice p,
|
||||||
|
.lh-continuity__head p,
|
||||||
|
.lh-continuity__status{
|
||||||
|
margin:7px 0 0;
|
||||||
|
color:var(--text-body);
|
||||||
|
font-size:var(--fs-sm);
|
||||||
|
line-height:1.55;
|
||||||
|
word-break:keep-all;
|
||||||
|
}
|
||||||
|
.lh-continuity__status.is-error{
|
||||||
|
color:var(--warn-text);
|
||||||
|
}
|
||||||
|
.lh-continuity__head{
|
||||||
|
display:flex;
|
||||||
|
align-items:start;
|
||||||
|
justify-content:space-between;
|
||||||
|
gap:var(--sp-3);
|
||||||
|
}
|
||||||
|
.lh-continuity__head > span{
|
||||||
|
flex:none;
|
||||||
|
color:var(--accent-deep);
|
||||||
|
font-family:var(--font-num);
|
||||||
|
font-size:var(--fs-xs);
|
||||||
|
font-weight:800;
|
||||||
|
white-space:nowrap;
|
||||||
|
}
|
||||||
|
.lh-continuity__case-select{
|
||||||
|
min-width:0;
|
||||||
|
display:grid;
|
||||||
|
gap:6px;
|
||||||
|
}
|
||||||
|
.lh-continuity__case-select > span{
|
||||||
|
color:var(--text-muted);
|
||||||
|
font-size:var(--fs-xs);
|
||||||
|
font-weight:760;
|
||||||
|
}
|
||||||
|
.lh-continuity__case-select select{
|
||||||
|
width:100%;
|
||||||
|
min-height:44px;
|
||||||
|
padding:8px 10px;
|
||||||
|
border:1px solid var(--border-subtle);
|
||||||
|
border-radius:var(--radius-sm);
|
||||||
|
background:var(--bg-surface);
|
||||||
|
color:var(--text-strong);
|
||||||
|
font:inherit;
|
||||||
|
}
|
||||||
|
.lh-continuity__case-select select:focus-visible{
|
||||||
|
outline:2px solid var(--focus-ring);
|
||||||
|
outline-offset:2px;
|
||||||
|
}
|
||||||
|
.lh-continuity__case-select select:disabled{
|
||||||
|
cursor:not-allowed;
|
||||||
|
opacity:.72;
|
||||||
|
}
|
||||||
.lh-summary{
|
.lh-summary{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
padding:var(--sp-4);
|
padding:var(--sp-4);
|
||||||
|
|
@ -1406,13 +1592,15 @@
|
||||||
gap:var(--sp-4);
|
gap:var(--sp-4);
|
||||||
padding:var(--sp-5);
|
padding:var(--sp-5);
|
||||||
}
|
}
|
||||||
.lh-activity__stats{
|
.lh-activity__stats,
|
||||||
|
.lh-continuity__stats{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
display:grid;
|
display:grid;
|
||||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||||
gap:8px;
|
gap:8px;
|
||||||
}
|
}
|
||||||
.lh-activity__stats span{
|
.lh-activity__stats span,
|
||||||
|
.lh-continuity__stats span{
|
||||||
min-width:0;
|
min-width:0;
|
||||||
min-height:58px;
|
min-height:58px;
|
||||||
display:grid;
|
display:grid;
|
||||||
|
|
@ -1423,13 +1611,15 @@
|
||||||
border-radius:var(--radius);
|
border-radius:var(--radius);
|
||||||
background:var(--bg-surface-2);
|
background:var(--bg-surface-2);
|
||||||
}
|
}
|
||||||
.lh-activity__stats b{
|
.lh-activity__stats b,
|
||||||
|
.lh-continuity__stats b{
|
||||||
color:var(--text-strong);
|
color:var(--text-strong);
|
||||||
font-family:var(--font-num);
|
font-family:var(--font-num);
|
||||||
font-size:22px;
|
font-size:22px;
|
||||||
line-height:1;
|
line-height:1;
|
||||||
}
|
}
|
||||||
.lh-activity__stats small{
|
.lh-activity__stats small,
|
||||||
|
.lh-continuity__stats small{
|
||||||
color:var(--text-muted);
|
color:var(--text-muted);
|
||||||
font-size:12px;
|
font-size:12px;
|
||||||
font-weight:720;
|
font-weight:720;
|
||||||
|
|
@ -1912,6 +2102,7 @@
|
||||||
.lh-list-pane,
|
.lh-list-pane,
|
||||||
.lh-preview__main,
|
.lh-preview__main,
|
||||||
.lh-activity,
|
.lh-activity,
|
||||||
|
.lh-continuity,
|
||||||
.lh-persona-progress,
|
.lh-persona-progress,
|
||||||
.lh-history-main,
|
.lh-history-main,
|
||||||
.lh-archive-note{
|
.lh-archive-note{
|
||||||
|
|
@ -1975,16 +2166,27 @@
|
||||||
margin:0;
|
margin:0;
|
||||||
max-width:70%;
|
max-width:70%;
|
||||||
}
|
}
|
||||||
.lh-activity__stats{
|
.lh-activity__stats,
|
||||||
|
.lh-continuity__stats{
|
||||||
gap:6px;
|
gap:6px;
|
||||||
}
|
}
|
||||||
.lh-activity__stats span{
|
.lh-activity__stats span,
|
||||||
|
.lh-continuity__stats span{
|
||||||
min-height:46px;
|
min-height:46px;
|
||||||
padding:8px 4px;
|
padding:8px 4px;
|
||||||
text-align:center;
|
text-align:center;
|
||||||
}
|
}
|
||||||
.lh-activity__stats b{font-size:17px;}
|
.lh-activity__stats b,
|
||||||
.lh-activity__stats small{font-size:11px;}
|
.lh-continuity__stats b{font-size:17px;}
|
||||||
|
.lh-activity__stats small,
|
||||||
|
.lh-continuity__stats small{font-size:11px;}
|
||||||
|
.lh-launch-mode{
|
||||||
|
width:100%;
|
||||||
|
}
|
||||||
|
.lh-continuity__head{
|
||||||
|
display:grid;
|
||||||
|
gap:6px;
|
||||||
|
}
|
||||||
.lh-session-list li{
|
.lh-session-list li{
|
||||||
grid-template-columns:1fr;
|
grid-template-columns:1fr;
|
||||||
gap:10px;
|
gap:10px;
|
||||||
|
|
|
||||||
|
|
@ -919,8 +919,10 @@
|
||||||
.ps-stepper button.is-current > span:first-child,.ps-stepper button.is-done > span:first-child{border-color:var(--accent);background:var(--accent);color:var(--text-on-accent);}
|
.ps-stepper button.is-current > span:first-child,.ps-stepper button.is-done > span:first-child{border-color:var(--accent);background:var(--accent);color:var(--text-on-accent);}
|
||||||
.ps-authoring-layout{grid-template-columns:minmax(0,1fr) minmax(260px,320px);}
|
.ps-authoring-layout{grid-template-columns:minmax(0,1fr) minmax(260px,320px);}
|
||||||
.ps-authoring-layout[data-authoring-step="source"] .ps-tabs,
|
.ps-authoring-layout[data-authoring-step="source"] .ps-tabs,
|
||||||
|
.ps-authoring-layout[data-authoring-step="source"] .ps-tabs__panel,
|
||||||
.ps-authoring-layout[data-authoring-step="source"] .ps-edit-panel,
|
.ps-authoring-layout[data-authoring-step="source"] .ps-edit-panel,
|
||||||
.ps-authoring-layout[data-authoring-step="generate"] .ps-tabs,
|
.ps-authoring-layout[data-authoring-step="generate"] .ps-tabs,
|
||||||
|
.ps-authoring-layout[data-authoring-step="generate"] .ps-tabs__panel,
|
||||||
.ps-authoring-layout[data-authoring-step="generate"] .ps-edit-panel,
|
.ps-authoring-layout[data-authoring-step="generate"] .ps-edit-panel,
|
||||||
.ps-authoring-layout[data-authoring-step="edit"] .ps-source-panel,
|
.ps-authoring-layout[data-authoring-step="edit"] .ps-source-panel,
|
||||||
.ps-authoring-layout[data-authoring-step="review"] .ps-source-panel{display:none;}
|
.ps-authoring-layout[data-authoring-step="review"] .ps-source-panel{display:none;}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type {
|
import type {
|
||||||
|
ReviewEvaluationFailure,
|
||||||
SessionReviewResponse,
|
SessionReviewResponse,
|
||||||
TeacherSessionReviewStatusResponse,
|
TeacherSessionReviewStatusResponse,
|
||||||
UserPrepostMeasureItem,
|
UserPrepostMeasureItem,
|
||||||
|
|
@ -134,9 +135,36 @@ export function displayGeneratedReviewText(text: string) {
|
||||||
|
|
||||||
export function displayEvaluationRetryError(message: string) {
|
export function displayEvaluationRetryError(message: string) {
|
||||||
if (!message.trim()) return "AI 평가를 다시 실행하지 못했습니다.";
|
if (!message.trim()) return "AI 평가를 다시 실행하지 못했습니다.";
|
||||||
|
if (/timeout|timed out/i.test(message)) {
|
||||||
|
return "AI 평가가 제한 시간 안에 끝나지 않았습니다. 최신 상태를 다시 불러왔습니다.";
|
||||||
|
}
|
||||||
|
if (/engine_error|engine unavailable|transport error/i.test(message)) {
|
||||||
|
return "평가 엔진에 일시적으로 연결하지 못했습니다. 최신 상태를 다시 불러왔습니다.";
|
||||||
|
}
|
||||||
return "AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요.";
|
return "AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function evaluationFailureMessage(
|
||||||
|
failure: ReviewEvaluationFailure | null | undefined,
|
||||||
|
) {
|
||||||
|
switch (failure?.code) {
|
||||||
|
case "timeout":
|
||||||
|
return "평가 생성이 제한 시간 안에 끝나지 않았습니다. 축어록은 보존됐으며 다시 시도할 수 있습니다.";
|
||||||
|
case "engine_unavailable":
|
||||||
|
return "평가 엔진에 일시적으로 연결하지 못했습니다. 축어록은 보존됐으며 다시 시도할 수 있습니다.";
|
||||||
|
case "legacy_argv_limit":
|
||||||
|
return "이전 Windows 입력 한도에 걸린 평가입니다. 축어록은 보존됐으며 현재 입력 경로로 다시 시도할 수 있습니다.";
|
||||||
|
case "prompt_too_large":
|
||||||
|
return "평가 입력이 허용 크기를 넘어섰습니다. 같은 재시도 대신 입력 경로를 조정해야 합니다.";
|
||||||
|
case "invalid_structured_output":
|
||||||
|
return "평가 결과 형식이 검증되지 않았습니다. 축어록은 보존됐으며 다시 시도할 수 있습니다.";
|
||||||
|
case "missing_evaluation":
|
||||||
|
return "회기말 평가 기록이 아직 저장되지 않았습니다. 축어록은 보존됐으며 다시 시도할 수 있습니다.";
|
||||||
|
default:
|
||||||
|
return "평가 AI가 완료되지 않았습니다. 축어록은 보존됐으며 다시 시도할 수 있습니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function displayTranscriptText(text: string) {
|
export function displayTranscriptText(text: string) {
|
||||||
return displayPiiSafeText(text);
|
return displayPiiSafeText(text);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -314,27 +314,32 @@
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
background: transparent;
|
background-color: transparent;
|
||||||
color: var(--text-body);
|
color: var(--text-body);
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-height: 42px;
|
min-height: 44px;
|
||||||
transition: all var(--dur-fast) var(--ease-spring);
|
transition:
|
||||||
|
background-color var(--dur-fast) var(--ease-spring),
|
||||||
|
border-color var(--dur-fast) var(--ease-spring),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-spring),
|
||||||
|
color var(--dur-fast) var(--ease-spring),
|
||||||
|
transform var(--dur-fast) var(--ease-spring);
|
||||||
}
|
}
|
||||||
.sr-tabs button:active {
|
.sr-tabs button:active {
|
||||||
transform: scale(0.96);
|
transform: scale(0.96);
|
||||||
}
|
}
|
||||||
.sr-tabs button:hover {
|
.sr-tabs button:hover {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
background: color-mix(in srgb, var(--text-strong) 5%, transparent);
|
background-color: color-mix(in srgb, var(--text-strong) 5%, transparent);
|
||||||
}
|
}
|
||||||
.sr-tabs button:focus-visible {
|
.sr-tabs button:focus-visible {
|
||||||
outline: 2px solid var(--focus-ring);
|
outline: 2px solid var(--focus-ring);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
.sr-tabs button.is-active {
|
.sr-tabs button.is-active {
|
||||||
background: var(--bg-surface);
|
background-color: var(--bg-surface);
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
@ -1452,11 +1457,6 @@
|
||||||
color: var(--text-body);
|
color: var(--text-body);
|
||||||
background: color-mix(in srgb, var(--bg-surface) 82%, var(--bg-surface-2));
|
background: color-mix(in srgb, var(--bg-surface) 82%, var(--bg-surface-2));
|
||||||
}
|
}
|
||||||
.sr-ws-input.is-readonly:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--border-subtle);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.sr-ws-input::placeholder {
|
.sr-ws-input::placeholder {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,11 @@
|
||||||
font: 650 12px/1 var(--font-sans);
|
font: 650 12px/1 var(--font-sans);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--dur-fast) var(--ease-out);
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease-out),
|
||||||
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
|
color var(--dur-fast) var(--ease-out),
|
||||||
|
opacity var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
.sx-sessionbar__toggle-btn:hover {
|
.sx-sessionbar__toggle-btn:hover {
|
||||||
background: var(--accent-tint);
|
background: var(--accent-tint);
|
||||||
|
|
@ -183,7 +187,11 @@
|
||||||
font: 700 12.5px/1 var(--font-sans);
|
font: 700 12.5px/1 var(--font-sans);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--dur-fast) var(--ease-out);
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease-out),
|
||||||
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
|
color var(--dur-fast) var(--ease-out),
|
||||||
|
transform var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
.sx-sessionbar button:hover {
|
.sx-sessionbar button:hover {
|
||||||
background: var(--accent-tint);
|
background: var(--accent-tint);
|
||||||
|
|
@ -411,7 +419,10 @@
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font: 600 11.5px/1 var(--font-sans);
|
font: 600 11.5px/1 var(--font-sans);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--dur-fast) var(--ease-out);
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease-out),
|
||||||
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
|
color var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-panel-close-btn:hover {
|
.sx-panel-close-btn:hover {
|
||||||
|
|
@ -448,7 +459,12 @@
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 18px 4px;
|
padding: 18px 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--dur-fast) var(--ease-spring);
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease-spring),
|
||||||
|
border-color var(--dur-fast) var(--ease-spring),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-spring),
|
||||||
|
color var(--dur-fast) var(--ease-spring),
|
||||||
|
transform var(--dur-fast) var(--ease-spring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sx-mini-rail-btn:hover {
|
.sx-mini-rail-btn:hover {
|
||||||
|
|
@ -1169,6 +1185,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.sx-utt__line {
|
.sx-utt__line {
|
||||||
|
|
@ -1176,7 +1193,8 @@
|
||||||
line-height: 1.58;
|
line-height: 1.58;
|
||||||
padding: 9px 13px;
|
padding: 9px 13px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
max-width: min(88%, 68ch);
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
/* 내담자 = clay-tint 틴트 블록 (외곽선·꼬리·border-left 없음) */
|
/* 내담자 = clay-tint 틴트 블록 (외곽선·꼬리·border-left 없음) */
|
||||||
|
|
@ -1351,6 +1369,7 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.sx-compose textarea {
|
.sx-compose textarea {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
resize: none;
|
resize: none;
|
||||||
background: var(--bg-surface-2);
|
background: var(--bg-surface-2);
|
||||||
|
|
@ -1404,7 +1423,7 @@
|
||||||
|
|
||||||
.sx-compose__actions {
|
.sx-compose__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
@ -1428,7 +1447,13 @@
|
||||||
font: 700 13px/1 var(--font-sans);
|
font: 700 13px/1 var(--font-sans);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--dur-fast) var(--ease-out);
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease-out),
|
||||||
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out),
|
||||||
|
color var(--dur-fast) var(--ease-out),
|
||||||
|
opacity var(--dur-fast) var(--ease-out),
|
||||||
|
transform var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
.sx-coach-trigger-btn:hover:not(:disabled) {
|
.sx-coach-trigger-btn:hover:not(:disabled) {
|
||||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg-surface-2));
|
background: color-mix(in srgb, var(--accent) 22%, var(--bg-surface-2));
|
||||||
|
|
@ -1582,7 +1607,9 @@
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--dur-fast) var(--ease-out);
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease-out),
|
||||||
|
color var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
.sx-coach-bubble-popup__close:hover {
|
.sx-coach-bubble-popup__close:hover {
|
||||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||||
|
|
@ -1677,7 +1704,11 @@
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--dur-fast) var(--ease-out);
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease-out),
|
||||||
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
|
color var(--dur-fast) var(--ease-out),
|
||||||
|
transform var(--dur-fast) var(--ease-out);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.sx-coach-chip:hover:not(:disabled) {
|
.sx-coach-chip:hover:not(:disabled) {
|
||||||
|
|
@ -1741,7 +1772,9 @@
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--dur-fast) var(--ease-out);
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease-out),
|
||||||
|
transform var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
.sx-coach-msg__apply-btn:hover {
|
.sx-coach-msg__apply-btn:hover {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|
@ -1808,7 +1841,9 @@
|
||||||
background: var(--bg-surface-2);
|
background: var(--bg-surface-2);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
transition: all var(--dur-fast) var(--ease-out);
|
transition:
|
||||||
|
border-color var(--dur-fast) var(--ease-out),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
.sx-coach-bubble-popup__composer input:focus {
|
.sx-coach-bubble-popup__composer input:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
|
@ -1827,7 +1862,9 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: all var(--dur-fast) var(--ease-out);
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease-out),
|
||||||
|
color var(--dur-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
.sx-coach-bubble-popup__composer button:hover:not(:disabled) {
|
.sx-coach-bubble-popup__composer button:hover:not(:disabled) {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|
@ -5760,3 +5797,467 @@
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 2026-08-30 · 회기 프리브리프: 사례 → 한 가지 수행 초점 → 근거 기반 재연습 ──
|
||||||
|
시작 전 화면은 설정을 한꺼번에 읽는 대시보드가 아니라, 첫 발화에 필요한 판단만
|
||||||
|
앞에 두는 학습 준비면이다. 기존 API payload·동의·처방/음성 URL 문맥은 바꾸지 않는다. */
|
||||||
|
.sx-page--prestart .sx-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-head__lt {
|
||||||
|
max-width: 46rem;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart {
|
||||||
|
grid-template-areas: "case learning map";
|
||||||
|
grid-template-columns: minmax(210px, 0.78fr) minmax(0, 1.5fr) minmax(220px, 0.84fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--sp-5);
|
||||||
|
padding: var(--sp-5);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__visual {
|
||||||
|
grid-area: case;
|
||||||
|
align-self: stretch;
|
||||||
|
align-content: start;
|
||||||
|
justify-items: start;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__visual .vg-avatar {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__case {
|
||||||
|
justify-items: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__case b {
|
||||||
|
margin-top: var(--sp-1);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__case p {
|
||||||
|
max-width: 28ch;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__facts {
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--sp-3) 0;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__chips {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__learning {
|
||||||
|
grid-area: learning;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__intro {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__intro .vg-kicker {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__title,
|
||||||
|
.sx-page--prestart .sx-prestart__desc {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__title {
|
||||||
|
max-width: 20ch;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__desc {
|
||||||
|
max-width: 38ch;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__learning .sx-voice-practice-context {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__focus {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding-top: var(--sp-4);
|
||||||
|
border-top: 1px solid var(--glass-inset-border);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__focus-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.84fr) minmax(0, 1.16fr);
|
||||||
|
align-items: end;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__focus-head h3 {
|
||||||
|
margin: var(--sp-1) 0 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: var(--fs-h3);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__focus-head p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-body);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-goals {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-goals__grid {
|
||||||
|
padding: var(--sp-2);
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-goals__grid button {
|
||||||
|
min-height: 68px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-goals__grid button.is-primary {
|
||||||
|
border-color: var(--accent-deep);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-deep);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-goals__grid button.is-primary span::after {
|
||||||
|
content: " · 핵심";
|
||||||
|
color: var(--accent-deep);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-goals__hint {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 54ch;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-goals__hint b {
|
||||||
|
color: var(--accent-deep);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__primary-switch {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding-top: var(--sp-3);
|
||||||
|
border-top: 1px solid var(--glass-inset-border);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__primary-switch p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__primary-switch p b {
|
||||||
|
color: var(--text-body);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__primary-switch [role="group"] {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__primary-switch button {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 var(--sp-3);
|
||||||
|
border: 1px solid var(--glass-inset-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-body);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__primary-switch button:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 48%, var(--glass-inset-border));
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__primary-switch button:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__primary-select {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__settings {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: var(--sp-3);
|
||||||
|
border-top: 1px solid var(--glass-inset-border);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__settings:not(.is-prescribed) > summary {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__settings-body {
|
||||||
|
padding-top: var(--sp-3);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-theory {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__settings-note {
|
||||||
|
margin: var(--sp-3) 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__voice-disclosure,
|
||||||
|
.sx-page--prestart .sx-consent {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__actions {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__plan {
|
||||||
|
grid-area: map;
|
||||||
|
align-self: stretch;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__learning-map h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: var(--fs-h3);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__learning-loop {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__learning-loop li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(3.5rem, auto) minmax(0, 1fr);
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-3) 0;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__learning-loop li > span {
|
||||||
|
color: var(--accent-deep);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__learning-loop li > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__learning-loop b {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__learning-loop p,
|
||||||
|
.sx-page--prestart .sx-prestart__boundary {
|
||||||
|
margin: var(--sp-1) 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__boundary {
|
||||||
|
padding-top: var(--sp-3);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1260px) {
|
||||||
|
.sx-page--prestart .sx-prestart {
|
||||||
|
grid-template-areas:
|
||||||
|
"case learning"
|
||||||
|
"map map";
|
||||||
|
grid-template-columns: minmax(190px, 0.7fr) minmax(0, 1.3fr);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__plan {
|
||||||
|
border-top: 1px solid var(--glass-inset-border);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__learning-loop {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--sp-3);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__learning-loop li {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 짧은 데스크톱(1280×720 등)에서는 첫 발화 준비의 정보는 모두 유지하되 중앙 학습 열의
|
||||||
|
여백만 조밀하게 만든다. 이전에는 상태 설명이 줄바꿈되면서 핵심 CTA가 접혔다. */
|
||||||
|
@media (min-width: 1261px) and (max-height: 800px) {
|
||||||
|
.sx-page--prestart .sx-prestart__learning {
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__focus {
|
||||||
|
gap: var(--sp-1);
|
||||||
|
padding-top: var(--sp-2);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-goals__grid {
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-goals__grid button {
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__settings {
|
||||||
|
padding-top: var(--sp-2);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__primary-switch {
|
||||||
|
gap: var(--sp-1);
|
||||||
|
padding-top: var(--sp-2);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__primary-switch > p,
|
||||||
|
.sx-page--prestart .sx-prestart__primary-switch > [role="group"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__primary-select {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(10.5rem, 1.25fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__primary-select span {
|
||||||
|
color: var(--text-body);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__primary-select select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 var(--sp-2);
|
||||||
|
border: 1px solid var(--glass-inset-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--glass-surface-inset);
|
||||||
|
color: var(--text-strong);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__actions {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 3;
|
||||||
|
bottom: var(--sp-1);
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-2);
|
||||||
|
border: 1px solid var(--glass-inset-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--glass-surface);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__actions span {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.vg-main:has(.sx-page--prestart),
|
||||||
|
.vg-main:has(.sx-page--prestart .sx-prestart--prescribed) {
|
||||||
|
padding-bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-6));
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart {
|
||||||
|
grid-template-areas:
|
||||||
|
"case"
|
||||||
|
"learning"
|
||||||
|
"map";
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: var(--sp-4);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__visual {
|
||||||
|
grid-template-columns: 108px minmax(0, 1fr);
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
column-gap: var(--sp-3);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__visual .vg-avatar,
|
||||||
|
.sx-page--prestart .sx-prestart__visual .vg-avatar__svg {
|
||||||
|
width: 108px !important;
|
||||||
|
height: 108px !important;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__visual .vg-avatar__stage {
|
||||||
|
height: 108px !important;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__case {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__facts,
|
||||||
|
.sx-page--prestart .sx-prestart__chips {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__facts {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr));
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__focus-head {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-goals__grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__plan {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
/* 처방 재연습은 과제·성공 기준이 이미 위에 고정되어 있다. 작은 화면에서 이를
|
||||||
|
다시 설명하거나 목표 선택을 노출하면 주 행동(회기 시작)이 첫 화면 밖으로 밀린다. */
|
||||||
|
.sx-page--prestart .sx-prestart--prescribed {
|
||||||
|
grid-template-areas:
|
||||||
|
"learning"
|
||||||
|
"case";
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart--prescribed > .sx-prestart__plan,
|
||||||
|
.sx-page--prestart .sx-prestart--prescribed .sx-prestart__focus {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart--prescribed .sx-prestart__actions span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__learning-loop {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__learning-loop li {
|
||||||
|
grid-template-columns: minmax(3.5rem, auto) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__actions,
|
||||||
|
.sx-page--prestart .sx-prestart--prescribed .sx-prestart__actions {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 3;
|
||||||
|
bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-2));
|
||||||
|
padding: var(--sp-2);
|
||||||
|
border: 1px solid var(--glass-inset-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--glass-surface);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__actions .vg-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__actions span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 460px) {
|
||||||
|
.sx-page--prestart .sx-prestart__visual {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart--prescribed {
|
||||||
|
grid-template-areas: "learning";
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart--prescribed .sx-prestart__visual {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__visual {
|
||||||
|
grid-template-columns: 88px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__visual .vg-avatar,
|
||||||
|
.sx-page--prestart .sx-prestart__visual .vg-avatar__svg {
|
||||||
|
width: 88px !important;
|
||||||
|
height: 88px !important;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-prestart__visual .vg-avatar__stage {
|
||||||
|
height: 88px !important;
|
||||||
|
}
|
||||||
|
.sx-page--prestart .sx-goals__grid button {
|
||||||
|
min-height: 64px;
|
||||||
|
padding: var(--sp-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue