케이스 이어하기 UI와 공통 탭·이미지 복구를 반영
This commit is contained in:
parent
72353ecd82
commit
f1b80676c1
38 changed files with 3581 additions and 455 deletions
|
|
@ -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 ? <InlineError message={usersError} /> : null}
|
||||
|
||||
<TabBar
|
||||
<Tabs<UserTab>
|
||||
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}
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
@ -2171,10 +2175,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
{pendingUsers.length > 0 ? (
|
||||
<div className={surfaceClassName("vgops-approval-list")}>
|
||||
{pendingUsers.map((user) => (
|
||||
<article
|
||||
className={surfaceClassName("vgops-approval", { variant: "inset" })}
|
||||
key={user.user_id}
|
||||
>
|
||||
<article className="vgops-approval" key={user.user_id}>
|
||||
<div className="vgops-user__id">
|
||||
<span aria-hidden="true">{initialOf(user)}</span>
|
||||
<div>
|
||||
|
|
@ -2869,91 +2870,94 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
기준을 먼저 고정합니다.
|
||||
</div>
|
||||
) : null}
|
||||
<TabBar
|
||||
<Tabs<AccessTab>
|
||||
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" ? (
|
||||
<section className="vgops-policy-grid">
|
||||
{ROLE_POLICIES.map((policy) => (
|
||||
<article
|
||||
className={surfaceClassName("vgops-policy", { variant: "inset" })}
|
||||
key={policy.role}
|
||||
>
|
||||
<div className="vgops-section__head">
|
||||
<h2>{policy.role}</h2>
|
||||
<Badge tone={policy.role === "관리자" ? "warn" : "neutral"}>
|
||||
{policy.permissions.length}개 권한
|
||||
</Badge>
|
||||
</div>
|
||||
<p>{policy.scope}</p>
|
||||
<div className="vgops-chip-row">
|
||||
{policy.permissions.map((permission) => (
|
||||
<span key={permission}>{permission}</span>
|
||||
))}
|
||||
</div>
|
||||
<small>{policy.risk}</small>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{accessTab === "groups" ? (
|
||||
<section className="vgops-policy-grid">
|
||||
{GROUP_POLICIES.map((group) => (
|
||||
<article
|
||||
className={surfaceClassName("vgops-policy", { variant: "inset" })}
|
||||
key={group.name}
|
||||
>
|
||||
<div className="vgops-section__head">
|
||||
<h2>{group.name}</h2>
|
||||
<Badge tone="neutral">정책 초안</Badge>
|
||||
</div>
|
||||
<p>{group.scope}</p>
|
||||
<div className="vgops-chip-row">
|
||||
{group.access.map((item) => (
|
||||
<span key={item}>{item}</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{accessTab === "matrix" ? (
|
||||
<section className={surfaceClassName("vgops-panel")}>
|
||||
<div
|
||||
className={surfaceClassName("vgops-access-table", {
|
||||
variant: "inset",
|
||||
})}
|
||||
>
|
||||
<div className="vgops-access-table__head">
|
||||
<span>리소스</span>
|
||||
<span>관리자</span>
|
||||
<span>교수자</span>
|
||||
<span>학습자</span>
|
||||
</div>
|
||||
{PERMISSION_MATRIX.map((row) => (
|
||||
<div className="vgops-access-row" key={row.resource}>
|
||||
<b>{row.resource}</b>
|
||||
<span>{row.admin}</span>
|
||||
<span>{row.teacher}</span>
|
||||
<span>{row.learner}</span>
|
||||
</div>
|
||||
onValueChange={setAccessTab}
|
||||
listClassName="vgops-tabs"
|
||||
panelClassName="vgops-tabs__panel"
|
||||
>
|
||||
{accessTab === "roles" ? (
|
||||
<section className="vgops-policy-grid">
|
||||
{ROLE_POLICIES.map((policy) => (
|
||||
<article
|
||||
className={surfaceClassName("vgops-policy", { variant: "inset" })}
|
||||
key={policy.role}
|
||||
>
|
||||
<div className="vgops-section__head">
|
||||
<h2>{policy.role}</h2>
|
||||
<Badge tone={policy.role === "관리자" ? "warn" : "neutral"}>
|
||||
{policy.permissions.length}개 권한
|
||||
</Badge>
|
||||
</div>
|
||||
<p>{policy.scope}</p>
|
||||
<div className="vgops-chip-row">
|
||||
{policy.permissions.map((permission) => (
|
||||
<span key={permission}>{permission}</span>
|
||||
))}
|
||||
</div>
|
||||
<small>{policy.risk}</small>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{accessTab === "protocols" ? renderProtocols() : null}
|
||||
{accessTab === "groups" ? (
|
||||
<section className="vgops-policy-grid">
|
||||
{GROUP_POLICIES.map((group) => (
|
||||
<article
|
||||
className={surfaceClassName("vgops-policy", { variant: "inset" })}
|
||||
key={group.name}
|
||||
>
|
||||
<div className="vgops-section__head">
|
||||
<h2>{group.name}</h2>
|
||||
<Badge tone="neutral">정책 초안</Badge>
|
||||
</div>
|
||||
<p>{group.scope}</p>
|
||||
<div className="vgops-chip-row">
|
||||
{group.access.map((item) => (
|
||||
<span key={item}>{item}</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{accessTab === "matrix" ? (
|
||||
<section className={surfaceClassName("vgops-panel")}>
|
||||
<div
|
||||
className={surfaceClassName("vgops-access-table", {
|
||||
variant: "inset",
|
||||
})}
|
||||
>
|
||||
<div className="vgops-access-table__head">
|
||||
<span>리소스</span>
|
||||
<span>관리자</span>
|
||||
<span>교수자</span>
|
||||
<span>학습자</span>
|
||||
</div>
|
||||
{PERMISSION_MATRIX.map((row) => (
|
||||
<div className="vgops-access-row" key={row.resource}>
|
||||
<b>{row.resource}</b>
|
||||
<span>{row.admin}</span>
|
||||
<span>{row.teacher}</span>
|
||||
<span>{row.learner}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{accessTab === "protocols" ? renderProtocols() : null}
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
@ -3125,12 +3129,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
duplicateParentId !== ticket.ticket_id &&
|
||||
duplicateParentId !== ticket.parent_ticket_id;
|
||||
return (
|
||||
<article
|
||||
className={surfaceClassName("vgops-ticket", {
|
||||
variant: "inset",
|
||||
})}
|
||||
key={ticket.ticket_id}
|
||||
>
|
||||
<article className="vgops-ticket" key={ticket.ticket_id}>
|
||||
<div>
|
||||
{/* 담당 그룹·처리 이력은 아래 <small> 에서 가운뎃점으로 이어 붙었었다.
|
||||
칸 구분이 있는 meta 행으로 옮겨 <small> 은 가운뎃점 1개만 남긴다. */}
|
||||
|
|
@ -3212,9 +3211,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
>
|
||||
{recentResolvedTickets.map((ticket) => (
|
||||
<article
|
||||
className={surfaceClassName("vgops-ticket vgops-ticket--history", {
|
||||
variant: "inset",
|
||||
})}
|
||||
className="vgops-ticket vgops-ticket--history"
|
||||
key={ticket.ticket_id}
|
||||
>
|
||||
<div>
|
||||
|
|
@ -3291,39 +3288,6 @@ function EmptyState({ title, body }: { title: string; body: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function TabBar<T extends string>({
|
||||
ariaLabel,
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
items: Array<[T, string]>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={surfaceClassName("vgops-tabs", { variant: "inset", flat: true })}
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{items.map(([id, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
role="tab"
|
||||
aria-selected={value === id}
|
||||
className={value === id ? "is-active" : ""}
|
||||
onClick={() => onChange(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleMeter({
|
||||
label,
|
||||
value,
|
||||
|
|
|
|||
|
|
@ -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<LoadState>("loading");
|
||||
// 새 사례와 이어지는 사례는 같은 NPC라도 case_id가 다르다. Home은 시작 전
|
||||
// 선택만 담당하고, 기억 본문은 사용자가 foldout을 열 때까지 요청하지 않는다.
|
||||
const [launchMode, setLaunchMode] = useState<SessionStartMode>("fresh");
|
||||
const [continuityCases, setContinuityCases] = useState<LearnerCaseSummary[]>([]);
|
||||
const [selectedContinuityCaseId, setSelectedContinuityCaseId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [continuityLoadState, setContinuityLoadState] =
|
||||
useState<LoadState>("loading");
|
||||
const [memoryPreview, setMemoryPreview] = useState<CaseMemoryPreview | null>(
|
||||
null,
|
||||
);
|
||||
const [memoryLoadState, setMemoryLoadState] =
|
||||
useState<MemoryLoadState>("idle");
|
||||
const [historyFilter, setHistoryFilter] = useState<HistoryFilter>("all");
|
||||
const [historyQuery, setHistoryQuery] = useState("");
|
||||
const [archiveBusyId, setArchiveBusyId] = useState<string | null>(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) {
|
|||
: "기록"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="lh-review-link lh-review-link--ghost"
|
||||
onClick={() =>
|
||||
navigate(`/learn/session/${session.persona_code}`)
|
||||
}
|
||||
>
|
||||
다시 연습
|
||||
</button>
|
||||
{session.status === "ended" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="lh-review-link lh-review-link--ghost"
|
||||
title="새 회기를 만들고 이전 회기의 맥락을 이어갑니다."
|
||||
onClick={() => {
|
||||
const params = new URLSearchParams({ continuity: "continue" });
|
||||
if (session.case_id) params.set("case", session.case_id);
|
||||
navigate(
|
||||
`/learn/session/${session.persona_code}?${params.toString()}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
다음 회기 이어가기
|
||||
</button>
|
||||
) : null}
|
||||
{session.status === "ended" ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1258,7 +1397,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div role="radiogroup" aria-label="회기 방식">
|
||||
<span>상담 진행</span>
|
||||
<b>
|
||||
{spotlightSession ? `${recapProgress}%` : "0%"}
|
||||
|
|
@ -1823,9 +1962,47 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
</div>
|
||||
|
||||
<div className="lh-actions">
|
||||
{caseModeAvailable ? (
|
||||
<fieldset className="lh-launch-mode">
|
||||
<legend>회기 방식</legend>
|
||||
<div>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="session-launch-mode"
|
||||
checked={launchMode === "fresh"}
|
||||
disabled={activeCaseSessionId != null}
|
||||
onChange={() => setLaunchMode("fresh")}
|
||||
/>
|
||||
<span>완전히 새로 시작</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="session-launch-mode"
|
||||
checked={launchMode === "continue"}
|
||||
disabled={
|
||||
activeCaseSessionId != null || !continuationAvailable
|
||||
}
|
||||
onChange={() => setLaunchMode("continue")}
|
||||
/>
|
||||
<span>
|
||||
{continuityLoadState === "loading"
|
||||
? "기록 확인 중"
|
||||
: "이어서 진행"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
) : null}
|
||||
<span className="lh-actions__note">
|
||||
회기 종료 후 저장된 대화 기록으로 리뷰와 코칭이
|
||||
생성됩니다.
|
||||
{activeCaseSessionId
|
||||
? "진행 중인 회기가 있습니다. 같은 내담자의 새 사례는 이 회기를 마친 뒤 시작할 수 있습니다."
|
||||
: caseModeAvailable && launchMode === "fresh"
|
||||
? "새 사례는 이전 회기의 기억과 누적 기록을 가져오지 않습니다."
|
||||
: caseModeAvailable
|
||||
? "선택한 사례의 압축 기억과 누적 기록을 이어받습니다."
|
||||
: "회기 종료 후 저장된 대화 기록으로 리뷰와 코칭이 생성됩니다."}
|
||||
</span>
|
||||
<Button
|
||||
size="lg"
|
||||
|
|
@ -1833,15 +2010,189 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
disabled={
|
||||
!selected ||
|
||||
(voicePracticeRequested && !voicePracticeContext) ||
|
||||
(practiceLaunchRequested && !practiceLaunchIntent)
|
||||
(practiceLaunchRequested && !practiceLaunchIntent) ||
|
||||
(caseModeAvailable &&
|
||||
activeCaseSessionId == null &&
|
||||
launchMode === "continue" &&
|
||||
!continuationAvailable)
|
||||
}
|
||||
trailing={<Icon name="chevron-right" size={18} />}
|
||||
>
|
||||
새 회기 시작
|
||||
{activeCaseSessionId
|
||||
? "진행 중인 회기 이어가기"
|
||||
: caseModeAvailable && launchMode === "continue"
|
||||
? "이어서 진행"
|
||||
: "완전히 새로 시작"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{caseModeAvailable ? (
|
||||
<section
|
||||
className="lh-continuity"
|
||||
aria-label="회기 연속성"
|
||||
aria-live="polite"
|
||||
>
|
||||
{activeCaseSessionId ? (
|
||||
<div className="lh-continuity__notice">
|
||||
<Kicker>진행 중인 사례</Kicker>
|
||||
<p>
|
||||
현재 {selected?.code} 회기가 열려 있습니다. 위 버튼으로
|
||||
그 회기를 이어가면 됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!activeCaseSessionId && launchMode === "fresh" ? (
|
||||
<div className="lh-continuity__notice">
|
||||
<Kicker>새 사례</Kicker>
|
||||
<p>
|
||||
이전 회기의 요약, 미해결 주제, 고정 기억을 가져오지 않고
|
||||
1회기부터 시작합니다. 기존 사례 기록은 보존됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{(activeCaseSessionId || launchMode === "continue") &&
|
||||
continuityLoadState === "loading" ? (
|
||||
<p className="lh-continuity__status">
|
||||
이 내담자의 이어지는 사례를 확인하고 있습니다.
|
||||
</p>
|
||||
) : null}
|
||||
{(activeCaseSessionId || launchMode === "continue") &&
|
||||
continuityLoadState === "error" ? (
|
||||
<p className="lh-continuity__status is-error" role="status">
|
||||
연속 사례 통계를 확인하지 못했습니다. 이어가기는 열지 않았고,
|
||||
완전히 새로 시작은 계속할 수 있습니다.
|
||||
</p>
|
||||
) : null}
|
||||
{(activeCaseSessionId || launchMode === "continue") &&
|
||||
continuityLoadState === "ready" &&
|
||||
selectedContinuityCase ? (
|
||||
<>
|
||||
{continuityCases.length > 1 ? (
|
||||
<label className="lh-continuity__case-select">
|
||||
<span>이어서 진행할 사례</span>
|
||||
<select
|
||||
aria-label="이어서 진행할 사례"
|
||||
value={selectedContinuityCase.case_id}
|
||||
disabled={activeCaseSessionId != null}
|
||||
onChange={(event) =>
|
||||
selectContinuityCase(event.target.value)
|
||||
}
|
||||
>
|
||||
{continuityCases.map((item, index) => {
|
||||
const progress = item.progress ?? selectedContinuityProgress;
|
||||
return (
|
||||
<option key={item.case_id} value={item.case_id}>
|
||||
{index === 0 ? "가장 최근 사례" : `이전 사례 ${index}`} · {progress.total_sessions}회기 · {progress.total_turns}턴
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="lh-continuity__head">
|
||||
<div>
|
||||
<Kicker>이어서 진행할 사례</Kicker>
|
||||
<p>
|
||||
이 사례에서 쌓인 기록만 다음 회기에 이어집니다.
|
||||
</p>
|
||||
</div>
|
||||
<span>다음 {selectedContinuityCase.last_session_no + 1}회기</span>
|
||||
</div>
|
||||
<div className="lh-continuity__stats">
|
||||
<span>
|
||||
<b>{selectedContinuityProgress.total_sessions}회기</b>
|
||||
<small>총 회기</small>
|
||||
</span>
|
||||
<span>
|
||||
<b>{selectedContinuityProgress.total_turns}턴</b>
|
||||
<small>대화 턴</small>
|
||||
</span>
|
||||
<span>
|
||||
<b>
|
||||
{formatCaseDuration(
|
||||
selectedContinuityProgress.total_duration_seconds,
|
||||
)}
|
||||
</b>
|
||||
<small>누적 회기 시간</small>
|
||||
</span>
|
||||
</div>
|
||||
<details
|
||||
className="lh-continuity__memory"
|
||||
onToggle={(event) => {
|
||||
if (event.currentTarget.open) loadContinuityMemory();
|
||||
}}
|
||||
>
|
||||
<summary>
|
||||
<span>내담자 기억</span>
|
||||
<b>필요할 때만 펼쳐 보기</b>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</summary>
|
||||
<div className="lh-continuity__memory-body">
|
||||
{memoryLoadState === "loading" ||
|
||||
memoryPreview?.case_id !== selectedContinuityCase.case_id ? (
|
||||
<p>기억 요약을 불러오고 있습니다.</p>
|
||||
) : memoryLoadState === "error" ? (
|
||||
<p role="status">
|
||||
기억 요약을 불러오지 못했습니다. 원문 대화는 표시하지
|
||||
않습니다.
|
||||
</p>
|
||||
) : memoryPreview?.memory_available ? (
|
||||
<dl>
|
||||
{memoryPreview.latest_session_digest ? (
|
||||
<div>
|
||||
<dt>지난 회기 요약</dt>
|
||||
<dd>{memoryPreview.latest_session_digest}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{memoryPreview.case_digest ? (
|
||||
<div>
|
||||
<dt>사례의 큰 흐름</dt>
|
||||
<dd>{memoryPreview.case_digest}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{(memoryPreview.open_threads ?? []).length > 0 ? (
|
||||
<div>
|
||||
<dt>이어 볼 주제</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
{(memoryPreview.open_threads ?? []).map((thread) => (
|
||||
<li key={thread}>{thread}</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{(memoryPreview.pinned_facts ?? []).length > 0 ? (
|
||||
<div>
|
||||
<dt>계속 기억하는 내용</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
{(memoryPreview.pinned_facts ?? []).map((fact) => (
|
||||
<li key={fact}>{fact}</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
) : (
|
||||
<p>
|
||||
이 사례에는 다음 회기에 넘길 압축 기억이 아직 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : (activeCaseSessionId || launchMode === "continue") &&
|
||||
continuityLoadState === "ready" ? (
|
||||
<p className="lh-continuity__status">
|
||||
이어갈 사례가 없습니다. 완전히 새로 시작할 수 있습니다.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={surfaceClassName("lh-summary", {
|
||||
variant: "inset",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "../components/ui";
|
||||
import { Button, Icon } from "../components/ui";
|
||||
import { surfaceClassName } from "../components/ui/Surface";
|
||||
import { AuthShell } from "../components/auth/AuthShell";
|
||||
import { ResilientImage } from "../components/avatar/ResilientImage";
|
||||
import { roleHomePath, useAuth } from "../lib/auth";
|
||||
import { apiUrl, userApi, type LegalDocumentsResponse, type UserProfileResponse } from "../lib/api";
|
||||
import { userApi, type LegalDocumentsResponse, type UserProfileResponse } from "../lib/api";
|
||||
import "./onboarding.css";
|
||||
|
||||
interface OnboardingForm {
|
||||
|
|
@ -59,7 +60,7 @@ function profileToForm(profile: UserProfileResponse | null): OnboardingForm {
|
|||
|
||||
export default function Onboarding() {
|
||||
const navigate = useNavigate();
|
||||
const { user, refresh } = useAuth();
|
||||
const { user, logout, refresh } = useAuth();
|
||||
const [docs, setDocs] = useState<LegalDocumentsResponse | null>(null);
|
||||
const [form, setForm] = useState<OnboardingForm>(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 = <K extends keyof OnboardingForm>(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() {
|
|||
<>
|
||||
<AuthShell className="ob-page">
|
||||
<section className={surfaceClassName("ob-shell")} aria-label="가입 정보 입력">
|
||||
<div className="ob-account" aria-label="현재 로그인 계정">
|
||||
<div className="ob-account__identity">
|
||||
<span>현재 Google 계정</span>
|
||||
<strong>{user?.email ?? "계정 확인 중"}</strong>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
leading={<Icon name="logout" size={16} />}
|
||||
onClick={() => void signOut()}
|
||||
>
|
||||
다른 계정으로 로그인
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<header className="ob-head">
|
||||
<p>Vignette 가입 설정</p>
|
||||
<h1>가입 정보를 입력합니다.</h1>
|
||||
|
|
@ -180,7 +201,11 @@ export default function Onboarding() {
|
|||
</div>
|
||||
<div className="ob-avatar">
|
||||
<div className="ob-avatar__preview" aria-hidden="true">
|
||||
{avatarSrc ? <img src={avatarSrc} alt="" /> : <span>{form.nickname.trim().slice(0, 1) || "V"}</span>}
|
||||
<ResilientImage
|
||||
src={avatarSrc}
|
||||
alt=""
|
||||
fallback={form.nickname.trim().slice(0, 1) || "V"}
|
||||
/>
|
||||
</div>
|
||||
<div className="ob-avatar__body">
|
||||
<span>상징 아바타</span>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
Icon,
|
||||
Kicker,
|
||||
surfaceClassName,
|
||||
Tabs,
|
||||
} from "../components/ui";
|
||||
import {
|
||||
ApiError,
|
||||
|
|
@ -2758,32 +2759,23 @@ export default function PersonaStudio() {
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<div
|
||||
className={surfaceClassName("ps-tabs", {
|
||||
variant: "inset",
|
||||
flat: true,
|
||||
})}
|
||||
role="tablist"
|
||||
aria-label="저작 섹션"
|
||||
<Tabs<StudioTab>
|
||||
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) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.key}
|
||||
className={activeTab === tab.key ? "is-active" : ""}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.key}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="ps-edit-panel">
|
||||
<GuidancePanel tab={activeTab} />
|
||||
{renderTab()}
|
||||
</Card>
|
||||
<Card className="ps-edit-panel">
|
||||
<GuidancePanel tab={activeTab} />
|
||||
{renderTab()}
|
||||
</Card>
|
||||
</Tabs>
|
||||
|
||||
{formError ? (
|
||||
<p className="ps-status is-error" role="alert">
|
||||
|
|
|
|||
|
|
@ -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<SessionStartMode>(
|
||||
() =>
|
||||
!voicePracticeRequested &&
|
||||
!practiceLaunchRequested &&
|
||||
searchParams.get("continuity") === "fresh"
|
||||
? "fresh"
|
||||
: "continue",
|
||||
[practiceLaunchRequested, searchParams, voicePracticeRequested],
|
||||
);
|
||||
const selectedCaseId = useMemo(() => {
|
||||
if (startMode !== "continue") return undefined;
|
||||
const candidate = searchParams.get("case")?.trim();
|
||||
return candidate || undefined;
|
||||
}, [searchParams, startMode]);
|
||||
const practiceContextSearch = useMemo(
|
||||
() =>
|
||||
practiceLaunchIntent
|
||||
|
|
@ -416,8 +440,10 @@ export default function Session() {
|
|||
const [consentChecked, setConsentChecked] = useState(false);
|
||||
const [consentBusy, setConsentBusy] = useState(false);
|
||||
const [selectedTheoryMode, setSelectedTheoryMode] = useState<TheoryMode>("humanistic");
|
||||
// 이번 회기 목표(2026-07-13 회의 P1): 4단계 전부가 아니라 1~2개를 고르고 시작한다.
|
||||
const [selectedGoals, setSelectedGoals] = useState<SessionStage[]>(["라포", "탐색"]);
|
||||
// 새 회기는 한 가지 수행 초점에서 시작한다. 처방 재연습은 검증된 라포·탐색 과업을 유지한다.
|
||||
const [selectedGoals, setSelectedGoals] = useState<SessionStage[]>(
|
||||
practiceLaunchIntent ? ["라포", "탐색"] : ["라포"],
|
||||
);
|
||||
|
||||
// ── 회기/대화 상태 ──
|
||||
const [stage, setStage] = useState<SessionStage>("라포");
|
||||
|
|
@ -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<HTMLElement>(null);
|
||||
const shortcutHelpConfirmRef = useRef<HTMLButtonElement>(null);
|
||||
const shortcutHelpPreviousFocusRef = useRef<HTMLElement | null>(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<string>("");
|
||||
const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null);
|
||||
const coachHistoryCloseRef = useRef<HTMLButtonElement>(null);
|
||||
const coachEvidencePanelRef = useRef<HTMLElement>(null);
|
||||
const coachHistoryPanelRef = useRef<HTMLElement>(null);
|
||||
const coachEvidencePreviousFocusRef = useRef<HTMLElement | null>(null);
|
||||
const coachHistoryPreviousFocusRef = useRef<HTMLElement | null>(null);
|
||||
const coachCreditSeenRef = useRef<Set<string>>(new Set());
|
||||
const coachCreditPulseTimerRef = useRef<number | null>(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<HTMLElement>(
|
||||
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
|
||||
) ?? [],
|
||||
).filter((control) => control.getClientRects().length > 0);
|
||||
if (controls.length === 0) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [shortcutHelpOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcutHelpOpen) return;
|
||||
const previous = shortcutHelpPreviousFocusRef.current;
|
||||
if (previous?.isConnected) previous.focus();
|
||||
shortcutHelpPreviousFocusRef.current = null;
|
||||
}, [shortcutHelpOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
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<HTMLElement>(
|
||||
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
|
||||
) ?? [],
|
||||
).filter((control) => control.getClientRects().length > 0);
|
||||
if (controls.length === 0) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
|
@ -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<HTMLElement>(
|
||||
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])",
|
||||
) ?? [],
|
||||
).filter((control) => control.getClientRects().length > 0);
|
||||
if (controls.length === 0) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [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() {
|
|||
<Kicker>상담 연습 · {personaCode}</Kicker>
|
||||
</div>
|
||||
<h1 className="sx-head__title">
|
||||
지금은 <em>{stage} 단계</em>입니다.
|
||||
{started ? (
|
||||
<>
|
||||
지금은 <em>{stage} 단계</em>입니다.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
첫 발화의 <em>기준</em>을 정합니다.
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<div className="sx-head__sub">
|
||||
{clientName}의 말에 귀를 기울이세요. 정밀 평가는 회기가 끝난 뒤 리뷰에서 함께 봅니다.
|
||||
{started
|
||||
? `${clientName}의 말에 귀를 기울이세요. 정밀 평가는 회기가 끝난 뒤 리뷰에서 함께 봅니다.`
|
||||
: `${clientName}의 사례를 읽고, 첫 반응에서 해볼 한 가지를 정한 뒤 시작하세요.`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sx-phases" aria-label="회기 진행 단계">
|
||||
{SESSION_PHASES.map((p, i) => {
|
||||
const state = i < stageIdx ? "is-done" : i === stageIdx ? "is-cur" : "";
|
||||
const isGoal = started && goalStages.includes(p.key);
|
||||
return (
|
||||
<div key={p.key} style={{ display: "flex", alignItems: "center" }}>
|
||||
{i > 0 ? (
|
||||
<span className={"sx-ph__link" + (i <= stageIdx ? " is-fill" : "")} />
|
||||
) : null}
|
||||
<div className={"sx-ph " + state + (isGoal ? " is-goal" : "")}>
|
||||
<span className="sx-ph__dot" />
|
||||
<span className="sx-ph__meta">
|
||||
<span className="sx-ph__name">
|
||||
{p.key}
|
||||
{isGoal ? (
|
||||
<em className="sx-ph__goal" title="이번 회기 목표">
|
||||
목표
|
||||
</em>
|
||||
) : null}
|
||||
{started ? (
|
||||
<div className="sx-phases" aria-label="회기 진행 단계">
|
||||
{SESSION_PHASES.map((p, i) => {
|
||||
const state = i < stageIdx ? "is-done" : i === stageIdx ? "is-cur" : "";
|
||||
const isGoal = goalStages.includes(p.key);
|
||||
return (
|
||||
<div key={p.key} style={{ display: "flex", alignItems: "center" }}>
|
||||
{i > 0 ? (
|
||||
<span className={"sx-ph__link" + (i <= stageIdx ? " is-fill" : "")} />
|
||||
) : null}
|
||||
<div className={"sx-ph " + state + (isGoal ? " is-goal" : "")}>
|
||||
<span className="sx-ph__dot" />
|
||||
<span className="sx-ph__meta">
|
||||
<span className="sx-ph__name">
|
||||
{p.key}
|
||||
{isGoal ? (
|
||||
<em className="sx-ph__goal" title="이번 회기 목표">
|
||||
목표
|
||||
</em>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="sx-ph__t">
|
||||
{i < stageIdx ? "완료" : i === stageIdx ? "진행 중" : "예정"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="sx-ph__t">
|
||||
{i < stageIdx ? "완료" : i === stageIdx ? "진행 중" : "예정"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{started ? (
|
||||
|
|
@ -2949,13 +3117,18 @@ export default function Session() {
|
|||
) : null}
|
||||
|
||||
{!started ? (
|
||||
/* ── 시작 전: 준비 화면(한 화면 한 의도 = 세션 시작) ── */
|
||||
<div
|
||||
<>
|
||||
{/* ── 시작 전: 사례 맥락 → 한 가지 수행 초점 → 근거 기반 복기 ── */}
|
||||
<section
|
||||
className={surfaceClassName(
|
||||
`sx-prestart${practiceLaunchIntent ? " sx-prestart--prescribed" : ""}`,
|
||||
)}
|
||||
aria-label="회기 시작 전 준비"
|
||||
>
|
||||
<div className={surfaceClassName("sx-prestart__visual", { variant: "inset" })}>
|
||||
<aside
|
||||
className={surfaceClassName("sx-prestart__visual", { variant: "inset" })}
|
||||
aria-labelledby="sx-prestart-case-title"
|
||||
>
|
||||
<ClientAvatar
|
||||
persona={personaUi.avatar}
|
||||
state="idle"
|
||||
|
|
@ -2966,29 +3139,41 @@ export default function Session() {
|
|||
showCaption={false}
|
||||
showMeta={false}
|
||||
/>
|
||||
{/* 아바타 카드는 인물 그림 + 이름 + 한 줄 소개만 맡는다.
|
||||
호소·대상·난도 목록은 본문 .sx-prestart__facts 와 똑같은 내용이라
|
||||
(3열·2열 모두에서 두 번 노출) 여기서는 제거했다. 이름 이니셜 원도
|
||||
바로 위 초상과 이름을 중복하는 장식이라 함께 걷어냈다. */}
|
||||
<div className="sx-prestart__case" aria-label="내담자 요약">
|
||||
<b>{personaUi.context.name}</b>
|
||||
{/* D3 — 한 줄에 · 가 3개 몰리던 소개를 줄당 1개로 나눠 표시 */}
|
||||
<div className="sx-prestart__case">
|
||||
<Kicker>오늘 만날 내담자</Kicker>
|
||||
<b id="sx-prestart-case-title">{personaUi.context.name}</b>
|
||||
<p>
|
||||
{clientMetaLines.map((line, i) => (
|
||||
<span key={`${i}-${line}`}>{line}</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="sx-prestart__facts">
|
||||
{personaUi.context.rows.map((row) => (
|
||||
<div key={row.l}>
|
||||
<dt>{row.l}</dt>
|
||||
<dd>{row.v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="sx-prestart__chips" aria-label="내담자 태그">
|
||||
{personaUi.context.chips.map((chip) => (
|
||||
<span className={chip.clay ? "is-clay" : ""} key={chip.t}>
|
||||
{chip.t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="sx-prestart__main">
|
||||
{/* D4 — eyebrow "○○ 단계 준비" 는 바로 위 페이지 제목 "지금은 ○○ 단계입니다."
|
||||
와 같은 말이라 제거했다(단계 정보는 상단 제목·단계 레일·진행 초점에 이미 있다). */}
|
||||
<h2 className="sx-prestart__title">{prestartTitle}</h2>
|
||||
<p className="sx-prestart__desc">
|
||||
짧은 첫 인사로 안전감을 만들고, 정밀 평가는 회기가 끝난 뒤 리뷰에서 함께
|
||||
확인합니다.
|
||||
</p>
|
||||
<div className="sx-prestart__main sx-prestart__learning">
|
||||
<header className="sx-prestart__intro">
|
||||
<Kicker>시작 전 1분</Kicker>
|
||||
<h2 className="sx-prestart__title">{prestartTitle}</h2>
|
||||
<p className="sx-prestart__desc">
|
||||
사례를 모두 해결하려고 하기보다, 첫 반응에서 해볼 한 가지를 정하세요. 대화가
|
||||
끝나면 리뷰의 근거 턴을 보고 같은 장면을 다시 연습합니다.
|
||||
</p>
|
||||
</header>
|
||||
{voicePracticeRequested ? (
|
||||
voicePracticeContext ? (
|
||||
<section className="sx-voice-practice-context" aria-labelledby="sx-voice-practice-title">
|
||||
|
|
@ -3041,33 +3226,97 @@ export default function Session() {
|
|||
</p>
|
||||
)
|
||||
) : null}
|
||||
<details
|
||||
className={`sx-prestart__settings${practiceLaunchIntent ? " is-prescribed" : ""}`}
|
||||
open={practiceLaunchIntent ? undefined : true}
|
||||
>
|
||||
<section className="sx-prestart__focus" aria-labelledby="sx-prestart-focus-title">
|
||||
<div className="sx-prestart__focus-head">
|
||||
<div>
|
||||
<Kicker>오늘의 핵심 초점</Kicker>
|
||||
<h3 id="sx-prestart-focus-title">{primaryGoal?.key ?? "초점 선택 필요"}</h3>
|
||||
</div>
|
||||
<p>
|
||||
{primaryGoal?.desc ?? "목표 하나를 고르면 첫 발화에서 해볼 반응을 안내합니다."}
|
||||
</p>
|
||||
</div>
|
||||
<fieldset className="sx-goals" aria-label="이번 회기 목표 선택">
|
||||
<legend>
|
||||
이번 회기 목표 <small>핵심 1개 · 보조 0~3개</small>
|
||||
</legend>
|
||||
<div className="sx-goals__grid">
|
||||
{SESSION_PHASES.map((phase) => {
|
||||
const selected = selectedGoals.includes(phase.key);
|
||||
const isPrimary = selected && primaryGoal?.key === phase.key;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={phase.key}
|
||||
className={
|
||||
(selected ? "is-selected" : "") + (isPrimary ? " is-primary" : "")
|
||||
}
|
||||
aria-pressed={selected}
|
||||
aria-label={
|
||||
isPrimary
|
||||
? `${phase.key} 핵심 초점, ${phase.desc}`
|
||||
: `${phase.key}, ${phase.desc}`
|
||||
}
|
||||
onClick={() => toggleGoal(phase.key)}
|
||||
>
|
||||
<span>{phase.key}</span>
|
||||
<small>{phase.desc}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="sx-goals__hint">
|
||||
{primaryGoal ? (
|
||||
<>
|
||||
첫 발화의 기준은 <b>{primaryGoal.key}</b>입니다. 보조 목표는 회기 전체에
|
||||
남고, 회기는 {Math.round((durationLimitSeconds > 0 ? durationLimitSeconds : 3600) / 60)}분 기준으로 이어집니다.
|
||||
</>
|
||||
) : (
|
||||
"목표 하나를 고르면 첫 발화의 기준이 표시되고 회기를 시작할 수 있습니다."
|
||||
)}
|
||||
</p>
|
||||
</fieldset>
|
||||
{selectedGoalPhases.length > 1 ? (
|
||||
<div className="sx-prestart__primary-switch">
|
||||
<p>
|
||||
<b>보조 목표</b>는 유지한 채 첫 발화의 기준만 바꿀 수 있습니다.
|
||||
</p>
|
||||
<div role="group" aria-label="핵심 초점 바꾸기">
|
||||
{selectedGoalPhases.slice(1).map((phase) => (
|
||||
<button
|
||||
key={phase.key}
|
||||
type="button"
|
||||
onClick={() => makePrimaryGoal(phase.key)}
|
||||
>
|
||||
{primaryGoalActionLabel(phase.key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="sx-prestart__primary-select">
|
||||
<span>첫 발화 핵심</span>
|
||||
<select
|
||||
value={primaryGoal?.key ?? ""}
|
||||
onChange={(event) => makePrimaryGoal(event.target.value as SessionStage)}
|
||||
>
|
||||
{selectedGoalPhases.map((phase) => (
|
||||
<option key={phase.key} value={phase.key}>
|
||||
{primaryGoalActionLabel(phase.key)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<details className={`sx-prestart__settings${practiceLaunchIntent ? " is-prescribed" : ""}`}>
|
||||
<summary>
|
||||
<span>선택한 회기 설정</span>
|
||||
<span>연습 방식과 추가 설정</span>
|
||||
<b>
|
||||
{selectedTheoryOption.label}, {selectedGoalSummary || "목표 선택 필요"}
|
||||
{selectedTheoryOption.label} · {selectedGoalSummary || "핵심 초점 선택 필요"}
|
||||
</b>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</summary>
|
||||
<div className="sx-prestart__settings-body">
|
||||
<dl className="sx-prestart__facts">
|
||||
{personaUi.context.rows.map((row) => (
|
||||
<div key={row.l}>
|
||||
<dt>{row.l}</dt>
|
||||
<dd>{row.v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="sx-prestart__chips" aria-label="내담자 태그">
|
||||
{personaUi.context.chips.map((chip) => (
|
||||
<span className={chip.clay ? "is-clay" : ""} key={chip.t}>
|
||||
{chip.t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<fieldset className="sx-theory" aria-label="이론모드 선택">
|
||||
<legend>이론모드</legend>
|
||||
<div className="sx-theory__seg">
|
||||
|
|
@ -3085,33 +3334,9 @@ export default function Session() {
|
|||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset className="sx-goals" aria-label="이번 회기 목표 선택">
|
||||
<legend>
|
||||
이번 회기 목표 <small>1~4개 선택</small>
|
||||
</legend>
|
||||
<div className="sx-goals__grid">
|
||||
{SESSION_PHASES.map((phase) => {
|
||||
const selected = selectedGoals.includes(phase.key);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={phase.key}
|
||||
className={selected ? "is-selected" : ""}
|
||||
aria-pressed={selected}
|
||||
onClick={() => toggleGoal(phase.key)}
|
||||
>
|
||||
<span>{phase.key}</span>
|
||||
<small>{phase.desc}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="sx-goals__hint">
|
||||
실제 상담처럼 한 회기에 모든 단계를 이루지 않아도 됩니다. 회기는{" "}
|
||||
{Math.round((durationLimitSeconds > 0 ? durationLimitSeconds : 3600) / 60)}분
|
||||
기준으로 진행되고, 목표를 이뤄도 시간이 남으면 계속 이어갈 수 있어요.
|
||||
</p>
|
||||
</fieldset>
|
||||
<p className="sx-prestart__settings-note">
|
||||
{selectedTheoryOption.focus}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
{personaStatusMessage ? (
|
||||
|
|
@ -3156,52 +3381,114 @@ export default function Session() {
|
|||
</Button>
|
||||
<span>
|
||||
{selectedGoals.length === 0
|
||||
? "이번 회기 목표를 1개 이상 선택하면 시작할 수 있어요."
|
||||
: "실시간에는 대화 흐름만 낮은 강도로 표시됩니다."}
|
||||
? "오늘의 핵심 초점을 1개 이상 선택하면 시작할 수 있어요."
|
||||
: "실시간에는 대화 흐름만 낮은 강도로 표시되고, 상세 피드백은 리뷰에서 확인합니다."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={surfaceClassName("sx-prestart__plan", { variant: "inset" })}
|
||||
aria-label="시작 전 초점"
|
||||
<aside
|
||||
className={surfaceClassName("sx-prestart__plan sx-prestart__learning-map", { variant: "inset" })}
|
||||
aria-labelledby="sx-prestart-learning-map-title"
|
||||
>
|
||||
<Kicker>진행 초점</Kicker>
|
||||
<p className="sx-prestart__plan-summary">
|
||||
선택한 회기 설정을 첫 발화에서 바로 쓸 수 있는 기준으로 정리했습니다.
|
||||
<Kicker>학습 흐름</Kicker>
|
||||
<h3 id="sx-prestart-learning-map-title">시도한 장면을 다시 씁니다.</h3>
|
||||
<ol className="sx-prestart__learning-loop">
|
||||
<li>
|
||||
<span>준비</span>
|
||||
<div>
|
||||
<b>한 가지 반응을 정합니다.</b>
|
||||
<p>
|
||||
{primaryGoal
|
||||
? `첫 발화의 기준으로 ${primaryGoal.key}에 집중하고 시작합니다.`
|
||||
: "목표 하나를 고르면 첫 발화의 기준이 여기에 표시됩니다."}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span>대화</span>
|
||||
<div>
|
||||
<b>먼저 말해 본 뒤 신호를 봅니다.</b>
|
||||
<p>실시간에는 대화 흐름만 낮은 강도로 표시합니다.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span>리뷰·재연습</span>
|
||||
<div>
|
||||
<b>근거 턴으로 다음 시도를 고릅니다.</b>
|
||||
<p>첫 회기라면 라포 반영과 한 초점 개방질문을 근거와 함께 확인합니다.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<p className="sx-prestart__boundary">
|
||||
위험 신호는 대화보다 안전 확인을 우선합니다.
|
||||
</p>
|
||||
<dl className="sx-prestart__plan-list">
|
||||
<div>
|
||||
<dt>시작 과업</dt>
|
||||
<dd>
|
||||
<b>{currentPhase.key}</b>
|
||||
{currentPhase.desc}
|
||||
</dd>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
{shortcutHelpOpen ? (
|
||||
<div
|
||||
className="sx-coach-modal-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) setShortcutHelpOpen(false);
|
||||
}}
|
||||
>
|
||||
<section
|
||||
ref={shortcutHelpPanelRef}
|
||||
className="sx-coach-modal sx-shortcut-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sx-shortcut-modal-title"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="sx-coach-modal__head">
|
||||
<span className="sx-coach-modal__avatar" aria-hidden="true">
|
||||
<Icon name="spark" size={20} />
|
||||
</span>
|
||||
<div>
|
||||
<h2 id="sx-shortcut-modal-title">키보드 단축키 안내</h2>
|
||||
<p>시작 전에는 목표를 고르고 회기를 여는 데만 집중하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<dt>선택 접근</dt>
|
||||
<dd>
|
||||
<b>{selectedTheoryOption.label}</b>
|
||||
{selectedTheoryOption.focus}
|
||||
</dd>
|
||||
|
||||
<div className="sx-coach-modal__body sx-shortcut-modal__body">
|
||||
<div className="sx-shortcut-group">
|
||||
<h3>시작 전 조작</h3>
|
||||
<dl className="sx-shortcut-list">
|
||||
<div>
|
||||
<dt><kbd>Space</kbd> 또는 <kbd>Enter</kbd></dt>
|
||||
<dd>포커스한 회기 목표 선택/해제</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><kbd>Ctrl</kbd>+<kbd>Enter</kbd></dt>
|
||||
<dd>선택한 목표로 회기 시작</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><kbd>?</kbd> 또는 <kbd>Ctrl</kbd>+<kbd>/</kbd></dt>
|
||||
<dd>단축키 도움말 열기/닫기</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><kbd>Escape</kbd></dt>
|
||||
<dd>이 도움말 닫기</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<dt>이번 목표</dt>
|
||||
<dd>
|
||||
<b>{selectedGoals.length > 0 ? `${selectedGoals.length}개 선택` : "선택 필요"}</b>
|
||||
{selectedGoalSummary || "목표를 1개 이상 선택하면 시작할 수 있습니다."}
|
||||
</dd>
|
||||
|
||||
<div className="sx-coach-modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
ref={shortcutHelpConfirmRef}
|
||||
onClick={() => setShortcutHelpOpen(false)}
|
||||
>
|
||||
확인 (Esc)
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<dt>운영 기준</dt>
|
||||
<dd>
|
||||
<b>{limitMinutesLabel}분 회기</b>
|
||||
종료 {warningMinutesLabel}분 전 알림 · 위험 신호 시 안전 확인 우선
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* ── 시간 알람 바(회의 P1): 10분 전 경고 + 시간 만료 정리 유도 — 강제 노출 ── */}
|
||||
|
|
@ -4109,7 +4396,7 @@ export default function Session() {
|
|||
<blockquote>{coachSuggestion.next_utterance}</blockquote>
|
||||
) : null}
|
||||
<div className="sx-coach-bubble__actions">
|
||||
<button type="button" onClick={() => setCoachEvidenceOpen(true)}>
|
||||
<button type="button" onClick={openCoachEvidence}>
|
||||
근거 보기
|
||||
</button>
|
||||
<button type="button" onClick={() => openCoachHistory(null)}>
|
||||
|
|
@ -4432,6 +4719,7 @@ export default function Session() {
|
|||
}}
|
||||
>
|
||||
<section
|
||||
ref={coachHistoryPanelRef}
|
||||
className="sx-coach-history__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
|
@ -4558,6 +4846,7 @@ export default function Session() {
|
|||
}}
|
||||
>
|
||||
<section
|
||||
ref={coachEvidencePanelRef}
|
||||
className="sx-coach-modal__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
|
@ -4775,6 +5064,7 @@ export default function Session() {
|
|||
}}
|
||||
>
|
||||
<section
|
||||
ref={shortcutHelpPanelRef}
|
||||
className="sx-coach-modal sx-shortcut-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
|
@ -4876,6 +5166,7 @@ export default function Session() {
|
|||
<div className="sx-coach-modal__actions">
|
||||
<button
|
||||
type="button"
|
||||
ref={shortcutHelpConfirmRef}
|
||||
onClick={() => setShortcutHelpOpen(false)}
|
||||
>
|
||||
확인 (Esc)
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ import {
|
|||
REVIEW_READY_POLL_INTERVAL_MS,
|
||||
REVIEW_READY_POLL_LIMIT,
|
||||
displayEvaluationRetryError,
|
||||
evaluationFailureMessage,
|
||||
displayGeneratedReviewText,
|
||||
displayReviewSummary,
|
||||
displayTranscriptText,
|
||||
|
|
@ -1108,7 +1109,6 @@ export default function SessionReview() {
|
|||
if (resultError) {
|
||||
throw new Error(resultError);
|
||||
}
|
||||
setReviewReloadSeq((seq) => seq + 1);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
|
|
@ -1119,6 +1119,9 @@ export default function SessionReview() {
|
|||
setEvaluationRetryError(displayEvaluationRetryError(message));
|
||||
} finally {
|
||||
setEvaluationRetrying(false);
|
||||
// 재시도 결과가 error여도 서버가 최신 durable 상태를 저장할 수 있다. 성공/실패
|
||||
// 어느 쪽이든 다시 읽어야 교수자가 오래된 실패 원인을 보지 않는다.
|
||||
setReviewReloadSeq((seq) => seq + 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1253,7 +1256,9 @@ export default function SessionReview() {
|
|||
canReviewEndedSession &&
|
||||
hasTranscript &&
|
||||
!data.reviewReady &&
|
||||
data.supervisorState === "평가 실패";
|
||||
data.supervisorState === "평가 실패" &&
|
||||
(data.evaluationFailure?.retryable ?? true);
|
||||
const evaluationFailure = isSupervisorView ? data.evaluationFailure : null;
|
||||
const reviewReadiness = hasTranscript
|
||||
? `${turns.length}개 발화 기반`
|
||||
: "축어록 저장 후 생성";
|
||||
|
|
@ -1447,6 +1452,17 @@ export default function SessionReview() {
|
|||
{evaluationRetrying ? "AI 평가 실행 중" : "AI 평가 재시도"}
|
||||
</Button>
|
||||
) : null}
|
||||
{evaluationFailure ? (
|
||||
<p
|
||||
className={surfaceClassName(
|
||||
`sr-share-note sr-evaluation-failure${evaluationFailure.retryable ? "" : " sr-share-note--error"}`,
|
||||
{ variant: "inset", flat: true },
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
{evaluationFailureMessage(evaluationFailure)}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AppShell } from "../components/shell/AppShell";
|
||||
import { ResilientImage } from "../components/avatar/ResilientImage";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
|
|
@ -15,7 +16,6 @@ import { roleLabel, useAuth } from "../lib/auth";
|
|||
import {
|
||||
adminApi,
|
||||
adminEngineApi,
|
||||
apiUrl,
|
||||
userApi,
|
||||
type AdminHealthResponse,
|
||||
type AdminEngineConfigResponse,
|
||||
|
|
@ -653,8 +653,8 @@ export default function Settings() {
|
|||
const accountName = displayName || profile?.email || user?.name || user?.email || "사용자";
|
||||
const accountEmail = profile?.email ?? user?.email ?? "";
|
||||
const initials = accountName.trim().slice(0, 1).toUpperCase();
|
||||
// 프로필이 먼저 도착하면 프로필 값을, 아니면 auth 스토어의 절대화된 URL을 쓴다.
|
||||
const avatarSrc = profile?.avatar_url ? apiUrl(profile.avatar_url) : user?.avatarUrl ?? "";
|
||||
// 프로필이 먼저 도착하면 원본 값을 쓰고, 공통 이미지 경계에서 URL을 검증·절대화한다.
|
||||
const avatarSrc = profile?.avatar_url || user?.avatarUrl || "";
|
||||
const isLearner = role === "learner";
|
||||
const consentAt = user?.consentAt ?? null;
|
||||
const engineModels = engineCapabilities?.models ?? [];
|
||||
|
|
@ -702,7 +702,7 @@ export default function Settings() {
|
|||
|
||||
<div className={surfaceClassName("vg-set__rail-card", { variant: "inset" })} aria-label="계정 요약">
|
||||
<div className="vg-set__rail-avatar" aria-hidden="true">
|
||||
{avatarSrc ? <img src={avatarSrc} alt="" /> : initials}
|
||||
<ResilientImage src={avatarSrc} alt="" fallback={initials} />
|
||||
</div>
|
||||
<div className="vg-set__rail-copy">
|
||||
<div className="vg-set__rail-name">{accountName}</div>
|
||||
|
|
@ -753,7 +753,7 @@ export default function Settings() {
|
|||
|
||||
<div className={surfaceClassName("vg-set__profile", { variant: "inset", flat: true })}>
|
||||
<div className="vg-set__avatar" aria-hidden="true">
|
||||
{avatarSrc ? <img src={avatarSrc} alt="" /> : initials}
|
||||
<ResilientImage src={avatarSrc} alt="" fallback={initials} />
|
||||
</div>
|
||||
<div className="vg-set__profile-meta">
|
||||
<div className="n">{accountName}</div>
|
||||
|
|
|
|||
|
|
@ -606,6 +606,12 @@
|
|||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.vgops-tabs__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.vgops-tabs button {
|
||||
flex: 0 0 auto;
|
||||
min-height: 44px;
|
||||
|
|
|
|||
|
|
@ -94,27 +94,32 @@
|
|||
padding:8px 12px;
|
||||
border:1px solid transparent;
|
||||
border-radius:var(--radius-pill);
|
||||
background:transparent;
|
||||
background-color:transparent;
|
||||
color:var(--text-body);
|
||||
font-size:var(--fs-sm);
|
||||
font-weight:650;
|
||||
cursor:pointer;
|
||||
min-height:42px;
|
||||
transition:all var(--dur-fast) var(--ease-spring);
|
||||
min-height:44px;
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease-spring),
|
||||
border-color var(--dur-fast) var(--ease-spring),
|
||||
box-shadow var(--dur-fast) var(--ease-spring),
|
||||
color var(--dur-fast) var(--ease-spring),
|
||||
transform var(--dur-fast) var(--ease-spring);
|
||||
}
|
||||
.lh-tabs button:active{
|
||||
transform:scale(0.96);
|
||||
}
|
||||
.lh-tabs button:hover{
|
||||
color:var(--text-strong);
|
||||
background:color-mix(in srgb,var(--text-strong) 5%,transparent);
|
||||
background-color:color-mix(in srgb,var(--text-strong) 5%,transparent);
|
||||
}
|
||||
.lh-tabs button:focus-visible{
|
||||
outline:2px solid var(--focus-ring);
|
||||
outline-offset:1px;
|
||||
}
|
||||
.lh-tabs button.is-active{
|
||||
background:var(--bg-surface);
|
||||
background-color:var(--bg-surface);
|
||||
border-color:var(--border-strong);
|
||||
color:var(--text-strong);
|
||||
box-shadow:var(--shadow-sm);
|
||||
|
|
@ -1019,10 +1024,12 @@
|
|||
.lh-practice-launch-intent dl{
|
||||
grid-template-columns:repeat(auto-fit,minmax(128px,1fr));
|
||||
}
|
||||
.lh-practice-launch-intent__details{
|
||||
.lh-practice-launch-intent__details,
|
||||
.lh-continuity__memory{
|
||||
min-width:0;
|
||||
}
|
||||
.lh-practice-launch-intent__details summary{
|
||||
.lh-practice-launch-intent__details summary,
|
||||
.lh-continuity__memory summary{
|
||||
min-width:0;
|
||||
min-height:44px;
|
||||
display:grid;
|
||||
|
|
@ -1037,15 +1044,18 @@
|
|||
cursor:pointer;
|
||||
list-style:none;
|
||||
}
|
||||
.lh-practice-launch-intent__details summary::-webkit-details-marker{
|
||||
.lh-practice-launch-intent__details summary::-webkit-details-marker,
|
||||
.lh-continuity__memory summary::-webkit-details-marker{
|
||||
display:none;
|
||||
}
|
||||
.lh-practice-launch-intent__details summary span{
|
||||
.lh-practice-launch-intent__details summary span,
|
||||
.lh-continuity__memory summary span{
|
||||
color:var(--accent-deep);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:760;
|
||||
}
|
||||
.lh-practice-launch-intent__details summary b{
|
||||
.lh-practice-launch-intent__details summary b,
|
||||
.lh-continuity__memory summary b{
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
color:var(--text-strong);
|
||||
|
|
@ -1054,19 +1064,62 @@
|
|||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.lh-practice-launch-intent__details summary svg{
|
||||
.lh-practice-launch-intent__details summary svg,
|
||||
.lh-continuity__memory summary svg{
|
||||
transition:transform var(--dur-base) var(--ease-out);
|
||||
}
|
||||
.lh-practice-launch-intent__details[open] summary svg{
|
||||
.lh-practice-launch-intent__details[open] summary svg,
|
||||
.lh-continuity__memory[open] summary svg{
|
||||
transform:rotate(90deg);
|
||||
}
|
||||
.lh-practice-launch-intent__details summary:focus-visible{
|
||||
.lh-practice-launch-intent__details summary:focus-visible,
|
||||
.lh-continuity__memory summary:focus-visible{
|
||||
outline:2px solid var(--border-focus);
|
||||
outline-offset:2px;
|
||||
}
|
||||
.lh-practice-launch-intent__details dl{
|
||||
margin-top:var(--sp-2);
|
||||
}
|
||||
.lh-continuity__memory-body{
|
||||
margin-top:var(--sp-2);
|
||||
}
|
||||
.lh-continuity__memory-body > p{
|
||||
margin:0;
|
||||
color:var(--text-body);
|
||||
font-size:var(--fs-sm);
|
||||
line-height:1.55;
|
||||
word-break:keep-all;
|
||||
}
|
||||
.lh-continuity__memory-body dl{
|
||||
display:grid;
|
||||
gap:var(--sp-2);
|
||||
margin:0;
|
||||
}
|
||||
.lh-continuity__memory-body dl > div{
|
||||
min-width:0;
|
||||
padding-top:var(--sp-2);
|
||||
border-top:1px solid var(--border-subtle);
|
||||
}
|
||||
.lh-continuity__memory-body dt{
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:760;
|
||||
}
|
||||
.lh-continuity__memory-body dd{
|
||||
margin:5px 0 0;
|
||||
color:var(--text-body);
|
||||
font-size:var(--fs-sm);
|
||||
line-height:1.55;
|
||||
white-space:pre-wrap;
|
||||
word-break:keep-all;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.lh-continuity__memory-body ul{
|
||||
display:grid;
|
||||
gap:4px;
|
||||
margin:0;
|
||||
padding-left:18px;
|
||||
}
|
||||
.lh-practice-layout{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
|
|
@ -1357,6 +1410,139 @@
|
|||
text-align:right;
|
||||
word-break:keep-all;
|
||||
}
|
||||
.lh-launch-mode{
|
||||
width:min(100%,340px);
|
||||
margin:0;
|
||||
padding:0;
|
||||
border:0;
|
||||
}
|
||||
.lh-launch-mode legend{
|
||||
margin:0 0 6px;
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:760;
|
||||
}
|
||||
.lh-launch-mode > div{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:8px;
|
||||
}
|
||||
.lh-launch-mode label{
|
||||
position:relative;
|
||||
min-width:0;
|
||||
display:block;
|
||||
}
|
||||
.lh-launch-mode input{
|
||||
position:absolute;
|
||||
width:1px;
|
||||
height:1px;
|
||||
margin:-1px;
|
||||
overflow:hidden;
|
||||
clip:rect(0 0 0 0);
|
||||
white-space:nowrap;
|
||||
}
|
||||
.lh-launch-mode label > span{
|
||||
min-width:0;
|
||||
min-height:44px;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
padding:8px 10px;
|
||||
border:1px solid var(--border-subtle);
|
||||
border-radius:var(--radius-sm);
|
||||
background:var(--bg-surface-2);
|
||||
color:var(--text-body);
|
||||
font-size:var(--fs-sm);
|
||||
font-weight:740;
|
||||
line-height:1.25;
|
||||
word-break:keep-all;
|
||||
cursor:pointer;
|
||||
}
|
||||
.lh-launch-mode label:hover input:not(:disabled) + span{
|
||||
border-color:var(--border-strong);
|
||||
background:var(--bg-surface);
|
||||
color:var(--text-strong);
|
||||
}
|
||||
.lh-launch-mode input:checked + span{
|
||||
border-color:var(--accent);
|
||||
background:var(--accent-tint);
|
||||
color:var(--accent-deep);
|
||||
box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--accent) 12%,transparent);
|
||||
}
|
||||
.lh-launch-mode input:focus-visible + span{
|
||||
outline:2px solid var(--focus-ring);
|
||||
outline-offset:2px;
|
||||
}
|
||||
.lh-launch-mode input:disabled + span{
|
||||
cursor:not-allowed;
|
||||
opacity:.58;
|
||||
}
|
||||
.lh-continuity{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:var(--sp-3);
|
||||
padding:var(--sp-4);
|
||||
border:1px solid var(--border-subtle);
|
||||
border-radius:var(--radius);
|
||||
background:var(--bg-surface-2);
|
||||
}
|
||||
.lh-continuity__notice,
|
||||
.lh-continuity__head{
|
||||
min-width:0;
|
||||
}
|
||||
.lh-continuity__notice p,
|
||||
.lh-continuity__head p,
|
||||
.lh-continuity__status{
|
||||
margin:7px 0 0;
|
||||
color:var(--text-body);
|
||||
font-size:var(--fs-sm);
|
||||
line-height:1.55;
|
||||
word-break:keep-all;
|
||||
}
|
||||
.lh-continuity__status.is-error{
|
||||
color:var(--warn-text);
|
||||
}
|
||||
.lh-continuity__head{
|
||||
display:flex;
|
||||
align-items:start;
|
||||
justify-content:space-between;
|
||||
gap:var(--sp-3);
|
||||
}
|
||||
.lh-continuity__head > span{
|
||||
flex:none;
|
||||
color:var(--accent-deep);
|
||||
font-family:var(--font-num);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:800;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.lh-continuity__case-select{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:6px;
|
||||
}
|
||||
.lh-continuity__case-select > span{
|
||||
color:var(--text-muted);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:760;
|
||||
}
|
||||
.lh-continuity__case-select select{
|
||||
width:100%;
|
||||
min-height:44px;
|
||||
padding:8px 10px;
|
||||
border:1px solid var(--border-subtle);
|
||||
border-radius:var(--radius-sm);
|
||||
background:var(--bg-surface);
|
||||
color:var(--text-strong);
|
||||
font:inherit;
|
||||
}
|
||||
.lh-continuity__case-select select:focus-visible{
|
||||
outline:2px solid var(--focus-ring);
|
||||
outline-offset:2px;
|
||||
}
|
||||
.lh-continuity__case-select select:disabled{
|
||||
cursor:not-allowed;
|
||||
opacity:.72;
|
||||
}
|
||||
.lh-summary{
|
||||
min-width:0;
|
||||
padding:var(--sp-4);
|
||||
|
|
@ -1406,13 +1592,15 @@
|
|||
gap:var(--sp-4);
|
||||
padding:var(--sp-5);
|
||||
}
|
||||
.lh-activity__stats{
|
||||
.lh-activity__stats,
|
||||
.lh-continuity__stats{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||
gap:8px;
|
||||
}
|
||||
.lh-activity__stats span{
|
||||
.lh-activity__stats span,
|
||||
.lh-continuity__stats span{
|
||||
min-width:0;
|
||||
min-height:58px;
|
||||
display:grid;
|
||||
|
|
@ -1423,13 +1611,15 @@
|
|||
border-radius:var(--radius);
|
||||
background:var(--bg-surface-2);
|
||||
}
|
||||
.lh-activity__stats b{
|
||||
.lh-activity__stats b,
|
||||
.lh-continuity__stats b{
|
||||
color:var(--text-strong);
|
||||
font-family:var(--font-num);
|
||||
font-size:22px;
|
||||
line-height:1;
|
||||
}
|
||||
.lh-activity__stats small{
|
||||
.lh-activity__stats small,
|
||||
.lh-continuity__stats small{
|
||||
color:var(--text-muted);
|
||||
font-size:12px;
|
||||
font-weight:720;
|
||||
|
|
@ -1912,6 +2102,7 @@
|
|||
.lh-list-pane,
|
||||
.lh-preview__main,
|
||||
.lh-activity,
|
||||
.lh-continuity,
|
||||
.lh-persona-progress,
|
||||
.lh-history-main,
|
||||
.lh-archive-note{
|
||||
|
|
@ -1975,16 +2166,27 @@
|
|||
margin:0;
|
||||
max-width:70%;
|
||||
}
|
||||
.lh-activity__stats{
|
||||
.lh-activity__stats,
|
||||
.lh-continuity__stats{
|
||||
gap:6px;
|
||||
}
|
||||
.lh-activity__stats span{
|
||||
.lh-activity__stats span,
|
||||
.lh-continuity__stats span{
|
||||
min-height:46px;
|
||||
padding:8px 4px;
|
||||
text-align:center;
|
||||
}
|
||||
.lh-activity__stats b{font-size:17px;}
|
||||
.lh-activity__stats small{font-size:11px;}
|
||||
.lh-activity__stats b,
|
||||
.lh-continuity__stats b{font-size:17px;}
|
||||
.lh-activity__stats small,
|
||||
.lh-continuity__stats small{font-size:11px;}
|
||||
.lh-launch-mode{
|
||||
width:100%;
|
||||
}
|
||||
.lh-continuity__head{
|
||||
display:grid;
|
||||
gap:6px;
|
||||
}
|
||||
.lh-session-list li{
|
||||
grid-template-columns:1fr;
|
||||
gap:10px;
|
||||
|
|
|
|||
|
|
@ -919,8 +919,10 @@
|
|||
.ps-stepper button.is-current > span:first-child,.ps-stepper button.is-done > span:first-child{border-color:var(--accent);background:var(--accent);color:var(--text-on-accent);}
|
||||
.ps-authoring-layout{grid-template-columns:minmax(0,1fr) minmax(260px,320px);}
|
||||
.ps-authoring-layout[data-authoring-step="source"] .ps-tabs,
|
||||
.ps-authoring-layout[data-authoring-step="source"] .ps-tabs__panel,
|
||||
.ps-authoring-layout[data-authoring-step="source"] .ps-edit-panel,
|
||||
.ps-authoring-layout[data-authoring-step="generate"] .ps-tabs,
|
||||
.ps-authoring-layout[data-authoring-step="generate"] .ps-tabs__panel,
|
||||
.ps-authoring-layout[data-authoring-step="generate"] .ps-edit-panel,
|
||||
.ps-authoring-layout[data-authoring-step="edit"] .ps-source-panel,
|
||||
.ps-authoring-layout[data-authoring-step="review"] .ps-source-panel{display:none;}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type {
|
||||
ReviewEvaluationFailure,
|
||||
SessionReviewResponse,
|
||||
TeacherSessionReviewStatusResponse,
|
||||
UserPrepostMeasureItem,
|
||||
|
|
@ -134,9 +135,36 @@ export function displayGeneratedReviewText(text: string) {
|
|||
|
||||
export function displayEvaluationRetryError(message: string) {
|
||||
if (!message.trim()) return "AI 평가를 다시 실행하지 못했습니다.";
|
||||
if (/timeout|timed out/i.test(message)) {
|
||||
return "AI 평가가 제한 시간 안에 끝나지 않았습니다. 최신 상태를 다시 불러왔습니다.";
|
||||
}
|
||||
if (/engine_error|engine unavailable|transport error/i.test(message)) {
|
||||
return "평가 엔진에 일시적으로 연결하지 못했습니다. 최신 상태를 다시 불러왔습니다.";
|
||||
}
|
||||
return "AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요.";
|
||||
}
|
||||
|
||||
export function evaluationFailureMessage(
|
||||
failure: ReviewEvaluationFailure | null | undefined,
|
||||
) {
|
||||
switch (failure?.code) {
|
||||
case "timeout":
|
||||
return "평가 생성이 제한 시간 안에 끝나지 않았습니다. 축어록은 보존됐으며 다시 시도할 수 있습니다.";
|
||||
case "engine_unavailable":
|
||||
return "평가 엔진에 일시적으로 연결하지 못했습니다. 축어록은 보존됐으며 다시 시도할 수 있습니다.";
|
||||
case "legacy_argv_limit":
|
||||
return "이전 Windows 입력 한도에 걸린 평가입니다. 축어록은 보존됐으며 현재 입력 경로로 다시 시도할 수 있습니다.";
|
||||
case "prompt_too_large":
|
||||
return "평가 입력이 허용 크기를 넘어섰습니다. 같은 재시도 대신 입력 경로를 조정해야 합니다.";
|
||||
case "invalid_structured_output":
|
||||
return "평가 결과 형식이 검증되지 않았습니다. 축어록은 보존됐으며 다시 시도할 수 있습니다.";
|
||||
case "missing_evaluation":
|
||||
return "회기말 평가 기록이 아직 저장되지 않았습니다. 축어록은 보존됐으며 다시 시도할 수 있습니다.";
|
||||
default:
|
||||
return "평가 AI가 완료되지 않았습니다. 축어록은 보존됐으며 다시 시도할 수 있습니다.";
|
||||
}
|
||||
}
|
||||
|
||||
export function displayTranscriptText(text: string) {
|
||||
return displayPiiSafeText(text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -314,27 +314,32 @@
|
|||
padding: 8px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
background-color: transparent;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
min-height: 42px;
|
||||
transition: all var(--dur-fast) var(--ease-spring);
|
||||
min-height: 44px;
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease-spring),
|
||||
border-color var(--dur-fast) var(--ease-spring),
|
||||
box-shadow var(--dur-fast) var(--ease-spring),
|
||||
color var(--dur-fast) var(--ease-spring),
|
||||
transform var(--dur-fast) var(--ease-spring);
|
||||
}
|
||||
.sr-tabs button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.sr-tabs button:hover {
|
||||
color: var(--text-strong);
|
||||
background: color-mix(in srgb, var(--text-strong) 5%, transparent);
|
||||
background-color: color-mix(in srgb, var(--text-strong) 5%, transparent);
|
||||
}
|
||||
.sr-tabs button:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.sr-tabs button.is-active {
|
||||
background: var(--bg-surface);
|
||||
background-color: var(--bg-surface);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-strong);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
|
@ -1452,11 +1457,6 @@
|
|||
color: var(--text-body);
|
||||
background: color-mix(in srgb, var(--bg-surface) 82%, var(--bg-surface-2));
|
||||
}
|
||||
.sr-ws-input.is-readonly:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--border-subtle);
|
||||
box-shadow: none;
|
||||
}
|
||||
.sr-ws-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,7 +158,11 @@
|
|||
font: 650 12px/1 var(--font-sans);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all var(--dur-fast) var(--ease-out);
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out),
|
||||
opacity var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.sx-sessionbar__toggle-btn:hover {
|
||||
background: var(--accent-tint);
|
||||
|
|
@ -183,7 +187,11 @@
|
|||
font: 700 12.5px/1 var(--font-sans);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all var(--dur-fast) var(--ease-out);
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out),
|
||||
transform var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.sx-sessionbar button:hover {
|
||||
background: var(--accent-tint);
|
||||
|
|
@ -411,7 +419,10 @@
|
|||
color: var(--text-muted);
|
||||
font: 600 11.5px/1 var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: all var(--dur-fast) var(--ease-out);
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.sx-panel-close-btn:hover {
|
||||
|
|
@ -448,7 +459,12 @@
|
|||
gap: 14px;
|
||||
padding: 18px 4px;
|
||||
cursor: pointer;
|
||||
transition: all var(--dur-fast) var(--ease-spring);
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-spring),
|
||||
border-color var(--dur-fast) var(--ease-spring),
|
||||
box-shadow var(--dur-fast) var(--ease-spring),
|
||||
color var(--dur-fast) var(--ease-spring),
|
||||
transform var(--dur-fast) var(--ease-spring);
|
||||
}
|
||||
|
||||
.sx-mini-rail-btn:hover {
|
||||
|
|
@ -1169,6 +1185,7 @@
|
|||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.sx-utt__line {
|
||||
|
|
@ -1176,7 +1193,8 @@
|
|||
line-height: 1.58;
|
||||
padding: 9px 13px;
|
||||
border-radius: var(--radius);
|
||||
max-width: min(88%, 68ch);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* 내담자 = clay-tint 틴트 블록 (외곽선·꼬리·border-left 없음) */
|
||||
|
|
@ -1351,6 +1369,7 @@
|
|||
min-width: 0;
|
||||
}
|
||||
.sx-compose textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
background: var(--bg-surface-2);
|
||||
|
|
@ -1404,7 +1423,7 @@
|
|||
|
||||
.sx-compose__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
|
@ -1428,7 +1447,13 @@
|
|||
font: 700 13px/1 var(--font-sans);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all var(--dur-fast) var(--ease-out);
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out),
|
||||
opacity var(--dur-fast) var(--ease-out),
|
||||
transform var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.sx-coach-trigger-btn:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg-surface-2));
|
||||
|
|
@ -1582,7 +1607,9 @@
|
|||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--dur-fast) var(--ease-out);
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.sx-coach-bubble-popup__close:hover {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
|
|
@ -1677,7 +1704,11 @@
|
|||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
transition: all var(--dur-fast) var(--ease-out);
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out),
|
||||
transform var(--dur-fast) var(--ease-out);
|
||||
text-align: left;
|
||||
}
|
||||
.sx-coach-chip:hover:not(:disabled) {
|
||||
|
|
@ -1741,7 +1772,9 @@
|
|||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all var(--dur-fast) var(--ease-out);
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
transform var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.sx-coach-msg__apply-btn:hover {
|
||||
background: var(--accent);
|
||||
|
|
@ -1808,7 +1841,9 @@
|
|||
background: var(--bg-surface-2);
|
||||
color: var(--text-strong);
|
||||
font-size: 12.5px;
|
||||
transition: all var(--dur-fast) var(--ease-out);
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.sx-coach-bubble-popup__composer input:focus {
|
||||
border-color: var(--accent);
|
||||
|
|
@ -1827,7 +1862,9 @@
|
|||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all var(--dur-fast) var(--ease-out);
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.sx-coach-bubble-popup__composer button:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
|
|
@ -5760,3 +5797,467 @@
|
|||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 2026-08-30 · 회기 프리브리프: 사례 → 한 가지 수행 초점 → 근거 기반 재연습 ──
|
||||
시작 전 화면은 설정을 한꺼번에 읽는 대시보드가 아니라, 첫 발화에 필요한 판단만
|
||||
앞에 두는 학습 준비면이다. 기존 API payload·동의·처방/음성 URL 문맥은 바꾸지 않는다. */
|
||||
.sx-page--prestart .sx-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.sx-page--prestart .sx-head__lt {
|
||||
max-width: 46rem;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart {
|
||||
grid-template-areas: "case learning map";
|
||||
grid-template-columns: minmax(210px, 0.78fr) minmax(0, 1.5fr) minmax(220px, 0.84fr);
|
||||
align-items: start;
|
||||
gap: var(--sp-5);
|
||||
padding: var(--sp-5);
|
||||
overflow: visible;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__visual {
|
||||
grid-area: case;
|
||||
align-self: stretch;
|
||||
align-content: start;
|
||||
justify-items: start;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__visual .vg-avatar {
|
||||
justify-self: center;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__case {
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__case b {
|
||||
margin-top: var(--sp-1);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__case p {
|
||||
max-width: 28ch;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__facts {
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
|
||||
gap: var(--sp-3);
|
||||
margin: 0;
|
||||
padding: var(--sp-3) 0;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__chips {
|
||||
margin-top: 0;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__learning {
|
||||
grid-area: learning;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__intro {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__intro .vg-kicker {
|
||||
margin: 0;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__title,
|
||||
.sx-page--prestart .sx-prestart__desc {
|
||||
margin: 0;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__title {
|
||||
max-width: 20ch;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__desc {
|
||||
max-width: 38ch;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__learning .sx-voice-practice-context {
|
||||
margin-top: 0;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__focus {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--glass-inset-border);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__focus-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.84fr) minmax(0, 1.16fr);
|
||||
align-items: end;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__focus-head h3 {
|
||||
margin: var(--sp-1) 0 0;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-h3);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__focus-head p {
|
||||
margin: 0;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.sx-page--prestart .sx-goals {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.sx-page--prestart .sx-goals__grid {
|
||||
padding: var(--sp-2);
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.sx-page--prestart .sx-goals__grid button {
|
||||
min-height: 68px;
|
||||
text-align: left;
|
||||
}
|
||||
.sx-page--prestart .sx-goals__grid button.is-primary {
|
||||
border-color: var(--accent-deep);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-deep);
|
||||
}
|
||||
.sx-page--prestart .sx-goals__grid button.is-primary span::after {
|
||||
content: " · 핵심";
|
||||
color: var(--accent-deep);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
.sx-page--prestart .sx-goals__hint {
|
||||
margin: 0;
|
||||
max-width: 54ch;
|
||||
}
|
||||
.sx-page--prestart .sx-goals__hint b {
|
||||
color: var(--accent-deep);
|
||||
font-weight: 700;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__primary-switch {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--glass-inset-border);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__primary-switch p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__primary-switch p b {
|
||||
color: var(--text-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__primary-switch [role="group"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__primary-switch button {
|
||||
min-height: 44px;
|
||||
padding: 0 var(--sp-3);
|
||||
border: 1px solid var(--glass-inset-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__primary-switch button:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 48%, var(--glass-inset-border));
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__primary-switch button:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__primary-select {
|
||||
display: none;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__settings {
|
||||
margin-top: 0;
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--glass-inset-border);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__settings:not(.is-prescribed) > summary {
|
||||
display: grid;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__settings-body {
|
||||
padding-top: var(--sp-3);
|
||||
}
|
||||
.sx-page--prestart .sx-theory {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__settings-note {
|
||||
margin: var(--sp-3) 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__voice-disclosure,
|
||||
.sx-page--prestart .sx-consent {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__plan {
|
||||
grid-area: map;
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__learning-map h3 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-h3);
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__learning-loop {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__learning-loop li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(3.5rem, auto) minmax(0, 1fr);
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3) 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__learning-loop li > span {
|
||||
color: var(--accent-deep);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__learning-loop li > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__learning-loop b {
|
||||
display: block;
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__learning-loop p,
|
||||
.sx-page--prestart .sx-prestart__boundary {
|
||||
margin: var(--sp-1) 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__boundary {
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
@media (max-width: 1260px) {
|
||||
.sx-page--prestart .sx-prestart {
|
||||
grid-template-areas:
|
||||
"case learning"
|
||||
"map map";
|
||||
grid-template-columns: minmax(190px, 0.7fr) minmax(0, 1.3fr);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__plan {
|
||||
border-top: 1px solid var(--glass-inset-border);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__learning-loop {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__learning-loop li {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 짧은 데스크톱(1280×720 등)에서는 첫 발화 준비의 정보는 모두 유지하되 중앙 학습 열의
|
||||
여백만 조밀하게 만든다. 이전에는 상태 설명이 줄바꿈되면서 핵심 CTA가 접혔다. */
|
||||
@media (min-width: 1261px) and (max-height: 800px) {
|
||||
.sx-page--prestart .sx-prestart__learning {
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__focus {
|
||||
gap: var(--sp-1);
|
||||
padding-top: var(--sp-2);
|
||||
}
|
||||
.sx-page--prestart .sx-goals__grid {
|
||||
padding: 2px;
|
||||
}
|
||||
.sx-page--prestart .sx-goals__grid button {
|
||||
min-height: 52px;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__settings {
|
||||
padding-top: var(--sp-2);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__primary-switch {
|
||||
gap: var(--sp-1);
|
||||
padding-top: var(--sp-2);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__primary-switch > p,
|
||||
.sx-page--prestart .sx-prestart__primary-switch > [role="group"] {
|
||||
display: none;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__primary-select {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(10.5rem, 1.25fr);
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__primary-select span {
|
||||
color: var(--text-body);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__primary-select select {
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--sp-2);
|
||||
border: 1px solid var(--glass-inset-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--glass-surface-inset);
|
||||
color: var(--text-strong);
|
||||
font: inherit;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__actions {
|
||||
position: sticky;
|
||||
z-index: 3;
|
||||
bottom: var(--sp-1);
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2);
|
||||
border: 1px solid var(--glass-inset-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-surface);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__actions span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.vg-main:has(.sx-page--prestart),
|
||||
.vg-main:has(.sx-page--prestart .sx-prestart--prescribed) {
|
||||
padding-bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-6));
|
||||
}
|
||||
.sx-page--prestart .sx-prestart {
|
||||
grid-template-areas:
|
||||
"case"
|
||||
"learning"
|
||||
"map";
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__visual {
|
||||
grid-template-columns: 108px minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: var(--sp-3);
|
||||
align-items: center;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__visual .vg-avatar,
|
||||
.sx-page--prestart .sx-prestart__visual .vg-avatar__svg {
|
||||
width: 108px !important;
|
||||
height: 108px !important;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__visual .vg-avatar__stage {
|
||||
height: 108px !important;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__case {
|
||||
display: grid;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__facts,
|
||||
.sx-page--prestart .sx-prestart__chips {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__facts {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr));
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__focus-head {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.sx-page--prestart .sx-goals__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__plan {
|
||||
display: grid;
|
||||
}
|
||||
/* 처방 재연습은 과제·성공 기준이 이미 위에 고정되어 있다. 작은 화면에서 이를
|
||||
다시 설명하거나 목표 선택을 노출하면 주 행동(회기 시작)이 첫 화면 밖으로 밀린다. */
|
||||
.sx-page--prestart .sx-prestart--prescribed {
|
||||
grid-template-areas:
|
||||
"learning"
|
||||
"case";
|
||||
}
|
||||
.sx-page--prestart .sx-prestart--prescribed > .sx-prestart__plan,
|
||||
.sx-page--prestart .sx-prestart--prescribed .sx-prestart__focus {
|
||||
display: none;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart--prescribed .sx-prestart__actions span {
|
||||
display: none;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__learning-loop {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__learning-loop li {
|
||||
grid-template-columns: minmax(3.5rem, auto) minmax(0, 1fr);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__actions,
|
||||
.sx-page--prestart .sx-prestart--prescribed .sx-prestart__actions {
|
||||
position: sticky;
|
||||
z-index: 3;
|
||||
bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-2));
|
||||
padding: var(--sp-2);
|
||||
border: 1px solid var(--glass-inset-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--glass-surface);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__actions .vg-btn {
|
||||
width: 100%;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__actions span {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.sx-page--prestart .sx-prestart__visual {
|
||||
display: grid;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart--prescribed {
|
||||
grid-template-areas: "learning";
|
||||
}
|
||||
.sx-page--prestart .sx-prestart--prescribed .sx-prestart__visual {
|
||||
display: none;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__visual {
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__visual .vg-avatar,
|
||||
.sx-page--prestart .sx-prestart__visual .vg-avatar__svg {
|
||||
width: 88px !important;
|
||||
height: 88px !important;
|
||||
}
|
||||
.sx-page--prestart .sx-prestart__visual .vg-avatar__stage {
|
||||
height: 88px !important;
|
||||
}
|
||||
.sx-page--prestart .sx-goals__grid button {
|
||||
min-height: 64px;
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue