dsh-plugin-lookatstudy 0.22.1 → 0.23.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
@@ -391,6 +391,14 @@ window.__ModuleLoader__.load({
391
391
  .lks-ui .lks14-emptycard-hint{font-size:.825rem;color:var(--ink-muted);line-height:1.6}
392
392
  /* B5: notebook tab capsule + 960px reading column */
393
393
  .lks-ui .lks14-notebody{margin:0 auto;max-width:960px;width:100%;padding:12px 20px 24px;box-sizing:border-box}
394
+ /* #10-followup: the reading column is height:auto (the note col scrolls), so
395
+ .lks14-board's height:100% resolved against a content-sized parent and the
396
+ stage collapsed to a ~90px sliver — the never-upscale contain fit then
397
+ scaled tall artifacts to tens of px. On the board tab the body fills the
398
+ col instead (a flex column); the notes/cmap tabs keep the scrolling
399
+ reading column. */
400
+ .lks-ui .lks14-notebody.lks14-notebody-fill{display:flex;flex-direction:column;flex:1 1 0;min-height:0;max-width:none;padding-bottom:8px}
401
+ .lks-ui .lks14-notebody-fill .lks14-board{flex:1 1 0}
394
402
  .lks-ui .lks14-viewtabs{background:var(--surface-2);border-radius:10px;padding:3px;align-self:flex-start}
395
403
  .lks-ui .lks14-readbar{position:sticky;top:0;z-index:20;background:var(--surface-2);border-radius:10px}
396
404
 
@@ -484,8 +492,12 @@ window.__ModuleLoader__.load({
484
492
  .lks-ui .lks14-board-artifact{padding:20px;width:fit-content;max-width:1200px}
485
493
  /* issue #10: the board stage handles oversize via zoom — the table lays out
486
494
  normally inside a bounded card (display:block + overflow was made for the
487
- narrow chat column; on the canvas it broke cells into overlap) */
488
- .lks-ui .lks14-board-artifact .lks-acard-table{display:table;width:100%;max-width:100%;overflow:visible}
495
+ narrow chat column; on the canvas it broke cells into overlap).
496
+ #10-followup: width:100% made the table's width depend on the fit-content
497
+ wrapper WHILE the wrapper's width depended on the table — a circular
498
+ dependency that collapsed to near-zero in the wild; width:auto lets both
499
+ resolve from content (the wrapper hugs the table's max-content). */
500
+ .lks-ui .lks14-board-artifact .lks-acard-table{display:table;width:auto;max-width:100%;overflow:visible}
489
501
  .lks-ui .lks14-board-artifact .lks-acard{min-width:560px;background:var(--surface-0);border:1px solid var(--border-faint);border-radius:14px}
490
502
  .lks-ui .lks14-cmapwrap{position:relative}
491
503
  .lks-ui .lks14-cmap-expand{position:absolute;top:10px;right:10px;z-index:5}
@@ -775,7 +787,31 @@ window.__ModuleLoader__.load({
775
787
  .lks-ui .lks14-threadmenu-row.on{background:rgb(var(--brand-rgb)/0.1)}
776
788
  .lks-ui .lks14-threadmenu-row.on .lks14-threadmenu-title{color:var(--brand);font-weight:700}
777
789
  .lks-ui .lks14-threadmenu-dot{flex:none;width:6px;height:6px;border-radius:999px;background:var(--ink-faint)}
778
- .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}
790
+ /* 0.23.0 course scope: the coverage line under a course thread's title, and
791
+ the granularity toggle row pinned above the +新建 row */
792
+ .lks-ui .lks14-threadmenu-title{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
793
+ .lks-ui .lks14-threadmenu-cover{font-size:10.5px;color:var(--ink-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
794
+ .lks-ui .lks14-threadmenu-row.scope{margin-top:3px;border-top:1px solid var(--border-faint);border-radius:0;padding-top:7px}
795
+ .lks-ui .lks14-threadmenu-scope-icon{flex:none;font-size:12px;line-height:1}
796
+ .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)}
797
+ .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)}
798
+ .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}
799
+ /* 0.23.0: the ask_user_question answer row — the panel's own answer UI for
800
+ the host's pending clarifying question (the hidden host column used to be
801
+ the only place its options lived) */
802
+ .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)}
803
+ .lks-ui .lks14-askrow.answered{opacity:.62;border-color:var(--border-faint);background:none}
804
+ .lks-ui .lks14-askhead{font-size:12px;font-weight:800;color:var(--brand)}
805
+ .lks-ui .lks14-askrow.answered .lks14-askhead{color:var(--ink-muted)}
806
+ .lks-ui .lks14-askq{display:flex;flex-direction:column;gap:6px}
807
+ .lks-ui .lks14-askq-h{font-size:13px;font-weight:700;color:var(--ink)}
808
+ .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}
809
+ .lks-ui .lks14-askopt:hover:not(:disabled){border-color:rgb(var(--brand-rgb)/0.55);background:rgb(var(--brand-rgb)/0.07)}
810
+ .lks-ui .lks14-askopt:disabled{cursor:default;opacity:.75}
811
+ .lks-ui .lks14-askopt-label{font-size:12.5px;font-weight:600;color:var(--ink)}
812
+ .lks-ui .lks14-askopt-desc{font-size:11px;color:var(--ink-muted);line-height:1.5}
813
+ .lks-ui .lks14-askfree{font-size:12px;color:var(--ink-muted)}
814
+ .lks-ui .lks14-askdone{font-size:11px;color:var(--ink-faint)}
779
815
  .lks-ui .lks14-threadmenu-row{position:relative}
780
816
  .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}
781
817
  .lks-ui .lks14-threadmenu-row:hover .lks14-threadmenu-gear,.lks-ui .lks14-threadmenu-gear:focus-visible{opacity:1}
@@ -1689,8 +1725,16 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1689
1725
  */
1690
1726
  /** Poll cadence for the shared store; one cycle serves every mounted seat. */
1691
1727
  const POLL_MS = 3e3;
1728
+ /** Sentinel for 304 responses (the state poll's ETag path): keep the snapshot. */
1729
+ const NOT_MODIFIED = Symbol("not-modified");
1692
1730
  async function fetchJson(url, init) {
1693
- const res = await fetch(url, init);
1731
+ const headers = new Headers(init?.headers);
1732
+ headers.set("x-lks-request", "1");
1733
+ const res = await fetch(url, {
1734
+ ...init,
1735
+ headers
1736
+ });
1737
+ if (res.status === 304) return NOT_MODIFIED;
1694
1738
  const body = await res.json().catch(() => null);
1695
1739
  if (!res.ok) {
1696
1740
  const error = body !== null && typeof body === "object" && "error" in body && typeof body.error === "string" ? body.error : `HTTP ${res.status}`;
@@ -1728,8 +1772,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1728
1772
  this.inflight = true;
1729
1773
  try {
1730
1774
  const next = await fetchJson("/lookatstudy/api/state");
1731
- this.snapshot = next;
1732
- this.emit();
1775
+ if (next !== NOT_MODIFIED) {
1776
+ this.snapshot = next;
1777
+ this.emit();
1778
+ }
1733
1779
  } catch {} finally {
1734
1780
  this.inflight = false;
1735
1781
  }
@@ -1828,6 +1874,19 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1828
1874
  this.refresh();
1829
1875
  return res;
1830
1876
  }
1877
+ /** Flip a course's thread granularity (0.23.0, issue #11 follow-up). Never
1878
+ * touches sediment — both sides' groups stay and are resumed on flip. */
1879
+ async setCourseScope(courseId, scope) {
1880
+ await fetchJson("/lookatstudy/api/course-scope", {
1881
+ method: "POST",
1882
+ headers: { "content-type": "application/json" },
1883
+ body: JSON.stringify({
1884
+ courseId,
1885
+ scope
1886
+ })
1887
+ });
1888
+ this.refresh();
1889
+ }
1831
1890
  /** Delete one course (and its proposals) from the host state. */
1832
1891
  async deleteCourse(courseId) {
1833
1892
  await fetchJson("/lookatstudy/api/course/delete", {
@@ -1968,7 +2027,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1968
2027
  const bin = atob(raw);
1969
2028
  const bytes = new Uint8Array(bin.length);
1970
2029
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1971
- return bytes.buffer;
2030
+ return bytes;
1972
2031
  }
1973
2032
  };
1974
2033
  /** The voices the dashboard route accepts (same allowlist server-side). */
@@ -2028,6 +2087,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2028
2087
  uploadAttachment: studyStore.uploadAttachment.bind(studyStore),
2029
2088
  setFocus: studyStore.setFocus.bind(studyStore),
2030
2089
  searchLessons: studyStore.searchLessons.bind(studyStore),
2090
+ tts: studyStore.tts.bind(studyStore),
2091
+ setCourseScope: studyStore.setCourseScope.bind(studyStore),
2031
2092
  deleteCourse: studyStore.deleteCourse.bind(studyStore),
2032
2093
  deleteNote: studyStore.deleteNote.bind(studyStore),
2033
2094
  editNote: studyStore.editNote.bind(studyStore),
@@ -2086,7 +2147,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2086
2147
  "quiz.right": "答对了",
2087
2148
  "quiz.wrong": "答错了",
2088
2149
  "quiz.youChose": "你选了",
2089
- "quiz.answer": "正确答案",
2090
2150
  "quiz.hook": "练习卡完成:{correct}/{total} 正确。请简短点评,针对错题讲讲思路。",
2091
2151
  "quiz.action.explain-wrong": "讲讲错题",
2092
2152
  "quiz.action.retry": "再来一组",
@@ -2122,8 +2182,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2122
2182
  "review.nudge.go": "去复习",
2123
2183
  "rail.search.jump": "打开这一课",
2124
2184
  "proposal.banner": "《{lesson}》可以提前毕业了",
2125
- "proposal.accept": "接受",
2126
- "proposal.decline": "再练练",
2127
2185
  "proposal.accept.msg": "我接受《{lesson}》的掌握度提议,帮我标记为掌握。",
2128
2186
  "proposal.decline.msg": "《{lesson}》我先再练练,暂不接受提前毕业。",
2129
2187
  "tutor.thinking": "导师思考中…",
@@ -2235,6 +2293,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2235
2293
  "review.banner": "正在复习:{lesson}",
2236
2294
  "review.threadTitle": "复习:{lesson}",
2237
2295
  "threads.label": "课时线程",
2296
+ "threads.scope": "跨课时连续对话",
2297
+ "threads.scope.on": "已开启",
2298
+ "threads.scope.off": "未开启",
2299
+ "threads.coverage": "覆盖",
2300
+ "ask.title": "导师想先确认一下",
2301
+ "ask.free": "在下方输入框直接回复即可",
2302
+ "ask.answered": "已回答",
2238
2303
  "meter.label": "上下文用量",
2239
2304
  "meter.aria": "上下文已用 {pct}%",
2240
2305
  "meter.brk.system": "系统提示",
@@ -2404,7 +2469,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2404
2469
  "exam.leave.message": "考试进行中——现在离开,未答的题将按错计分。",
2405
2470
  "exam.leave.confirm": "离开并结算",
2406
2471
  "exam.leave.cancel": "继续答题",
2407
- "prompt.exam.bank": "请为「{section}」的章节测验出题:先用 study_view 读一遍本节课时,再按各课时的知识点用 study_exam_bank_apply 一次性提交整份题库(单选 5-15 题,kcTitle 必须用本节的概念标题,每题附一句话解析)。题库就绪后告诉我可以开考。",
2472
+ "prompt.exam.bank": "请为「{section}」的章节测验出题:先用 study_lesson 读一遍本节课时,再按各课时的知识点用 study_exam_bank_apply 一次性提交整份题库(单选 5-15 题,kcTitle 必须用本节的概念标题,每题附一句话解析)。题库就绪后告诉我可以开考。",
2408
2473
  "prompt.review": "开始今天的复习,从最到期的课时开始",
2409
2474
  "prompt.proposal.accept": "接受提案 {id} —— 确认标记这课为已掌握",
2410
2475
  "prompt.proposal.decline": "拒绝提案 {id} —— 我想再练练"
@@ -2449,7 +2514,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2449
2514
  "quiz.right": "Correct",
2450
2515
  "quiz.wrong": "Wrong",
2451
2516
  "quiz.youChose": "You chose",
2452
- "quiz.answer": "Answer",
2453
2517
  "quiz.hook": "Practice card finished: {correct}/{total} correct. Please comment briefly and walk through the wrong ones.",
2454
2518
  "quiz.action.explain-wrong": "Explain my wrong answers",
2455
2519
  "quiz.action.retry": "Another set",
@@ -2485,8 +2549,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2485
2549
  "review.nudge.go": "Review now",
2486
2550
  "rail.search.jump": "Open this lesson",
2487
2551
  "proposal.banner": "{lesson} is ready to graduate early",
2488
- "proposal.accept": "Accept",
2489
- "proposal.decline": "Keep practicing",
2490
2552
  "proposal.accept.msg": "I accept the mastery proposal for \"{lesson}\" — mark it mastered.",
2491
2553
  "proposal.decline.msg": "I want more practice on \"{lesson}\" before graduating.",
2492
2554
  "tutor.thinking": "The tutor is thinking…",
@@ -2586,6 +2648,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2586
2648
  "palette.kind.action": "Action",
2587
2649
  "palette.empty": "No matches",
2588
2650
  "threads.label": "Lesson threads",
2651
+ "threads.scope": "Continuous course thread",
2652
+ "threads.scope.on": "on",
2653
+ "threads.scope.off": "off",
2654
+ "threads.coverage": "covers",
2655
+ "ask.title": "The tutor wants a quick confirm",
2656
+ "ask.free": "Answer in the composer below",
2657
+ "ask.answered": "answered",
2589
2658
  "threads.open": "{n} threads",
2590
2659
  "threads.now": "current",
2591
2660
  "threads.new": "New thread (your next message starts it)",
@@ -2767,7 +2836,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2767
2836
  "exam.leave.message": "The exam is in progress — leaving now counts unanswered questions as wrong.",
2768
2837
  "exam.leave.confirm": "Leave & settle",
2769
2838
  "exam.leave.cancel": "Keep answering",
2770
- "prompt.exam.bank": "Author the section exam bank for \"{section}\": read this section's lessons with study_view, then submit the full multiple-choice set in one study_exam_bank_apply call (5-15 single-choice questions, kcTitle must be one of the section's concept titles, one-line explanation each). Tell me when the bank is ready.",
2839
+ "prompt.exam.bank": "Author the section exam bank for \"{section}\": read this section's lessons with study_lesson, then submit the full multiple-choice set in one study_exam_bank_apply call (5-15 single-choice questions, kcTitle must be one of the section's concept titles, one-line explanation each). Tell me when the bank is ready.",
2771
2840
  "prompt.review": "Start today's review, from the most overdue lesson first",
2772
2841
  "prompt.proposal.accept": "Accept proposal {id} — confirm marking this lesson as mastered",
2773
2842
  "prompt.proposal.decline": "Reject proposal {id} — I want to practice more"
@@ -2981,6 +3050,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2981
3050
  return out.join("\n");
2982
3051
  }
2983
3052
  //#endregion
3053
+ //#region src/thread-key.ts
3054
+ /** Resolve the thread-group map key for a send from `lessonId`. Absent scope
3055
+ * is legacy lesson behavior — old files never move. */
3056
+ function threadOwnerKey(scope, courseId, lessonId) {
3057
+ return scope === "course" ? `course:${courseId}` : lessonId;
3058
+ }
3059
+ //#endregion
2984
3060
  //#region src/vendor/mermaid-elk-rewrite.ts
2985
3061
  /**
2986
3062
  * mermaid-elk-rewrite —— flowchart → flowchart-elk 前缀改写(v0.21 纯函数)。
@@ -3076,6 +3152,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3076
3152
  //#endregion
3077
3153
  //#region src/client/enhance.ts
3078
3154
  var enhance_exports = /* @__PURE__ */ __exportAll({
3155
+ KATEX_SRI: () => KATEX_SRI,
3156
+ MERMAID_SECURITY_LEVEL: () => MERMAID_SECURITY_LEVEL,
3079
3157
  attachCodeHeader: () => attachCodeHeader,
3080
3158
  enhanceCode: () => enhanceCode,
3081
3159
  enhanceDiagrams: () => enhanceDiagrams,
@@ -3127,10 +3205,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3127
3205
  mermaid: "https://esm.sh/mermaid@11.4.1",
3128
3206
  mermaidElk: "https://esm.sh/@mermaid-js/layout-elk@0.1.7"
3129
3207
  };
3130
- function loadScript(src) {
3208
+ function loadScript(src, integrity) {
3131
3209
  return new Promise((resolve, reject) => {
3132
3210
  const el = document.createElement("script");
3133
3211
  el.src = src;
3212
+ if (integrity !== void 0) {
3213
+ el.integrity = integrity;
3214
+ el.crossOrigin = "anonymous";
3215
+ }
3134
3216
  el.onload = () => {
3135
3217
  resolve();
3136
3218
  };
@@ -3140,6 +3222,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3140
3222
  document.head.append(el);
3141
3223
  });
3142
3224
  }
3225
+ /** Audit D32: SRI pin for the KaTeX CDN script (0.16.22, sha384 over the exact bytes). */
3226
+ const KATEX_SRI = "sha384-cMkvdD8LoxVzGF/RPUKAcvmm49FQ0oxwDF3BGKtDXcEc+T1b2N+teh/OJfpU0jr6";
3143
3227
  let katexPromise = null;
3144
3228
  function defaultLoadKatex() {
3145
3229
  katexPromise ??= (async () => {
@@ -3150,7 +3234,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3150
3234
  link.setAttribute("data-lks-katex", "1");
3151
3235
  document.head.append(link);
3152
3236
  }
3153
- if (window.katex === void 0) await loadScript(CDN.katexJs);
3237
+ if (window.katex === void 0) await loadScript(CDN.katexJs, KATEX_SRI);
3154
3238
  const katex = window.katex;
3155
3239
  if (katex === void 0) throw new Error("katex missing after load");
3156
3240
  return katex;
@@ -3239,6 +3323,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3239
3323
  noteTextColor: read("--ink-rgb", "rgb(245 245 250)")
3240
3324
  };
3241
3325
  }
3326
+ /**
3327
+ * Audit A1 (2026-09-14): diagram sources arrive from imported (untrusted)
3328
+ * content — mermaid must run under 'strict'. 'loose' skips DOMPurify label
3329
+ * sanitization and enables click callbacks, which turns a hostile repo
3330
+ * README into stored XSS in the host page. Exported so the gate is testable.
3331
+ */
3332
+ const MERMAID_SECURITY_LEVEL = "strict";
3242
3333
  function defaultLoadMermaid() {
3243
3334
  mermaidPromise ??= (async () => {
3244
3335
  const mermaid = (await import(
@@ -3255,7 +3346,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3255
3346
  mermaid.initialize({
3256
3347
  startOnLoad: false,
3257
3348
  theme: "base",
3258
- securityLevel: "loose",
3349
+ securityLevel: MERMAID_SECURITY_LEVEL,
3259
3350
  themeVariables: mermaidThemeVariables()
3260
3351
  });
3261
3352
  return mermaid;
@@ -3267,19 +3358,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3267
3358
  const display = /\$\$([^$]+)\$\$/.exec(text);
3268
3359
  const inline = /\$([^$\n]+)\$/.exec(text);
3269
3360
  if (display === null && inline === null) return null;
3270
- const m = display !== null && (inline === null || display.index <= inline.index) ? {
3271
- ...display,
3272
- display: true
3273
- } : inline === null ? null : {
3274
- ...inline,
3275
- display: false
3276
- };
3361
+ const useDisplay = display !== null && (inline === null || display.index <= inline.index);
3362
+ const m = useDisplay ? display : inline;
3277
3363
  if (m === null) return null;
3278
3364
  return {
3279
3365
  before: text.slice(0, m.index),
3280
3366
  tex: m[1],
3281
3367
  after: text.slice(m.index + m[0].length),
3282
- display: m.display
3368
+ display: useDisplay
3283
3369
  };
3284
3370
  }
3285
3371
  /** KaTeX every $..$ / $$..$$ in the container; degrades to plain text on any failure. */
@@ -3294,7 +3380,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3294
3380
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
3295
3381
  const targets = [];
3296
3382
  let node;
3297
- while ((node = walker.nextNode()) !== null) if (findMathSpan(node.data) !== null && node.parentElement?.tagName !== "CODE") targets.push(node);
3383
+ while ((node = walker.nextNode()) !== null) {
3384
+ const parent = node.parentElement;
3385
+ if (findMathSpan(node.data) !== null && parent?.tagName !== "CODE" && parent?.closest(".lks-shiki, .lks-mermaid") === null) targets.push(node);
3386
+ }
3298
3387
  for (const t of targets) {
3299
3388
  let guard = 0;
3300
3389
  while (guard++ < 200) {
@@ -3474,7 +3563,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3474
3563
  * no zoom; this is the one place our port must normalize).
3475
3564
  */
3476
3565
  function panelZoomOf(el) {
3477
- const z = el?.closest(".lks14")?.style.zoom;
3566
+ const z = (el?.closest(".lks14"))?.style.zoom;
3478
3567
  const n = z === void 0 || z === "" ? NaN : Number(z);
3479
3568
  return Number.isFinite(n) && n > 0 ? n : 1;
3480
3569
  }
@@ -3631,25 +3720,21 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3631
3720
  for (const line of text.split("\n")) {
3632
3721
  const plain = /^([A-D])[.、:)]\s*(.+)$/.exec(line.trim());
3633
3722
  const table = /^\|\s*\*{0,2}([A-D])\*{0,2}\s*\|\s*(.+?)\s*\|$/.exec(line.trim());
3634
- const match = plain ?? (table !== null ? [
3635
- table[0],
3636
- table[1],
3637
- table[2].replaceAll("**", "").replaceAll("`", "")
3638
- ] : null);
3723
+ const match = plain !== null ? [plain[1], plain[2]] : table !== null ? [table[1], table[2].replaceAll("**", "").replaceAll("`", "")] : null;
3639
3724
  if (match === null) {
3640
3725
  flush();
3641
3726
  continue;
3642
3727
  }
3643
3728
  const nextLetter = run.length === 0 ? "A" : String.fromCharCode(run[run.length - 1].letter.charCodeAt(0) + 1);
3644
- if (match[1] === nextLetter) run.push({
3645
- letter: match[1],
3646
- text: match[2]
3729
+ if (match[0] === nextLetter) run.push({
3730
+ letter: match[0],
3731
+ text: match[1]
3647
3732
  });
3648
3733
  else {
3649
3734
  flush();
3650
- if (match[1] === "A") run.push({
3651
- letter: match[1],
3652
- text: match[2]
3735
+ if (match[0] === "A") run.push({
3736
+ letter: match[0],
3737
+ text: match[1]
3653
3738
  });
3654
3739
  }
3655
3740
  }
@@ -3725,10 +3810,26 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3725
3810
  return group.threads.filter((t) => t.status !== "archived").slice().sort((a, b) => a.lastAt < b.lastAt ? 1 : -1).map((t) => ({
3726
3811
  id: t.id,
3727
3812
  title: t.title,
3728
- current: t.id === group.active
3813
+ current: t.id === group.active,
3814
+ touchedLessons: t.touchedLessons
3729
3815
  }));
3730
3816
  }
3731
3817
  /**
3818
+ * 0.23.0 course scope: the coverage label under a course-scoped thread — the
3819
+ * lessons it has been sent from, by section order, capped at three titles
3820
+ * with an 等 N 课时 tail. Empty/undefined coverage renders '' (lesson-scoped
3821
+ * threads show nothing). Pure.
3822
+ */
3823
+ function threadCoverageLabel(touched, courses) {
3824
+ if (touched === void 0 || touched.length === 0) return "";
3825
+ const titleOf = /* @__PURE__ */ new Map();
3826
+ for (const c of courses) for (const s of c.sections) for (const l of s.lessons) titleOf.set(l.id, l.title);
3827
+ const titles = touched.map((id) => titleOf.get(id)).filter((t) => t !== void 0);
3828
+ if (titles.length === 0) return "";
3829
+ const head = titles.slice(0, 3).join(" · ");
3830
+ return titles.length > 3 ? `${head} 等 ${String(titles.length)} 课时` : head;
3831
+ }
3832
+ /**
3732
3833
  * Default expansion for one rail section: collapsed when every study lesson is
3733
3834
  * done (mastered) or not yet reachable (locked). The focus lesson's section
3734
3835
  * stays open; exam nodes never force a section open. Pure.
@@ -3794,16 +3895,22 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3794
3895
  */
3795
3896
  function ctxSegments(pct, breakdown) {
3796
3897
  if (pct <= 0) return [];
3797
- const total = breakdown === null ? 0 : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens;
3898
+ if (breakdown === null) return [{
3899
+ key: "total",
3900
+ cls: "",
3901
+ width: pct
3902
+ }];
3903
+ const bd = breakdown;
3904
+ const total = bd.systemTokens + bd.toolsTokens + bd.messageTokens;
3798
3905
  if (total <= 0) return [{
3799
3906
  key: "total",
3800
3907
  cls: "",
3801
3908
  width: pct
3802
3909
  }];
3803
3910
  return [
3804
- ["sys", breakdown.systemTokens],
3805
- ["tools", breakdown.toolsTokens],
3806
- ["msgs", breakdown.messageTokens]
3911
+ ["sys", bd.systemTokens],
3912
+ ["tools", bd.toolsTokens],
3913
+ ["msgs", bd.messageTokens]
3807
3914
  ].map(([key, tokens]) => ({
3808
3915
  key,
3809
3916
  cls: key,
@@ -4740,8 +4847,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4740
4847
  const claimed = /* @__PURE__ */ new Set();
4741
4848
  const out = [];
4742
4849
  for (const row of rows) {
4743
- const type = row.role === "tool" && row.toolState === "done" && row.resultText !== void 0 ? ARTIFACT_TOOLS[row.text] : void 0;
4744
- if (type === void 0) {
4850
+ const type = row.role === "tool" && row.toolState === "done" && row.resultText !== void 0 && row.text !== void 0 ? ARTIFACT_TOOLS[row.text] : void 0;
4851
+ if (type === void 0 || row.resultText === void 0) {
4745
4852
  out.push(row);
4746
4853
  continue;
4747
4854
  }
@@ -4841,8 +4948,20 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4841
4948
  });
4842
4949
  } else if (event.type === "tool/call") {
4843
4950
  const name = event.data?.tool?.name ?? event.data?.name ?? "tool";
4951
+ const callId = event.data?.callId ?? event.data?.toolCallId;
4952
+ if (name === "ask_user_question") {
4953
+ const ask = parseAskArguments(event.data?.arguments, callId ?? String(event.seq));
4954
+ rows.push({
4955
+ key: `t${String(callId ?? event.seq)}`,
4956
+ role: "ask",
4957
+ text: ask.summary,
4958
+ toolState: "loading",
4959
+ ask
4960
+ });
4961
+ continue;
4962
+ }
4844
4963
  rows.push({
4845
- key: `t${event.data?.callId ?? event.data?.toolCallId ?? event.seq}`,
4964
+ key: `t${String(callId ?? event.seq)}`,
4846
4965
  role: "tool",
4847
4966
  text: name,
4848
4967
  toolState: "loading"
@@ -4850,13 +4969,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4850
4969
  } else if (event.type === "tool/result") {
4851
4970
  const rid = event.data?.message?.source?.callId ?? event.data?.callId ?? event.data?.toolCallId;
4852
4971
  if (rid !== void 0) {
4853
- const idx = rows.findIndex((r) => r.key === `t${rid}` && r.role === "tool");
4972
+ const idx = rows.findIndex((r) => r.key === `t${rid}` && (r.role === "tool" || r.role === "ask"));
4854
4973
  if (idx >= 0) {
4855
4974
  const failed = event.data?.message?.isError === true || event.data?.error !== void 0;
4856
4975
  rows[idx] = {
4857
4976
  ...rows[idx],
4858
4977
  toolState: failed ? "error" : "done",
4859
- resultText: failed ? void 0 : resultRenderedText(event)
4978
+ resultText: failed ? void 0 : resultRenderedText(event),
4979
+ ...rows[idx].role === "ask" && rows[idx].ask !== void 0 && !failed ? { ask: {
4980
+ ...rows[idx].ask,
4981
+ answered: true
4982
+ } } : {}
4860
4983
  };
4861
4984
  }
4862
4985
  }
@@ -4876,6 +4999,18 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4876
4999
  else stream.text += chunk.text ?? "";
4877
5000
  latest = attemptId;
4878
5001
  }
5002
+ let askOpen = false;
5003
+ for (let i = rows.length - 1; i >= 0; i--) {
5004
+ const r = rows[i];
5005
+ if (r.role === "user") askOpen = true;
5006
+ else if (r.role === "ask" && r.ask !== void 0 && askOpen && !r.ask.answered) rows[i] = {
5007
+ ...r,
5008
+ ask: {
5009
+ ...r.ask,
5010
+ answered: true
5011
+ }
5012
+ };
5013
+ }
4879
5014
  const stream = latest !== void 0 ? streams.get(latest) : void 0;
4880
5015
  if (stream !== void 0 && !stream.settled) {
4881
5016
  const text = stream.text.trim();
@@ -4887,6 +5022,60 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4887
5022
  }
4888
5023
  return rows;
4889
5024
  }
5025
+ /**
5026
+ * Parse an ask_user_question call's arguments into the answerable row payload
5027
+ * (0.23.0). The live journal carries a JSON STRING (verified against real
5028
+ * journals 2026-09-14: {questions:[{header,id,multi_select,options:[{label,
5029
+ * description}]}]}); tolerate an already-parsed object arm for robustness.
5030
+ * Unparseable shapes degrade to a header-only row (the composer still answers
5031
+ * in free text). Pure.
5032
+ */
5033
+ function parseAskArguments(raw, callId) {
5034
+ const empty = {
5035
+ callId,
5036
+ summary: "",
5037
+ questions: [],
5038
+ answered: false
5039
+ };
5040
+ if (raw === void 0 || raw === "") return empty;
5041
+ let parsed;
5042
+ try {
5043
+ parsed = JSON.parse(raw);
5044
+ } catch {
5045
+ return empty;
5046
+ }
5047
+ if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.questions)) return empty;
5048
+ const questions = [];
5049
+ for (const q of parsed.questions) {
5050
+ if (typeof q !== "object" || q === null) continue;
5051
+ const rec = q;
5052
+ const id = typeof rec.id === "string" ? rec.id : String(questions.length);
5053
+ const header = typeof rec.header === "string" && rec.header !== "" ? rec.header : typeof rec.question === "string" ? rec.question : "";
5054
+ const multiSelect = rec.multi_select === true;
5055
+ const options = [];
5056
+ if (Array.isArray(rec.options)) for (const o of rec.options) {
5057
+ if (typeof o !== "object" || o === null) continue;
5058
+ const orec = o;
5059
+ if (typeof orec.label === "string" && orec.label !== "") options.push({
5060
+ label: orec.label,
5061
+ description: typeof orec.description === "string" ? orec.description : ""
5062
+ });
5063
+ }
5064
+ questions.push({
5065
+ id,
5066
+ header,
5067
+ multiSelect,
5068
+ options
5069
+ });
5070
+ }
5071
+ if (questions.length === 0) return empty;
5072
+ return {
5073
+ callId,
5074
+ summary: questions.map((q) => q.header).filter((h) => h !== "").join(" / "),
5075
+ questions,
5076
+ answered: false
5077
+ };
5078
+ }
4890
5079
  //#endregion
4891
5080
  //#region src/client/error-boundary.tsx
4892
5081
  /**
@@ -5680,8 +5869,11 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5680
5869
  * does everything. Gestures: single-pointer drag pan, two-pointer pinch
5681
5870
  * anchored at the midpoint, wheel zoom anchored at the cursor, double
5682
5871
  * click/tap toggles fit↔100%. ResizeObserver refits on content/stage size
5683
- * changes (mermaid's async svg). A floating −/%/fit/+ toolbar stays mounted.
5684
- * Shared by the notebook's board tab and the diagram modals.
5872
+ * changes (mermaid's async svg) — through the #12 gates: value-equality on
5873
+ * setSize, a bounds-differ short-circuit on refit, rAF coalescing, and a
5874
+ * recurrence-tripping oscillation breaker (see makeRefitBreaker). A floating
5875
+ * −/%/fit/+ toolbar stays mounted. Shared by the notebook's board tab and
5876
+ * the diagram modals.
5685
5877
  * (lucide glyphs collapse to text/plugin icons; the client stays dependency-
5686
5878
  * free.)
5687
5879
  * @module dsh-plugin-lookatstudy/client/canvasstage
@@ -5690,6 +5882,43 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5690
5882
  const MAX_SCALE = 4;
5691
5883
  /** 工具条步进(手势/滚轮连续,按钮给干脆的一档)。 */
5692
5884
  const STEP = 1.25;
5885
+ /** True when the two bounds readings differ in ANY field — the auto-refit
5886
+ * short-circuit. The pre-fix refit ran fitTransform on every RO callback with
5887
+ * zero hysteresis, so any feedback touching a size re-fired it forever. */
5888
+ function stageBoundsDiffer(a, b) {
5889
+ if (a === null) return true;
5890
+ return a.contentW !== b.contentW || a.contentH !== b.contentH || a.viewW !== b.viewW || a.viewH !== b.viewH;
5891
+ }
5892
+ function makeRefitBreaker(opts = {}) {
5893
+ const windowMs = opts.windowMs ?? 2e3;
5894
+ const now = opts.now ?? (() => performance.now());
5895
+ let seen = [];
5896
+ let tripped = false;
5897
+ return {
5898
+ allow(key) {
5899
+ const t = now();
5900
+ seen = seen.filter((s) => t - s.t < windowMs);
5901
+ if (tripped) return false;
5902
+ if (seen.some((s) => s.key === key)) {
5903
+ tripped = true;
5904
+ return false;
5905
+ }
5906
+ seen.push({
5907
+ t,
5908
+ key
5909
+ });
5910
+ return true;
5911
+ },
5912
+ reset() {
5913
+ seen = [];
5914
+ tripped = false;
5915
+ },
5916
+ tripped() {
5917
+ return tripped;
5918
+ }
5919
+ };
5920
+ }
5921
+ const boundsKey = (b) => `${String(b.contentW)}x${String(b.contentH)}@${String(b.viewW)}x${String(b.viewH)}`;
5693
5922
  function CanvasStage({ children, grid = true, testid }) {
5694
5923
  const stageRef = (0, react.useRef)(null);
5695
5924
  const contentRef = (0, react.useRef)(null);
@@ -5711,6 +5940,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5711
5940
  moved: false
5712
5941
  });
5713
5942
  const lastTapRef = (0, react.useRef)(null);
5943
+ const lastBoundsRef = (0, react.useRef)(null);
5944
+ const breakerRef = (0, react.useRef)(null);
5945
+ if (breakerRef.current === null) breakerRef.current = makeRefitBreaker();
5714
5946
  const bounds = (0, react.useCallback)(() => {
5715
5947
  const stage = stageRef.current;
5716
5948
  const content = contentRef.current;
@@ -5723,32 +5955,62 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5723
5955
  viewH: stage?.clientHeight ?? 0
5724
5956
  };
5725
5957
  }, []);
5726
- const refit = (0, react.useCallback)(() => {
5727
- const b = bounds();
5728
- if (b.contentW === 0 || b.viewW === 0) return;
5958
+ const currentBounds = (0, react.useCallback)(() => ({
5959
+ contentW: contentRef.current?.offsetWidth ?? 0,
5960
+ contentH: contentRef.current?.offsetHeight ?? 0,
5961
+ viewW: stageRef.current?.clientWidth ?? 0,
5962
+ viewH: stageRef.current?.clientHeight ?? 0
5963
+ }), []);
5964
+ const applyFit = (0, react.useCallback)((b) => {
5729
5965
  setTf(fitTransform(b.contentW, b.contentH, b.viewW, b.viewH));
5730
5966
  atFitRef.current = true;
5731
- }, [bounds]);
5967
+ }, []);
5968
+ const refit = (0, react.useCallback)(() => {
5969
+ breakerRef.current?.reset();
5970
+ const b = currentBounds();
5971
+ if (b.contentW === 0 || b.viewW === 0) return;
5972
+ lastBoundsRef.current = b;
5973
+ applyFit(b);
5974
+ }, [currentBounds, applyFit]);
5975
+ const measureNow = (0, react.useCallback)(() => {
5976
+ const content = contentRef.current;
5977
+ if (content === null) return;
5978
+ const w = content.offsetWidth;
5979
+ const h = content.offsetHeight;
5980
+ setSize((prev) => prev !== null && prev.w === w && prev.h === h ? prev : {
5981
+ w,
5982
+ h
5983
+ });
5984
+ const b = currentBounds();
5985
+ if (!stageBoundsDiffer(lastBoundsRef.current, b)) return;
5986
+ lastBoundsRef.current = b;
5987
+ if (b.contentW === 0 || b.viewW === 0) return;
5988
+ if (b.contentW < 120) return;
5989
+ if ((breakerRef.current ?? makeRefitBreaker()).allow(boundsKey(b))) applyFit(b);
5990
+ }, [currentBounds, applyFit]);
5732
5991
  (0, react.useEffect)(() => {
5733
5992
  const stage = stageRef.current;
5734
5993
  const content = contentRef.current;
5735
5994
  if (stage === null || content === null) return;
5736
- const measure = () => {
5737
- setSize({
5738
- w: content.offsetWidth,
5739
- h: content.offsetHeight
5995
+ measureNow();
5996
+ let raf = 0;
5997
+ const onRo = () => {
5998
+ if (raf !== 0) return;
5999
+ raf = requestAnimationFrame(() => {
6000
+ raf = 0;
6001
+ measureNow();
5740
6002
  });
5741
- refit();
5742
6003
  };
5743
- measure();
5744
- const ro = new ResizeObserver(measure);
6004
+ const ro = new ResizeObserver(onRo);
5745
6005
  ro.observe(content);
5746
6006
  ro.observe(stage);
5747
6007
  return () => {
5748
6008
  ro.disconnect();
6009
+ if (raf !== 0) cancelAnimationFrame(raf);
5749
6010
  };
5750
- }, [refit]);
6011
+ }, [measureNow]);
5751
6012
  const zoomBy = (0, react.useCallback)((factor, ax, ay) => {
6013
+ breakerRef.current?.reset();
5752
6014
  const b = bounds();
5753
6015
  const cx = ax ?? b.viewW / 2;
5754
6016
  const cy = ay ?? b.viewH / 2;
@@ -5960,7 +6222,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5960
6222
  let seen;
5961
6223
  try {
5962
6224
  const raw = storage.getItem(seenArtifactsKey(lessonId));
5963
- seen = new Set(Array.isArray(JSON.parse(raw ?? "[]")) ? JSON.parse(raw ?? "[]") : []);
6225
+ const parsed = JSON.parse(raw ?? "[]");
6226
+ seen = new Set(Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : []);
5964
6227
  } catch {
5965
6228
  seen = /* @__PURE__ */ new Set();
5966
6229
  }
@@ -6071,12 +6334,32 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6071
6334
  to: a.lineEnd
6072
6335
  })}`), a.note))));
6073
6336
  }
6337
+ /** The 放大查看 modal's mermaid host — rendered ONCE per mermaid source. The
6338
+ * raw-ref-callback version re-ran renderMermaidInto on every parent render
6339
+ * (each 3s state poll), and its el.textContent='' clear + async re-render
6340
+ * re-mounted the svg twice per tick — the #12 flicker. A stable effect deps
6341
+ * chain (string mermaid + useCallback'd onFail) breaks the loop at the
6342
+ * source; the CanvasStage gates are the second line of defense. */
6343
+ function DiagramModalDiagram({ mermaid, onFail }) {
6344
+ const bodyRef = (0, react.useRef)(null);
6345
+ (0, react.useEffect)(() => {
6346
+ if (bodyRef.current === null) return;
6347
+ renderMermaidInto(bodyRef.current, mermaid, onFail);
6348
+ }, [mermaid, onFail]);
6349
+ return (0, react.createElement)("div", {
6350
+ className: "lks14-modal-diagram",
6351
+ ref: bodyRef
6352
+ });
6353
+ }
6074
6354
  /** Mermaid diagram card with an expand modal (upstream DiagramViewerModal). */
6075
6355
  function DiagramCard({ artifact }) {
6076
6356
  const mermaid = typeof artifact.data.mermaid === "string" ? artifact.data.mermaid : "";
6077
6357
  const bodyRef = (0, react.useRef)(null);
6078
6358
  const [failed, setFailed] = (0, react.useState)(false);
6079
6359
  const [expanded, setExpanded] = (0, react.useState)(false);
6360
+ const onFail = (0, react.useCallback)(() => {
6361
+ setFailed(true);
6362
+ }, []);
6080
6363
  (0, react.useEffect)(() => {
6081
6364
  if (!expanded) return;
6082
6365
  const onKey = (e) => {
@@ -6089,10 +6372,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6089
6372
  }, [expanded]);
6090
6373
  (0, react.useEffect)(() => {
6091
6374
  if (bodyRef.current === null) return;
6092
- renderMermaidInto(bodyRef.current, mermaid, () => {
6093
- setFailed(true);
6094
- });
6095
- }, [mermaid]);
6375
+ renderMermaidInto(bodyRef.current, mermaid, onFail);
6376
+ }, [mermaid, onFail]);
6096
6377
  const body = failed ? (0, react.createElement)("pre", { className: "lks-acard-code" }, mermaid) : (0, react.createElement)("div", {
6097
6378
  className: "lks-acard-diagram",
6098
6379
  ref: bodyRef
@@ -6121,13 +6402,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6121
6402
  onClick: () => {
6122
6403
  setExpanded(false);
6123
6404
  }
6124
- }, (0, react.createElement)(IconCloseFill16, { size: 13 }))), failed ? (0, react.createElement)("pre", { className: "lks-acard-code" }, mermaid) : (0, react.createElement)(CanvasStage, { testid: "diagram-modal-stage" }, (0, react.createElement)("div", {
6125
- className: "lks14-modal-diagram",
6126
- ref: (el) => {
6127
- if (el !== null) renderMermaidInto(el, mermaid, () => {
6128
- setFailed(true);
6129
- });
6130
- }
6405
+ }, (0, react.createElement)(IconCloseFill16, { size: 13 }))), failed ? (0, react.createElement)("pre", { className: "lks-acard-code" }, mermaid) : (0, react.createElement)(CanvasStage, { testid: "diagram-modal-stage" }, (0, react.createElement)(DiagramModalDiagram, {
6406
+ mermaid,
6407
+ onFail
6131
6408
  })))) : null);
6132
6409
  }
6133
6410
  /** The opening guess: two big options, click = pick (sent to the tutor, who
@@ -7272,6 +7549,25 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7272
7549
  key: row.key,
7273
7550
  className: "lks14-toolchip " + String(row.toolState ?? "loading")
7274
7551
  }, row.toolState === "done" ? "✓" : row.toolState === "error" ? "×" : (0, react.createElement)("i", null), (0, react.createElement)("span", null, row.text));
7552
+ if (row.role === "ask" && row.ask !== void 0) {
7553
+ const answered = row.ask.answered;
7554
+ return (0, react.createElement)("div", {
7555
+ key: row.key,
7556
+ className: `lks14-askrow${answered ? " answered" : ""}`,
7557
+ "data-testid": "ask-row"
7558
+ }, (0, react.createElement)("div", { className: "lks14-askhead" }, tr("ask.title")), ...row.ask.questions.map((q) => (0, react.createElement)("div", {
7559
+ key: q.id,
7560
+ className: "lks14-askq"
7561
+ }, q.header !== "" ? (0, react.createElement)("div", { className: "lks14-askq-h" }, q.header) : null, ...q.options.map((opt) => (0, react.createElement)("button", {
7562
+ key: opt.label,
7563
+ className: "lks14-askopt",
7564
+ disabled: answered || interactive?.answerAsk === void 0,
7565
+ "data-testid": "ask-option",
7566
+ onClick: () => {
7567
+ interactive?.answerAsk?.(opt.label);
7568
+ }
7569
+ }, (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);
7570
+ }
7275
7571
  const cls = row.role === "streaming" ? "lks14-msg lks14-msg-assistant streaming" : "lks14-msg lks14-msg-assistant";
7276
7572
  const body = (0, react.createElement)(ErrorBoundary, { key: `b-${row.key}` }, (0, react.createElement)("div", {
7277
7573
  className: cls,
@@ -7307,7 +7603,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7307
7603
  key: opt.letter,
7308
7604
  className: "lks14-opt",
7309
7605
  onClick: () => {
7310
- interactive.send(tr("quiz.answer", {
7606
+ interactive?.send(tr("quiz.answer", {
7311
7607
  letter: opt.letter,
7312
7608
  text: opt.text
7313
7609
  }));
@@ -7324,7 +7620,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7324
7620
  };
7325
7621
  }
7326
7622
  function StudyPanelBody({ ctx }) {
7327
- const { data, activate, setMode, setFocus, searchLessons, deleteCourse, deleteNote, bindLessonSession, lessonThreadOp, uploadAttachment } = useStudy();
7623
+ const { data, activate, setMode, setFocus, searchLessons, deleteCourse, deleteNote, bindLessonSession, lessonThreadOp, setCourseScope, uploadAttachment } = useStudy();
7328
7624
  (0, react.useEffect)(() => {
7329
7625
  wireCodeBlockCopy();
7330
7626
  }, []);
@@ -7472,6 +7768,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7472
7768
  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 ?? []);
7473
7769
  const localBound = (0, react.useRef)(null);
7474
7770
  const boundSessionOf = (lessonId) => lessonId !== null && localBound.current?.lessonId === lessonId ? localBound.current.sessionId : null;
7771
+ const threadKeyOf = (lessonId) => {
7772
+ if (lessonId === null || data === null) return null;
7773
+ 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);
7774
+ return null;
7775
+ };
7776
+ const threadGroupOf = (lessonId) => {
7777
+ const key = threadKeyOf(lessonId);
7778
+ return key !== null ? data?.lessonThreads[key] ?? null : null;
7779
+ };
7475
7780
  const streamEl = (0, react.useRef)(null);
7476
7781
  const touchStart = (0, react.useRef)(null);
7477
7782
  const activeFace = (0, react.useRef)(null);
@@ -7480,7 +7785,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7480
7785
  try {
7481
7786
  let face = activeFace.current;
7482
7787
  if (face === null) {
7483
- const sessionId = boundSessionOf(lesson !== null ? lesson.lessonId : null) ?? (lesson !== null ? data?.lessonThreads[lesson.lessonId]?.active ?? data?.lessonSessions[lesson.lessonId] ?? null : null);
7788
+ const sessionId = boundSessionOf(lesson !== null ? lesson.lessonId : null) ?? threadGroupOf(lesson !== null ? lesson.lessonId : null)?.active ?? (lesson !== null ? data?.lessonSessions[lesson.lessonId] ?? null : null);
7484
7789
  if (sessionId !== null && sessionKnown(ctx, sessionId)) {
7485
7790
  const actx = ctx.sessions.scope(sessionId);
7486
7791
  face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx) ?? null;
@@ -7516,7 +7821,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7516
7821
  } else celebrate(ev.kind);
7517
7822
  prevCelebrationRef.current = snapshot;
7518
7823
  }, [data]);
7519
- const boundId = boundSessionOf(lesson !== null ? lesson.lessonId : null) ?? (lesson !== null ? data?.lessonThreads[lesson.lessonId]?.active ?? data?.lessonSessions[lesson.lessonId] ?? null : null);
7824
+ const boundId = boundSessionOf(lesson !== null ? lesson.lessonId : null) ?? threadGroupOf(lesson !== null ? lesson.lessonId : null)?.active ?? (lesson !== null ? data?.lessonSessions[lesson.lessonId] ?? null : null);
7520
7825
  const nudged = (0, react.useRef)(false);
7521
7826
  (0, react.useEffect)(() => {
7522
7827
  if (nudged.current || (data?.dueCount ?? 0) === 0) return;
@@ -7703,7 +8008,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7703
8008
  if (data?.active !== true) await activate(true);
7704
8009
  if (targetLessonId !== void 0 && targetLessonId !== lesson.lessonId) await setFocus(targetLessonId).catch(() => {});
7705
8010
  const bindId = targetLessonId ?? lesson.lessonId;
7706
- let sessionId = boundSessionOf(bindId) ?? data?.lessonThreads[bindId]?.active ?? data?.lessonSessions[bindId] ?? null;
8011
+ let sessionId = boundSessionOf(bindId) ?? threadGroupOf(bindId)?.active ?? data?.lessonSessions[bindId] ?? null;
7707
8012
  if (sessionId !== null && !sessionKnown(ctx, sessionId)) sessionId = null;
7708
8013
  if (sessionId === null) {
7709
8014
  const area = await fetch("/lookatstudy/api/study-workspace");
@@ -7716,10 +8021,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7716
8021
  sessionId
7717
8022
  };
7718
8023
  await bindLessonSession(bindId, sessionId, threadTitle ?? threadAutoTitle(text));
7719
- } else localBound.current = {
7720
- lessonId: bindId,
7721
- sessionId
7722
- };
8024
+ } else {
8025
+ localBound.current = {
8026
+ lessonId: bindId,
8027
+ sessionId
8028
+ };
8029
+ await bindLessonSession(bindId, sessionId);
8030
+ }
7723
8031
  const shell = PANEL_SHELL.current;
7724
8032
  if (shell !== null) shell.suppressHandBack(() => {
7725
8033
  ctx.sessions.open(sessionId);
@@ -7898,6 +8206,11 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7898
8206
  onThreadRename: renameLessonThreadUi,
7899
8207
  onThreadArchive: archiveLessonThreadUi,
7900
8208
  onThreadDelete: deleteLessonThreadUi,
8209
+ courseScope: data !== null && lesson !== null ? data.courses.find((c) => c.sections.some((sec) => sec.lessons.some((l) => l.id === lesson.lessonId)))?.threadScope ?? "lesson" : "lesson",
8210
+ onScopeToggle: (next) => {
8211
+ 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;
8212
+ if (cid !== void 0) setCourseScope(cid, next);
8213
+ },
7901
8214
  contextMeter,
7902
8215
  contextBreakdown: breakdown,
7903
8216
  uploadAttachment
@@ -7980,6 +8293,33 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7980
8293
  * search-and-jump (the dashboard search API), course switch, review, start
7981
8294
  * studying — all funneling the panel's own actions.
7982
8295
  */
8296
+ /** The cmap modal's diagram host — rendered ONCE per (title, concepts) VALUE.
8297
+ * The raw-ref-callback version re-ran renderLessonConceptMap on every parent
8298
+ * render (each 3s state poll recreates the feed-derived concepts array, so
8299
+ * even a memo-by-identity effect would re-fire); the dataset key compares by
8300
+ * value — poll re-renders no-op, a real mastery change re-renders. Same #12
8301
+ * family as the diagram modal. */
8302
+ function CmapModalDiagram({ title, concepts }) {
8303
+ const ref = (0, react.useRef)(null);
8304
+ (0, react.useEffect)(() => {
8305
+ const el = ref.current;
8306
+ if (el === null) return;
8307
+ const key = `${title}\u0000${concepts.map((c) => `${c.title}:${String(c.masteryPct)}`).join("")}`;
8308
+ if (el.dataset.cmapKey === key) return;
8309
+ el.dataset.cmapKey = key;
8310
+ el.textContent = "";
8311
+ renderLessonConceptMap(el, title, concepts.map((c) => ({
8312
+ title: c.title,
8313
+ masteryPct: c.masteryPct
8314
+ }))).catch(() => {
8315
+ el.textContent = tr("bb.fallback.cmap");
8316
+ });
8317
+ });
8318
+ return (0, react.createElement)("div", {
8319
+ className: "lks14-modal-diagram",
8320
+ ref
8321
+ });
8322
+ }
7983
8323
  function CommandPalette({ searchLessons, courses, onClose, onLesson, onCourse, onReview, onStudy }) {
7984
8324
  const [query, setQuery] = (0, react.useState)("");
7985
8325
  const [lessons, setLessons] = (0, react.useState)([]);
@@ -8834,7 +9174,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8834
9174
  return cut <= 0 ? path : norm.slice(0, cut);
8835
9175
  }
8836
9176
  /** 中栏:the tutor chat stream with its own composer (upstream ChatStream + ChatComposer). */
8837
- function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, onThreadSwitch, onThreadNew, onThreadRename, onThreadArchive, onThreadDelete, contextMeter, contextBreakdown, uploadAttachment }) {
9177
+ 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 }) {
8838
9178
  const stuck = (0, react.useRef)(true);
8839
9179
  const [showFab, setShowFab] = (0, react.useState)(false);
8840
9180
  const [threadsOpen, setThreadsOpen] = (0, react.useState)(false);
@@ -9060,7 +9400,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9060
9400
  revealed
9061
9401
  }));
9062
9402
  };
9063
- const threadPills = threadGroupPills(lesson !== null && data !== null ? data.lessonThreads[lesson.lessonId] : void 0);
9403
+ const threadKeyOfHere = (lessonId) => {
9404
+ if (lessonId === null || data === null) return null;
9405
+ 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);
9406
+ return null;
9407
+ };
9408
+ const keyHere = threadKeyOfHere(lesson !== null ? lesson.lessonId : null);
9409
+ const threadPills = threadGroupPills(keyHere !== null ? data?.lessonThreads[keyHere] : void 0);
9064
9410
  const lastAssistant = rowsView.reduce((acc, row, i) => row.role === "assistant" ? i : acc, -1);
9065
9411
  const [error, setError] = (0, react.useState)(null);
9066
9412
  const fire = (action) => {
@@ -9151,14 +9497,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9151
9497
  }));
9152
9498
  send(tr("proposal.decline.msg", { lesson: proposal.lessonTitle }));
9153
9499
  }
9154
- }, 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", {
9500
+ }, 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", {
9155
9501
  className: "lks14-threadchip",
9156
9502
  "aria-expanded": String(threadsOpen),
9157
9503
  "data-tooltip": tr("threads.label"),
9158
9504
  onClick: () => {
9159
9505
  setThreadsOpen((v) => !v);
9160
9506
  }
9161
- }, tr("threads.open", { n: String(threadPills.length) }), (0, react.createElement)("span", { className: "lks14-threadchip-caret" }, threadsOpen ? "▾" : "▸")), threadsOpen ? (0, react.createElement)("div", {
9507
+ }, 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", {
9162
9508
  className: "lks14-threadmenu",
9163
9509
  role: "menu"
9164
9510
  }, ...threadPills.map((p) => (0, react.createElement)("div", {
@@ -9203,7 +9549,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9203
9549
  if (v !== "" && v !== p.title) onThreadRename(p.id, v);
9204
9550
  setRenaming(null);
9205
9551
  }
9206
- }) : (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", {
9552
+ }) : (0, react.createElement)("span", { className: "lks14-threadmenu-title" }, p.title, courseScope === "course" && threadCoverageLabel(p.touchedLessons, data?.courses ?? []) !== "" ? (0, react.createElement)("span", {
9553
+ className: "lks14-threadmenu-cover",
9554
+ "data-testid": "thread-cover"
9555
+ }, `${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", {
9207
9556
  className: "lks14-threadmenu-gear",
9208
9557
  "aria-label": tr("threads.actions"),
9209
9558
  "data-tooltip": tr("threads.actions"),
@@ -9246,6 +9595,18 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9246
9595
  setGearFor(null);
9247
9596
  }
9248
9597
  }, tr("threads.delete"))) : null)), (0, react.createElement)("button", {
9598
+ key: "__scope__",
9599
+ className: "lks14-threadmenu-row scope",
9600
+ role: "menuitemcheckbox",
9601
+ "aria-checked": String(courseScope === "course"),
9602
+ "data-testid": "thread-scope-toggle",
9603
+ onClick: () => {
9604
+ onScopeToggle(courseScope === "course" ? "lesson" : "course");
9605
+ }
9606
+ }, (0, react.createElement)("span", {
9607
+ className: "lks14-threadmenu-scope-icon",
9608
+ "aria-hidden": "true"
9609
+ }, 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", {
9249
9610
  key: "__new__",
9250
9611
  className: "lks14-threadmenu-row new",
9251
9612
  role: "menuitem",
@@ -9280,7 +9641,20 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9280
9641
  onClick: () => {
9281
9642
  send(starters[0]?.message ?? tr("prompt.start"));
9282
9643
  }
9283
- }, 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" ? {
9644
+ }, 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) ? {
9645
+ send,
9646
+ answerAsk: (label) => {
9647
+ (async () => {
9648
+ if (busy) {
9649
+ stop();
9650
+ await new Promise((r) => {
9651
+ setTimeout(r, 1800);
9652
+ });
9653
+ }
9654
+ send(label);
9655
+ })();
9656
+ }
9657
+ } : void 0, row.role === "assistant" ? {
9284
9658
  playing: msgAudio?.key === row.key,
9285
9659
  index: msgAudio?.index ?? 0,
9286
9660
  total: msgAudio?.total ?? 0,
@@ -9669,7 +10043,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9669
10043
  (0, react.useEffect)(() => {
9670
10044
  if (tab !== "teach" || proseRef.current === null || lesson === null) return;
9671
10045
  enhanceRendered(proseRef.current).catch(() => {}).finally(() => {
9672
- applyHighlights(proseRef.current, recordQuotes);
10046
+ if (proseRef.current !== null) applyHighlights(proseRef.current, recordQuotes);
9673
10047
  setEnhanceTick(Date.now());
9674
10048
  });
9675
10049
  }, [tab, lesson?.html]);
@@ -9678,7 +10052,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9678
10052
  locateNoteQuote(pendingLocate, proseRef.current);
9679
10053
  setPendingLocate(null);
9680
10054
  }, [pendingLocate, tab]);
9681
- const body = lesson === null ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("bb.empty"), (0, react.createElement)("br"), tr("bb.empty.hint")) : (0, react.createElement)("div", { className: "lks14-notebody" }, (0, react.createElement)("div", { className: "lks14-viewtabs" }, (0, react.createElement)("button", {
10055
+ const body = lesson === null ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("bb.empty"), (0, react.createElement)("br"), tr("bb.empty.hint")) : (0, react.createElement)("div", { className: `lks14-notebody${tab === "board" ? " lks14-notebody-fill" : ""}` }, (0, react.createElement)("div", { className: "lks14-viewtabs" }, (0, react.createElement)("button", {
9682
10056
  className: `lks14-viewtab${tab === "teach" ? " on" : ""}`,
9683
10057
  "aria-pressed": String(tab === "teach"),
9684
10058
  onClick: () => {
@@ -9722,7 +10096,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9722
10096
  onClick: () => {
9723
10097
  setRateBusy(true);
9724
10098
  recordReview(lesson.lessonId, Number(q)).then(() => {
9725
- const rateEl = document.querySelector(`[data-lks-rate="${lesson.lessonId}"]`);
10099
+ const rateEl = document.querySelector(`[data-lks-rate="${CSS.escape(lesson.lessonId)}"]`);
9726
10100
  const rr = Number(q) >= 4 && rateEl !== null ? rateEl.getBoundingClientRect() : null;
9727
10101
  celebrate(Number(q) >= 4 ? "correct" : "wrong", rr !== null ? { origin: {
9728
10102
  x: rr.right - 48,
@@ -9830,19 +10204,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9830
10204
  onClick: () => {
9831
10205
  setCmapExpanded(false);
9832
10206
  }
9833
- }, (0, react.createElement)(IconCloseFill16, { size: 13 }))), (0, react.createElement)(CanvasStage, { testid: "cmap-modal-stage" }, (0, react.createElement)("div", {
9834
- className: "lks14-modal-diagram",
9835
- ref: (el) => {
9836
- if (el !== null) {
9837
- el.textContent = "";
9838
- renderLessonConceptMap(el, lesson.title, lesson.concepts.map((c) => ({
9839
- title: c.title,
9840
- masteryPct: c.masteryPct
9841
- }))).catch(() => {
9842
- el.textContent = tr("bb.fallback.cmap");
9843
- });
9844
- }
9845
- }
10207
+ }, (0, react.createElement)(IconCloseFill16, { size: 13 }))), (0, react.createElement)(CanvasStage, { testid: "cmap-modal-stage" }, (0, react.createElement)(CmapModalDiagram, {
10208
+ title: lesson.title,
10209
+ concepts: lesson.concepts.map((c) => ({
10210
+ title: c.title,
10211
+ masteryPct: c.masteryPct
10212
+ }))
9846
10213
  })))) : null) : (0, react.createElement)("div", { className: "lks14-zones" }, (() => {
9847
10214
  const understand = artifacts.filter((a) => a.artifactType === "compare_table" || a.artifactType === "diagram" || a.artifactType === "code_walkthrough");
9848
10215
  if (understand.length === 0) return null;
@@ -9893,10 +10260,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9893
10260
  "data-tooltip": tr("note.locate"),
9894
10261
  "aria-label": tr("note.locate"),
9895
10262
  onClick: () => {
9896
- if (tab !== "teach") {
9897
- setTab("teach");
9898
- setPendingLocate(n.quote ?? "");
9899
- } else locateNoteQuote(n.quote ?? "", proseRef.current);
10263
+ setTab("teach");
10264
+ setPendingLocate(n.quote ?? "");
9900
10265
  }
9901
10266
  }, (0, react.createElement)(IconGoalOutline16, { size: 12 })) : null, (0, react.createElement)("button", {
9902
10267
  className: "lks-note-act",