dsh-plugin-lookatstudy 0.13.1 → 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,15 +166,28 @@ 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}
@@ -264,17 +197,20 @@ window.__ModuleLoader__.load({
264
197
  .lks-set-stats{margin:0;padding-left:18px;font-size:14px;line-height:1.9}
265
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}
266
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
+
267
207
  /* composer dock status pill (conversation.composer.dock entry) */
268
208
  .lks-dockpill{display:inline-flex;gap:10px;align-items:center;font-size:12px;color:var(--dsw-alias-label-secondary)}
269
- .lks-dockseg{white-space:nowrap}
209
+ .lks-dockseg{white-space:nowrap;display:inline-flex;align-items:center;gap:3px}
270
210
  .lks-dock-due{color:var(--lks-warn-ink)}
271
211
  .lks-dock-streak{color:var(--dsw-alias-state-business-primary)}
272
212
  .lks-dockseg.lks-muted{color:var(--dsw-alias-label-secondary)}
273
213
 
274
- /* concept map legend (amber = weak) */
275
- .lks-cmap-legend{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--dsw-alias-label-secondary);margin-top:4px}
276
- .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)}
277
-
278
214
  /* keyed tool.call.toolview cards (conversation tab tool rows) */
279
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}
280
216
  .lks-tv.err{border-color:var(--dsw-alias-state-error-primary)}
