dsh-plugin-lookatstudy 0.22.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -156,6 +156,7 @@ window.__ModuleLoader__.load({
156
156
  .lks-ui .lks14-viewtab:hover{color:var(--ink)}
157
157
  .lks-ui .lks14-viewtab.on{background:rgb(var(--brand-rgb)/0.16);color:var(--brand)}
158
158
  .lks-ui .lks14-prose{color:var(--ink);font-size:.9375rem;line-height:1.65}
159
+ .lks-ui .lks14-prose img{max-width:100%;height:auto}
159
160
  .lks-ui .lks14-prose h1,.lks-ui .lks-note-text h1{font-size:1.25rem;font-weight:800;color:var(--ink-strong)}
160
161
  .lks-ui .lks14-prose h2,.lks-ui .lks-note-text h2{font-size:1.1rem;font-weight:700;color:var(--ink-strong);border-bottom:1px solid var(--border);padding-bottom:.3em}
161
162
  .lks-ui .lks14-prose h3,.lks-ui .lks-note-text h3{font-size:1rem;font-weight:700;color:var(--ink)}
@@ -434,9 +435,27 @@ window.__ModuleLoader__.load({
434
435
 
435
436
  /* ══════════ P11b (C-track closure): chat parts, notes closure, tooltip, confirm ══════════ */
436
437
  /* C14: the collapsible reasoning block */
438
+ /* 0.24.0: the host-style thinking disclosure (ReasoningRow port) — running
439
+ rows stream the latest line follow-end with the sweep, settled rows lead
440
+ with the first line, the expanded body reads the tertiary tier */
437
441
  .lks-ui .lks14-reasoning{border:1px solid var(--border-faint);border-radius:10px;padding:4px 10px;font-size:12px;color:var(--ink-faint)}
438
442
  .lks-ui .lks14-reasoning summary{cursor:pointer;font-weight:600;color:var(--ink-muted);user-select:none}
439
443
  .lks-ui .lks14-reasoning[open] summary{margin-bottom:4px}
444
+ .lks-ui .lks14-reasoning-row{display:flex;align-items:center;gap:6px;min-width:0;position:relative;overflow:hidden}
445
+ .lks-ui .lks14-reasoning-title{flex:none;font-weight:400;color:var(--ink-muted)}
446
+ .lks-ui .lks14-reasoning-sep{flex:none;width:2px;height:2px;margin:0 4px;border-radius:1px;background:var(--ink-faint)}
447
+ .lks-ui .lks14-reasoning-summary{min-width:0;overflow:hidden;flex:1 1 auto;color:var(--ink-faint);white-space:nowrap}
448
+ .lks-ui .lks14-reasoning-sumtext{display:block;overflow:hidden;text-overflow:ellipsis}
449
+ .lks-ui .lks14-reasoning-summary[data-follow-end]{display:flex;justify-content:flex-end}
450
+ .lks-ui .lks14-reasoning-summary[data-follow-end] .lks14-reasoning-sumtext{flex:0 0 auto;width:max-content;min-width:100%;overflow:visible;text-align:start;text-overflow:clip}
451
+ .lks-ui .lks14-reasoning[data-state='running'] .lks14-reasoning-row::after{content:'';position:absolute;inset-block:0;left:0;width:180px;background:linear-gradient(90deg,transparent 0%,rgb(var(--surface-2-rgb)/0.9) 55%,transparent 100%);animation:lks-reasoning-sweep 2.6s ease-out infinite;pointer-events:none}
452
+ @keyframes lks-reasoning-sweep{0%{left:-180px}90%,100%{left:100%}}
453
+ @media (prefers-reduced-motion: reduce){.lks-ui .lks14-reasoning[data-state='running'] .lks14-reasoning-row::after{animation:none}}
454
+ .lks-ui .lks14-reasoning.open .lks14-reasoning-body,.lks-ui .lks14-reasoning[open] .lks14-reasoning-body{padding:4px 0 4px 20px;color:var(--ink-faint);white-space:pre-wrap;word-break:break-word}
455
+ /* 0.24.0: the teach-tab 原文/对照/译文 segmented control */
456
+ .lks-ui .lks-langview{display:inline-flex;gap:2px;padding:2px;border:1px solid var(--border-faint);border-radius:8px;background:rgb(var(--surface-2-rgb)/0.6)}
457
+ .lks-ui .lks-langview button{border:0;background:transparent;color:var(--ink-muted);font-size:12px;line-height:1;padding:4px 8px;border-radius:6px;cursor:pointer}
458
+ .lks-ui .lks-langview button.on{background:rgb(var(--brand-rgb)/0.16);color:var(--brand)}
440
459
  /* C14: the tool-call chip states */
441
460
  .lks-ui .lks14-toolchip{display:inline-flex;align-items:center;gap:6px;align-self:flex-start;font-size:12px;font-weight:600;
442
461
  padding:3px 10px;border-radius:999px;background:rgb(var(--ink-rgb)/0.05);color:var(--ink-muted)}
@@ -787,7 +806,31 @@ window.__ModuleLoader__.load({
787
806
  .lks-ui .lks14-threadmenu-row.on{background:rgb(var(--brand-rgb)/0.1)}
788
807
  .lks-ui .lks14-threadmenu-row.on .lks14-threadmenu-title{color:var(--brand);font-weight:700}
789
808
  .lks-ui .lks14-threadmenu-dot{flex:none;width:6px;height:6px;border-radius:999px;background:var(--ink-faint)}
790
- .lks-ui .lks14-threadmenu-row.new{margin-top:3px;border-top:1px solid var(--border-faint);border-radius:0 0 7px 7px;padding-top:7px}
809
+ /* 0.23.0 course scope: the coverage line under a course thread's title, and
810
+ the granularity toggle row pinned above the +新建 row */
811
+ .lks-ui .lks14-threadmenu-title{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
812
+ .lks-ui .lks14-threadmenu-cover{font-size:10.5px;color:var(--ink-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
813
+ .lks-ui .lks14-threadmenu-row.scope{margin-top:3px;border-top:1px solid var(--border-faint);border-radius:0;padding-top:7px}
814
+ .lks-ui .lks14-threadmenu-scope-icon{flex:none;font-size:12px;line-height:1}
815
+ .lks-ui .lks14-threadmenu-scope-state{flex:none;margin-left:auto;font-size:10.5px;font-weight:700;color:var(--ink-faint);padding:1px 7px;border-radius:999px;border:1px solid var(--border)}
816
+ .lks-ui .lks14-threadmenu-scope-state.on{color:var(--brand);border-color:rgb(var(--brand-rgb)/0.45);background:rgb(var(--brand-rgb)/0.08)}
817
+ .lks-ui .lks14-threadmenu-row.new{margin-top:1px;border-top:1px solid var(--border-faint);border-radius:0 0 7px 7px;padding-top:7px}
818
+ /* 0.23.0: the ask_user_question answer row — the panel's own answer UI for
819
+ the host's pending clarifying question (the hidden host column used to be
820
+ the only place its options lived) */
821
+ .lks-ui .lks14-askrow{display:flex;flex-direction:column;gap:8px;padding:12px 14px;border:1px solid rgb(var(--brand-rgb)/0.35);border-radius:12px;background:rgb(var(--brand-rgb)/0.05)}
822
+ .lks-ui .lks14-askrow.answered{opacity:.62;border-color:var(--border-faint);background:none}
823
+ .lks-ui .lks14-askhead{font-size:12px;font-weight:800;color:var(--brand)}
824
+ .lks-ui .lks14-askrow.answered .lks14-askhead{color:var(--ink-muted)}
825
+ .lks-ui .lks14-askq{display:flex;flex-direction:column;gap:6px}
826
+ .lks-ui .lks14-askq-h{font-size:13px;font-weight:700;color:var(--ink)}
827
+ .lks-ui .lks14-askopt{display:flex;flex-direction:column;gap:2px;align-items:flex-start;padding:8px 12px;border:1px solid var(--border);border-radius:9px;background:var(--surface-0);cursor:pointer;text-align:left;font:inherit}
828
+ .lks-ui .lks14-askopt:hover:not(:disabled){border-color:rgb(var(--brand-rgb)/0.55);background:rgb(var(--brand-rgb)/0.07)}
829
+ .lks-ui .lks14-askopt:disabled{cursor:default;opacity:.75}
830
+ .lks-ui .lks14-askopt-label{font-size:12.5px;font-weight:600;color:var(--ink)}
831
+ .lks-ui .lks14-askopt-desc{font-size:11px;color:var(--ink-muted);line-height:1.5}
832
+ .lks-ui .lks14-askfree{font-size:12px;color:var(--ink-muted)}
833
+ .lks-ui .lks14-askdone{font-size:11px;color:var(--ink-faint)}
791
834
  .lks-ui .lks14-threadmenu-row{position:relative}
792
835
  .lks-ui .lks14-threadmenu-gear{flex:none;margin-left:auto;border:none;background:none;color:var(--ink-faint);font-size:13px;line-height:1;cursor:pointer;padding:2px 5px;border-radius:5px;opacity:0;transition:opacity .12s,color .12s}
793
836
  .lks-ui .lks14-threadmenu-row:hover .lks14-threadmenu-gear,.lks-ui .lks14-threadmenu-gear:focus-visible{opacity:1}
@@ -1778,6 +1821,16 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1778
1821
  });
1779
1822
  this.refresh();
1780
1823
  }
1824
+ /** 0.24.0: push the host interface language (the panel reads <html lang>,
1825
+ * which the host keeps in sync with the active locale) — the apply-side
1826
+ * translation matching consumes it. */
1827
+ async setInterfaceLang(lang) {
1828
+ await fetchJson("/lookatstudy/api/prefs", {
1829
+ method: "POST",
1830
+ headers: { "content-type": "application/json" },
1831
+ body: JSON.stringify({ interfaceLang: lang })
1832
+ });
1833
+ }
1781
1834
  /** E3: land one attachment verbatim in the study workspace; returns the workspace-relative path. */
1782
1835
  async uploadAttachment(name, dataBase64) {
1783
1836
  const body = await fetchJson("/lookatstudy/api/attachment", {
@@ -1850,6 +1903,19 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1850
1903
  this.refresh();
1851
1904
  return res;
1852
1905
  }
1906
+ /** Flip a course's thread granularity (0.23.0, issue #11 follow-up). Never
1907
+ * touches sediment — both sides' groups stay and are resumed on flip. */
1908
+ async setCourseScope(courseId, scope) {
1909
+ await fetchJson("/lookatstudy/api/course-scope", {
1910
+ method: "POST",
1911
+ headers: { "content-type": "application/json" },
1912
+ body: JSON.stringify({
1913
+ courseId,
1914
+ scope
1915
+ })
1916
+ });
1917
+ this.refresh();
1918
+ }
1853
1919
  /** Delete one course (and its proposals) from the host state. */
1854
1920
  async deleteCourse(courseId) {
1855
1921
  await fetchJson("/lookatstudy/api/course/delete", {
@@ -2047,10 +2113,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2047
2113
  activate: studyStore.activate.bind(studyStore),
2048
2114
  setMode: studyStore.setMode.bind(studyStore),
2049
2115
  setHistoryBudget: studyStore.setHistoryBudget.bind(studyStore),
2116
+ setInterfaceLang: studyStore.setInterfaceLang.bind(studyStore),
2050
2117
  uploadAttachment: studyStore.uploadAttachment.bind(studyStore),
2051
2118
  setFocus: studyStore.setFocus.bind(studyStore),
2052
2119
  searchLessons: studyStore.searchLessons.bind(studyStore),
2053
2120
  tts: studyStore.tts.bind(studyStore),
2121
+ setCourseScope: studyStore.setCourseScope.bind(studyStore),
2054
2122
  deleteCourse: studyStore.deleteCourse.bind(studyStore),
2055
2123
  deleteNote: studyStore.deleteNote.bind(studyStore),
2056
2124
  editNote: studyStore.editNote.bind(studyStore),
@@ -2255,6 +2323,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2255
2323
  "review.banner": "正在复习:{lesson}",
2256
2324
  "review.threadTitle": "复习:{lesson}",
2257
2325
  "threads.label": "课时线程",
2326
+ "threads.scope": "跨课时连续对话",
2327
+ "threads.scope.on": "已开启",
2328
+ "threads.scope.off": "未开启",
2329
+ "threads.coverage": "覆盖",
2330
+ "ask.title": "导师想先确认一下",
2331
+ "ask.free": "在下方输入框直接回复即可",
2332
+ "ask.answered": "已回答",
2258
2333
  "meter.label": "上下文用量",
2259
2334
  "meter.aria": "上下文已用 {pct}%",
2260
2335
  "meter.brk.system": "系统提示",
@@ -2283,6 +2358,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2283
2358
  "rail.due.start": "开始复习",
2284
2359
  "rail.due.none": "暂时没有到期的复习,保持节奏!",
2285
2360
  "chat.reasoning": "思考过程({n} 字)",
2361
+ "chat.think": "思考",
2362
+ "langview.original": "原文",
2363
+ "langview.bilingual": "对照",
2364
+ "langview.translation": "译文",
2286
2365
  "msg.speak": "朗读",
2287
2366
  "note.pin": "置顶笔记",
2288
2367
  "note.unpin": "取消置顶",
@@ -2603,6 +2682,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2603
2682
  "palette.kind.action": "Action",
2604
2683
  "palette.empty": "No matches",
2605
2684
  "threads.label": "Lesson threads",
2685
+ "threads.scope": "Continuous course thread",
2686
+ "threads.scope.on": "on",
2687
+ "threads.scope.off": "off",
2688
+ "threads.coverage": "covers",
2689
+ "ask.title": "The tutor wants a quick confirm",
2690
+ "ask.free": "Answer in the composer below",
2691
+ "ask.answered": "answered",
2606
2692
  "threads.open": "{n} threads",
2607
2693
  "threads.now": "current",
2608
2694
  "threads.new": "New thread (your next message starts it)",
@@ -2643,6 +2729,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2643
2729
  "rail.due.start": "Start reviewing",
2644
2730
  "rail.due.none": "Nothing due right now — keep the streak!",
2645
2731
  "chat.reasoning": "Thinking ({n} chars)",
2732
+ "chat.think": "Think",
2733
+ "langview.original": "Original",
2734
+ "langview.bilingual": "Both",
2735
+ "langview.translation": "Translation",
2646
2736
  "msg.speak": "Speak",
2647
2737
  "note.pin": "Pin note",
2648
2738
  "note.unpin": "Unpin",
@@ -2998,6 +3088,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2998
3088
  return out.join("\n");
2999
3089
  }
3000
3090
  //#endregion
3091
+ //#region src/thread-key.ts
3092
+ /** Resolve the thread-group map key for a send from `lessonId`. Absent scope
3093
+ * is legacy lesson behavior — old files never move. */
3094
+ function threadOwnerKey(scope, courseId, lessonId) {
3095
+ return scope === "course" ? `course:${courseId}` : lessonId;
3096
+ }
3097
+ //#endregion
3001
3098
  //#region src/vendor/mermaid-elk-rewrite.ts
3002
3099
  /**
3003
3100
  * mermaid-elk-rewrite —— flowchart → flowchart-elk 前缀改写(v0.21 纯函数)。
@@ -3634,6 +3731,22 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3634
3731
  return (0, react.createElement)("div", { className: "lks-propcard-err" }, error);
3635
3732
  }
3636
3733
  /**
3734
+ * The thinking disclosure's collapsed one-line summary (0.24.0, the host
3735
+ * ReasoningRow contract verbatim): while RUNNING the LAST line rides (the
3736
+ * summary visibly follows the stream's tail), settled the FIRST line leads;
3737
+ * double-asterisk markers are omitted either way. Pure.
3738
+ */
3739
+ function reasoningSummaryLine(text, running) {
3740
+ return (running ? (() => {
3741
+ const v = text.trimEnd();
3742
+ const nl = v.lastIndexOf("\n");
3743
+ return nl === -1 ? v : v.slice(nl + 1);
3744
+ })() : (() => {
3745
+ const nl = text.indexOf("\n");
3746
+ return nl === -1 ? text : text.slice(0, nl);
3747
+ })()).replaceAll("**", "");
3748
+ }
3749
+ /**
3637
3750
  * Tooltip text for one course-tree status glyph. Pure (the translator is an
3638
3751
  * optional parameter defaulting to the active one, evaluated per call).
3639
3752
  */
@@ -3751,10 +3864,26 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3751
3864
  return group.threads.filter((t) => t.status !== "archived").slice().sort((a, b) => a.lastAt < b.lastAt ? 1 : -1).map((t) => ({
3752
3865
  id: t.id,
3753
3866
  title: t.title,
3754
- current: t.id === group.active
3867
+ current: t.id === group.active,
3868
+ touchedLessons: t.touchedLessons
3755
3869
  }));
3756
3870
  }
3757
3871
  /**
3872
+ * 0.23.0 course scope: the coverage label under a course-scoped thread — the
3873
+ * lessons it has been sent from, by section order, capped at three titles
3874
+ * with an 等 N 课时 tail. Empty/undefined coverage renders '' (lesson-scoped
3875
+ * threads show nothing). Pure.
3876
+ */
3877
+ function threadCoverageLabel(touched, courses) {
3878
+ if (touched === void 0 || touched.length === 0) return "";
3879
+ const titleOf = /* @__PURE__ */ new Map();
3880
+ for (const c of courses) for (const s of c.sections) for (const l of s.lessons) titleOf.set(l.id, l.title);
3881
+ const titles = touched.map((id) => titleOf.get(id)).filter((t) => t !== void 0);
3882
+ if (titles.length === 0) return "";
3883
+ const head = titles.slice(0, 3).join(" · ");
3884
+ return titles.length > 3 ? `${head} 等 ${String(titles.length)} 课时` : head;
3885
+ }
3886
+ /**
3758
3887
  * Default expansion for one rail section: collapsed when every study lesson is
3759
3888
  * done (mastered) or not yet reachable (locked). The focus lesson's section
3760
3889
  * stays open; exam nodes never force a section open. Pure.
@@ -4680,6 +4809,27 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4680
4809
  */
4681
4810
  const TURN_STALL_MS = 24e4;
4682
4811
  /**
4812
+ * 0.23.1 issue 3: how long an import funnel may sit with NO live turn before
4813
+ * the panel reads the import as failed. The old failure exit armed on the
4814
+ * turnActive prop having rendered true once — but a turn that dies inside one
4815
+ * event-window notification batch (turn/start + turn/end between two reads,
4816
+ * the keyless-provider fast fail; journal-verified: attempt → step/end →
4817
+ * turn/end within seconds) never flips the prop, so the exit never armed and
4818
+ * the funnel spun forever. This deadline needs no observation: past grace
4819
+ * with the turn not active = failed. The grace only covers prompt queue
4820
+ * latency (turn/start journals within seconds of the queue-time resolve); a
4821
+ * live turn holds the funnel open for its whole life however long the import
4822
+ * tools run.
4823
+ */
4824
+ const IMPORT_FAIL_GRACE_MS = 3e4;
4825
+ /**
4826
+ * The funnel's failure decision (pure — the wall clock lives at the poll call
4827
+ * site, mirroring {@link turnStalled}).
4828
+ */
4829
+ function importJobDead(job, turnActive, now, grace = IMPORT_FAIL_GRACE_MS) {
4830
+ return job !== null && !turnActive && now - job.startedAt > grace;
4831
+ }
4832
+ /**
4683
4833
  * The stall decision (pure — the wall clock lives at the poll call site):
4684
4834
  * an ACTIVE fold stays active while the window's top seq keeps advancing;
4685
4835
  * once it has been frozen for longer than stallMs the panel treats the turn
@@ -4873,8 +5023,20 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4873
5023
  });
4874
5024
  } else if (event.type === "tool/call") {
4875
5025
  const name = event.data?.tool?.name ?? event.data?.name ?? "tool";
5026
+ const callId = event.data?.callId ?? event.data?.toolCallId;
5027
+ if (name === "ask_user_question") {
5028
+ const ask = parseAskArguments(event.data?.arguments, callId ?? String(event.seq));
5029
+ rows.push({
5030
+ key: `t${String(callId ?? event.seq)}`,
5031
+ role: "ask",
5032
+ text: ask.summary,
5033
+ toolState: "loading",
5034
+ ask
5035
+ });
5036
+ continue;
5037
+ }
4876
5038
  rows.push({
4877
- key: `t${event.data?.callId ?? event.data?.toolCallId ?? event.seq}`,
5039
+ key: `t${String(callId ?? event.seq)}`,
4878
5040
  role: "tool",
4879
5041
  text: name,
4880
5042
  toolState: "loading"
@@ -4882,13 +5044,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4882
5044
  } else if (event.type === "tool/result") {
4883
5045
  const rid = event.data?.message?.source?.callId ?? event.data?.callId ?? event.data?.toolCallId;
4884
5046
  if (rid !== void 0) {
4885
- const idx = rows.findIndex((r) => r.key === `t${rid}` && r.role === "tool");
5047
+ const idx = rows.findIndex((r) => r.key === `t${rid}` && (r.role === "tool" || r.role === "ask"));
4886
5048
  if (idx >= 0) {
4887
5049
  const failed = event.data?.message?.isError === true || event.data?.error !== void 0;
4888
5050
  rows[idx] = {
4889
5051
  ...rows[idx],
4890
5052
  toolState: failed ? "error" : "done",
4891
- resultText: failed ? void 0 : resultRenderedText(event)
5053
+ resultText: failed ? void 0 : resultRenderedText(event),
5054
+ ...rows[idx].role === "ask" && rows[idx].ask !== void 0 && !failed ? { ask: {
5055
+ ...rows[idx].ask,
5056
+ answered: true
5057
+ } } : {}
4892
5058
  };
4893
5059
  }
4894
5060
  }
@@ -4897,17 +5063,32 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4897
5063
  continue;
4898
5064
  }
4899
5065
  const chunk = event.data?.chunk;
4900
- if (chunk === void 0 || chunk.type !== "text-delta") continue;
5066
+ if (chunk === void 0 || chunk.type !== "text-delta" && chunk.type !== "reasoning-delta") continue;
4901
5067
  const attemptId = event.data?.attemptId ?? String(event.seq);
5068
+ const delta = chunk.text ?? "";
4902
5069
  const stream = streams.get(attemptId);
4903
5070
  if (stream === void 0) streams.set(attemptId, {
4904
- text: chunk.text ?? "",
5071
+ text: chunk.type === "text-delta" ? delta : "",
5072
+ reasoning: chunk.type === "reasoning-delta" ? delta : "",
4905
5073
  anchor: rows.length,
4906
5074
  settled: false
4907
5075
  });
4908
- else stream.text += chunk.text ?? "";
5076
+ else if (chunk.type === "text-delta") stream.text += delta;
5077
+ else stream.reasoning += delta;
4909
5078
  latest = attemptId;
4910
5079
  }
5080
+ let askOpen = false;
5081
+ for (let i = rows.length - 1; i >= 0; i--) {
5082
+ const r = rows[i];
5083
+ if (r.role === "user") askOpen = true;
5084
+ else if (r.role === "ask" && r.ask !== void 0 && askOpen && !r.ask.answered) rows[i] = {
5085
+ ...r,
5086
+ ask: {
5087
+ ...r.ask,
5088
+ answered: true
5089
+ }
5090
+ };
5091
+ }
4911
5092
  const stream = latest !== void 0 ? streams.get(latest) : void 0;
4912
5093
  if (stream !== void 0 && !stream.settled) {
4913
5094
  const text = stream.text.trim();
@@ -4916,9 +5097,70 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4916
5097
  role: "streaming",
4917
5098
  text
4918
5099
  });
5100
+ const reasoning = stream.reasoning.trim();
5101
+ if (reasoning !== "") rows.splice(stream.anchor, 0, {
5102
+ key: "streaming-reasoning",
5103
+ role: "reasoning",
5104
+ text: reasoning,
5105
+ running: true
5106
+ });
4919
5107
  }
4920
5108
  return rows;
4921
5109
  }
5110
+ /**
5111
+ * Parse an ask_user_question call's arguments into the answerable row payload
5112
+ * (0.23.0). The live journal carries a JSON STRING (verified against real
5113
+ * journals 2026-09-14: {questions:[{header,id,multi_select,options:[{label,
5114
+ * description}]}]}); tolerate an already-parsed object arm for robustness.
5115
+ * Unparseable shapes degrade to a header-only row (the composer still answers
5116
+ * in free text). Pure.
5117
+ */
5118
+ function parseAskArguments(raw, callId) {
5119
+ const empty = {
5120
+ callId,
5121
+ summary: "",
5122
+ questions: [],
5123
+ answered: false
5124
+ };
5125
+ if (raw === void 0 || raw === "") return empty;
5126
+ let parsed;
5127
+ try {
5128
+ parsed = JSON.parse(raw);
5129
+ } catch {
5130
+ return empty;
5131
+ }
5132
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.questions)) return empty;
5133
+ const questions = [];
5134
+ for (const q of parsed.questions) {
5135
+ if (typeof q !== "object" || q === null) continue;
5136
+ const rec = q;
5137
+ const id = typeof rec.id === "string" ? rec.id : String(questions.length);
5138
+ const header = typeof rec.header === "string" && rec.header !== "" ? rec.header : typeof rec.question === "string" ? rec.question : "";
5139
+ const multiSelect = rec.multi_select === true;
5140
+ const options = [];
5141
+ if (Array.isArray(rec.options)) for (const o of rec.options) {
5142
+ if (typeof o !== "object" || o === null) continue;
5143
+ const orec = o;
5144
+ if (typeof orec.label === "string" && orec.label !== "") options.push({
5145
+ label: orec.label,
5146
+ description: typeof orec.description === "string" ? orec.description : ""
5147
+ });
5148
+ }
5149
+ questions.push({
5150
+ id,
5151
+ header,
5152
+ multiSelect,
5153
+ options
5154
+ });
5155
+ }
5156
+ if (questions.length === 0) return empty;
5157
+ return {
5158
+ callId,
5159
+ summary: questions.map((q) => q.header).filter((h) => h !== "").join(" / "),
5160
+ questions,
5161
+ answered: false
5162
+ };
5163
+ }
4922
5164
  //#endregion
4923
5165
  //#region src/client/error-boundary.tsx
4924
5166
  /**
@@ -7384,14 +7626,44 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7384
7626
  key: row.key,
7385
7627
  className: "lks14-msg lks14-msg-user"
7386
7628
  }, row.text);
7387
- if (row.role === "reasoning") return (0, react.createElement)("details", {
7388
- key: row.key,
7389
- className: "lks14-reasoning"
7390
- }, (0, react.createElement)("summary", null, tr("chat.reasoning", { n: String(row.text.length) })), row.text);
7629
+ if (row.role === "reasoning") {
7630
+ const running = row.running === true;
7631
+ return (0, react.createElement)("details", {
7632
+ key: row.key,
7633
+ className: "lks14-reasoning",
7634
+ "data-state": running ? "running" : "ok",
7635
+ "data-testid": "reasoning-row"
7636
+ }, (0, react.createElement)("summary", { className: "lks14-reasoning-row" }, (0, react.createElement)(IconThinkOutline16, { size: 14 }), (0, react.createElement)("span", { className: "lks14-reasoning-title" }, tr("chat.think")), (0, react.createElement)("span", {
7637
+ className: "lks14-reasoning-sep",
7638
+ "aria-hidden": true
7639
+ }), (0, react.createElement)("span", {
7640
+ className: "lks14-reasoning-summary",
7641
+ "data-follow-end": running || void 0
7642
+ }, (0, react.createElement)("span", { className: "lks14-reasoning-sumtext" }, reasoningSummaryLine(row.text, running)))), (0, react.createElement)("div", { className: "lks14-reasoning-body" }, row.text));
7643
+ }
7391
7644
  if (row.role === "tool") return (0, react.createElement)("div", {
7392
7645
  key: row.key,
7393
7646
  className: "lks14-toolchip " + String(row.toolState ?? "loading")
7394
7647
  }, row.toolState === "done" ? "✓" : row.toolState === "error" ? "×" : (0, react.createElement)("i", null), (0, react.createElement)("span", null, row.text));
7648
+ if (row.role === "ask" && row.ask !== void 0) {
7649
+ const answered = row.ask.answered;
7650
+ return (0, react.createElement)("div", {
7651
+ key: row.key,
7652
+ className: `lks14-askrow${answered ? " answered" : ""}`,
7653
+ "data-testid": "ask-row"
7654
+ }, (0, react.createElement)("div", { className: "lks14-askhead" }, tr("ask.title")), ...row.ask.questions.map((q) => (0, react.createElement)("div", {
7655
+ key: q.id,
7656
+ className: "lks14-askq"
7657
+ }, q.header !== "" ? (0, react.createElement)("div", { className: "lks14-askq-h" }, q.header) : null, ...q.options.map((opt) => (0, react.createElement)("button", {
7658
+ key: opt.label,
7659
+ className: "lks14-askopt",
7660
+ disabled: answered || interactive?.answerAsk === void 0,
7661
+ "data-testid": "ask-option",
7662
+ onClick: () => {
7663
+ interactive?.answerAsk?.(opt.label);
7664
+ }
7665
+ }, (0, react.createElement)("span", { className: "lks14-askopt-label" }, opt.label), opt.description !== "" ? (0, react.createElement)("span", { className: "lks14-askopt-desc" }, opt.description) : null)), q.options.length === 0 ? (0, react.createElement)("div", { className: "lks14-askfree" }, tr("ask.free")) : null)), answered ? (0, react.createElement)("div", { className: "lks14-askdone" }, tr("ask.answered")) : null);
7666
+ }
7395
7667
  const cls = row.role === "streaming" ? "lks14-msg lks14-msg-assistant streaming" : "lks14-msg lks14-msg-assistant";
7396
7668
  const body = (0, react.createElement)(ErrorBoundary, { key: `b-${row.key}` }, (0, react.createElement)("div", {
7397
7669
  className: cls,
@@ -7444,7 +7716,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7444
7716
  };
7445
7717
  }
7446
7718
  function StudyPanelBody({ ctx }) {
7447
- const { data, activate, setMode, setFocus, searchLessons, deleteCourse, deleteNote, bindLessonSession, lessonThreadOp, uploadAttachment } = useStudy();
7719
+ const { data, activate, setMode, setFocus, searchLessons, deleteCourse, deleteNote, bindLessonSession, lessonThreadOp, setCourseScope, uploadAttachment, setInterfaceLang } = useStudy();
7720
+ (0, react.useEffect)(() => {
7721
+ const read = () => {
7722
+ const lang = document.documentElement.lang;
7723
+ if (/^[a-z]{2,3}(-[A-Za-z0-9]+)*$/.test(lang)) setInterfaceLang(lang).catch(() => {});
7724
+ };
7725
+ read();
7726
+ return ctx.locale?.subscribe(read);
7727
+ }, []);
7448
7728
  (0, react.useEffect)(() => {
7449
7729
  wireCodeBlockCopy();
7450
7730
  }, []);
@@ -7592,6 +7872,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7592
7872
  const examLessonActive = lesson !== null && lesson.kind === "exam" && examOpen(data?.courses.flatMap((c) => c.sections).find((s) => s.lessons.some((l) => l.id === lesson.lessonId))?.lessons ?? []);
7593
7873
  const localBound = (0, react.useRef)(null);
7594
7874
  const boundSessionOf = (lessonId) => lessonId !== null && localBound.current?.lessonId === lessonId ? localBound.current.sessionId : null;
7875
+ const threadKeyOf = (lessonId) => {
7876
+ if (lessonId === null || data === null) return null;
7877
+ for (const c of data.courses) if (c.sections.some((s) => s.lessons.some((l) => l.id === lessonId))) return threadOwnerKey(c.threadScope, c.courseId, lessonId);
7878
+ return null;
7879
+ };
7880
+ const threadGroupOf = (lessonId) => {
7881
+ const key = threadKeyOf(lessonId);
7882
+ return key !== null ? data?.lessonThreads[key] ?? null : null;
7883
+ };
7595
7884
  const streamEl = (0, react.useRef)(null);
7596
7885
  const touchStart = (0, react.useRef)(null);
7597
7886
  const activeFace = (0, react.useRef)(null);
@@ -7600,7 +7889,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7600
7889
  try {
7601
7890
  let face = activeFace.current;
7602
7891
  if (face === null) {
7603
- const sessionId = boundSessionOf(lesson !== null ? lesson.lessonId : null) ?? (lesson !== null ? data?.lessonThreads[lesson.lessonId]?.active ?? data?.lessonSessions[lesson.lessonId] ?? null : null);
7892
+ const sessionId = boundSessionOf(lesson !== null ? lesson.lessonId : null) ?? threadGroupOf(lesson !== null ? lesson.lessonId : null)?.active ?? (lesson !== null ? data?.lessonSessions[lesson.lessonId] ?? null : null);
7604
7893
  if (sessionId !== null && sessionKnown(ctx, sessionId)) {
7605
7894
  const actx = ctx.sessions.scope(sessionId);
7606
7895
  face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx) ?? null;
@@ -7636,7 +7925,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7636
7925
  } else celebrate(ev.kind);
7637
7926
  prevCelebrationRef.current = snapshot;
7638
7927
  }, [data]);
7639
- const boundId = boundSessionOf(lesson !== null ? lesson.lessonId : null) ?? (lesson !== null ? data?.lessonThreads[lesson.lessonId]?.active ?? data?.lessonSessions[lesson.lessonId] ?? null : null);
7928
+ const boundId = boundSessionOf(lesson !== null ? lesson.lessonId : null) ?? threadGroupOf(lesson !== null ? lesson.lessonId : null)?.active ?? (lesson !== null ? data?.lessonSessions[lesson.lessonId] ?? null : null);
7640
7929
  const nudged = (0, react.useRef)(false);
7641
7930
  (0, react.useEffect)(() => {
7642
7931
  if (nudged.current || (data?.dueCount ?? 0) === 0) return;
@@ -7823,7 +8112,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7823
8112
  if (data?.active !== true) await activate(true);
7824
8113
  if (targetLessonId !== void 0 && targetLessonId !== lesson.lessonId) await setFocus(targetLessonId).catch(() => {});
7825
8114
  const bindId = targetLessonId ?? lesson.lessonId;
7826
- let sessionId = boundSessionOf(bindId) ?? data?.lessonThreads[bindId]?.active ?? data?.lessonSessions[bindId] ?? null;
8115
+ let sessionId = boundSessionOf(bindId) ?? threadGroupOf(bindId)?.active ?? data?.lessonSessions[bindId] ?? null;
7827
8116
  if (sessionId !== null && !sessionKnown(ctx, sessionId)) sessionId = null;
7828
8117
  if (sessionId === null) {
7829
8118
  const area = await fetch("/lookatstudy/api/study-workspace");
@@ -7836,10 +8125,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7836
8125
  sessionId
7837
8126
  };
7838
8127
  await bindLessonSession(bindId, sessionId, threadTitle ?? threadAutoTitle(text));
7839
- } else localBound.current = {
7840
- lessonId: bindId,
7841
- sessionId
7842
- };
8128
+ } else {
8129
+ localBound.current = {
8130
+ lessonId: bindId,
8131
+ sessionId
8132
+ };
8133
+ await bindLessonSession(bindId, sessionId);
8134
+ }
7843
8135
  const shell = PANEL_SHELL.current;
7844
8136
  if (shell !== null) shell.suppressHandBack(() => {
7845
8137
  ctx.sessions.open(sessionId);
@@ -8018,6 +8310,11 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8018
8310
  onThreadRename: renameLessonThreadUi,
8019
8311
  onThreadArchive: archiveLessonThreadUi,
8020
8312
  onThreadDelete: deleteLessonThreadUi,
8313
+ courseScope: data !== null && lesson !== null ? data.courses.find((c) => c.sections.some((sec) => sec.lessons.some((l) => l.id === lesson.lessonId)))?.threadScope ?? "lesson" : "lesson",
8314
+ onScopeToggle: (next) => {
8315
+ const cid = data !== null && lesson !== null ? data.courses.find((c) => c.sections.some((sec) => sec.lessons.some((l) => l.id === lesson.lessonId)))?.courseId : void 0;
8316
+ if (cid !== void 0) setCourseScope(cid, next);
8317
+ },
8021
8318
  contextMeter,
8022
8319
  contextBreakdown: breakdown,
8023
8320
  uploadAttachment
@@ -8390,11 +8687,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8390
8687
  };
8391
8688
  const [importJob, setImportJob] = (0, react.useState)(null);
8392
8689
  const [importResult, setImportResult] = (0, react.useState)(null);
8393
- const sawTurnRef = (0, react.useRef)(false);
8394
- if (importJob !== null && turnActive) sawTurnRef.current = true;
8395
8690
  const startImport = (label, prompt) => {
8396
8691
  setImportResult(null);
8397
- sawTurnRef.current = false;
8398
8692
  setImportJob({
8399
8693
  label,
8400
8694
  startedAt: Date.now(),
@@ -8415,7 +8709,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8415
8709
  setPanel("map");
8416
8710
  return;
8417
8711
  }
8418
- if (sawTurnRef.current && !turnActive) {
8712
+ if (importJobDead(importJob, turnActive, Date.now())) {
8419
8713
  setImportJob(null);
8420
8714
  setImportResult({
8421
8715
  ok: false,
@@ -8981,7 +9275,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8981
9275
  return cut <= 0 ? path : norm.slice(0, cut);
8982
9276
  }
8983
9277
  /** 中栏:the tutor chat stream with its own composer (upstream ChatStream + ChatComposer). */
8984
- function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, onThreadSwitch, onThreadNew, onThreadRename, onThreadArchive, onThreadDelete, contextMeter, contextBreakdown, uploadAttachment }) {
9278
+ function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, onThreadSwitch, onThreadNew, onThreadRename, onThreadArchive, onThreadDelete, courseScope, onScopeToggle, contextMeter, contextBreakdown, uploadAttachment }) {
8985
9279
  const stuck = (0, react.useRef)(true);
8986
9280
  const [showFab, setShowFab] = (0, react.useState)(false);
8987
9281
  const [threadsOpen, setThreadsOpen] = (0, react.useState)(false);
@@ -9207,7 +9501,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9207
9501
  revealed
9208
9502
  }));
9209
9503
  };
9210
- const threadPills = threadGroupPills(lesson !== null && data !== null ? data.lessonThreads[lesson.lessonId] : void 0);
9504
+ const threadKeyOfHere = (lessonId) => {
9505
+ if (lessonId === null || data === null) return null;
9506
+ for (const c of data.courses) if (c.sections.some((sec) => sec.lessons.some((l) => l.id === lessonId))) return threadOwnerKey(c.threadScope, c.courseId, lessonId);
9507
+ return null;
9508
+ };
9509
+ const keyHere = threadKeyOfHere(lesson !== null ? lesson.lessonId : null);
9510
+ const threadPills = threadGroupPills(keyHere !== null ? data?.lessonThreads[keyHere] : void 0);
9211
9511
  const lastAssistant = rowsView.reduce((acc, row, i) => row.role === "assistant" ? i : acc, -1);
9212
9512
  const [error, setError] = (0, react.useState)(null);
9213
9513
  const fire = (action) => {
@@ -9298,14 +9598,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9298
9598
  }));
