dsh-nerd-mode 0.2.1 → 0.4.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/README.md +215 -163
- package/README.zh.md +217 -163
- package/assets/calibration.svg +69 -0
- package/assets/choose-a-mode.svg +48 -0
- package/assets/nerd.svg +22 -0
- package/assets/two-paths.svg +93 -0
- package/client.js +1375 -161
- package/cordis.patch.yml +28 -19
- package/lib/index.js +121 -1
- package/package.json +15 -3
- package/skills/study/SKILL.md +78 -86
- package/skills/study-plus/SKILL.md +239 -185
- package/skills/study-plus/scripts/book-client.js +286 -0
- package/skills/study-plus/scripts/book-data.mjs +289 -0
- package/skills/study-plus/scripts/build-book.mjs +149 -0
- package/skills/writing-beats/agents/openai.yaml +1 -1
- package/assets/icon-dark.svg +0 -5
- package/assets/icon-light.svg +0 -5
package/client.js
CHANGED
|
@@ -1,161 +1,1375 @@
|
|
|
1
|
-
window.__ModuleLoader__.load({
|
|
2
|
-
id: 'dsh-nerd-mode/client',
|
|
3
|
-
factory(require) {
|
|
4
|
-
const React = require('react');
|
|
5
|
-
const { Toast } = require('@deepseek-ai/dsh-client-ui-primitives');
|
|
6
|
-
|
|
7
|
-
const SLOT = 'conversation.input.left';
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
),
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-nerd-mode/client',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react');
|
|
5
|
+
const { Toast } = require('@deepseek-ai/dsh-client-ui-primitives');
|
|
6
|
+
|
|
7
|
+
const SLOT = 'conversation.input.left';
|
|
8
|
+
const SETTINGS_SLOT = 'settings.section';
|
|
9
|
+
const LABEL = 'Nerd Mode';
|
|
10
|
+
const SETTINGS_NAMESPACE = 'dsh-nerd-mode';
|
|
11
|
+
const SETTINGS_DEFAULTS = Object.freeze({
|
|
12
|
+
recallSessionEnabled: false,
|
|
13
|
+
unseenQuestionCount: 3,
|
|
14
|
+
unseenQuestionType: 'short-answer',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function settingsValues(snapshot) {
|
|
18
|
+
const value = snapshot?.value ?? {};
|
|
19
|
+
const base = snapshot?.base ?? {};
|
|
20
|
+
const user = snapshot?.user ?? {};
|
|
21
|
+
return {
|
|
22
|
+
recallSessionEnabled: value.recallSessionEnabled ?? user.recallSessionEnabled ?? base.recallSessionEnabled ?? SETTINGS_DEFAULTS.recallSessionEnabled,
|
|
23
|
+
unseenQuestionCount: String(value.unseenQuestionCount ?? user.unseenQuestionCount ?? base.unseenQuestionCount ?? SETTINGS_DEFAULTS.unseenQuestionCount),
|
|
24
|
+
unseenQuestionType: value.unseenQuestionType ?? user.unseenQuestionType ?? base.unseenQuestionType ?? SETTINGS_DEFAULTS.unseenQuestionType,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function settingsOperations(draft, snapshot) {
|
|
29
|
+
const base = snapshot?.base ?? {};
|
|
30
|
+
const user = snapshot?.user ?? {};
|
|
31
|
+
const operations = [];
|
|
32
|
+
for (const [key, value] of Object.entries({
|
|
33
|
+
recallSessionEnabled: draft.recallSessionEnabled,
|
|
34
|
+
unseenQuestionCount: Number(draft.unseenQuestionCount),
|
|
35
|
+
unseenQuestionType: draft.unseenQuestionType,
|
|
36
|
+
})) {
|
|
37
|
+
const inherited = base[key] ?? SETTINGS_DEFAULTS[key];
|
|
38
|
+
if (value === inherited) {
|
|
39
|
+
if (Object.hasOwn(user, key)) operations.push({ op: 'unset', path: [key] });
|
|
40
|
+
} else if (!Object.hasOwn(user, key) || user[key] !== value) {
|
|
41
|
+
operations.push({ op: 'set', path: [key], value });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return operations;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validSettings(draft) {
|
|
48
|
+
const count = Number(draft.unseenQuestionCount);
|
|
49
|
+
return draft.unseenQuestionCount !== ''
|
|
50
|
+
&& Number.isInteger(count)
|
|
51
|
+
&& count >= 0
|
|
52
|
+
&& count <= 10
|
|
53
|
+
&& ['short-answer', 'multiple-choice', 'mixed'].includes(draft.unseenQuestionType);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sameSettings(left, right) {
|
|
57
|
+
return left.recallSessionEnabled === right.recallSessionEnabled
|
|
58
|
+
&& left.unseenQuestionCount === right.unseenQuestionCount
|
|
59
|
+
&& left.unseenQuestionType === right.unseenQuestionType;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function NerdModeSettingsEntry({ configForms, close }) {
|
|
63
|
+
const description = configForms.describe();
|
|
64
|
+
const described = React.useSyncExternalStore(
|
|
65
|
+
(listener) => description.subscribe(listener),
|
|
66
|
+
() => description.getSnapshot(),
|
|
67
|
+
() => description.getSnapshot(),
|
|
68
|
+
);
|
|
69
|
+
const settingsExist = described.view?.namespaces.some(({ ns }) => ns === SETTINGS_NAMESPACE) === true;
|
|
70
|
+
if (!settingsExist) {
|
|
71
|
+
const loading = (described.status === 'idle' || described.status === 'loading') && !described.error;
|
|
72
|
+
return React.createElement('div', { 'data-nerd-mode-settings': '' },
|
|
73
|
+
React.createElement('p', {
|
|
74
|
+
role: 'status',
|
|
75
|
+
'aria-busy': loading,
|
|
76
|
+
style: feedbackStyle,
|
|
77
|
+
}, loading
|
|
78
|
+
? 'Loading Nerd Mode settings…'
|
|
79
|
+
: described.status === 'ready'
|
|
80
|
+
? 'Nerd Mode settings are unavailable in this profile.'
|
|
81
|
+
: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.'),
|
|
82
|
+
loading ? null : React.createElement('button', {
|
|
83
|
+
type: 'button',
|
|
84
|
+
onClick: close,
|
|
85
|
+
style: secondaryButtonStyle,
|
|
86
|
+
}, 'Close'),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return React.createElement(NerdModeSettings, {
|
|
90
|
+
close,
|
|
91
|
+
scope: configForms.get(SETTINGS_NAMESPACE),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function NerdModeSettings({ scope, close }) {
|
|
96
|
+
const snapshot = React.useSyncExternalStore(
|
|
97
|
+
(listener) => scope.subscribe(listener),
|
|
98
|
+
() => scope.getSnapshot(),
|
|
99
|
+
() => scope.getSnapshot(),
|
|
100
|
+
);
|
|
101
|
+
const [draft, setDraft] = React.useState(() => snapshot?.status === 'ready' ? settingsValues(snapshot) : null);
|
|
102
|
+
const [saving, setSaving] = React.useState(false);
|
|
103
|
+
const [feedback, setFeedback] = React.useState(null);
|
|
104
|
+
React.useEffect(() => {
|
|
105
|
+
if (draft === null && snapshot?.status === 'ready') {
|
|
106
|
+
setDraft(settingsValues(snapshot));
|
|
107
|
+
}
|
|
108
|
+
}, [draft, snapshot?.status]);
|
|
109
|
+
if (snapshot?.status === 'loading' || snapshot?.status === 'idle' || snapshot?.status === 'ready' && draft === null) {
|
|
110
|
+
return React.createElement('p', {
|
|
111
|
+
role: 'status',
|
|
112
|
+
'aria-busy': true,
|
|
113
|
+
style: feedbackStyle,
|
|
114
|
+
}, 'Loading Nerd Mode settings…');
|
|
115
|
+
}
|
|
116
|
+
if (snapshot?.status !== 'ready') {
|
|
117
|
+
return React.createElement('div', { 'data-nerd-mode-settings': '' },
|
|
118
|
+
React.createElement('p', { role: 'status', style: feedbackStyle },
|
|
119
|
+
snapshot?.status === 'unavailable'
|
|
120
|
+
? 'Nerd Mode settings are unavailable in this profile.'
|
|
121
|
+
: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.'),
|
|
122
|
+
React.createElement('button', {
|
|
123
|
+
type: 'button',
|
|
124
|
+
onClick: close,
|
|
125
|
+
style: secondaryButtonStyle,
|
|
126
|
+
}, 'Close'),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const current = settingsValues(snapshot);
|
|
130
|
+
const dirty = !sameSettings(draft, current);
|
|
131
|
+
const writable = snapshot.writable === true;
|
|
132
|
+
const updateDraft = (key, value) => {
|
|
133
|
+
setDraft((previous) => ({ ...previous, [key]: value }));
|
|
134
|
+
setFeedback(null);
|
|
135
|
+
};
|
|
136
|
+
const restoreDefaults = () => {
|
|
137
|
+
setDraft({
|
|
138
|
+
...SETTINGS_DEFAULTS,
|
|
139
|
+
unseenQuestionCount: String(SETTINGS_DEFAULTS.unseenQuestionCount),
|
|
140
|
+
});
|
|
141
|
+
setFeedback(null);
|
|
142
|
+
};
|
|
143
|
+
const cancel = () => {
|
|
144
|
+
setDraft(settingsValues(scope.getSnapshot()));
|
|
145
|
+
setFeedback(null);
|
|
146
|
+
};
|
|
147
|
+
const save = async () => {
|
|
148
|
+
const latest = scope.getSnapshot();
|
|
149
|
+
if (latest?.status !== 'ready' || !validSettings(draft) || latest.writable !== true) return;
|
|
150
|
+
const operations = settingsOperations(draft, latest);
|
|
151
|
+
if (operations.length === 0) return;
|
|
152
|
+
setSaving(true);
|
|
153
|
+
setFeedback(null);
|
|
154
|
+
try {
|
|
155
|
+
const accepted = await scope.mutate(operations, latest.revision);
|
|
156
|
+
if (accepted === false) throw new Error('The profile rejected these settings.');
|
|
157
|
+
setDraft(settingsValues(scope.getSnapshot()));
|
|
158
|
+
setFeedback('Saved to this profile. Changes apply to the next study-plus session.');
|
|
159
|
+
} catch (error) {
|
|
160
|
+
setFeedback(`Could not save settings: ${error?.message ?? 'the profile rejected the changes'}`);
|
|
161
|
+
} finally {
|
|
162
|
+
setSaving(false);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return React.createElement(
|
|
167
|
+
'div',
|
|
168
|
+
{
|
|
169
|
+
'data-nerd-mode-settings': '',
|
|
170
|
+
style: {
|
|
171
|
+
boxSizing: 'border-box',
|
|
172
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
173
|
+
display: 'flex',
|
|
174
|
+
flexDirection: 'column',
|
|
175
|
+
fontFamily: 'var(--dsw-font-family, inherit)',
|
|
176
|
+
gap: 16,
|
|
177
|
+
maxWidth: 720,
|
|
178
|
+
paddingBottom: 24,
|
|
179
|
+
width: '100%',
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
React.createElement(
|
|
183
|
+
'header',
|
|
184
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 4 } },
|
|
185
|
+
React.createElement('h2', {
|
|
186
|
+
style: { fontSize: 16, fontWeight: 600, lineHeight: '24px', margin: 0 },
|
|
187
|
+
}, 'Nerd Mode'),
|
|
188
|
+
React.createElement('p', {
|
|
189
|
+
style: {
|
|
190
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
191
|
+
fontSize: 14,
|
|
192
|
+
lineHeight: '22px',
|
|
193
|
+
margin: 0,
|
|
194
|
+
},
|
|
195
|
+
}, 'Profile defaults for new study-plus sessions.'),
|
|
196
|
+
),
|
|
197
|
+
React.createElement(
|
|
198
|
+
'section',
|
|
199
|
+
{
|
|
200
|
+
'data-settings-group': 'recall-session',
|
|
201
|
+
style: groupStyle,
|
|
202
|
+
},
|
|
203
|
+
React.createElement(
|
|
204
|
+
'div',
|
|
205
|
+
{ style: { minWidth: 0 } },
|
|
206
|
+
React.createElement('h3', { style: groupTitleStyle }, 'Recall Session / 课前回忆'),
|
|
207
|
+
React.createElement('p', { style: groupDescriptionStyle }, 'One ungraded recall question before teaching.'),
|
|
208
|
+
),
|
|
209
|
+
React.createElement(
|
|
210
|
+
'button',
|
|
211
|
+
{
|
|
212
|
+
type: 'button',
|
|
213
|
+
role: 'switch',
|
|
214
|
+
'aria-checked': draft.recallSessionEnabled,
|
|
215
|
+
'aria-label': 'Recall Session',
|
|
216
|
+
'data-setting': 'recallSessionEnabled',
|
|
217
|
+
'data-state': draft.recallSessionEnabled ? 'on' : 'off',
|
|
218
|
+
disabled: !writable || saving,
|
|
219
|
+
onClick: () => updateDraft('recallSessionEnabled', !draft.recallSessionEnabled),
|
|
220
|
+
style: switchStyle(draft.recallSessionEnabled, !writable || saving),
|
|
221
|
+
},
|
|
222
|
+
React.createElement('span', null, draft.recallSessionEnabled ? 'On' : 'Off'),
|
|
223
|
+
React.createElement('span', {
|
|
224
|
+
'aria-hidden': 'true',
|
|
225
|
+
style: {
|
|
226
|
+
background: 'currentColor',
|
|
227
|
+
borderRadius: '50%',
|
|
228
|
+
height: 8,
|
|
229
|
+
opacity: draft.recallSessionEnabled ? 1 : 0.5,
|
|
230
|
+
width: 8,
|
|
231
|
+
},
|
|
232
|
+
}),
|
|
233
|
+
),
|
|
234
|
+
),
|
|
235
|
+
React.createElement(
|
|
236
|
+
'section',
|
|
237
|
+
{
|
|
238
|
+
'data-settings-group': 'post-lecture-questions',
|
|
239
|
+
style: groupStyle,
|
|
240
|
+
},
|
|
241
|
+
React.createElement(
|
|
242
|
+
'div',
|
|
243
|
+
null,
|
|
244
|
+
React.createElement('h3', { style: groupTitleStyle }, 'Post-lecture questions'),
|
|
245
|
+
React.createElement('p', { style: groupDescriptionStyle }, 'Choose how many unseen questions follow each lecture.'),
|
|
246
|
+
),
|
|
247
|
+
React.createElement(
|
|
248
|
+
'div',
|
|
249
|
+
{ style: { display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' } },
|
|
250
|
+
React.createElement(
|
|
251
|
+
'label',
|
|
252
|
+
{ htmlFor: 'nerd-mode-question-count', style: fieldLabelStyle },
|
|
253
|
+
React.createElement('span', null, 'Question count'),
|
|
254
|
+
React.createElement('input', {
|
|
255
|
+
id: 'nerd-mode-question-count',
|
|
256
|
+
type: 'number',
|
|
257
|
+
min: 0,
|
|
258
|
+
max: 10,
|
|
259
|
+
step: 1,
|
|
260
|
+
inputMode: 'numeric',
|
|
261
|
+
value: draft.unseenQuestionCount,
|
|
262
|
+
'data-setting': 'unseenQuestionCount',
|
|
263
|
+
disabled: !writable || saving,
|
|
264
|
+
onChange: (event) => updateDraft('unseenQuestionCount', event.target.value),
|
|
265
|
+
style: fieldControlStyle,
|
|
266
|
+
}),
|
|
267
|
+
React.createElement('span', { style: fieldHintStyle }, 'Use 0 to skip unseen questions.'),
|
|
268
|
+
),
|
|
269
|
+
React.createElement(
|
|
270
|
+
'label',
|
|
271
|
+
{ htmlFor: 'nerd-mode-question-type', style: fieldLabelStyle },
|
|
272
|
+
React.createElement('span', null, 'Question type'),
|
|
273
|
+
React.createElement(
|
|
274
|
+
'select',
|
|
275
|
+
{
|
|
276
|
+
id: 'nerd-mode-question-type',
|
|
277
|
+
value: draft.unseenQuestionType,
|
|
278
|
+
'data-setting': 'unseenQuestionType',
|
|
279
|
+
disabled: !writable || saving,
|
|
280
|
+
onChange: (event) => updateDraft('unseenQuestionType', event.target.value),
|
|
281
|
+
style: fieldControlStyle,
|
|
282
|
+
},
|
|
283
|
+
React.createElement('option', { value: 'short-answer' }, 'Short answer'),
|
|
284
|
+
React.createElement('option', { value: 'multiple-choice' }, 'Multiple choice'),
|
|
285
|
+
React.createElement('option', { value: 'mixed' }, 'Mixed'),
|
|
286
|
+
),
|
|
287
|
+
React.createElement('span', { style: fieldHintStyle }, 'Mixed chooses a type for each question.'),
|
|
288
|
+
),
|
|
289
|
+
),
|
|
290
|
+
),
|
|
291
|
+
React.createElement(
|
|
292
|
+
'footer',
|
|
293
|
+
{
|
|
294
|
+
style: {
|
|
295
|
+
alignItems: 'center',
|
|
296
|
+
borderTop: '1px solid var(--dsw-alias-border-l2)',
|
|
297
|
+
display: 'flex',
|
|
298
|
+
flexWrap: 'wrap',
|
|
299
|
+
gap: 12,
|
|
300
|
+
justifyContent: 'space-between',
|
|
301
|
+
paddingTop: 16,
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
React.createElement('button', {
|
|
305
|
+
type: 'button',
|
|
306
|
+
disabled: !writable || saving,
|
|
307
|
+
onClick: restoreDefaults,
|
|
308
|
+
style: secondaryButtonStyle,
|
|
309
|
+
}, 'Restore defaults'),
|
|
310
|
+
React.createElement(
|
|
311
|
+
'div',
|
|
312
|
+
{ style: { display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' } },
|
|
313
|
+
React.createElement('button', {
|
|
314
|
+
type: 'button',
|
|
315
|
+
disabled: !dirty || saving,
|
|
316
|
+
onClick: cancel,
|
|
317
|
+
style: secondaryButtonStyle,
|
|
318
|
+
}, 'Cancel'),
|
|
319
|
+
React.createElement('button', {
|
|
320
|
+
type: 'button',
|
|
321
|
+
disabled: !dirty || !validSettings(draft) || !writable || saving,
|
|
322
|
+
onClick: save,
|
|
323
|
+
style: primaryButtonStyle,
|
|
324
|
+
}, saving ? 'Saving…' : 'Save'),
|
|
325
|
+
),
|
|
326
|
+
),
|
|
327
|
+
snapshot?.writable === false
|
|
328
|
+
? React.createElement('p', { role: 'status', style: feedbackStyle }, 'These profile settings are read-only.')
|
|
329
|
+
: null,
|
|
330
|
+
feedback === null
|
|
331
|
+
? null
|
|
332
|
+
: React.createElement('p', {
|
|
333
|
+
role: feedback.startsWith('Could not save') ? 'alert' : 'status',
|
|
334
|
+
style: feedbackStyle,
|
|
335
|
+
}, feedback),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const groupStyle = {
|
|
340
|
+
alignItems: 'center',
|
|
341
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
342
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
343
|
+
borderRadius: 'var(--dsw-radius-sm)',
|
|
344
|
+
display: 'flex',
|
|
345
|
+
flexWrap: 'wrap',
|
|
346
|
+
gap: 18,
|
|
347
|
+
justifyContent: 'space-between',
|
|
348
|
+
padding: 20,
|
|
349
|
+
};
|
|
350
|
+
const groupTitleStyle = {
|
|
351
|
+
fontSize: 14,
|
|
352
|
+
fontWeight: 600,
|
|
353
|
+
lineHeight: '22px',
|
|
354
|
+
margin: 0,
|
|
355
|
+
};
|
|
356
|
+
const groupDescriptionStyle = {
|
|
357
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
358
|
+
fontSize: 13,
|
|
359
|
+
lineHeight: '20px',
|
|
360
|
+
margin: '3px 0 0',
|
|
361
|
+
};
|
|
362
|
+
const fieldLabelStyle = {
|
|
363
|
+
display: 'flex',
|
|
364
|
+
flexDirection: 'column',
|
|
365
|
+
fontSize: 13,
|
|
366
|
+
fontWeight: 500,
|
|
367
|
+
gap: 7,
|
|
368
|
+
minWidth: 0,
|
|
369
|
+
};
|
|
370
|
+
const fieldControlStyle = {
|
|
371
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
372
|
+
border: '1px solid var(--dsw-alias-border-l3)',
|
|
373
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
374
|
+
boxSizing: 'border-box',
|
|
375
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
376
|
+
font: 'inherit',
|
|
377
|
+
height: 38,
|
|
378
|
+
minWidth: 0,
|
|
379
|
+
padding: '0 10px',
|
|
380
|
+
width: '100%',
|
|
381
|
+
};
|
|
382
|
+
const fieldHintStyle = {
|
|
383
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
384
|
+
fontSize: 12,
|
|
385
|
+
fontWeight: 400,
|
|
386
|
+
lineHeight: '18px',
|
|
387
|
+
};
|
|
388
|
+
const secondaryButtonStyle = {
|
|
389
|
+
background: 'transparent',
|
|
390
|
+
border: '1px solid var(--dsw-alias-border-l3)',
|
|
391
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
392
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
393
|
+
cursor: 'pointer',
|
|
394
|
+
font: 'inherit',
|
|
395
|
+
minHeight: 36,
|
|
396
|
+
padding: '0 12px',
|
|
397
|
+
};
|
|
398
|
+
const primaryButtonStyle = {
|
|
399
|
+
...secondaryButtonStyle,
|
|
400
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
401
|
+
borderColor: 'var(--dsw-alias-state-business-primary)',
|
|
402
|
+
color: 'var(--dsw-alias-state-business-primary)',
|
|
403
|
+
fontWeight: 600,
|
|
404
|
+
};
|
|
405
|
+
const feedbackStyle = {
|
|
406
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
407
|
+
fontSize: 13,
|
|
408
|
+
lineHeight: '20px',
|
|
409
|
+
margin: 0,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
function switchStyle(active, disabled) {
|
|
413
|
+
const accent = 'var(--dsw-alias-state-business-primary)';
|
|
414
|
+
return {
|
|
415
|
+
alignItems: 'center',
|
|
416
|
+
background: active ? 'var(--dsw-alias-state-business-tertiary)' : 'var(--dsw-alias-bg-layer-2)',
|
|
417
|
+
border: `1px solid ${active ? accent : 'var(--dsw-alias-border-l3)'}`,
|
|
418
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
419
|
+
color: active ? accent : 'var(--dsw-alias-label-secondary)',
|
|
420
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
421
|
+
display: 'inline-flex',
|
|
422
|
+
flexShrink: 0,
|
|
423
|
+
font: 'inherit',
|
|
424
|
+
gap: 8,
|
|
425
|
+
justifyContent: 'center',
|
|
426
|
+
minHeight: 36,
|
|
427
|
+
minWidth: 72,
|
|
428
|
+
padding: '0 10px',
|
|
429
|
+
transition: 'background 120ms ease, border-color 120ms ease, color 120ms ease',
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// 16px book, same footprint as the Full Throttle bolt.
|
|
434
|
+
const BOOK = React.createElement(
|
|
435
|
+
'svg',
|
|
436
|
+
{
|
|
437
|
+
'aria-hidden': 'true',
|
|
438
|
+
fill: 'none',
|
|
439
|
+
height: 16,
|
|
440
|
+
stroke: 'currentColor',
|
|
441
|
+
strokeLinecap: 'round',
|
|
442
|
+
strokeLinejoin: 'round',
|
|
443
|
+
strokeWidth: 1.6,
|
|
444
|
+
viewBox: '0 0 16 16',
|
|
445
|
+
width: 16,
|
|
446
|
+
},
|
|
447
|
+
React.createElement('path', {
|
|
448
|
+
d: 'M2.5 3.2A1.7 1.7 0 0 1 4.2 1.5H7.5v11H4.2a1.7 1.7 0 0 0-1.7 1.7z',
|
|
449
|
+
}),
|
|
450
|
+
React.createElement('path', {
|
|
451
|
+
d: 'M13.5 3.2a1.7 1.7 0 0 0-1.7-1.7H8.5v11h3.3a1.7 1.7 0 0 1 1.7 1.7z',
|
|
452
|
+
}),
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
// The theme owns the colors; only the state picks which alias to read.
|
|
456
|
+
function pillStyle(active) {
|
|
457
|
+
if (active) {
|
|
458
|
+
const business = 'var(--dsw-alias-state-business-primary)';
|
|
459
|
+
return {
|
|
460
|
+
background: `color-mix(in srgb, ${business} 16%, transparent)`,
|
|
461
|
+
boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${business} 55%, transparent)`,
|
|
462
|
+
color: business,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
background: 'var(--dsw-alias-fill-tsp-secondary)',
|
|
467
|
+
boxShadow: 'none',
|
|
468
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
469
|
+
opacity: 0.65,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Unknown commands resolve as { ok: true, value: undefined }; they never
|
|
474
|
+
// reach the command executor and write nothing to the session log.
|
|
475
|
+
function failureText(result) {
|
|
476
|
+
if (!result?.ok) {
|
|
477
|
+
return `Nerd Mode did not switch: ${result?.error?.message ?? 'the host rejected the command'}`;
|
|
478
|
+
}
|
|
479
|
+
if (result.value == null) {
|
|
480
|
+
return 'Nerd Mode did not switch: /nerd-mode is not available in this build.';
|
|
481
|
+
}
|
|
482
|
+
const outcome = result.value.result;
|
|
483
|
+
if (outcome?.kind === 'error') {
|
|
484
|
+
return `Nerd Mode did not switch: ${outcome.text ?? 'the command failed'}`;
|
|
485
|
+
}
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function NerdModeToggle({ useProjection, execute }) {
|
|
490
|
+
const state = useProjection('studyMode');
|
|
491
|
+
const active = state?.active === true;
|
|
492
|
+
const [busy, setBusy] = React.useState(false);
|
|
493
|
+
const [failure, setFailure] = React.useState(null);
|
|
494
|
+
const [toast, setToast] = React.useState(null);
|
|
495
|
+
|
|
496
|
+
React.useEffect(() => {
|
|
497
|
+
setFailure(null);
|
|
498
|
+
setToast(null);
|
|
499
|
+
}, [active]);
|
|
500
|
+
|
|
501
|
+
const onClick = async () => {
|
|
502
|
+
if (busy) return;
|
|
503
|
+
setBusy(true);
|
|
504
|
+
setFailure(null);
|
|
505
|
+
setToast(null);
|
|
506
|
+
try {
|
|
507
|
+
const result = await execute(active ? '/nerd-mode off' : '/nerd-mode on');
|
|
508
|
+
const text = failureText(result);
|
|
509
|
+
if (text !== null) {
|
|
510
|
+
setFailure(text);
|
|
511
|
+
setToast({ id: Date.now(), text });
|
|
512
|
+
}
|
|
513
|
+
} catch (error) {
|
|
514
|
+
const text = failureText({ ok: false, error });
|
|
515
|
+
setFailure(text);
|
|
516
|
+
setToast({ id: Date.now(), text });
|
|
517
|
+
} finally {
|
|
518
|
+
setBusy(false);
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
return React.createElement(
|
|
523
|
+
React.Fragment,
|
|
524
|
+
null,
|
|
525
|
+
React.createElement(
|
|
526
|
+
'button',
|
|
527
|
+
{
|
|
528
|
+
type: 'button',
|
|
529
|
+
role: 'switch',
|
|
530
|
+
'aria-checked': active,
|
|
531
|
+
'aria-label': LABEL,
|
|
532
|
+
'data-state': active ? 'on' : 'off',
|
|
533
|
+
title: failure ?? (active ? 'Nerd Mode is on' : 'Nerd Mode is off'),
|
|
534
|
+
disabled: busy,
|
|
535
|
+
onMouseDown: (event) => event.preventDefault(),
|
|
536
|
+
onClick,
|
|
537
|
+
style: {
|
|
538
|
+
alignItems: 'center',
|
|
539
|
+
border: 'none',
|
|
540
|
+
borderRadius: 'var(--dsw-radius-sm)',
|
|
541
|
+
cursor: busy ? 'progress' : 'pointer',
|
|
542
|
+
display: 'inline-flex',
|
|
543
|
+
fontFamily: 'inherit',
|
|
544
|
+
fontSize: 13,
|
|
545
|
+
fontWeight: 500,
|
|
546
|
+
gap: 6,
|
|
547
|
+
height: 28,
|
|
548
|
+
lineHeight: '20px',
|
|
549
|
+
padding: '0 8px',
|
|
550
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
551
|
+
...pillStyle(active),
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
BOOK,
|
|
555
|
+
React.createElement('span', null, LABEL),
|
|
556
|
+
),
|
|
557
|
+
toast === null
|
|
558
|
+
? null
|
|
559
|
+
: React.createElement(Toast, {
|
|
560
|
+
key: toast.id,
|
|
561
|
+
text: toast.text,
|
|
562
|
+
onDone: () => setToast(null),
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// The conversation view bar, driven by the entries in `conversation.view`.
|
|
568
|
+
// Live build orders: chat 0, trajectory 10, context 20, fleet 30.
|
|
569
|
+
const VIEW_SLOT = 'conversation.view';
|
|
570
|
+
const VIEW_ID = 'study';
|
|
571
|
+
const VIEW_LABEL = 'Study';
|
|
572
|
+
const VIEW_ORDER = 100;
|
|
573
|
+
const TRACKER_ROOT = 'study';
|
|
574
|
+
const DASH = '—';
|
|
575
|
+
|
|
576
|
+
// --- Tracker parsing -----------------------------------------------------
|
|
577
|
+
// Parsing lives here because the client bundle is a classic script: a
|
|
578
|
+
// relative require throws and an export statement is invalid.
|
|
579
|
+
|
|
580
|
+
function splitRow(line) {
|
|
581
|
+
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function tableBlocks(text) {
|
|
585
|
+
const blocks = [];
|
|
586
|
+
let current = null;
|
|
587
|
+
for (const line of String(text ?? '').split(/\r?\n/)) {
|
|
588
|
+
if (!line.trim().startsWith('|')) {
|
|
589
|
+
current = null;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
const cells = splitRow(line);
|
|
593
|
+
if (cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell))) continue;
|
|
594
|
+
if (current === null) {
|
|
595
|
+
current = { header: cells, rows: [] };
|
|
596
|
+
blocks.push(current);
|
|
597
|
+
} else {
|
|
598
|
+
current.rows.push(cells);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return blocks;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const columnOf = (block, name) => block.header
|
|
605
|
+
.findIndex((cell) => cell.toLowerCase() === name.toLowerCase());
|
|
606
|
+
|
|
607
|
+
function parseDate(value) {
|
|
608
|
+
const match = /^\d{4}-\d{2}-\d{2}/.exec(String(value ?? '').trim());
|
|
609
|
+
return match ? match[0] : null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function parseGraph(text) {
|
|
613
|
+
const lines = String(text ?? '').split(/\r?\n/);
|
|
614
|
+
let topic = null;
|
|
615
|
+
let book = null;
|
|
616
|
+
const chapters = new Map();
|
|
617
|
+
let inChapters = false;
|
|
618
|
+
for (const line of lines) {
|
|
619
|
+
const trimmed = line.trim();
|
|
620
|
+
const topicMatch = /^#\s*Topic:\s*(.+)$/i.exec(trimmed);
|
|
621
|
+
if (topicMatch) topic = topicMatch[1].trim();
|
|
622
|
+
const bookMatch = /^Book:\s*(.+)$/i.exec(trimmed);
|
|
623
|
+
if (bookMatch) book = bookMatch[1].trim();
|
|
624
|
+
if (/^##\s*Chapters\s*$/i.test(trimmed)) {
|
|
625
|
+
inChapters = true;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (!inChapters) continue;
|
|
629
|
+
const chapterRow = /^(\d+)\.\s*(.+?)(?::.*)?$/.exec(trimmed);
|
|
630
|
+
if (chapterRow) chapters.set(Number(chapterRow[1]), chapterRow[2].trim());
|
|
631
|
+
else if (trimmed !== '') inChapters = false;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const table = tableBlocks(text).find((block) => columnOf(block, 'Concept') !== -1);
|
|
635
|
+
const concepts = [];
|
|
636
|
+
let malformed = 0;
|
|
637
|
+
if (table) {
|
|
638
|
+
const nameAt = columnOf(table, 'Concept');
|
|
639
|
+
const chapterAt = columnOf(table, 'Chapter');
|
|
640
|
+
const sectionAt = columnOf(table, 'Section');
|
|
641
|
+
table.rows.forEach((cells, position) => {
|
|
642
|
+
if (cells.length !== table.header.length || cells[nameAt] === '') {
|
|
643
|
+
malformed += 1;
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const chapter = chapterAt === -1 ? 1 : Number.parseInt(cells[chapterAt], 10);
|
|
647
|
+
const section = sectionAt === -1 ? concepts.length + 1 : Number.parseInt(cells[sectionAt], 10);
|
|
648
|
+
concepts.push({
|
|
649
|
+
name: cells[nameAt],
|
|
650
|
+
chapter: Number.isInteger(chapter) && chapter > 0 ? chapter : 1,
|
|
651
|
+
section: Number.isInteger(section) && section > 0 ? section : position + 1,
|
|
652
|
+
order: position,
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
return { topic, book, chapters, concepts, malformed };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function parseState(text) {
|
|
660
|
+
const table = tableBlocks(text).find((block) => columnOf(block, 'State') !== -1);
|
|
661
|
+
const states = new Map();
|
|
662
|
+
let malformed = 0;
|
|
663
|
+
if (!table) return { states, malformed };
|
|
664
|
+
const nameAt = columnOf(table, 'Concept');
|
|
665
|
+
const stateAt = columnOf(table, 'State');
|
|
666
|
+
const dateAt = columnOf(table, 'Last evidence');
|
|
667
|
+
for (const cells of table.rows) {
|
|
668
|
+
if (cells.length !== table.header.length || cells[nameAt] === '') {
|
|
669
|
+
malformed += 1;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
states.set(cells[nameAt], {
|
|
673
|
+
state: cells[stateAt].toLowerCase(),
|
|
674
|
+
date: dateAt === -1 ? null : parseDate(cells[dateAt]),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return { states, malformed };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function parseLog(text) {
|
|
681
|
+
const blocks = [];
|
|
682
|
+
let current = null;
|
|
683
|
+
for (const line of String(text ?? '').split(/\r?\n/)) {
|
|
684
|
+
const heading = /^##\s+(.+)$/.exec(line.trim());
|
|
685
|
+
if (heading) {
|
|
686
|
+
current = { date: heading[1].trim(), concept: null, result: null };
|
|
687
|
+
blocks.push(current);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (current === null) continue;
|
|
691
|
+
const field = /^(Concept|Result):\s*(.*)$/i.exec(line.trim());
|
|
692
|
+
if (!field) continue;
|
|
693
|
+
if (field[1].toLowerCase() === 'concept') current.concept = field[2].trim() || null;
|
|
694
|
+
else current.result = field[2].trim() || null;
|
|
695
|
+
}
|
|
696
|
+
return blocks.filter((block) => block.concept !== null || block.result !== null);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const latestEvidence = (text) => {
|
|
700
|
+
const dates = [...parseState(text).states.values()].map((row) => row.date ?? '');
|
|
701
|
+
return dates.sort().pop() ?? '';
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
function buildSnapshot(topic, graphText, stateText, logText) {
|
|
705
|
+
const graph = parseGraph(graphText);
|
|
706
|
+
const state = parseState(stateText);
|
|
707
|
+
const log = parseLog(logText);
|
|
708
|
+
const chapterTitle = (chapter) => graph.chapters.get(chapter) ?? graph.book ?? graph.topic ?? topic;
|
|
709
|
+
|
|
710
|
+
const concepts = graph.concepts
|
|
711
|
+
.map((concept) => {
|
|
712
|
+
const row = state.states.get(concept.name) ?? { state: 'new', date: null };
|
|
713
|
+
return { ...concept, ...row, chapterTitle: chapterTitle(concept.chapter) };
|
|
714
|
+
})
|
|
715
|
+
.sort((left, right) => left.chapter - right.chapter
|
|
716
|
+
|| left.section - right.section
|
|
717
|
+
|| left.order - right.order);
|
|
718
|
+
concepts.forEach((concept, position) => { concept.position = position; });
|
|
719
|
+
|
|
720
|
+
const skipped = concepts.filter((concept) => concept.state === 'skipped').length;
|
|
721
|
+
const fluent = concepts.filter((concept) => concept.state === 'fluent').length;
|
|
722
|
+
const denominator = concepts.length - skipped;
|
|
723
|
+
const percentage = denominator > 0 ? Math.floor((fluent / denominator) * 100) : null;
|
|
724
|
+
|
|
725
|
+
const current = concepts
|
|
726
|
+
.filter((concept) => concept.date !== null)
|
|
727
|
+
.sort((left, right) => right.date.localeCompare(left.date))[0] ?? null;
|
|
728
|
+
const previous = current === null ? null : concepts[current.position - 1] ?? null;
|
|
729
|
+
const remaining = current === null ? concepts : concepts.slice(current.position + 1);
|
|
730
|
+
const next = remaining.find((concept) => concept.state !== 'fluent' && concept.state !== 'skipped') ?? null;
|
|
731
|
+
const nextState = next !== null
|
|
732
|
+
? 'concept'
|
|
733
|
+
: remaining.some((concept) => concept.state === 'skipped') ? 'all-skipped' : 'end';
|
|
734
|
+
|
|
735
|
+
const shaky = concepts
|
|
736
|
+
.filter((concept) => concept.state === 'shaky')
|
|
737
|
+
.sort((left, right) => (right.date ?? '').localeCompare(left.date ?? ''));
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
topic,
|
|
741
|
+
bookTitle: graph.book ?? graph.topic ?? topic,
|
|
742
|
+
total: concepts.length,
|
|
743
|
+
skipped,
|
|
744
|
+
fluent,
|
|
745
|
+
denominator,
|
|
746
|
+
percentage,
|
|
747
|
+
concepts,
|
|
748
|
+
current,
|
|
749
|
+
previous,
|
|
750
|
+
next,
|
|
751
|
+
nextState,
|
|
752
|
+
shaky,
|
|
753
|
+
shakyTotal: shaky.length,
|
|
754
|
+
cycles: log.slice(-3).reverse().map((block) => ({
|
|
755
|
+
date: block.date,
|
|
756
|
+
label: block.result ?? block.concept ?? '',
|
|
757
|
+
})),
|
|
758
|
+
malformed: graph.malformed + state.malformed,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// --- Tracker reads -------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
async function readText(files, sessionId, path) {
|
|
765
|
+
const result = await files.read(sessionId, path, {});
|
|
766
|
+
if (result.ok) return result.value?.text ?? '';
|
|
767
|
+
// A missing file is an empty input, not a failure.
|
|
768
|
+
if (result.error?.code === 'workspace-file/not-found') return '';
|
|
769
|
+
throw new Error(result.error?.message ?? `${path} could not be read`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function loadTopic(files, sessionId, topic, stateText) {
|
|
773
|
+
const base = `${TRACKER_ROOT}/${topic}`;
|
|
774
|
+
const [state, graph, log] = await Promise.all([
|
|
775
|
+
stateText === undefined ? readText(files, sessionId, `${base}/state.md`) : stateText,
|
|
776
|
+
readText(files, sessionId, `${base}/graph.md`),
|
|
777
|
+
readText(files, sessionId, `${base}/log.md`),
|
|
778
|
+
]);
|
|
779
|
+
const snapshot = buildSnapshot(topic, graph, state, log);
|
|
780
|
+
return snapshot.total === 0 ? { status: 'empty', topic } : { status: 'ready', snapshot };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// "Changed most recently" is read from the newest dated Evidence, because
|
|
784
|
+
// the file surface exposes no modification time.
|
|
785
|
+
async function discoverTopics(files, sessionId) {
|
|
786
|
+
const listing = await files.list(sessionId, TRACKER_ROOT);
|
|
787
|
+
if (!listing.ok) {
|
|
788
|
+
if (listing.error?.code === 'workspace-file/not-found') return { topics: [], content: { status: 'empty' } };
|
|
789
|
+
throw new Error(listing.error?.message ?? 'the Tracker folder could not be read');
|
|
790
|
+
}
|
|
791
|
+
const topics = (listing.value?.entries ?? [])
|
|
792
|
+
.filter((entry) => entry.type === 'directory')
|
|
793
|
+
.map((entry) => entry.name);
|
|
794
|
+
if (topics.length === 0) return { topics, content: { status: 'empty' } };
|
|
795
|
+
const states = [];
|
|
796
|
+
for (const topic of topics) {
|
|
797
|
+
states.push({ topic, text: await readText(files, sessionId, `${TRACKER_ROOT}/${topic}/state.md`) });
|
|
798
|
+
}
|
|
799
|
+
states.sort((left, right) => latestEvidence(right.text).localeCompare(latestEvidence(left.text)));
|
|
800
|
+
const chosen = states[0];
|
|
801
|
+
return { topics, content: await loadTopic(files, sessionId, chosen.topic, chosen.text) };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// --- Study view ----------------------------------------------------------
|
|
805
|
+
|
|
806
|
+
const conceptLabel = (concept) => `${concept.chapterTitle} · Section ${concept.section} · ${concept.name}`;
|
|
807
|
+
|
|
808
|
+
function progressBlock(snapshot) {
|
|
809
|
+
if (snapshot.percentage === null) {
|
|
810
|
+
return React.createElement('div', { 'data-study-progress': 'none', style: panelProgressStyle },
|
|
811
|
+
React.createElement('p', { style: panelFigureStyle }, DASH),
|
|
812
|
+
React.createElement('p', { style: panelMutedStyle }, 'Every Concept is skipped.'));
|
|
813
|
+
}
|
|
814
|
+
const text = `${snapshot.percentage}% of the Book`;
|
|
815
|
+
return React.createElement('div', { style: panelProgressStyle },
|
|
816
|
+
React.createElement('p', { style: panelFigureRowStyle },
|
|
817
|
+
React.createElement('span', { style: panelFigureStyle }, `${snapshot.percentage}%`),
|
|
818
|
+
' ',
|
|
819
|
+
React.createElement('span', { style: panelFigureUnitStyle }, 'of the Book')),
|
|
820
|
+
React.createElement('div', {
|
|
821
|
+
role: 'progressbar',
|
|
822
|
+
'aria-label': 'Book progress',
|
|
823
|
+
'aria-valuemin': 0,
|
|
824
|
+
'aria-valuemax': 100,
|
|
825
|
+
'aria-valuenow': snapshot.percentage,
|
|
826
|
+
'aria-valuetext': text,
|
|
827
|
+
style: panelTrackStyle,
|
|
828
|
+
}, React.createElement('div', {
|
|
829
|
+
style: { ...panelFillStyle, width: `${snapshot.percentage}%` },
|
|
830
|
+
})),
|
|
831
|
+
snapshot.current === null
|
|
832
|
+
? React.createElement('p', { style: panelMutedStyle }, 'No evidence yet')
|
|
833
|
+
: null);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function stripBlock(snapshot) {
|
|
837
|
+
const slots = [
|
|
838
|
+
{ label: 'Previous', value: snapshot.previous === null ? DASH : conceptLabel(snapshot.previous), muted: snapshot.previous === null },
|
|
839
|
+
{ label: 'Current', value: snapshot.current === null ? 'No lesson recorded yet' : conceptLabel(snapshot.current), muted: snapshot.current === null, current: true },
|
|
840
|
+
{
|
|
841
|
+
label: 'Next',
|
|
842
|
+
value: snapshot.next !== null
|
|
843
|
+
? conceptLabel(snapshot.next)
|
|
844
|
+
: snapshot.nextState === 'all-skipped' ? 'Everything left is skipped' : 'End of Book',
|
|
845
|
+
muted: snapshot.next === null,
|
|
846
|
+
},
|
|
847
|
+
];
|
|
848
|
+
return React.createElement('dl', { style: panelStripStyle },
|
|
849
|
+
...slots.map((slot) => React.createElement('div', {
|
|
850
|
+
key: slot.label,
|
|
851
|
+
style: slot.current ? panelStripCellCurrentStyle : panelStripCellStyle,
|
|
852
|
+
},
|
|
853
|
+
React.createElement('dt', { style: panelStripLabelStyle }, slot.label),
|
|
854
|
+
React.createElement('dd', {
|
|
855
|
+
style: slot.muted ? panelStripMutedValueStyle : panelStripValueStyle,
|
|
856
|
+
}, slot.value))));
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function topicPicker(topics, value, onTopic) {
|
|
860
|
+
if (topics.length < 2) return null;
|
|
861
|
+
return React.createElement('label', { style: panelFieldStyle },
|
|
862
|
+
React.createElement('span', null, 'Topic'),
|
|
863
|
+
React.createElement('select', {
|
|
864
|
+
'aria-label': 'Topic',
|
|
865
|
+
value,
|
|
866
|
+
onChange: (event) => onTopic(event.target.value),
|
|
867
|
+
style: panelSelectStyle,
|
|
868
|
+
}, ...topics.map((topic) => React.createElement('option', { key: topic, value: topic }, topic))));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function readyBody(snapshot, error, topics, onTopic) {
|
|
872
|
+
const nodes = [
|
|
873
|
+
React.createElement('div', { style: panelHeaderStyle },
|
|
874
|
+
React.createElement('h2', { style: panelTitleStyle }, snapshot.bookTitle),
|
|
875
|
+
topicPicker(topics, snapshot.topic, onTopic)),
|
|
876
|
+
];
|
|
877
|
+
if (error) {
|
|
878
|
+
nodes.push(React.createElement('p', { role: 'alert', style: panelMutedStyle },
|
|
879
|
+
`The Tracker could not be read: ${error}.`));
|
|
880
|
+
}
|
|
881
|
+
nodes.push(progressBlock(snapshot), stripBlock(snapshot));
|
|
882
|
+
|
|
883
|
+
const shown = snapshot.shaky.slice(0, 5);
|
|
884
|
+
nodes.push(React.createElement('section', { 'data-study-section': 'shaky', style: panelSectionStyle },
|
|
885
|
+
React.createElement('h3', { style: panelHeadingStyle }, 'Shaky Concepts'),
|
|
886
|
+
shown.length === 0
|
|
887
|
+
? React.createElement('p', { style: panelMutedStyle }, 'No shaky Concepts.')
|
|
888
|
+
: React.createElement('ul', { style: panelListStyle },
|
|
889
|
+
...shown.map((concept) => React.createElement('li', { key: concept.name }, conceptLabel(concept)))),
|
|
890
|
+
snapshot.shakyTotal > shown.length
|
|
891
|
+
? React.createElement('p', { style: panelMutedStyle }, `${snapshot.shakyTotal - shown.length} more not shown.`)
|
|
892
|
+
: null));
|
|
893
|
+
|
|
894
|
+
nodes.push(React.createElement('section', { 'data-study-section': 'cycles', style: panelSectionStyle },
|
|
895
|
+
React.createElement('h3', { style: panelHeadingStyle }, 'Recent cycles'),
|
|
896
|
+
snapshot.cycles.length === 0
|
|
897
|
+
? React.createElement('p', { style: panelMutedStyle }, 'No cycles yet.')
|
|
898
|
+
: React.createElement('ul', { style: panelListStyle },
|
|
899
|
+
...snapshot.cycles.map((cycle, index) => React.createElement('li', { key: `${cycle.date}-${index}` },
|
|
900
|
+
`${cycle.date} · ${cycle.label}`)))));
|
|
901
|
+
|
|
902
|
+
if (snapshot.malformed > 0) {
|
|
903
|
+
nodes.push(React.createElement('p', { role: 'status', style: panelMutedStyle },
|
|
904
|
+
`${snapshot.malformed} malformed ${snapshot.malformed === 1 ? 'row was' : 'rows were'} ignored.`));
|
|
905
|
+
}
|
|
906
|
+
return nodes;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function StudyView({ sessionId, workspaceFiles }) {
|
|
910
|
+
const [topics, setTopics] = React.useState([]);
|
|
911
|
+
const [selected, setSelected] = React.useState(null);
|
|
912
|
+
const [content, setContent] = React.useState({ status: 'loading' });
|
|
913
|
+
|
|
914
|
+
React.useEffect(() => {
|
|
915
|
+
let live = true;
|
|
916
|
+
const run = async () => {
|
|
917
|
+
try {
|
|
918
|
+
const found = selected === null
|
|
919
|
+
? await discoverTopics(workspaceFiles, sessionId)
|
|
920
|
+
: { topics: null, content: await loadTopic(workspaceFiles, sessionId, selected) };
|
|
921
|
+
if (!live) return;
|
|
922
|
+
if (found.topics !== null) setTopics(found.topics);
|
|
923
|
+
setContent(found.content);
|
|
924
|
+
} catch (error) {
|
|
925
|
+
if (!live) return;
|
|
926
|
+
const message = error?.message ?? 'the Tracker could not be read';
|
|
927
|
+
// Keep the last good snapshot so a failed reload does not blank the page.
|
|
928
|
+
setContent((previous) => previous.status === 'ready' || previous.status === 'empty'
|
|
929
|
+
? { ...previous, error: message }
|
|
930
|
+
: { status: 'failed', error: message });
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
run();
|
|
934
|
+
return () => { live = false; };
|
|
935
|
+
}, [selected, sessionId, workspaceFiles]);
|
|
936
|
+
|
|
937
|
+
let nodes;
|
|
938
|
+
if (content.status === 'loading') {
|
|
939
|
+
nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, style: panelMutedStyle }, 'Loading the Tracker…')];
|
|
940
|
+
} else if (content.status === 'failed') {
|
|
941
|
+
nodes = [React.createElement('p', { role: 'alert', style: panelMutedStyle }, `The Tracker could not be read: ${content.error}.`)];
|
|
942
|
+
} else if (content.status === 'empty') {
|
|
943
|
+
const searched = content.topic ? `${TRACKER_ROOT}/${content.topic}` : `${TRACKER_ROOT}/`;
|
|
944
|
+
const picker = topicPicker(topics, content.topic ?? selected, setSelected);
|
|
945
|
+
nodes = [
|
|
946
|
+
picker === null ? null : React.createElement('div', { style: panelHeaderStyle }, picker),
|
|
947
|
+
React.createElement('p', { style: panelMutedStyle }, `No Tracker in ${searched}. Run /study-plus to create one.`),
|
|
948
|
+
];
|
|
949
|
+
} else {
|
|
950
|
+
nodes = readyBody(content.snapshot, content.error, topics, setSelected);
|
|
951
|
+
}
|
|
952
|
+
return React.createElement('div', {
|
|
953
|
+
role: 'region',
|
|
954
|
+
'aria-label': VIEW_LABEL,
|
|
955
|
+
style: panelStyle,
|
|
956
|
+
}, ...nodes);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const panelStyle = {
|
|
960
|
+
boxSizing: 'border-box',
|
|
961
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
962
|
+
display: 'flex',
|
|
963
|
+
flexDirection: 'column',
|
|
964
|
+
fontFamily: 'var(--dsw-font-family, inherit)',
|
|
965
|
+
gap: 28,
|
|
966
|
+
maxWidth: 720,
|
|
967
|
+
// The conversation view area carries no padding of its own.
|
|
968
|
+
padding: '24px 16px 40px',
|
|
969
|
+
width: '100%',
|
|
970
|
+
};
|
|
971
|
+
const panelHeaderStyle = { alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'space-between' };
|
|
972
|
+
const panelTitleStyle = { fontSize: 16, fontWeight: 600, lineHeight: '24px', margin: 0 };
|
|
973
|
+
const panelMutedStyle = { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13, lineHeight: '20px', margin: 0 };
|
|
974
|
+
const panelHeadingStyle = { fontSize: 14, fontWeight: 600, lineHeight: '22px', margin: 0 };
|
|
975
|
+
const panelSectionStyle = { display: 'flex', flexDirection: 'column', gap: 10 };
|
|
976
|
+
const panelListStyle = { display: 'flex', flexDirection: 'column', gap: 8, listStyle: 'none', margin: 0, padding: 0 };
|
|
977
|
+
const panelProgressStyle = { display: 'flex', flexDirection: 'column', gap: 10 };
|
|
978
|
+
const panelFigureRowStyle = { lineHeight: '34px', margin: 0 };
|
|
979
|
+
const panelFigureUnitStyle = {
|
|
980
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
981
|
+
fontSize: 13,
|
|
982
|
+
lineHeight: '20px',
|
|
983
|
+
marginLeft: 2,
|
|
984
|
+
};
|
|
985
|
+
const panelTrackStyle = {
|
|
986
|
+
background: 'var(--dsw-alias-border-l3)',
|
|
987
|
+
borderRadius: 999,
|
|
988
|
+
height: 8,
|
|
989
|
+
overflow: 'hidden',
|
|
990
|
+
width: '100%',
|
|
991
|
+
};
|
|
992
|
+
const panelFillStyle = { background: 'var(--dsw-alias-state-business-primary)', height: '100%' };
|
|
993
|
+
const panelStripStyle = {
|
|
994
|
+
alignItems: 'start',
|
|
995
|
+
display: 'grid',
|
|
996
|
+
gap: 12,
|
|
997
|
+
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
|
998
|
+
margin: 0,
|
|
999
|
+
};
|
|
1000
|
+
const panelStripCellStyle = {
|
|
1001
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
1002
|
+
borderRadius: 'var(--dsw-radius-sm)',
|
|
1003
|
+
display: 'flex',
|
|
1004
|
+
flexDirection: 'column',
|
|
1005
|
+
gap: 6,
|
|
1006
|
+
padding: '12px 14px',
|
|
1007
|
+
};
|
|
1008
|
+
// The Current cell is the answer to "where am I", so it is the only filled cell.
|
|
1009
|
+
const panelStripCellCurrentStyle = {
|
|
1010
|
+
...panelStripCellStyle,
|
|
1011
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
1012
|
+
borderColor: 'var(--dsw-alias-border-l3)',
|
|
1013
|
+
};
|
|
1014
|
+
const panelStripLabelStyle = { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12, lineHeight: '18px', margin: 0 };
|
|
1015
|
+
const panelStripValueStyle = { fontSize: 13, lineHeight: '20px', margin: 0 };
|
|
1016
|
+
const panelStripMutedValueStyle = {
|
|
1017
|
+
...panelStripValueStyle,
|
|
1018
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
1019
|
+
};
|
|
1020
|
+
const panelFigureStyle = {
|
|
1021
|
+
fontSize: 28,
|
|
1022
|
+
fontWeight: 600,
|
|
1023
|
+
fontVariantNumeric: 'tabular-nums',
|
|
1024
|
+
lineHeight: '34px',
|
|
1025
|
+
margin: 0,
|
|
1026
|
+
};
|
|
1027
|
+
const panelFieldStyle = { alignItems: 'center', display: 'flex', fontSize: 13, gap: 8 };
|
|
1028
|
+
const panelSelectStyle = {
|
|
1029
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
1030
|
+
border: '1px solid var(--dsw-alias-border-l3)',
|
|
1031
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
1032
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
1033
|
+
font: 'inherit',
|
|
1034
|
+
height: 32,
|
|
1035
|
+
padding: '0 8px',
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
// --- Lectures dock page --------------------------------------------------
|
|
1039
|
+
const LECTURES_ID = 'dsh-nerd-mode/lectures';
|
|
1040
|
+
const LECTURES_KIND = 'lectures';
|
|
1041
|
+
const LECTURES_LABEL = 'Lectures';
|
|
1042
|
+
const LECTURES_GUIDE_ORDER = 40;
|
|
1043
|
+
|
|
1044
|
+
// Same 16px book as the composer pill, drawn at the size the guide asks for.
|
|
1045
|
+
function LecturesGlyph({ size = 16 }) {
|
|
1046
|
+
return React.createElement(
|
|
1047
|
+
'svg',
|
|
1048
|
+
{
|
|
1049
|
+
'aria-hidden': 'true',
|
|
1050
|
+
fill: 'none',
|
|
1051
|
+
height: size,
|
|
1052
|
+
stroke: 'currentColor',
|
|
1053
|
+
strokeLinecap: 'round',
|
|
1054
|
+
strokeLinejoin: 'round',
|
|
1055
|
+
strokeWidth: 1.6,
|
|
1056
|
+
viewBox: '0 0 16 16',
|
|
1057
|
+
width: size,
|
|
1058
|
+
},
|
|
1059
|
+
...BOOK.props.children,
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// The leading block only; a `---` rule later in the file stays content.
|
|
1064
|
+
// A block without a closing fence, or with a line that is not `key: value`,
|
|
1065
|
+
// returns null so the caller can leave the state empty.
|
|
1066
|
+
function lectureFrontmatter(lines) {
|
|
1067
|
+
if (lines[0]?.trim() !== '---') return {};
|
|
1068
|
+
const end = lines.findIndex((line, at) => at > 0 && line.trim() === '---');
|
|
1069
|
+
if (end === -1) return null;
|
|
1070
|
+
const fields = {};
|
|
1071
|
+
for (const line of lines.slice(1, end)) {
|
|
1072
|
+
if (line.trim() === '') continue;
|
|
1073
|
+
const match = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line.trim());
|
|
1074
|
+
if (match === null) return null;
|
|
1075
|
+
fields[match[1]] = match[2].trim();
|
|
1076
|
+
}
|
|
1077
|
+
return fields;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function parseLecture(text, slug) {
|
|
1081
|
+
const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
1082
|
+
const fields = lectureFrontmatter(lines);
|
|
1083
|
+
const heading = lines.find((line) => /^#\s+\S/.test(line));
|
|
1084
|
+
const draft = fields !== null && fields.status === 'draft';
|
|
1085
|
+
const date = fields !== null && /^\d{4}-\d{2}-\d{2}$/.test(fields.date ?? '') ? fields.date : null;
|
|
1086
|
+
// A draft that also carries a teaching date is malformed, so the block reads as unparsable.
|
|
1087
|
+
const valid = fields !== null && !(draft && date !== null);
|
|
1088
|
+
return {
|
|
1089
|
+
title: heading === undefined ? slug : heading.replace(/^#\s+/, '').trim(),
|
|
1090
|
+
concept: valid && /^\d+$/.test(fields.concept ?? '') ? Number(fields.concept) : null,
|
|
1091
|
+
date: valid ? date : null,
|
|
1092
|
+
draft: valid && draft,
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// A missing folder is an empty listing. Any other failure becomes data.
|
|
1097
|
+
// `found` tells the caller whether the folder exists, so a missing root and
|
|
1098
|
+
// an empty root can render different pages.
|
|
1099
|
+
async function listFolder(files, sessionId, path) {
|
|
1100
|
+
try {
|
|
1101
|
+
const listing = await files.list(sessionId, path);
|
|
1102
|
+
if (listing.ok) return { entries: listing.value?.entries ?? [], error: null, found: true };
|
|
1103
|
+
if (listing.error?.code === 'workspace-file/not-found') return { entries: [], error: null, found: false };
|
|
1104
|
+
return { entries: [], error: listing.error?.message ?? `${path} could not be read`, found: false };
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
return { entries: [], error: error?.message ?? `${path} could not be read`, found: false };
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** Return `{ topics, error, rootFound }`. `rootFound` is false only when `study/` is missing, and `error` is the first read failure or null. */
|
|
1111
|
+
async function loadLectures(files, sessionId) {
|
|
1112
|
+
const model = { topics: [], error: null, rootFound: false };
|
|
1113
|
+
const root = await listFolder(files, sessionId, TRACKER_ROOT);
|
|
1114
|
+
model.rootFound = root.found;
|
|
1115
|
+
if (root.error !== null) {
|
|
1116
|
+
model.error = root.error;
|
|
1117
|
+
return model;
|
|
1118
|
+
}
|
|
1119
|
+
const names = root.entries
|
|
1120
|
+
.filter((entry) => entry.type === 'directory')
|
|
1121
|
+
.map((entry) => entry.name)
|
|
1122
|
+
.sort((left, right) => left.localeCompare(right));
|
|
1123
|
+
for (const topic of names) {
|
|
1124
|
+
const entries = [];
|
|
1125
|
+
const seen = new Set();
|
|
1126
|
+
const folders = [
|
|
1127
|
+
[`${TRACKER_ROOT}/${topic}/lectures`, false],
|
|
1128
|
+
[`${TRACKER_ROOT}/${topic}/lectures/archive`, true],
|
|
1129
|
+
];
|
|
1130
|
+
for (const [folder, archived] of folders) {
|
|
1131
|
+
const listing = await listFolder(files, sessionId, folder);
|
|
1132
|
+
if (listing.error !== null && model.error === null) model.error = listing.error;
|
|
1133
|
+
for (const item of listing.entries) {
|
|
1134
|
+
if (item.type !== 'file') continue;
|
|
1135
|
+
const match = /^(\d{2,})-(.+)\.md$/.exec(item.name);
|
|
1136
|
+
// One lecture per number: a stale copy in the archive never repeats it.
|
|
1137
|
+
if (match === null || seen.has(match[1])) continue;
|
|
1138
|
+
let text;
|
|
1139
|
+
try {
|
|
1140
|
+
text = await readText(files, sessionId, `${folder}/${item.name}`);
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
if (model.error === null) model.error = error?.message ?? `${folder}/${item.name} could not be read`;
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
const parsed = parseLecture(text, match[2]);
|
|
1146
|
+
entries.push({
|
|
1147
|
+
number: match[1],
|
|
1148
|
+
topic,
|
|
1149
|
+
title: parsed.title,
|
|
1150
|
+
concept: parsed.concept,
|
|
1151
|
+
mark: archived ? 'archived' : parsed.draft ? 'draft' : parsed.date !== null ? 'dated' : null,
|
|
1152
|
+
date: parsed.date,
|
|
1153
|
+
path: `${folder}/${item.name}`,
|
|
1154
|
+
});
|
|
1155
|
+
seen.add(match[1]);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
entries.sort((left, right) => Number(left.number) - Number(right.number));
|
|
1159
|
+
model.topics.push({ topic, entries });
|
|
1160
|
+
}
|
|
1161
|
+
return model;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// The rows and the fold are the only elements the host cannot style for us.
|
|
1165
|
+
const LECTURES_CSS = '.lectures-entry:focus-visible,.lectures-fold:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary, currentColor);outline-offset:2px}';
|
|
1166
|
+
const lectureRowStyle = {
|
|
1167
|
+
background: 'none',
|
|
1168
|
+
border: 'none',
|
|
1169
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
1170
|
+
color: 'inherit',
|
|
1171
|
+
cursor: 'pointer',
|
|
1172
|
+
font: 'inherit',
|
|
1173
|
+
lineHeight: '22px',
|
|
1174
|
+
overflowWrap: 'anywhere',
|
|
1175
|
+
padding: '2px 4px',
|
|
1176
|
+
textAlign: 'left',
|
|
1177
|
+
whiteSpace: 'normal',
|
|
1178
|
+
width: '100%',
|
|
1179
|
+
};
|
|
1180
|
+
const lectureFoldStyle = {
|
|
1181
|
+
background: 'none',
|
|
1182
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
1183
|
+
borderRadius: 'var(--dsw-radius-sm, 6px)',
|
|
1184
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
1185
|
+
cursor: 'pointer',
|
|
1186
|
+
font: 'inherit',
|
|
1187
|
+
lineHeight: '22px',
|
|
1188
|
+
padding: '6px 10px',
|
|
1189
|
+
textAlign: 'left',
|
|
1190
|
+
width: '100%',
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
const LECTURE_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
1194
|
+
|
|
1195
|
+
// `2026-10-28` reads as `28 Oct 2026`: shorter than the ISO form and still one date.
|
|
1196
|
+
function lectureDate(value) {
|
|
1197
|
+
const [year, month, day] = value.split('-');
|
|
1198
|
+
const name = LECTURE_MONTHS[Number(month) - 1];
|
|
1199
|
+
return name === undefined ? value : `${Number(day)} ${name} ${year}`;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function lectureMark(entry) {
|
|
1203
|
+
if (entry.mark === 'archived') return 'Archived';
|
|
1204
|
+
if (entry.mark === 'draft') return 'Draft';
|
|
1205
|
+
if (entry.mark === 'dated' && entry.date !== null) return lectureDate(entry.date);
|
|
1206
|
+
return null;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Copied verbatim from `@deepseek-ai/dsh-util-workspace-path`, which a client
|
|
1210
|
+
// plugin cannot load from the browser module table.
|
|
1211
|
+
const FILE_ADDRESS_PREFIX = 'dsh-resource://file/';
|
|
1212
|
+
/** Component-encode one id or path segment, keeping `:` literal for drive letters. */
|
|
1213
|
+
function encodeSegment(segment) {
|
|
1214
|
+
return encodeURIComponent(segment).replace(/%3A/gi, ':');
|
|
1215
|
+
}
|
|
1216
|
+
/** Encode a `/`-separated path segment by segment. */
|
|
1217
|
+
function encodePath(path) {
|
|
1218
|
+
return path.split('/').map(encodeSegment).join('/');
|
|
1219
|
+
}
|
|
1220
|
+
/** Build the address of a file read through one Session. */
|
|
1221
|
+
function sessionFileAddress(sessionId, path) {
|
|
1222
|
+
const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '');
|
|
1223
|
+
return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// The whole row is the button, so its text is also its accessible name.
|
|
1227
|
+
function lectureRow(entry, onOpen) {
|
|
1228
|
+
const mark = lectureMark(entry);
|
|
1229
|
+
return React.createElement('li', { key: entry.number },
|
|
1230
|
+
React.createElement('button', {
|
|
1231
|
+
type: 'button',
|
|
1232
|
+
className: 'lectures-entry',
|
|
1233
|
+
onClick: () => onOpen(entry),
|
|
1234
|
+
style: lectureRowStyle,
|
|
1235
|
+
}, `${entry.number} · ${entry.title}${mark === null ? '' : ` · ${mark}`}`));
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function lectureTopic(group, open, onToggle, onOpen) {
|
|
1239
|
+
const main = group.entries.filter((entry) => entry.mark !== 'archived');
|
|
1240
|
+
const archived = group.entries.filter((entry) => entry.mark === 'archived');
|
|
1241
|
+
const nodes = [React.createElement('h2', { key: 'heading', style: panelHeadingStyle }, group.topic)];
|
|
1242
|
+
if (group.entries.length === 0) {
|
|
1243
|
+
nodes.push(React.createElement('p', { key: 'empty', style: panelMutedStyle }, 'No lectures yet.'));
|
|
1244
|
+
} else if (main.length > 0) {
|
|
1245
|
+
nodes.push(React.createElement('ul', { key: 'main', style: panelListStyle }, ...main.map((entry) => lectureRow(entry, onOpen))));
|
|
1246
|
+
}
|
|
1247
|
+
if (archived.length > 0) {
|
|
1248
|
+
nodes.push(React.createElement('button', {
|
|
1249
|
+
key: 'fold',
|
|
1250
|
+
type: 'button',
|
|
1251
|
+
className: 'lectures-fold',
|
|
1252
|
+
'aria-expanded': open,
|
|
1253
|
+
onClick: () => onToggle(group.topic),
|
|
1254
|
+
style: lectureFoldStyle,
|
|
1255
|
+
}, `Archived lectures (${archived.length})`));
|
|
1256
|
+
if (open) {
|
|
1257
|
+
nodes.push(React.createElement('ul', { key: 'archive', style: panelListStyle }, ...archived.map((entry) => lectureRow(entry, onOpen))));
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return React.createElement('section', { key: group.topic, style: panelSectionStyle }, ...nodes);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function LecturesPage({ useTabInfo, sessionId, workspaceFiles }) {
|
|
1264
|
+
const { tab } = useTabInfo();
|
|
1265
|
+
const [content, setContent] = React.useState({ status: 'loading' });
|
|
1266
|
+
const [openTopics, setOpenTopics] = React.useState({});
|
|
1267
|
+
|
|
1268
|
+
React.useEffect(() => {
|
|
1269
|
+
let live = true;
|
|
1270
|
+
const run = async () => {
|
|
1271
|
+
const model = await loadLectures(workspaceFiles, sessionId);
|
|
1272
|
+
if (!live) return;
|
|
1273
|
+
setContent({ status: 'ready', ...model });
|
|
1274
|
+
};
|
|
1275
|
+
run();
|
|
1276
|
+
return () => { live = false; };
|
|
1277
|
+
}, [sessionId, workspaceFiles]);
|
|
1278
|
+
|
|
1279
|
+
const toggle = (topic) => setOpenTopics((previous) => ({
|
|
1280
|
+
...previous,
|
|
1281
|
+
[topic]: previous[topic] !== true,
|
|
1282
|
+
}));
|
|
1283
|
+
|
|
1284
|
+
const open = (entry) => tab.actions.openResource(
|
|
1285
|
+
sessionFileAddress(sessionId, entry.path),
|
|
1286
|
+
{ preferNewPane: true },
|
|
1287
|
+
);
|
|
1288
|
+
|
|
1289
|
+
let nodes;
|
|
1290
|
+
if (content.status === 'loading') {
|
|
1291
|
+
nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, style: panelMutedStyle }, 'Loading the lectures…')];
|
|
1292
|
+
} else if (content.error === null && !content.rootFound) {
|
|
1293
|
+
nodes = [React.createElement('p', { style: panelMutedStyle }, `No Tracker in ${TRACKER_ROOT}/. Run /study-plus to create one.`)];
|
|
1294
|
+
} else {
|
|
1295
|
+
nodes = content.error === null
|
|
1296
|
+
? []
|
|
1297
|
+
: [React.createElement('p', { role: 'alert', style: panelMutedStyle }, `The lecture library could not be read: ${content.error}.`)];
|
|
1298
|
+
nodes.push(...content.topics.map((group) => lectureTopic(group, openTopics[group.topic] === true, toggle, open)));
|
|
1299
|
+
}
|
|
1300
|
+
return React.createElement('div', {
|
|
1301
|
+
role: 'region',
|
|
1302
|
+
'aria-label': LECTURES_LABEL,
|
|
1303
|
+
style: panelStyle,
|
|
1304
|
+
}, React.createElement('style', null, LECTURES_CSS), ...nodes);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// A page type names no resource pattern and no `multiple`, so the host
|
|
1308
|
+
// records it at one fixed page address and a reopen focuses that tab.
|
|
1309
|
+
const LECTURES_DEFINITION = {
|
|
1310
|
+
id: LECTURES_ID,
|
|
1311
|
+
kind: LECTURES_KIND,
|
|
1312
|
+
title: () => LECTURES_LABEL,
|
|
1313
|
+
guide: [{
|
|
1314
|
+
id: 'lectures',
|
|
1315
|
+
order: LECTURES_GUIDE_ORDER,
|
|
1316
|
+
title: () => LECTURES_LABEL,
|
|
1317
|
+
description: () => 'Read the lectures saved in this workspace.',
|
|
1318
|
+
icon: LecturesGlyph,
|
|
1319
|
+
}],
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
return {
|
|
1323
|
+
inject: ['slots', 'remote', 'remote.commands', 'remote.workspaceFiles', 'configForms'],
|
|
1324
|
+
apply(ctx) {
|
|
1325
|
+
ctx.slots.inject(SLOT, () =>
|
|
1326
|
+
ctx.slots.register({
|
|
1327
|
+
name: SLOT,
|
|
1328
|
+
id: 'nerd-mode',
|
|
1329
|
+
order: -1,
|
|
1330
|
+
registrant: 'dsh-nerd-mode',
|
|
1331
|
+
inject: (sessionId) => ({
|
|
1332
|
+
execute: (command) => ctx.remote.commands.execute(sessionId, command, []),
|
|
1333
|
+
}),
|
|
1334
|
+
}, NerdModeToggle),
|
|
1335
|
+
);
|
|
1336
|
+
ctx.slots.inject(SETTINGS_SLOT, () =>
|
|
1337
|
+
ctx.slots.register({
|
|
1338
|
+
name: SETTINGS_SLOT,
|
|
1339
|
+
id: 'dsh-nerd-mode',
|
|
1340
|
+
order: 30,
|
|
1341
|
+
label: LABEL,
|
|
1342
|
+
}, ({ close }) => React.createElement(NerdModeSettingsEntry, {
|
|
1343
|
+
close,
|
|
1344
|
+
configForms: ctx.configForms,
|
|
1345
|
+
})),
|
|
1346
|
+
);
|
|
1347
|
+
ctx.slots.inject(VIEW_SLOT, () =>
|
|
1348
|
+
ctx.slots.register({
|
|
1349
|
+
name: VIEW_SLOT,
|
|
1350
|
+
id: VIEW_ID,
|
|
1351
|
+
order: VIEW_ORDER,
|
|
1352
|
+
label: VIEW_LABEL,
|
|
1353
|
+
inject: (sessionId) => ({
|
|
1354
|
+
sessionId,
|
|
1355
|
+
workspaceFiles: ctx.remote.workspaceFiles,
|
|
1356
|
+
}),
|
|
1357
|
+
}, StudyView),
|
|
1358
|
+
);
|
|
1359
|
+
// A host without the right Sidebar never provides the registry, so this
|
|
1360
|
+
// one seat waits. The rest of the plugin keeps working.
|
|
1361
|
+
ctx.inject(['sidebarRightTabs'], (dock) => {
|
|
1362
|
+
dock.effect(
|
|
1363
|
+
() => dock.sidebarRightTabs.register(LECTURES_DEFINITION),
|
|
1364
|
+
'dsh-nerd-mode: lectures tab type',
|
|
1365
|
+
);
|
|
1366
|
+
});
|
|
1367
|
+
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
|
1368
|
+
name: 'sidebar.right.pane.tab',
|
|
1369
|
+
key: LECTURES_ID,
|
|
1370
|
+
inject: (sessionId) => ({ sessionId, workspaceFiles: ctx.remote.workspaceFiles }),
|
|
1371
|
+
}, LecturesPage));
|
|
1372
|
+
},
|
|
1373
|
+
};
|
|
1374
|
+
},
|
|
1375
|
+
});
|