From f1b80676c1e45fe83a4bbbf71276799e2f7484c1 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Tue, 1 Sep 2026 11:45:23 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=ED=95=98=EA=B8=B0=20UI=EC=99=80=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20=ED=83=AD=C2=B7=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=EB=A5=BC=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/design.md | 9 + apps/web/e2e/image-resilience.spec.ts | 157 +++++ apps/web/e2e/layout-visual-gate.spec.ts | 8 +- .../e2e/self-directed-learning-loop.spec.ts | 3 +- apps/web/e2e/session-layout.spec.ts | 41 ++ apps/web/e2e/session-persistence.spec.ts | 1 + apps/web/e2e/session-review.spec.ts | 112 +++- apps/web/e2e/tabs-behavior.spec.ts | 138 ++++ .../e2e/uc-learner-practice-history.spec.ts | 30 +- apps/web/e2e/uc-session-case-launch.spec.ts | 373 +++++++++++ .../e2e/uc-session-continuity-guard.spec.ts | 135 ++++ apps/web/e2e/uc-session-conversation.spec.ts | 28 + apps/web/e2e/uc-session-prestart.spec.ts | 223 +++++-- apps/web/e2e/uc-settings-consent.spec.ts | 46 ++ apps/web/scripts/check-design-ssot.mjs | 41 ++ .../src/components/avatar/ResilientImage.tsx | 106 ++++ apps/web/src/components/shell/AppShell.tsx | 9 +- apps/web/src/components/shell/Topbar.tsx | 7 +- apps/web/src/components/shell/shell.css | 23 + apps/web/src/components/ui/Tabs.tsx | 125 ++++ apps/web/src/components/ui/index.ts | 3 + apps/web/src/components/ui/ui.css | 10 + apps/web/src/lib/api.gen.ts | 229 ++++++- apps/web/src/lib/api.ts | 23 +- apps/web/src/lib/auth.tsx | 4 +- apps/web/src/pages/Admin.tsx | 244 ++++---- apps/web/src/pages/LearnerHome.tsx | 389 +++++++++++- apps/web/src/pages/Onboarding.tsx | 35 +- apps/web/src/pages/PersonaStudio.tsx | 42 +- apps/web/src/pages/Session.tsx | 587 +++++++++++++----- apps/web/src/pages/SessionReview.tsx | 20 +- apps/web/src/pages/Settings.tsx | 10 +- apps/web/src/pages/admin/admin-console.css | 6 + apps/web/src/pages/learner-home.css | 244 +++++++- apps/web/src/pages/persona-studio.css | 2 + apps/web/src/pages/session-review/model.ts | 28 + .../pages/session-review/session-review.css | 20 +- apps/web/src/pages/session/session.css | 525 +++++++++++++++- 38 files changed, 3581 insertions(+), 455 deletions(-) create mode 100644 apps/web/e2e/image-resilience.spec.ts create mode 100644 apps/web/e2e/tabs-behavior.spec.ts create mode 100644 apps/web/e2e/uc-session-case-launch.spec.ts create mode 100644 apps/web/e2e/uc-session-continuity-guard.spec.ts create mode 100644 apps/web/src/components/avatar/ResilientImage.tsx create mode 100644 apps/web/src/components/ui/Tabs.tsx diff --git a/apps/web/design.md b/apps/web/design.md index bb7288a..a3b1c08 100644 --- a/apps/web/design.md +++ b/apps/web/design.md @@ -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/`의 소유권을 유지한다. - 2026-08-28 · `/learn` 성장 지표의 라포 헤더에서 중첩 inset surface를 제거해 같은 패널의 일반 헤더 위계와 정렬했다. 토큰 변경 없음. +- 2026-08-29 · 온보딩 최상단에 현재 Google 이메일과 기존 secondary 계정 전환 동선을 표시해 잘못 선택한 계정에서 즉시 로그아웃할 수 있게 했다. 토큰 변경 없음. - 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으로 포커스를 옮긴다. diff --git a/apps/web/e2e/image-resilience.spec.ts b/apps/web/e2e/image-resilience.spec.ts new file mode 100644 index 0000000..35c64b1 --- /dev/null +++ b/apps/web/e2e/image-resilience.spec.ts @@ -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((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); + }); +}); diff --git a/apps/web/e2e/layout-visual-gate.spec.ts b/apps/web/e2e/layout-visual-gate.spec.ts index 0158e2b..23e1e9a 100644 --- a/apps/web/e2e/layout-visual-gate.spec.ts +++ b/apps/web/e2e/layout-visual-gate.spec.ts @@ -605,10 +605,10 @@ test.describe("layout visual gate @single-run", () => { timeout: 15_000, }); 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 head = document.querySelector(".sx-page--prestart .sx-head"); diff --git a/apps/web/e2e/self-directed-learning-loop.spec.ts b/apps/web/e2e/self-directed-learning-loop.spec.ts index cc41396..adfe7e2 100644 --- a/apps/web/e2e/self-directed-learning-loop.spec.ts +++ b/apps/web/e2e/self-directed-learning-loop.spec.ts @@ -1053,8 +1053,9 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi ); await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute( "aria-pressed", - "true", + "false", ); + await goals.getByRole("button", { name: /탐색/ }).click(); await goals.getByRole("button", { name: /개입/ }).click(); await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute( "aria-pressed", diff --git a/apps/web/e2e/session-layout.spec.ts b/apps/web/e2e/session-layout.spec.ts index e4f9e70..47c4c43 100644 --- a/apps/web/e2e/session-layout.spec.ts +++ b/apps/web/e2e/session-layout.spec.ts @@ -279,6 +279,46 @@ async function expectMainControlsUnclipped(page: Page) { 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(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>; + 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) { const result = await page.evaluate(() => { const right = document.querySelector(".sx-page--active .sx-col-right"); @@ -500,6 +540,7 @@ test.describe("learner session full-screen layout", () => { await expectSessionControlsInsideViewport(page); await expectNoVisibleSessionPanelOverlap(page); await expectMainControlsUnclipped(page); + await expectComposeControlsBottomAligned(page); await expectSessionPageHeightToMatchViewport(page); await expectActiveSessionUsableLayout(page); await expectRightPanelDoesNotIntersectSessionCore(page); diff --git a/apps/web/e2e/session-persistence.spec.ts b/apps/web/e2e/session-persistence.spec.ts index 4b92df1..8f0bc8f 100644 --- a/apps/web/e2e/session-persistence.spec.ts +++ b/apps/web/e2e/session-persistence.spec.ts @@ -689,6 +689,7 @@ test.describe("session persistence", () => { const persona = await fetchAvailablePersona(page, 1); 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/ }); await expect(cbtButton).toBeVisible(); await cbtButton.click(); diff --git a/apps/web/e2e/session-review.spec.ts b/apps/web/e2e/session-review.spec.ts index 0724c10..7abdef3 100644 --- a/apps/web/e2e/session-review.spec.ts +++ b/apps/web/e2e/session-review.spec.ts @@ -394,6 +394,10 @@ test.describe("session review", () => { growthPoints: [], degraded: true, reviewReady: false, + evaluationFailure: { + code: "timeout", + retryable: true, + }, teacherReview: { status: "pending", note: "", @@ -440,6 +444,9 @@ test.describe("session review", () => { page.getByText("저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다."), ).toBeVisible(); 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: "검토 완료" })).toBeDisabled(); @@ -474,6 +481,10 @@ test.describe("session review", () => { growthPoints: [], degraded: true, reviewReady: false, + evaluationFailure: { + code: "timeout", + retryable: true, + }, teacherReview: { status: "pending", note: "", @@ -503,13 +514,110 @@ test.describe("session review", () => { await page.getByRole("button", { name: "AI 평가 재시도" }).click(); await expect( - page.getByText("AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요."), + page.getByText("AI 평가가 제한 시간 안에 끝나지 않았습니다. 최신 상태를 다시 불러왔습니다."), ).toBeVisible(); await expect(page.getByText("engine_error: retry timeout")).toHaveCount(0); await expect(page.getByText("평가 실패")).toBeVisible(); await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeEnabled(); 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 }) => { diff --git a/apps/web/e2e/tabs-behavior.spec.ts b/apps/web/e2e/tabs-behavior.spec.ts new file mode 100644 index 0000000..61ea273 --- /dev/null +++ b/apps/web/e2e/tabs-behavior.spec.ts @@ -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", "개요"); + }); +}); diff --git a/apps/web/e2e/uc-learner-practice-history.spec.ts b/apps/web/e2e/uc-learner-practice-history.spec.ts index 01f07cf..8a672d9 100644 --- a/apps/web/e2e/uc-learner-practice-history.spec.ts +++ b/apps/web/e2e/uc-learner-practice-history.spec.ts @@ -401,13 +401,23 @@ test.describe("uc learner practice/history", () => { card.getByRole("button", { name: item.action, exact: true }), ).toBeVisible(); } - // 진행 중 카드에는 보관 버튼이 없고, 종료 카드에는 있다. - await expect( - cards.nth(0).getByRole("button", { name: "보관", exact: true }), - ).toHaveCount(0); - await expect( - cards.nth(2).getByRole("button", { name: "보관", exact: true }), - ).toBeVisible(); + // 진행 중 카드에는 보관·다음 회기 버튼이 없고, 종료 카드에는 있다. + await expect( + cards.nth(0).getByRole("button", { name: "보관", exact: true }), + ).toHaveCount(0); + await expect( + cards + .nth(0) + .getByRole("button", { name: "다음 회기 이어가기", exact: true }), + ).toHaveCount(0); + await expect( + cards.nth(2).getByRole("button", { name: "보관", exact: true }), + ).toBeVisible(); + await expect( + cards + .nth(2) + .getByRole("button", { name: "다음 회기 이어가기", exact: true }), + ).toBeVisible(); await expect( cards.nth(3).getByRole("button", { name: "복원", exact: true }), ).toBeVisible(); @@ -428,8 +438,8 @@ test.describe("uc learner practice/history", () => { await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`)); }); - // usecase: 종료된 기록 카드에서 '다시 연습'을 눌러 같은 내담자로 새 회기를 연다. - test("기록 카드의 다시 연습 버튼이 같은 내담자 새 회기로 이동한다", async ({ + // usecase: 종료된 기록 카드에서 다음 회기를 열어 같은 내담자의 맥락을 이어간다. + test("종료 기록의 다음 회기 이어가기 버튼이 같은 내담자 새 회기로 이동한다", async ({ page, }) => { await installFixtures(page); @@ -438,7 +448,7 @@ test.describe("uc learner practice/history", () => { const endCard = page.locator(".lh-session-card", { hasText: "김도윤" }); await expect(endCard).toHaveCount(1); await endCard - .getByRole("button", { name: "다시 연습", exact: true }) + .getByRole("button", { name: "다음 회기 이어가기", exact: true }) .click(); await expect(page).toHaveURL(/\/learn\/session\/P2$/); }); diff --git a/apps/web/e2e/uc-session-case-launch.spec.ts b/apps/web/e2e/uc-session-case-launch.spec.ts new file mode 100644 index 0000000..0fa96df --- /dev/null +++ b/apps/web/e2e/uc-session-case-launch.spec.ts @@ -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>; +} + +/** catch-all을 먼저 등록하고, 실제 fixture endpoint를 뒤에 두어 LIFO 우선순위를 고정한다. */ +async function installLaunchFixture(page: Page): Promise { + let memoryRequests = 0; + const startRequests: Array> = []; + + 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; + 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, + }); + }); + } +}); diff --git a/apps/web/e2e/uc-session-continuity-guard.spec.ts b/apps/web/e2e/uc-session-continuity-guard.spec.ts new file mode 100644 index 0000000..c79b2b2 --- /dev/null +++ b/apps/web/e2e/uc-session-continuity-guard.spec.ts @@ -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); + }); +}); diff --git a/apps/web/e2e/uc-session-conversation.spec.ts b/apps/web/e2e/uc-session-conversation.spec.ts index 393d6b6..2dc5a49 100644 --- a/apps/web/e2e/uc-session-conversation.spec.ts +++ b/apps/web/e2e/uc-session-conversation.spec.ts @@ -471,6 +471,34 @@ test.describe("회기 대화 — 텍스트 턴과 SSE 스트림", () => { ).toBeVisible(); expect(stream.texts[0]).toHaveLength(2000); 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(".sx-utt"); + const body = bubble.closest(".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); }); diff --git a/apps/web/e2e/uc-session-prestart.spec.ts b/apps/web/e2e/uc-session-prestart.spec.ts index bbd5ecd..fdcf177 100644 --- a/apps/web/e2e/uc-session-prestart.spec.ts +++ b/apps/web/e2e/uc-session-prestart.spec.ts @@ -255,69 +255,75 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => { } }); - // usecase: 학습자가 기본 선택된 회기 설정 요약(이론모드·목표)을 읽는다 - test("기본 회기 설정 요약이 인간중심 이론과 라포·탐색 목표를 보여준다", async ({ + // usecase: 학습자가 기본 회기 설정 요약과 첫 발화의 한 가지 초점을 읽는다 + test("기본 회기 설정 요약이 인간중심 이론과 라포 핵심 초점을 보여준다", async ({ page, - }, testInfo) => { + }) => { await routePrestartFixture(page); await gotoPrestart(page); 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); await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute( "aria-pressed", "true", ); - await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute( - "aria-pressed", - "true", - ); + await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute("aria-pressed", "false"); await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute( "aria-pressed", "false", ); - if (!testInfo.project.name.includes("mobile")) { - const plan = page.locator(".sx-prestart__plan"); - await expect(plan).toContainText("2개 선택"); - await expect(plan).toContainText("라포 · 탐색"); - await expect(plan).toContainText("60분 회기"); - await expect(plan).toContainText("종료 10분 전 알림"); - } + const plan = page.locator(".sx-prestart__plan"); + await expect(plan).toContainText("준비"); + await expect(plan).toContainText("대화"); + await expect(plan).toContainText("리뷰·재연습"); + 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: 학습자가 목표를 전부 해제해 보면 시작 버튼이 잠기고 안내를 받는다 test("목표를 모두 해제하면 회기 시작이 차단되고 선택 안내가 남는다", async ({ page, - }, testInfo) => { + }) => { await routePrestartFixture(page); await gotoPrestart(page); const goals = goalsGroup(page); await expect(startButton(page)).toBeEnabled(); await goals.getByRole("button", { name: /라포/ }).click(); - await goals.getByRole("button", { name: /탐색/ }).click(); await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute( "aria-pressed", "false", ); - await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute( - "aria-pressed", - "false", - ); await expect(startButton(page)).toBeDisabled(); await expect( page.locator("details.sx-prestart__settings > summary"), - ).toContainText("목표 선택 필요"); - if (!testInfo.project.name.includes("mobile")) { - await expect( - page.getByText("이번 회기 목표를 1개 이상 선택하면 시작할 수 있어요."), - ).toBeVisible(); - await expect(page.locator(".sx-prestart__plan")).toContainText("선택 필요"); - } + ).toContainText("핵심 초점 선택 필요"); + await expect(page.getByRole("heading", { name: "초점 선택 필요" })).toBeVisible(); + await expect(page.locator(".sx-prestart__plan")).toContainText( + "목표 하나를 고르면 첫 발화의 기준이 여기에 표시됩니다.", + ); + await expect( + page.getByText("오늘의 핵심 초점을 1개 이상 선택하면 시작할 수 있어요."), + ).toBeVisible(); }); // usecase: 목표 0개 상태에서 학습자가 목표 하나를 고르면 다시 시작할 수 있다 @@ -327,7 +333,6 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => { const goals = goalsGroup(page); await goals.getByRole("button", { name: /라포/ }).click(); - await goals.getByRole("button", { name: /탐색/ }).click(); await expect(startButton(page)).toBeDisabled(); await goals.getByRole("button", { name: /개입/ }).click(); @@ -338,11 +343,11 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => { await expect(startButton(page)).toBeEnabled(); await expect( page.locator("details.sx-prestart__settings > summary"), - ).toContainText("인간중심, 개입"); + ).toContainText("인간중심 · 개입"); }); // usecase: 학습자가 네 단계 목표를 전부 선택한다 — 선택지는 4개가 전부라 초과 선택은 불가능하다 - test("목표 4개를 모두 선택할 수 있고 선택지는 4개가 전부다", async ({ page }, testInfo) => { + test("목표 4개를 모두 선택할 수 있고 선택지는 4개가 전부다", async ({ page }) => { await routePrestartFixture(page); await gotoPrestart(page); @@ -351,6 +356,7 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => { // 회기 단계는 라포·탐색·개입·정리 4개뿐 — 4개 초과 선택 자체가 불가능한 UI 계약. await expect(goalButtons).toHaveCount(4); + await goals.getByRole("button", { name: /탐색/ }).click(); await goals.getByRole("button", { name: /개입/ }).click(); await goals.getByRole("button", { name: /정리/ }).click(); for (const name of [/라포/, /탐색/, /개입/, /정리/]) { @@ -363,9 +369,114 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => { page.locator("details.sx-prestart__settings > summary"), ).toContainText("라포 · 탐색 · 개입 · 정리"); 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(".vg-main"); + const actions = document.querySelector(".sx-prestart__actions"); + const cta = actions?.querySelector(".vg-btn"); + const primaryControl = document.querySelector(".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(".vg-main"); + const caseCard = document.querySelector(".sx-prestart__visual"); + const learning = document.querySelector(".sx-prestart__learning"); + const map = document.querySelector(".sx-prestart__plan"); + const actions = document.querySelector(".sx-prestart__actions"); + const cta = actions?.querySelector(".vg-btn"); + const bottomNav = document.querySelector('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: 학습자가 선택돼 있던 목표 하나를 다시 눌러 해제한다 @@ -375,13 +486,16 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => { const goals = goalsGroup(page); const exploreGoal = goals.getByRole("button", { name: /탐색/ }); + await expect(exploreGoal).toHaveAttribute("aria-pressed", "false"); + + await exploreGoal.click(); await expect(exploreGoal).toHaveAttribute("aria-pressed", "true"); await exploreGoal.click(); await expect(exploreGoal).toHaveAttribute("aria-pressed", "false"); await expect( page.locator("details.sx-prestart__settings > summary"), - ).toContainText("인간중심, 라포"); + ).toContainText("인간중심 · 라포"); // 나머지 기본 목표는 그대로 유지된다. await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute( "aria-pressed", @@ -393,10 +507,11 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => { // usecase: 학습자가 이론모드를 CBT로 바꿔 회기 접근을 조정한다 test("이론모드를 CBT로 바꾸면 선택 상태와 진행 초점이 갱신된다", async ({ page, - }, testInfo) => { + }) => { await routePrestartFixture(page); await gotoPrestart(page); + await page.locator("details.sx-prestart__settings > summary").click(); const theory = page.getByRole("group", { name: "이론모드 선택" }); await expect(theory.getByRole("button", { name: /인간중심/ })).toHaveAttribute( "aria-pressed", @@ -414,12 +529,10 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => { ); await expect( page.locator("details.sx-prestart__settings > summary"), - ).toContainText("CBT, 라포 · 탐색"); - if (!testInfo.project.name.includes("mobile")) { - await expect(page.locator(".sx-prestart__plan")).toContainText( - "상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.", - ); - } + ).toContainText("CBT · 라포"); + await expect(page.locator(".sx-prestart__settings-note")).toContainText( + "상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.", + ); }); // usecase: 학습자가 회기 시작을 눌러 active 회기로 넘어간다 — 고른 목표·이론이 요청에 실린다 @@ -429,8 +542,7 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => { const fixture = await routePrestartFixture(page); await gotoPrestart(page); - // 기본 선택에서 탐색을 해제해 라포 1개만 목표로 남긴다(자유 선택 검증). - await goalsGroup(page).getByRole("button", { name: /탐색/ }).click(); + // 기본 선택인 라포 한 가지 초점으로 시작한다. await startButton(page).click(); await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); @@ -465,6 +577,29 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => { 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)에서 넘어온 학습자가 처방 맥락을 프리스타트에서 확인한다 test("G4 처방 연습 launch intent가 프리스타트에 난도 사다리 맥락으로 표시된다", async ({ page, diff --git a/apps/web/e2e/uc-settings-consent.spec.ts b/apps/web/e2e/uc-settings-consent.spec.ts index e2d2fc5..06ead39 100644 --- a/apps/web/e2e/uc-settings-consent.spec.ts +++ b/apps/web/e2e/uc-settings-consent.spec.ts @@ -141,6 +141,14 @@ async function mountSettingsFixtures( 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) => route.fulfill( jsonRoute({ @@ -471,6 +479,44 @@ test.describe("설정·동의 관리 유스케이스", () => { 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: 학습자가 표시 이름을 빈값으로 저장 시도했다가 서버 거절을 받는다 test("표시 이름을 빈값으로 저장하면 거짓 저장됨 표시 없이 입력 상태가 유지된다", async ({ page, diff --git a/apps/web/scripts/check-design-ssot.mjs b/apps/web/scripts/check-design-ssot.mjs index fcd603d..27576a1 100644 --- a/apps/web/scripts/check-design-ssot.mjs +++ b/apps/web/scripts/check-design-ssot.mjs @@ -20,17 +20,23 @@ function countRawColors(source) { const shellPath = "src/components/shell/shell.css"; const surfacePath = "src/components/ui/ui.css"; +const tabsPath = "src/components/ui/Tabs.tsx"; const settingsPath = "src/pages/settings/settings.css"; const sessionPath = "src/pages/Session.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 appPath = "src/App.tsx"; const designDocPath = "../../docs/DESIGN_CONCEPT.md"; const shell = await read(shellPath); const surface = await read(surfacePath); +const tabs = await read(tabsPath); const settings = await read(settingsPath); const session = await read(sessionPath); const learnerHome = await read(learnerHomePath); +const admin = await read(adminPath); +const personaStudio = await read(personaStudioPath); const personaViewModel = await read(personaViewModelPath); const app = await read(appPath); const designDoc = await read(designDocPath); @@ -95,6 +101,26 @@ forbid( /#[0-9a-f]{3,8}|rgba?\(/i, "공통 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( shellPath, shell, @@ -113,6 +139,21 @@ forbid( /#[0-9a-f]{3,8}|rgba?\(/i, "학습자 화면의 아바타 팔레트는 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( settingsPath, settings, diff --git a/apps/web/src/components/avatar/ResilientImage.tsx b/apps/web/src/components/avatar/ResilientImage.tsx new file mode 100644 index 0000000..9744ef9 --- /dev/null +++ b/apps/web/src/components/avatar/ResilientImage.tsx @@ -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, + "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 ? ( + + {fallback} + + ) : null} + {normalizedSrc && !failed ? ( + { + setFailedSrc(""); + setLoadedSrc(normalizedSrc); + }} + onError={() => { + setLoadedSrc(""); + setFailedSrc(normalizedSrc); + }} + /> + ) : null} + + ); +} diff --git a/apps/web/src/components/shell/AppShell.tsx b/apps/web/src/components/shell/AppShell.tsx index 42738a9..82a53be 100644 --- a/apps/web/src/components/shell/AppShell.tsx +++ b/apps/web/src/components/shell/AppShell.tsx @@ -162,6 +162,13 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi .filter(Boolean) .join(" ")} > + window.requestAnimationFrame(() => mainRef.current?.focus())} + > + 본문으로 건너뛰기 + {hideTopbar ? null : }
{showNav ? ( @@ -170,7 +177,7 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi showAdminEntry={canAccessRole(user, "admin")} /> ) : null} -
+
{children}
diff --git a/apps/web/src/components/shell/Topbar.tsx b/apps/web/src/components/shell/Topbar.tsx index b15f0cb..bfd33df 100644 --- a/apps/web/src/components/shell/Topbar.tsx +++ b/apps/web/src/components/shell/Topbar.tsx @@ -1,5 +1,6 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import { Icon } from "../ui/Icon"; +import { ResilientImage } from "../avatar/ResilientImage"; import { accessibleRolesFor, canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth"; import { useTheme } from "../../lib/useTheme"; @@ -101,7 +102,11 @@ export function Topbar({ contextLabel }: TopbarProps) { <> {user.name} diff --git a/apps/web/src/components/shell/shell.css b/apps/web/src/components/shell/shell.css index 941ac9f..5bd0cc4 100644 --- a/apps/web/src/components/shell/shell.css +++ b/apps/web/src/components/shell/shell.css @@ -17,6 +17,29 @@ .vg-shell--fullscreen { 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 { diff --git a/apps/web/src/components/ui/Tabs.tsx b/apps/web/src/components/ui/Tabs.tsx new file mode 100644 index 0000000..41dcf2a --- /dev/null +++ b/apps/web/src/components/ui/Tabs.tsx @@ -0,0 +1,125 @@ +import { + useRef, + type KeyboardEvent, + type ReactNode, +} from "react"; + +export interface TabItem { + value: T; + label: ReactNode; + disabled?: boolean; +} + +export interface TabsProps { + id: string; + ariaLabel: string; + items: readonly TabItem[]; + 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({ + id, + ariaLabel, + items, + value, + onValueChange, + listClassName, + panelClassName, + children, +}: TabsProps) { + const triggerRefs = useRef>([]); + 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, + 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 ( + <> +
+ {items.map((item, index) => { + const selected = value === item.value; + return ( + + ); + })} +
+
+ {children} +
+ + ); +} diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts index a0b5ea5..0a67073 100644 --- a/apps/web/src/components/ui/index.ts +++ b/apps/web/src/components/ui/index.ts @@ -16,6 +16,9 @@ export type { PanelProps } from "./Panel"; export { Surface, surfaceClassName } from "./Surface"; export type { SurfaceProps, SurfaceOptions, SurfaceVariant } from "./Surface"; +export { Tabs } from "./Tabs"; +export type { TabsProps, TabItem } from "./Tabs"; + export { Kicker } from "./Kicker"; export type { KickerProps } from "./Kicker"; diff --git a/apps/web/src/components/ui/ui.css b/apps/web/src/components/ui/ui.css index 5de5c32..11e3184 100644 --- a/apps/web/src/components/ui/ui.css +++ b/apps/web/src/components/ui/ui.css @@ -160,6 +160,16 @@ 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만 소유한다. ── */ .vg-card { padding: var(--sp-5); diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts index 2779091..2dec17a 100644 --- a/apps/web/src/lib/api.gen.ts +++ b/apps/web/src/lib/api.gen.ts @@ -728,7 +728,8 @@ export interface paths { * @description 회기 전체 deep-loop 재평가(슈퍼바이저 rationale/critique + 개선점 + 대안발화). * * 저장된 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다. - * 엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출). + * 엔진 장애는 503으로 변환한다. provider 예외 원문은 durable 기록에만 남기고 HTTP에는 + * 안전한 안내만 반환한다. */ post: operations["reevaluate_session_eval_sessions__session_id__reevaluate_post"]; delete?: never; @@ -1667,6 +1668,46 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -4083,6 +4124,61 @@ export interface components { /** Transfer Suites */ 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: { /** Catalog Entry Id */ @@ -5106,7 +5202,7 @@ export interface components { }; /** * EvaluationSummary - * @description 회기 평가 조회 응답(분포 + deep 결과 합본). + * @description 회기 평가 조회 응답(분포 + deep 결과 합본, provider 오류 원문 제외). */ EvaluationSummary: { /** Deep */ @@ -5120,6 +5216,7 @@ export interface components { durable: boolean; /** Error */ error?: string | null; + failure?: components["schemas"]["ReviewEvaluationFailure"] | null; /** Session Id */ session_id: string; /** Stage */ @@ -5843,6 +5940,33 @@ export interface components { /** Uncertainty */ 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: { /** Detail */ @@ -6117,6 +6241,11 @@ export interface components { archived: boolean; /** Archived At */ archived_at?: string | null; + /** + * Case Id + * @default + */ + case_id: string; /** Client Turn Count */ client_turn_count: number; /** Ended At */ @@ -8899,6 +9028,19 @@ export interface components { /** Persona */ 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: { /** @@ -9546,6 +9688,7 @@ export interface components { durationLabel: string; /** Durationseconds */ durationSeconds: number; + evaluationFailure?: components["schemas"]["ReviewEvaluationFailure"] | null; firstSessionChecklist?: components["schemas"]["ReviewFirstSessionChecklist"] | null; /** Goodmoments */ goodMoments?: components["schemas"]["ReviewPoint"][]; @@ -9640,6 +9783,8 @@ export interface components { }; /** SessionStartRequest */ SessionStartRequest: { + /** Case Id */ + case_id?: string | null; /** Goal Stages */ goal_stages?: ("라포" | "탐색" | "개입" | "정리")[]; /** @@ -9647,6 +9792,12 @@ export interface components { * @example P1 */ persona_code: string; + /** + * Start Mode + * @default continue + * @enum {string} + */ + start_mode: "continue" | "fresh"; /** * Theory Mode * @default humanistic @@ -9692,6 +9843,12 @@ export interface components { * @enum {string} */ stage: "라포" | "탐색" | "개입" | "정리"; + /** + * Start Mode + * @default continue + * @enum {string} + */ + start_mode: "continue" | "fresh"; /** * Started At * @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: { parameters: { query?: never; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 526cc84..c3d07ae 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -224,6 +224,7 @@ export type PersonaDraftGenerateResponse = ApiSchema<"PersonaDraftGenerateRespon /** POST /sessions — sessions.py SessionStartResponse */ export type SessionStartResponse = ApiSchema<"SessionStartResponse">; +export type SessionStartMode = ApiSchema<"SessionStartRequest">["start_mode"]; /** P2 단계 누적 게이지·상세 수치 — session_read_model.SessionProgress */ export type SessionProgress = ApiSchema<"SessionProgress">; export type SessionStageProgress = ApiSchema<"SessionStageProgress">; @@ -248,6 +249,10 @@ export type LearnerSessionSummary = ApiSchema<"LearnerSessionSummary">; export type SessionArchiveResponse = ApiSchema<"SessionArchiveResponse">; 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 LearnerDashboardPersonaProgress = ApiSchema<"LearnerDashboardPersonaProgress">; export type LearnerDashboardAchievement = ApiSchema<"LearnerDashboardAchievement">; @@ -269,6 +274,7 @@ export type ReviewPhaseSegment = ApiSchema<"ReviewPhaseSegment">; export type ReviewValencePoint = ApiSchema<"ReviewValencePoint">; export type ReviewRubricRow = ApiSchema<"ReviewRubricRow">; export type ReviewPoint = ApiSchema<"ReviewPoint">; +export type ReviewEvaluationFailure = ApiSchema<"ReviewEvaluationFailure">; export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">; export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">; @@ -537,12 +543,27 @@ export const sessionApi = { }, get: (sessionId: string) => api.get(`/sessions/${encodeURIComponent(sessionId)}`), + cases: (personaCode: string) => + api.get( + `/sessions/cases?persona_code=${encodeURIComponent(personaCode)}`, + ), + caseMemory: (caseId: string) => + api.get( + `/sessions/cases/${encodeURIComponent(caseId)}/memory`, + ), start: ( persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic", goal_stages: SessionStage[] = [], + options: { startMode?: SessionStartMode; caseId?: string } = {}, ) => - api.post("/sessions", { persona_code, theory_mode, goal_stages }), + api.post("/sessions", { + persona_code, + theory_mode, + goal_stages, + start_mode: options.startMode ?? "continue", + case_id: options.caseId ?? null, + }), turn: (sessionId: string, text: string) => api.post(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }), liveCoach: (sessionId: string, payload: LiveCoachRequest) => diff --git a/apps/web/src/lib/auth.tsx b/apps/web/src/lib/auth.tsx index 4a8fa4d..4f38915 100644 --- a/apps/web/src/lib/auth.tsx +++ b/apps/web/src/lib/auth.tsx @@ -12,7 +12,6 @@ import { AUTH_EXPIRED_EVENT, ApiError, api, - apiUrl, authApi, type MeResponse, } from "./api"; @@ -172,7 +171,8 @@ function userFromMe(me: MeResponse): AuthUser { onboardingCompletedAt: onboarding.onboarding_completed_at ?? null, nickname, selfIntroduction: (me.self_introduction ?? "").trim(), - avatarUrl: avatarUrl ? apiUrl(avatarUrl) : "", + // 이미지 URL의 scheme·상대경로 검증은 실제 렌더 경계(ResilientImage)가 소유한다. + avatarUrl, }; } diff --git a/apps/web/src/pages/Admin.tsx b/apps/web/src/pages/Admin.tsx index cfb3f62..ad57297 100644 --- a/apps/web/src/pages/Admin.tsx +++ b/apps/web/src/pages/Admin.tsx @@ -26,6 +26,7 @@ import { Kicker, ProgressBar, surfaceClassName, + Tabs, } from "../components/ui"; import { adminApi, @@ -2076,25 +2077,28 @@ export default function Admin({ section = "overview" }: AdminProps) { {usersError ? : null} - + id="admin-users-tabs" ariaLabel="사용자 관리 탭" items={[ - [ - "approval", - `가입 승인${accountCounts.pending ? ` ${accountCounts.pending}` : ""}`, - ], - ["manage", "사용자 목록"], - ["register", "외부 연구참여자 사전등록"], - ["activity", "활동 요약"], + { + value: "approval", + label: `가입 승인${accountCounts.pending ? ` ${accountCounts.pending}` : ""}`, + }, + { value: "manage", label: "사용자 목록" }, + { value: "register", label: "외부 연구참여자 사전등록" }, + { value: "activity", label: "활동 요약" }, ]} value={userTab} - onChange={setUserTab} - /> - - {userTab === "approval" ? renderApprovalQueue() : null} - {userTab === "manage" ? renderUserList() : null} - {userTab === "register" ? renderUserCreate() : null} - {userTab === "activity" ? renderUserActivity() : null} + onValueChange={setUserTab} + listClassName="vgops-tabs" + panelClassName="vgops-tabs__panel" + > + {userTab === "approval" ? renderApprovalQueue() : null} + {userTab === "manage" ? renderUserList() : null} + {userTab === "register" ? renderUserCreate() : null} + {userTab === "activity" ? renderUserActivity() : null} + ); @@ -2171,10 +2175,7 @@ export default function Admin({ section = "overview" }: AdminProps) { {pendingUsers.length > 0 ? (
{pendingUsers.map((user) => ( -
+
@@ -2869,91 +2870,94 @@ export default function Admin({ section = "overview" }: AdminProps) { 기준을 먼저 고정합니다.
) : null} - + id="admin-access-tabs" ariaLabel="접근 권한 탭" items={[ - ["roles", "역할"], - ["groups", "그룹"], - ["matrix", "권한 매트릭스"], - ["protocols", "상담 프로토콜"], + { value: "roles", label: "역할" }, + { value: "groups", label: "그룹" }, + { value: "matrix", label: "권한 매트릭스" }, + { value: "protocols", label: "상담 프로토콜" }, ]} value={accessTab} - onChange={setAccessTab} - /> - - {accessTab === "roles" ? ( -
- {ROLE_POLICIES.map((policy) => ( -
-
-

{policy.role}

- - {policy.permissions.length}개 권한 - -
-

{policy.scope}

-
- {policy.permissions.map((permission) => ( - {permission} - ))} -
- {policy.risk} -
- ))} -
- ) : null} - - {accessTab === "groups" ? ( -
- {GROUP_POLICIES.map((group) => ( -
-
-

{group.name}

- 정책 초안 -
-

{group.scope}

-
- {group.access.map((item) => ( - {item} - ))} -
-
- ))} -
- ) : null} - - {accessTab === "matrix" ? ( -
-
-
- 리소스 - 관리자 - 교수자 - 학습자 -
- {PERMISSION_MATRIX.map((row) => ( -
- {row.resource} - {row.admin} - {row.teacher} - {row.learner} -
+ onValueChange={setAccessTab} + listClassName="vgops-tabs" + panelClassName="vgops-tabs__panel" + > + {accessTab === "roles" ? ( +
+ {ROLE_POLICIES.map((policy) => ( +
+
+

{policy.role}

+ + {policy.permissions.length}개 권한 + +
+

{policy.scope}

+
+ {policy.permissions.map((permission) => ( + {permission} + ))} +
+ {policy.risk} +
))} -
-
- ) : null} + + ) : null} - {accessTab === "protocols" ? renderProtocols() : null} + {accessTab === "groups" ? ( +
+ {GROUP_POLICIES.map((group) => ( +
+
+

{group.name}

+ 정책 초안 +
+

{group.scope}

+
+ {group.access.map((item) => ( + {item} + ))} +
+
+ ))} +
+ ) : null} + + {accessTab === "matrix" ? ( +
+
+
+ 리소스 + 관리자 + 교수자 + 학습자 +
+ {PERMISSION_MATRIX.map((row) => ( +
+ {row.resource} + {row.admin} + {row.teacher} + {row.learner} +
+ ))} +
+
+ ) : null} + + {accessTab === "protocols" ? renderProtocols() : null} + ); @@ -3125,12 +3129,7 @@ export default function Admin({ section = "overview" }: AdminProps) { duplicateParentId !== ticket.ticket_id && duplicateParentId !== ticket.parent_ticket_id; return ( -
+
{/* 담당 그룹·처리 이력은 아래 에서 가운뎃점으로 이어 붙었었다. 칸 구분이 있는 meta 행으로 옮겨 은 가운뎃점 1개만 남긴다. */} @@ -3212,9 +3211,7 @@ export default function Admin({ section = "overview" }: AdminProps) { > {recentResolvedTickets.map((ticket) => (
@@ -3291,39 +3288,6 @@ function EmptyState({ title, body }: { title: string; body: string }) { ); } -function TabBar({ - ariaLabel, - items, - value, - onChange, -}: { - ariaLabel: string; - items: Array<[T, string]>; - value: T; - onChange: (value: T) => void; -}) { - return ( -
- {items.map(([id, label]) => ( - - ))} -
- ); -} - function RoleMeter({ label, value, diff --git a/apps/web/src/pages/LearnerHome.tsx b/apps/web/src/pages/LearnerHome.tsx index 40512d7..256da08 100644 --- a/apps/web/src/pages/LearnerHome.tsx +++ b/apps/web/src/pages/LearnerHome.tsx @@ -16,11 +16,14 @@ import { Button, Icon, Kicker, surfaceClassName } from "../components/ui"; import { personaApi, sessionApi, + type CaseMemoryPreview, type LearnerDashboardPersonaProgress, type LearnerDashboardResponse, + type LearnerCaseSummary, type LearnerSessionSummary, type PersonaSummary, type SessionDetailResponse, + type SessionStartMode, } from "../lib/api"; import { displayPiiSafeText } from "../lib/piiDisplay"; import { @@ -71,6 +74,7 @@ import { import "./learner-home.css"; type LoadState = "loading" | "ready" | "error"; +type MemoryLoadState = "idle" | LoadState; export type LearnerHomeView = "dashboard" | "practice" | "history"; const DASHBOARD_TABS = [ @@ -110,6 +114,14 @@ function oneMiddot(text: string): string { 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 { view?: LearnerHomeView; } @@ -142,6 +154,20 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) { null, ); const [recapLoadState, setRecapLoadState] = useState("loading"); + // 새 사례와 이어지는 사례는 같은 NPC라도 case_id가 다르다. Home은 시작 전 + // 선택만 담당하고, 기억 본문은 사용자가 foldout을 열 때까지 요청하지 않는다. + const [launchMode, setLaunchMode] = useState("fresh"); + const [continuityCases, setContinuityCases] = useState([]); + const [selectedContinuityCaseId, setSelectedContinuityCaseId] = useState< + string | null + >(null); + const [continuityLoadState, setContinuityLoadState] = + useState("loading"); + const [memoryPreview, setMemoryPreview] = useState( + null, + ); + const [memoryLoadState, setMemoryLoadState] = + useState("idle"); const [historyFilter, setHistoryFilter] = useState("all"); const [historyQuery, setHistoryQuery] = useState(""); const [archiveBusyId, setArchiveBusyId] = useState(null); @@ -270,6 +296,101 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) { null, [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( () => [...sessions].sort((a, b) => sessionSortTime(b) - sessionSortTime(a)), [sessions], @@ -477,12 +598,23 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) { if (selected && isUsablePersona(selected)) { if (voicePracticeRequested && !voicePracticeContext) return; if (practiceLaunchRequested && !practiceLaunchIntent) return; - const suffix = practiceLaunchIntent - ? `?${practiceLaunchSearch(practiceLaunchIntent)}` + if (caseModeAvailable && activeCaseSessionId) { + navigate(`/learn/session/${encodeURIComponent(activeCaseSessionId)}`); + return; + } + const params = practiceLaunchIntent + ? new URLSearchParams(practiceLaunchSearch(practiceLaunchIntent)) : voicePracticeContext - ? `?${voicePracticeSearch(voicePracticeContext)}` - : ""; - navigate(`/learn/session/${selected.code}${suffix}`); + ? new URLSearchParams(voicePracticeSearch(voicePracticeContext)) + : new URLSearchParams(); + 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) { : "기록"} )} - + {session.status === "ended" ? ( + + ) : null} {session.status === "ended" ? (
+ {caseModeAvailable ? ( +
+ {activeCaseSessionId ? ( +
+ 진행 중인 사례 +

+ 현재 {selected?.code} 회기가 열려 있습니다. 위 버튼으로 + 그 회기를 이어가면 됩니다. +

+
+ ) : null} + {!activeCaseSessionId && launchMode === "fresh" ? ( +
+ 새 사례 +

+ 이전 회기의 요약, 미해결 주제, 고정 기억을 가져오지 않고 + 1회기부터 시작합니다. 기존 사례 기록은 보존됩니다. +

+
+ ) : null} + {(activeCaseSessionId || launchMode === "continue") && + continuityLoadState === "loading" ? ( +

+ 이 내담자의 이어지는 사례를 확인하고 있습니다. +

+ ) : null} + {(activeCaseSessionId || launchMode === "continue") && + continuityLoadState === "error" ? ( +

+ 연속 사례 통계를 확인하지 못했습니다. 이어가기는 열지 않았고, + 완전히 새로 시작은 계속할 수 있습니다. +

+ ) : null} + {(activeCaseSessionId || launchMode === "continue") && + continuityLoadState === "ready" && + selectedContinuityCase ? ( + <> + {continuityCases.length > 1 ? ( + + ) : null} +
+
+ 이어서 진행할 사례 +

+ 이 사례에서 쌓인 기록만 다음 회기에 이어집니다. +

+
+ 다음 {selectedContinuityCase.last_session_no + 1}회기 +
+
+ + {selectedContinuityProgress.total_sessions}회기 + 총 회기 + + + {selectedContinuityProgress.total_turns}턴 + 대화 턴 + + + + {formatCaseDuration( + selectedContinuityProgress.total_duration_seconds, + )} + + 누적 회기 시간 + +
+
{ + if (event.currentTarget.open) loadContinuityMemory(); + }} + > + + 내담자 기억 + 필요할 때만 펼쳐 보기 + + +
+ {memoryLoadState === "loading" || + memoryPreview?.case_id !== selectedContinuityCase.case_id ? ( +

기억 요약을 불러오고 있습니다.

+ ) : memoryLoadState === "error" ? ( +

+ 기억 요약을 불러오지 못했습니다. 원문 대화는 표시하지 + 않습니다. +

+ ) : memoryPreview?.memory_available ? ( +
+ {memoryPreview.latest_session_digest ? ( +
+
지난 회기 요약
+
{memoryPreview.latest_session_digest}
+
+ ) : null} + {memoryPreview.case_digest ? ( +
+
사례의 큰 흐름
+
{memoryPreview.case_digest}
+
+ ) : null} + {(memoryPreview.open_threads ?? []).length > 0 ? ( +
+
이어 볼 주제
+
+
    + {(memoryPreview.open_threads ?? []).map((thread) => ( +
  • {thread}
  • + ))} +
+
+
+ ) : null} + {(memoryPreview.pinned_facts ?? []).length > 0 ? ( +
+
계속 기억하는 내용
+
+
    + {(memoryPreview.pinned_facts ?? []).map((fact) => ( +
  • {fact}
  • + ))} +
+
+
+ ) : null} +
+ ) : ( +

+ 이 사례에는 다음 회기에 넘길 압축 기억이 아직 없습니다. +

+ )} +
+
+ + ) : (activeCaseSessionId || launchMode === "continue") && + continuityLoadState === "ready" ? ( +

+ 이어갈 사례가 없습니다. 완전히 새로 시작할 수 있습니다. +

+ ) : null} +
+ ) : null} +
(null); const [form, setForm] = useState(EMPTY_FORM); const [loading, setLoading] = useState(true); @@ -105,12 +106,17 @@ export default function Onboarding() { [form], ); - const avatarSrc = form.avatar_url ? apiUrl(form.avatar_url) : ""; + const avatarSrc = form.avatar_url; const update = (key: K, value: OnboardingForm[K]) => { setForm((current) => ({ ...current, [key]: value })); }; + const signOut = async () => { + await logout(); + navigate("/login", { replace: true }); + }; + const uploadAvatar = async (file: File | null) => { if (!file || uploadingAvatar) return; setUploadingAvatar(true); @@ -162,6 +168,21 @@ export default function Onboarding() { <>
+
+
+ 현재 Google 계정 + {user?.email ?? "계정 확인 중"} +
+ +
+

Vignette 가입 설정

가입 정보를 입력합니다.

@@ -180,7 +201,11 @@ export default function Onboarding() {
상징 아바타 diff --git a/apps/web/src/pages/PersonaStudio.tsx b/apps/web/src/pages/PersonaStudio.tsx index 8990b9f..ec37cd7 100644 --- a/apps/web/src/pages/PersonaStudio.tsx +++ b/apps/web/src/pages/PersonaStudio.tsx @@ -10,6 +10,7 @@ import { Icon, Kicker, surfaceClassName, + Tabs, } from "../components/ui"; import { ApiError, @@ -2758,32 +2759,23 @@ export default function PersonaStudio() {
-
+ id="persona-authoring-tabs" + ariaLabel="저작 섹션" + items={STUDIO_TABS.map((tab) => ({ + value: tab.key, + label: tab.label, + }))} + value={activeTab} + onValueChange={setActiveTab} + listClassName="ps-tabs" + panelClassName="ps-tabs__panel" > - {STUDIO_TABS.map((tab) => ( - - ))} -
- - - - {renderTab()} - + + + {renderTab()} + + {formError ? (

diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index 861e935..59f6b01 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -40,6 +40,7 @@ import { type SessionDetailResponse, type SessionProgress, type SessionStage, + type SessionStartMode, } from "../lib/api"; import { useAuth } from "../lib/auth"; import { formatElapsed, formatTimecode, clamp01 } from "../lib/format"; @@ -135,6 +136,13 @@ const SESSION_PHASES: PhaseInfo[] = [ { 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: { value: TheoryMode; label: string; @@ -366,6 +374,22 @@ export default function Session() { () => parsePracticeLaunchIntent(searchParams), [searchParams], ); + // Home에서 고른 사례 방식만 REST 시작 요청으로 전달한다. 음성 재연습/처방은 + // 출처 URL 계약을 우선하므로 이 선택기를 덮어쓰지 않는다. + const startMode = useMemo( + () => + !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( () => practiceLaunchIntent @@ -416,8 +440,10 @@ export default function Session() { const [consentChecked, setConsentChecked] = useState(false); const [consentBusy, setConsentBusy] = useState(false); const [selectedTheoryMode, setSelectedTheoryMode] = useState("humanistic"); - // 이번 회기 목표(2026-07-13 회의 P1): 4단계 전부가 아니라 1~2개를 고르고 시작한다. - const [selectedGoals, setSelectedGoals] = useState(["라포", "탐색"]); + // 새 회기는 한 가지 수행 초점에서 시작한다. 처방 재연습은 검증된 라포·탐색 과업을 유지한다. + const [selectedGoals, setSelectedGoals] = useState( + practiceLaunchIntent ? ["라포", "탐색"] : ["라포"], + ); // ── 회기/대화 상태 ── const [stage, setStage] = useState("라포"); @@ -504,6 +530,9 @@ export default function Session() { const [leftPanelCollapsed, setLeftPanelCollapsed] = useState(false); const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false); const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false); + const shortcutHelpPanelRef = useRef(null); + const shortcutHelpConfirmRef = useRef(null); + const shortcutHelpPreviousFocusRef = useRef(null); const [ctxCollapsed, setCtxCollapsed] = useState(true); const [metersCollapsed, setMetersCollapsed] = useState(false); const [safetyCollapsed, setSafetyCollapsed] = useState(true); @@ -533,6 +562,10 @@ export default function Session() { const pendingVoiceLearnerTextRef = useRef(""); const coachEvidenceCloseRef = useRef(null); const coachHistoryCloseRef = useRef(null); + const coachEvidencePanelRef = useRef(null); + const coachHistoryPanelRef = useRef(null); + const coachEvidencePreviousFocusRef = useRef(null); + const coachHistoryPreviousFocusRef = useRef(null); const coachCreditSeenRef = useRef>(new Set()); const coachCreditPulseTimerRef = useRef(null); @@ -929,15 +962,27 @@ export default function Session() { [applyCoachCreditEvents, liveSessionId], ); + const openCoachEvidence = useCallback(() => { + if (!coachEvidenceOpen) { + coachEvidencePreviousFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + } + setCoachEvidenceOpen(true); + }, [coachEvidenceOpen]); + const openCoachHistory = useCallback( (turnSeq: number | null = null) => { + if (!coachHistoryOpen) { + coachHistoryPreviousFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + } setCoachHistoryTurnSeq(turnSeq); setCoachHistoryError(null); setCoachEvidenceOpen(false); setCoachHistoryOpen(true); void refreshCoachHistory(liveSessionId, { surfaceErrors: true }); }, - [liveSessionId, refreshCoachHistory], + [coachHistoryOpen, liveSessionId, refreshCoachHistory], ); const requestLiveCoach = useCallback( @@ -1294,13 +1339,17 @@ export default function Session() { }; }, [navigate, pushSignal, routeIsSessionId, routeParam, voicePracticeContext]); - // 이번 회기 목표 토글 — 1~4개 자유 선택(소유자 지시 2026-07-15). + // 이번 회기 목표 — 핵심 1개 + 보조 0~3개를 유지한다(소유자 지시 2026-07-15). const toggleGoal = useCallback((goal: SessionStage) => { setSelectedGoals((prev) => 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 () => { if (voicePracticeRequested && !voicePracticeContext) { @@ -1320,7 +1369,10 @@ export default function Session() { setResumedSessionLoaded(false); setClientReplyPending(false); 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 이 이걸 써야 라이브 setSessionEnded(false); setReviewReady(false); @@ -1385,6 +1437,25 @@ export default function Session() { 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 ( err instanceof ApiError && err.detail === "session_persistence_unavailable" @@ -1398,7 +1469,7 @@ export default function Session() { } finally { 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) => { setAlliancePreGateBlocked(blocked); @@ -2373,9 +2444,13 @@ export default function Session() { } // 2. 단축키 도움말: ? (입력창 밖) 또는 Ctrl+/ or Alt+/ + // 브라우저·키보드 레이아웃에 따라 Shift+/가 key="?" 대신 key="/"+shiftKey로 + // 들어올 수 있으므로 두 형태를 모두 같은 단축키로 취급한다. + const isQuestionMarkShortcut = e.key === "?" || (e.shiftKey && e.key === "/"); if ( ((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(); setShortcutHelpOpen((prev) => !prev); @@ -2471,7 +2546,7 @@ export default function Session() { // Alt+E: AI 코칭 근거 if (isAlt && e.key.toLowerCase() === "e") { e.preventDefault(); - setCoachEvidenceOpen(true); + openCoachEvidence(); return; } @@ -2504,6 +2579,7 @@ export default function Session() { rightPanelCollapsed, sessionEnded, jumpToLatest, + openCoachEvidence, openCoachHistory, shortcutHelpOpen, coachEvidenceOpen, @@ -2554,12 +2630,63 @@ export default function Session() { voiceConsentPreviousFocusRef.current = null; }, [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( + "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(() => { if (!coachEvidenceOpen) return; window.setTimeout(() => coachEvidenceCloseRef.current?.focus(), 0); const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") { setCoachEvidenceOpen(false); + return; + } + if (event.key !== "Tab") return; + const controls = Array.from( + coachEvidencePanelRef.current?.querySelectorAll( + "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); @@ -2572,12 +2699,43 @@ export default function Session() { const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") { setCoachHistoryOpen(false); + return; + } + if (event.key !== "Tab") return; + const controls = Array.from( + coachHistoryPanelRef.current?.querySelectorAll( + "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); }, [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(() => { if (!endDialogOpen) return; endPreviousFocusRef.current = @@ -2705,16 +2863,14 @@ export default function Session() { !sessionEnded; const elapsedLabel = timeUp && !sessionEnded ? "시간 만료" : formatTimecode(elapsed); const remainingLabel = formatTimecode(remainingSeconds); - const limitMinutesLabel = Math.round(sessionLimitSeconds / 60); - const warningMinutesLabel = Math.max(1, Math.round(sessionWarningSeconds / 60)); const selectedTheoryOption = THEORY_MODE_OPTIONS.find((option) => option.value === selectedTheoryMode) ?? THEORY_MODE_OPTIONS[0]; - const selectedGoalSummary = SESSION_PHASES.filter((phase) => - selectedGoals.includes(phase.key), - ) - .map((phase) => phase.key) - .join(" · "); + const selectedGoalPhases = selectedGoals + .map((goal) => SESSION_PHASES.find((phase) => phase.key === goal)) + .filter((phase): phase is PhaseInfo => phase != null); + const primaryGoal = selectedGoalPhases[0]; + const selectedGoalSummary = selectedGoalPhases.map((phase) => phase.key).join(" · "); const turnCount = utterances.filter((utterance) => !utterance.partial && !utterance.failed).length; const latestClientUtterance = [...utterances] .reverse() @@ -2839,42 +2995,54 @@ export default function Session() { 상담 연습 · {personaCode}

- 지금은 {stage} 단계입니다. + {started ? ( + <> + 지금은 {stage} 단계입니다. + + ) : ( + <> + 첫 발화의 기준을 정합니다. + + )}

- {clientName}의 말에 귀를 기울이세요. 정밀 평가는 회기가 끝난 뒤 리뷰에서 함께 봅니다. + {started + ? `${clientName}의 말에 귀를 기울이세요. 정밀 평가는 회기가 끝난 뒤 리뷰에서 함께 봅니다.` + : `${clientName}의 사례를 읽고, 첫 반응에서 해볼 한 가지를 정한 뒤 시작하세요.`}
-
- {SESSION_PHASES.map((p, i) => { - const state = i < stageIdx ? "is-done" : i === stageIdx ? "is-cur" : ""; - const isGoal = started && goalStages.includes(p.key); - return ( -
- {i > 0 ? ( - - ) : null} -
- - - - {p.key} - {isGoal ? ( - - 목표 - - ) : null} + {started ? ( +
+ {SESSION_PHASES.map((p, i) => { + const state = i < stageIdx ? "is-done" : i === stageIdx ? "is-cur" : ""; + const isGoal = goalStages.includes(p.key); + return ( +
+ {i > 0 ? ( + + ) : null} +
+ + + + {p.key} + {isGoal ? ( + + 목표 + + ) : null} + + + {i < stageIdx ? "완료" : i === stageIdx ? "진행 중" : "예정"} + - - {i < stageIdx ? "완료" : i === stageIdx ? "진행 중" : "예정"} - - +
-
- ); - })} -
+ ); + })} +
+ ) : null} {started ? ( @@ -2949,13 +3117,18 @@ export default function Session() { ) : null} {!started ? ( - /* ── 시작 전: 준비 화면(한 화면 한 의도 = 세션 시작) ── */ -
+ {/* ── 시작 전: 사례 맥락 → 한 가지 수행 초점 → 근거 기반 복기 ── */} +
-
+ -
- {/* D4 — eyebrow "○○ 단계 준비" 는 바로 위 페이지 제목 "지금은 ○○ 단계입니다." - 와 같은 말이라 제거했다(단계 정보는 상단 제목·단계 레일·진행 초점에 이미 있다). */} -

{prestartTitle}

-

- 짧은 첫 인사로 안전감을 만들고, 정밀 평가는 회기가 끝난 뒤 리뷰에서 함께 - 확인합니다. -

+
+
+ 시작 전 1분 +

{prestartTitle}

+

+ 사례를 모두 해결하려고 하기보다, 첫 반응에서 해볼 한 가지를 정하세요. 대화가 + 끝나면 리뷰의 근거 턴을 보고 같은 장면을 다시 연습합니다. +

+
{voicePracticeRequested ? ( voicePracticeContext ? (
@@ -3041,33 +3226,97 @@ export default function Session() {

) ) : null} -
+
+
+
+ 오늘의 핵심 초점 +

{primaryGoal?.key ?? "초점 선택 필요"}

+
+

+ {primaryGoal?.desc ?? "목표 하나를 고르면 첫 발화에서 해볼 반응을 안내합니다."} +

+
+
+ + 이번 회기 목표 핵심 1개 · 보조 0~3개 + +
+ {SESSION_PHASES.map((phase) => { + const selected = selectedGoals.includes(phase.key); + const isPrimary = selected && primaryGoal?.key === phase.key; + return ( + + ); + })} +
+

+ {primaryGoal ? ( + <> + 첫 발화의 기준은 {primaryGoal.key}입니다. 보조 목표는 회기 전체에 + 남고, 회기는 {Math.round((durationLimitSeconds > 0 ? durationLimitSeconds : 3600) / 60)}분 기준으로 이어집니다. + + ) : ( + "목표 하나를 고르면 첫 발화의 기준이 표시되고 회기를 시작할 수 있습니다." + )} +

+
+ {selectedGoalPhases.length > 1 ? ( +
+

+ 보조 목표는 유지한 채 첫 발화의 기준만 바꿀 수 있습니다. +

+
+ {selectedGoalPhases.slice(1).map((phase) => ( + + ))} +
+ +
+ ) : null} +
+
- 선택한 회기 설정 + 연습 방식과 추가 설정 - {selectedTheoryOption.label}, {selectedGoalSummary || "목표 선택 필요"} + {selectedTheoryOption.label} · {selectedGoalSummary || "핵심 초점 선택 필요"}
-
- {personaUi.context.rows.map((row) => ( -
-
{row.l}
-
{row.v}
-
- ))} -
-
- {personaUi.context.chips.map((chip) => ( - - {chip.t} - - ))} -
이론모드
@@ -3085,33 +3334,9 @@ export default function Session() { ))}
-
- - 이번 회기 목표 1~4개 선택 - -
- {SESSION_PHASES.map((phase) => { - const selected = selectedGoals.includes(phase.key); - return ( - - ); - })} -
-

- 실제 상담처럼 한 회기에 모든 단계를 이루지 않아도 됩니다. 회기는{" "} - {Math.round((durationLimitSeconds > 0 ? durationLimitSeconds : 3600) / 60)}분 - 기준으로 진행되고, 목표를 이뤄도 시간이 남으면 계속 이어갈 수 있어요. -

-
+

+ {selectedTheoryOption.focus} +

{personaStatusMessage ? ( @@ -3156,52 +3381,114 @@ export default function Session() { {selectedGoals.length === 0 - ? "이번 회기 목표를 1개 이상 선택하면 시작할 수 있어요." - : "실시간에는 대화 흐름만 낮은 강도로 표시됩니다."} + ? "오늘의 핵심 초점을 1개 이상 선택하면 시작할 수 있어요." + : "실시간에는 대화 흐름만 낮은 강도로 표시되고, 상세 피드백은 리뷰에서 확인합니다."}
-
- 진행 초점 -

- 선택한 회기 설정을 첫 발화에서 바로 쓸 수 있는 기준으로 정리했습니다. + 학습 흐름 +

시도한 장면을 다시 씁니다.

+
    +
  1. + 준비 +
    + 한 가지 반응을 정합니다. +

    + {primaryGoal + ? `첫 발화의 기준으로 ${primaryGoal.key}에 집중하고 시작합니다.` + : "목표 하나를 고르면 첫 발화의 기준이 여기에 표시됩니다."} +

    +
    +
  2. +
  3. + 대화 +
    + 먼저 말해 본 뒤 신호를 봅니다. +

    실시간에는 대화 흐름만 낮은 강도로 표시합니다.

    +
    +
  4. +
  5. + 리뷰·재연습 +
    + 근거 턴으로 다음 시도를 고릅니다. +

    첫 회기라면 라포 반영과 한 초점 개방질문을 근거와 함께 확인합니다.

    +
    +
  6. +
+

+ 위험 신호는 대화보다 안전 확인을 우선합니다.

-
-
-
시작 과업
-
- {currentPhase.key} - {currentPhase.desc} -
+ +
+ + {shortcutHelpOpen ? ( +
{ + if (event.target === event.currentTarget) setShortcutHelpOpen(false); + }} + > +
event.stopPropagation()} + > +
+ +
+

키보드 단축키 안내

+

시작 전에는 목표를 고르고 회기를 여는 데만 집중하세요.

+
-
-
선택 접근
-
- {selectedTheoryOption.label} - {selectedTheoryOption.focus} -
+ +
+
+

시작 전 조작

+
+
+
Space 또는 Enter
+
포커스한 회기 목표 선택/해제
+
+
+
Ctrl+Enter
+
선택한 목표로 회기 시작
+
+
+
? 또는 Ctrl+/
+
단축키 도움말 열기/닫기
+
+
+
Escape
+
이 도움말 닫기
+
+
+
-
-
이번 목표
-
- {selectedGoals.length > 0 ? `${selectedGoals.length}개 선택` : "선택 필요"} - {selectedGoalSummary || "목표를 1개 이상 선택하면 시작할 수 있습니다."} -
+ +
+
-
-
운영 기준
-
- {limitMinutesLabel}분 회기 - 종료 {warningMinutesLabel}분 전 알림 · 위험 신호 시 안전 확인 우선 -
-
- +
-
+ ) : null} + ) : ( <> {/* ── 시간 알람 바(회의 P1): 10분 전 경고 + 시간 만료 정리 유도 — 강제 노출 ── */} @@ -4109,7 +4396,7 @@ export default function Session() {
{coachSuggestion.next_utterance}
) : null}
- ) : null} + {evaluationFailure ? ( +

+ {evaluationFailureMessage(evaluationFailure)} +

+ ) : null}