9299
9599
  send(tr("proposal.decline.msg", { lesson: proposal.lessonTitle }));
9300
9600
  }
9301
- }, tr("proposal.decline"))) : decidedBadge !== null ? (0, react.createElement)("div", { className: `lks-propbanner decided ${decidedBadge.kind}` }, decidedBadge.kind === "accepted" ? (0, react.createElement)(IconCrownFill16, { size: 14 }) : (0, react.createElement)(IconBookFill16, { size: 14 }), (0, react.createElement)("span", { className: "lks-propbanner-why" }, tr(decidedBadge.kind === "accepted" ? "proposal.applied" : "proposal.rejected", { lesson: decidedBadge.title }))) : null, lesson !== null || !dormant ? (0, react.createElement)("div", { className: "lks14-lessonrow" }, lesson !== null ? (0, react.createElement)("span", null, lesson.title) : (0, react.createElement)("span", null, tr("col.tutor")), threadPills.length > 0 ? (0, react.createElement)("div", { className: "lks14-threadswitch" }, (0, react.createElement)("button", {
9601
+ }, tr("proposal.decline"))) : decidedBadge !== null ? (0, react.createElement)("div", { className: `lks-propbanner decided ${decidedBadge.kind}` }, decidedBadge.kind === "accepted" ? (0, react.createElement)(IconCrownFill16, { size: 14 }) : (0, react.createElement)(IconBookFill16, { size: 14 }), (0, react.createElement)("span", { className: "lks-propbanner-why" }, tr(decidedBadge.kind === "accepted" ? "proposal.applied" : "proposal.rejected", { lesson: decidedBadge.title }))) : null, lesson !== null || !dormant ? (0, react.createElement)("div", { className: "lks14-lessonrow" }, lesson !== null ? (0, react.createElement)("span", null, lesson.title) : (0, react.createElement)("span", null, tr("col.tutor")), threadPills.length > 0 || courseScope === "course" ? (0, react.createElement)("div", { className: "lks14-threadswitch" }, (0, react.createElement)("button", {
9302
9602
  className: "lks14-threadchip",
9303
9603
  "aria-expanded": String(threadsOpen),
9304
9604
  "data-tooltip": tr("threads.label"),
9305
9605
  onClick: () => {
9306
9606
  setThreadsOpen((v) => !v);
9307
9607
  }
9308
- }, tr("threads.open", { n: String(threadPills.length) }), (0, react.createElement)("span", { className: "lks14-threadchip-caret" }, threadsOpen ? "▾" : "▸")), threadsOpen ? (0, react.createElement)("div", {
9608
+ }, threadPills.length > 0 ? tr("threads.open", { n: String(threadPills.length) }) : tr("threads.scope"), (0, react.createElement)("span", { className: "lks14-threadchip-caret" }, threadsOpen ? "▾" : "▸")), threadsOpen ? (0, react.createElement)("div", {
9309
9609
  className: "lks14-threadmenu",
9310
9610
  role: "menu"
9311
9611
  }, ...threadPills.map((p) => (0, react.createElement)("div", {
@@ -9350,7 +9650,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9350
9650
  if (v !== "" && v !== p.title) onThreadRename(p.id, v);
9351
9651
  setRenaming(null);
9352
9652
  }
9353
- }) : (0, react.createElement)("span", { className: "lks14-threadmenu-title" }, p.title), p.current ? (0, react.createElement)("span", { className: "lks14-threadmenu-now" }, tr("threads.now")) : null, (0, react.createElement)("button", {
9653
+ }) : (0, react.createElement)("span", { className: "lks14-threadmenu-title" }, p.title, courseScope === "course" && threadCoverageLabel(p.touchedLessons, data?.courses ?? []) !== "" ? (0, react.createElement)("span", {
9654
+ className: "lks14-threadmenu-cover",
9655
+ "data-testid": "thread-cover"
9656
+ }, `${tr("threads.coverage")}: ${threadCoverageLabel(p.touchedLessons, data?.courses ?? [])}`) : null), p.current ? (0, react.createElement)("span", { className: "lks14-threadmenu-now" }, tr("threads.now")) : null, (0, react.createElement)("button", {
9354
9657
  className: "lks14-threadmenu-gear",
9355
9658
  "aria-label": tr("threads.actions"),
9356
9659
  "data-tooltip": tr("threads.actions"),
@@ -9393,6 +9696,18 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9393
9696
  setGearFor(null);
9394
9697
  }
9395
9698
  }, tr("threads.delete"))) : null)), (0, react.createElement)("button", {
9699
+ key: "__scope__",
9700
+ className: "lks14-threadmenu-row scope",
9701
+ role: "menuitemcheckbox",
9702
+ "aria-checked": String(courseScope === "course"),
9703
+ "data-testid": "thread-scope-toggle",
9704
+ onClick: () => {
9705
+ onScopeToggle(courseScope === "course" ? "lesson" : "course");
9706
+ }
9707
+ }, (0, react.createElement)("span", {
9708
+ className: "lks14-threadmenu-scope-icon",
9709
+ "aria-hidden": "true"
9710
+ }, courseScope === "course" ? "🔗" : "⛓"), (0, react.createElement)("span", { className: "lks14-threadmenu-title" }, tr("threads.scope")), (0, react.createElement)("span", { className: `lks14-threadmenu-scope-state${courseScope === "course" ? " on" : ""}` }, courseScope === "course" ? tr("threads.scope.on") : tr("threads.scope.off"))), (0, react.createElement)("button", {
9396
9711
  key: "__new__",
9397
9712
  className: "lks14-threadmenu-row new",
9398
9713
  role: "menuitem",
@@ -9427,7 +9742,20 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9427
9742
  onClick: () => {
9428
9743
  send(starters[0]?.message ?? tr("prompt.start"));
9429
9744
  }
9430
- }, tr("chat.start")) : null) : rowsView.map((row, i) => row.role === "artifact" ? inlineArtifactCard(row, rowsView.slice(i + 1).some((r) => r.role === "assistant")) : chatRow(row, !dormant && i === lastAssistant ? { send } : void 0, row.role === "assistant" ? {
9745
+ }, tr("chat.start")) : null) : rowsView.map((row, i) => row.role === "artifact" ? inlineArtifactCard(row, rowsView.slice(i + 1).some((r) => r.role === "assistant")) : chatRow(row, !dormant && (i === lastAssistant || row.role === "ask" && row.ask !== void 0 && !row.ask.answered) ? {
9746
+ send,
9747
+ answerAsk: (label) => {
9748
+ (async () => {
9749
+ if (busy) {
9750
+ stop();
9751
+ await new Promise((r) => {
9752
+ setTimeout(r, 1800);
9753
+ });
9754
+ }
9755
+ send(label);
9756
+ })();
9757
+ }
9758
+ } : void 0, row.role === "assistant" ? {
9431
9759
  playing: msgAudio?.key === row.key,
9432
9760
  index: msgAudio?.index ?? 0,
9433
9761
  total: msgAudio?.total ?? 0,
@@ -9618,6 +9946,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9618
9946
  const [read, setRead] = (0, react.useState)(null);
9619
9947
  const readCtl = (0, react.useRef)(null);
9620
9948
  const [readError, setReadError] = (0, react.useState)(null);
9949
+ const [langView, setLangView] = (0, react.useState)("bilingual");
9950
+ (0, react.useEffect)(() => {
9951
+ if (lesson === null) return;
9952
+ let next = "bilingual";
9953
+ try {
9954
+ const stored = localStorage.getItem(`lks-langview:${lesson.lessonId}`);
9955
+ if (stored === "original" || stored === "bilingual" || stored === "translation") next = stored;
9956
+ } catch {}
9957
+ setLangView(next);
9958
+ }, [lesson?.lessonId]);
9959
+ const teachHtml = lesson === null ? "" : langView === "translation" && lesson.translationHtml !== void 0 ? lesson.translationHtml : langView === "bilingual" && lesson.bilingualHtml !== void 0 ? lesson.bilingualHtml : lesson.html;
9621
9960
  const { tts, addUserNote, recordReview, editNote, pinNote } = useStudy();
9622
9961
  const proseRef = (0, react.useRef)(null);
9623
9962
  const diagRef = (0, react.useRef)(null);
@@ -9819,7 +10158,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9819
10158
  if (proseRef.current !== null) applyHighlights(proseRef.current, recordQuotes);
9820
10159
  setEnhanceTick(Date.now());
9821
10160
  });
9822
- }, [tab, lesson?.html]);
10161
+ }, [tab, teachHtml]);
9823
10162
  (0, react.useEffect)(() => {
9824
10163
  if (pendingLocate === null || tab !== "teach" || proseRef.current === null) return;
9825
10164
  locateNoteQuote(pendingLocate, proseRef.current);
@@ -9882,7 +10221,24 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9882
10221
  setRateBusy(false);
9883
10222
  });
