dsh-plugin-lookatstudy 0.22.2 → 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
@@ -787,7 +787,31 @@ window.__ModuleLoader__.load({
787
787
  .lks-ui .lks14-threadmenu-row.on{background:rgb(var(--brand-rgb)/0.1)}
788
788
  .lks-ui .lks14-threadmenu-row.on .lks14-threadmenu-title{color:var(--brand);font-weight:700}
789
789
  .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}
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)}
791
815
  .lks-ui .lks14-threadmenu-row{position:relative}
792
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}
793
817
  .lks-ui .lks14-threadmenu-row:hover .lks14-threadmenu-gear,.lks-ui .lks14-threadmenu-gear:focus-visible{opacity:1}
@@ -1850,6 +1874,19 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1850
1874
  this.refresh();
1851
1875
  return res;
1852
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
+ }
1853
1890
  /** Delete one course (and its proposals) from the host state. */
1854
1891
  async deleteCourse(courseId) {
1855
1892
  await fetchJson("/lookatstudy/api/course/delete", {
@@ -2051,6 +2088,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2051
2088
  setFocus: studyStore.setFocus.bind(studyStore),
2052
2089
  searchLessons: studyStore.searchLessons.bind(studyStore),
2053
2090
  tts: studyStore.tts.bind(studyStore),
2091
+ setCourseScope: studyStore.setCourseScope.bind(studyStore),
2054
2092
  deleteCourse: studyStore.deleteCourse.bind(studyStore),
2055
2093
  deleteNote: studyStore.deleteNote.bind(studyStore),
2056
2094
  editNote: studyStore.editNote.bind(studyStore),
@@ -2255,6 +2293,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2255
2293
  "review.banner": "正在复习:{lesson}",
2256
2294
  "review.threadTitle": "复习:{lesson}",
2257
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": "已回答",
2258
2303
  "meter.label": "上下文用量",
2259
2304
  "meter.aria": "上下文已用 {pct}%",
2260
2305
  "meter.brk.system": "系统提示",
@@ -2603,6 +2648,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2603
2648
  "palette.kind.action": "Action",
2604
2649
  "palette.empty": "No matches",
2605
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",
2606
2658
  "threads.open": "{n} threads",
2607
2659
  "threads.now": "current",
2608
2660
  "threads.new": "New thread (your next message starts it)",
@@ -2998,6 +3050,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2998
3050
  return out.join("\n");
2999
3051
  }
3000
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
3001
3060
  //#region src/vendor/mermaid-elk-rewrite.ts
3002
3061
  /**
3003
3062
  * mermaid-elk-rewrite —— flowchart → flowchart-elk 前缀改写(v0.21 纯函数)。
@@ -3751,10 +3810,26 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3751
3810
  return group.threads.filter((t) => t.status !== "archived").slice().sort((a, b) => a.lastAt < b.lastAt ? 1 : -1).map((t) => ({
3752
3811
  id: t.id,
3753
3812
  title: t.title,
3754
- current: t.id === group.active
3813
+ current: t.id === group.active,
3814
+ touchedLessons: t.touchedLessons
3755
3815
  }));
3756
3816
  }
3757
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
+ /**
3758
3833
  * Default expansion for one rail section: collapsed when every study lesson is
3759
3834
  * done (mastered) or not yet reachable (locked). The focus lesson's section
3760
3835
  * stays open; exam nodes never force a section open. Pure.
@@ -4873,8 +4948,20 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4873
4948
  });
4874
4949
  } else if (event.type === "tool/call") {
4875
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
+ }
4876
4963
  rows.push({
4877
- key: `t${event.data?.callId ?? event.data?.toolCallId ?? event.seq}`,
4964
+ key: `t${String(callId ?? event.seq)}`,
4878
4965
  role: "tool",
4879
4966
  text: name,
4880
4967
  toolState: "loading"
@@ -4882,13 +4969,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4882
4969
  } else if (event.type === "tool/result") {
4883
4970
  const rid = event.data?.message?.source?.callId ?? event.data?.callId ?? event.data?.toolCallId;
4884
4971
  if (rid !== void 0) {
4885
- 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"));
4886
4973
  if (idx >= 0) {
4887
4974
  const failed = event.data?.message?.isError === true || event.data?.error !== void 0;
4888
4975
  rows[idx] = {
4889
4976
  ...rows[idx],
4890
4977
  toolState: failed ? "error" : "done",
4891
- 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
+ } } : {}
4892
4983
  };
4893
4984
  }
4894
4985
  }
@@ -4908,6 +4999,18 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4908
4999
  else stream.text += chunk.text ?? "";
4909
5000
  latest = attemptId;
4910
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
+ }
4911
5014
  const stream = latest !== void 0 ? streams.get(latest) : void 0;
4912
5015
  if (stream !== void 0 && !stream.settled) {
4913
5016
  const text = stream.text.trim();
@@ -4919,6 +5022,60 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4919
5022
  }
4920
5023
  return rows;
4921
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
+ }
4922
5079
  //#endregion
4923
5080
  //#region src/client/error-boundary.tsx
4924
5081
  /**
@@ -7392,6 +7549,25 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7392
7549
  key: row.key,
7393
7550
  className: "lks14-toolchip " + String(row.toolState ?? "loading")
7394
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
+ }
7395
7571
  const cls = row.role === "streaming" ? "lks14-msg lks14-msg-assistant streaming" : "lks14-msg lks14-msg-assistant";
7396
7572
  const body = (0, react.createElement)(ErrorBoundary, { key: `b-${row.key}` }, (0, react.createElement)("div", {
7397
7573
  className: cls,
@@ -7444,7 +7620,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7444
7620
  };
7445
7621
  }
7446
7622
  function StudyPanelBody({ ctx }) {
7447
- 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();
7448
7624
  (0, react.useEffect)(() => {
7449
7625
  wireCodeBlockCopy();
7450
7626
  }, []);
@@ -7592,6 +7768,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7592
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 ?? []);
7593
7769
  const localBound = (0, react.useRef)(null);
7594
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
+ };
7595
7780
  const streamEl = (0, react.useRef)(null);
7596
7781
  const touchStart = (0, react.useRef)(null);
7597
7782
  const activeFace = (0, react.useRef)(null);
@@ -7600,7 +7785,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7600
7785
  try {
7601
7786
  let face = activeFace.current;
7602
7787
  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);
7788
+ const sessionId = boundSessionOf(lesson !== null ? lesson.lessonId : null) ?? threadGroupOf(lesson !== null ? lesson.lessonId : null)?.active ?? (lesson !== null ? data?.lessonSessions[lesson.lessonId] ?? null : null);
7604
7789
  if (sessionId !== null && sessionKnown(ctx, sessionId)) {
7605
7790
  const actx = ctx.sessions.scope(sessionId);
7606
7791
  face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx) ?? null;
@@ -7636,7 +7821,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7636
7821
  } else celebrate(ev.kind);
7637
7822
  prevCelebrationRef.current = snapshot;
7638
7823
  }, [data]);
7639
- 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);
7640
7825
  const nudged = (0, react.useRef)(false);
7641
7826
  (0, react.useEffect)(() => {
7642
7827
  if (nudged.current || (data?.dueCount ?? 0) === 0) return;
@@ -7823,7 +8008,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7823
8008
  if (data?.active !== true) await activate(true);
7824
8009
  if (targetLessonId !== void 0 && targetLessonId !== lesson.lessonId) await setFocus(targetLessonId).catch(() => {});
7825
8010
  const bindId = targetLessonId ?? lesson.lessonId;
7826
- let sessionId = boundSessionOf(bindId) ?? data?.lessonThreads[bindId]?.active ?? data?.lessonSessions[bindId] ?? null;
8011
+ let sessionId = boundSessionOf(bindId) ?? threadGroupOf(bindId)?.active ?? data?.lessonSessions[bindId] ?? null;
7827
8012
  if (sessionId !== null && !sessionKnown(ctx, sessionId)) sessionId = null;
7828
8013
  if (sessionId === null) {
7829
8014
  const area = await fetch("/lookatstudy/api/study-workspace");
@@ -7836,10 +8021,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7836
8021
  sessionId
7837
8022
  };
7838
8023
  await bindLessonSession(bindId, sessionId, threadTitle ?? threadAutoTitle(text));
7839
- } else localBound.current = {
7840
- lessonId: bindId,
7841
- sessionId
7842
- };
8024
+ } else {
8025
+ localBound.current = {
8026
+ lessonId: bindId,
8027
+ sessionId
8028
+ };
8029
+ await bindLessonSession(bindId, sessionId);
8030
+ }
7843
8031
  const shell = PANEL_SHELL.current;
7844
8032
  if (shell !== null) shell.suppressHandBack(() => {
7845
8033
  ctx.sessions.open(sessionId);
@@ -8018,6 +8206,11 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8018
8206
  onThreadRename: renameLessonThreadUi,
8019
8207
  onThreadArchive: archiveLessonThreadUi,
8020
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
+ },
8021
8214
  contextMeter,
8022
8215
  contextBreakdown: breakdown,
8023
8216
  uploadAttachment
@@ -8981,7 +9174,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8981
9174
  return cut <= 0 ? path : norm.slice(0, cut);
8982
9175
  }
8983
9176
  /** 中栏: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 }) {
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 }) {
8985
9178
  const stuck = (0, react.useRef)(true);
8986
9179
  const [showFab, setShowFab] = (0, react.useState)(false);
8987
9180
  const [threadsOpen, setThreadsOpen] = (0, react.useState)(false);
@@ -9207,7 +9400,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9207
9400
  revealed
9208
9401
  }));
9209
9402
  };
9210
- 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);
9211
9410
  const lastAssistant = rowsView.reduce((acc, row, i) => row.role === "assistant" ? i : acc, -1);
9212
9411
  const [error, setError] = (0, react.useState)(null);
9213
9412
  const fire = (action) => {
@@ -9298,14 +9497,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9298
9497
  }));
9299
9498
  send(tr("proposal.decline.msg", { lesson: proposal.lessonTitle }));
9300
9499
  }
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", {
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", {
9302
9501
  className: "lks14-threadchip",
9303
9502
  "aria-expanded": String(threadsOpen),
9304
9503
  "data-tooltip": tr("threads.label"),
9305
9504
  onClick: () => {
9306
9505
  setThreadsOpen((v) => !v);
9307
9506
  }
9308
- }, 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", {
9309
9508
  className: "lks14-threadmenu",
9310
9509
  role: "menu"
9311
9510
  }, ...threadPills.map((p) => (0, react.createElement)("div", {
@@ -9350,7 +9549,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9350
9549
  if (v !== "" && v !== p.title) onThreadRename(p.id, v);
9351
9550
  setRenaming(null);
9352
9551
  }
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", {
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", {
9354
9556
  className: "lks14-threadmenu-gear",
9355
9557
  "aria-label": tr("threads.actions"),
9356
9558
  "data-tooltip": tr("threads.actions"),
@@ -9393,6 +9595,18 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9393
9595
  setGearFor(null);
9394
9596
  }
9395
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", {
9396
9610
  key: "__new__",
9397
9611
  className: "lks14-threadmenu-row new",
9398
9612
  role: "menuitem",
@@ -9427,7 +9641,20 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9427
9641
  onClick: () => {
9428
9642
  send(starters[0]?.message ?? tr("prompt.start"));
9429
9643
  }
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" ? {
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" ? {
9431
9658
  playing: msgAudio?.key === row.key,
9432
9659
  index: msgAudio?.index ?? 0,
9433
9660
  total: msgAudio?.total ?? 0,