dsh-plugin-lookatstudy 0.13.0 → 0.14.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
@@ -5,11 +5,15 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
+ let react_dom_client = require("react-dom/client");
8
9
  //#region src/client/styles.ts
9
10
  /**
10
- * Style injection for the study tab: one `<style>` element of `.lks-*`
11
- * classes authored against the dsh `--dsw-*` design tokens, so the tab
12
- * follows the app's theme (light/dark) instead of carrying its own palette.
11
+ * Style injection for the study surfaces: one `<style>` element carrying the
12
+ * `.lks14-*` panel classes (the sidebar-entry center-takeover, arranged like
13
+ * upstream LookatStudy) plus the shared `.lks-*` classes the host-native
14
+ * surfaces still use (toolview cards, dock pill, settings section, note
15
+ * cards, read-aloud bar). Authored against the dsh `--dsw-*` design tokens so
16
+ * everything follows the app's theme instead of carrying its own palette.
13
17
  * CSS Modules are unavailable to patch-layer bundles, so the stylesheet is
14
18
  * injected once at client-plugin apply; the `data-` attribute keeps the
15
19
  * injection idempotent.
@@ -18,8 +22,10 @@ window.__ModuleLoader__.load({
18
22
  /** Marker attribute proving the stylesheet is already in the document. */
19
23
  const STYLE_ID = "data-dsh-plugin-lookatstudy";
20
24
  /**
21
- * The study tab's stylesheet. Class names are `lks-*` namespaced; colors,
22
- * fonts, and radii come from `--dsw-*` tokens wherever the concept exists.
25
+ * The study stylesheet. The center-takeover visibility follows the stardeck
26
+ * panel doctrine: the shell view is display:none until the `<html>` active
27
+ * attribute flips, and the host conversation column's own children are hidden
28
+ * behind `!important` so React never fights the takeover.
23
29
  */