9884
10223
  }
9885
- }, tr(key))))) : null, (0, react.createElement)("div", { className: "lks-readbar" }, (0, react.createElement)("button", {
10224
+ }, tr(key))))) : null, (0, react.createElement)("div", { className: "lks-readbar" }, lesson.bilingualHtml !== void 0 ? (0, react.createElement)("div", {
10225
+ className: "lks-langview",
10226
+ "data-testid": "langview"
10227
+ }, [
10228
+ "original",
10229
+ "bilingual",
10230
+ "translation"
10231
+ ].map((v) => (0, react.createElement)("button", {
10232
+ key: v,
10233
+ className: langView === v ? "on" : "",
10234
+ "data-testid": `langview-${v}`,
10235
+ onClick: () => {
10236
+ setLangView(v);
10237
+ try {
10238
+ localStorage.setItem(`lks-langview:${lesson.lessonId}`, v);
10239
+ } catch {}
10240
+ }
10241
+ }, tr(`langview.${v}`)))) : null, (0, react.createElement)("button", {
9886
10242
  className: "lks-btn ghost",
9887
10243
  style: {
9888
10244
  padding: "3px 8px",
@@ -9916,7 +10272,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9916
10272
  }, (0, react.createElement)("div", {
9917
10273
  className: "lks14-prose",
9918
10274
  ref: proseRef,
9919
- dangerouslySetInnerHTML: { __html: lesson.html }
10275
+ dangerouslySetInnerHTML: { __html: teachHtml }
9920
10276
  })), quoteBtn !== null ? (0, react.createElement)("div", {
9921
10277
  className: "lks-quote-btn",
9922
10278
  "data-lks-quote-btn": "",