dsh-plugin-lookatstudy 0.19.0 → 0.20.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
@@ -152,8 +152,9 @@ window.__ModuleLoader__.load({
152
152
  .lks-ui .lks14-pill.on{background:rgb(var(--accent-rgb)/0.12);color:var(--accent-light)}
153
153
 
154
154
  /* ── notebook: teach prose + view tabs ── */
155
- .lks-ui .lks14-viewtab{color:var(--ink-muted);border-radius:999px}
156
- .lks-ui .lks14-viewtab.on{background:rgb(var(--ink-rgb)/0.08);color:var(--ink-strong)}
155
+ .lks-ui .lks14-viewtab{color:var(--ink-muted);border-radius:8px;transition:color .15s,background .15s}
156
+ .lks-ui .lks14-viewtab:hover{color:var(--ink)}
157
+ .lks-ui .lks14-viewtab.on{background:rgb(var(--brand-rgb)/0.16);color:var(--brand)}
157
158
  .lks-ui .lks14-prose{color:var(--ink);font-size:.9375rem;line-height:1.65}
158
159
  .lks-ui .lks14-prose h1,.lks-ui .lks-note-text h1{font-size:1.25rem;font-weight:800;color:var(--ink-strong)}
159
160
  .lks-ui .lks14-prose h2,.lks-ui .lks-note-text h2{font-size:1.1rem;font-weight:700;color:var(--ink-strong);border-bottom:1px solid var(--border);padding-bottom:.3em}
@@ -390,7 +391,7 @@ window.__ModuleLoader__.load({
390
391
  .lks-ui .lks14-emptycard-hint{font-size:.825rem;color:var(--ink-muted);line-height:1.6}
391
392
  /* B5: notebook tab capsule + 960px reading column */
392
393
  .lks-ui .lks14-notebody{margin:0 auto;max-width:960px;width:100%;padding:12px 20px 24px;box-sizing:border-box}
393
- .lks-ui .lks14-viewtabs{background:rgb(var(--ink-rgb)/0.08);border-radius:999px;padding:3px;align-self:flex-start}
394
+ .lks-ui .lks14-viewtabs{background:var(--surface-2);border-radius:10px;padding:3px;align-self:flex-start}
394
395
  .lks-ui .lks14-readbar{position:sticky;top:0;z-index:20;background:var(--surface-2);border-radius:10px}
395
396
 
396
397
  /* ══════════ C-track (P11a): interaction chrome ══════════ */
@@ -445,7 +446,7 @@ window.__ModuleLoader__.load({
445
446
  /* C6: zone head becomes the collapse toggle */
446
447
  .lks-ui .lks14-zoneh{display:flex;align-items:center;gap:8px;background:none;border:none;font:inherit;cursor:pointer;text-align:left;padding:0}
447
448
  .lks-ui .lks14-zonecount{font-size:10.5px;font-weight:700;color:var(--brand);background:rgb(var(--brand-rgb)/0.15);border-radius:999px;padding:1px 7px}
448
- .lks-ui .lks14-zonecaret{color:var(--ink-faint);font-size:10px}
449
+ .lks-ui .lks14-zonecaret{color:var(--ink-muted);font-size:11px;line-height:1}
449
450
  /* C6: note actions + pinned + edit */
450
451
  .lks-ui .lks-note-act{background:none;border:none;color:var(--ink-faint);cursor:pointer;padding:2px;display:inline-flex}
451
452
  .lks-ui .lks-note-act:hover{color:var(--ink)}
@@ -690,14 +691,9 @@ window.__ModuleLoader__.load({
690
691
  .lks-ui .lks14-palette-text{display:flex;flex-direction:column;gap:2px;min-width:0}
691
692
  .lks-ui .lks14-palette-sub{font-size:10.5px;color:var(--ink-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
692
693
  .lks-ui .lks14-palette-empty{padding:18px;text-align:center;color:var(--ink-faint);font-size:12px}
693
- /* E5: the thread pills under the lesson row */
694
+ /* E5: the lesson row carries the labeled thread chip (menu opens on click) */
694
695
  .lks-ui .lks14-lessonrow{display:flex;align-items:center;gap:8px;min-width:0}
695
696
  .lks-ui .lks14-lessonrow>span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
696
- .lks-ui .lks14-threadpills{display:flex;gap:4px;margin-left:auto;overflow-x:auto;max-width:55%;scrollbar-width:none}
697
- .lks-ui .lks14-threadpill{flex:none;border:1px solid var(--border-faint);background:var(--surface-1);color:var(--ink-muted);font:inherit;font-size:10.5px;padding:2.5px 8px;border-radius:999px;cursor:pointer;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:all .15s}
698
- .lks-ui .lks14-threadpill:hover{color:var(--ink);border-color:var(--border)}
699
- .lks-ui .lks14-threadpill.on{background:color-mix(in srgb,var(--brand) 16%,transparent);border-color:rgb(var(--brand-rgb)/0.4);color:var(--brand)}
700
- .lks-ui .lks14-threadpill:focus-visible{box-shadow:0 0 0 2px var(--business-primary);outline:none}
701
697
  /* E7: the font-scale pair in the app header */
702
698
  .lks-ui .lks-hdr-zoom{display:flex;align-items:center;gap:4px;margin-left:4px}
703
699
  .lks-ui .lks-hdr-zoom-btn{border:1px solid var(--border-faint);background:var(--surface-1);color:var(--ink-muted);font:inherit;font-size:10px;font-weight:700;border-radius:6px;padding:2px 6px;cursor:pointer;transition:all .15s}
@@ -706,12 +702,30 @@ window.__ModuleLoader__.load({
706
702
  .lks-ui .lks-hdr-zoom-btn:focus-visible{box-shadow:0 0 0 2px var(--business-primary);outline:none}
707
703
  .lks-ui .lks-hdr-zoom-val{font-size:10px;color:var(--ink-faint);min-width:32px;text-align:center}
708
704
 
709
- /* E1: the context meter above the composer */
710
- .lks-ui .lks14-ctxmeter{display:flex;align-items:center;gap:8px;padding:0 4px 6px;font-size:10px;color:var(--ink-faint)}
711
- .lks-ui .lks14-ctxmeter-bar{flex:1;height:4px;border-radius:999px;background:rgb(var(--ink-rgb)/0.08);overflow:hidden}
712
- .lks-ui .lks14-ctxmeter-bar i{display:block;height:100%;border-radius:999px;background:var(--brand);transform-origin:left;transition:transform .3s var(--ease-out-expo)}
713
- .lks-ui .lks14-ctxmeter-bar i.hot{background:var(--warning)}
714
- .lks-ui .lks14-ctxmeter-label{flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
705
+ /* E1: the context ring the host composer's occupancy meter (a 14px ring
706
+ beside send; hover reads, click opens the figures panel). */
707
+ .lks-ui .lks14-ctxring-wrap{position:relative;display:inline-flex;align-items:center}
708
+ .lks-ui .lks14-ctxring{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;padding:0;border:none;border-radius:50%;background:transparent;cursor:pointer;color:var(--ink-faint);transition:color .15s}
709
+ .lks-ui .lks14-ctxring:hover{color:var(--ink-muted)}
710
+ .lks-ui .lks14-ctxring:focus-visible{outline:none;box-shadow:0 0 0 2px var(--business-primary)}
711
+ .lks-ui .lks14-ctxring .track{fill:none;stroke:rgb(var(--ink-rgb)/0.18);stroke-width:2}
712
+ .lks-ui .lks14-ctxring .fill{fill:none;stroke:var(--brand);stroke-width:2;stroke-linecap:round;transition:stroke-dasharray .3s var(--ease-out-expo)}
713
+ .lks-ui .lks14-ctxring .fill.hot{stroke:var(--warning)}
714
+ .lks-ui .lks14-ctxpanel{position:absolute;bottom:calc(100% + 8px);right:0;z-index:30;width:240px;padding:10px 12px;border-radius:10px;background:var(--surface-2);border:1px solid var(--border-faint);box-shadow:0 8px 24px rgb(0 0 0/0.25);font-size:11px;color:var(--ink);text-align:left}
715
+ .lks-ui .lks14-ctxpanel-head{display:flex;align-items:baseline;gap:8px;margin-bottom:7px}
716
+ .lks-ui .lks14-ctxpanel-pct{font-size:15px;font-weight:700;color:var(--ink-strong)}
717
+ .lks-ui .lks14-ctxpanel-figs{margin-left:auto;color:var(--ink-muted);font-variant-numeric:tabular-nums}
718
+ .lks-ui .lks14-ctxpanel-bar{display:flex;height:4px;border-radius:999px;background:rgb(var(--ink-rgb)/0.1);overflow:hidden}
719
+ .lks-ui .lks14-ctxpanel-bar i{display:block;height:100%;background:var(--brand)}
720
+ .lks-ui .lks14-ctxpanel-bar i.tools{background:color-mix(in srgb,var(--brand) 55%,var(--warning))}
721
+ .lks-ui .lks14-ctxpanel-bar i.msgs{background:var(--warning)}
722
+ .lks-ui .lks14-ctxpanel-rows{margin-top:7px;display:flex;flex-direction:column;gap:3px}
723
+ .lks-ui .lks14-ctxpanel-row{display:flex;align-items:center;gap:6px;color:var(--ink-muted)}
724
+ .lks-ui .lks14-ctxpanel-row b{margin-left:auto;font-weight:600;color:var(--ink);font-variant-numeric:tabular-nums}
725
+ .lks-ui .lks14-ctxpanel-row .swatch{flex:none;width:7px;height:7px;border-radius:2px;background:var(--brand)}
726
+ .lks-ui .lks14-ctxpanel-row .swatch.tools{background:color-mix(in srgb,var(--brand) 55%,var(--warning))}
727
+ .lks-ui .lks14-ctxpanel-row .swatch.msgs{background:var(--warning)}
728
+ .lks-ui .lks14-ctxpanel-note{margin-top:6px;font-size:10px;color:var(--ink-faint)}
715
729
  /* E6: the model face chip + catalog popover */
716
730
  .lks-ui .lks-modelface{position:relative;display:flex}
717
731
  .lks-ui .lks-modelface-chip{display:flex;align-items:center;gap:4px;border:1px solid var(--border-faint);background:var(--surface-1);color:var(--ink-muted);font:inherit;font-size:10px;font-weight:700;border-radius:999px;padding:2.5px 8px;cursor:pointer;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:all .15s}
@@ -730,7 +744,6 @@ window.__ModuleLoader__.load({
730
744
  .lks-ui .lks-btn-attach:hover:not(:disabled){color:var(--ink);background:var(--surface-2)}
731
745
  .lks-ui .lks-btn-attach:disabled{opacity:.4;cursor:not-allowed}
732
746
  .lks-ui .lks-btn-attach:focus-visible{box-shadow:0 0 0 2px var(--business-primary);outline:none}
733
- .lks-ui .lks14-threadlabel{color:var(--ink-faint)}
734
747
  /* ── the distilled two-row head (0.19): identity+pct+delete over a hairline
735
748
  mastery bar, then search/review/worldswitch sharing one tools row ── */
736
749
  .lks-ui .lks14-railcard-row.main{min-width:0;max-width:100%;overflow:hidden}
@@ -747,6 +760,22 @@ window.__ModuleLoader__.load({
747
760
  .lks-ui .lks-audio-toggle{display:inline-flex;align-items:center;gap:4px}
748
761
  .lks-ui .lks-audio-toggle .lks-audio-label{display:none}
749
762
  .lks-ui .lks14-msgwrap:hover .lks-audio-toggle .lks-audio-label,.lks-ui .lks-audio-toggle:focus-visible .lks-audio-label{display:inline}
763
+ /* ── the thread switcher as one labeled chip + menu (0.19 owner note) ── */
764
+ .lks-ui .lks14-threadswitch{position:relative;margin-left:auto}
765
+ .lks-ui .lks14-threadchip{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border:none;border-radius:999px;background:rgb(var(--ink-rgb)/0.06);color:var(--ink-muted);font:inherit;font-size:11px;font-weight:700;cursor:pointer;transition:color .15s,background .15s;max-width:220px}
766
+ .lks-ui .lks14-threadchip:hover{background:rgb(var(--ink-rgb)/0.1);color:var(--ink)}
767
+ .lks-ui .lks14-threadchip-caret{font-size:9px}
768
+ .lks-ui .lks14-threadmenu{position:absolute;top:calc(100% + 6px);right:0;z-index:60;min-width:240px;max-width:300px;max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:1px;padding:4px;border-radius:10px;background:var(--surface-0);border:1px solid var(--border);box-shadow:0 12px 32px -8px rgb(var(--shadow-rgb)/0.5)}
769
+ .lks-ui .lks14-threadmenu-row{display:flex;align-items:center;gap:7px;width:100%;padding:6px 8px;border:none;border-radius:7px;background:none;font:inherit;font-size:12px;color:var(--ink);cursor:pointer;text-align:left}
770
+ .lks-ui .lks14-threadmenu-row:hover{background:rgb(var(--ink-rgb)/0.06)}
771
+ .lks-ui .lks14-threadmenu-row.on{background:rgb(var(--brand-rgb)/0.1)}
772
+ .lks-ui .lks14-threadmenu-row.on .lks14-threadmenu-title{color:var(--brand);font-weight:700}
773
+ .lks-ui .lks14-threadmenu-dot{flex:none;width:6px;height:6px;border-radius:999px;background:var(--ink-faint)}
774
+ .lks-ui .lks14-threadmenu-row.on .lks14-threadmenu-dot{background:var(--brand)}
775
+ .lks-ui .lks14-threadmenu-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
776
+ .lks-ui .lks14-threadmenu-now{flex:none;font-size:10px;font-weight:800;color:var(--brand)}
777
+
778
+
750
779
  /* ── light-mode chrome sweep: the rail-head glass family hardcoded dark for
751
780
  the map era; the list rail follows the theme, so the chrome reads
752
781
  ink-on-surface here too ── */
@@ -785,6 +814,8 @@ window.__ModuleLoader__.load({
785
814
  .lks-ui[data-lks-theme='light'] .lks14-importprog-step.pending .lks14-importprog-text{color:var(--ink-faint)}
786
815
  .lks-ui[data-lks-theme='light'] .lks14-importprog-step.pending .lks14-importprog-dots{border-color:var(--border)}
787
816
  .lks-ui[data-lks-theme='light'] .lks14-importprog-elapsed{color:var(--ink-muted)}
817
+ .lks-ui[data-lks-theme='light'] .lks14-threadmenu{background:rgb(255 255 255/0.98);border-color:var(--border-faint);box-shadow:0 12px 32px -8px rgb(0 0 0/0.18)}
818
+ .lks-ui[data-lks-theme='light'] .lks14-ctxpanel{background:rgb(255 255 255/0.98);box-shadow:0 8px 24px rgb(0 0 0/0.12)}
788
819
  /* ambient glyph motion sleeps under reduced-motion (A7 never had a guard) */
789
820
  @media (prefers-reduced-motion: reduce){
790
821
  .lks-ui .lks-lessorow.st-mastered .lks-lessorow-glyph svg,.lks-ui .lks-hdr-xp .lks-hdr-glyph,.lks-ui .lks-hdr-streak .lks-hdr-glyph{animation:none}
@@ -953,19 +984,6 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
953
984
 
954
985
 
955
986
 
956
- /* the companion creature (P7): bottom-right of the panel; gentle bob idle,
957
- bounce while talking, pop on celebration; reduced-motion = static (a11y). */
958
- .lks-companion{position:absolute;right:18px;bottom:14px;z-index:20;width:56px;height:56px;cursor:pointer;filter:drop-shadow(0 4px 10px rgba(0,0,0,.18));animation:lks-comp-bob 3.2s ease-in-out infinite}
959
- .lks-companion:focus-visible{outline:none;filter:drop-shadow(0 0 0 2px var(--dsw-alias-state-business-primary)) drop-shadow(0 4px 10px rgba(0,0,0,.18))}
960
- .lks-companion.mood-talking{animation:lks-comp-talk .6s ease-in-out infinite}
961
- .lks-companion.mood-celebrating{animation:lks-comp-pop .5s cubic-bezier(.34,1.56,.64,1) 3}
962
- .lks-companion.mood-encouraging{animation:lks-comp-nudge 1.2s ease-in-out 2}
963
- @keyframes lks-comp-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
964
- @keyframes lks-comp-talk{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-7px) scale(1.06)}}
965
- @keyframes lks-comp-pop{0%{transform:scale(1)}40%{transform:scale(1.25) rotate(-8deg)}100%{transform:scale(1)}}
966
- @keyframes lks-comp-nudge{0%,100%{transform:translateX(0)}25%{transform:translateX(-5px) rotate(-4deg)}75%{transform:translateX(5px) rotate(4deg)}}
967
- @media (prefers-reduced-motion:reduce){.lks-companion{animation:none}}
968
-
969
987
  /* P5: proposal banner (ConfirmCard semantics) + thinking row + narrow switch */
970
988
  .lks-propbanner{flex:none;display:flex;align-items:center;gap:8px;margin:8px 12px 0;padding:8px 12px;border-radius:10px;background:var(--dsw-alias-state-business-tertiary);border:1px solid var(--dsw-alias-border-l2);font-size:12.5px;color:var(--dsw-alias-label-primary-bluish)}
971
989
  .lks-propbanner .lks-propbanner-why{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -1017,7 +1035,6 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
1017
1035
 
1018
1036
  /* rail search results panel (P4, upstream CourseSearchPanel) */
1019
1037
  .lks14-searchpanel{margin:4px 0 6px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}
1020
- .lks14-threadlabel{flex:none;padding:8px 20px 0;font-size:11px;font-weight:600;color:var(--dsw-alias-label-tertiary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1021
1038
  .lks14-searchrow{display:flex;flex-direction:column;align-items:flex-start;gap:1px;width:100%;text-align:left;font:inherit;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--dsw-alias-border-l1)}
1022
1039
  .lks14-searchrow:last-child{border-bottom:none}
1023
1040
  .lks14-searchrow:hover{background:var(--dsw-alias-interactive-bg-hover)}
@@ -2046,7 +2063,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2046
2063
  "note.src.learner": "我的笔记",
2047
2064
  "error.send": "消息没有送达——请稍后再试一次。",
2048
2065
  "error.action": "操作没有完成——请稍后再试。",
2049
- "thread.label": "「{title}」的对话",
2050
2066
  "thread.streamingBadge": "AI 正在输出",
2051
2067
  "map.search.label": "搜索",
2052
2068
  "map.review.label": "复习",
@@ -2063,15 +2079,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2063
2079
  "quiz.submit": "提交",
2064
2080
  "proposal.applied": "《{lesson}》已采纳提前毕业提议",
2065
2081
  "proposal.rejected": "《{lesson}》提议已忽略,继续保持练习",
2066
- "companion.poke": "戳一戳伴学伙伴",
2067
- "companion.poked": "伴学伙伴向你眨了眨眼",
2068
- "companion.form.ember": "小焰",
2069
- "companion.form.frost": "霜绒",
2070
- "companion.form.moss": "苔芽",
2071
- "companion.form.star": "星尘",
2072
- "companion.form.ink": "墨墨",
2073
- "settings.companion": "伴学伙伴",
2074
- "settings.companion.hint": "选择学习面板右下角的伴学生物形态。",
2075
2082
  "quiz.msg.explain-wrong": "我刚才有题答错了,帮我讲讲为什么错、正确的思路是什么。",
2076
2083
  "quiz.msg.retry": "再来一组类似的题巩固一下。",
2077
2084
  "quiz.msg.go-deeper": "这组我答得不错,帮我深入讲讲背后的原理和容易混淆的地方。",
@@ -2130,9 +2137,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2130
2137
  "palette.study": "开始学习",
2131
2138
  "palette.kind.action": "操作",
2132
2139
  "palette.empty": "没有匹配项",
2140
+ "threads.open": "{n} 条对话",
2141
+ "threads.now": "当前",
2133
2142
  "threads.label": "课时线程",
2134
2143
  "meter.label": "上下文用量",
2135
- "meter.estimate": " {n} tok(按事件字符估算)",
2144
+ "meter.aria": "上下文已用 {pct}%",
2145
+ "meter.brk.system": "系统提示",
2146
+ "meter.brk.tools": "工具",
2147
+ "meter.brk.messages": "对话",
2148
+ "meter.estimate.note": "按事件字符估算",
2136
2149
  "model.unknown": "当前模型",
2137
2150
  "model.switched": "已切换到 {model}",
2138
2151
  "model.switchFailed": "切换失败——请看宿主模型设置",
@@ -2198,7 +2211,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2198
2211
  "quiz.answer": "选 {letter}:{text}",
2199
2212
  "tutor.dormant": "学习模式已关闭 — 在下方对话区点「开始学习」即可唤醒导师。",
2200
2213
  "tutor.empty": "对话会出现在这里",
2201
- "tutor.empty.hint": "在下方输入框和导师说话",
2214
+ "tutor.empty.hint": "和导师聊聊这一课",
2202
2215
  "proposal.text": "导师提议你已掌握「{lesson}」:{rationale}",
2203
2216
  "proposal.accept": "接受",
2204
2217
  "proposal.decline": "再练练",
@@ -2393,7 +2406,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2393
2406
  "note.src.learner": "My note",
2394
2407
  "error.send": "The message did not go through — please try again.",
2395
2408
  "error.action": "The action did not complete — please try again.",
2396
- "thread.label": "Conversation · {title}",
2397
2409
  "thread.streamingBadge": "AI is responding",
2398
2410
  "map.search.label": "Search",
2399
2411
  "map.review.label": "Review",
@@ -2410,15 +2422,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2410
2422
  "quiz.submit": "Submit",
2411
2423
  "proposal.applied": "Graduation proposal for \"{lesson}\" accepted",
2412
2424
  "proposal.rejected": "Proposal for \"{lesson}\" declined — keep practicing",
2413
- "companion.poke": "Poke the companion",
2414
- "companion.poked": "The companion winks at you",
2415
- "companion.form.ember": "Ember",
2416
- "companion.form.frost": "Frost",
2417
- "companion.form.moss": "Moss",
2418
- "companion.form.star": "Stardust",
2419
- "companion.form.ink": "Ink",
2420
- "settings.companion": "Companion",
2421
- "settings.companion.hint": "Choose the companion creature in the study panel corner.",
2422
2425
  "quiz.msg.explain-wrong": "I got some questions wrong — walk me through why and the right line of thinking.",
2423
2426
  "quiz.msg.retry": "Give me another similar set to consolidate.",
2424
2427
  "quiz.msg.go-deeper": "I did well on this set — go deeper into the principles and common confusions.",
@@ -2478,8 +2481,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2478
2481
  "palette.kind.action": "Action",
2479
2482
  "palette.empty": "No matches",
2480
2483
  "threads.label": "Lesson threads",
2484
+ "threads.open": "{n} threads",
2485
+ "threads.now": "current",
2481
2486
  "meter.label": "Context usage",
2482
- "meter.estimate": "~{n} tok (char-volume estimate)",
2487
+ "meter.aria": "{pct}% of context used",
2488
+ "meter.brk.system": "System prompt",
2489
+ "meter.brk.tools": "Tools",
2490
+ "meter.brk.messages": "Messages",
2491
+ "meter.estimate.note": "Char-volume estimate",
2483
2492
  "model.unknown": "Current model",
2484
2493
  "model.switched": "Switched to {model}",
2485
2494
  "model.switchFailed": "Switch failed — check the host model settings",
@@ -3191,6 +3200,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3191
3200
  */
3192
3201
  function attachCodeHeader(wrap, lang) {
3193
3202
  if (wrap.parentElement?.querySelector(".lks-codehead") !== null) return;
3203
+ wrap.closest(".lks-codeblock")?.querySelector(":scope > .lks-codeblock-head")?.remove();
3194
3204
  const head = document.createElement("div");
3195
3205
  head.className = "lks-codehead";
3196
3206
  const label = document.createElement("span");
@@ -3291,6 +3301,220 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3291
3301
  } catch {}
3292
3302
  }
3293
3303
  //#endregion
3304
+ //#region src/client/views.tsx
3305
+ /**
3306
+ * Shared pure helpers for the study panel's pieces: chat-row shapes, tree
3307
+ * status copy, quiz-option extraction, and section default expansion. The
3308
+ * panel itself lives in `panel.tsx`; since 0.14.0 nothing here touches the
3309
+ * host composer or the conversation view.
3310
+ * @module dsh-plugin-lookatstudy/client/views
3311
+ */
3312
+ /** Small inline error surface for failed write actions (shared with settings). */
3313
+ function ActionError({ error }) {
3314
+ if (error === null) return null;
3315
+ return (0, react.createElement)("div", { className: "lks-propcard-err" }, error);
3316
+ }
3317
+ /**
3318
+ * Tooltip text for one course-tree status glyph. Pure (the translator is an
3319
+ * optional parameter defaulting to the active one, evaluated per call).
3320
+ */
3321
+ function statusTitle(kind, status, t = tr) {
3322
+ if (kind === "exam") return t("status.exam");
3323
+ switch (status) {
3324
+ case "mastered": return t("status.mastered");
3325
+ case "in_progress": return t("status.in_progress");
3326
+ case "available": return t("status.available");
3327
+ default: return t("status.locked");
3328
+ }
3329
+ }
3330
+ /**
3331
+ * Extract the last quiz option block (2–4 consecutive `A. …` `B. …` lines,
3332
+ * letters strictly consecutive from A) from an assistant reply — the raw text
3333
+ * of the tutor's question becomes the clickable answer surface. Pure.
3334
+ */
3335
+ function quizOptions(text) {
3336
+ const runs = [];
3337
+ let run = [];
3338
+ const flush = () => {
3339
+ if (run.length >= 2) runs.push(run);
3340
+ run = [];
3341
+ };
3342
+ for (const line of text.split("\n")) {
3343
+ const plain = /^([A-D])[.、:)]\s*(.+)$/.exec(line.trim());
3344
+ const table = /^\|\s*\*{0,2}([A-D])\*{0,2}\s*\|\s*(.+?)\s*\|$/.exec(line.trim());
3345
+ const match = plain ?? (table !== null ? [
3346
+ table[0],
3347
+ table[1],
3348
+ table[2].replaceAll("**", "").replaceAll("`", "")
3349
+ ] : null);
3350
+ if (match === null) {
3351
+ flush();
3352
+ continue;
3353
+ }
3354
+ const nextLetter = run.length === 0 ? "A" : String.fromCharCode(run[run.length - 1].letter.charCodeAt(0) + 1);
3355
+ if (match[1] === nextLetter) run.push({
3356
+ letter: match[1],
3357
+ text: match[2]
3358
+ });
3359
+ else {
3360
+ flush();
3361
+ if (match[1] === "A") run.push({
3362
+ letter: match[1],
3363
+ text: match[2]
3364
+ });
3365
+ }
3366
+ }
3367
+ flush();
3368
+ return runs.at(-1) ?? [];
3369
+ }
3370
+ /**
3371
+ * Merge the rail's two search tracks into the results panel: title hits
3372
+ * (lessons whose name matches — they are also filtered into the tree) first,
3373
+ * then full-text hits not already covered, snippets carried for the body
3374
+ * matches. Pure; empty queries yield no panel.
3375
+ */
3376
+ function mergeRailSearch(query, lessons, textHits) {
3377
+ const keys = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
3378
+ if (keys.length === 0) return [];
3379
+ const rows = [];
3380
+ const seen = /* @__PURE__ */ new Set();
3381
+ const matches = (title) => keys.every((k) => title.toLowerCase().includes(k));
3382
+ for (const lesson of lessons) {
3383
+ if (lesson.kind === "exam" || lesson.status === "locked" || !matches(lesson.title)) continue;
3384
+ rows.push({
3385
+ lessonId: lesson.id,
3386
+ title: lesson.title,
3387
+ snippet: "",
3388
+ courseTitle: "",
3389
+ fromTitle: true
3390
+ });
3391
+ seen.add(lesson.id);
3392
+ }
3393
+ for (const hit of textHits) {
3394
+ if (seen.has(hit.lessonId)) continue;
3395
+ rows.push({
3396
+ lessonId: hit.lessonId,
3397
+ title: hit.lessonTitle,
3398
+ snippet: hit.snippet,
3399
+ courseTitle: hit.courseTitle,
3400
+ fromTitle: false
3401
+ });
3402
+ seen.add(hit.lessonId);
3403
+ }
3404
+ return rows.slice(0, 12);
3405
+ }
3406
+ /**
3407
+ * A section's effective openness: the user's explicit toggle (persisted per
3408
+ * course) overrides the frontier-based default. Pure.
3409
+ */
3410
+ function effectiveOpen(sectionTitle, defaultOpen, overrides) {
3411
+ return overrides[sectionTitle] ?? defaultOpen;
3412
+ }
3413
+ /**
3414
+ * The narrow-mode pane picker: only valid ids, remembering the last choice
3415
+ * (upstream's narrow switcher semantics). Pure.
3416
+ */
3417
+ function pickNarrowPane(stored) {
3418
+ return stored === "rail" || stored === "chat" || stored === "note" ? stored : "chat";
3419
+ }
3420
+ /**
3421
+ * Default expansion for one rail section: collapsed when every study lesson is
3422
+ * done (mastered) or not yet reachable (locked). The focus lesson's section
3423
+ * stays open; exam nodes never force a section open. Pure.
3424
+ */
3425
+ function sectionDefaultOpen(section) {
3426
+ return section.lessons.some((l) => l.focus || l.kind !== "exam" && l.status !== "mastered" && l.status !== "locked");
3427
+ }
3428
+ /**
3429
+ * C1 sticky-follow: the stream follows new rows only while the reader is
3430
+ * within `tolerance` of the bottom (upstream's 80px rule — scroll up and the
3431
+ * feed stops chasing you). Pure.
3432
+ */
3433
+ function isStuck(scrollTop, scrollHeight, clientHeight, tolerance = 80) {
3434
+ return scrollHeight - scrollTop - clientHeight <= tolerance;
3435
+ }
3436
+ /**
3437
+ * C12 touch pane swipe: a horizontal flick (>50px, dominance >2x) switches the
3438
+ * narrow pane neighbor; anything else is not a pane gesture. Pure
3439
+ * (upstream swipeTarget semantics).
3440
+ */
3441
+ function swipePane(pane, dx, dy) {
3442
+ if (Math.abs(dx) < 50 || Math.abs(dx) <= 2 * Math.abs(dy)) return null;
3443
+ const order = [
3444
+ "rail",
3445
+ "chat",
3446
+ "note"
3447
+ ];
3448
+ const idx = order.indexOf(pane);
3449
+ if (idx < 0) return null;
3450
+ const next = dx < 0 ? idx + 1 : idx - 1;
3451
+ return next >= 0 && next < order.length ? order[next] : null;
3452
+ }
3453
+ /**
3454
+ * C12 selection-popover settle: coarse pointers need a longer quiet window
3455
+ * (upstream's 600/250 two-tier). Pure.
3456
+ */
3457
+ function settleMs(coarse) {
3458
+ return coarse ? 600 : 250;
3459
+ }
3460
+ /**
3461
+ * C9 interleaved review: pick one due lesson at random (Math.random; empty in,
3462
+ * null out). Pure.
3463
+ */
3464
+ function pickRandomDue(items) {
3465
+ if (items.length === 0) return null;
3466
+ return items[Math.floor(Math.random() * items.length)] ?? null;
3467
+ }
3468
+ /**
3469
+ * Display-side section-title humanizer (0.19 critique round): import-design
3470
+ * section ids arrive as repo-folder slugs ("0-course-setup", "1-Intro") —
3471
+ * strip the leading episode number, hyphen/underscore runs become spaces.
3472
+ * Keys, anchors, and ids stay raw; this is presentation only. Never returns
3473
+ * an empty string (a title that humanizes to nothing keeps its raw form).
3474
+ */
3475
+ function humanizeSectionTitle(raw) {
3476
+ const t = raw.trim().replace(/^\d+[.\-_ ]+/, "").replace(/[\-_]+/g, " ").replace(/\s+/g, " ").trim();
3477
+ return t === "" ? raw.trim() : t;
3478
+ }
3479
+ /**
3480
+ * Proportion the panel's bar through the host's heuristic composition, in the
3481
+ * segment order system → tools → messages; without a breakdown a single
3482
+ * brand segment carries the whole reading. A zero-width part is dropped.
3483
+ */
3484
+ function ctxSegments(pct, breakdown) {
3485
+ if (pct <= 0) return [];
3486
+ const total = breakdown === null ? 0 : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens;
3487
+ if (total <= 0) return [{
3488
+ key: "total",
3489
+ cls: "",
3490
+ width: pct
3491
+ }];
3492
+ return [
3493
+ ["sys", breakdown.systemTokens],
3494
+ ["tools", breakdown.toolsTokens],
3495
+ ["msgs", breakdown.messageTokens]
3496
+ ].map(([key, tokens]) => ({
3497
+ key,
3498
+ cls: key,
3499
+ width: pct * tokens / total
3500
+ })).filter((s) => s.width > 0);
3501
+ }
3502
+ /** Compact token figure for the context panel (host formatTokens arrangement). */
3503
+ function fmtTokens(value) {
3504
+ if (value < 1e3) return String(value);
3505
+ if (value < 1e6) return `${String(Math.round(value / 100) / 10)}K`;
3506
+ return `${String(Math.round(value / 1e5) / 10)}M`;
3507
+ }
3508
+ /**
3509
+ * Panel-facing error text (0.19 critique round): raw err.message (host RPC
3510
+ * internals like "prompt rejected: …") never renders in the DOM — the user
3511
+ * gets one honest locale line, the raw error goes to the console.
3512
+ */
3513
+ function friendlyError(err, kind = "action", t = tr) {
3514
+ console.error("[lookatstudy]", err);
3515
+ return t(kind === "send" ? "error.send" : "error.action");
3516
+ }
3517
+ //#endregion
3294
3518
  //#region src/vendor/cmap-elk-layout.ts
3295
3519
  /** 字符宽度估算:CJK 全宽,ASCII 约 0.58em,其余(BBOP…)同 ASCII。 */
3296
3520
  function estTextWidth(text, fontSize = 13) {
@@ -5562,191 +5786,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5562
5786
  }
5563
5787
  }
5564
5788
  //#endregion
5565
- //#region src/client/companion.tsx
5566
- /**
5567
- * The companion creature — the upstream 伴学生物's minimal honest DOM port
5568
- * (0.15.0 P7): a small being in the panel's bottom-right corner with five
5569
- * forms, a pure mood machine (idle / talking / celebrating / encouraging),
5570
- * and the a11y iron rule carried over — reduced-motion renders it static.
5571
- * Upstream's Electron-native life (mic envelopes, typing squeeze, drag-throw)
5572
- * has no web-panel equivalent and stays unported by design.
5573
- * @module dsh-plugin-lookatstudy/client/companion
5574
- */
5575
- const COMPANION_FORMS = [
5576
- {
5577
- id: "ember",
5578
- labelKey: "companion.form.ember",
5579
- body: "#f59e0b",
5580
- accent: "#dc2626"
5581
- },
5582
- {
5583
- id: "frost",
5584
- labelKey: "companion.form.frost",
5585
- body: "#7dd3fc",
5586
- accent: "#0284c7"
5587
- },
5588
- {
5589
- id: "moss",
5590
- labelKey: "companion.form.moss",
5591
- body: "#86efac",
5592
- accent: "#16a34a"
5593
- },
5594
- {
5595
- id: "star",
5596
- labelKey: "companion.form.star",
5597
- body: "#c4b5fd",
5598
- accent: "#7c3aed"
5599
- },
5600
- {
5601
- id: "ink",
5602
- labelKey: "companion.form.ink",
5603
- body: "#cbd5e1",
5604
- accent: "#334155"
5605
- }
5606
- ];
5607
- /** Normalize a stored form id; junk falls back to 小焰 (upstream's default). */
5608
- function normalizeCompanionForm(stored) {
5609
- return COMPANION_FORMS.some((f) => f.id === stored) ? stored : "ember";
5610
- }
5611
- function storedCompanionForm() {
5612
- try {
5613
- return normalizeCompanionForm(localStorage.getItem("dsh-plugin-lookatstudy:companion-form"));
5614
- } catch {
5615
- return "ember";
5616
- }
5617
- }
5618
- function storeCompanionForm(id) {
5619
- try {
5620
- localStorage.setItem("dsh-plugin-lookatstudy:companion-form", id);
5621
- } catch {}
5622
- }
5623
- /**
5624
- * The mood machine — pure. `celebrate` and `encourage` are momentary (the
5625
- * view decays them back through `decay`); `talking` latches while read-aloud
5626
- * runs; poke is a self-echoing celebration.
5627
- */
5628
- function nextCompanionMood(current, event) {
5629
- switch (event) {
5630
- case "talk-start": return "talking";
5631
- case "talk-end": return current === "talking" ? "idle" : current;
5632
- case "celebrate":
5633
- case "poke": return "celebrating";
5634
- case "encourage": return "encouraging";
5635
- case "decay": return current === "celebrating" || current === "encouraging" ? "idle" : current;
5636
- default: return current;
5637
- }
5638
- }
5639
- /** One creature face — the shared skin every form wears. */
5640
- function CompanionFace({ form, mood }) {
5641
- const skin = COMPANION_FORMS.find((f) => f.id === form) ?? COMPANION_FORMS[0];
5642
- const eye = mood === "celebrating" ? "M -3.4 0 q 1.7 -2.6 3.4 0" : "M -3.4 -1.4 a 1.4 1.4 0 1 0 0.01 0";
5643
- const mouth = mood === "celebrating" ? "M 20 21 q 4 4 8 0" : mood === "encouraging" ? "M 21 22 q 3 -2.5 6 0" : "M 21 21 q 3 2.4 6 0";
5644
- return (0, react.createElement)("svg", {
5645
- width: 64,
5646
- height: 64,
5647
- viewBox: "0 0 64 64",
5648
- "aria-hidden": "true"
5649
- }, (0, react.createElement)("g", null, (0, react.createElement)("circle", {
5650
- cx: 32,
5651
- cy: 34,
5652
- r: 20,
5653
- fill: skin.body,
5654
- stroke: skin.accent,
5655
- strokeWidth: 2
5656
- }), form === "ember" ? (0, react.createElement)("path", {
5657
- d: "M32 4 q6 8 3 12 q8 -2 9 6 q4 -1 4 4 l-2 4 H18 l-2 -4 q0 -5 4 -4 q1 -8 9 -6 q-3 -4 3 -12z",
5658
- fill: skin.accent,
5659
- opacity: .85
5660
- }) : form === "star" ? (0, react.createElement)("path", {
5661
- d: "M32 2 l4 8 9 1 -6.5 6 1.5 9 -8.5 -4.5 -8 4.5 1.5 -9 -6.5 -6 9 -1z",
5662
- fill: skin.accent,
5663
- opacity: .85
5664
- }) : form === "moss" ? (0, react.createElement)("path", {
5665
- d: "M32 2 q-2 6 2 10 q-8 0 -8 6 H40 q0 -6 -8 -6 q4 -4 0 -10z",
5666
- fill: skin.accent,
5667
- opacity: .85
5668
- }) : form === "ink" ? (0, react.createElement)("path", {
5669
- d: "M32 2 q8 12 8 18 a8 8 0 1 1 -16 0 q0 -6 8 -18z",
5670
- fill: skin.accent,
5671
- opacity: .55
5672
- }) : (0, react.createElement)("path", {
5673
- d: "M18 12 l3 5 5 -1 -1 5 5 3 -5 3 1 5 -5 -1 -3 5 -3 -5 -5 1 1 -5 -5 -3 5 -3 -1 -5 5 1z",
5674
- fill: skin.accent,
5675
- opacity: .7
5676
- }), (0, react.createElement)("path", {
5677
- d: eye,
5678
- transform: "translate(25 30)",
5679
- stroke: skin.accent,
5680
- strokeWidth: 1.6,
5681
- fill: mood === "celebrating" ? "none" : skin.accent,
5682
- strokeLinecap: "round"
5683
- }), (0, react.createElement)("path", {
5684
- d: eye,
5685
- transform: "translate(14 30)",
5686
- stroke: skin.accent,
5687
- strokeWidth: 1.6,
5688
- fill: mood === "celebrating" ? "none" : skin.accent,
5689
- strokeLinecap: "round"
5690
- }), (0, react.createElement)("path", {
5691
- d: mouth,
5692
- stroke: skin.accent,
5693
- strokeWidth: 1.8,
5694
- fill: "none",
5695
- strokeLinecap: "round"
5696
- })));
5697
- }
5698
- /** The creature: fixed bottom-right inside the panel, mood-animated, pokeable. */
5699
- function Companion({ mood, onPoke }) {
5700
- const form = storedCompanionForm();
5701
- return (0, react.createElement)("div", {
5702
- className: `lks-companion mood-${mood}`,
5703
- "data-lks-companion": form,
5704
- title: tr("companion.poke"),
5705
- role: "button",
5706
- tabIndex: 0,
5707
- "aria-label": tr("companion.poke"),
5708
- onClick: () => {
5709
- onPoke?.();
5710
- },
5711
- onKeyDown: (e) => {
5712
- if (e.key === "Enter" || e.key === " ") {
5713
- e.preventDefault();
5714
- onPoke?.();
5715
- }
5716
- }
5717
- }, (0, react.createElement)(CompanionFace, {
5718
- form,
5719
- mood
5720
- }));
5721
- }
5722
- /** The hook driving one creature instance: event → mood, momentary decay. */
5723
- function useCompanionMood() {
5724
- const [mood, setMood] = (0, react.useState)("idle");
5725
- const moodRef = (0, react.useRef)(mood);
5726
- moodRef.current = mood;
5727
- const emit = (event) => {
5728
- const next = nextCompanionMood(moodRef.current, event);
5729
- if (next !== moodRef.current) {
5730
- moodRef.current = next;
5731
- setMood(next);
5732
- }
5733
- };
5734
- (0, react.useEffect)(() => {
5735
- if (mood !== "celebrating" && mood !== "encouraging") return;
5736
- const timer = setTimeout(() => {
5737
- moodRef.current = nextCompanionMood(moodRef.current, "decay");
5738
- setMood(moodRef.current);
5739
- }, 2400);
5740
- return () => {
5741
- clearTimeout(timer);
5742
- };
5743
- }, [mood]);
5744
- return {
5745
- mood,
5746
- emit
5747
- };
5748
- }
5749
- //#endregion
5750
5789
  //#region src/client/highlights.ts
5751
5790
  /**
5752
5791
  * Locate a selection inside the model text: exact `selected` text search,
@@ -5874,191 +5913,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5874
5913
  return applied;
5875
5914
  }
5876
5915
  //#endregion
5877
- //#region src/client/views.tsx
5878
- /**
5879
- * Shared pure helpers for the study panel's pieces: chat-row shapes, tree
5880
- * status copy, quiz-option extraction, and section default expansion. The
5881
- * panel itself lives in `panel.tsx`; since 0.14.0 nothing here touches the
5882
- * host composer or the conversation view.
5883
- * @module dsh-plugin-lookatstudy/client/views
5884
- */
5885
- /** Small inline error surface for failed write actions (shared with settings). */
5886
- function ActionError({ error }) {
5887
- if (error === null) return null;
5888
- return (0, react.createElement)("div", { className: "lks-propcard-err" }, error);
5889
- }
5890
- /**
5891
- * Tooltip text for one course-tree status glyph. Pure (the translator is an
5892
- * optional parameter defaulting to the active one, evaluated per call).
5893
- */
5894
- function statusTitle(kind, status, t = tr) {
5895
- if (kind === "exam") return t("status.exam");
5896
- switch (status) {
5897
- case "mastered": return t("status.mastered");
5898
- case "in_progress": return t("status.in_progress");
5899
- case "available": return t("status.available");
5900
- default: return t("status.locked");
5901
- }
5902
- }
5903
- /**
5904
- * Extract the last quiz option block (2–4 consecutive `A. …` `B. …` lines,
5905
- * letters strictly consecutive from A) from an assistant reply — the raw text
5906
- * of the tutor's question becomes the clickable answer surface. Pure.
5907
- */
5908
- function quizOptions(text) {
5909
- const runs = [];
5910
- let run = [];
5911
- const flush = () => {
5912
- if (run.length >= 2) runs.push(run);
5913
- run = [];
5914
- };
5915
- for (const line of text.split("\n")) {
5916
- const plain = /^([A-D])[.、:)]\s*(.+)$/.exec(line.trim());
5917
- const table = /^\|\s*\*{0,2}([A-D])\*{0,2}\s*\|\s*(.+?)\s*\|$/.exec(line.trim());
5918
- const match = plain ?? (table !== null ? [
5919
- table[0],
5920
- table[1],
5921
- table[2].replaceAll("**", "").replaceAll("`", "")
5922
- ] : null);
5923
- if (match === null) {
5924
- flush();
5925
- continue;
5926
- }
5927
- const nextLetter = run.length === 0 ? "A" : String.fromCharCode(run[run.length - 1].letter.charCodeAt(0) + 1);
5928
- if (match[1] === nextLetter) run.push({
5929
- letter: match[1],
5930
- text: match[2]
5931
- });
5932
- else {
5933
- flush();
5934
- if (match[1] === "A") run.push({
5935
- letter: match[1],
5936
- text: match[2]
5937
- });
5938
- }
5939
- }
5940
- flush();
5941
- return runs.at(-1) ?? [];
5942
- }
5943
- /**
5944
- * Merge the rail's two search tracks into the results panel: title hits
5945
- * (lessons whose name matches — they are also filtered into the tree) first,
5946
- * then full-text hits not already covered, snippets carried for the body
5947
- * matches. Pure; empty queries yield no panel.
5948
- */
5949
- function mergeRailSearch(query, lessons, textHits) {
5950
- const keys = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
5951
- if (keys.length === 0) return [];
5952
- const rows = [];
5953
- const seen = /* @__PURE__ */ new Set();
5954
- const matches = (title) => keys.every((k) => title.toLowerCase().includes(k));
5955
- for (const lesson of lessons) {
5956
- if (lesson.kind === "exam" || lesson.status === "locked" || !matches(lesson.title)) continue;
5957
- rows.push({
5958
- lessonId: lesson.id,
5959
- title: lesson.title,
5960
- snippet: "",
5961
- courseTitle: "",
5962
- fromTitle: true
5963
- });
5964
- seen.add(lesson.id);
5965
- }
5966
- for (const hit of textHits) {
5967
- if (seen.has(hit.lessonId)) continue;
5968
- rows.push({
5969
- lessonId: hit.lessonId,
5970
- title: hit.lessonTitle,
5971
- snippet: hit.snippet,
5972
- courseTitle: hit.courseTitle,
5973
- fromTitle: false
5974
- });
5975
- seen.add(hit.lessonId);
5976
- }
5977
- return rows.slice(0, 12);
5978
- }
5979
- /**
5980
- * A section's effective openness: the user's explicit toggle (persisted per
5981
- * course) overrides the frontier-based default. Pure.
5982
- */
5983
- function effectiveOpen(sectionTitle, defaultOpen, overrides) {
5984
- return overrides[sectionTitle] ?? defaultOpen;
5985
- }
5986
- /**
5987
- * The narrow-mode pane picker: only valid ids, remembering the last choice
5988
- * (upstream's narrow switcher semantics). Pure.
5989
- */
5990
- function pickNarrowPane(stored) {
5991
- return stored === "rail" || stored === "chat" || stored === "note" ? stored : "chat";
5992
- }
5993
- /**
5994
- * Default expansion for one rail section: collapsed when every study lesson is
5995
- * done (mastered) or not yet reachable (locked). The focus lesson's section
5996
- * stays open; exam nodes never force a section open. Pure.
5997
- */
5998
- function sectionDefaultOpen(section) {
5999
- return section.lessons.some((l) => l.focus || l.kind !== "exam" && l.status !== "mastered" && l.status !== "locked");
6000
- }
6001
- /**
6002
- * C1 sticky-follow: the stream follows new rows only while the reader is
6003
- * within `tolerance` of the bottom (upstream's 80px rule — scroll up and the
6004
- * feed stops chasing you). Pure.
6005
- */
6006
- function isStuck(scrollTop, scrollHeight, clientHeight, tolerance = 80) {
6007
- return scrollHeight - scrollTop - clientHeight <= tolerance;
6008
- }
6009
- /**
6010
- * C12 touch pane swipe: a horizontal flick (>50px, dominance >2x) switches the
6011
- * narrow pane neighbor; anything else is not a pane gesture. Pure
6012
- * (upstream swipeTarget semantics).
6013
- */
6014
- function swipePane(pane, dx, dy) {
6015
- if (Math.abs(dx) < 50 || Math.abs(dx) <= 2 * Math.abs(dy)) return null;
6016
- const order = [
6017
- "rail",
6018
- "chat",
6019
- "note"
6020
- ];
6021
- const idx = order.indexOf(pane);
6022
- if (idx < 0) return null;
6023
- const next = dx < 0 ? idx + 1 : idx - 1;
6024
- return next >= 0 && next < order.length ? order[next] : null;
6025
- }
6026
- /**
6027
- * C12 selection-popover settle: coarse pointers need a longer quiet window
6028
- * (upstream's 600/250 two-tier). Pure.
6029
- */
6030
- function settleMs(coarse) {
6031
- return coarse ? 600 : 250;
6032
- }
6033
- /**
6034
- * C9 interleaved review: pick one due lesson at random (Math.random; empty in,
6035
- * null out). Pure.
6036
- */
6037
- function pickRandomDue(items) {
6038
- if (items.length === 0) return null;
6039
- return items[Math.floor(Math.random() * items.length)] ?? null;
6040
- }
6041
- /**
6042
- * Display-side section-title humanizer (0.19 critique round): import-design
6043
- * section ids arrive as repo-folder slugs ("0-course-setup", "1-Intro") —
6044
- * strip the leading episode number, hyphen/underscore runs become spaces.
6045
- * Keys, anchors, and ids stay raw; this is presentation only. Never returns
6046
- * an empty string (a title that humanizes to nothing keeps its raw form).
6047
- */
6048
- function humanizeSectionTitle(raw) {
6049
- const t = raw.trim().replace(/^\d+[.\-_ ]+/, "").replace(/[\-_]+/g, " ").replace(/\s+/g, " ").trim();
6050
- return t === "" ? raw.trim() : t;
6051
- }
6052
- /**
6053
- * Panel-facing error text (0.19 critique round): raw err.message (host RPC
6054
- * internals like "prompt rejected: …") never renders in the DOM — the user
6055
- * gets one honest locale line, the raw error goes to the console.
6056
- */
6057
- function friendlyError(err, kind = "action", t = tr) {
6058
- console.error("[lookatstudy]", err);
6059
- return t(kind === "send" ? "error.send" : "error.action");
6060
- }
6061
- //#endregion
6062
5916
  //#region src/client/maprail.tsx
6063
5917
  /**
6064
5918
  * RailList — the course rail as a quiet list (owner decision 2026-09-08: the
@@ -6173,6 +6027,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6173
6027
  y
6174
6028
  });
6175
6029
  };
6030
+ const panelZoom = () => {
6031
+ const z = parseFloat(document.querySelector(".lks-ui")?.style.zoom ?? "1");
6032
+ return Number.isFinite(z) && z > 0 ? z : 1;
6033
+ };
6176
6034
  const onMove = (e) => {
6177
6035
  if (coarse) return;
6178
6036
  const el = target(e);
@@ -6180,7 +6038,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6180
6038
  setTip(null);
6181
6039
  return;
6182
6040
  }
6183
- show(el, e.clientX + 12, e.clientY + 16);
6041
+ const z = panelZoom();
6042
+ show(el, (e.clientX + 10) / z, (e.clientY + 12) / z);
6184
6043
  };
6185
6044
  const onLeave = () => {
6186
6045
  setTip(null);
@@ -6197,7 +6056,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6197
6056
  pressTimer.current = setTimeout(() => {
6198
6057
  if (Math.hypot(e.clientX - start.x, e.clientY - start.y) < 10) {
6199
6058
  const r = el.getBoundingClientRect();
6200
- show(el, r.left + r.width / 2 - 80, r.top - 36);
6059
+ const z = panelZoom();
6060
+ show(el, (r.left + r.width / 2 - 80) / z, (r.top - 36) / z);
6201
6061
  }
6202
6062
  }, 500);
6203
6063
  };
@@ -6225,12 +6085,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6225
6085
  };
6226
6086
  }, []);
6227
6087
  if (tip === null) return null;
6088
+ const z = parseFloat(document.querySelector(".lks-ui")?.style.zoom ?? "1") || 1;
6089
+ const clamped = clampTip(tip.x, tip.y, 200, 40, window.innerWidth / z, window.innerHeight / z);
6228
6090
  return (0, react.createElement)("div", {
6229
6091
  className: "lks-tip",
6230
6092
  role: "tooltip",
6231
6093
  style: {
6232
- left: `${String(clampTip(tip.x, tip.y, 200, 40, window.innerWidth, window.innerHeight).x)}px`,
6233
- top: `${String(clampTip(tip.x, tip.y, 200, 40, window.innerWidth, window.innerHeight).y)}px`
6094
+ left: `${String(clamped.x)}px`,
6095
+ top: `${String(clamped.y)}px`
6234
6096
  }
6235
6097
  }, tip.text);
6236
6098
  }
@@ -6757,7 +6619,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6757
6619
  * transition here — the client bundle stays dependency-free.)
6758
6620
  * @module dsh-plugin-lookatstudy/client/celebration-layer
6759
6621
  */
6760
- /** prefers-reduced-motion, live-updating (the companion rides the same rule). */
6622
+ /** prefers-reduced-motion, live-updating. */
6761
6623
  function usePrefersReducedMotion() {
6762
6624
  const [reduced, setReduced] = (0, react.useState)(() => typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
6763
6625
  (0, react.useEffect)(() => {
@@ -7097,24 +6959,39 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7097
6959
  }, []);
7098
6960
  const [rows, setRows] = (0, react.useState)([]);
7099
6961
  const [pressure, setPressure] = (0, react.useState)(null);
6962
+ const [breakdown, setBreakdown] = (0, react.useState)(null);
7100
6963
  const importProgress = (0, react.useMemo)(() => importProgressOf(rows), [rows]);
7101
6964
  const contextMeter = (0, react.useMemo)(() => {
7102
6965
  if (pressure !== null && pressure.contextWindow !== void 0 && pressure.contextWindow > 0 && pressure.projectedTokens !== void 0) return {
7103
6966
  pct: Math.min(100, Math.round(pressure.projectedTokens / pressure.contextWindow * 100)),
7104
- label: `${String(pressure.projectedTokens)}/${String(pressure.contextWindow)}`,
7105
- estimated: false
6967
+ estimated: false,
6968
+ used: pressure.projectedTokens,
6969
+ window: pressure.contextWindow
6970
+ };
6971
+ const est = Math.round(rows.reduce((n, r) => n + r.text.length, 0) / 2.2);
6972
+ const window = data?.model?.contextWindow ?? null;
6973
+ if (window !== null && window > 0) return {
6974
+ pct: Math.min(100, Math.round(est / window * 100)),
6975
+ estimated: true,
6976
+ used: est,
6977
+ window
7106
6978
  };
7107
- const chars = rows.reduce((n, r) => n + r.text.length, 0);
7108
6979
  return {
7109
6980
  pct: null,
7110
- label: tr("meter.estimate", { n: String(Math.round(chars / 2.2)) }),
7111
- estimated: true
6981
+ estimated: true,
6982
+ used: est,
6983
+ window: null
7112
6984
  };
7113
- }, [pressure, rows]);
6985
+ }, [
6986
+ pressure,
6987
+ rows,
6988
+ data?.model
6989
+ ]);
7114
6990
  const [feedAttached, setFeedAttached] = (0, react.useState)(false);
7115
6991
  const [draft, setDraft] = (0, react.useState)("");
7116
6992
  const [narrowPane, setNarrowPane] = (0, react.useState)(pickNarrowPane(null));
7117
6993
  const lesson = data?.lesson ?? null;
6994
+ 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 ?? []);
7118
6995
  const localBound = (0, react.useRef)(null);
7119
6996
  const streamEl = (0, react.useRef)(null);
7120
6997
  const touchStart = (0, react.useRef)(null);
@@ -7187,14 +7064,25 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7187
7064
  let off;
7188
7065
  try {
7189
7066
  const actx = ctx.sessions.scope(boundId);
7190
- const proj = (actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx))?.projections?.faceOf("contextPressure");
7067
+ const face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx);
7068
+ const proj = face?.projections?.faceOf("contextPressure");
7069
+ const bproj = face?.projections?.faceOf("contextBreakdown");
7191
7070
  if (proj !== void 0) {
7192
7071
  const read = () => {
7072
+ if (disposed) return;
7193
7073
  const snap = proj.getSnapshot();
7194
- if (!disposed && snap !== void 0 && typeof snap === "object") setPressure({
7074
+ if (snap !== void 0 && typeof snap === "object") setPressure({
7195
7075
  projectedTokens: snap.projectedTokens,
7196
7076
  contextWindow: snap.contextWindow
7197
7077
  });
7078
+ if (bproj !== void 0) {
7079
+ const bsnap = bproj.getSnapshot();
7080
+ if (bsnap !== void 0 && typeof bsnap === "object") setBreakdown({
7081
+ systemTokens: bsnap.systemTokens ?? 0,
7082
+ toolsTokens: bsnap.toolsTokens ?? 0,
7083
+ messageTokens: bsnap.messageTokens ?? 0
7084
+ });
7085
+ }
7198
7086
  };
7199
7087
  read();
7200
7088
  off = proj.subscribe(read);
@@ -7361,8 +7249,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7361
7249
  window.removeEventListener("lookatstudy-exam-generate", onGenerate);
7362
7250
  };
7363
7251
  }, [send]);
7364
- const companion = useCompanionMood();
7365
- const setCompanionEvent = companion.emit;
7366
7252
  const progress = data?.progress ?? null;
7367
7253
  const body = (0, react.createElement)("div", {
7368
7254
  className: "lks14-body",
@@ -7392,9 +7278,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7392
7278
  onJumped: () => {
7393
7279
  setNarrowPane("chat");
7394
7280
  },
7395
- onBlankTap: () => {
7396
- setCompanionEvent("poke");
7397
- },
7398
7281
  streamingLessonId: feedGen && lesson !== null ? lesson.lessonId : null,
7399
7282
  turnActive: feedGen,
7400
7283
  importProgress,
@@ -7429,7 +7312,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7429
7312
  onClick: () => {
7430
7313
  setZoom(zoomTier + .1);
7431
7314
  }
7432
- }, "A+"))), (0, react.createElement)("div", { className: "lks14-row" }, (0, react.createElement)(ChatPane, {
7315
+ }, "A+"))), (0, react.createElement)("div", { className: "lks14-row" }, examLessonActive ? (0, react.createElement)("div", { className: "lks14-col lks14-chat" }, (0, react.createElement)(ExamView, {
7316
+ lessonId: lesson.lessonId,
7317
+ sectionTitle: lesson.sectionTitle,
7318
+ paused: examLeave !== null,
7319
+ onSessionChange: onExamSession
7320
+ })) : (0, react.createElement)(ChatPane, {
7433
7321
  data,
7434
7322
  lesson,
7435
7323
  rows,
@@ -7443,20 +7331,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7443
7331
  stop,
7444
7332
  setMode,
7445
7333
  narrowPane,
7446
- companionEvent: setCompanionEvent,
7447
7334
  onThreadJump: (id) => {
7448
7335
  setNarrowPane("chat");
7449
7336
  guardedSetFocus(id);
7450
7337
  },
7451
7338
  contextMeter,
7339
+ contextBreakdown: breakdown,
7452
7340
  uploadAttachment
7453
7341
  }), (0, react.createElement)(NotebookPane, {
7454
7342
  data,
7455
7343
  deleteNote,
7456
- send,
7457
- companionEvent: setCompanionEvent,
7458
- onExamSession,
7459
- examPaused: examLeave !== null
7344
+ send
7460
7345
  }))));
7461
7346
  return (0, react.createElement)("div", {
7462
7347
  className: "lks14 lks-ui",
@@ -7490,13 +7375,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7490
7375
  setNarrowPane("chat");
7491
7376
  send(tr("prompt.start"));
7492
7377
  }
7493
- }) : null, (0, react.createElement)(Companion, {
7494
- mood: companion.mood,
7495
- onPoke: () => {
7496
- setCompanionEvent("poke");
7497
- showStudyToast(tr("companion.poked"), { severity: "success" });
7498
- }
7499
- }), (0, react.createElement)("div", { className: "lks14-switch" }, ...[
7378
+ }) : null, (0, react.createElement)("div", { className: "lks14-switch" }, ...[
7500
7379
  "rail",
7501
7380
  "chat",
7502
7381
  "note"
@@ -7764,7 +7643,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7764
7643
  PANEL_SHELL.current = shell;
7765
7644
  }
7766
7645
  /** 左栏:course picker, tree, review box, import. */
7767
- function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send, onJumped, onBlankTap, streamingLessonId, turnActive, importProgress, stop, paletteBus }) {
7646
+ function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send, onJumped, streamingLessonId, turnActive, importProgress, stop, paletteBus }) {
7768
7647
  const [selectedCourse, setSelectedCourse] = (0, react.useState)("");
7769
7648
  const [query, setQuery] = (0, react.useState)("");
7770
7649
  const [searchRows, setSearchRows] = (0, react.useState)([]);
@@ -7835,7 +7714,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7835
7714
  turnActive
7836
7715
  ]);
7837
7716
  const course = data?.courses.find((c) => c.courseId === courseId) ?? null;
7838
- const blankDown = (0, react.useRef)(null);
7839
7717
  (0, react.useEffect)(() => {
7840
7718
  if (optFocus !== null && data?.focusLessonId === optFocus) setOptFocus(null);
7841
7719
  }, [data?.focusLessonId, optFocus]);
@@ -7955,20 +7833,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7955
7833
  }, (0, react.createElement)(IconWrenchOutline16, { size: 13 }), tr("map.world.practice"))) : null)) : railtabs);