24
30
  const STUDY_CSS = `
25
31
  /* State inks: small colored text must clear WCAG AA (4.5:1), but the host's
@@ -28,211 +34,125 @@ window.__ModuleLoader__.load({
28
34
  host at all (the tv-chip used to reference them and silently inherit).
29
35
  Mixing the state color toward label-primary keeps the hue while pulling
30
36
  luminance to the readable pole — and works in BOTH themes, because
31
- label-primary is always the current theme's high-contrast ink. */
32
- .lks-root{--lks-warn-ink:color-mix(in srgb,var(--dsw-alias-state-warn-label) 45%,var(--dsw-alias-label-primary));--lks-ok-ink:color-mix(in srgb,var(--dsw-alias-state-success-primary) 40%,var(--dsw-alias-label-primary));--lks-err-ink:color-mix(in srgb,var(--dsw-alias-state-error-primary) 55%,var(--dsw-alias-label-primary));font-family:var(--dsw-font-family)}
37
+ label-primary is always the current theme's high-contrast ink. Declared on
38
+ every surface family that consumes the inks: .lks-root (dock pill,
39
+ settings) and .lks-tv (toolview cards, which render in the host
40
+ conversation with no .lks-root ancestor). */
41
+ .lks-root,.lks-tv{--lks-warn-ink:color-mix(in srgb,var(--dsw-alias-state-warn-label) 45%,var(--dsw-alias-label-primary));--lks-ok-ink:color-mix(in srgb,var(--dsw-alias-state-success-primary) 40%,var(--dsw-alias-label-primary));--lks-err-ink:color-mix(in srgb,var(--dsw-alias-state-error-primary) 55%,var(--dsw-alias-label-primary))}
42
+ .lks-root{font-family:var(--dsw-font-family)}
33
43
  .lks-root :where(button){font-family:var(--dsw-font-family);cursor:pointer;border:none;background:none;padding:0}
34
- .lks-root :where(a){color:var(--dsw-alias-state-business-primary)}
35
44
 
36
- /* ── the study tab: composer-width center, self-scrolling flanks ──
37
- data-conversation-composer-overlay (set on the root element) opts the
38
- view into the host's overlay mode: viewArea becomes a fixed-height
39
- non-growing host and the composer floats over the tab's bottom band, so
40
- the tab itself never feeds the page scroll. The bottom padding clears the
41
- floating composer via the host-published live --dsh-composer-height. The
42
- tutor column shares the conversation column's width axis
43
- (--dsh-composer-card-max-width inherits from ConversationRoot's root) but
44
- is capped at 42% of the tab so the BLACKBOARD — the reading surface —
45
- keeps the lion's share at every wide width; the composer card follows the
46
- tutor column's real measured width (views.tsx), so the two stay visually
47
- tied. Flanking columns absorb the remaining width and scroll independently. */
48
- .lks-study{height:100%;min-height:0;width:100%;box-sizing:border-box;overflow:hidden;display:flex;flex-direction:column;padding:10px 14px calc(var(--dsh-composer-height, 152px) + 12px);color:var(--dsw-alias-label-primary);container:lksstudy/inline-size}
49
- /* Direction carrier (a container query cannot style the container itself). */
50
- .lks-body{flex:1;min-height:0;min-width:0;display:flex;gap:16px}
51
- .lks-col{display:flex;flex-direction:column;min-width:0}
52
- .lks-colhead{flex:none;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary);letter-spacing:.06em;text-transform:uppercase;margin-bottom:10px}
53
- .lks-col-rail{flex:0 1 260px;min-width:180px;overflow-y:auto;padding:2px 6px}
54
- .lks-col-tutor{flex:0 1 auto;width:min(100%,var(--dsh-composer-card-max-width,780px),42cqi);min-width:360px;overflow:hidden}
55
- .lks-col-bb{flex:1 1 0;min-width:320px;overflow-y:auto;padding:2px 6px}
56
- /* While the study view is mounted in wide mode, the host composer card is
57
- shifted under the tutor column (the transcript's width axis) instead of the
58
- scroll body's center — views.tsx measures the offset, sets the variables
59
- and toggles the class, and removes all three whenever the shift collapses
60
- (narrow pane, hidden view) so the host's natural centering and width come
61
- back. max-width tracks the tutor column's live width: the tutor shrinks
62
- proportionally below ~1857px containers, and the card shrinks with it. */
63
- [data-composer-card].lks-composer-follow{align-self:flex-start;margin-left:var(--lks-composer-shift,0px);max-width:var(--lks-composer-follow-width,var(--dsh-composer-card-max-width,780px))}
64
- /* narrow (<1220px): one composer-width pane at a time, chosen by a centered
65
- segmented pill group (joined buttons in one capsule — button language, not
66
- tab language, against the host's view tabs right above). */
67
- .lks-switch{display:none;flex-direction:row;gap:2px;align-self:center;margin-bottom:10px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px}
68
- .lks-switch-btn{border-radius:999px;padding:5px 18px;font-size:14px;color:var(--dsw-alias-label-tertiary);background:transparent;transition:background .12s ease,color .12s ease}
69
- .lks-switch-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
70
- .lks-switch-btn.on{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 86%,#000);color:#fff;font-weight:600}
71
- @container lksstudy (max-width: 1220px){
72
- .lks-body{flex-direction:column}
73
- .lks-switch{display:flex}
74
- .lks-colhead{display:none}
75
- .lks-col{flex:1 1 auto;min-height:0;width:auto;overflow-y:auto}
76
- .lks-col-tutor{width:min(100%,var(--dsh-composer-card-max-width,780px));overflow:hidden;display:flex;flex-direction:column}
77
- .lks-study[data-pane='rail'] .lks-col-tutor,.lks-study[data-pane='rail'] .lks-col-bb,
78
- .lks-study[data-pane='tutor'] .lks-col-rail,.lks-study[data-pane='tutor'] .lks-col-bb,
79
- .lks-study[data-pane='bb'] .lks-col-rail,.lks-study[data-pane='bb'] .lks-col-tutor{display:none}
80
- }
45
+ /* ── sidebar entry row (DOM-injected next to the session family) ── */
46
+ .lks14-sidebar-row{display:flex;align-items:center;gap:9px;width:100%;padding:7px 10px;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;font-family:var(--dsw-font-family);cursor:pointer;border-radius:8px;text-align:left}
47
+ .lks14-sidebar-row:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
48
+ .lks14-sidebar-row[data-active='true']{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:600}
49
+ .lks14-sidebar-row:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
50
+ .lks14-sidebar-icon{display:inline-flex;align-items:center;flex:0 0 auto;color:currentColor}
51
+ .lks14-sidebar-label{white-space:nowrap}
52
+ /* Host sidebar collapsed to the icon rail: hide the label, center the icon. */
53
+ [class*='_collapsed'] .lks14-sidebar-row{justify-content:center;padding:7px 0;gap:0}
54
+ [class*='_collapsed'] .lks14-sidebar-label{display:none}
81
55
 
82
- /* ── left column: course rail ── */
83
- .lks-rail-head{display:flex;align-items:center;gap:6px;margin-bottom:4px}
84
- .lks-rail-head .lks-rail-select{flex:1;min-width:0;margin-bottom:0}
85
- .lks-rail-head .lks-btn{padding:3px 8px;font-size:13px}
86
- .lks-rail-title{font-size:16px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
87
- .lks-rail-sub{font-size:13px;color:var(--dsw-alias-label-secondary);margin:4px 0 6px}
88
- .lks-masterybar{height:5px;border-radius:3px;background:var(--dsw-alias-bg-layer-3);overflow:hidden;margin-bottom:8px}
89
- .lks-masterybar i{display:block;height:100%;width:100%;background:var(--dsw-alias-state-success-primary);transform-origin:0 50%;transition:transform .3s ease}
90
- .lks-masterybar.gold i{background:var(--dsw-alias-state-warn-primary)}
91
- .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}
92
- .lks-search:focus{outline:none;border-color:var(--dsw-alias-state-business-primary);box-shadow:0 0 0 2px var(--dsw-alias-border-l3)}
93
- .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(--lks-warn-ink);font-size:11px;font-weight:700;margin-right:4px}
94
- .lks-tag.due{background:var(--dsw-alias-state-warn-tertiary);color:var(--lks-warn-ink)}
95
- .lks-import{margin-top:8px}
96
- .lks-import .lks-inputrow{margin-top:0}
97
- .lks-inputrow{display:flex;gap:8px;align-items:flex-end;flex:none;margin-top:8px}
98
- .lks-input{flex:1;min-width:0;resize:none;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:14px;line-height:1.6;padding:8px 10px}
99
- .lks-input:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}
100
- .lks-input:disabled{opacity:.55}
101
- .lks-import-hint{font-size:12.5px;color:var(--dsw-alias-label-secondary);margin-top:6px;line-height:1.7}
102
- .lks-duebox{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;margin:8px 0 14px;font-size:13.5px}
103
- .lks-duebox .lks-due-item{color:var(--dsw-alias-label-secondary);margin-top:4px;display:flex;justify-content:space-between;gap:10px}
104
- .lks-duebox .lks-over{color:var(--lks-warn-ink);flex:none}
56
+ /* ── center-column takeover (the study panel) ── */
57
+ .lks14-shell-view{display:none}
58
+ html[data-dsh-lookatstudy-active] .lks14-shell-view{display:flex;flex-direction:column;height:100%;min-height:0}
59
+ html[data-dsh-lookatstudy-active] [data-pane='conversation'] > :not([data-dsh-lookatstudy-view]),
60
+ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatstudy-view]){display:none !important}
61
+ [data-dsh-lookatstudy-view]{container-type:inline-size}
62
+ .lks14{flex:1;min-height:0;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);overflow:hidden;font-family:var(--dsw-font-family)}
63
+ .lks14 button{font-family:var(--dsw-font-family);cursor:pointer}
64
+ .lks14-body{display:flex;flex:1;min-width:0;min-height:0}
65
+ .lks14-col{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
66
+ .lks14-colhead{flex:none;display:flex;align-items:center;gap:8px;padding:10px 12px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-secondary);border-bottom:1px solid var(--dsw-alias-border-l1)}
105
67
 
106
- .lks-sec{font-size:12px;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.05em;margin:14px 0 4px}
107
- /* Collapsible section head: a real button (keyboard can toggle it), sticky so
108
- the current chapter stays identified while 4000px of nodes scroll under it. */
109
- .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-secondary);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)}
110
- .lks-sechead:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}
111
- .lks-sechead:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
112
- .lks-sechead-t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
113
- .lks-sechead-c{flex:none;font-size:10px}
114
- .lks-sechead-n{flex:none;font-size:11px;text-transform:none;letter-spacing:0}
115
- .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}
116
- .lks-node:hover{background:var(--dsw-alias-interactive-bg-hover)}
117
- .lks-node:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
118
- .lks-node.focus{background:var(--dsw-alias-bg-layer-3);outline:1px solid var(--dsw-alias-border-l2)}
119
- .lks-node .lks-g{width:18px;text-align:center;flex:none}
120
- .lks-node .lks-t{flex:1;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
121
- /* aria-disabled (not the disabled attribute): the row stays focusable so the
122
- status tooltip can explain WHY it is locked — Firefox drops titles on truly
123
- disabled form controls. */
124
- .lks-node[aria-disabled='true']{opacity:.45;cursor:not-allowed}
125
- .lks-node[aria-disabled='true']:hover{background:none}
126
- .lks-tag{font-size:11px;border-radius:4px;padding:0 4px;flex:none}
127
- .lks-tag.weak{background:var(--dsw-alias-state-warn-tertiary);color:var(--lks-warn-ink)}
128
- .lks-tag.fric{background:var(--dsw-alias-state-error-tertiary);color:var(--lks-err-ink)}
129
- .lks-bar{width:44px;height:4px;border-radius:2px;background:var(--dsw-alias-bg-layer-3);overflow:hidden;flex:none}
130
- .lks-bar i{display:block;height:100%;width:100%;background:var(--dsw-alias-state-success-primary);transform-origin:0 50%}
131
- .lks-pct{font-size:12px;color:var(--dsw-alias-label-secondary);flex:none;width:32px;text-align:right}
68
+ /* 左 rail: course picker, tree, review, import */
69
+ .lks14-rail{flex:0 0 240px;border-right:1px solid var(--dsw-alias-border-l1);padding:0 10px 10px;overflow-y:auto}
70
+ .lks14-railhead{display:flex;align-items:center;gap:6px;margin-top:10px}
71
+ .lks14-railtitle{font-weight:600;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
72
+ .lks14-railsub{font-size:12px;color:var(--dsw-alias-label-secondary);margin:6px 0}
73
+ .lks14-masterybar{height:5px;border-radius:3px;background:var(--dsw-alias-bg-layer-2);overflow:hidden;margin-bottom:8px}
74
+ .lks14-masterybar i{display:block;height:100%;background:var(--dsw-alias-business-primary);transform-origin:left;transition:transform .3s}
75
+ .lks14-masterybar.gold i{background:var(--dsw-alias-state-warn-primary)}
76
+ .lks14-search{width:100%;box-sizing:border-box;font:inherit;font-size:12.5px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:5px 8px;margin:4px 0}
77
+ .lks14-search:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}
78
+ .lks14-duebox{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px 10px;margin:8px 0;font-size:12.5px}
79
+ .lks14-dueitem{display:flex;justify-content:space-between;gap:6px;padding:2px 0;color:var(--dsw-alias-label-secondary)}
80
+ .lks14-over{color:var(--dsw-alias-state-error-primary)}
81
+ .lks14-sechead{display:flex;align-items:center;gap:6px;width:100%;background:none;border:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12.5px;font-weight:600;padding:6px 2px;cursor:pointer;text-align:left}
82
+ .lks14-sechead:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
83
+ .lks14-secnum{flex:none;width:18px;height:18px;border-radius:5px;background:var(--dsw-alias-bg-layer-2);display:inline-flex;align-items:center;justify-content:center;font-size:11px}
84
+ .lks14-secheadt{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
85
+ .lks14-node{display:flex;align-items:center;gap:6px;width:100%;background:none;border:none;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;padding:5px 6px;border-radius:8px;cursor:pointer;text-align:left}
86
+ .lks14-node:hover{background:var(--dsw-alias-bg-layer-1)}
87
+ .lks14-node:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
88
+ .lks14-node.focus{background:var(--dsw-alias-bg-layer-2)}
89
+ .lks14-node[aria-disabled='true']{color:var(--dsw-alias-label-tertiary);cursor:default}
90
+ .lks14-g{flex:none;display:inline-flex}
91
+ .lks14-t{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
92
+ .lks14-tag{flex:none;display:inline-flex;align-items:center;gap:2px;font-size:10.5px;border-radius:6px;padding:1px 4px}
93
+ .lks14-tag.weak{color:var(--dsw-alias-state-warn-primary)}
94
+ .lks14-bar{flex:none;width:26px;height:4px;border-radius:2px;background:var(--dsw-alias-bg-layer-2);overflow:hidden}
95
+ .lks14-bar i{display:block;height:100%;background:var(--dsw-alias-business-primary);transform-origin:left}
96
+ .lks14-pct{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary)}
97
+ .lks14-empty{color:var(--dsw-alias-label-tertiary);font-size:13px;padding:18px 6px;line-height:1.7}
98
+ .lks14-import{margin:8px 0}
99
+ .lks14-inputrow{display:flex;gap:6px}
100
+ .lks14-hint{font-size:11.5px;color:var(--dsw-alias-label-tertiary);margin-top:6px;line-height:1.6}
132
101
 
133
- .lks-empty{color:var(--dsw-alias-label-secondary);text-align:center;padding:48px 8px;font-size:14px;line-height:2}
134
- .lks-actbar{display:flex;flex-direction:row;justify-content:flex-end;flex:none;padding-bottom:8px}
102
+ /* 中 chat: the tutor stream + its own composer (upstream ChatStream/ChatComposer) */
103
+ .lks14-chat{flex:1 1 46%;min-width:340px;border-right:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-0, transparent)}
104
+ .lks14-chatlesson{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:400;color:var(--dsw-alias-label-tertiary)}
105
+ .lks14-pills{display:inline-flex;gap:2px}
106
+ .lks14-pill{border:none;background:none;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:12px;padding:2px 8px;border-radius:7px;cursor:pointer}
107
+ .lks14-pill:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
108
+ .lks14-pill.on{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
109
+ .lks14-stream{flex:1;overflow-y:auto;padding:12px 14px;display:flex;flex-direction:column;gap:10px}
110
+ .lks14-msg{max-width:92%;line-height:1.65;font-size:13.5px}
111
+ .lks14-msg-user{align-self:flex-end;background:var(--dsw-alias-bg-layer-2);border-radius:12px 12px 3px 12px;padding:7px 11px;white-space:pre-wrap}
112
+ .lks14-msg-assistant{align-self:flex-start;background:var(--dsw-alias-bg-layer-1);border-radius:12px 12px 12px 3px;padding:8px 12px}
113
+ .lks14-msg.streaming{opacity:.7}
114
+ .lks14-msg-assistant p{margin:4px 0}
115
+ .lks14-msg-assistant pre{overflow-x:auto;font-size:12px}
116
+ .lks14-msg-assistant table{border-collapse:collapse;margin:6px 0;display:block;max-width:100%;overflow-x:auto}
117
+ .lks14-msg-assistant th,.lks14-msg-assistant td{border:1px solid var(--dsw-alias-border-l2);padding:4px 8px;font-size:12px}
118
+ .lks14-turn{max-width:92%;align-self:flex-start;display:flex;flex-direction:column;gap:6px}
119
+ .lks14-quiz{display:flex;flex-direction:column;gap:4px}
120
+ .lks14-opt{display:flex;gap:6px;align-items:baseline;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:9px;padding:6px 10px;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);cursor:pointer;text-align:left}
121
+ .lks14-opt:hover{background:var(--dsw-alias-bg-layer-2)}
122
+ .lks14-opt:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
123
+ .lks14-optletter{flex:none;font-weight:700;color:var(--dsw-alias-business-primary)}
124
+ .lks14-starters{flex:none;display:flex;flex-wrap:wrap;gap:4px;padding:6px 12px}
125
+ .lks14-starter{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;border-radius:14px;padding:3px 10px;cursor:pointer}
126
+ .lks14-starter:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
127
+ .lks14-starter:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
128
+ .lks14-starter[aria-disabled='true']{opacity:.5;cursor:default}
129
+ .lks14-composer{flex:none;display:flex;gap:6px;align-items:flex-end;padding:8px 12px 10px;border-top:1px solid var(--dsw-alias-border-l1)}
130
+ .lks14-composertext{flex:1;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:7px 10px;resize:none;box-sizing:border-box}
131
+ .lks14-composertext:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}
135
132
 
136
- /* ── middle column: the tutor ── */
137
- .lks-transcript{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:10px;padding:4px 2px}
138
- .lks-msg{max-width:94%;font-size:16px;line-height:1.7;border-radius:12px;padding:8px 12px;word-break:break-word}
139
- .lks-msg.user{align-self:flex-end;white-space:pre-wrap;background:var(--dsw-alias-state-business-tertiary);color:var(--dsw-alias-label-primary-bluish)}
140
- .lks-msg.assistant{align-self:flex-start;background:var(--dsw-alias-bg-layer-2)}
141
- .lks-msg.streaming{opacity:.7}
142
- .lks-msg.thinking{align-self:flex-start;display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--dsw-alias-label-secondary);background:none;border-radius:0;padding:2px}
143
- .lks-msg.error{align-self:stretch;color:var(--dsw-alias-state-error-primary);font-size:13px;background:none;border-radius:0;padding:1px 2px}
144
- /* quiz-taking surface: the tutor's A–D options become clickable answers on the last settled reply */
145
- .lks-turn{display:flex;flex-direction:column;align-items:flex-start;max-width:94%}
146
- .lks-quiz{display:flex;flex-direction:column;gap:6px;width:100%;margin:2px 0 4px}
147
- .lks-opt{display:flex;align-items:baseline;gap:10px;text-align:left;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);border-radius:10px;padding:8px 14px;font-size:14px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer;font-family:inherit;transition:border-color .12s ease,color .12s ease,background .12s ease}
148
- .lks-opt:hover{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-state-business-tertiary)}
149
- .lks-opt:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
150
- .lks-optletter{font-weight:700;color:var(--dsw-alias-label-primary-bluish);flex:none}
133
+ /* notebook: 讲解/概念图/笔记 (upstream NotebookPanel) */
134
+ .lks14-note{flex:0 0 clamp(320px, 30vw, 460px);padding:0 12px 12px;overflow-y:auto}
135
+ .lks14-lessonhead{margin:10px 0 6px}
136
+ .lks14-lessonhead h2{margin:0 0 4px;font-size:16px}
137
+ .lks14-meta{font-size:12px;color:var(--dsw-alias-label-secondary);margin:2px 0}
138
+ .lks14-viewtabs{display:flex;gap:2px;margin:8px 0}
139
+ .lks14-viewtab{border:none;background:none;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:12.5px;padding:4px 10px;border-radius:8px;cursor:pointer;display:inline-flex;align-items:center;gap:4px}
140
+ .lks14-viewtab:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
141
+ .lks14-viewtab.on{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
142
+ .lks14-prose{font-size:13.5px;line-height:1.7;color:var(--dsw-alias-label-primary);min-height:200px}
143
+ .lks14-prose h1,.lks14-prose h2,.lks14-prose h3{font-size:15px;margin:12px 0 4px}
144
+ .lks14-prose p{margin:6px 0}
145
+ .lks14-prose pre{overflow-x:auto;font-size:12px;background:var(--dsw-alias-bg-layer-1);border-radius:8px;padding:8px}
146
+ .lks14-prose code{font-family:var(--dsw-font-markdown-code);background:var(--dsw-alias-bg-layer-1);border-radius:4px;padding:1px 4px;font-size:.95em}
147
+ .lks14-prose table{border-collapse:collapse;margin:6px 0;display:block;max-width:100%;overflow-x:auto}
148
+ .lks14-prose th,.lks14-prose td{border:1px solid var(--dsw-alias-border-l2);padding:4px 8px;font-size:12.5px}
149
+ .lks14-prose svg{max-width:100%;height:auto}
150
+ .lks14-prose .katex-display{overflow-x:auto}
151
+ .lks14-zones{min-height:200px}
152
+ .lks14-zone{margin-bottom:12px}
153
+ .lks14-zoneh{font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:6px}
151
154
 
152
- /* soul pills (native tool-row trigger language: 28px transparent, tinted active) */
153
- .lks-pills{display:inline-flex;align-items:center;height:28px}
154
- .lks-pill{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:none;border-radius:24px;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;font-weight:500;transition:background .12s ease,color .12s ease}
155
- .lks-pill:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
156
- .lks-pill:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
157
- .lks-pill.on{background:var(--dsw-alias-state-business-tertiary);color:var(--dsw-alias-label-primary-bluish)}
158
- .lks-pill.on:hover{background:var(--dsw-alias-state-business-tertiary);color:var(--dsw-alias-label-primary-bluish)}
159
-
160
- /* SVG glyph alignment (status icons, tag badges, dock segments, icon buttons) */
161
- .lks-g svg{display:block;margin:0 auto}
162
- .lks-tag{display:inline-flex;align-items:center;gap:2px;line-height:1}
163
- .lks-dockseg{display:inline-flex;align-items:center;gap:3px}
164
- .lks-dock-due svg,.lks-dock-streak svg{flex:none}
165
- .lks-dock-due.lks-muted svg,.lks-dock-streak.lks-muted svg{opacity:.75}
166
- .lks-btn svg,.lks-viewtab svg,.lks-sechead svg{flex:none}
167
- .lks-viewtab svg,.lks-btn svg{align-self:center}
168
-
169
- /* ic_ds_* glyph carriers + busy spinner (native .8s linear spin) */
170
- @keyframes lks-spin{to{transform:rotate(360deg)}}
171
- .lks-spin{animation:lks-spin .8s linear infinite}
172
-
173
- /* dormant state: pills/starters stay visible but inert (aria-disabled, same
174
- contract as locked lesson rows — focusable so the hint explains why) */
175
- .lks-pill[aria-disabled='true'],.lks-starter[aria-disabled='true']{opacity:.45;cursor:not-allowed}
176
- .lks-pill[aria-disabled='true']:hover,.lks-starter[aria-disabled='true']:hover{background:transparent;border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}
177
- .lks-dormant{font-size:13px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);border:1px dashed var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px;margin:8px 0;line-height:1.7}
178
-
179
- /* starter chips */
180
- .lks-dock{display:flex;align-items:center;flex-wrap:wrap;gap:6px;padding:0 2px 6px;flex:none}
181
- .lks-starter{border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:4px 12px;font-size:13px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);transition:border-color .12s ease,color .12s ease}
182
- .lks-starter:hover{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary)}
183
-
184
- /* proposal banner + shared buttons */
185
- /* A mastery proposal is a positive, informational moment — business tint, not
186
- the warn tint it previously wore (a reviewer flagged the semantic mismatch). */
187
- .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}
188
- .lks-banner .lks-why{flex:1;color:var(--dsw-alias-label-secondary)}
189
- .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}
190
- .lks-btn:focus-visible,.lks-starter:focus-visible,.lks-switch-btn:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
191
- .lks-btn.primary{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 86%,#000);color:#fff}
192
- .lks-btn.primary:hover{filter:brightness(1.1)}
193
- .lks-btn.primary:disabled{opacity:.5;cursor:default;filter:none}
194
- .lks-btn.ghost{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l2)}
195
- .lks-btn.ghost:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
196
-
197
- /* ── right column: blackboard ── */
198
- .lks-lessonhead h2{font-size:18px;margin:0 0 4px;font-weight:700}
199
- .lks-lessonhead .lks-meta{color:var(--dsw-alias-label-secondary);font-size:13px;margin-bottom:4px}
200
- .lks-chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0}
201
- .lks-chip{border-radius:999px;padding:2px 10px;font-size:12.5px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}
202
- .lks-chip.weak{background:var(--dsw-alias-state-warn-tertiary);color:var(--lks-warn-ink)}
203
-
204
- /* blackboard view tabs (same joined-capsule button language as the narrow
205
- mode switcher — buttons, not tabs, against the host's view tabs above) */
206
- .lks-viewtabs{display:flex;gap:2px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px;width:max-content;margin:10px 0}
207
- .lks-viewtab{border-radius:999px;padding:4px 16px;font-size:13.5px;color:var(--dsw-alias-label-secondary);background:transparent;transition:background .12s ease,color .12s ease}
208
- .lks-viewtab:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
209
- .lks-viewtab.on{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 86%,#000);color:#fff;font-weight:600}
210
- .lks-viewtab:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
211
-
212
- /* rendered markdown (host-sanitized lesson bodies and assistant replies) */
213
- .lks-prose{font-size:16px;line-height:1.75}
214
- .lks-prose h1,.lks-prose h2,.lks-prose h3,.lks-prose h4{margin:14px 0 6px;line-height:1.4}
215
- .lks-prose p{margin:6px 0}
216
- .lks-prose pre{background:var(--dsw-alias-markdown-code-block);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px;overflow-x:auto;margin:8px 0;font-size:13.5px;font-family:var(--dsw-font-markdown-code-block-small)}
217
- .lks-prose code{font-family:var(--dsw-font-markdown-code);background:var(--dsw-alias-bg-layer-3);border-radius:4px;padding:1px 5px;font-size:.92em}
218
- .lks-prose pre code{background:none;padding:0}
219
- .lks-prose table{border-collapse:collapse;margin:8px 0;display:block;max-width:100%;overflow-x:auto}
220
- .lks-prose th,.lks-prose td{border:1px solid var(--dsw-alias-border-l2);padding:5px 11px;font-size:14px}
221
- .lks-prose th{background:var(--dsw-alias-bg-layer-2)}
222
- .lks-prose blockquote{border-left:3px solid var(--dsw-alias-state-business-primary);padding:2px 12px;color:var(--dsw-alias-label-secondary);margin:8px 0}
223
- .lks-prose ul,.lks-prose ol{padding-left:22px;margin:6px 0}
224
- .lks-prose img{max-width:100%}
225
- /* width guards for CDN-rendered artifacts (both teach and diagram panes are
226
- .lks-prose): mermaid/elk SVGs carry fixed width attributes; KaTeX
227
- display math can exceed the column; both must degrade to fit/scroll
228
- internally instead of stretching the blackboard. */
229
- .lks-prose svg{max-width:100%;height:auto}
230
- .lks-prose .katex-display{overflow-x:auto;overflow-y:hidden;padding:2px 0}
231
-
232
- /* Cornell notes zones */
233
- .lks-bb-notes{margin-top:14px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}
234
- .lks-zone{margin-bottom:14px}
235
- .lks-zone-h{font-size:14px;color:var(--dsw-alias-label-secondary);margin:0 0 8px;font-weight:600}
155
+ /* note cards (shared: panel notebook + settings-era shapes) */
236
156
  .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}
237
157
  .lks-note .lks-note-src{float:right;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
238
158
  .lks-note-del{float:right;clear:right;border:none;background:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;padding:2px;border-radius:5px;line-height:0}
@@ -246,34 +166,51 @@ window.__ModuleLoader__.load({
246
166
  .lks-note .lks-note-text code{font-family:var(--dsw-font-markdown-code);background:var(--dsw-alias-bg-layer-3);border-radius:4px;padding:1px 4px;font-size:.95em}
247
167
  .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}
248
168
 
249
- /* inline error text (write-action failures) */
169
+ /* shared buttons + inline error text */
170
+ .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;font-family:var(--dsw-font-family);cursor:pointer}
171
+ .lks-btn:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
172
+ .lks-btn.primary{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 86%,#000);color:#fff}
173
+ .lks-btn.primary:hover{filter:brightness(1.1)}
174
+ .lks-btn.primary:disabled{opacity:.5;cursor:default;filter:none}
175
+ .lks-btn.ghost{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l2)}
176
+ .lks-btn.ghost:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
250
177
  .lks-propcard-err{color:var(--dsw-alias-state-error-primary);font-size:12px;margin-top:6px;flex:none}
251
178
 
252
- /* read-aloud bar (teach pane, under the view tabs) */
179
+ /* read-aloud bar (notebook teach pane) + busy spinner */
180
+ @keyframes lks-spin{to{transform:rotate(360deg)}}
181
+ .lks-spin{animation:lks-spin .8s linear infinite}
253
182
  .lks-readbar{display:flex;align-items:center;gap:6px;margin:0 0 8px;min-height:26px}
254
183
  .lks-readbar .lks-btn{display:inline-flex;align-items:center;gap:4px}
255
184
  .lks-readbar-cur{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--dsw-alias-label-secondary);border-left:2px solid var(--dsw-alias-state-info-primary,var(--dsw-alias-business-primary));padding-left:8px}
256
185
  .lks-readbar-notice{flex:none;font-size:11.5px;color:var(--dsw-alias-label-tertiary)}
257
186
 
187
+ /* concept map legend (amber = weak) */
188
+ .lks-cmap-legend{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--dsw-alias-label-secondary);margin-top:4px}
189
+ .lks-cmap-legend i{width:10px;height:10px;border-radius:3px;background:var(--dsw-alias-state-warn-tertiary,#fef5e7);border:1px solid var(--dsw-alias-state-warn-primary,#dd8629)}
190
+
258
191
  /* settings section (settings.section entry inside the host settings shell) */
259
192
  .lks-settings{display:flex;flex-direction:column;gap:18px;font-family:var(--dsw-font-family);color:var(--dsw-alias-label-primary)}
260
193
  .lks-set-row h3{margin:0 0 4px;font-size:15px;font-weight:600}
261
194
  .lks-set-hint{margin:0 0 10px;font-size:13px;color:var(--dsw-alias-label-secondary)}
262
195
  .lks-set-state{margin-left:10px;font-size:13px;color:var(--dsw-alias-label-secondary)}
196
+ .lks-set-select{font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:5px 8px;max-width:100%}
263
197
  .lks-set-stats{margin:0;padding-left:18px;font-size:14px;line-height:1.9}
264
198
  .lks-set-path{font-family:var(--dsw-font-markdown-code);font-size:12.5px;background:var(--dsw-alias-bg-layer-3);border-radius:6px;padding:3px 8px;word-break:break-all}
265
199
 
200
+ /* soul pills (settings reuse) */
201
+ .lks-pills{display:inline-flex;align-items:center;height:28px}
202
+ .lks-pill{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:none;border-radius:24px;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;font-weight:500;transition:background .12s ease,color .12s ease;font-family:var(--dsw-font-family);cursor:pointer}
203
+ .lks-pill:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
204
+ .lks-pill:focus-visible{box-shadow:0 0 0 2px var(--dsw-alias-state-business-primary);outline:none}
205
+ .lks-pill.on{background:var(--dsw-alias-state-business-tertiary);color:var(--dsw-alias-label-primary-bluish)}
206
+
266
207
  /* composer dock status pill (conversation.composer.dock entry) */
267
208
  .lks-dockpill{display:inline-flex;gap:10px;align-items:center;font-size:12px;color:var(--dsw-alias-label-secondary)}
268
- .lks-dockseg{white-space:nowrap}
209
+ .lks-dockseg{white-space:nowrap;display:inline-flex;align-items:center;gap:3px}
269
210
  .lks-dock-due{color:var(--lks-warn-ink)}
270
211
  .lks-dock-streak{color:var(--dsw-alias-state-business-primary)}
271
212
  .lks-dockseg.lks-muted{color:var(--dsw-alias-label-secondary)}
272
213
 
273
- /* concept map legend (amber = weak) */
274
- .lks-cmap-legend{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--dsw-alias-label-secondary);margin-top:4px}
275
- .lks-cmap-legend i{width:10px;height:10px;border-radius:3px;background:var(--dsw-alias-state-warn-tertiary,#fef5e7);border:1px solid var(--dsw-alias-state-warn-primary,#dd8629)}
276
-
277
214
  /* keyed tool.call.toolview cards (conversation tab tool rows) */
278
215
  .lks-tv{display:flex;flex-direction:column;gap:4px;padding:6px 10px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);font-size:13px}
279
216
  .lks-tv.err{border-color:var(--dsw-alias-state-error-primary)}
@@ -281,6 +218,7 @@ window.__ModuleLoader__.load({
281
218
  .lks-tv-chip{align-self:flex-start;border-radius:999px;padding:2px 10px;font-size:12.5px;font-weight:600}
282
219
  .lks-tv-chip.ok{background:var(--dsw-alias-state-success-tertiary);color:var(--lks-ok-ink)}
283
220
  .lks-tv-chip.bad{background:var(--dsw-alias-state-error-tertiary);color:var(--lks-err-ink)}
221
+ .lks-tv-lines{display:flex;flex-direction:column;gap:4px}
284
222
  .lks-tv-line{color:var(--dsw-alias-label-secondary);line-height:1.6;white-space:pre-wrap}
285
223
  /* exam star card + the failure moment */
286
224
  .lks-tv-stars{display:inline-flex;align-items:center;gap:5px;font-size:13px;font-weight:600;color:var(--lks-warn-ink)}
@@ -318,25 +256,6 @@ window.__ModuleLoader__.load({
318
256
  * language — those are NOT ic_ds, see their section comment below.
319
257
  * @module dsh-plugin-lookatstudy/client/icons
320
258
  */
321
- /** ic_ds_think_outline_16 */
322
- function IconThinkOutline16({ size = 16, className }) {
323
- return (0, react.createElement)("svg", {
324
- width: size,
325
- height: size,
326
- className,
327
- viewBox: "0 0 16 16",
328
- fill: "none",
329
- xmlns: "http://www.w3.org/2000/svg"
330
- }, (0, react.createElement)("path", {
331
- d: "M8.00192 6.64454C8.75026 6.64454 9.35732 7.25169 9.35739 8.00001C9.35739 8.74838 8.7503 9.35548 8.00192 9.35548C7.25367 9.35533 6.64743 8.74829 6.64743 8.00001C6.6475 7.25178 7.25371 6.64468 8.00192 6.64454Z",
332
- fill: "currentColor"
333
- }), (0, react.createElement)("path", {
334
- "fill-rule": "evenodd",
335
- "clip-rule": "evenodd",
336
- d: "M9.97165 1.29981C11.5853 0.718916 13.271 0.642197 14.3144 1.68555C15.3577 2.72902 15.2811 4.41466 14.7002 6.02833C14.4707 6.66561 14.1504 7.32937 13.75 8.00001C14.1504 8.67062 14.4707 9.33444 14.7002 9.97169C15.2811 11.5854 15.3578 13.271 14.3144 14.3145C13.271 15.3579 11.5854 15.2811 9.97165 14.7002C9.3344 14.4708 8.67059 14.1505 7.99997 13.75C7.32933 14.1505 6.66558 14.4708 6.02829 14.7002C4.41461 15.2811 2.72899 15.3578 1.68552 14.3145C0.642155 13.271 0.71887 11.5854 1.29977 9.97169C1.52915 9.33454 1.84865 8.67049 2.24899 8.00001C1.84866 7.32953 1.52915 6.66544 1.29977 6.02833C0.718852 4.41459 0.64207 2.729 1.68552 1.68555C2.72897 0.642112 4.41456 0.718887 6.02829 1.29981C6.66541 1.52918 7.32949 1.8487 7.99997 2.24903C8.67045 1.84869 9.33451 1.52919 9.97165 1.29981ZM12.9404 9.2129C12.4391 9.893 11.8616 10.5681 11.2148 11.2149C10.568 11.8616 9.89296 12.4391 9.21286 12.9404C9.62532 13.1579 10.0271 13.338 10.4121 13.4766C11.9146 14.0174 12.9172 13.8738 13.3955 13.3955C13.8737 12.9173 14.0174 11.9146 13.4765 10.4121C13.3379 10.0271 13.1578 9.62535 12.9404 9.2129ZM3.05856 9.2129C2.84121 9.62523 2.66197 10.0272 2.52341 10.4121C1.98252 11.9146 2.12627 12.9172 2.60446 13.3955C3.08278 13.8737 4.08544 14.0174 5.58786 13.4766C5.97264 13.338 6.37389 13.1577 6.7861 12.9404C6.10624 12.4393 5.43168 11.8614 4.78513 11.2149C4.13823 10.5679 3.55992 9.89313 3.05856 9.2129ZM7.99899 3.792C7.23179 4.31419 6.45306 4.95512 5.70407 5.70411C4.95509 6.45309 4.31415 7.23184 3.79196 7.99903C4.3143 8.76666 4.95471 9.54653 5.70407 10.2959C6.45309 11.0449 7.23271 11.6848 7.99997 12.207C8.76725 11.6848 9.54683 11.0449 10.2959 10.2959C11.0449 9.54686 11.6848 8.76729 12.207 8.00001C11.6848 7.23275 11.0449 6.45312 10.2959 5.70411C9.5465 4.95475 8.76662 4.31434 7.99899 3.792ZM5.58786 2.52344C4.08533 1.98255 3.08272 2.12625 2.60446 2.6045C2.12621 3.08275 1.98252 4.08536 2.52341 5.5879C2.66189 5.97253 2.8414 6.37409 3.05856 6.78614C3.55983 6.10611 4.1384 5.43189 4.78513 4.78516C5.43186 4.13843 6.10606 3.55987 6.7861 3.0586C6.37405 2.84144 5.97249 2.66192 5.58786 2.52344ZM13.3955 2.6045C12.9172 2.12631 11.9146 1.98257 10.4121 2.52344C10.0272 2.66201 9.62519 2.84125 9.21286 3.0586C9.8931 3.55996 10.5679 4.13827 11.2148 4.78516C11.8614 5.43172 12.4392 6.10627 12.9404 6.78614C13.1577 6.37393 13.338 5.97267 13.4765 5.5879C14.0174 4.08549 13.8736 3.08281 13.3955 2.6045Z",
337
- fill: "currentColor"
338
- }));
339
- }
340
259
  /** ic_ds_loading_outline_16 */
341
260
  function IconLoadingOutline16({ size = 16, className }) {
342
261
  return (0, react.createElement)("svg", {
@@ -365,38 +284,6 @@ window.__ModuleLoader__.load({
365
284
  fill: "currentColor"
366
285
  }));
367
286
  }
368
- /** Vendored 2026-09-01 for the critique icon round (same figma source as the three above). */
369
- /** IconPlayOutline16 */
370
- function IconPlayOutline16({ size = 16, className }) {
371
- return (0, react.createElement)("svg", {
372
- width: size,
373
- height: size,
374
- className,
375
- viewBox: "0 0 16 16",
376
- fill: "none",
377
- xmlns: "http://www.w3.org/2000/svg"
378
- }, (0, react.createElement)("path", {
379
- d: "M14.1446 8C14.1446 4.6062 11.3938 1.85539 8 1.85539C4.6062 1.85539 1.85539 4.6062 1.85539 8C1.85539 11.3938 4.6062 14.1446 8 14.1446C11.3938 14.1446 14.1446 11.3938 14.1446 8ZM15.511 8C15.511 12.148 12.148 15.511 8 15.511C3.85202 15.511 0.489014 12.148 0.489014 8C0.489014 3.85202 3.85202 0.489014 8 0.489014C12.148 0.489014 15.511 3.85202 15.511 8Z",
380
- fill: "currentColor"
381
- }), (0, react.createElement)("path", {
382
- d: "M10.5617 8.42578C10.852 8.21614 10.852 7.78386 10.5617 7.57422L7.25708 5.18751C6.90974 4.93666 6.42436 5.18484 6.42436 5.61329V10.3867C6.42436 10.8152 6.90974 11.0633 7.25708 10.8125L10.5617 8.42578Z",
383
- fill: "currentColor"
384
- }));
385
- }
386
- /** IconPlusOutline16 */
387
- function IconPlusOutline16({ size = 16, className }) {
388
- return (0, react.createElement)("svg", {
389
- width: size,
390
- height: size,
391
- className,
392
- viewBox: "0 0 16 16",
393
- fill: "none",
394
- xmlns: "http://www.w3.org/2000/svg"
395
- }, (0, react.createElement)("path", {
396
- d: "M8.64453 1.5V7.34961H14.5V8.65039H8.64453V14.5H7.34473V8.65039H1.5V7.34961H7.34473V1.5H8.64453Z",
397
- fill: "currentColor"
398
- }));
399
- }
400
287
  /** IconTrashOutline16 */
401
288
  function IconTrashOutline16({ size = 16, className }) {
402
289
  return (0, react.createElement)("svg", {
@@ -459,26 +346,6 @@ window.__ModuleLoader__.load({
459
346
  fill: "currentColor"
460
347
  }));
461
348
  }
462
- /** IconWarningOutline16 */
463
- function IconWarningOutline16({ size = 16, className }) {
464
- return (0, react.createElement)("svg", {
465
- width: size,
466
- height: size,
467
- className,
468
- viewBox: "0 0 16 16",
469
- fill: "none",
470
- xmlns: "http://www.w3.org/2000/svg"
471
- }, (0, react.createElement)("path", {
472
- d: "M6.3002 3.32843L7.69986 3.32843L7.69986 7.79657H6.3002L6.3002 3.32843Z",
473
- fill: "currentColor"
474
- }), (0, react.createElement)("path", {
475
- d: "M6.3002 9.01935H7.69986V10.6711H6.3002V9.01935Z",
476
- fill: "currentColor"
477
- }), (0, react.createElement)("path", {
478
- d: "M12.6328 6.99976C12.6328 3.88874 10.111 1.36694 7 1.36694C3.88899 1.36695 1.3672 3.88875 1.36719 6.99976C1.36719 10.1108 3.88899 12.6326 7 12.6326C10.111 12.6326 12.6328 10.1108 12.6328 6.99976ZM13.8582 6.99976C13.8582 10.7873 10.7876 13.8579 7 13.8579C3.21244 13.8579 0.141846 10.7873 0.141846 6.99976C0.141857 3.2122 3.21245 0.141612 7 0.141602C10.7876 0.141602 13.8581 3.21219 13.8582 6.99976Z",
479
- fill: "currentColor"
480
- }));
481
- }
482
349
  /**
483
350
  * Plugin-original companions for learning-domain glyphs the host set does not
484
351
  * carry (lock/star/book/crown/bolt/power/pin/flame) — authored here in the
@@ -559,41 +426,6 @@ window.__ModuleLoader__.load({
559
426
  fill: "currentColor"
560
427
  }));
561
428
  }
562
- /** IconPowerFill16 */
563
- function IconPowerFill16({ size = 16, className }) {
564
- return (0, react.createElement)("svg", {
565
- width: size,
566
- height: size,
567
- className,
568
- viewBox: "0 0 16 16",
569
- fill: "none",
570
- xmlns: "http://www.w3.org/2000/svg"
571
- }, (0, react.createElement)("path", {
572
- d: "M7.2 1h1.6v5.6H7.2V1Z",
573
- fill: "currentColor"
574
- }), (0, react.createElement)("path", {
575
- "fill-rule": "evenodd",
576
- "clip-rule": "evenodd",
577
- d: "M4.55 3.15A6 6 0 1 0 11.45 3.15L12.5 4.2a7.5 7.5 0 1 1-9 0l1.05-1.05Z",
578
- fill: "currentColor"
579
- }));
580
- }
581
- /** IconPinFill16 */
582
- function IconPinFill16({ size = 16, className }) {
583
- return (0, react.createElement)("svg", {
584
- width: size,
585
- height: size,
586
- className,
587
- viewBox: "0 0 16 16",
588
- fill: "none",
589
- xmlns: "http://www.w3.org/2000/svg"
590
- }, (0, react.createElement)("path", {
591
- "fill-rule": "evenodd",
592
- "clip-rule": "evenodd",
593
- d: "M8 1.4a4.8 4.8 0 0 1 4.8 4.8c0 3.4-4.8 8.4-4.8 8.4S3.2 9.6 3.2 6.2A4.8 4.8 0 0 1 8 1.4Zm0 2.7a2.1 2.1 0 1 0 0 4.2 2.1 2.1 0 0 0 0-4.2Z",
594
- fill: "currentColor"
595
- }));
596
- }
597
429
  /** IconFlameFill16 */
598
430
  function IconFlameFill16({ size = 16, className }) {
599
431
  return (0, react.createElement)("svg", {
@@ -754,6 +586,51 @@ window.__ModuleLoader__.load({
754
586
  return bytes.buffer;
755
587
  }
756
588
  };
589
+ /** The voices the dashboard route accepts (same allowlist server-side). */
590
+ const TTS_VOICES = [
591
+ {
592
+ id: "zh-CN-XiaoxiaoNeural",
593
+ labelKey: "voice.xiaoxiao"
594
+ },
595
+ {
596
+ id: "zh-CN-YunxiNeural",
597
+ labelKey: "voice.yunxi"
598
+ },
599
+ {
600
+ id: "zh-CN-YunyangNeural",
601
+ labelKey: "voice.yunyang"
602
+ },
603
+ {
604
+ id: "zh-CN-XiaoyiNeural",
605
+ labelKey: "voice.xiaoyi"
606
+ },
607
+ {
608
+ id: "en-US-AriaNeural",
609
+ labelKey: "voice.aria"
610
+ },
611
+ {
612
+ id: "en-US-GuyNeural",
613
+ labelKey: "voice.guy"
614
+ }
615
+ ];
616
+ const TTS_VOICE_KEY = "dsh-plugin-lookatstudy:tts-voice";
617
+ /** Narrow a stored voice id to the allowlist; anything else falls back to 晓晓. Pure. */
618
+ function normalizeStoredVoice(raw) {
619
+ return TTS_VOICES.some((v) => v.id === raw) ? raw : TTS_VOICES[0].id;
620
+ }
621
+ /** The selected read-aloud voice (browser-local preference; host state untouched). */
622
+ function storedTtsVoice() {
623
+ try {
624
+ return normalizeStoredVoice(typeof localStorage === "undefined" ? null : localStorage.getItem(TTS_VOICE_KEY));
625
+ } catch {
626
+ return TTS_VOICES[0].id;
627
+ }
628
+ }
629
+ function storeTtsVoice(id) {
630
+ try {
631
+ localStorage.setItem(TTS_VOICE_KEY, normalizeStoredVoice(id));
632
+ } catch {}
633
+ }
757
634
  /** The one shared store instance backing every study seat component. */
758
635
  const studyStore = new StudyStore();
759
636
  /** React binding: live snapshot plus the write actions. */
@@ -1592,6 +1469,8 @@ window.__ModuleLoader__.load({
1592
1469
  "rail.empty.hint2": "本地文件夹 → 说「导入 D:/path/to/folder」",
1593
1470
  "rail.empty.placeholder": "GitHub 仓库链接,如 microsoft/AI-For-Beginners",
1594
1471
  "rail.empty.button": "导入",
1472
+ "composer.send": "发送",
1473
+ "composer.busy": "导师正在回复…",
1595
1474
  "rail.empty.demo": "导入示例课程",
1596
1475
  "rail.mastered": "毕业 {mastered}/{total} 课",
1597
1476
  "rail.avg": "平均掌握度 {pct}%",
@@ -1664,6 +1543,14 @@ window.__ModuleLoader__.load({
1664
1543
  "settings.mode": "教学风格",
1665
1544
  "settings.mode.hint": "导师的讲解风格;也可在学习页随时切换",
1666
1545
  "settings.studyMode": "学习模式",
1546
+ "settings.voice": "朗读语音",
1547
+ "settings.voice.hint": "「朗读本课」使用的神经网络声音(浏览器本地偏好)",
1548
+ "voice.xiaoxiao": "晓晓 · 中文女声",
1549
+ "voice.yunxi": "云希 · 中文男声",
1550
+ "voice.yunyang": "云扬 · 中文男声(新闻)",
1551
+ "voice.xiaoyi": "晓伊 · 中文女声(轻快)",
1552
+ "voice.aria": "Aria · English female",
1553
+ "voice.guy": "Guy · English male",
1667
1554
  "settings.studyMode.hint": "开启后注册 study_* 工具并载入导师人格;进度与笔记始终保留",
1668
1555
  "settings.on": "状态:已开启",
1669
1556
  "settings.off": "状态:已关闭",
@@ -1720,6 +1607,8 @@ window.__ModuleLoader__.load({
1720
1607
  "rail.empty.hint2": "Local folder → say \"import D:/path/to/folder\"",
1721
1608
  "rail.empty.placeholder": "GitHub repo link, e.g. microsoft/AI-For-Beginners",
1722
1609
  "rail.empty.button": "Import",
1610
+ "composer.send": "Send",
1611
+ "composer.busy": "The tutor is replying…",
1723
1612
  "rail.empty.demo": "Import the demo course",
1724
1613
  "rail.mastered": "{mastered}/{total} lessons graduated",
1725
1614
  "rail.avg": "avg mastery {pct}%",
@@ -1792,6 +1681,14 @@ window.__ModuleLoader__.load({
1792
1681
  "settings.mode": "Teaching style",
1793
1682
  "settings.mode.hint": "The tutor's explanation style; switchable anytime in the study tab too",
1794
1683
  "settings.studyMode": "Study mode",
1684
+ "settings.voice": "Read-aloud voice",
1685
+ "settings.voice.hint": "The neural voice used by 朗读本课 (a browser-local preference)",
1686
+ "voice.xiaoxiao": "Xiaoxiao · Chinese female",
1687
+ "voice.yunxi": "Yunxi · Chinese male",
1688
+ "voice.yunyang": "Yunyang · Chinese male (news)",
1689
+ "voice.xiaoyi": "Xiaoyi · Chinese female (lively)",
1690
+ "voice.aria": "Aria · English female",
1691
+ "voice.guy": "Guy · English male",
1795
1692
  "settings.studyMode.hint": "On registers the study_* tools and the tutor persona; progress and notes are always kept",
1796
1693
  "settings.on": "Status: on",
1797
1694
  "settings.off": "Status: off",
@@ -2286,6 +2183,74 @@ window.__ModuleLoader__.load({
2286
2183
  });
2287
2184
  }
2288
2185
  //#endregion
2186
+ //#region src/client/session-feed.ts
2187
+ function userText(event) {
2188
+ const content = event.data?.content;
2189
+ if (content === void 0) return "";
2190
+ return content.filter((part) => (part.type ?? "text") === "text").map((part) => part.text ?? "").join("\n\n").trim();
2191
+ }
2192
+ function assistantText(event) {
2193
+ const message = event.data?.message;
2194
+ if (message === void 0) return "";
2195
+ return (message.content ?? message.blocks ?? []).filter((block) => (block.kind ?? block.type) === "text").map((block) => block.text ?? "").join("\n\n").trim();
2196
+ }
2197
+ /**
2198
+ * Fold one event-window snapshot into ordered chat rows. The transient
2199
+ * `text-delta` chunks of the most recent attempt accumulate into a single
2200
+ * streaming row (the durable `assistant/message` replaces it on settlement).
2201
+ * @param window - the bound session's event window snapshot, or undefined.
2202
+ */
2203
+ function feedRows(window) {
2204
+ if (window === void 0) return [];
2205
+ const rows = [];
2206
+ const streams = /* @__PURE__ */ new Map();
2207
+ let latest;
2208
+ for (const entry of window.entries) {
2209
+ const event = entry?.event;
2210
+ if (event === void 0) continue;
2211
+ if (entry.type === "event") {
2212
+ if (event.type === "user/message") {
2213
+ const text = userText(event);
2214
+ if (text !== "") rows.push({
2215
+ key: `u${event.seq}`,
2216
+ role: "user",
2217
+ text
2218
+ });
2219
+ } else if (event.type === "assistant/message") {
2220
+ const text = assistantText(event);
2221
+ if (text !== "") rows.push({
2222
+ key: `a${event.seq}`,
2223
+ role: "assistant",
2224
+ text
2225
+ });
2226
+ }
2227
+ for (const s of streams.values()) if (rows.length > s.anchor) s.settled = true;
2228
+ continue;
2229
+ }
2230
+ const chunk = event.data?.chunk;
2231
+ if (chunk === void 0 || chunk.type !== "text-delta") continue;
2232
+ const attemptId = event.data?.attemptId ?? String(event.seq);
2233
+ const stream = streams.get(attemptId);
2234
+ if (stream === void 0) streams.set(attemptId, {
2235
+ text: chunk.text ?? "",
2236
+ anchor: rows.length,
2237
+ settled: false
2238
+ });
2239
+ else stream.text += chunk.text ?? "";
2240
+ latest = attemptId;
2241
+ }
2242
+ const stream = latest !== void 0 ? streams.get(latest) : void 0;
2243
+ if (stream !== void 0 && !stream.settled) {
2244
+ const text = stream.text.trim();
2245
+ if (text !== "") rows.splice(stream.anchor, 0, {
2246
+ key: "streaming",
2247
+ role: "streaming",
2248
+ text
2249
+ });
2250
+ }
2251
+ return rows;
2252
+ }
2253
+ //#endregion
2289
2254
  //#region src/client/readaloud.ts
2290
2255
  const sleep = (ms) => new Promise((resolve) => {
2291
2256
  setTimeout(resolve, ms);
@@ -2353,7 +2318,10 @@ window.__ModuleLoader__.load({
2353
2318
  return this.run;
2354
2319
  }
2355
2320
  async speakOn(text) {
2356
- await (this.engine === "system" ? this.fallback : this.primary).speak(text);
2321
+ const impl = this.engine === "system" ? this.fallback : this.primary;
2322
+ const next = this.sentences[this.index + 1];
2323
+ if (next !== void 0) impl.prewarm?.(next);
2324
+ await impl.speak(text);
2357
2325
  while (this.paused && !this.stopped) await sleep(120);
2358
2326
  }
2359
2327
  pause() {
@@ -2389,97 +2357,20 @@ window.__ModuleLoader__.load({
2389
2357
  //#endregion
2390
2358
  //#region src/client/views.tsx
2391
2359
  /**
2392
- * The study tab: one `conversation.view` entry rendering the whole plugin
2393
- * a simplified LookatStudy inside dsh, three columns in the tab. Left: the
2394
- * course map (selector, tree, due box, one-click demo import). Middle: the
2395
- * tutor conversation a read-only transcript folded from the session
2396
- * snapshot (user text, assistant text rendered through the plugin's own
2397
- * markdown pipeline; tool calls stay in the host conversation's toolview
2398
- * cards — a mirrored second record diverged) plus the soul pills, starters,
2399
- * pending proposal banner, and a reverse-channel input. Right: the blackboard —
2400
- * focus-lesson 讲解 and the Cornell 笔记 zones. Nothing outside this tab
2401
- * touches dsh chrome.
2360
+ * Shared pure helpers for the study panel's pieces: chat-row shapes, tree
2361
+ * status copy, quiz-option extraction, and section default expansion. The
2362
+ * panel itself lives in `panel.tsx`; since 0.14.0 nothing here touches the
2363
+ * host composer or the conversation view.
2402
2364
  * @module dsh-plugin-lookatstudy/client/views
2403
2365
  */
2404
- /** The three souls, in pill order (labels from LookatStudy's mode switcher). */
2405
- const MODES$1 = [
2406
- {
2407
- id: "direct",
2408
- labelKey: "soul.direct",
2409
- hintKey: "soul.direct.hint"
2410
- },
2411
- {
2412
- id: "guide",
2413
- labelKey: "soul.guide",
2414
- hintKey: "soul.guide.hint"
2415
- },
2416
- {
2417
- id: "practice",
2418
- labelKey: "soul.practice",
2419
- hintKey: "soul.practice.hint"
2420
- }
2421
- ];
2422
- /** Zone keys for the Cornell notebook (understand / record / practice). */
2423
- const ZONES = [
2424
- ["understand", "zone.understand"],
2425
- ["record", "zone.record"],
2426
- ["practice", "zone.practice"]
2427
- ];
2428
- /** Status icon for one lesson row (LookatStudy's map icon semantics, SVG glyphs). */
2429
- function statusIcon(kind, status) {
2430
- if (kind === "exam") return (0, react.createElement)(IconGoalOutline16, { size: 14 });
2431
- if (status === "mastered") return (0, react.createElement)(IconCrownFill16, { size: 14 });
2432
- if (status === "in_progress") return (0, react.createElement)(IconBookFill16, { size: 14 });
2433
- if (status === "available") return (0, react.createElement)(IconStarFill16, { size: 14 });
2434
- return (0, react.createElement)(IconLockFill16, { size: 14 });
2435
- }
2436
- /** LookatStudy's exam gate: an exam node opens only when every sibling study lesson reached mastery ≥50%. */
2437
- function examOpen(lessons) {
2438
- return lessons.every((l) => l.kind !== "study" || (l.masteryPct ?? 0) >= 50);
2439
- }
2440
- /** Multi-keyword AND title filter (LookatStudy course-tree-filter). */
2441
- function titleMatches(title, query) {
2442
- return query.trim().toLowerCase().split(/\s+/).filter(Boolean).every((k) => title.toLowerCase().includes(k));
2443
- }
2444
- /**
2445
- * Default expansion for one rail section: collapsed when every study lesson is
2446
- * done (mastered) or not yet reachable (locked) — long courses otherwise scroll
2447
- * 5× their viewport. The active frontier and the focus lesson's section stay
2448
- * open; exam nodes never force a section open (they are gated separately).
2449
- * Pure.
2450
- * @param section - one course section projection with lesson kinds/statuses.
2451
- */
2452
- function sectionDefaultOpen(section) {
2453
- return section.lessons.some((l) => l.focus || l.kind !== "exam" && l.status !== "mastered" && l.status !== "locked");
2454
- }
2455
- /** The rail's import row: a GitHub URL input plus the paste/folder hints. */
2456
- function ImportRow({ send }) {
2457
- const [url, setUrl] = (0, react.useState)("");
2458
- return (0, react.createElement)("div", { className: "lks-import" }, (0, react.createElement)("div", { className: "lks-inputrow" }, (0, react.createElement)("input", {
2459
- className: "lks-input",
2460
- type: "url",
2461
- placeholder: tr("rail.empty.placeholder"),
2462
- value: url,
2463
- onChange: (e) => {
2464
- setUrl(e.target.value);
2465
- },
2466
- onKeyDown: (e) => {
2467
- if (e.key === "Enter" && url.trim() !== "") send(tr("prompt.import", { url: url.trim() }));
2468
- }
2469
- }), (0, react.createElement)("button", {
2470
- className: "lks-btn primary",
2471
- disabled: url.trim() === "",
2472
- onClick: () => {
2473
- send(tr("prompt.import", { url: url.trim() }));
2474
- }
2475
- }, tr("rail.empty.button"))), (0, react.createElement)("div", { className: "lks-import-hint" }, tr("rail.empty.hint"), (0, react.createElement)("br"), tr("rail.empty.hint2")));
2366
+ /** Small inline error surface for failed write actions (shared with settings). */
2367
+ function ActionError({ error }) {
2368
+ if (error === null) return null;
2369
+ return (0, react.createElement)("div", { className: "lks-propcard-err" }, error);
2476
2370
  }
2477
2371
  /**
2478
2372
  * Tooltip text for one course-tree status glyph. Pure (the translator is an
2479
2373
  * optional parameter defaulting to the active one, evaluated per call).
2480
- * @param kind - lesson kind ('study' | 'practice' | 'exam').
2481
- * @param status - lesson status ('locked' | 'available' | 'in_progress' | 'mastered').
2482
- * @param t - translator (defaults to the module's active translator).
2483
2374
  */
2484
2375
  function statusTitle(kind, status, t = tr) {
2485
2376
  if (kind === "exam") return t("status.exam");
@@ -2494,8 +2385,6 @@ window.__ModuleLoader__.load({
2494
2385
  * Extract the last quiz option block (2–4 consecutive `A. …` `B. …` lines,
2495
2386
  * letters strictly consecutive from A) from an assistant reply — the raw text
2496
2387
  * of the tutor's question becomes the clickable answer surface. Pure.
2497
- * @param text - one assistant reply's markdown.
2498
- * @returns the options of the last valid block, or [] when none.
2499
2388
  */
2500
2389
  function quizOptions(text) {
2501
2390
  const runs = [];
@@ -2526,268 +2415,216 @@ window.__ModuleLoader__.load({
2526
2415
  flush();
2527
2416
  return runs.at(-1) ?? [];
2528
2417
  }
2529
- function textOf(blocks) {
2530
- return (blocks ?? []).filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n\n").trim();
2531
- }
2532
- function assistantText(blocks) {
2533
- return (blocks ?? []).filter((b) => b.kind === "text").map((b) => b.text ?? "").join("\n\n").trim();
2534
- }
2535
2418
  /**
2536
- * Fold conversation nodes (plus the streaming partial) into render rows.
2537
- * Tool results are skipped: the host conversation renders the rich
2538
- * `tool.call.toolview` cards, and the tutor column stays a pure persona
2539
- * surface (a transcript mirror there grew a second, diverging record).
2540
- * Both inputs tolerate undefined: newer hosts (DSH v0.1.2-alpha.4) mount
2541
- * the conversation view before session data exists, and the fold must
2542
- * degrade to an empty transcript instead of blanking the whole tab.
2543
- * @param nodes - finalized conversation nodes from the snapshot.
2544
- * @param partial - the in-flight assistant partial, or null.
2545
- * @param t - translator (defaults to the active one, per call).
2546
- * @returns ordered rows; never mutates its inputs.
2419
+ * Default expansion for one rail section: collapsed when every study lesson is
2420
+ * done (mastered) or not yet reachable (locked). The focus lesson's section
2421
+ * stays open; exam nodes never force a section open. Pure.
2547
2422
  */
2548
- function transcriptRows(nodes, partial, t = tr) {
2549
- const rows = [];
2550
- for (const node of nodes ?? []) switch (node.kind) {
2551
- case "user":
2552
- case "steering": {
2553
- const text = textOf(node.content);
2554
- if (text !== "") rows.push({
2555
- key: `u${node.seq}`,
2556
- role: "user",
2557
- text
2558
- });
2559
- break;
2560
- }
2561
- case "assistant": {
2562
- const text = assistantText(node.blocks);
2563
- if (text !== "") rows.push({
2564
- key: `a${node.seq}`,
2565
- role: "assistant",
2566
- text
2567
- });
2568
- break;
2569
- }
2570
- case "turn-error": rows.push({
2571
- key: `e${node.seq}`,
2572
- role: "error",
2573
- text: node.message ?? ""
2574
- });
2575
- }
2576
- if (partial != null) {
2577
- const text = assistantText(partial.blocks);
2578
- if (text !== "") rows.push({
2579
- key: "streaming",
2580
- role: "streaming",
2581
- text
2582
- });
2583
- else rows.push({
2584
- key: "thinking",
2585
- role: "thinking",
2586
- text: t("row.thinking")
2587
- });
2588
- }
2589
- return rows;
2590
- }
2591
- /** Small inline error surface for failed write actions (shared with the settings page). */
2592
- function ActionError({ error }) {
2593
- if (error === null) return null;
2594
- return (0, react.createElement)("div", { className: "lks-propcard-err" }, error);
2423
+ function sectionDefaultOpen(section) {
2424
+ return section.lessons.some((l) => l.focus || l.kind !== "exam" && l.status !== "mastered" && l.status !== "locked");
2595
2425
  }
2426
+ //#endregion
2427
+ //#region src/client/panel.tsx
2596
2428
  /**
2597
- * Resolve the transcript across host generations. 0.1.3+ hosts expose the
2598
- * node array as the Chat target's `legacy` slice; pre-0.1.3 hosts hand the
2599
- * whole conversation snapshot (nodes + partial) through `useSession`, whose
2600
- * 0.1.3+ successor returns the session MACHINE (no node array). The array
2601
- * check tells those two `useSession` shapes apart. Pure.
2429
+ * The study panel the sidebar-entry surface that takes over the center
2430
+ * column, arranged like upstream LookatStudy's app (App.tsx): course rail
2431
+ * (tree/import/review) | the tutor chat stream with its OWN composer (the
2432
+ * host composer is never involved) | the notebook (讲解/概念图/笔记).
2433
+ *
2434
+ * Interaction model aligned with upstream: opening a lesson only FOCUSES it
2435
+ * (state-side attempt: in_progress + BKT seed + unlocks — zero LLM); the
2436
+ * tutor engages when the learner sends a message or taps a starter, which
2437
+ * lazily mints and stages the lesson's thread and prompts through the host's
2438
+ * session face. The host is exactly the conversation-model + agent-turn
2439
+ * engine; the thread's event window streams the reply back into the chat.
2440
+ * @module dsh-plugin-lookatstudy/client/panel
2602
2441
  */
2603
- function pickTranscript(chatLegacy, sessionSnapshot) {
2604
- if (chatLegacy !== void 0) return chatLegacy;
2605
- return sessionSnapshot !== void 0 && Array.isArray(sessionSnapshot.nodes) ? sessionSnapshot : void 0;
2606
- }
2607
- /** sessionStorage key keeping the narrow-mode pane across view remounts (tab switches) and reloads. */
2608
- const PANE_KEY = "dsh-plugin-lookatstudy:pane";
2609
- /** Narrow the stored value back to a pane id; anything else (or nothing stored) falls back to 导师. Pure, unit-testable. */
2610
- function pickPane(stored) {
2611
- return stored === "rail" || stored === "tutor" || stored === "bb" ? stored : "tutor";
2612
- }
2613
- /** Read the persisted pane; the try swallows only the SecurityError privacy modes throw on storage access — the fallback is the default pane. */
2614
- function storedPane() {
2615
- try {
2616
- return pickPane(sessionStorage.getItem(PANE_KEY));
2617
- } catch {
2618
- return "tutor";
2619
- }
2620
- }
2621
- const sessionId = (id) => id;
2622
- /** The whole study tab. Wide = three columns (课程 | 导师 | 黑板); narrow (<1220px) = one composer-width pane with a three-way switcher. */
2623
- function studyView(ctx) {
2624
- return function StudyView(props) {
2625
- return (0, react.createElement)(StudyTab, {
2626
- ...props,
2627
- ctx,
2628
- key: "tab"
2442
+ /** The three souls in pill order (same shape as the study tab's pills). */
2443
+ const MODES$1 = [
2444
+ {
2445
+ id: "direct",
2446
+ labelKey: "soul.direct",
2447
+ hintKey: "soul.direct.hint"
2448
+ },
2449
+ {
2450
+ id: "guide",
2451
+ labelKey: "soul.guide",
2452
+ hintKey: "soul.guide.hint"
2453
+ },
2454
+ {
2455
+ id: "practice",
2456
+ labelKey: "soul.practice",
2457
+ hintKey: "soul.practice.hint"
2458
+ }
2459
+ ];
2460
+ const ZONES = [
2461
+ ["understand", "zone.understand"],
2462
+ ["record", "zone.record"],
2463
+ ["practice", "zone.practice"]
2464
+ ];
2465
+ function statusIcon(kind, status) {
2466
+ if (kind === "exam") return (0, react.createElement)(IconGoalOutline16, { size: 14 });
2467
+ if (status === "mastered") return (0, react.createElement)(IconCrownFill16, { size: 14 });
2468
+ if (status === "in_progress") return (0, react.createElement)(IconBookFill16, { size: 14 });
2469
+ if (status === "available") return (0, react.createElement)(IconStarFill16, { size: 14 });
2470
+ return (0, react.createElement)(IconLockFill16, { size: 14 });
2471
+ }
2472
+ function examOpen(lessons) {
2473
+ return lessons.every((l) => l.kind !== "study" || (l.masteryPct ?? 0) >= 50);
2474
+ }
2475
+ function titleMatches(title, query) {
2476
+ return query.trim().toLowerCase().split(/\s+/).filter(Boolean).every((k) => title.toLowerCase().includes(k));
2477
+ }
2478
+ /** One rendered chat row (shared shape with the read-aloud era's renderer). */
2479
+ function chatRow(row, interactive) {
2480
+ if (row.role === "user") return (0, react.createElement)("div", {
2481
+ key: row.key,
2482
+ className: "lks14-msg lks14-msg-user"
2483
+ }, row.text);
2484
+ const cls = row.role === "streaming" ? "lks14-msg lks14-msg-assistant streaming" : "lks14-msg lks14-msg-assistant";
2485
+ const body = (0, react.createElement)("div", {
2486
+ key: row.key,
2487
+ className: cls,
2488
+ dangerouslySetInnerHTML: { __html: renderMarkdown(row.text) }
2489
+ });
2490
+ const options = interactive === void 0 || row.role === "streaming" ? [] : quizOptions(row.text);
2491
+ if (options.length < 2) return body;
2492
+ return (0, react.createElement)("div", {
2493
+ key: row.key,
2494
+ className: "lks14-turn"
2495
+ }, body, (0, react.createElement)("div", {
2496
+ className: "lks14-quiz",
2497
+ title: tr("quiz.title")
2498
+ }, ...options.map((opt) => (0, react.createElement)("button", {
2499
+ key: opt.letter,
2500
+ className: "lks14-opt",
2501
+ onClick: () => {
2502
+ interactive.send(tr("quiz.answer", {
2503
+ letter: opt.letter,
2504
+ text: opt.text
2505
+ }));
2506
+ }
2507
+ }, (0, react.createElement)("span", { className: "lks14-optletter" }, opt.letter), (0, react.createElement)("span", null, opt.text)))));
2508
+ }
2509
+ /** Build the panel component bound to the client services. */
2510
+ function studyPanelView(ctx) {
2511
+ return function StudyPanel() {
2512
+ return (0, react.createElement)(StudyPanelBody, {
2513
+ ctx,
2514
+ key: "panel"
2629
2515
  });
2630
2516
  };
2631
2517
  }
2632
- /** Tab body: the factory-bound ctx carries workspaces/sessions for the per-lesson session jumps. */
2633
- function StudyTab({ inputActions, ctx, ...standard }) {
2518
+ function StudyPanelBody({ ctx }) {
2634
2519
  const { data, activate, setMode, setFocus, searchLessons, deleteCourse, deleteNote, bindLessonSession } = useStudy();
2635
- const chatLegacy = standard.useChat?.((s) => s.legacy);
2636
- const sessionSnapshot = standard.useSession?.((s) => s);
2637
- const snapshot = pickTranscript(chatLegacy, sessionSnapshot);
2638
- const currentSessionId = standard.sessionId ?? snapshot?.sessionId ?? "";
2639
- const [pane, setPane] = (0, react.useState)(storedPane);
2640
- const rootRef = (0, react.useRef)(null);
2520
+ const [sendError, setSendError] = (0, react.useState)(null);
2521
+ const [busy, setBusy] = (0, react.useState)(false);
2522
+ const [rows, setRows] = (0, react.useState)([]);
2523
+ const [draft, setDraft] = (0, react.useState)("");
2524
+ const lesson = data?.lesson ?? null;
2525
+ const localBound = (0, react.useRef)(null);
2526
+ const streamEl = (0, react.useRef)(null);
2527
+ const boundId = localBound.current ?? (lesson !== null ? data?.lessonSessions[lesson.lessonId] ?? null : null);
2641
2528
  (0, react.useEffect)(() => {
2642
- const root = rootRef.current;
2643
- const card = document.querySelector("[data-composer-card]");
2644
- const seat = card?.parentElement ?? null;
2645
- if (root === null || card === null || seat === null) return;
2646
- const clear = () => {
2647
- card.classList.remove("lks-composer-follow");
2648
- card.style.removeProperty("--lks-composer-shift");
2649
- card.style.removeProperty("--lks-composer-follow-width");
2650
- };
2651
- const apply = () => {
2652
- const tutor = root.querySelector(".lks-col-tutor");
2653
- const rail = root.querySelector(".lks-col-rail");
2654
- if (tutor === null || rail === null || getComputedStyle(rail).display === "none") return clear();
2655
- const seatRect = seat.getBoundingClientRect();
2656
- const tutorRect = tutor.getBoundingClientRect();
2657
- if (seatRect.width === 0 || tutorRect.width === 0) return clear();
2658
- const pad = parseFloat(getComputedStyle(seat).paddingLeft) || 0;
2659
- const cardW = card.getBoundingClientRect().width;
2660
- const max = seatRect.width - 2 * pad - cardW;
2661
- const shift = Math.round(Math.max(0, Math.min(tutorRect.left - (seatRect.left + pad), max)));
2662
- if (shift < 2) return clear();
2663
- card.style.setProperty("--lks-composer-shift", `${shift}px`);
2664
- card.style.setProperty("--lks-composer-follow-width", `${Math.round(tutorRect.width)}px`);
2665
- card.classList.add("lks-composer-follow");
2666
- };
2667
- apply();
2668
- const ro = new ResizeObserver(apply);
2669
- ro.observe(root);
2670
- ro.observe(seat);
2671
- window.addEventListener("resize", apply);
2672
- return () => {
2673
- ro.disconnect();
2674
- window.removeEventListener("resize", apply);
2675
- clear();
2529
+ if (boundId === null) {
2530
+ setRows([]);
2531
+ return;
2532
+ }
2533
+ const source = ctx.sessions.binding(boundId)?.eventSource;
2534
+ if (source === void 0) {
2535
+ setRows([]);
2536
+ return;
2537
+ }
2538
+ const update = () => {
2539
+ setRows(feedRows(source.getSnapshot()));
2676
2540
  };
2677
- }, []);
2541
+ update();
2542
+ return source.subscribe(update);
2543
+ }, [boundId, ctx]);
2544
+ (0, react.useEffect)(() => {
2545
+ const el = streamEl.current;
2546
+ if (el !== null) el.scrollTop = el.scrollHeight;
2547
+ }, [rows]);
2548
+ /** The one send path: lazily stage the lesson thread, then prompt. */
2678
2549
  const send = (text) => {
2679
2550
  (async () => {
2680
- if (data?.active !== true) await activate(true);
2681
- inputActions.setDraft(text);
2682
- inputActions.submit();
2551
+ if (busy || lesson === null || text.trim() === "") return;
2552
+ setBusy(true);
2553
+ setSendError(null);
2554
+ try {
2555
+ if (data?.active !== true) await activate(true);
2556
+ let sessionId = localBound.current ?? data?.lessonSessions[lesson.lessonId];
2557
+ if (sessionId === void 0 || sessionId === null) {
2558
+ const area = await fetch("/lookatstudy/api/study-workspace");
2559
+ if (!area.ok) throw new Error(`study area unavailable (HTTP ${String(area.status)})`);
2560
+ const { path } = await area.json();
2561
+ const workspace = await ctx.workspaces.create({ path });
2562
+ sessionId = await ctx.sessions.create({ workspaceId: workspace.workspaceId });
2563
+ localBound.current = sessionId;
2564
+ await bindLessonSession(lesson.lessonId, sessionId);
2565
+ }
2566
+ const shell = PANEL_SHELL.current;
2567
+ if (shell !== null) shell.suppressHandBack(() => {
2568
+ ctx.sessions.open(sessionId);
2569
+ });
2570
+ else ctx.sessions.open(sessionId);
2571
+ const actx = ctx.sessions.scope(sessionId);
2572
+ const face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx);
2573
+ if (face === void 0) throw new Error("lesson session is not addressable yet");
2574
+ const result = await face.prompt([{
2575
+ type: "text",
2576
+ text
2577
+ }], "queue");
2578
+ if (!result.ok) throw new Error(`prompt rejected: ${result.error.code}: ${result.error.message}`);
2579
+ setDraft("");
2580
+ } catch (err) {
2581
+ setSendError(err instanceof Error ? err.message : String(err));
2582
+ } finally {
2583
+ setBusy(false);
2584
+ }
2683
2585
  })();
2684
2586
  };
2685
- const [, bumpLocale] = (0, react.useState)(0);
2686
- (0, react.useEffect)(() => {
2687
- const localeSvc = ctx.locale;
2688
- if (localeSvc === void 0) return;
2689
- return localeSvc.subscribe(() => {
2690
- bumpLocale((v) => v + 1);
2691
- });
2692
- }, [ctx]);
2693
- return (0, react.createElement)("div", {
2694
- ref: rootRef,
2695
- className: "lks-root lks-study",
2696
- "data-conversation-composer-overlay": "",
2697
- "data-pane": pane
2698
- }, (0, react.createElement)("div", { className: "lks-actbar" }, data === null ? null : (0, react.createElement)("button", {
2699
- className: `lks-btn ${data.active ? "ghost" : "primary"}`,
2700
- title: data.active ? tr("active.on.title") : tr("active.off.title"),
2701
- onClick: () => {
2702
- activate(!data.active);
2703
- }
2704
- }, data.active ? (0, react.createElement)(IconPowerFill16, { size: 13 }) : (0, react.createElement)(IconPlayOutline16, { size: 13 }), data.active ? tr("active.on") : tr("active.off"))), (0, react.createElement)("div", { className: "lks-body" }, (0, react.createElement)("div", { className: "lks-switch" }, ...[
2705
- {
2706
- id: "rail",
2707
- labelKey: "pane.rail"
2708
- },
2709
- {
2710
- id: "tutor",
2711
- labelKey: "pane.tutor"
2712
- },
2713
- {
2714
- id: "bb",
2715
- labelKey: "pane.bb"
2716
- }
2717
- ].map((p) => (0, react.createElement)("button", {
2718
- key: p.id,
2719
- className: `lks-switch-btn${pane === p.id ? " on" : ""}`,
2720
- "aria-pressed": String(pane === p.id),
2721
- onClick: () => {
2722
- setPane(p.id);
2723
- try {
2724
- sessionStorage.setItem(PANE_KEY, p.id);
2725
- } catch {}
2726
- }
2727
- }, tr(p.labelKey)))), (0, react.createElement)(CourseRail, {
2587
+ const body = (0, react.createElement)("div", { className: "lks14-body" }, (0, react.createElement)(CourseRail, {
2728
2588
  data,
2729
2589
  activate,
2730
2590
  setFocus,
2731
2591
  searchLessons,
2732
2592
  deleteCourse,
2733
- bindLessonSession,
2734
- send,
2735
- ctx,
2736
- currentSessionId
2737
- }), (0, react.createElement)(TutorColumn, {
2593
+ send
2594
+ }), (0, react.createElement)(ChatPane, {
2738
2595
  data,
2739
- setMode,
2596
+ lesson,
2597
+ rows,
2598
+ busy,
2599
+ sendError,
2600
+ draft,
2601
+ setDraft,
2740
2602
  send,
2741
- snapshot
2742
- }), (0, react.createElement)(BlackboardColumn, {
2603
+ setMode
2604
+ }), (0, react.createElement)(NotebookPane, {
2743
2605
  data,
2744
2606
  deleteNote
2745
- })));
2607
+ }));
2608
+ return (0, react.createElement)("div", {
2609
+ className: "lks14",
2610
+ "data-lks-panel": ""
2611
+ }, body);
2746
2612
  }
2747
- /** Left column: course management (pick/delete/search/import), lesson tree, due box. */
2748
- function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, bindLessonSession, send, ctx, currentSessionId }) {
2749
- const [jumping, setJumping] = (0, react.useState)(null);
2750
- /** Open (or mint) the lesson's own session — the simplified thread system. */
2751
- const openLessonThread = (lesson) => {
2752
- if (jumping !== null) return;
2753
- const mapped = data?.lessonSessions[lesson.id];
2754
- if (mapped === currentSessionId) {
2755
- setFocus(lesson.id).catch(reportError);
2756
- return;
2757
- }
2758
- if (mapped !== void 0 && ctx.sessions.binding(sessionId(mapped)) !== void 0) {
2759
- setFocus(lesson.id).catch(reportError);
2760
- ctx.sessions.open(sessionId(mapped));
2761
- return;
2762
- }
2763
- setJumping(lesson.id);
2764
- setFocus(lesson.id).then(() => (async () => {
2765
- if (data?.active !== true) await activate(true);
2766
- const area = await fetch("/lookatstudy/api/study-workspace");
2767
- if (!area.ok) throw new Error(`study area unavailable (HTTP ${area.status})`);
2768
- const { path } = await area.json();
2769
- const workspace = await ctx.workspaces.create({ path });
2770
- const sessionId = ctx.workspaces.connectWorkspace !== void 0 ? await ctx.workspaces.connectWorkspace(workspace.workspaceId) : await ctx.sessions.create({ workspaceId: workspace.workspaceId });
2771
- const actx = ctx.sessions.scope(sessionId);
2772
- const face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx);
2773
- if (face === void 0) throw new Error("lesson session is not addressable yet");
2774
- const result = await face.prompt([{
2775
- type: "text",
2776
- text: tr("prompt.lesson", { title: lesson.title })
2777
- }], "queue");
2778
- if (!result.ok) throw new Error(`lesson prompt rejected: ${result.error.code}: ${result.error.message}`);
2779
- await bindLessonSession(lesson.id, sessionId);
2780
- ctx.sessions.open(sessionId);
2781
- })()).then(() => {
2782
- setJumping(null);
2783
- }, (err) => {
2784
- reportError(err);
2785
- setJumping(null);
2786
- });
2787
- };
2613
+ /** Module-level shell handle so the panel can suppress hand-back on internal opens. */
2614
+ const PANEL_SHELL = { current: null };
2615
+ function setPanelShell(shell) {
2616
+ PANEL_SHELL.current = shell;
2617
+ }
2618
+ /** 左栏:course picker, tree, review box, import. */
2619
+ function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send }) {
2788
2620
  const [selectedCourse, setSelectedCourse] = (0, react.useState)("");
2789
2621
  const [query, setQuery] = (0, react.useState)("");
2790
2622
  const [confirmDelete, setConfirmDelete] = (0, react.useState)(false);
2623
+ const [showImport, setShowImport] = (0, react.useState)(false);
2624
+ const [error, setError] = (0, react.useState)(null);
2625
+ const reportError = (err) => {
2626
+ setError(err instanceof Error ? err.message : String(err));
2627
+ };
2791
2628
  (0, react.useEffect)(() => {
2792
2629
  if (!confirmDelete) return;
2793
2630
  const disarm = () => {
@@ -2803,14 +2640,7 @@ window.__ModuleLoader__.load({
2803
2640
  window.removeEventListener("keydown", onKey);
2804
2641
  };
2805
2642
  }, [confirmDelete]);
2806
- const [showImport, setShowImport] = (0, react.useState)(false);
2807
- /** Per-section open overrides (courseId/sectionTitle → open?), on top of the frontier default. */
2808
- const [secOpen, setSecOpen] = (0, react.useState)({});
2809
- const [error, setError] = (0, react.useState)(null);
2810
- const reportError = (err) => {
2811
- setError(err instanceof Error ? err.message : String(err));
2812
- };
2813
- const body = data === null ? (0, react.createElement)("div", { className: "lks-empty" }, tr("loading")) : data.courses.length === 0 ? (0, react.createElement)("div", { className: "lks-empty" }, tr("rail.empty.title"), (0, react.createElement)("br"), (0, react.createElement)("button", {
2643
+ const body = data === null ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("loading")) : data.courses.length === 0 ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("rail.empty.title"), (0, react.createElement)("br"), (0, react.createElement)("button", {
2814
2644
  className: "lks-btn primary",
2815
2645
  style: { margin: "10px 0" },
2816
2646
  onClick: () => {
@@ -2819,8 +2649,8 @@ window.__ModuleLoader__.load({
2819
2649
  }, (0, react.createElement)(IconDownloadOutline16, null), tr("rail.empty.demo")), (0, react.createElement)(ImportRow, { send })) : (() => {
2820
2650
  const courseId = data.courses.some((c) => c.courseId === selectedCourse) ? selectedCourse : data.courses[0].courseId;
2821
2651
  const course = data.courses.find((c) => c.courseId === courseId);
2822
- return (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "lks-rail-head" }, data.courses.length > 1 ? (0, react.createElement)("select", {
2823
- className: "lks-rail-select",
2652
+ return (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "lks14-railhead" }, data.courses.length > 1 ? (0, react.createElement)("select", {
2653
+ className: "lks-set-select",
2824
2654
  value: courseId,
2825
2655
  onChange: (e) => {
2826
2656
  setSelectedCourse(e.target.value);
@@ -2830,7 +2660,7 @@ window.__ModuleLoader__.load({
2830
2660
  key: c.courseId,
2831
2661
  value: c.courseId
2832
2662
  }, c.title))) : (0, react.createElement)("div", {
2833
- className: "lks-rail-title",
2663
+ className: "lks14-railtitle",
2834
2664
  title: course.title
2835
2665
  }, course.title), (0, react.createElement)("button", {
2836
2666
  className: `lks-btn ${confirmDelete ? "primary" : "ghost"}`,
@@ -2846,14 +2676,14 @@ window.__ModuleLoader__.load({
2846
2676
  setSelectedCourse("");
2847
2677
  }, reportError);
2848
2678
  }
2849
- }, confirmDelete ? tr("rail.delete.confirm") : (0, react.createElement)(IconTrashOutline16, { size: 14 }))), (0, react.createElement)("div", { className: "lks-rail-sub" }, tr("rail.mastered", {
2679
+ }, confirmDelete ? tr("rail.delete.confirm") : (0, react.createElement)(IconTrashOutline16, { size: 14 }))), (0, react.createElement)("div", { className: "lks14-railsub" }, tr("rail.mastered", {
2850
2680
  mastered: course.mastered,
2851
2681
  total: course.total
2852
2682
  })), (0, react.createElement)("div", {
2853
- className: `lks-masterybar${course.avgMasteryPct === 100 ? " gold" : ""}`,
2683
+ className: `lks14-masterybar${course.avgMasteryPct === 100 ? " gold" : ""}`,
2854
2684
  title: course.avgMasteryPct === null ? tr("rail.avg.none") : tr("rail.avg", { pct: course.avgMasteryPct })
2855
2685
  }, (0, react.createElement)("i", { style: { transform: `scaleX(${(course.avgMasteryPct ?? 0) / 100})` } })), (0, react.createElement)("input", {
2856
- className: "lks-search",
2686
+ className: "lks14-search",
2857
2687
  type: "search",
2858
2688
  placeholder: tr("rail.search"),
2859
2689
  value: query,
@@ -2877,32 +2707,10 @@ window.__ModuleLoader__.load({
2877
2707
  }
2878
2708
  }).catch(reportError);
2879
2709
  }
2880
- }), course.sections.some((s) => s.lessons.some((l) => l.focus)) ? (0, react.createElement)("button", {
2881
- className: "lks-btn ghost",
2882
- style: {
2883
- margin: "0 0 6px",
2884
- padding: "3px 8px",
2885
- fontSize: "13px"
2886
- },
2887
- title: tr("rail.locate.title"),
2888
- onClick: () => {
2889
- const focusSection = course.sections.find((s) => s.lessons.some((l) => l.focus));
2890
- if (focusSection === void 0) return;
2891
- setSecOpen((m) => ({
2892
- ...m,
2893
- [`${courseId}/${focusSection.title}`]: true
2894
- }));
2895
- window.setTimeout(() => {
2896
- document.querySelector(".lks-col-rail .lks-node.focus")?.scrollIntoView({
2897
- block: "center",
2898
- behavior: "smooth"
2899
- });
2900
- }, 60);
2901
- }
2902
- }, (0, react.createElement)(IconPinFill16, { size: 13 }), tr("rail.locate")) : null, data.dueCount > 0 ? (0, react.createElement)("div", { className: "lks-duebox" }, (0, react.createElement)(IconRefreshOutline16, { size: 13 }), tr("rail.due", { count: data.dueCount }), ...data.due.map((d) => (0, react.createElement)("div", {
2710
+ }), data.dueCount > 0 ? (0, react.createElement)("div", { className: "lks14-duebox" }, (0, react.createElement)(IconRefreshOutline16, { size: 13 }), tr("rail.due", { count: data.dueCount }), ...data.due.map((d) => (0, react.createElement)("div", {
2903
2711
  key: d.lessonId,
2904
- className: "lks-due-item"
2905
- }, (0, react.createElement)("span", null, d.lessonTitle), d.overdueDays > 0 ? (0, react.createElement)("span", { className: "lks-over" }, tr("rail.due.over", { days: d.overdueDays })) : null)), (0, react.createElement)("button", {
2712
+ className: "lks14-dueitem"
2713
+ }, (0, react.createElement)("span", null, d.lessonTitle), d.overdueDays > 0 ? (0, react.createElement)("span", { className: "lks14-over" }, tr("rail.due.over", { days: d.overdueDays })) : null)), (0, react.createElement)("button", {
2906
2714
  className: "lks-btn ghost",
2907
2715
  style: { marginTop: "6px" },
2908
2716
  onClick: () => {
@@ -2912,56 +2720,33 @@ window.__ModuleLoader__.load({
2912
2720
  const examAllowed = examOpen(section.lessons);
2913
2721
  const lessons = section.lessons.filter((l) => query.trim() === "" || titleMatches(l.title, query) || l.focus);
2914
2722
  if (lessons.length === 0) return [];
2915
- const secKey = `${courseId}/${section.title}`;
2916
- const open = query.trim() !== "" || (secOpen[secKey] ?? sectionDefaultOpen(section));
2723
+ const open = query.trim() !== "" || sectionDefaultOpen(section);
2917
2724
  return [(0, react.createElement)("button", {
2918
2725
  key: section.title,
2919
2726
  type: "button",
2920
- className: "lks-sechead",
2727
+ className: "lks14-sechead",
2921
2728
  "aria-expanded": String(open),
2922
2729
  title: open ? tr("rail.section.collapse") : tr("rail.section.expand", { count: section.lessons.length }),
2923
- onClick: () => {
2924
- setSecOpen((m) => ({
2925
- ...m,
2926
- [secKey]: !open
2927
- }));
2928
- }
2929
- }, (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" }, tr("rail.section.count", { count: section.lessons.length })), (0, react.createElement)("span", { className: "lks-sechead-c" }, open ? "▾" : "▸")), ...open ? lessons.map((lesson) => {
2730
+ onClick: () => {}
2731
+ }, (0, react.createElement)("span", { className: "lks14-secnum" }, String(section.index + 1)), (0, react.createElement)("span", { className: "lks14-secheadt" }, section.title), (0, react.createElement)("span", { className: "lks14-secheadc" }, open ? "▾" : "▸")), ...open ? lessons.map((lesson) => {
2930
2732
  const locked = lesson.status === "locked" || lesson.kind === "exam" && !examAllowed;
2931
2733
  return (0, react.createElement)("button", {
2932
2734
  key: lesson.id,
2933
2735
  type: "button",
2934
- className: `lks-node${lesson.focus ? " focus" : ""}`,
2736
+ className: `lks14-node${lesson.focus ? " focus" : ""}`,
2935
2737
  "aria-disabled": locked || void 0,
2936
- title: jumping === lesson.id ? tr("rail.lesson.opening") : `${lesson.title} — ${statusTitle(lesson.kind, locked ? "locked" : lesson.status)}${locked || lesson.kind === "exam" ? "" : tr("rail.lesson.openHint")}`,
2738
+ title: `${lesson.title} — ${statusTitle(lesson.kind, locked ? "locked" : lesson.status)}`,
2937
2739
  onClick: () => {
2938
2740
  if (locked) return;
2939
- if (lesson.kind === "exam") {
2940
- setFocus(lesson.id).catch(reportError);
2941
- send(tr("prompt.exam", { section: section.title }));
2942
- return;
2943
- }
2944
- openLessonThread(lesson);
2741
+ setFocus(lesson.id).catch(reportError);
2945
2742
  }
2946
- }, (0, react.createElement)("span", { className: "lks-g" }, jumping === lesson.id ? (0, react.createElement)(IconLoadingOutline16, {
2947
- size: 14,
2948
- className: "lks-spin"
2949
- }) : statusIcon(lesson.kind, locked ? "locked" : lesson.status)), (0, react.createElement)("span", { className: "lks-t" }, lesson.title), lesson.due ? (0, react.createElement)("span", {
2950
- className: "lks-tag due",
2951
- title: tr("rail.due.tag")
2952
- }, (0, react.createElement)(IconRefreshOutline16, { size: 11 })) : null, lesson.weakConcepts > 0 ? (0, react.createElement)("span", {
2953
- className: "lks-tag weak",
2743
+ }, (0, react.createElement)("span", { className: "lks14-g" }, statusIcon(lesson.kind, locked ? "locked" : lesson.status)), (0, react.createElement)("span", { className: "lks14-t" }, lesson.title), lesson.weakConcepts > 0 ? (0, react.createElement)("span", {
2744
+ className: "lks14-tag weak",
2954
2745
  title: tr("tag.weak", { count: lesson.weakConcepts })
2955
- }, (0, react.createElement)(IconBoltFill16, { size: 10 }), String(lesson.weakConcepts)) : null, lesson.frictionCount > 0 ? (0, react.createElement)("span", {
2956
- className: "lks-tag fric",
2957
- title: tr("tag.friction", { count: lesson.frictionCount })
2958
- }, (0, react.createElement)(IconWarningOutline16, { size: 10 }), String(lesson.frictionCount)) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", {
2959
- className: "lks-bar",
2746
+ }, (0, react.createElement)(IconBoltFill16, { size: 10 }), String(lesson.weakConcepts)) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", {
2747
+ className: "lks14-bar",
2960
2748
  title: tr("tag.mastery", { pct: lesson.masteryPct })
2961
- }, (0, react.createElement)("i", { style: { transform: `scaleX(${lesson.masteryPct / 100})` } })) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", {
2962
- className: "lks-pct",
2963
- title: tr("tag.mastery.short")
2964
- }, `${lesson.masteryPct}%`) : null);
2749
+ }, (0, react.createElement)("i", { style: { transform: `scaleX(${lesson.masteryPct / 100})` } })) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", { className: "lks14-pct" }, `${lesson.masteryPct}%`) : null);
2965
2750
  }) : []];
2966
2751
  }), (0, react.createElement)("button", {
2967
2752
  className: "lks-btn ghost",
@@ -2969,71 +2754,41 @@ window.__ModuleLoader__.load({
2969
2754
  onClick: () => {
2970
2755
  setShowImport(!showImport);
2971
2756
  }
2972
- }, showImport ? tr("rail.import.close") : (0, react.createElement)(IconPlusOutline16, { size: 13 }), tr("rail.import.toggle")), showImport ? (0, react.createElement)(ImportRow, { send }) : null);
2757
+ }, showImport ? tr("rail.import.close") : null, tr("rail.import.toggle")), showImport ? (0, react.createElement)(ImportRow, { send }) : null);
2973
2758
  })();
2974
- return (0, react.createElement)("div", { className: "lks-col lks-col-rail" }, (0, react.createElement)("div", { className: "lks-colhead" }, tr("col.rail")), body, (0, react.createElement)(ActionError, { error }));
2759
+ return (0, react.createElement)("div", { className: "lks14-col lks14-rail" }, (0, react.createElement)("div", { className: "lks14-colhead" }, tr("col.rail")), body, (0, react.createElement)("div", { className: "lks-propcard-err" }, error));
2975
2760
  }
2976
- /** One transcript row's element; `interactive` adds the quiz-answer buttons to an assistant row. */
2977
- function chatRowElement(row, interactive) {
2978
- if (row.role === "user") return (0, react.createElement)("div", {
2979
- key: row.key,
2980
- className: "lks-msg user"
2981
- }, row.text);
2982
- if (row.role === "error") return (0, react.createElement)("div", {
2983
- key: row.key,
2984
- className: "lks-msg error"
2985
- }, `⚠ ${row.text}`);
2986
- if (row.role === "thinking") return (0, react.createElement)("div", {
2987
- key: row.key,
2988
- className: "lks-msg thinking",
2989
- title: tr("row.thinking.title")
2990
- }, (0, react.createElement)(IconLoadingOutline16, {
2991
- size: 14,
2992
- className: "lks-spin"
2993
- }), row.text);
2994
- const cls = row.role === "streaming" ? "lks-msg assistant lks-prose streaming" : "lks-msg assistant lks-prose";
2995
- const body = (0, react.createElement)("div", {
2996
- key: row.key,
2997
- className: cls,
2998
- dangerouslySetInnerHTML: { __html: renderMarkdown(row.text) }
2999
- });
3000
- const options = interactive === void 0 || row.role === "streaming" ? [] : quizOptions(row.text);
3001
- if (options.length < 2) return body;
3002
- return (0, react.createElement)("div", {
3003
- key: row.key,
3004
- className: "lks-turn"
3005
- }, body, (0, react.createElement)("div", {
3006
- className: "lks-quiz",
3007
- title: tr("quiz.title")
3008
- }, ...options.map((opt) => (0, react.createElement)("button", {
3009
- key: opt.letter,
3010
- className: "lks-opt",
2761
+ function ImportRow({ send }) {
2762
+ const [url, setUrl] = (0, react.useState)("");
2763
+ return (0, react.createElement)("div", { className: "lks14-import" }, (0, react.createElement)("div", { className: "lks14-inputrow" }, (0, react.createElement)("input", {
2764
+ className: "lks14-search",
2765
+ type: "url",
2766
+ placeholder: tr("rail.empty.placeholder"),
2767
+ value: url,
2768
+ onChange: (e) => {
2769
+ setUrl(e.target.value);
2770
+ },
2771
+ onKeyDown: (e) => {
2772
+ if (e.key === "Enter" && url.trim() !== "") send(tr("prompt.import", { url: url.trim() }));
2773
+ }
2774
+ }), (0, react.createElement)("button", {
2775
+ className: "lks-btn primary",
2776
+ disabled: url.trim() === "",
3011
2777
  onClick: () => {
3012
- interactive.send(tr("quiz.answer", {
3013
- letter: opt.letter,
3014
- text: opt.text
3015
- }));
2778
+ send(tr("prompt.import", { url: url.trim() }));
3016
2779
  }
3017
- }, (0, react.createElement)("span", { className: "lks-optletter" }, opt.letter), (0, react.createElement)("span", null, opt.text)))));
2780
+ }, tr("rail.empty.button"))), (0, react.createElement)("div", { className: "lks14-hint" }, tr("rail.empty.hint"), (0, react.createElement)("br"), tr("rail.empty.hint2")));
3018
2781
  }
3019
- /** Middle column: the tutor transcript, proposal banner, pills, starters. Typing happens in the native composer below the tab. */
3020
- function TutorColumn({ data, setMode, send, snapshot }) {
3021
- const [error, setError] = (0, react.useState)(null);
3022
- const rows = transcriptRows(snapshot?.nodes, snapshot?.partial ?? null);
3023
- const lastAssistant = rows.reduce((acc, row, i) => row.role === "assistant" ? i : acc, -1);
3024
- const scrollRef = (0, react.useRef)(null);
2782
+ /** 中栏:the tutor chat stream with its own composer (upstream ChatStream + ChatComposer). */
2783
+ function ChatPane({ data, lesson, rows, busy, sendError, draft, setDraft, send, setMode }) {
2784
+ const streamEl = (0, react.useRef)(null);
3025
2785
  (0, react.useEffect)(() => {
3026
- const el = scrollRef.current;
2786
+ const el = streamEl.current;
3027
2787
  if (el !== null) el.scrollTop = el.scrollHeight;
3028
2788
  }, [rows.length]);
3029
- (0, react.useEffect)(() => {
3030
- const el = scrollRef.current;
3031
- if (el !== null && rows.length > 0) enhanceRendered(el).catch(() => {});
3032
- }, [rows.length]);
3033
- const proposal = data?.pendingProposals[0] ?? null;
3034
- const lesson = data?.lesson ?? null;
3035
2789
  const dormant = data?.active !== true;
3036
- /** Mode switches are the only fallible write left here (host route); surface failures inline. */
2790
+ const lastAssistant = rows.reduce((acc, row, i) => row.role === "assistant" ? i : acc, -1);
2791
+ const [error, setError] = (0, react.useState)(null);
3037
2792
  const fire = (action) => {
3038
2793
  action.then(() => {
3039
2794
  setError(null);
@@ -3041,132 +2796,68 @@ window.__ModuleLoader__.load({
3041
2796
  setError(err instanceof Error ? err.message : String(err));
3042
2797
  });
3043
2798
  };
3044
- return (0, react.createElement)("div", { className: "lks-col lks-col-tutor" }, (0, react.createElement)("div", { className: "lks-colhead" }, tr("col.tutor")), dormant ? (0, react.createElement)("div", { className: "lks-dormant" }, tr("tutor.dormant")) : null, (0, react.createElement)("div", {
3045
- className: "lks-transcript",
3046
- ref: scrollRef
3047
- }, rows.length === 0 && !dormant ? (0, react.createElement)("div", { className: "lks-empty" }, tr("tutor.empty"), (0, react.createElement)("br"), tr("tutor.empty.hint")) : rows.map((row, i) => chatRowElement(row, !dormant && i === lastAssistant ? { send } : void 0))), proposal !== null ? (0, react.createElement)("div", { className: "lks-banner" }, (0, react.createElement)("span", null, "🎓"), (0, react.createElement)("span", { className: "lks-why" }, tr("proposal.text", {
3048
- lesson: proposal.lessonTitle,
3049
- rationale: proposal.rationale
3050
- })), (0, react.createElement)("button", {
3051
- className: "lks-btn primary",
3052
- onClick: () => {
3053
- send(tr("prompt.proposal.accept", { id: proposal.id }));
3054
- }
3055
- }, tr("proposal.accept")), (0, react.createElement)("button", {
3056
- className: "lks-btn ghost",
3057
- onClick: () => {
3058
- send(tr("prompt.proposal.decline", { id: proposal.id }));
3059
- }
3060
- }, tr("proposal.decline"))) : null, (0, react.createElement)("div", {
3061
- className: "lks-pills",
3062
- style: {
3063
- height: "auto",
3064
- padding: "4px 0"
2799
+ const starters = lesson?.starters ?? [];
2800
+ const onComposerKey = (e) => {
2801
+ if (e.key === "Enter" && !e.shiftKey) {
2802
+ e.preventDefault();
2803
+ if (draft.trim() !== "" && !busy) send(draft);
3065
2804
  }
3066
- }, ...MODES$1.map((mode) => (0, react.createElement)("button", {
2805
+ };
2806
+ return (0, react.createElement)("div", { className: "lks14-col lks14-chat" }, (0, react.createElement)("div", { className: "lks14-colhead" }, tr("col.tutor"), lesson !== null ? (0, react.createElement)("span", { className: "lks14-chatlesson" }, lesson.title) : null, (0, react.createElement)("span", { className: "lks14-pills" }, ...MODES$1.map((mode) => (0, react.createElement)("button", {
3067
2807
  key: mode.id,
3068
- className: `lks-pill${data?.mode === mode.id ? " on" : ""}`,
2808
+ className: `lks14-pill${data?.mode === mode.id ? " on" : ""}`,
3069
2809
  "aria-pressed": String(data?.mode === mode.id),
3070
2810
  "aria-disabled": dormant || void 0,
3071
2811
  title: dormant ? tr("tutor.dormant") : tr(mode.hintKey),
3072
2812
  onClick: () => {
3073
2813
  if (!dormant) fire(setMode(mode.id));
3074
2814
  }
3075
- }, tr(mode.labelKey)))), lesson !== null && lesson.starters.length > 0 ? (0, react.createElement)("div", {
3076
- className: "lks-dock",
3077
- style: { padding: "0 0 6px" }
3078
- }, ...lesson.starters.map((s) => (0, react.createElement)("button", {
2815
+ }, tr(mode.labelKey))))), (0, react.createElement)("div", {
2816
+ className: "lks14-stream",
2817
+ ref: streamEl
2818
+ }, rows.length === 0 ? (0, react.createElement)("div", { className: "lks14-empty" }, dormant ? tr("tutor.dormant") : tr("tutor.empty"), (0, react.createElement)("br"), tr("tutor.empty.hint")) : rows.map((row, i) => chatRow(row, !dormant && i === lastAssistant ? { send } : void 0))), (0, react.createElement)("div", { className: "lks14-starters" }, ...starters.map((s) => (0, react.createElement)("button", {
3079
2819
  key: s.label,
3080
- className: "lks-starter",
2820
+ className: "lks14-starter",
3081
2821
  "aria-disabled": dormant || void 0,
3082
2822
  title: dormant ? tr("tutor.dormant") : s.message,
3083
2823
  onClick: () => {
3084
2824
  if (!dormant) send(s.message);
3085
2825
  }
3086
- }, s.label))) : null, (0, react.createElement)(ActionError, { error }));
3087
- }
3088
- /** Browser speechSynthesis engine the offline/endpoint-gone fallback voice. */
3089
- function systemSpeechEngine() {
3090
- return {
3091
- speak(text) {
3092
- return new Promise((resolve, reject) => {
3093
- const synth = typeof window === "undefined" ? void 0 : window.speechSynthesis;
3094
- if (synth === void 0) {
3095
- reject(/* @__PURE__ */ new Error("no speechSynthesis"));
3096
- return;
3097
- }
3098
- const utterance = new SpeechSynthesisUtterance(text);
3099
- utterance.lang = "zh-CN";
3100
- utterance.onend = () => resolve();
3101
- utterance.onerror = () => reject(/* @__PURE__ */ new Error("speechSynthesis failed"));
3102
- synth.speak(utterance);
3103
- });
3104
- },
3105
- pause() {
3106
- window.speechSynthesis?.pause();
3107
- },
3108
- resume() {
3109
- window.speechSynthesis?.resume();
3110
- },
3111
- cancel() {
3112
- window.speechSynthesis?.cancel();
3113
- }
3114
- };
3115
- }
3116
- /** Edge-over-dashboard engine: MP3 from /api/tts played through an <audio>. */
3117
- function audioSpeechEngine(fetchTts) {
3118
- let audio = null;
3119
- return {
3120
- speak(text) {
3121
- return fetchTts(text).then((buf) => new Promise((resolve, reject) => {
3122
- audio = new Audio(URL.createObjectURL(new Blob([buf], { type: "audio/mpeg" })));
3123
- audio.onended = () => resolve();
3124
- audio.onerror = () => reject(/* @__PURE__ */ new Error("audio playback failed"));
3125
- audio.play().catch(reject);
3126
- }));
3127
- },
3128
- pause() {
3129
- audio?.pause();
3130
- },
3131
- resume() {
3132
- audio?.play();
2826
+ }, s.label))), (0, react.createElement)("div", { className: "lks14-composer" }, (0, react.createElement)("textarea", {
2827
+ className: "lks14-composertext",
2828
+ placeholder: busy ? tr("composer.busy") : tr("tutor.empty.hint"),
2829
+ value: draft,
2830
+ rows: 2,
2831
+ disabled: dormant,
2832
+ onChange: (e) => {
2833
+ setDraft(e.target.value);
3133
2834
  },
3134
- cancel() {
3135
- audio?.pause();
3136
- audio = null;
2835
+ onKeyDown: onComposerKey
2836
+ }), (0, react.createElement)("button", {
2837
+ className: "lks-btn primary",
2838
+ disabled: busy || dormant || draft.trim() === "",
2839
+ onClick: () => {
2840
+ send(draft);
3137
2841
  }
3138
- };
2842
+ }, busy ? (0, react.createElement)(IconLoadingOutline16, {
2843
+ size: 13,
2844
+ className: "lks-spin"
2845
+ }) : null, tr("composer.send"))), sendError !== null || error !== null ? (0, react.createElement)("div", { className: "lks-propcard-err" }, sendError ?? error) : null);
3139
2846
  }
3140
- /** Right column: the blackboardfocus-lesson 讲解/脑图/概念图 plus the Cornell 笔记. */
3141
- function BlackboardColumn({ data, deleteNote }) {
2847
+ /** 右栏:the notebook讲解/概念图/笔记 tabs (upstream NotebookPanel arrangement). */
2848
+ function NotebookPane({ data, deleteNote }) {
3142
2849
  const lesson = data?.lesson ?? null;
3143
- const { tts } = useStudy();
3144
- const [pane, setPane] = (0, react.useState)("teach");
2850
+ const [tab, setTab] = (0, react.useState)("teach");
3145
2851
  const [error, setError] = (0, react.useState)(null);
2852
+ const [armedNote, setArmedNote] = (0, react.useState)(null);
3146
2853
  const [read, setRead] = (0, react.useState)(null);
3147
2854
  const readCtl = (0, react.useRef)(null);
3148
2855
  const [readError, setReadError] = (0, react.useState)(null);
3149
- const startReading = () => {
3150
- if (lesson === null || lesson.speechText.trim() === "") return;
3151
- readCtl.current?.stop();
3152
- const controller = new ReadAloudController(speechSentencesOf(lesson.speechText).map((s) => speakMathInSentence(s)), audioSpeechEngine((text) => tts(text)), systemSpeechEngine(), (s) => {
3153
- setRead(s);
3154
- if (s.degraded && s.engine === "system" && !readError) setReadError(tr("read.engine.system"));
3155
- });
3156
- readCtl.current = controller;
3157
- setReadError(null);
3158
- controller.start().catch((err) => {
3159
- setReadError(err instanceof Error ? err.message : String(err));
3160
- });
3161
- };
3162
- const stopReading = () => {
3163
- readCtl.current?.stop();
3164
- setRead(null);
3165
- setReadError(null);
3166
- };
3167
- const [armedNote, setArmedNote] = (0, react.useState)(null);
2856
+ const { tts } = useStudy();
2857
+ const proseRef = (0, react.useRef)(null);
2858
+ const diagRef = (0, react.useRef)(null);
3168
2859
  (0, react.useEffect)(() => {
3169
- if (armedNote === null) return;
2860
+ if (!armedNote) return;
3170
2861
  const disarm = () => {
3171
2862
  setArmedNote(null);
3172
2863
  };
@@ -3187,46 +2878,130 @@ window.__ModuleLoader__.load({
3187
2878
  setError(err instanceof Error ? err.message : String(err));
3188
2879
  });
3189
2880
  };
3190
- const proseRef = (0, react.useRef)(null);
3191
- const diagRef = (0, react.useRef)(null);
3192
- (0, react.useEffect)(() => {
3193
- if (pane !== "teach" || proseRef.current === null || lesson === null) return;
3194
- const el = proseRef.current;
3195
- enhanceRendered(el).catch(() => {});
3196
- }, [pane, lesson?.html]);
2881
+ const startReading = () => {
2882
+ if (lesson === null || lesson.speechText.trim() === "") return;
2883
+ readCtl.current?.stop();
2884
+ const sentences = speechSentencesOf(lesson.speechText);
2885
+ const voice = storedTtsVoice();
2886
+ const prefetch = /* @__PURE__ */ new Map();
2887
+ let currentAudio = null;
2888
+ const fetchAudio = (text) => {
2889
+ const hit = prefetch.get(text);
2890
+ if (hit !== void 0) {
2891
+ prefetch.delete(text);
2892
+ return hit;
2893
+ }
2894
+ return tts(text, voice);
2895
+ };
2896
+ const controller = new ReadAloudController(sentences.map((s) => speakMathInSentence(s)), {
2897
+ speak(text) {
2898
+ return fetchAudio(text).then((buf) => new Promise((resolve, reject) => {
2899
+ const blobUrl = URL.createObjectURL(new Blob([buf], { type: "audio/mpeg" }));
2900
+ const audio = new Audio(blobUrl);
2901
+ currentAudio = audio;
2902
+ audio.onended = () => {
2903
+ URL.revokeObjectURL(blobUrl);
2904
+ if (currentAudio === audio) currentAudio = null;
2905
+ resolve();
2906
+ };
2907
+ audio.onerror = () => {
2908
+ URL.revokeObjectURL(blobUrl);
2909
+ if (currentAudio === audio) currentAudio = null;
2910
+ reject(/* @__PURE__ */ new Error("audio playback failed"));
2911
+ };
2912
+ audio.play().catch(reject);
2913
+ }));
2914
+ },
2915
+ pause() {
2916
+ currentAudio?.pause();
2917
+ },
2918
+ resume() {
2919
+ currentAudio?.play().catch(() => {});
2920
+ },
2921
+ cancel() {
2922
+ currentAudio?.pause();
2923
+ currentAudio = null;
2924
+ },
2925
+ prewarm(text) {
2926
+ if (!prefetch.has(text)) prefetch.set(text, tts(text, voice));
2927
+ }
2928
+ }, {
2929
+ speak(text) {
2930
+ return new Promise((resolve, reject) => {
2931
+ const synth = window.speechSynthesis;
2932
+ if (synth === void 0) {
2933
+ reject(/* @__PURE__ */ new Error("system speech unavailable"));
2934
+ return;
2935
+ }
2936
+ const utter = new SpeechSynthesisUtterance(text);
2937
+ utter.lang = voice.startsWith("en") ? "en-US" : "zh-CN";
2938
+ utter.onend = () => {
2939
+ resolve();
2940
+ };
2941
+ utter.onerror = () => {
2942
+ reject(/* @__PURE__ */ new Error("system speech failed"));
2943
+ };
2944
+ synth.speak(utter);
2945
+ });
2946
+ },
2947
+ pause() {
2948
+ window.speechSynthesis?.pause();
2949
+ },
2950
+ resume() {
2951
+ window.speechSynthesis?.resume();
2952
+ },
2953
+ cancel() {
2954
+ window.speechSynthesis?.cancel();
2955
+ }
2956
+ }, (s) => setRead(s));
2957
+ readCtl.current = controller;
2958
+ controller.start().catch((err) => {
2959
+ setReadError(err instanceof Error ? err.message : String(err));
2960
+ });
2961
+ };
2962
+ const stopReading = () => {
2963
+ readCtl.current?.stop();
2964
+ setRead(null);
2965
+ };
3197
2966
  (0, react.useEffect)(() => {
3198
- if (pane !== "cmap" || diagRef.current === null || lesson === null) return;
2967
+ if (tab !== "cmap" || diagRef.current === null || lesson === null) return;
3199
2968
  const el = diagRef.current;
3200
2969
  el.textContent = "";
3201
2970
  (lesson.concepts.length === 0 ? Promise.reject(/* @__PURE__ */ new Error("no concepts")) : renderLessonConceptMap(el, lesson.title, lesson.concepts.map((c) => ({
3202
2971
  title: c.title,
3203
2972
  masteryPct: c.masteryPct
3204
2973
  })))).catch((err) => {
3205
- console.error("lks diagram pane failed:", pane, err);
3206
2974
  el.textContent = lesson.concepts.length === 0 ? tr("bb.fallback.cmap.empty") : tr("bb.fallback.cmap");
3207
2975
  });
3208
2976
  }, [
3209
- pane,
2977
+ tab,
3210
2978
  lesson?.lessonId,
3211
2979
  lesson?.concepts.length
3212
2980
  ]);
3213
- const body = lesson === null ? (0, react.createElement)("div", { className: "lks-empty" }, tr("bb.empty"), (0, react.createElement)("br"), tr("bb.empty.hint")) : (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "lks-lessonhead" }, (0, react.createElement)("h2", null, lesson.title), (0, react.createElement)("div", { className: "lks-meta" }, `${lesson.courseTitle} · ${statusTitle("study", lesson.status)}` + (lesson.masteryPct === null ? "" : ` · ${tr("bb.mastery", { pct: lesson.masteryPct })}`)), (0, react.createElement)("div", { className: "lks-meta" }, `${tr("bb.strategy")}:${lesson.strategy}`), lesson.concepts.length > 0 ? (0, react.createElement)("div", { className: "lks-chips" }, ...lesson.concepts.map((c) => (0, react.createElement)("span", {
3214
- key: c.title,
3215
- className: `lks-chip${c.weak ? " weak" : ""}`
3216
- }, `${c.title} ${c.masteryPct}%${c.weak ? " ⚡" : ""}`))) : null), (0, react.createElement)("div", { className: "lks-viewtabs" }, (0, react.createElement)("button", {
3217
- className: `lks-viewtab${pane === "teach" ? " on" : ""}`,
3218
- "aria-pressed": String(pane === "teach"),
2981
+ (0, react.useEffect)(() => {
2982
+ if (tab !== "teach" || proseRef.current === null || lesson === null) return;
2983
+ enhanceRendered(proseRef.current).catch(() => {});
2984
+ }, [tab, lesson?.html]);
2985
+ const body = lesson === null ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("bb.empty"), (0, react.createElement)("br"), tr("bb.empty.hint")) : (0, react.createElement)("div", { className: "lks14-notebody" }, (0, react.createElement)("div", { className: "lks14-lessonhead" }, (0, react.createElement)("h2", null, lesson.title), (0, react.createElement)("div", { className: "lks14-meta" }, `${lesson.courseTitle} · ${statusTitle("study", lesson.status)}` + (lesson.masteryPct === null ? "" : ` · ${tr("bb.mastery", { pct: lesson.masteryPct })}`))), (0, react.createElement)("div", { className: "lks14-viewtabs" }, (0, react.createElement)("button", {
2986
+ className: `lks14-viewtab${tab === "teach" ? " on" : ""}`,
2987
+ "aria-pressed": String(tab === "teach"),
3219
2988
  onClick: () => {
3220
- setPane("teach");
2989
+ setTab("teach");
3221
2990
  }
3222
2991
  }, tr("viewtab.teach")), (0, react.createElement)("button", {
3223
- className: `lks-viewtab${pane === "cmap" ? " on" : ""}`,
3224
- "aria-pressed": String(pane === "cmap"),
2992
+ className: `lks14-viewtab${tab === "cmap" ? " on" : ""}`,
2993
+ "aria-pressed": String(tab === "cmap"),
3225
2994
  title: tr("viewtab.cmap.title"),
3226
2995
  onClick: () => {
3227
- setPane("cmap");
2996
+ setTab("cmap");
2997
+ }
2998
+ }, (0, react.createElement)(IconGlobeOutline14, { size: 13 }), tr("viewtab.cmap")), (0, react.createElement)("button", {
2999
+ className: `lks14-viewtab${tab === "notes" ? " on" : ""}`,
3000
+ "aria-pressed": String(tab === "notes"),
3001
+ onClick: () => {
3002
+ setTab("notes");
3228
3003
  }
3229
- }, (0, react.createElement)(IconGlobeOutline14, { size: 13 }), tr("viewtab.cmap"))), pane === "teach" ? (0, react.createElement)("div", { className: "lks-readbar" }, (0, react.createElement)("button", {
3004
+ }, tr("bb.notes"))), tab === "teach" ? (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "lks-readbar" }, (0, react.createElement)("button", {
3230
3005
  className: "lks-btn ghost",
3231
3006
  style: {
3232
3007
  padding: "3px 8px",
@@ -3245,7 +3020,7 @@ window.__ModuleLoader__.load({
3245
3020
  }
3246
3021
  startReading();
3247
3022
  }
3248
- }, (0, react.createElement)(IconPlayOutline16, { size: 12 }), read !== null && read.state === "speaking" ? tr("read.pause") : read !== null && read.state === "paused" ? tr("read.resume") : tr("read.play")), read !== null ? (0, react.createElement)("button", {
3023
+ }, read !== null && read.state === "speaking" ? tr("read.pause") : tr("read.play")), read !== null ? (0, react.createElement)("button", {
3249
3024
  className: "lks-btn ghost",
3250
3025
  style: {
3251
3026
  padding: "3px 8px",
@@ -3254,20 +3029,20 @@ window.__ModuleLoader__.load({
3254
3029
  },
3255
3030
  title: tr("read.stop"),
3256
3031
  onClick: stopReading
3257
- }, tr("read.stop")) : null, read !== null && read.state !== "idle" && lesson !== null ? (0, react.createElement)("span", { className: "lks-readbar-cur" }, (speechSentencesOf(lesson.speechText)[read.index] ?? "").slice(0, 80)) : null, readError !== null ? (0, react.createElement)("span", { className: "lks-readbar-notice" }, readError) : null) : null, pane === "teach" ? (0, react.createElement)("div", {
3258
- className: "lks-prose",
3032
+ }, tr("read.stop")) : null, read !== null && read.state !== "idle" && lesson !== null ? (0, react.createElement)("span", { className: "lks-readbar-cur" }, (speechSentencesOf(lesson.speechText)[read.index] ?? "").slice(0, 80)) : null, readError !== null ? (0, react.createElement)("span", { className: "lks-readbar-notice" }, readError) : null, read !== null && read.degraded ? (0, react.createElement)("span", { className: "lks-readbar-notice" }, tr("read.engine.system")) : null), (0, react.createElement)("div", {
3033
+ className: "lks14-prose",
3259
3034
  ref: proseRef,
3260
3035
  dangerouslySetInnerHTML: { __html: lesson.html }
3261
- }) : (0, react.createElement)("div", {
3262
- className: "lks-prose",
3036
+ })) : tab === "cmap" ? (0, react.createElement)("div", {
3037
+ className: "lks14-prose",
3263
3038
  ref: diagRef
3264
- }), (0, react.createElement)("div", { className: "lks-bb-notes" }, (0, react.createElement)("div", { className: "lks-sec" }, tr("bb.notes")), lesson.notes.length === 0 ? (0, react.createElement)("div", {
3265
- className: "lks-empty",
3039
+ }) : (0, react.createElement)("div", { className: "lks14-zones" }, lesson.notes.length === 0 ? (0, react.createElement)("div", {
3040
+ className: "lks14-empty",
3266
3041
  style: { padding: "16px 0" }
3267
3042
  }, tr("bb.notes.empty")) : ZONES.filter(([zone]) => lesson.notes.some((n) => n.zone === zone)).map(([zone, labelKey]) => (0, react.createElement)("div", {
3268
3043
  key: zone,
3269
- className: "lks-zone"
3270
- }, (0, react.createElement)("div", { className: "lks-zone-h" }, tr(labelKey)), ...lesson.notes.filter((n) => n.zone === zone).map((n) => (0, react.createElement)("div", {
3044
+ className: "lks14-zone"
3045
+ }, (0, react.createElement)("div", { className: "lks14-zoneh" }, tr(labelKey)), ...lesson.notes.filter((n) => n.zone === zone).map((n) => (0, react.createElement)("div", {
3271
3046
  key: n.id,
3272
3047
  className: "lks-note"
3273
3048
  }, (0, react.createElement)("span", { className: "lks-note-src" }, n.source), (0, react.createElement)("button", {
@@ -3287,83 +3062,223 @@ window.__ModuleLoader__.load({
3287
3062
  className: "lks-note-text",
3288
3063
  dangerouslySetInnerHTML: { __html: renderMarkdown(n.text) }
3289
3064
  }), n.quote !== null ? (0, react.createElement)("div", { className: "lks-note-q" }, `“${n.quote}”`) : null))))));
3290
- return (0, react.createElement)("div", { className: "lks-col lks-col-bb" }, (0, react.createElement)("div", { className: "lks-colhead" }, tr("col.bb")), body, (0, react.createElement)(ActionError, { error }));
3065
+ return (0, react.createElement)("div", { className: "lks14-col lks14-note" }, (0, react.createElement)("div", { className: "lks14-colhead" }, tr("col.bb")), body, (0, react.createElement)("div", { className: "lks-propcard-err" }, error));
3291
3066
  }
3292
3067
  //#endregion
3293
- //#region src/client/starter.tsx
3068
+ //#region src/client/shell-entry.ts
3294
3069
  /**
3295
- * The one-click study starter: a pill button in the blank-session (hero)
3296
- * composer tool row. One click adopts the plugin's dedicated study workspace
3297
- * (created by the host beside the state file), opens a session in it, sends
3298
- * the kickoff prompt through the session API, and makes that session current
3299
- * the learner lands in the chat with the tutor already responding, one
3300
- * click away from the 学习 tab (view switching is ui-conversation-private,
3301
- * not reachable from an additive plugin).
3302
- * @module dsh-plugin-lookatstudy/client/starter
3070
+ * The study shell entry the panel-entry doctrine, stardeck-style: the study
3071
+ * surface is CROSS-WORKSPACE (plugin state is workspace-independent by
3072
+ * design), so it enters through a sidebar row and takes over the center
3073
+ * column, never living inside one conversation. The conversation slot family
3074
+ * is single-occupant and external plugins cannot declare shell slots, so both
3075
+ * the row and the view are DOM-level: a plain-DOM button (never disturbs the
3076
+ * shell's reconciliation) + a container appended to the center column as an
3077
+ * extra trailing child React never manages. Visibility toggles via an <html>
3078
+ * data attribute; sibling panels evict each other through the dsh-panel-activate
3079
+ * event.
3080
+ *
3081
+ * Differences from stardeck's original: our own attribute namespace, a
3082
+ * hand-back SUPPRESSION hook (the panel itself calls sessions.open to stage
3083
+ * the focus lesson's thread — an internal navigation must not close the
3084
+ * panel; only USER session navigation hands the column back), and dsw-token
3085
+ * styling via the injected stylesheet.
3086
+ * @module dsh-plugin-lookatstudy/client/shell-entry
3303
3087
  */
3088
+ const ROW_ATTRIBUTE = "data-dsh-lookatstudy-entry";
3089
+ const ROW_SELECTOR = `[${ROW_ATTRIBUTE}]`;
3090
+ const VIEW_ATTRIBUTE = "data-dsh-lookatstudy-view";
3091
+ const ACTIVE_ATTR = "data-dsh-lookatstudy-active";
3092
+ /** Sibling panels' activation attributes, evicted when the study panel opens. */
3093
+ const OTHER_ACTIVE_ATTRS = [
3094
+ "data-dsh-warroom-active",
3095
+ "data-dsh-taskboard-active",
3096
+ "data-dsh-ssh-active"
3097
+ ];
3098
+ /** Cross-plugin activation event; detail is the activating panel name. */
3099
+ const ACTIVATE_EVENT = "dsh-panel-activate";
3100
+ const PANEL_NAME = "lookatstudy";
3101
+ /** Family rows we order against (the sibling-panel entry block). */
3102
+ const FAMILY_SELECTORS = [
3103
+ "[data-dsh-warroom-entry]",
3104
+ "[data-dsh-ssh-entry]",
3105
+ "[data-dsh-taskboard-entry]",
3106
+ ROW_SELECTOR
3107
+ ];
3108
+ const SIDEBAR_COLUMN_SELECTOR = "[data-pane=\"sidebar\"], [class*=\"sidebarCol\"]";
3109
+ const CENTER_COLUMN_SELECTOR = "[data-pane=\"conversation\"], [class*=\"centerCol\"]";
3110
+ /** The open/close state shared by the sidebar row and the view. */
3111
+ var ShellOpenState = class {
3112
+ open = false;
3113
+ listeners = /* @__PURE__ */ new Set();
3114
+ isOpen() {
3115
+ return this.open;
3116
+ }
3117
+ setOpen(next) {
3118
+ if (this.open === next) return;
3119
+ this.open = next;
3120
+ for (const l of this.listeners) l();
3121
+ }
3122
+ subscribe(listener) {
3123
+ this.listeners.add(listener);
3124
+ return () => {
3125
+ this.listeners.delete(listener);
3126
+ };
3127
+ }
3128
+ };
3129
+ function sidebarRoot(doc) {
3130
+ const column = doc.querySelector(SIDEBAR_COLUMN_SELECTOR);
3131
+ if (column === null) return void 0;
3132
+ return column.querySelector("[class*=\"logoRow\"]")?.parentElement ?? column.firstElementChild;
3133
+ }
3134
+ function newSessionButton(root) {
3135
+ const nested = root.querySelector("button[class*=\"newSession\"]");
3136
+ if (nested !== null) return nested;
3137
+ for (const child of root.children) if (child.tagName === "BUTTON") return child;
3138
+ }
3139
+ /** A 16px study glyph: an open book with a mastery spark. */
3140
+ const STUDY_ICON = "<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\"><path d=\"M2 3.2C3.2 2.4 4.9 2.2 8 3.4c3.1-1.2 4.8-1 6-.2v9.2c-1.2-.8-2.9-1-6 .2-3.1-1.2-4.8-1-6-.2V3.2z\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linejoin=\"round\"/><path d=\"M8 3.4v9.2\" stroke=\"currentColor\" stroke-width=\"1.2\"/></svg>";
3304
3141
  /**
3305
- * Build the hero starter button bound to the framework services.
3306
- * @param ctx - client root context (workspaces + sessions injected).
3307
- * @returns the component for `conversation.input.left`.
3142
+ * Mount the study shell surface: sidebar row + center-column panel.
3143
+ * @param view - the panel COMPONENT (an element factory, e.g. studyPanelView(services)).
3144
+ * @param label - the entry label (locale-aware getter, re-read on paints).
3145
+ * @param dom - the DOM mounting faces (injectable for tests).
3146
+ * @param sessions - the host session list; USER navigation between sessions
3147
+ * hands the center column back (closes the panel). Hydration (the first
3148
+ * current arriving) and suppressed internal opens do not count.
3308
3149
  */
3309
- function studyStartButton(ctx) {
3310
- return function StudyStartButton(props) {
3311
- const machine = props.useSession?.((s) => s);
3312
- if ((props.session?.blank ?? machine?.blank) !== true) return null;
3313
- return (0, react.createElement)(Inner, {
3314
- key: "inner",
3315
- ctx
3316
- });
3150
+ function mountStudyShell(view, label, dom, sessions) {
3151
+ if (typeof document === "undefined") return {
3152
+ dispose: () => {},
3153
+ close: () => {},
3154
+ suppressHandBack: (fn) => {
3155
+ fn();
3156
+ }
3317
3157
  };
3318
- }
3319
- /** Button body in its own component so hook order stays constant. */
3320
- function Inner({ ctx }) {
3321
- const { data, activate } = useStudy();
3322
- const [busy, setBusy] = (0, react.useState)(false);
3323
- const [error, setError] = (0, react.useState)(null);
3324
- const start = () => {
3325
- if (busy) return;
3326
- setBusy(true);
3327
- setError(null);
3328
- (async () => {
3158
+ const doc = dom?.doc ?? document;
3159
+ if (doc.querySelector(ROW_SELECTOR) !== null) return {
3160
+ dispose: () => {},
3161
+ close: () => {},
3162
+ suppressHandBack: (fn) => {
3163
+ fn();
3164
+ }
3165
+ };
3166
+ const state = new ShellOpenState();
3167
+ let suppressed = false;
3168
+ const row = doc.createElement("button");
3169
+ row.type = "button";
3170
+ row.setAttribute(ROW_ATTRIBUTE, "");
3171
+ row.setAttribute("data-dsh-plugin", "lookatstudy");
3172
+ row.setAttribute("data-dsh-part", "sidebar-entry");
3173
+ row.className = "lks14-sidebar-row";
3174
+ const paintRow = () => {
3175
+ const text = label();
3176
+ row.setAttribute("aria-label", text);
3177
+ row.setAttribute("title", text);
3178
+ row.innerHTML = `<span class="lks14-sidebar-icon">${STUDY_ICON}</span><span class="lks14-sidebar-label">${text}</span>`;
3179
+ };
3180
+ paintRow();
3181
+ row.addEventListener("click", () => state.setOpen(!state.isOpen()));
3182
+ const placeRow = () => {
3183
+ const root = sidebarRoot(doc);
3184
+ if (root === void 0) return false;
3185
+ const button = newSessionButton(root);
3186
+ if (button === void 0) return false;
3187
+ if (row.parentElement !== root) {
3188
+ const logoRow = button.closest("[class*=\"logoRow\"]");
3189
+ const base = logoRow !== null && logoRow.parentElement === root ? logoRow : button;
3190
+ const family = Array.from(root.children).filter((el) => el instanceof HTMLElement && el.matches(FAMILY_SELECTORS.join(", ")));
3191
+ const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling;
3192
+ root.insertBefore(row, anchor);
3193
+ }
3194
+ return true;
3195
+ };
3196
+ let container;
3197
+ let root;
3198
+ const roots = dom ?? { createRoot: (el) => (0, react_dom_client.createRoot)(el) };
3199
+ const ensureView = () => {
3200
+ if (container !== void 0) return;
3201
+ const column = doc.querySelector(CENTER_COLUMN_SELECTOR);
3202
+ if (column === null) return;
3203
+ container = doc.createElement("div");
3204
+ container.setAttribute(VIEW_ATTRIBUTE, "");
3205
+ container.setAttribute("data-dsh-plugin", "lookatstudy");
3206
+ container.className = "lks14-shell-view";
3207
+ column.appendChild(container);
3208
+ root = roots.createRoot(container);
3209
+ root.render((0, react.createElement)(view));
3210
+ };
3211
+ const applyActive = () => {
3212
+ if (state.isOpen()) {
3213
+ for (const attr of OTHER_ACTIVE_ATTRS) doc.documentElement.removeAttribute(attr);
3214
+ doc.documentElement.setAttribute(ACTIVE_ATTR, "");
3215
+ doc.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }));
3216
+ } else doc.documentElement.removeAttribute(ACTIVE_ATTR);
3217
+ if (state.isOpen()) row.dataset.active = "true";
3218
+ else delete row.dataset.active;
3219
+ };
3220
+ const onOtherActivate = (event) => {
3221
+ if (event.detail !== PANEL_NAME) state.setOpen(false);
3222
+ };
3223
+ const SIDEBAR_ROW_SELECTOR = "[class*=\"sessionRow\"], [class*=\"projectRow\"], [class*=\"searchResultRow\"], [class*=\"searchResultWorkspace\"], [class*=\"newSession\"]";
3224
+ const onClickSidebarRow = (event) => {
3225
+ if (!state.isOpen()) return;
3226
+ const target = event.target;
3227
+ if (target === null) return;
3228
+ if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) state.setOpen(false);
3229
+ };
3230
+ const bodyObserver = new MutationObserver(() => {
3231
+ placeRow();
3232
+ ensureView();
3233
+ });
3234
+ bodyObserver.observe(doc.body, {
3235
+ childList: true,
3236
+ subtree: true
3237
+ });
3238
+ doc.addEventListener("click", onClickSidebarRow, true);
3239
+ doc.addEventListener(ACTIVATE_EVENT, onOtherActivate);
3240
+ let unsubscribeSessions;
3241
+ if (sessions?.list !== void 0) {
3242
+ let lastCurrent = sessions.list.getSnapshot().current;
3243
+ unsubscribeSessions = sessions.list.subscribe(() => {
3244
+ const next = sessions.list.getSnapshot().current;
3245
+ if (next === lastCurrent) return;
3246
+ const wasHydration = lastCurrent === void 0;
3247
+ lastCurrent = next;
3248
+ if (shouldHandBack(suppressed, wasHydration)) state.setOpen(false);
3249
+ });
3250
+ }
3251
+ const unsubscribe = state.subscribe(applyActive);
3252
+ placeRow();
3253
+ ensureView();
3254
+ applyActive();
3255
+ return {
3256
+ close: () => state.setOpen(false),
3257
+ /** Internal session navigation (staging the lesson thread) must not hand
3258
+ * the column back — only USER navigation does. */
3259
+ suppressHandBack: (fn) => {
3260
+ suppressed = true;
3329
3261
  try {
3330
- if (data?.active !== true) await activate(true);
3331
- const area = await fetch("/lookatstudy/api/study-workspace");
3332
- if (!area.ok) throw new Error(`study area unavailable (HTTP ${area.status})`);
3333
- const { path } = await area.json();
3334
- const workspace = await ctx.workspaces.create({ path });
3335
- const sessionId = ctx.workspaces.connectWorkspace !== void 0 ? await ctx.workspaces.connectWorkspace(workspace.workspaceId) : await ctx.sessions.create({ workspaceId: workspace.workspaceId });
3336
- const actx = ctx.sessions.scope(sessionId);
3337
- const face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx);
3338
- if (face === void 0) throw new Error("study session is not addressable yet");
3339
- const result = await face.prompt([{
3340
- type: "text",
3341
- text: tr("prompt.kickoff")
3342
- }], "queue");
3343
- if (!result.ok) throw new Error(`kickoff prompt rejected: ${result.error.code}: ${result.error.message}`);
3344
- ctx.sessions.open(sessionId);
3345
- } catch (err) {
3346
- setError(err instanceof Error ? err.message : String(err));
3347
- setBusy(false);
3262
+ fn();
3263
+ } finally {
3264
+ suppressed = false;
3348
3265
  }
3349
- })();
3350
- };
3351
- return (0, react.createElement)("span", {
3352
- className: "lks-root",
3353
- style: {
3354
- display: "inline-flex",
3355
- alignItems: "center",
3356
- gap: "8px"
3266
+ },
3267
+ dispose: () => {
3268
+ bodyObserver.disconnect();
3269
+ doc.removeEventListener("click", onClickSidebarRow, true);
3270
+ doc.removeEventListener(ACTIVATE_EVENT, onOtherActivate);
3271
+ unsubscribeSessions?.();
3272
+ unsubscribe();
3273
+ root?.unmount();
3274
+ container?.remove();
3275
+ row.remove();
3357
3276
  }
3358
- }, (0, react.createElement)("button", {
3359
- className: "lks-pill on",
3360
- title: tr("start.title"),
3361
- disabled: busy,
3362
- onClick: start
3363
- }, busy ? (0, react.createElement)(IconLoadingOutline16, { className: "lks-spin" }) : (0, react.createElement)(IconThinkOutline16, null), busy ? tr("start.busy") : data?.active === true ? tr("start.enter") : tr("start.go")), error !== null ? (0, react.createElement)("span", {
3364
- className: "lks-propcard-err",
3365
- style: { marginTop: 0 }
3366
- }, error) : null);
3277
+ };
3278
+ }
3279
+ /** Whether a session change should hand the center column back (suppressible). */
3280
+ function shouldHandBack(suppressed, wasHydration) {
3281
+ return !suppressed && !wasHydration;
3367
3282
  }
3368
3283
  //#endregion
3369
3284
  //#region src/client/settings.tsx
@@ -3401,6 +3316,7 @@ window.__ModuleLoader__.load({
3401
3316
  function StudySettingsSection() {
3402
3317
  const { data, setMode, activate } = useStudy();
3403
3318
  const [error, setError] = (0, react.useState)(null);
3319
+ const [voice, setVoice] = (0, react.useState)(storedTtsVoice);
3404
3320
  const fire = (action) => {
3405
3321
  action.then(() => {
3406
3322
  setError(null);
@@ -3421,7 +3337,17 @@ window.__ModuleLoader__.load({
3421
3337
  onClick: () => {
3422
3338
  if (data !== null) fire(activate(!data.active));
3423
3339
  }
3424
- }, data?.active === true ? tr("settings.turnOff") : tr("settings.turnOn")), (0, react.createElement)("span", { className: "lks-set-state" }, data?.active === true ? tr("settings.on") : tr("settings.off")))), progress === void 0 ? null : (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.stats")), (0, react.createElement)("ul", { className: "lks-set-stats" }, (0, react.createElement)("li", null, tr("settings.stats.courses", { count: data?.courses.length ?? 0 })), (0, react.createElement)("li", null, tr("settings.stats.xp", {
3340
+ }, data?.active === true ? tr("settings.turnOff") : tr("settings.turnOn")), (0, react.createElement)("span", { className: "lks-set-state" }, data?.active === true ? tr("settings.on") : tr("settings.off")))), (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", {
3341
+ className: "lks-set-select",
3342
+ value: voice,
3343
+ onChange: (e) => {
3344
+ storeTtsVoice(e.target.value);
3345
+ setVoice(e.target.value);
3346
+ }
3347
+ }, ...TTS_VOICES.map((v) => (0, react.createElement)("option", {
3348
+ key: v.id,
3349
+ value: v.id
3350
+ }, tr(v.labelKey)))))), progress === void 0 ? null : (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.stats")), (0, react.createElement)("ul", { className: "lks-set-stats" }, (0, react.createElement)("li", null, tr("settings.stats.courses", { count: data?.courses.length ?? 0 })), (0, react.createElement)("li", null, tr("settings.stats.xp", {
3425
3351
  xp: progress.totalXp,
3426
3352
  level: progress.level,
3427
3353
  pct: progress.levelPct
@@ -3590,14 +3516,9 @@ window.__ModuleLoader__.load({
3590
3516
  "locale"
3591
3517
  ];
3592
3518
  /**
3593
- * Register the study surfaces: styles inject once; the single
3594
- * `conversation.view` tab carries the whole plugin; the hero starter button
3595
- * (`conversation.input.left`, blank sessions only) automates the onboarding —
3596
- * study workspace + session + kickoff prompt in one click. The `lookatstudy`
3597
- * locale namespace rides the framework locale service (zh/en) and the
3598
- * client-wide translator is installed before any component renders; both
3599
- * slot entries declare the namespace so the framework synthesizes their `t`
3600
- * seat and follows locale switches for the labels.
3519
+ * Register the study surfaces: styles + locale once; the sidebar-entry panel
3520
+ * carries the whole study UI; the host-native extras (settings, dock, tool
3521
+ * cards) ride their slots as before.
3601
3522
  * @param ctx - client root context.
3602
3523
  */
3603
3524
  function apply(ctx) {
@@ -3605,19 +3526,12 @@ window.__ModuleLoader__.load({
3605
3526
  const localeSvc = ctx.locale;
3606
3527
  ctx.effect(() => registerStudyLocale(localeSvc), "lookatstudy.locale()");
3607
3528
  setStudyTranslator(studyTranslator(localeSvc));
3608
- ctx.slots.inject("conversation.view", () => ctx.slots.register({
3609
- name: "conversation.view",
3610
- id: "lookatstudy-study",
3611
- order: 15,
3612
- label: () => tr("tab.label"),
3613
- locale: "lookatstudy"
3614
- }, studyView(ctx)));
3615
- ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
3616
- name: "conversation.input.left",
3617
- id: "lookatstudy-start",
3618
- order: 10,
3619
- locale: "lookatstudy"
3620
- }, studyStartButton(ctx)));
3529
+ const shell = mountStudyShell(studyPanelView(ctx), () => tr("tab.label"), void 0, ctx.sessions);
3530
+ setPanelShell(shell);
3531
+ ctx.effect(() => () => {
3532
+ setPanelShell(null);
3533
+ shell.dispose();
3534
+ }, "lookatstudy.studyPanel()");
3621
3535
  ctx.slots.inject("settings.section", () => ctx.slots.register({
3622
3536
  name: "settings.section",
3623
3537
  id: "lookatstudy",
@@ -3645,9 +3559,8 @@ window.__ModuleLoader__.load({
3645
3559
  }
3646
3560
  //#endregion
3647
3561
  exports.apply = apply;
3562
+ exports.feedRows = feedRows;
3648
3563
  exports.inject = inject;
3649
- exports.studyView = studyView;
3650
- exports.transcriptRows = transcriptRows;
3651
3564
  return module.exports;
3652
3565
  }
3653
3566
  });