dsh-plugin-lookatstudy 0.8.1 → 0.9.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-plugin-lookatstudy
2
2
 
3
- Turn any markdown document, local folder, or GitHub learning repository into a guided course inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) — your dsh agent becomes a full AI tutor with the interaction design of [LookatStudy](https://github.com/kaiji/LookatStudy): per-concept knowledge tracking, mastery-driven progression, spaced repetition, mastery proposals, friction awareness, learner memory, a Cornell notebook, and an in-chat proposal card. Learning engine modules are vendored from LookatStudy (MIT).
3
+ Turn any markdown document, local folder, or GitHub learning repository into a guided course inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) — your dsh agent becomes a full AI tutor with the interaction design of [LookatStudy](https://github.com/Kaiji-Z/LookatStudy): per-concept knowledge tracking, mastery-driven progression, spaced repetition, mastery proposals, friction awareness, learner memory, a Cornell notebook, and an in-chat proposal card. Learning engine modules are vendored from LookatStudy (MIT).
4
4
 
5
5
  ## Install
6
6
 
@@ -65,8 +65,8 @@ LookatStudy's Electron-native experiences have no host surface in dsh: persisten
65
65
  pnpm exec tsdown # build lib/ (host + client entries, peers external)
66
66
  pnpm test # 48 node:test cases over the real source (no key needed)
67
67
 
68
- # iterate against a live dsh (from a deepseek-harness checkout):
69
- pnpm dsh web --patch ./lookatstudy-plugin/cordis.dev.yml
68
+ # iterate against a live dsh (this repo lives beside a deepseek-harness checkout):
69
+ pnpm dsh web --patch ../dsh-plugin-lookatstudy/cordis.dev.yml # run from the harness checkout
70
70
  # then open http://127.0.0.1:3080/ and switch to the 学习 tab
71
71
  ```
72
72
 
@@ -0,0 +1,47 @@
1
+ //#region src/vendor/adoc-parser.ts
2
+ function parseAdoc(adocText) {
3
+ const lines = adocText.split("\n");
4
+ const output = [];
5
+ let inSourceBlock = false;
6
+ let pendingLang = "";
7
+ for (let i = 0; i < lines.length; i++) {
8
+ const line = lines[i] ?? "";
9
+ const sourceMatch = line.match(/^\[source,\s*(\w*)\s*\]$/);
10
+ if (sourceMatch && !inSourceBlock) {
11
+ pendingLang = sourceMatch[1] ?? "";
12
+ if ((lines[i + 1] ?? "").trim() === "----") {
13
+ inSourceBlock = true;
14
+ i++;
15
+ output.push(`\`\`\`${pendingLang}`);
16
+ continue;
17
+ }
18
+ }
19
+ if (inSourceBlock && line.trim() === "----") {
20
+ inSourceBlock = false;
21
+ output.push("```");
22
+ continue;
23
+ }
24
+ if (inSourceBlock) {
25
+ output.push(line);
26
+ continue;
27
+ }
28
+ let processed = line;
29
+ processed = processed.replace(/^(=+)\s+/, (_match, eqs) => {
30
+ if (eqs.length > 6) return "#".repeat(6) + " ";
31
+ return "#".repeat(eqs.length) + " ";
32
+ });
33
+ processed = processed.replace(/image::(\S+?)\[([^\]]*)\]/g, (_m, path, alt) => {
34
+ return alt ? `![${alt}](${path})` : `![](${path})`;
35
+ });
36
+ processed = processed.replace(/image:(\S+?)\[([^\]]*)\]/g, (_m, path, alt) => {
37
+ return alt ? `![${alt}](${path})` : `![](${path})`;
38
+ });
39
+ processed = processed.replace(/link:(\S+?)\[([^\]]*)\]/g, "[$2]($1)");
40
+ processed = processed.replace(/(\s|^)\*([^\s*][^*]*?)\*(?=\s|[.,;:!?)])/gm, "$1**$2**");
41
+ processed = processed.replace(/(\s|^)_([^\s_][^_]*?)_(?=\s|[.,;:!?)])/gm, "$1*$2*");
42
+ output.push(processed);
43
+ }
44
+ return { markdown: output.join("\n") };
45
+ }
46
+ //#endregion
47
+ export { parseAdoc };
package/lib/client.js CHANGED
@@ -41,9 +41,15 @@ window.__ModuleLoader__.load({
41
41
  .lks-body{flex:1;min-height:0;min-width:0;display:flex;gap:16px}
42
42
  .lks-col{display:flex;flex-direction:column;min-width:0}
43
43
  .lks-colhead{flex:none;font-size:12px;font-weight:600;color:var(--dsw-alias-label-tertiary);letter-spacing:.06em;text-transform:uppercase;margin-bottom:10px}
44
- .lks-col-rail{flex:1 1 0;min-width:190px;overflow-y:auto;padding:2px 6px}
44
+ .lks-col-rail{flex:0 1 260px;min-width:180px;overflow-y:auto;padding:2px 6px}
45
45
  .lks-col-tutor{flex:0 0 auto;width:min(100%,var(--dsh-composer-card-max-width,780px))}
46
46
  .lks-col-bb{flex:1 1 0;min-width:230px;overflow-y:auto;padding:2px 6px}
47
+ /* While the study view is mounted in wide mode, the host composer card is
48
+ shifted under the tutor column (the transcript's width axis) instead of the
49
+ scroll body's center — views.tsx measures the offset, sets the variable and
50
+ toggles the class, and removes both whenever the shift collapses (narrow
51
+ pane, hidden view) so the host's natural centering comes back. */
52
+ [data-composer-card].lks-composer-follow{align-self:flex-start;margin-left:var(--lks-composer-shift,0px)}
47
53
  /* narrow (<1220px): one composer-width pane at a time, chosen by a centered
48
54
  segmented pill group (joined buttons in one capsule — button language, not
49
55
  tab language, against the host's view tabs right above). */
@@ -72,7 +78,7 @@ window.__ModuleLoader__.load({
72
78
  .lks-masterybar i{display:block;height:100%;background:var(--dsw-alias-state-business-primary);transition:width .3s ease}
73
79
  .lks-masterybar.gold i{background:var(--dsw-alias-state-warn-primary)}
74
80
  .lks-search{width:100%;box-sizing:border-box;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13.5px;padding:5px 9px;margin-bottom:4px}
75
- .lks-search:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}
81
+ .lks-search:focus{outline:none;border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px var(--dsw-alias-border-l3)}
76
82
  .lks-sec-num{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;border-radius:50%;background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label);font-size:11px;font-weight:700;margin-right:4px}
77
83
  .lks-tag.due{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label)}
78
84
  .lks-import{margin-top:8px}
@@ -87,13 +93,25 @@ window.__ModuleLoader__.load({
87
93
  .lks-duebox .lks-over{color:var(--dsw-alias-state-warn-label);flex:none}
88
94
 
89
95
  .lks-sec{font-size:12px;color:var(--dsw-alias-label-tertiary);text-transform:uppercase;letter-spacing:.05em;margin:14px 0 4px}
90
- .lks-node{display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:8px;margin:1px 0;cursor:pointer}
96
+ /* Collapsible section head: a real button (keyboard can toggle it), sticky so
97
+ the current chapter stays identified while 4000px of nodes scroll under it. */
98
+ .lks-sechead{display:flex;align-items:center;gap:6px;width:100%;box-sizing:border-box;font-size:12px;font-weight:600;color:var(--dsw-alias-label-tertiary);text-transform:uppercase;letter-spacing:.05em;text-align:left;padding:6px 4px;margin:14px 0 4px;border-radius:6px;position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-base)}
99
+ .lks-sechead:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}
100
+ .lks-sechead:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3);outline:none}
101
+ .lks-sechead-t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
102
+ .lks-sechead-c{flex:none;font-size:10px}
103
+ .lks-sechead-n{flex:none;font-size:11px;text-transform:none;letter-spacing:0}
104
+ .lks-node{display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:6px 9px;border-radius:8px;margin:1px 0;cursor:pointer}
91
105
  .lks-node:hover{background:var(--dsw-alias-interactive-bg-hover)}