7956
7834
  const mapPane = data === null ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("loading")) : course === null ? null : (0, react.createElement)("div", {
7957
7835
  className: "lks14-railscroll",
7958
- ref: scrollEl,
7959
- onPointerDown: (e) => {
7960
- blankDown.current = {
7961
- x: e.clientX,
7962
- y: e.clientY
7963
- };
7964
- },
7965
- onClick: (e) => {
7966
- const d = blankDown.current;
7967
- blankDown.current = null;
7968
- if (d === null || Math.hypot(e.clientX - d.x, e.clientY - d.y) > 6) return;
7969
- if (e.target.closest("button, a, input, textarea, select") !== null) return;
7970
- onBlankTap();
7971
- }
7836
+ ref: scrollEl
7972
7837
  }, (0, react.createElement)("div", { className: "lks-raillist" }, query.trim() === "" && world === "practice" && !course.sections.some((s) => sectionWorldOf(s) === "practice") ? (0, react.createElement)("div", { className: "lks-empty-practice" }, tr("map.empty.practice")) : course.sections.filter((s) => query.trim() !== "" || sectionWorldOf(s) === world).flatMap((section) => {
7973
7838
  const examAllowed = examOpen(section.lessons);
7974
7839
  const lessons = section.lessons.filter((l) => query.trim() === "" || titleMatches(l.title, query) || l.focus);
@@ -8375,9 +8240,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8375
8240
  return cut <= 0 ? path : norm.slice(0, cut);
8376
8241
  }
8377
8242
  /** 中栏:the tutor chat stream with its own composer (upstream ChatStream + ChatComposer). */
8378
- function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, companionEvent, onThreadJump, contextMeter, uploadAttachment }) {
8243
+ function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, onThreadJump, contextMeter, contextBreakdown, uploadAttachment }) {
8379
8244
  const stuck = (0, react.useRef)(true);
8380
8245
  const [showFab, setShowFab] = (0, react.useState)(false);
8246
+ const [threadsOpen, setThreadsOpen] = (0, react.useState)(false);
8381
8247
  const onStreamScroll = () => {
8382
8248
  const el = streamEl.current;
8383
8249
  if (el === null) return;
@@ -8402,6 +8268,37 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8402
8268
  window.removeEventListener("keydown", onKey);
8403
8269
  };
8404
8270
  }, [busy, stop]);
8271
+ (0, react.useEffect)(() => {
8272
+ if (!threadsOpen) return;
8273
+ const onKey = (e) => {
8274
+ if (e.key === "Escape") setThreadsOpen(false);
8275
+ };
8276
+ const onDown = (e) => {
8277
+ if (e.target.closest(".lks14-threadswitch") === null) setThreadsOpen(false);
8278
+ };
8279
+ window.addEventListener("keydown", onKey);
8280
+ document.addEventListener("pointerdown", onDown);
8281
+ return () => {
8282
+ window.removeEventListener("keydown", onKey);
8283
+ document.removeEventListener("pointerdown", onDown);
8284
+ };
8285
+ }, [threadsOpen]);
8286
+ const [ctxOpen, setCtxOpen] = (0, react.useState)(false);
8287
+ (0, react.useEffect)(() => {
8288
+ if (!ctxOpen) return;
8289
+ const onKey = (e) => {
8290
+ if (e.key === "Escape") setCtxOpen(false);
8291
+ };
8292
+ const onDown = (e) => {
8293
+ if (e.target.closest(".lks14-ctxring-wrap") === null) setCtxOpen(false);
8294
+ };
8295
+ window.addEventListener("keydown", onKey);
8296
+ document.addEventListener("pointerdown", onDown);
8297
+ return () => {
8298
+ window.removeEventListener("keydown", onKey);
8299
+ document.removeEventListener("pointerdown", onDown);
8300
+ };
8301
+ }, [ctxOpen]);
8405
8302
  const { tts } = useStudy();
8406
8303
  const [msgAudio, setMsgAudio] = (0, react.useState)(null);
8407
8304
  const msgReadCtl = (0, react.useRef)(null);
@@ -8441,7 +8338,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8441
8338
  if (el === null) return;
8442
8339
  const sentences = speechSentencesOf((el.textContent ?? "").trim());
8443
8340
  if (sentences.length === 0) return;
8444
- companionEvent("talk-start");
8445
8341
  msgReadCtl.current?.stop();
8446
8342
  const voice = storedTtsVoice();
8447
8343
  const prefetch = /* @__PURE__ */ new Map();
@@ -8522,7 +8418,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8522
8418
  });
8523
8419
  };
8524
8420
  const stopMessage = () => {
8525
- companionEvent("talk-end");
8526
8421
  msgReadCtl.current?.stop();
8527
8422
  setMsgAudio(null);
8528
8423
  document.querySelectorAll("mark.lks-reading").forEach((m) => {
@@ -8550,9 +8445,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8550
8445
  data: artifact.data,
8551
8446
  masteryPct: lesson.masteryPct,
8552
8447
  send,
8553
- onFinished: (allCorrect) => {
8554
- companionEvent(allCorrect ? "celebrate" : "encourage");
8555
- }
8448
+ onFinished: () => {}
8556
8449
  }) : (0, react.createElement)(ArtifactCard, {
8557
8450
  artifact,
8558
8451
  send
@@ -8650,20 +8543,29 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8650
8543
  }));
8651
8544
  send(tr("proposal.decline.msg", { lesson: proposal.lessonTitle }));
8652
8545
  }
8653
- }, 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)("span", {
8654
- className: "lks14-threadpills",
8655
- role: "tablist",
8656
- "aria-label": tr("threads.label")
8546
+ }, 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", {
8547
+ className: "lks14-threadchip",
8548
+ "aria-expanded": String(threadsOpen),
8549
+ "data-tooltip": tr("threads.label"),
8550
+ onClick: () => {
8551
+ setThreadsOpen((v) => !v);
8552
+ }
8553
+ }, tr("threads.open", { n: String(threadPills.length) }), (0, react.createElement)("span", { className: "lks14-threadchip-caret" }, threadsOpen ? "▾" : "▸")), threadsOpen ? (0, react.createElement)("div", {
8554
+ className: "lks14-threadmenu",
8555
+ role: "menu"
8657
8556
  }, ...threadPills.map((p) => (0, react.createElement)("button", {
8658
8557
  key: p.id,
8659
- className: `lks14-threadpill${p.current ? " on" : ""}`,
8660
- role: "tab",
8661
- "aria-selected": String(p.current),
8662
- "data-tooltip": p.title,
8558
+ className: `lks14-threadmenu-row${p.current ? " on" : ""}`,
8559
+ role: "menuitem",
8560
+ "aria-current": p.current ? "true" : void 0,
8663
8561
  onClick: () => {
8562
+ setThreadsOpen(false);
8664
8563
  if (!p.current) onThreadJump(p.id);
8665
8564
  }
8666
- }, p.title))) : null) : null, bound && lesson !== null ? (0, react.createElement)("div", { className: "lks14-threadlabel" }, tr("thread.label", { title: lesson.title })) : null, (0, react.createElement)("div", {
8565
+ }, (0, react.createElement)("span", {
8566
+ className: "lks14-threadmenu-dot",
8567
+ "aria-hidden": "true"
8568
+ }), (0, react.createElement)("span", { className: "lks14-threadmenu-title" }, p.title), p.current ? (0, react.createElement)("span", { className: "lks14-threadmenu-now" }, tr("threads.now")) : null))) : null) : null) : null, (0, react.createElement)("div", {
8667
8569
  className: "lks14-stream",
8668
8570
  onScroll: onStreamScroll,
8669
8571
  ref: streamEl,
@@ -8687,9 +8589,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8687
8589
  data: a.data,
8688
8590
  masteryPct: lesson.masteryPct,
8689
8591
  send,
8690
- onFinished: (allCorrect) => {
8691
- companionEvent(allCorrect ? "celebrate" : "encourage");
8692
- }
8592
+ onFinished: () => {}
8693
8593
  })), ...backlog.filter((a) => a.artifactType !== "quiz").map((a) => (0, react.createElement)(ArtifactCard, {
8694
8594
  key: a.id,
8695
8595
  artifact: a,
@@ -8703,14 +8603,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8703
8603
  if (!dormant) send(s.message);
8704
8604
  }
8705
8605
  }, s.label))) : null, (0, react.createElement)("div", { className: "lks14-composer" }, (0, react.createElement)("div", {
8706
- className: "lks14-ctxmeter",
8707
- "data-testid": "context-meter",
8708
- "data-estimated": String(contextMeter.estimated),
8709
- title: tr("meter.label")
8710
- }, (0, react.createElement)("span", { className: "lks14-ctxmeter-bar" }, (0, react.createElement)("i", {
8711
- style: { transform: `scaleX(${String((contextMeter.pct ?? 0) / 100)})` },
8712
- className: contextMeter.pct !== null && contextMeter.pct > 80 ? "hot" : void 0
8713
- })), (0, react.createElement)("span", { className: "lks14-ctxmeter-label" }, contextMeter.estimated ? contextMeter.label : `${String(contextMeter.pct ?? 0)}% · ${contextMeter.label}`)), (0, react.createElement)("div", {
8714
8606
  className: "lks14-composer-card",
8715
8607
  onDragOver: (e) => {
8716
8608
  if (e.dataTransfer?.types.includes("Files") === true) e.preventDefault();
@@ -8765,7 +8657,55 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8765
8657
  if (file !== void 0 && file !== null) attachFile(file);
8766
8658
  e.target.value = "";
8767
8659
  }
8768
- }), busy ? (0, react.createElement)("button", {
8660
+ }), contextMeter.pct !== null ? (0, react.createElement)("span", {
8661
+ className: "lks14-ctxring-wrap",
8662
+ key: "ctxring"
8663
+ }, (0, react.createElement)("button", {
8664
+ className: "lks14-ctxring",
8665
+ "data-testid": "context-meter",
8666
+ "data-estimated": String(contextMeter.estimated),
8667
+ "aria-label": tr("meter.aria", { pct: String(contextMeter.pct) }),
8668
+ "data-tooltip": tr("meter.aria", { pct: String(contextMeter.pct) }),
8669
+ "aria-haspopup": "dialog",
8670
+ "aria-expanded": String(ctxOpen),
8671
+ onClick: () => {
8672
+ setCtxOpen((v) => !v);
8673
+ }
8674
+ }, (0, react.createElement)("svg", {
8675
+ viewBox: "0 0 14 14",
8676
+ width: 14,
8677
+ height: 14,
8678
+ "aria-hidden": "true"
8679
+ }, (0, react.createElement)("circle", {
8680
+ className: "track",
8681
+ cx: 7,
8682
+ cy: 7,
8683
+ r: 5.5
8684
+ }), (0, react.createElement)("circle", {
8685
+ className: `fill${contextMeter.pct > 80 ? " hot" : ""}`,
8686
+ cx: 7,
8687
+ cy: 7,
8688
+ r: 5.5,
8689
+ strokeDasharray: `${String(2 * Math.PI * 5.5 * contextMeter.pct / 100)} ${String(2 * Math.PI * 5.5)}`,
8690
+ transform: "rotate(-90 7 7)"
8691
+ }))), ctxOpen ? (0, react.createElement)("div", {
8692
+ className: "lks14-ctxpanel",
8693
+ role: "dialog",
8694
+ "aria-label": tr("meter.label")
8695
+ }, (0, react.createElement)("div", { className: "lks14-ctxpanel-head" }, (0, react.createElement)("span", { className: "lks14-ctxpanel-pct" }, `${String(contextMeter.pct)}%`), (0, react.createElement)("span", { className: "lks14-ctxpanel-figs" }, `~${fmtTokens(contextMeter.used ?? 0)} / ${fmtTokens(contextMeter.window ?? 0)}`)), (0, react.createElement)("div", { className: "lks14-ctxpanel-bar" }, ...ctxSegments(contextMeter.pct, contextBreakdown).map((s) => (0, react.createElement)("i", {
8696
+ key: s.key,
8697
+ className: s.cls,
8698
+ style: { width: `${String(s.width)}%` }
8699
+ }))), contextBreakdown !== null ? (0, react.createElement)("div", { className: "lks14-ctxpanel-rows" }, (0, react.createElement)("div", { className: "lks14-ctxpanel-row" }, (0, react.createElement)("span", {
8700
+ className: "swatch sys",
8701
+ "aria-hidden": "true"
8702
+ }), tr("meter.brk.system"), (0, react.createElement)("b", null, `~${fmtTokens(contextBreakdown.systemTokens)}`)), (0, react.createElement)("div", { className: "lks14-ctxpanel-row" }, (0, react.createElement)("span", {
8703
+ className: "swatch tools",
8704
+ "aria-hidden": "true"
8705
+ }), tr("meter.brk.tools"), (0, react.createElement)("b", null, `~${fmtTokens(contextBreakdown.toolsTokens)}`)), (0, react.createElement)("div", { className: "lks14-ctxpanel-row" }, (0, react.createElement)("span", {
8706
+ className: "swatch msgs",
8707
+ "aria-hidden": "true"
8708
+ }), tr("meter.brk.messages"), (0, react.createElement)("b", null, `~${fmtTokens(contextBreakdown.messageTokens)}`))) : null, contextMeter.estimated ? (0, react.createElement)("div", { className: "lks14-ctxpanel-note" }, tr("meter.estimate.note")) : null) : null) : null, busy ? (0, react.createElement)("button", {
8769
8709
  className: "lks-btn-send stop",
8770
8710
  "aria-label": tr("chat.stop"),
8771
8711
  "data-tooltip": tr("chat.stop"),
@@ -8790,7 +8730,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8790
8730
  }, "▾") : null, sendError !== null || error !== null ? (0, react.createElement)("div", { className: "lks-propcard-err" }, sendError ?? error) : null));
8791
8731
  }
8792
8732
  /** 右栏:the notebook — 讲解/概念图/笔记 tabs (upstream NotebookPanel arrangement). */
8793
- function NotebookPane({ data, deleteNote, send, companionEvent, onExamSession, examPaused }) {
8733
+ function NotebookPane({ data, deleteNote, send }) {
8794
8734
  const lesson = data?.lesson ?? null;
8795
8735
  const [tab, setTab] = (0, react.useState)("teach");
8796
8736
  const [error, setError] = (0, react.useState)(null);
@@ -8824,18 +8764,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8824
8764
  setError(friendlyError(err, "action"));
8825
8765
  });
8826
8766
  };
8827
- const examContent = lesson !== null && lesson.kind === "exam" ? (() => {
8828
- const section = data?.courses.flatMap((c) => c.sections).find((s) => s.lessons.some((l) => l.id === lesson.lessonId));
8829
- return examOpen(section?.lessons ?? []) ? (0, react.createElement)(ExamView, {
8830
- lessonId: lesson.lessonId,
8831
- sectionTitle: lesson.sectionTitle,
8832
- paused: examPaused,
8833
- onSessionChange: onExamSession
8834
- }) : (0, react.createElement)("div", {
8835
- className: "lks14-empty",
8836
- style: { padding: "16px 0" }
8837
- }, tr("status.exam"));
8838
- })() : null;
8839
8767
  const artifacts = lesson?.artifacts ?? [];
8840
8768
  const [, setUnseenTick] = (0, react.useState)(0);
8841
8769
  const unseen = unseenArtifacts(lesson?.lessonId ?? "", artifacts);
@@ -8923,7 +8851,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8923
8851
  const [rateBusy, setRateBusy] = (0, react.useState)(false);
8924
8852
  const startReading = () => {
8925
8853
  if (lesson === null || lesson.speechText.trim() === "") return;
8926
- companionEvent("talk-start");
8927
8854
  readCtl.current?.stop();
8928
8855
  const sentences = speechSentencesOf(lesson.speechText);
8929
8856
  const voice = storedTtsVoice();
@@ -9004,7 +8931,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9004
8931
  });
9005
8932
  };
9006
8933
  const stopReading = () => {
9007
- companionEvent("talk-end");
9008
8934
  readCtl.current?.stop();
9009
8935
  setRead(null);
9010
8936
  };
@@ -9079,7 +9005,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9079
9005
  onClick: () => {
9080
9006
  setRateBusy(true);
9081
9007
  recordReview(lesson.lessonId, Number(q)).then(() => {
9082
- companionEvent(Number(q) >= 4 ? "celebrate" : "encourage");
9083
9008
  const rateEl = document.querySelector(`[data-lks-rate="${lesson.lessonId}"]`);
9084
9009
  const rr = Number(q) >= 4 && rateEl !== null ? rateEl.getBoundingClientRect() : null;
9085
9010
  celebrate(Number(q) >= 4 ? "correct" : "wrong", rr !== null ? { origin: {
@@ -9307,7 +9232,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9307
9232
  dangerouslySetInnerHTML: { __html: renderMarkdown(n.text) }
9308
9233
  })), n.quote !== null ? (0, react.createElement)("div", { className: "lks-note-q" }, `'${n.quote}'`) : null)) : []);
9309
9234
  })));
9310
- return (0, react.createElement)("div", { className: "lks14-col lks14-note" }, examContent !== null ? (0, react.createElement)("div", { className: "lks14-notebody" }, examContent) : body, examContent === null && confirmNoteDel !== null ? (0, react.createElement)(ConfirmCard, {
9235
+ return (0, react.createElement)("div", { className: "lks14-col lks14-note" }, body, confirmNoteDel !== null ? (0, react.createElement)(ConfirmCard, {
9311
9236
  anchor: confirmNoteDel.rect,
9312
9237
  message: tr("note.delete.confirm"),
9313
9238
  danger: true,
@@ -9575,7 +9500,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9575
9500
  const { data, setMode, activate, setHistoryBudget } = useStudy();
9576
9501
  const [error, setError] = (0, react.useState)(null);
9577
9502
  const [voice, setVoice] = (0, react.useState)(storedTtsVoice);
9578
- const [companion, setCompanion] = (0, react.useState)(storedCompanionForm);
9579
9503
  const fire = (action) => {
9580
9504
  action.then(() => {
9581
9505
  setError(null);
@@ -9602,17 +9526,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9602
9526
  onClick: () => {
9603
9527
  if (data !== null) fire(setHistoryBudget(!(data.historyBudget === true)));
9604
9528
  }
9605
- }, data?.historyBudget === true ? tr("settings.budget.off") : tr("settings.budget.on")))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.companion")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.companion.hint")), (0, react.createElement)("div", null, (0, react.createElement)("select", {
9606
- className: "lks-set-select",
9607
- value: companion,
9608
- onChange: (e) => {
9609
- storeCompanionForm(normalizeCompanionForm(e.target.value));
9610
- setCompanion(normalizeCompanionForm(e.target.value));
9611
- }
9612
- }, ...COMPANION_FORMS.map((f) => (0, react.createElement)("option", {
9613
- key: f.id,
9614
- value: f.id
9615
- }, tr(f.labelKey)))))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.voice")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.voice.hint")), (0, react.createElement)("div", null, (0, react.createElement)("select", {
9529
+ }, data?.historyBudget === true ? tr("settings.budget.off") : tr("settings.budget.on")))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.voice")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.voice.hint")), (0, react.createElement)("div", null, (0, react.createElement)("select", {
9616
9530
  className: "lks-set-select",
9617
9531
  value: voice,
9618
9532
  onChange: (e) => {