@@ -282,6 +218,7 @@ window.__ModuleLoader__.load({
282
218
  .lks-tv-chip{align-self:flex-start;border-radius:999px;padding:2px 10px;font-size:12.5px;font-weight:600}
283
219
  .lks-tv-chip.ok{background:var(--dsw-alias-state-success-tertiary);color:var(--lks-ok-ink)}
284
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}
285
222
  .lks-tv-line{color:var(--dsw-alias-label-secondary);line-height:1.6;white-space:pre-wrap}
286
223
  /* exam star card + the failure moment */
287
224
  .lks-tv-stars{display:inline-flex;align-items:center;gap:5px;font-size:13px;font-weight:600;color:var(--lks-warn-ink)}
@@ -319,25 +256,6 @@ window.__ModuleLoader__.load({
319
256
  * language — those are NOT ic_ds, see their section comment below.
320
257
  * @module dsh-plugin-lookatstudy/client/icons
321
258
  */
322
- /** ic_ds_think_outline_16 */
323
- function IconThinkOutline16({ size = 16, className }) {
324
- return (0, react.createElement)("svg", {
325
- width: size,
326
- height: size,
327
- className,
328
- viewBox: "0 0 16 16",
329
- fill: "none",
330
- xmlns: "http://www.w3.org/2000/svg"
331
- }, (0, react.createElement)("path", {
332
- 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",
333
- fill: "currentColor"
334
- }), (0, react.createElement)("path", {
335
- "fill-rule": "evenodd",
336
- "clip-rule": "evenodd",
337
- 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",
338
- fill: "currentColor"
339
- }));
340
- }
341
259
  /** ic_ds_loading_outline_16 */
342
260
  function IconLoadingOutline16({ size = 16, className }) {
343
261
  return (0, react.createElement)("svg", {
@@ -366,38 +284,6 @@ window.__ModuleLoader__.load({
366
284
  fill: "currentColor"
367
285
  }));
368
286
  }
369
- /** Vendored 2026-09-01 for the critique icon round (same figma source as the three above). */
370
- /** IconPlayOutline16 */
371
- function IconPlayOutline16({ size = 16, className }) {
372
- return (0, react.createElement)("svg", {
373
- width: size,
374
- height: size,
375
- className,
376
- viewBox: "0 0 16 16",
377
- fill: "none",
378
- xmlns: "http://www.w3.org/2000/svg"
379
- }, (0, react.createElement)("path", {
380
- 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",
381
- fill: "currentColor"
382
- }), (0, react.createElement)("path", {
383
- 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",
384
- fill: "currentColor"
385
- }));
386
- }
387
- /** IconPlusOutline16 */
388
- function IconPlusOutline16({ size = 16, className }) {
389
- return (0, react.createElement)("svg", {
390
- width: size,
391
- height: size,
392
- className,
393
- viewBox: "0 0 16 16",
394
- fill: "none",
395
- xmlns: "http://www.w3.org/2000/svg"
396
- }, (0, react.createElement)("path", {
397
- d: "M8.64453 1.5V7.34961H14.5V8.65039H8.64453V14.5H7.34473V8.65039H1.5V7.34961H7.34473V1.5H8.64453Z",
398
- fill: "currentColor"
399
- }));
400
- }
401
287
  /** IconTrashOutline16 */
402
288
  function IconTrashOutline16({ size = 16, className }) {
403
289
  return (0, react.createElement)("svg", {
@@ -460,26 +346,6 @@ window.__ModuleLoader__.load({
460
346
  fill: "currentColor"
461
347
  }));
462
348
  }
463
- /** IconWarningOutline16 */
464
- function IconWarningOutline16({ size = 16, className }) {
465
- return (0, react.createElement)("svg", {
466
- width: size,
467
- height: size,
468
- className,
469
- viewBox: "0 0 16 16",
470
- fill: "none",
471
- xmlns: "http://www.w3.org/2000/svg"
472
- }, (0, react.createElement)("path", {
473
- d: "M6.3002 3.32843L7.69986 3.32843L7.69986 7.79657H6.3002L6.3002 3.32843Z",
474
- fill: "currentColor"
475
- }), (0, react.createElement)("path", {
476
- d: "M6.3002 9.01935H7.69986V10.6711H6.3002V9.01935Z",
477
- fill: "currentColor"
478
- }), (0, react.createElement)("path", {
479
- 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",
480
- fill: "currentColor"
481
- }));
482
- }
483
349
  /**
484
350
  * Plugin-original companions for learning-domain glyphs the host set does not
485
351
  * carry (lock/star/book/crown/bolt/power/pin/flame) — authored here in the
@@ -560,41 +426,6 @@ window.__ModuleLoader__.load({
560
426
  fill: "currentColor"
561
427
  }));
562
428
  }
563
- /** IconPowerFill16 */
564
- function IconPowerFill16({ size = 16, className }) {
565
- return (0, react.createElement)("svg", {
566
- width: size,
567
- height: size,
568
- className,
569
- viewBox: "0 0 16 16",
570
- fill: "none",
571
- xmlns: "http://www.w3.org/2000/svg"
572
- }, (0, react.createElement)("path", {
573
- d: "M7.2 1h1.6v5.6H7.2V1Z",
574
- fill: "currentColor"
575
- }), (0, react.createElement)("path", {
576
- "fill-rule": "evenodd",
577
- "clip-rule": "evenodd",
578
- 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",
579
- fill: "currentColor"
580
- }));
581
- }
582
- /** IconPinFill16 */
583
- function IconPinFill16({ size = 16, className }) {
584
- return (0, react.createElement)("svg", {
585
- width: size,
586
- height: size,
587
- className,
588
- viewBox: "0 0 16 16",
589
- fill: "none",
590
- xmlns: "http://www.w3.org/2000/svg"
591
- }, (0, react.createElement)("path", {
592
- "fill-rule": "evenodd",
593
- "clip-rule": "evenodd",
594
- 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",
595
- fill: "currentColor"
596
- }));
597
- }
598
429
  /** IconFlameFill16 */
599
430
  function IconFlameFill16({ size = 16, className }) {
600
431
  return (0, react.createElement)("svg", {
@@ -1638,6 +1469,8 @@ window.__ModuleLoader__.load({
1638
1469
  "rail.empty.hint2": "本地文件夹 → 说「导入 D:/path/to/folder」",
1639
1470
  "rail.empty.placeholder": "GitHub 仓库链接,如 microsoft/AI-For-Beginners",
1640
1471
  "rail.empty.button": "导入",
1472
+ "composer.send": "发送",
1473
+ "composer.busy": "导师正在回复…",
1641
1474
  "rail.empty.demo": "导入示例课程",
1642
1475
  "rail.mastered": "毕业 {mastered}/{total} 课",
1643
1476
  "rail.avg": "平均掌握度 {pct}%",
@@ -1774,6 +1607,8 @@ window.__ModuleLoader__.load({
1774
1607
  "rail.empty.hint2": "Local folder → say \"import D:/path/to/folder\"",
1775
1608
  "rail.empty.placeholder": "GitHub repo link, e.g. microsoft/AI-For-Beginners",
1776
1609
  "rail.empty.button": "Import",
1610
+ "composer.send": "Send",
1611
+ "composer.busy": "The tutor is replying…",
1777
1612
  "rail.empty.demo": "Import the demo course",
1778
1613
  "rail.mastered": "{mastered}/{total} lessons graduated",
1779
1614
  "rail.avg": "avg mastery {pct}%",
@@ -2348,6 +2183,74 @@ window.__ModuleLoader__.load({
2348
2183
  });
2349
2184
  }
2350
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
2351
2254
  //#region src/client/readaloud.ts
2352
2255
  const sleep = (ms) => new Promise((resolve) => {
2353
2256
  setTimeout(resolve, ms);
@@ -2454,97 +2357,20 @@ window.__ModuleLoader__.load({
2454
2357
  //#endregion
2455
2358
  //#region src/client/views.tsx
2456
2359
  /**
2457
- * The study tab: one `conversation.view` entry rendering the whole plugin
2458
- * a simplified LookatStudy inside dsh, three columns in the tab. Left: the
2459
- * course map (selector, tree, due box, one-click demo import). Middle: the
2460
- * tutor conversation a read-only transcript folded from the session
2461
- * snapshot (user text, assistant text rendered through the plugin's own
2462
- * markdown pipeline; tool calls stay in the host conversation's toolview
2463
- * cards — a mirrored second record diverged) plus the soul pills, starters,
2464
- * pending proposal banner, and a reverse-channel input. Right: the blackboard —
2465
- * focus-lesson 讲解 and the Cornell 笔记 zones. Nothing outside this tab
2466
- * 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.
2467
2364
  * @module dsh-plugin-lookatstudy/client/views
2468
2365
  */
2469
- /** The three souls, in pill order (labels from LookatStudy's mode switcher). */
2470
- const MODES$1 = [
2471
- {
2472
- id: "direct",
2473
- labelKey: "soul.direct",
2474
- hintKey: "soul.direct.hint"
2475
- },
2476
- {
2477
- id: "guide",
2478
- labelKey: "soul.guide",
2479
- hintKey: "soul.guide.hint"
2480
- },
2481
- {
2482
- id: "practice",
2483
- labelKey: "soul.practice",
2484
- hintKey: "soul.practice.hint"
2485
- }
2486
- ];
2487
- /** Zone keys for the Cornell notebook (understand / record / practice). */
2488
- const ZONES = [
2489
- ["understand", "zone.understand"],
2490
- ["record", "zone.record"],
2491
- ["practice", "zone.practice"]
2492
- ];
2493
- /** Status icon for one lesson row (LookatStudy's map icon semantics, SVG glyphs). */
2494
- function statusIcon(kind, status) {
2495
- if (kind === "exam") return (0, react.createElement)(IconGoalOutline16, { size: 14 });
2496
- if (status === "mastered") return (0, react.createElement)(IconCrownFill16, { size: 14 });
2497
- if (status === "in_progress") return (0, react.createElement)(IconBookFill16, { size: 14 });
2498
- if (status === "available") return (0, react.createElement)(IconStarFill16, { size: 14 });
2499
- return (0, react.createElement)(IconLockFill16, { size: 14 });
2500
- }
2501
- /** LookatStudy's exam gate: an exam node opens only when every sibling study lesson reached mastery ≥50%. */
2502
- function examOpen(lessons) {
2503
- return lessons.every((l) => l.kind !== "study" || (l.masteryPct ?? 0) >= 50);
2504
- }
2505
- /** Multi-keyword AND title filter (LookatStudy course-tree-filter). */
2506
- function titleMatches(title, query) {
2507
- return query.trim().toLowerCase().split(/\s+/).filter(Boolean).every((k) => title.toLowerCase().includes(k));
2508
- }
2509
- /**
2510
- * Default expansion for one rail section: collapsed when every study lesson is
2511
- * done (mastered) or not yet reachable (locked) — long courses otherwise scroll
2512
- * 5× their viewport. The active frontier and the focus lesson's section stay
2513
- * open; exam nodes never force a section open (they are gated separately).
2514
- * Pure.
2515
- * @param section - one course section projection with lesson kinds/statuses.
2516
- */
2517
- function sectionDefaultOpen(section) {
2518
- return section.lessons.some((l) => l.focus || l.kind !== "exam" && l.status !== "mastered" && l.status !== "locked");
2519
- }
2520
- /** The rail's import row: a GitHub URL input plus the paste/folder hints. */
2521
- function ImportRow({ send }) {
2522
- const [url, setUrl] = (0, react.useState)("");
2523
- return (0, react.createElement)("div", { className: "lks-import" }, (0, react.createElement)("div", { className: "lks-inputrow" }, (0, react.createElement)("input", {
2524
- className: "lks-input",
2525
- type: "url",
2526
- placeholder: tr("rail.empty.placeholder"),
2527
- value: url,
2528
- onChange: (e) => {
2529
- setUrl(e.target.value);
2530
- },
2531
- onKeyDown: (e) => {
2532
- if (e.key === "Enter" && url.trim() !== "") send(tr("prompt.import", { url: url.trim() }));
2533
- }
2534
- }), (0, react.createElement)("button", {
2535
- className: "lks-btn primary",
2536
- disabled: url.trim() === "",
2537
- onClick: () => {
2538
- send(tr("prompt.import", { url: url.trim() }));
2539
- }
2540
- }, 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);
2541
2370
  }
2542
2371
  /**
2543
2372
  * Tooltip text for one course-tree status glyph. Pure (the translator is an
2544
2373
  * optional parameter defaulting to the active one, evaluated per call).
2545
- * @param kind - lesson kind ('study' | 'practice' | 'exam').
2546
- * @param status - lesson status ('locked' | 'available' | 'in_progress' | 'mastered').
2547
- * @param t - translator (defaults to the module's active translator).
2548
2374
  */
2549
2375
  function statusTitle(kind, status, t = tr) {
2550
2376
  if (kind === "exam") return t("status.exam");
@@ -2559,8 +2385,6 @@ window.__ModuleLoader__.load({
2559
2385
  * Extract the last quiz option block (2–4 consecutive `A. …` `B. …` lines,
2560
2386
  * letters strictly consecutive from A) from an assistant reply — the raw text
2561
2387
  * of the tutor's question becomes the clickable answer surface. Pure.
2562
- * @param text - one assistant reply's markdown.
2563
- * @returns the options of the last valid block, or [] when none.
2564
2388
  */
2565
2389
  function quizOptions(text) {
2566
2390
  const runs = [];
@@ -2591,268 +2415,216 @@ window.__ModuleLoader__.load({
2591
2415
  flush();
2592
2416
  return runs.at(-1) ?? [];
2593
2417
  }
2594
- function textOf(blocks) {
2595
- return (blocks ?? []).filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n\n").trim();
2596
- }
2597
- function assistantText(blocks) {
2598
- return (blocks ?? []).filter((b) => b.kind === "text").map((b) => b.text ?? "").join("\n\n").trim();
2599
- }
2600
2418
  /**
2601
- * Fold conversation nodes (plus the streaming partial) into render rows.
2602
- * Tool results are skipped: the host conversation renders the rich
2603
- * `tool.call.toolview` cards, and the tutor column stays a pure persona
2604
- * surface (a transcript mirror there grew a second, diverging record).
2605
- * Both inputs tolerate undefined: newer hosts (DSH v0.1.2-alpha.4) mount
2606
- * the conversation view before session data exists, and the fold must
2607
- * degrade to an empty transcript instead of blanking the whole tab.
2608
- * @param nodes - finalized conversation nodes from the snapshot.
2609
- * @param partial - the in-flight assistant partial, or null.
2610
- * @param t - translator (defaults to the active one, per call).
2611
- * @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.
2612
2422
  */
2613
- function transcriptRows(nodes, partial, t = tr) {
2614
- const rows = [];
2615
- for (const node of nodes ?? []) switch (node.kind) {
2616
- case "user":
2617
- case "steering": {
2618
- const text = textOf(node.content);
2619
- if (text !== "") rows.push({
2620
- key: `u${node.seq}`,
2621
- role: "user",
2622
- text
2623
- });
2624
- break;
2625
- }
2626
- case "assistant": {
2627
- const text = assistantText(node.blocks);
2628
- if (text !== "") rows.push({
2629
- key: `a${node.seq}`,
2630
- role: "assistant",
2631
- text
2632
- });
2633
- break;
2634
- }
2635
- case "turn-error": rows.push({
2636
- key: `e${node.seq}`,
2637
- role: "error",
2638
- text: node.message ?? ""
2639
- });
2640
- }
2641
- if (partial != null) {
2642
- const text = assistantText(partial.blocks);
2643
- if (text !== "") rows.push({
2644
- key: "streaming",
2645
- role: "streaming",
2646
- text
2647
- });
2648
- else rows.push({
2649
- key: "thinking",
2650
- role: "thinking",
2651
- text: t("row.thinking")
2652
- });
2653
- }
2654
- return rows;
2655
- }
2656
- /** Small inline error surface for failed write actions (shared with the settings page). */
2657
- function ActionError({ error }) {
2658
- if (error === null) return null;
2659
- 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");
2660
2425
  }
2426
+ //#endregion
2427
+ //#region src/client/panel.tsx
2661
2428
  /**
2662
- * Resolve the transcript across host generations. 0.1.3+ hosts expose the
2663
- * node array as the Chat target's `legacy` slice; pre-0.1.3 hosts hand the
2664
- * whole conversation snapshot (nodes + partial) through `useSession`, whose
2665
- * 0.1.3+ successor returns the session MACHINE (no node array). The array
2666
- * 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
2667
2441
  */
2668
- function pickTranscript(chatLegacy, sessionSnapshot) {
2669
- if (chatLegacy !== void 0) return chatLegacy;
2670
- return sessionSnapshot !== void 0 && Array.isArray(sessionSnapshot.nodes) ? sessionSnapshot : void 0;
2671
- }
2672
- /** sessionStorage key keeping the narrow-mode pane across view remounts (tab switches) and reloads. */
2673
- const PANE_KEY = "dsh-plugin-lookatstudy:pane";
2674
- /** Narrow the stored value back to a pane id; anything else (or nothing stored) falls back to 导师. Pure, unit-testable. */
2675
- function pickPane(stored) {
2676
- return stored === "rail" || stored === "tutor" || stored === "bb" ? stored : "tutor";
2677
- }
2678
- /** Read the persisted pane; the try swallows only the SecurityError privacy modes throw on storage access — the fallback is the default pane. */
2679
- function storedPane() {
2680
- try {
2681
- return pickPane(sessionStorage.getItem(PANE_KEY));
2682
- } catch {
2683
- return "tutor";
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"
2684
2458
  }
2685
- }
2686
- const sessionId = (id) => id;
2687
- /** The whole study tab. Wide = three columns (课程 | 导师 | 黑板); narrow (<1220px) = one composer-width pane with a three-way switcher. */
2688
- function studyView(ctx) {
2689
- return function StudyView(props) {
2690
- return (0, react.createElement)(StudyTab, {
2691
- ...props,
2692
- ctx,
2693
- key: "tab"
2694
- });
2695
- };
2696
- }
2697
- /** Tab body: the factory-bound ctx carries workspaces/sessions for the per-lesson session jumps. */
2698
- function StudyTab({ inputActions, ctx, ...standard }) {
2699
- const { data, activate, setMode, setFocus, searchLessons, deleteCourse, deleteNote, bindLessonSession } = useStudy();
2700
- const chatLegacy = standard.useChat?.((s) => s.legacy);
2701
- const sessionSnapshot = standard.useSession?.((s) => s);
2702
- const snapshot = pickTranscript(chatLegacy, sessionSnapshot);
2703
- const currentSessionId = standard.sessionId ?? snapshot?.sessionId ?? "";
2704
- const [pane, setPane] = (0, react.useState)(storedPane);
2705
- const rootRef = (0, react.useRef)(null);
2706
- (0, react.useEffect)(() => {
2707
- const root = rootRef.current;
2708
- const card = document.querySelector("[data-composer-card]");
2709
- const seat = card?.parentElement ?? null;
2710
- if (root === null || card === null || seat === null) return;
2711
- const clear = () => {
2712
- card.classList.remove("lks-composer-follow");
2713
- card.style.removeProperty("--lks-composer-shift");
2714
- card.style.removeProperty("--lks-composer-follow-width");
2715
- };
2716
- const apply = () => {
2717
- const tutor = root.querySelector(".lks-col-tutor");
2718
- const rail = root.querySelector(".lks-col-rail");
2719
- if (tutor === null || rail === null || getComputedStyle(rail).display === "none") return clear();
2720
- const seatRect = seat.getBoundingClientRect();
2721
- const tutorRect = tutor.getBoundingClientRect();
2722
- if (seatRect.width === 0 || tutorRect.width === 0) return clear();
2723
- const pad = parseFloat(getComputedStyle(seat).paddingLeft) || 0;
2724
- const cardW = card.getBoundingClientRect().width;
2725
- const max = seatRect.width - 2 * pad - cardW;
2726
- const shift = Math.round(Math.max(0, Math.min(tutorRect.left - (seatRect.left + pad), max)));
2727
- if (shift < 2) return clear();
2728
- card.style.setProperty("--lks-composer-shift", `${shift}px`);
2729
- card.style.setProperty("--lks-composer-follow-width", `${Math.round(tutorRect.width)}px`);
2730
- card.classList.add("lks-composer-follow");
2731
- };
2732
- apply();
2733
- const ro = new ResizeObserver(apply);
2734
- ro.observe(root);
2735
- ro.observe(seat);
2736
- window.addEventListener("resize", apply);
2737
- return () => {
2738
- ro.disconnect();
2739
- window.removeEventListener("resize", apply);
2740
- clear();
2741
- };
2742
- }, []);
2743
- const send = (text) => {
2744
- (async () => {
2745
- if (data?.active !== true) await activate(true);
2746
- inputActions.setDraft(text);
2747
- inputActions.submit();
2748
- })();
2749
- };
2750
- const [, bumpLocale] = (0, react.useState)(0);
2751
- (0, react.useEffect)(() => {
2752
- const localeSvc = ctx.locale;
2753
- if (localeSvc === void 0) return;
2754
- return localeSvc.subscribe(() => {
2755
- bumpLocale((v) => v + 1);
2756
- });
2757
- }, [ctx]);
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;
2758
2492
  return (0, react.createElement)("div", {
2759
- ref: rootRef,
2760
- className: "lks-root lks-study",
2761
- "data-conversation-composer-overlay": "",
2762
- "data-pane": pane
2763
- }, (0, react.createElement)("div", { className: "lks-actbar" }, data === null ? null : (0, react.createElement)("button", {
2764
- className: `lks-btn ${data.active ? "ghost" : "primary"}`,
2765
- title: data.active ? tr("active.on.title") : tr("active.off.title"),
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",
2766
2501
  onClick: () => {
2767
- activate(!data.active);
2502
+ interactive.send(tr("quiz.answer", {
2503
+ letter: opt.letter,
2504
+ text: opt.text
2505
+ }));
2768
2506
  }
2769
- }, 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" }, ...[
2770
- {
2771
- id: "rail",
2772
- labelKey: "pane.rail"
2773
- },
2774
- {
2775
- id: "tutor",
2776
- labelKey: "pane.tutor"
2777
- },
2778
- {
2779
- id: "bb",
2780
- labelKey: "pane.bb"
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"
2515
+ });
2516
+ };
2517
+ }
2518
+ function StudyPanelBody({ ctx }) {
2519
+ const { data, activate, setMode, setFocus, searchLessons, deleteCourse, deleteNote, bindLessonSession } = useStudy();
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);
2528
+ (0, react.useEffect)(() => {
2529
+ if (boundId === null) {
2530
+ setRows([]);
2531
+ return;
2781
2532
  }
2782
- ].map((p) => (0, react.createElement)("button", {
2783
- key: p.id,
2784
- className: `lks-switch-btn${pane === p.id ? " on" : ""}`,
2785
- "aria-pressed": String(pane === p.id),
2786
- onClick: () => {
2787
- setPane(p.id);
2788
- try {
2789
- sessionStorage.setItem(PANE_KEY, p.id);
2790
- } catch {}
2533
+ const source = ctx.sessions.binding(boundId)?.eventSource;
2534
+ if (source === void 0) {
2535
+ setRows([]);
2536
+ return;
2791
2537
  }
2792
- }, tr(p.labelKey)))), (0, react.createElement)(CourseRail, {
2538
+ const update = () => {
2539
+ setRows(feedRows(source.getSnapshot()));
2540
+ };
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. */
2549
+ const send = (text) => {
2550
+ (async () => {
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
+ }
2585
+ })();
2586
+ };
2587
+ const body = (0, react.createElement)("div", { className: "lks14-body" }, (0, react.createElement)(CourseRail, {
2793
2588
  data,
2794
2589
  activate,
2795
2590
  setFocus,
2796
2591
  searchLessons,
2797
2592
  deleteCourse,
2798
- bindLessonSession,
2799
- send,
2800
- ctx,
2801
- currentSessionId
2802
- }), (0, react.createElement)(TutorColumn, {
2593
+ send
2594
+ }), (0, react.createElement)(ChatPane, {
2803
2595
  data,
2804
- setMode,
2596
+ lesson,
2597
+ rows,
2598
+ busy,
2599
+ sendError,
2600
+ draft,
2601
+ setDraft,
2805
2602
  send,
2806
- snapshot
2807
- }), (0, react.createElement)(BlackboardColumn, {
2603
+ setMode
2604
+ }), (0, react.createElement)(NotebookPane, {
2808
2605
  data,
2809
2606
  deleteNote
2810
- })));
2607
+ }));
2608
+ return (0, react.createElement)("div", {
2609
+ className: "lks14",
2610
+ "data-lks-panel": ""
2611
+ }, body);
2811
2612
  }
2812
- /** Left column: course management (pick/delete/search/import), lesson tree, due box. */
2813
- function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, bindLessonSession, send, ctx, currentSessionId }) {
2814
- const [jumping, setJumping] = (0, react.useState)(null);
2815
- /** Open (or mint) the lesson's own session — the simplified thread system. */
2816
- const openLessonThread = (lesson) => {
2817
- if (jumping !== null) return;
2818
- const mapped = data?.lessonSessions[lesson.id];
2819
- if (mapped === currentSessionId) {
2820
- setFocus(lesson.id).catch(reportError);
2821
- return;
2822
- }
2823
- if (mapped !== void 0 && ctx.sessions.binding(sessionId(mapped)) !== void 0) {
2824
- setFocus(lesson.id).catch(reportError);
2825
- ctx.sessions.open(sessionId(mapped));
2826
- return;
2827
- }
2828
- setJumping(lesson.id);
2829
- setFocus(lesson.id).then(() => (async () => {
2830
- if (data?.active !== true) await activate(true);
2831
- const area = await fetch("/lookatstudy/api/study-workspace");
2832
- if (!area.ok) throw new Error(`study area unavailable (HTTP ${area.status})`);
2833
- const { path } = await area.json();
2834
- const workspace = await ctx.workspaces.create({ path });
2835
- const sessionId = ctx.workspaces.connectWorkspace !== void 0 ? await ctx.workspaces.connectWorkspace(workspace.workspaceId) : await ctx.sessions.create({ workspaceId: workspace.workspaceId });
2836
- const actx = ctx.sessions.scope(sessionId);
2837
- const face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx);
2838
- if (face === void 0) throw new Error("lesson session is not addressable yet");
2839
- const result = await face.prompt([{
2840
- type: "text",
2841
- text: tr("prompt.lesson", { title: lesson.title })
2842
- }], "queue");
2843
- if (!result.ok) throw new Error(`lesson prompt rejected: ${result.error.code}: ${result.error.message}`);
2844
- await bindLessonSession(lesson.id, sessionId);
2845
- ctx.sessions.open(sessionId);
2846
- })()).then(() => {
2847
- setJumping(null);
2848
- }, (err) => {
2849
- reportError(err);
2850
- setJumping(null);
2851
- });
2852
- };
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 }) {
2853
2620
  const [selectedCourse, setSelectedCourse] = (0, react.useState)("");
2854
2621
  const [query, setQuery] = (0, react.useState)("");
2855
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
+ };
2856
2628
  (0, react.useEffect)(() => {
2857
2629
  if (!confirmDelete) return;
2858
2630
  const disarm = () => {
@@ -2868,14 +2640,7 @@ window.__ModuleLoader__.load({
2868
2640
  window.removeEventListener("keydown", onKey);
2869
2641
  };
2870
2642
  }, [confirmDelete]);
2871
- const [showImport, setShowImport] = (0, react.useState)(false);
2872
- /** Per-section open overrides (courseId/sectionTitle → open?), on top of the frontier default. */
2873
- const [secOpen, setSecOpen] = (0, react.useState)({});
2874
- const [error, setError] = (0, react.useState)(null);
2875
- const reportError = (err) => {
2876
- setError(err instanceof Error ? err.message : String(err));
2877
- };
2878
- 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", {
2879
2644
  className: "lks-btn primary",
2880
2645
  style: { margin: "10px 0" },
2881
2646
  onClick: () => {
@@ -2884,8 +2649,8 @@ window.__ModuleLoader__.load({
2884
2649
  }, (0, react.createElement)(IconDownloadOutline16, null), tr("rail.empty.demo")), (0, react.createElement)(ImportRow, { send })) : (() => {
2885
2650
  const courseId = data.courses.some((c) => c.courseId === selectedCourse) ? selectedCourse : data.courses[0].courseId;
2886
2651
  const course = data.courses.find((c) => c.courseId === courseId);
2887
- return (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "lks-rail-head" }, data.courses.length > 1 ? (0, react.createElement)("select", {
2888
- 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",
2889
2654
  value: courseId,
2890
2655
  onChange: (e) => {
2891
2656
  setSelectedCourse(e.target.value);
@@ -2895,7 +2660,7 @@ window.__ModuleLoader__.load({
2895
2660
  key: c.courseId,
2896
2661
  value: c.courseId
2897
2662
  }, c.title))) : (0, react.createElement)("div", {
2898
- className: "lks-rail-title",
2663
+ className: "lks14-railtitle",
2899
2664
  title: course.title
2900
2665
  }, course.title), (0, react.createElement)("button", {
2901
2666
  className: `lks-btn ${confirmDelete ? "primary" : "ghost"}`,
@@ -2911,14 +2676,14 @@ window.__ModuleLoader__.load({
2911
2676
  setSelectedCourse("");
2912
2677
  }, reportError);
2913
2678
  }
2914
- }, 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", {
2915
2680
  mastered: course.mastered,
2916
2681
  total: course.total
2917
2682
  })), (0, react.createElement)("div", {
2918
- className: `lks-masterybar${course.avgMasteryPct === 100 ? " gold" : ""}`,
2683
+ className: `lks14-masterybar${course.avgMasteryPct === 100 ? " gold" : ""}`,
2919
2684
  title: course.avgMasteryPct === null ? tr("rail.avg.none") : tr("rail.avg", { pct: course.avgMasteryPct })
2920
2685
  }, (0, react.createElement)("i", { style: { transform: `scaleX(${(course.avgMasteryPct ?? 0) / 100})` } })), (0, react.createElement)("input", {
2921
- className: "lks-search",
2686
+ className: "lks14-search",
2922
2687
  type: "search",
2923
2688
  placeholder: tr("rail.search"),
2924
2689
  value: query,
@@ -2942,32 +2707,10 @@ window.__ModuleLoader__.load({
2942
2707
  }
2943
2708
  }).catch(reportError);
2944
2709
  }
2945
- }), course.sections.some((s) => s.lessons.some((l) => l.focus)) ? (0, react.createElement)("button", {
2946
- className: "lks-btn ghost",
2947
- style: {
2948
- margin: "0 0 6px",
2949
- padding: "3px 8px",
2950
- fontSize: "13px"
2951
- },
2952
- title: tr("rail.locate.title"),
2953
- onClick: () => {
2954
- const focusSection = course.sections.find((s) => s.lessons.some((l) => l.focus));
2955
- if (focusSection === void 0) return;
2956
- setSecOpen((m) => ({
2957
- ...m,
2958
- [`${courseId}/${focusSection.title}`]: true
2959
- }));
2960
- window.setTimeout(() => {
2961
- document.querySelector(".lks-col-rail .lks-node.focus")?.scrollIntoView({
2962
- block: "center",
2963
- behavior: "smooth"
2964
- });
2965
- }, 60);
2966
- }
2967
- }, (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", {
2968
2711
  key: d.lessonId,
2969
- className: "lks-due-item"
2970
- }, (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", {
2971
2714
  className: "lks-btn ghost",
2972
2715
  style: { marginTop: "6px" },
2973
2716
  onClick: () => {
@@ -2977,56 +2720,33 @@ window.__ModuleLoader__.load({
2977
2720
  const examAllowed = examOpen(section.lessons);
2978
2721
  const lessons = section.lessons.filter((l) => query.trim() === "" || titleMatches(l.title, query) || l.focus);
2979
2722
  if (lessons.length === 0) return [];
2980
- const secKey = `${courseId}/${section.title}`;
2981
- const open = query.trim() !== "" || (secOpen[secKey] ?? sectionDefaultOpen(section));
2723
+ const open = query.trim() !== "" || sectionDefaultOpen(section);
2982
2724
  return [(0, react.createElement)("button", {
2983
2725
  key: section.title,
2984
2726
  type: "button",
2985
- className: "lks-sechead",
2727
+ className: "lks14-sechead",
2986
2728
  "aria-expanded": String(open),
2987
2729
  title: open ? tr("rail.section.collapse") : tr("rail.section.expand", { count: section.lessons.length }),
2988
- onClick: () => {
2989
- setSecOpen((m) => ({
2990
- ...m,
2991
- [secKey]: !open
2992
- }));
2993
- }
2994
- }, (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) => {
2995
2732
  const locked = lesson.status === "locked" || lesson.kind === "exam" && !examAllowed;
2996
2733
  return (0, react.createElement)("button", {
2997
2734
  key: lesson.id,
2998
2735
  type: "button",
2999
- className: `lks-node${lesson.focus ? " focus" : ""}`,
2736
+ className: `lks14-node${lesson.focus ? " focus" : ""}`,
3000
2737
  "aria-disabled": locked || void 0,
3001
- 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)}`,
3002
2739
  onClick: () => {
3003
2740
  if (locked) return;
3004
- if (lesson.kind === "exam") {
3005
- setFocus(lesson.id).catch(reportError);
3006
- send(tr("prompt.exam", { section: section.title }));
3007
- return;
3008
- }
3009
- openLessonThread(lesson);
2741
+ setFocus(lesson.id).catch(reportError);
3010
2742
  }
3011
- }, (0, react.createElement)("span", { className: "lks-g" }, jumping === lesson.id ? (0, react.createElement)(IconLoadingOutline16, {
3012
- size: 14,
3013
- className: "lks-spin"
3014
- }) : statusIcon(lesson.kind, locked ? "locked" : lesson.status)), (0, react.createElement)("span", { className: "lks-t" }, lesson.title), lesson.due ? (0, react.createElement)("span", {
3015
- className: "lks-tag due",
3016
- title: tr("rail.due.tag")
3017
- }, (0, react.createElement)(IconRefreshOutline16, { size: 11 })) : null, lesson.weakConcepts > 0 ? (0, react.createElement)("span", {
3018
- 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",
3019
2745
  title: tr("tag.weak", { count: lesson.weakConcepts })
3020
- }, (0, react.createElement)(IconBoltFill16, { size: 10 }), String(lesson.weakConcepts)) : null, lesson.frictionCount > 0 ? (0, react.createElement)("span", {
3021
- className: "lks-tag fric",
3022
- title: tr("tag.friction", { count: lesson.frictionCount })
3023
- }, (0, react.createElement)(IconWarningOutline16, { size: 10 }), String(lesson.frictionCount)) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", {
3024
- 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",
3025
2748
  title: tr("tag.mastery", { pct: lesson.masteryPct })
3026
- }, (0, react.createElement)("i", { style: { transform: `scaleX(${lesson.masteryPct / 100})` } })) : null, lesson.masteryPct !== null ? (0, react.createElement)("span", {
3027
- className: "lks-pct",
3028
- title: tr("tag.mastery.short")
3029
- }, `${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);
3030
2750
  }) : []];
3031
2751
  }), (0, react.createElement)("button", {
3032
2752
  className: "lks-btn ghost",
@@ -3034,71 +2754,41 @@ window.__ModuleLoader__.load({
3034
2754
  onClick: () => {
3035
2755
  setShowImport(!showImport);
3036
2756
  }
3037
- }, 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);
3038
2758
  })();
3039
- 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));
3040
2760
  }
3041
- /** One transcript row's element; `interactive` adds the quiz-answer buttons to an assistant row. */
3042
- function chatRowElement(row, interactive) {
3043
- if (row.role === "user") return (0, react.createElement)("div", {
3044
- key: row.key,
3045
- className: "lks-msg user"
3046
- }, row.text);
3047
- if (row.role === "error") return (0, react.createElement)("div", {
3048
- key: row.key,
3049
- className: "lks-msg error"
3050
- }, `⚠ ${row.text}`);
3051
- if (row.role === "thinking") return (0, react.createElement)("div", {
3052
- key: row.key,
3053
- className: "lks-msg thinking",
3054
- title: tr("row.thinking.title")
3055
- }, (0, react.createElement)(IconLoadingOutline16, {
3056
- size: 14,
3057
- className: "lks-spin"
3058
- }), row.text);
3059
- const cls = row.role === "streaming" ? "lks-msg assistant lks-prose streaming" : "lks-msg assistant lks-prose";
3060
- const body = (0, react.createElement)("div", {
3061
- key: row.key,
3062
- className: cls,
3063
- dangerouslySetInnerHTML: { __html: renderMarkdown(row.text) }
3064
- });
3065
- const options = interactive === void 0 || row.role === "streaming" ? [] : quizOptions(row.text);
3066
- if (options.length < 2) return body;
3067
- return (0, react.createElement)("div", {
3068
- key: row.key,
3069
- className: "lks-turn"
3070
- }, body, (0, react.createElement)("div", {
3071
- className: "lks-quiz",
3072
- title: tr("quiz.title")
3073
- }, ...options.map((opt) => (0, react.createElement)("button", {
3074
- key: opt.letter,
3075
- 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() === "",
3076
2777
  onClick: () => {
3077
- interactive.send(tr("quiz.answer", {
3078
- letter: opt.letter,
3079
- text: opt.text
3080
- }));
2778
+ send(tr("prompt.import", { url: url.trim() }));
3081
2779
  }
3082
- }, (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")));
3083
2781
  }
3084
- /** Middle column: the tutor transcript, proposal banner, pills, starters. Typing happens in the native composer below the tab. */
3085
- function TutorColumn({ data, setMode, send, snapshot }) {
3086
- const [error, setError] = (0, react.useState)(null);
3087
- const rows = transcriptRows(snapshot?.nodes, snapshot?.partial ?? null);
3088
- const lastAssistant = rows.reduce((acc, row, i) => row.role === "assistant" ? i : acc, -1);
3089
- 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);
3090
2785
  (0, react.useEffect)(() => {
3091
- const el = scrollRef.current;
2786
+ const el = streamEl.current;
3092
2787
  if (el !== null) el.scrollTop = el.scrollHeight;
3093
2788
  }, [rows.length]);
3094
- (0, react.useEffect)(() => {
3095
- const el = scrollRef.current;
3096
- if (el !== null && rows.length > 0) enhanceRendered(el).catch(() => {});
3097
- }, [rows.length]);
3098
- const proposal = data?.pendingProposals[0] ?? null;
3099
- const lesson = data?.lesson ?? null;
3100
2789
  const dormant = data?.active !== true;
3101
- /** 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);
3102
2792
  const fire = (action) => {
3103
2793
  action.then(() => {
3104
2794
  setError(null);
@@ -3106,155 +2796,68 @@ window.__ModuleLoader__.load({
3106
2796
  setError(err instanceof Error ? err.message : String(err));
3107
2797
  });
3108
2798
  };
3109
- 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", {
3110
- className: "lks-transcript",
3111
- ref: scrollRef
3112
- }, 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", {
3113
- lesson: proposal.lessonTitle,
3114
- rationale: proposal.rationale
3115
- })), (0, react.createElement)("button", {
3116
- className: "lks-btn primary",
3117
- onClick: () => {
3118
- send(tr("prompt.proposal.accept", { id: proposal.id }));
3119
- }
3120
- }, tr("proposal.accept")), (0, react.createElement)("button", {
3121
- className: "lks-btn ghost",
3122
- onClick: () => {
3123
- send(tr("prompt.proposal.decline", { id: proposal.id }));
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);
3124
2804
  }
3125
- }, tr("proposal.decline"))) : null, (0, react.createElement)("div", {
3126
- className: "lks-pills",
3127
- style: {
3128
- height: "auto",
3129
- padding: "4px 0"
3130
- }
3131
- }, ...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", {
3132
2807
  key: mode.id,
3133
- className: `lks-pill${data?.mode === mode.id ? " on" : ""}`,
2808
+ className: `lks14-pill${data?.mode === mode.id ? " on" : ""}`,
3134
2809
  "aria-pressed": String(data?.mode === mode.id),
3135
2810
  "aria-disabled": dormant || void 0,
3136
2811
  title: dormant ? tr("tutor.dormant") : tr(mode.hintKey),
3137
2812
  onClick: () => {
3138
2813
  if (!dormant) fire(setMode(mode.id));
3139
2814
  }
3140
- }, tr(mode.labelKey)))), lesson !== null && lesson.starters.length > 0 ? (0, react.createElement)("div", {
3141
- className: "lks-dock",
3142
- style: { padding: "0 0 6px" }
3143
- }, ...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", {
3144
2819
  key: s.label,
3145
- className: "lks-starter",
2820
+ className: "lks14-starter",
3146
2821
  "aria-disabled": dormant || void 0,
3147
2822
  title: dormant ? tr("tutor.dormant") : s.message,
3148
2823
  onClick: () => {
3149
2824
  if (!dormant) send(s.message);
3150
2825
  }
3151
- }, s.label))) : null, (0, react.createElement)(ActionError, { error }));
3152
- }
3153
- /** Browser speechSynthesis engine the offline/endpoint-gone fallback voice. */
3154
- function systemSpeechEngine() {
3155
- return {
3156
- speak(text) {
3157
- return new Promise((resolve, reject) => {
3158
- const synth = typeof window === "undefined" ? void 0 : window.speechSynthesis;
3159
- if (synth === void 0) {
3160
- reject(/* @__PURE__ */ new Error("no speechSynthesis"));
3161
- return;
3162
- }
3163
- const utterance = new SpeechSynthesisUtterance(text);
3164
- utterance.lang = "zh-CN";
3165
- utterance.onend = () => resolve();
3166
- utterance.onerror = () => reject(/* @__PURE__ */ new Error("speechSynthesis failed"));
3167
- synth.speak(utterance);
3168
- });
3169
- },
3170
- pause() {
3171
- window.speechSynthesis?.pause();
3172
- },
3173
- resume() {
3174
- window.speechSynthesis?.resume();
3175
- },
3176
- cancel() {
3177
- window.speechSynthesis?.cancel();
3178
- }
3179
- };
3180
- }
3181
- /** Edge-over-dashboard engine: MP3 from /api/tts played through an <audio>, with per-session prefetch. */
3182
- function audioSpeechEngine(fetchTts) {
3183
- let audio = null;
3184
- const prefetch = /* @__PURE__ */ new Map();
3185
- const load = (text) => {
3186
- let pending = prefetch.get(text);
3187
- if (pending === void 0) {
3188
- pending = fetchTts(text);
3189
- pending.catch(() => prefetch.delete(text));
3190
- prefetch.set(text, pending);
3191
- }
3192
- return pending;
3193
- };
3194
- return {
3195
- speak(text) {
3196
- return load(text).then((buf) => new Promise((resolve, reject) => {
3197
- const blobUrl = URL.createObjectURL(new Blob([buf], { type: "audio/mpeg" }));
3198
- audio = new Audio(blobUrl);
3199
- const done = () => {
3200
- URL.revokeObjectURL(blobUrl);
3201
- resolve();
3202
- };
3203
- audio.onended = done;
3204
- audio.onerror = () => {
3205
- URL.revokeObjectURL(blobUrl);
3206
- reject(/* @__PURE__ */ new Error("audio playback failed"));
3207
- };
3208
- audio.play().catch(reject);
3209
- }));
3210
- },
3211
- pause() {
3212
- audio?.pause();
3213
- },
3214
- resume() {
3215
- audio?.play();
3216
- },
3217
- cancel() {
3218
- audio?.pause();
3219
- audio = null;
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);
3220
2834
  },
3221
- prewarm(text) {
3222
- load(text);
2835
+ onKeyDown: onComposerKey
2836
+ }), (0, react.createElement)("button", {
2837
+ className: "lks-btn primary",
2838
+ disabled: busy || dormant || draft.trim() === "",
2839
+ onClick: () => {
2840
+ send(draft);
3223
2841
  }
3224
- };
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);
3225
2846
  }
3226
- /** Right column: the blackboardfocus-lesson 讲解/脑图/概念图 plus the Cornell 笔记. */
3227
- function BlackboardColumn({ data, deleteNote }) {
2847
+ /** 右栏:the notebook讲解/概念图/笔记 tabs (upstream NotebookPanel arrangement). */
2848
+ function NotebookPane({ data, deleteNote }) {
3228
2849
  const lesson = data?.lesson ?? null;
3229
- const { tts } = useStudy();
3230
- const [pane, setPane] = (0, react.useState)("teach");
2850
+ const [tab, setTab] = (0, react.useState)("teach");
3231
2851
  const [error, setError] = (0, react.useState)(null);
2852
+ const [armedNote, setArmedNote] = (0, react.useState)(null);
3232
2853
  const [read, setRead] = (0, react.useState)(null);
3233
2854
  const readCtl = (0, react.useRef)(null);
3234
2855
  const [readError, setReadError] = (0, react.useState)(null);
3235
- const startReading = () => {
3236
- if (lesson === null || lesson.speechText.trim() === "") return;
3237
- readCtl.current?.stop();
3238
- const sentences = speechSentencesOf(lesson.speechText);
3239
- const voice = storedTtsVoice();
3240
- const controller = new ReadAloudController(sentences.map((s) => speakMathInSentence(s)), audioSpeechEngine((text) => tts(text, voice)), systemSpeechEngine(), (s) => {
3241
- setRead(s);
3242
- if (s.degraded && s.engine === "system" && !readError) setReadError(tr("read.engine.system"));
3243
- });
3244
- readCtl.current = controller;
3245
- setReadError(null);
3246
- controller.start().catch((err) => {
3247
- setReadError(err instanceof Error ? err.message : String(err));
3248
- });
3249
- };
3250
- const stopReading = () => {
3251
- readCtl.current?.stop();
3252
- setRead(null);
3253
- setReadError(null);
3254
- };
3255
- 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);
3256
2859
  (0, react.useEffect)(() => {
3257
- if (armedNote === null) return;
2860
+ if (!armedNote) return;
3258
2861
  const disarm = () => {
3259
2862
  setArmedNote(null);
3260
2863
  };
@@ -3275,46 +2878,130 @@ window.__ModuleLoader__.load({
3275
2878
  setError(err instanceof Error ? err.message : String(err));
3276
2879
  });
3277
2880
  };
3278
- const proseRef = (0, react.useRef)(null);
3279
- const diagRef = (0, react.useRef)(null);
3280
- (0, react.useEffect)(() => {
3281
- if (pane !== "teach" || proseRef.current === null || lesson === null) return;
3282
- const el = proseRef.current;
3283
- enhanceRendered(el).catch(() => {});
3284
- }, [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
+ };
3285
2966
  (0, react.useEffect)(() => {
3286
- if (pane !== "cmap" || diagRef.current === null || lesson === null) return;
2967
+ if (tab !== "cmap" || diagRef.current === null || lesson === null) return;
3287
2968
  const el = diagRef.current;
3288
2969
  el.textContent = "";
3289
2970
  (lesson.concepts.length === 0 ? Promise.reject(/* @__PURE__ */ new Error("no concepts")) : renderLessonConceptMap(el, lesson.title, lesson.concepts.map((c) => ({
3290
2971
  title: c.title,
3291
2972
  masteryPct: c.masteryPct
3292
2973
  })))).catch((err) => {
3293
- console.error("lks diagram pane failed:", pane, err);
3294
2974
  el.textContent = lesson.concepts.length === 0 ? tr("bb.fallback.cmap.empty") : tr("bb.fallback.cmap");
3295
2975
  });
3296
2976
  }, [
3297
- pane,
2977
+ tab,
3298
2978
  lesson?.lessonId,
3299
2979
  lesson?.concepts.length
3300
2980
  ]);
3301
- 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", {
3302
- key: c.title,
3303
- className: `lks-chip${c.weak ? " weak" : ""}`
3304
- }, `${c.title} ${c.masteryPct}%${c.weak ? " ⚡" : ""}`))) : null), (0, react.createElement)("div", { className: "lks-viewtabs" }, (0, react.createElement)("button", {
3305
- className: `lks-viewtab${pane === "teach" ? " on" : ""}`,
3306
- "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"),
3307
2988
  onClick: () => {
3308
- setPane("teach");
2989
+ setTab("teach");
3309
2990
  }
3310
2991
  }, tr("viewtab.teach")), (0, react.createElement)("button", {
3311
- className: `lks-viewtab${pane === "cmap" ? " on" : ""}`,
3312
- "aria-pressed": String(pane === "cmap"),
2992
+ className: `lks14-viewtab${tab === "cmap" ? " on" : ""}`,
2993
+ "aria-pressed": String(tab === "cmap"),
3313
2994
  title: tr("viewtab.cmap.title"),
3314
2995
  onClick: () => {
3315
- 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");
3316
3003
  }
3317
- }, (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", {
3318
3005
  className: "lks-btn ghost",
3319
3006
  style: {
3320
3007
  padding: "3px 8px",
@@ -3333,7 +3020,7 @@ window.__ModuleLoader__.load({
3333
3020
  }
3334
3021
  startReading();
3335
3022
  }
3336
- }, (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", {
3337
3024
  className: "lks-btn ghost",
3338
3025
  style: {
3339
3026
  padding: "3px 8px",
@@ -3342,20 +3029,20 @@ window.__ModuleLoader__.load({
3342
3029
  },
3343
3030
  title: tr("read.stop"),
3344
3031
  onClick: stopReading
3345
- }, 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", {
3346
- 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",
3347
3034
  ref: proseRef,
3348
3035
  dangerouslySetInnerHTML: { __html: lesson.html }
3349
- }) : (0, react.createElement)("div", {
3350
- className: "lks-prose",
3036
+ })) : tab === "cmap" ? (0, react.createElement)("div", {
3037
+ className: "lks14-prose",
3351
3038
  ref: diagRef
3352
- }), (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", {
3353
- className: "lks-empty",
3039
+ }) : (0, react.createElement)("div", { className: "lks14-zones" }, lesson.notes.length === 0 ? (0, react.createElement)("div", {
3040
+ className: "lks14-empty",
3354
3041
  style: { padding: "16px 0" }
3355
3042
  }, tr("bb.notes.empty")) : ZONES.filter(([zone]) => lesson.notes.some((n) => n.zone === zone)).map(([zone, labelKey]) => (0, react.createElement)("div", {
3356
3043
  key: zone,
3357
- className: "lks-zone"
3358
- }, (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", {
3359
3046
  key: n.id,
3360
3047
  className: "lks-note"
3361
3048
  }, (0, react.createElement)("span", { className: "lks-note-src" }, n.source), (0, react.createElement)("button", {
@@ -3375,83 +3062,223 @@ window.__ModuleLoader__.load({
3375
3062
  className: "lks-note-text",
3376
3063
  dangerouslySetInnerHTML: { __html: renderMarkdown(n.text) }
3377
3064
  }), n.quote !== null ? (0, react.createElement)("div", { className: "lks-note-q" }, `“${n.quote}”`) : null))))));
3378
- 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));
3379
3066
  }
3380
3067
  //#endregion
3381
- //#region src/client/starter.tsx
3068
+ //#region src/client/shell-entry.ts
3382
3069
  /**
3383
- * The one-click study starter: a pill button in the blank-session (hero)
3384
- * composer tool row. One click adopts the plugin's dedicated study workspace
3385
- * (created by the host beside the state file), opens a session in it, sends
3386
- * the kickoff prompt through the session API, and makes that session current
3387
- * the learner lands in the chat with the tutor already responding, one
3388
- * click away from the 学习 tab (view switching is ui-conversation-private,
3389
- * not reachable from an additive plugin).
3390
- * @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
3391
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>";
3392
3141
  /**
3393
- * Build the hero starter button bound to the framework services.
3394
- * @param ctx - client root context (workspaces + sessions injected).
3395
- * @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.
3396
3149
  */
3397
- function studyStartButton(ctx) {
3398
- return function StudyStartButton(props) {
3399
- const machine = props.useSession?.((s) => s);
3400
- if ((props.session?.blank ?? machine?.blank) !== true) return null;
3401
- return (0, react.createElement)(Inner, {
3402
- key: "inner",
3403
- ctx
3404
- });
3150
+ function mountStudyShell(view, label, dom, sessions) {
3151
+ if (typeof document === "undefined") return {
3152
+ dispose: () => {},
3153
+ close: () => {},
3154
+ suppressHandBack: (fn) => {
3155
+ fn();
3156
+ }
3405
3157
  };
3406
- }
3407
- /** Button body in its own component so hook order stays constant. */
3408
- function Inner({ ctx }) {
3409
- const { data, activate } = useStudy();
3410
- const [busy, setBusy] = (0, react.useState)(false);
3411
- const [error, setError] = (0, react.useState)(null);
3412
- const start = () => {
3413
- if (busy) return;
3414
- setBusy(true);
3415
- setError(null);
3416
- (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;
3417
3261
  try {
3418
- if (data?.active !== true) await activate(true);
3419
- const area = await fetch("/lookatstudy/api/study-workspace");
3420
- if (!area.ok) throw new Error(`study area unavailable (HTTP ${area.status})`);
3421
- const { path } = await area.json();
3422
- const workspace = await ctx.workspaces.create({ path });
3423
- const sessionId = ctx.workspaces.connectWorkspace !== void 0 ? await ctx.workspaces.connectWorkspace(workspace.workspaceId) : await ctx.sessions.create({ workspaceId: workspace.workspaceId });
3424
- const actx = ctx.sessions.scope(sessionId);
3425
- const face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx);
3426
- if (face === void 0) throw new Error("study session is not addressable yet");
3427
- const result = await face.prompt([{
3428
- type: "text",
3429
- text: tr("prompt.kickoff")
3430
- }], "queue");
3431
- if (!result.ok) throw new Error(`kickoff prompt rejected: ${result.error.code}: ${result.error.message}`);
3432
- ctx.sessions.open(sessionId);
3433
- } catch (err) {
3434
- setError(err instanceof Error ? err.message : String(err));
3435
- setBusy(false);
3262
+ fn();
3263
+ } finally {
3264
+ suppressed = false;
3436
3265
  }
3437
- })();
3438
- };
3439
- return (0, react.createElement)("span", {
3440
- className: "lks-root",
3441
- style: {
3442
- display: "inline-flex",
3443
- alignItems: "center",
3444
- 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();
3445
3276
  }
3446
- }, (0, react.createElement)("button", {
3447
- className: "lks-pill on",
3448
- title: tr("start.title"),
3449
- disabled: busy,
3450
- onClick: start
3451
- }, 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", {
3452
- className: "lks-propcard-err",
3453
- style: { marginTop: 0 }
3454
- }, 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;
3455
3282
  }
3456
3283
  //#endregion
3457
3284
  //#region src/client/settings.tsx
@@ -3689,14 +3516,9 @@ window.__ModuleLoader__.load({
3689
3516
  "locale"
3690
3517
  ];
3691
3518
  /**
3692
- * Register the study surfaces: styles inject once; the single
3693
- * `conversation.view` tab carries the whole plugin; the hero starter button
3694
- * (`conversation.input.left`, blank sessions only) automates the onboarding —
3695
- * study workspace + session + kickoff prompt in one click. The `lookatstudy`
3696
- * locale namespace rides the framework locale service (zh/en) and the
3697
- * client-wide translator is installed before any component renders; both
3698
- * slot entries declare the namespace so the framework synthesizes their `t`
3699
- * 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.
3700
3522
  * @param ctx - client root context.
3701
3523
  */
3702
3524
  function apply(ctx) {
@@ -3704,19 +3526,12 @@ window.__ModuleLoader__.load({
3704
3526
  const localeSvc = ctx.locale;
3705
3527
  ctx.effect(() => registerStudyLocale(localeSvc), "lookatstudy.locale()");
3706
3528
  setStudyTranslator(studyTranslator(localeSvc));
3707
- ctx.slots.inject("conversation.view", () => ctx.slots.register({
3708
- name: "conversation.view",
3709
- id: "lookatstudy-study",
3710
- order: 15,
3711
- label: () => tr("tab.label"),
3712
- locale: "lookatstudy"
3713
- }, studyView(ctx)));
3714
- ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
3715
- name: "conversation.input.left",
3716
- id: "lookatstudy-start",
3717
- order: 10,
3718
- locale: "lookatstudy"
3719
- }, 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()");
3720
3535
  ctx.slots.inject("settings.section", () => ctx.slots.register({
3721
3536
  name: "settings.section",
3722
3537
  id: "lookatstudy",
@@ -3744,9 +3559,8 @@ window.__ModuleLoader__.load({
3744
3559
  }
3745
3560
  //#endregion
3746
3561
  exports.apply = apply;
3562
+ exports.feedRows = feedRows;
3747
3563
  exports.inject = inject;
3748
- exports.studyView = studyView;
3749
- exports.transcriptRows = transcriptRows;
3750
3564
  return module.exports;
3751
3565
  }
3752
3566
  });