106
+ .lks-node:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-border-l3);outline:none}
92
107
  .lks-node.focus{background:var(--dsw-alias-bg-layer-3);outline:1px solid var(--dsw-alias-border-l2)}
93
108
  .lks-node .lks-g{width:18px;text-align:center;flex:none}
94
109
  .lks-node .lks-t{flex:1;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
95
- .lks-node.locked{opacity:.45;cursor:not-allowed}
96
- .lks-node.locked:hover{background:none}
110
+ /* aria-disabled (not the disabled attribute): the row stays focusable so the
111
+ status tooltip can explain WHY it is locked — Firefox drops titles on truly
112
+ disabled form controls. */
113
+ .lks-node[aria-disabled='true']{opacity:.45;cursor:not-allowed}
114
+ .lks-node[aria-disabled='true']:hover{background:none}
97
115
  .lks-tag{font-size:11px;border-radius:4px;padding:0 4px;flex:none}
98
116
  .lks-tag.weak{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label)}
99
117
  .lks-tag.fric{background:var(--dsw-alias-state-error-primary);color:#fff}
@@ -102,6 +120,7 @@ window.__ModuleLoader__.load({
102
120
  .lks-pct{font-size:12px;color:var(--dsw-alias-label-tertiary);flex:none;width:32px;text-align:right}
103
121
 
104
122
  .lks-empty{color:var(--dsw-alias-label-tertiary);text-align:center;padding:48px 8px;font-size:14px;line-height:2}
123
+ .lks-actbar{display:flex;flex-direction:row;justify-content:flex-end;flex:none;padding-bottom:8px}
105
124
 
106
125
  /* ── middle column: the tutor ── */
107
126
  .lks-transcript{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:10px;padding:4px 2px}
@@ -140,7 +159,9 @@ window.__ModuleLoader__.load({
140
159
  .lks-starter:hover{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary)}
141
160
 
142
161
  /* proposal banner + shared buttons */
143
- .lks-banner{display:flex;align-items:center;gap:10px;background:var(--dsw-alias-state-warn-tertiary);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 14px;margin:8px 0;font-size:14px;flex:none}
162
+ /* A mastery proposal is a positive, informational moment — business tint, not
163
+ the warn tint it previously wore (a reviewer flagged the semantic mismatch). */
164
+ .lks-banner{display:flex;align-items:center;gap:10px;background:var(--dsw-alias-state-business-tertiary);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 14px;margin:8px 0;font-size:14px;flex:none}
144
165
  .lks-banner .lks-why{flex:1;color:var(--dsw-alias-label-secondary)}
145
166
  .lks-btn{display:inline-flex;align-items:center;gap:6px;border-radius:8px;padding:6px 14px;font-size:13.5px;font-weight:600;flex:none}
146
167
  .lks-btn.primary{background:var(--dsw-alias-state-business-primary);color:#fff}
@@ -175,7 +196,7 @@ window.__ModuleLoader__.load({
175
196
  .lks-zone{margin-bottom:14px}
176
197
  .lks-zone h4{font-size:14px;color:var(--dsw-alias-label-secondary);margin:0 0 8px;font-weight:600}
177
198
  .lks-note{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 14px;margin-bottom:8px}
178
- .lks-note .lks-note-src{float:right;font-size:10px;color:var(--dsw-alias-label-tertiary)}
199
+ .lks-note .lks-note-src{float:right;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
179
200
  .lks-note .lks-note-title{font-weight:600;font-size:13px}
180
201
  .lks-note .lks-note-text{margin-top:4px;white-space:pre-wrap;font-size:13px;color:var(--dsw-alias-label-secondary)}
181
202
  .lks-note .lks-note-q{margin-top:6px;color:var(--dsw-alias-label-tertiary);font-size:12px;border-left:2px solid var(--dsw-alias-state-warn-primary);padding-left:8px}
@@ -325,6 +346,18 @@ window.__ModuleLoader__.load({
325
346
  });
326
347
  this.refresh();
327
348
  }
349
+ /**
350
+ * Flip learning mode. The host syncs its tool registry before responding,
351
+ * so awaiting this guarantees the `study_*` tools exist for the next prompt.
352
+ */
353
+ async activate(active) {
354
+ await fetchJson("/lookatstudy/api/active", {
355
+ method: "POST",
356
+ headers: { "content-type": "application/json" },
357
+ body: JSON.stringify({ active })
358
+ });
359
+ this.refresh();
360
+ }
328
361
  /** Point the tutor's focus at one lesson. */
329
362
  async setFocus(lessonId) {
330
363
  await fetchJson("/lookatstudy/api/focus", {
@@ -362,6 +395,7 @@ window.__ModuleLoader__.load({
362
395
  function useStudy() {
363
396
  return {
364
397
  data: (0, react.useSyncExternalStore)(studyStore.subscribe, studyStore.getSnapshot, studyStore.getSnapshot),
398
+ activate: studyStore.activate.bind(studyStore),
365
399
  setMode: studyStore.setMode.bind(studyStore),
366
400
  setFocus: studyStore.setFocus.bind(studyStore),
367
401
  deleteCourse: studyStore.deleteCourse.bind(studyStore),
@@ -559,6 +593,17 @@ window.__ModuleLoader__.load({
559
593
  function titleMatches(title, query) {
560
594
  return query.trim().toLowerCase().split(/\s+/).filter(Boolean).every((k) => title.toLowerCase().includes(k));
561
595
  }
596
+ /**
597
+ * Default expansion for one rail section: collapsed when every study lesson is
598
+ * done (mastered) or not yet reachable (locked) — long courses otherwise scroll
599
+ * 5× their viewport. The active frontier and the focus lesson's section stay
600
+ * open; exam nodes never force a section open (they are gated separately).
601
+ * Pure.
602
+ * @param section - one course section projection with lesson kinds/statuses.
603
+ */
604
+ function sectionDefaultOpen(section) {
605
+ return section.lessons.some((l) => l.focus || l.kind !== "exam" && l.status !== "mastered" && l.status !== "locked");
606
+ }
562
607
  /** The rail's import row: a GitHub URL input plus the paste/folder hints. */
563
608
  function ImportRow({ send }) {
564
609
  const [url, setUrl] = (0, react.useState)("");
@@ -707,14 +752,11 @@ window.__ModuleLoader__.load({
707
752
  call: node.call
708
753
  });
709
754
  break;
710
- case "turn-error":
711
- rows.push({
712
- key: `e${node.seq}`,
713
- role: "error",
714
- text: node.message
715
- });
716
- break;
717
- default: break;
755
+ case "turn-error": rows.push({
756
+ key: `e${node.seq}`,
757
+ role: "error",
758
+ text: node.message
759
+ });
718
760
  }
719
761
  if (partial !== null) {
720
762
  const text = assistantText(partial.blocks);
@@ -762,18 +804,64 @@ window.__ModuleLoader__.load({
762
804
  }
763
805
  /** Tab body: the factory-bound ctx carries workspaces/sessions for the per-lesson session jumps. */
764
806
  function StudyTab({ useSession, inputActions, ctx }) {
765
- const { data, setMode, setFocus, deleteCourse, bindLessonSession } = useStudy();
807
+ const { data, activate, setMode, setFocus, deleteCourse, bindLessonSession } = useStudy();
766
808
  const snapshot = useSession((s) => s);
767
809
  const [pane, setPane] = (0, react.useState)(storedPane);
810
+ const rootRef = (0, react.useRef)(null);
811
+ (0, react.useEffect)(() => {
812
+ const root = rootRef.current;
813
+ const card = document.querySelector("[data-composer-card]");
814
+ const seat = card?.parentElement ?? null;
815
+ if (root === null || card === null || seat === null) return;
816
+ const clear = () => {
817
+ card.classList.remove("lks-composer-follow");
818
+ card.style.removeProperty("--lks-composer-shift");
819
+ };
820
+ const apply = () => {
821
+ const tutor = root.querySelector(".lks-col-tutor");
822
+ const rail = root.querySelector(".lks-col-rail");
823
+ if (tutor === null || rail === null || getComputedStyle(rail).display === "none") return clear();
824
+ const seatRect = seat.getBoundingClientRect();
825
+ const tutorRect = tutor.getBoundingClientRect();
826
+ if (seatRect.width === 0 || tutorRect.width === 0) return clear();
827
+ const pad = parseFloat(getComputedStyle(seat).paddingLeft) || 0;
828
+ const cardW = card.getBoundingClientRect().width;
829
+ const max = seatRect.width - 2 * pad - cardW;
830
+ const shift = Math.round(Math.max(0, Math.min(tutorRect.left - (seatRect.left + pad), max)));
831
+ if (shift < 2) return clear();
832
+ card.style.setProperty("--lks-composer-shift", `${shift}px`);
833
+ card.classList.add("lks-composer-follow");
834
+ };
835
+ apply();
836
+ const ro = new ResizeObserver(apply);
837
+ ro.observe(root);
838
+ ro.observe(seat);
839
+ window.addEventListener("resize", apply);
840
+ return () => {
841
+ ro.disconnect();
842
+ window.removeEventListener("resize", apply);
843
+ clear();
844
+ };
845
+ }, []);
768
846
  const send = (text) => {
769
- inputActions.setDraft(text);
770
- inputActions.submit();
847
+ (async () => {
848
+ if (data?.active !== true) await activate(true);
849
+ inputActions.setDraft(text);
850
+ inputActions.submit();
851
+ })();
771
852
  };
772
853
  return (0, react.createElement)("div", {
854
+ ref: rootRef,
773
855
  className: "lks-root lks-study",
774
856
  "data-conversation-composer-overlay": "",
775
857
  "data-pane": pane
776
- }, (0, react.createElement)("div", { className: "lks-body" }, (0, react.createElement)("div", { className: "lks-switch" }, ...[
858
+ }, (0, react.createElement)("div", { className: "lks-actbar" }, data === null ? null : (0, react.createElement)("button", {
859
+ className: `lks-btn ${data.active ? "ghost" : "primary"}`,
860
+ title: data.active ? "注销 study 工具与导师人格;学习进度保留,可随时重新开启" : "注册 study 工具并载入导师人格,之后普通对话和现在一样",
861
+ onClick: () => {
862
+ activate(!data.active);
863
+ }
864
+ }, data.active ? "⏻ 退出学习模式" : "▶ 开始学习")), (0, react.createElement)("div", { className: "lks-body" }, (0, react.createElement)("div", { className: "lks-switch" }, ...[
777
865
  {
778
866
  id: "rail",
779
867
  label: "课程"
@@ -797,6 +885,7 @@ window.__ModuleLoader__.load({
797
885
  }
798
886
  }, p.label))), (0, react.createElement)(CourseRail, {
799
887
  data,
888
+ activate,
800
889
  setFocus,
801
890
  deleteCourse,
802
891
  bindLessonSession,
@@ -811,7 +900,7 @@ window.__ModuleLoader__.load({
811
900
  }), (0, react.createElement)(BlackboardColumn, { data })));
812
901
  }
813
902
  /** Left column: course management (pick/delete/search/import), lesson tree, due box. */
814
- function CourseRail({ data, setFocus, deleteCourse, bindLessonSession, send, ctx, currentSessionId }) {
903
+ function CourseRail({ data, activate, setFocus, deleteCourse, bindLessonSession, send, ctx, currentSessionId }) {
815
904
  const [jumping, setJumping] = (0, react.useState)(null);
816
905
  /** Open (or mint) the lesson's own session — the simplified thread system. */
817
906
  const openLessonThread = (lesson) => {
@@ -828,6 +917,7 @@ window.__ModuleLoader__.load({
828
917
  }
829
918
  setJumping(lesson.id);
830
919
  setFocus(lesson.id).then(() => (async () => {
920
+ if (data?.active !== true) await activate(true);
831
921
  const area = await fetch("/lookatstudy/api/study-workspace");
832
922
  if (!area.ok) throw new Error(`study area unavailable (HTTP ${area.status})`);
833
923
  const { path } = await area.json();
@@ -854,6 +944,8 @@ window.__ModuleLoader__.load({
854
944
  const [query, setQuery] = (0, react.useState)("");
855
945
  const [confirmDelete, setConfirmDelete] = (0, react.useState)(false);
856
946
  const [showImport, setShowImport] = (0, react.useState)(false);
947
+ /** Per-section open overrides (courseId/sectionTitle → open?), on top of the frontier default. */
948
+ const [secOpen, setSecOpen] = (0, react.useState)({});
857
949
  const [error, setError] = (0, react.useState)(null);
858
950
  const reportError = (err) => {
859
951
  setError(err instanceof Error ? err.message : String(err));
@@ -911,7 +1003,29 @@ window.__ModuleLoader__.load({
911
1003
  }
912
1004
  }
913
1005
  }
914
- }), data.dueCount > 0 ? (0, react.createElement)("div", { className: "lks-duebox" }, `🔁 待复习 ${data.dueCount}`, ...data.due.map((d) => (0, react.createElement)("div", {
1006
+ }), course.sections.some((s) => s.lessons.some((l) => l.focus)) ? (0, react.createElement)("button", {
1007
+ className: "lks-btn ghost",
1008
+ style: {
1009
+ margin: "0 0 6px",
1010
+ padding: "3px 8px",
1011
+ fontSize: "13px"
1012
+ },
1013
+ title: "在课程树中定位当前焦点课时(自动展开所在章节)",
1014
+ onClick: () => {
1015
+ const focusSection = course.sections.find((s) => s.lessons.some((l) => l.focus));
1016
+ if (focusSection === void 0) return;
1017
+ setSecOpen((m) => ({
1018
+ ...m,
1019
+ [`${courseId}/${focusSection.title}`]: true
1020
+ }));
1021
+ window.setTimeout(() => {
1022
+ document.querySelector(".lks-col-rail .lks-node.focus")?.scrollIntoView({
1023
+ block: "center",
1024
+ behavior: "smooth"
1025
+ });
1026
+ }, 60);
1027
+ }
1028
+ }, "📍 回到当前课时") : null, data.dueCount > 0 ? (0, react.createElement)("div", { className: "lks-duebox" }, `🔁 待复习 ${data.dueCount}`, ...data.due.map((d) => (0, react.createElement)("div", {
915
1029
  key: d.lessonId,
916
1030
  className: "lks-due-item"
917
1031
  }, (0, react.createElement)("span", null, d.lessonTitle), d.overdueDays > 0 ? (0, react.createElement)("span", { className: "lks-over" }, `超${d.overdueDays}天`) : null)), (0, react.createElement)("button", {
@@ -924,14 +1038,27 @@ window.__ModuleLoader__.load({
924
1038
  const examAllowed = examOpen(section.lessons);
925
1039
  const lessons = section.lessons.filter((l) => query.trim() === "" || titleMatches(l.title, query) || l.focus);
926
1040
  if (lessons.length === 0) return [];
927
- return [(0, react.createElement)("div", {
1041
+ const secKey = `${courseId}/${section.title}`;
1042
+ const open = query.trim() !== "" || (secOpen[secKey] ?? sectionDefaultOpen(section));
1043
+ return [(0, react.createElement)("button", {
928
1044
  key: section.title,
929
- className: "lks-sec"
930
- }, (0, react.createElement)("span", { className: "lks-sec-num" }, String(section.index + 1)), ` ${section.title}`), ...lessons.map((lesson) => {
1045
+ type: "button",
1046
+ className: "lks-sechead",
1047
+ "aria-expanded": String(open),
1048
+ title: open ? "折叠本章节" : `展开本章节(${section.lessons.length} 课时)`,
1049
+ onClick: () => {
1050
+ setSecOpen((m) => ({
1051
+ ...m,
1052
+ [secKey]: !open
1053
+ }));
1054
+ }
1055
+ }, (0, react.createElement)("span", { className: "lks-sec-num" }, String(section.index + 1)), (0, react.createElement)("span", { className: "lks-sechead-t" }, section.title), open ? null : (0, react.createElement)("span", { className: "lks-sechead-n" }, `${section.lessons.length} 课`), (0, react.createElement)("span", { className: "lks-sechead-c" }, open ? "▾" : "▸")), ...open ? lessons.map((lesson) => {
931
1056
  const locked = lesson.status === "locked" || lesson.kind === "exam" && !examAllowed;
932
- return (0, react.createElement)("div", {
1057
+ return (0, react.createElement)("button", {
933
1058
  key: lesson.id,
934
- className: `lks-node${locked ? " locked" : ""}${lesson.focus ? " focus" : ""}`,
1059
+ type: "button",
1060
+ className: `lks-node${lesson.focus ? " focus" : ""}`,
1061
+ "aria-disabled": locked || void 0,
935
1062
  title: jumping === lesson.id ? "正在打开课时会话…" : statusTitle(lesson.kind, locked ? "locked" : lesson.status),
936
1063
  onClick: () => {
937
1064
  if (locked) return;
@@ -958,7 +1085,7 @@ window.__ModuleLoader__.load({
958
1085
  className: "lks-pct",
959
1086
  title: "课时掌握度 = 最薄弱知识点的掌握度"
960
1087
  }, `${lesson.masteryPct}%`) : null);
961
- })];
1088
+ }) : []];
962
1089
  }), (0, react.createElement)("button", {
963
1090
  className: "lks-btn ghost",
964
1091
  style: { marginTop: "10px" },
@@ -1129,6 +1256,7 @@ window.__ModuleLoader__.load({
1129
1256
  }
1130
1257
  /** Button body in its own component so hook order stays constant. */
1131
1258
  function Inner({ ctx }) {
1259
+ const { data, activate } = useStudy();
1132
1260
  const [busy, setBusy] = (0, react.useState)(false);
1133
1261
  const [error, setError] = (0, react.useState)(null);
1134
1262
  const start = () => {
@@ -1137,6 +1265,7 @@ window.__ModuleLoader__.load({
1137
1265
  setError(null);
1138
1266
  (async () => {
1139
1267
  try {
1268
+ if (data?.active !== true) await activate(true);
1140
1269
  const area = await fetch("/lookatstudy/api/study-workspace");
1141
1270
  if (!area.ok) throw new Error(`study area unavailable (HTTP ${area.status})`);
1142
1271
  const { path } = await area.json();
@@ -1169,7 +1298,7 @@ window.__ModuleLoader__.load({
1169
1298
  title: "一键准备学习:建立学习工作区、开启会话并让导师就位",
1170
1299
  disabled: busy,
1171
1300
  onClick: start
1172
- }, busy ? (0, react.createElement)(IconLoadingOutline16, { className: "lks-spin" }) : (0, react.createElement)(IconThinkOutline16, null), busy ? "正在准备学习区…" : "开始学习"), error !== null ? (0, react.createElement)("span", {
1301
+ }, busy ? (0, react.createElement)(IconLoadingOutline16, { className: "lks-spin" }) : (0, react.createElement)(IconThinkOutline16, null), busy ? "正在准备学习区…" : data?.active === true ? "进入学习" : "开始学习"), error !== null ? (0, react.createElement)("span", {
1173
1302
  className: "lks-propcard-err",
1174
1303
  style: { marginTop: 0 }
1175
1304
  }, error) : null);