dsh-nerd-mode 0.5.0 → 0.5.2

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.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/assets/calibration.svg +69 -69
  4. package/assets/nerd.svg +22 -22
  5. package/assets/section.md +15 -15
  6. package/client.js +2170 -2225
  7. package/cordis.patch.yml +24 -24
  8. package/lib/index.js +838 -286
  9. package/lib/study-sessions.js +283 -283
  10. package/package.json +70 -70
  11. package/skills/convert-documents-to-markdown/SKILL.md +26 -26
  12. package/skills/diagram/SKILL.md +43 -19
  13. package/skills/diagram/scripts/check-svg.mjs +184 -0
  14. package/skills/humanizer/.claude-plugin/marketplace.json +18 -18
  15. package/skills/humanizer/.claude-plugin/plugin.json +15 -15
  16. package/skills/humanizer/.cursor-plugin/plugin.json +12 -12
  17. package/skills/humanizer/.github/ISSUE_TEMPLATE/config.yml +8 -8
  18. package/skills/humanizer/.github/ISSUE_TEMPLATE/new-pattern.yml +24 -24
  19. package/skills/humanizer/.github/ISSUE_TEMPLATE/rewrite-problem.yml +44 -44
  20. package/skills/humanizer/.github/workflows/validate.yml +29 -29
  21. package/skills/humanizer/AGENTS.md +54 -54
  22. package/skills/humanizer/CHANGELOG.md +116 -116
  23. package/skills/humanizer/LICENSE +21 -21
  24. package/skills/humanizer/README.md +206 -206
  25. package/skills/humanizer/SKILL.md +397 -397
  26. package/skills/humanizer/agents/openai.yaml +4 -4
  27. package/skills/humanizer/scripts/validate-package.py +146 -146
  28. package/skills/study/SKILL.md +81 -78
  29. package/skills/study-plus/SKILL.md +246 -246
  30. package/skills/study-plus/scripts/book-client.js +284 -284
  31. package/skills/study-plus/scripts/book-data.mjs +289 -289
  32. package/skills/study-plus/scripts/build-book.mjs +155 -155
  33. package/skills/writing-beats/SKILL.md +66 -66
  34. package/skills/writing-beats/agents/openai.yaml +5 -5
package/client.js CHANGED
@@ -1,2225 +1,2170 @@
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
- const MARKET_API = '/dsh-market/api/v1';
63
- const PACKAGE = 'dsh-nerd-mode';
64
- const UPDATE_SCHEMA = 'dsh-market/update-api/v1';
65
- const REQUEST_TIMEOUT = 10000;
66
- const POLL_INTERVAL = 1000;
67
-
68
- function useT(locale) {
69
- React.useSyncExternalStore(
70
- (listener) => locale.subscribe(listener),
71
- () => locale.getSnapshot(),
72
- () => locale.getSnapshot(),
73
- );
74
- return (text) => locale.resolveText(text);
75
- }
76
-
77
- // Every Market call carries the session cookies and gives up after 10 s.
78
- async function marketRequest(url, options = {}, life = null) {
79
- const controller = new AbortController();
80
- if (life !== null) life.controller = controller;
81
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
82
- try {
83
- const response = await fetch(url, {
84
- credentials: 'same-origin',
85
- ...options,
86
- signal: controller.signal,
87
- });
88
- const body = await response.json().catch(() => null);
89
- return { ok: response.ok, status: response.status, body };
90
- } catch (error) {
91
- if (controller.signal.aborted) throw new Error('DSH Market did not answer within 10 seconds.');
92
- throw error;
93
- } finally {
94
- clearTimeout(timer);
95
- if (life !== null && life.controller === controller) life.controller = null;
96
- }
97
- }
98
-
99
- function marketFailure(result, fallback) {
100
- const body = result?.body ?? {};
101
- return body.error
102
- ?? body.failure?.message
103
- ?? body.failure?.code
104
- ?? `${fallback} (HTTP ${result?.status ?? 0}).`;
105
- }
106
-
107
- // The schema is optional on the operation endpoints, but a wrong one is a different API.
108
- const wrongSchema = (body) => body?.schema !== undefined && body.schema !== UPDATE_SCHEMA;
109
-
110
- async function readCapabilities(life) {
111
- const result = await marketRequest(`${MARKET_API}/capabilities`, {}, life);
112
- const body = result.body ?? {};
113
- if (!result.ok) throw new Error(marketFailure(result, 'DSH Market did not answer'));
114
- if (body.schema !== UPDATE_SCHEMA || body.features?.update !== true) {
115
- throw new Error('DSH Market does not support plugin updates.');
116
- }
117
- const endpoints = body.endpoints ?? {};
118
- if (typeof endpoints.updates !== 'string' || typeof endpoints.operations !== 'string') {
119
- throw new Error('DSH Market returned no update endpoints.');
120
- }
121
- return {
122
- endpoints: { updates: endpoints.updates, operations: endpoints.operations },
123
- profile: typeof body.profile === 'string' ? body.profile : null,
124
- restart: body.features.restart === true && typeof endpoints.restart === 'string' ? endpoints.restart : null,
125
- };
126
- }
127
-
128
- async function readRelease(market, force, life) {
129
- const url = `${market.endpoints.updates}?name=${encodeURIComponent(PACKAGE)}${force ? '&force=1' : ''}`;
130
- const result = await marketRequest(url, {}, life);
131
- const body = result.body ?? {};
132
- if (!result.ok) throw new Error(marketFailure(result, 'DSH Market did not check for an update'));
133
- if (body.schema !== UPDATE_SCHEMA || typeof body.package !== 'object' || body.package === null) {
134
- throw new Error('DSH Market returned no release for this plugin.');
135
- }
136
- return body.package;
137
- }
138
-
139
- const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
140
-
141
- // Only a plain `major.minor.patch` pair can prove the local copy is newer.
142
- function compareVersions(left, right) {
143
- const first = VERSION.exec(String(left ?? ''));
144
- const second = VERSION.exec(String(right ?? ''));
145
- if (first === null || second === null) return 0;
146
- for (let at = 1; at <= 3; at += 1) {
147
- const difference = Number(first[at]) - Number(second[at]);
148
- if (difference !== 0) return difference;
149
- }
150
- return 0;
151
- }
152
-
153
- const manualCommand = (profile) => `dsh plugin --profile ${profile ?? '<profile>'} add ${PACKAGE}`;
154
-
155
- function NerdModeSettingsEntry({ configForms, close, locale }) {
156
- return React.createElement(
157
- 'div',
158
- { 'data-nerd-mode-settings': '', style: settingsColumnStyle },
159
- React.createElement(NerdModeSettingsBody, { configForms, close, locale }),
160
- React.createElement(AboutGroup, { locale }),
161
- );
162
- }
163
-
164
- function NerdModeSettingsBody({ configForms, close, locale }) {
165
- const t = useT(locale);
166
- const description = configForms.describe();
167
- const described = React.useSyncExternalStore(
168
- (listener) => description.subscribe(listener),
169
- () => description.getSnapshot(),
170
- () => description.getSnapshot(),
171
- );
172
- const settingsExist = described.view?.namespaces.some(({ ns }) => ns === SETTINGS_NAMESPACE) === true;
173
- if (!settingsExist) {
174
- const loading = (described.status === 'idle' || described.status === 'loading') && !described.error;
175
- return React.createElement(React.Fragment, null,
176
- React.createElement('p', {
177
- role: 'status',
178
- 'aria-busy': loading,
179
- style: feedbackStyle,
180
- }, loading
181
- ? t({ en: 'Loading Nerd Mode settings…', zh: '正在加载 Nerd Mode 设置…' })
182
- : described.status === 'ready'
183
- ? t({ en: 'Nerd Mode settings are unavailable in this profile.', zh: '此 profile 中没有 Nerd Mode 设置。' })
184
- : t({ en: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.', zh: '无法加载 Nerd Mode 设置。请刷新设置窗口后重试。' })),
185
- loading ? null : React.createElement('button', {
186
- type: 'button',
187
- onClick: close,
188
- style: secondaryButtonStyle,
189
- }, t({ en: 'Close', zh: '关闭' })),
190
- );
191
- }
192
- return React.createElement(NerdModeSettings, {
193
- close,
194
- locale,
195
- scope: configForms.get(SETTINGS_NAMESPACE),
196
- });
197
- }
198
-
199
- function NerdModeSettings({ scope, close, locale }) {
200
- const t = useT(locale);
201
- const snapshot = React.useSyncExternalStore(
202
- (listener) => scope.subscribe(listener),
203
- () => scope.getSnapshot(),
204
- () => scope.getSnapshot(),
205
- );
206
- const [draft, setDraft] = React.useState(() => snapshot?.status === 'ready' ? settingsValues(snapshot) : null);
207
- const [saving, setSaving] = React.useState(false);
208
- const [feedback, setFeedback] = React.useState(null);
209
- React.useEffect(() => {
210
- if (draft === null && snapshot?.status === 'ready') {
211
- setDraft(settingsValues(snapshot));
212
- }
213
- }, [draft, snapshot?.status]);
214
- if (snapshot?.status === 'loading' || snapshot?.status === 'idle' || snapshot?.status === 'ready' && draft === null) {
215
- return React.createElement('p', {
216
- role: 'status',
217
- 'aria-busy': true,
218
- style: feedbackStyle,
219
- }, t({ en: 'Loading Nerd Mode settings…', zh: '正在加载 Nerd Mode 设置…' }));
220
- }
221
- if (snapshot?.status !== 'ready') {
222
- return React.createElement(React.Fragment, null,
223
- React.createElement('p', { role: 'status', style: feedbackStyle },
224
- snapshot?.status === 'unavailable'
225
- ? t({ en: 'Nerd Mode settings are unavailable in this profile.', zh: '此 profile 中没有 Nerd Mode 设置。' })
226
- : t({ en: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.', zh: '无法加载 Nerd Mode 设置。请刷新设置窗口后重试。' })),
227
- React.createElement('button', {
228
- type: 'button',
229
- onClick: close,
230
- style: secondaryButtonStyle,
231
- }, t({ en: 'Close', zh: '关闭' })),
232
- );
233
- }
234
- const current = settingsValues(snapshot);
235
- const dirty = !sameSettings(draft, current);
236
- const writable = snapshot.writable === true;
237
- const updateDraft = (key, value) => {
238
- setDraft((previous) => ({ ...previous, [key]: value }));
239
- setFeedback(null);
240
- };
241
- const restoreDefaults = () => {
242
- setDraft({
243
- ...SETTINGS_DEFAULTS,
244
- unseenQuestionCount: String(SETTINGS_DEFAULTS.unseenQuestionCount),
245
- });
246
- setFeedback(null);
247
- };
248
- const cancel = () => {
249
- setDraft(settingsValues(scope.getSnapshot()));
250
- setFeedback(null);
251
- };
252
- const save = async () => {
253
- const latest = scope.getSnapshot();
254
- if (latest?.status !== 'ready' || !validSettings(draft) || latest.writable !== true) return;
255
- const operations = settingsOperations(draft, latest);
256
- if (operations.length === 0) return;
257
- setSaving(true);
258
- setFeedback(null);
259
- try {
260
- const accepted = await scope.mutate(operations, latest.revision);
261
- if (accepted === false) {
262
- throw new Error(t({ en: 'The profile rejected these settings.', zh: 'profile 拒绝了这些设置。' }));
263
- }
264
- setDraft(settingsValues(scope.getSnapshot()));
265
- setFeedback({
266
- tone: 'status',
267
- text: {
268
- en: 'Saved to this profile. Changes apply to the next study-plus session.',
269
- zh: '已保存到此 profile。更改将在下次 study-plus 会话中生效。',
270
- },
271
- });
272
- } catch (error) {
273
- setFeedback({
274
- tone: 'alert',
275
- text: {
276
- en: `Could not save settings: ${error?.message ?? 'the profile rejected the changes'}`,
277
- zh: `无法保存设置:${error?.message ?? 'profile 拒绝了这些更改'}`,
278
- },
279
- });
280
- } finally {
281
- setSaving(false);
282
- }
283
- };
284
-
285
- return React.createElement(
286
- React.Fragment,
287
- null,
288
- React.createElement(
289
- 'header',
290
- { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
291
- React.createElement('h2', {
292
- style: { fontSize: 16, fontWeight: 600, lineHeight: '24px', margin: 0 },
293
- }, t({ en: 'Nerd Mode', zh: 'Nerd Mode' })),
294
- React.createElement('p', {
295
- style: {
296
- color: 'var(--dsw-alias-label-tertiary)',
297
- fontSize: 14,
298
- lineHeight: '22px',
299
- margin: 0,
300
- },
301
- }, t({ en: 'Profile defaults for new study-plus sessions.', zh: '新 study-plus 会话的 profile 默认值。' })),
302
- ),
303
- React.createElement(
304
- 'section',
305
- {
306
- 'data-settings-group': 'recall-session',
307
- style: groupStyle,
308
- },
309
- React.createElement(
310
- 'div',
311
- { style: { minWidth: 0 } },
312
- React.createElement('h3', { style: groupTitleStyle }, t({ en: 'Recall Session / 课前回忆', zh: '课前回忆' })),
313
- React.createElement('p', { style: groupDescriptionStyle }, t({ en: 'One ungraded recall question before teaching.', zh: '讲授前的一次不计分课前回忆。' })),
314
- ),
315
- React.createElement(
316
- 'button',
317
- {
318
- type: 'button',
319
- role: 'switch',
320
- 'aria-checked': draft.recallSessionEnabled,
321
- 'aria-label': t({ en: 'Recall Session', zh: '课前回忆' }),
322
- 'data-setting': 'recallSessionEnabled',
323
- 'data-state': draft.recallSessionEnabled ? 'on' : 'off',
324
- disabled: !writable || saving,
325
- onClick: () => updateDraft('recallSessionEnabled', !draft.recallSessionEnabled),
326
- style: switchStyle(draft.recallSessionEnabled, !writable || saving),
327
- },
328
- React.createElement('span', null, draft.recallSessionEnabled
329
- ? t({ en: 'On', zh: '开' })
330
- : t({ en: 'Off', zh: '关' })),
331
- React.createElement('span', {
332
- 'aria-hidden': 'true',
333
- style: {
334
- background: 'currentColor',
335
- borderRadius: '50%',
336
- height: 8,
337
- opacity: draft.recallSessionEnabled ? 1 : 0.5,
338
- width: 8,
339
- },
340
- }),
341
- ),
342
- ),
343
- React.createElement(
344
- 'section',
345
- {
346
- 'data-settings-group': 'post-lecture-questions',
347
- style: groupStyle,
348
- },
349
- React.createElement(
350
- 'div',
351
- null,
352
- React.createElement('h3', { style: groupTitleStyle }, t({ en: 'Post-lecture questions', zh: '课后提问' })),
353
- React.createElement('p', { style: groupDescriptionStyle }, t({ en: 'Choose how many unseen questions follow each lecture.', zh: '选择每节课后出几道题。' })),
354
- ),
355
- React.createElement(
356
- 'div',
357
- { style: { display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' } },
358
- React.createElement(
359
- 'label',
360
- { htmlFor: 'nerd-mode-question-count', style: fieldLabelStyle },
361
- React.createElement('span', null, t({ en: 'Question count', zh: '题数' })),
362
- React.createElement('input', {
363
- id: 'nerd-mode-question-count',
364
- type: 'number',
365
- min: 0,
366
- max: 10,
367
- step: 1,
368
- inputMode: 'numeric',
369
- value: draft.unseenQuestionCount,
370
- 'data-setting': 'unseenQuestionCount',
371
- disabled: !writable || saving,
372
- onChange: (event) => updateDraft('unseenQuestionCount', event.target.value),
373
- style: fieldControlStyle,
374
- }),
375
- React.createElement('span', { style: fieldHintStyle }, t({ en: 'Use 0 to skip unseen questions.', zh: '设为 0 可跳过课后提问。' })),
376
- ),
377
- React.createElement(
378
- 'label',
379
- { htmlFor: 'nerd-mode-question-type', style: fieldLabelStyle },
380
- React.createElement('span', null, t({ en: 'Question type', zh: '题型' })),
381
- React.createElement(
382
- 'select',
383
- {
384
- id: 'nerd-mode-question-type',
385
- value: draft.unseenQuestionType,
386
- 'data-setting': 'unseenQuestionType',
387
- disabled: !writable || saving,
388
- onChange: (event) => updateDraft('unseenQuestionType', event.target.value),
389
- style: fieldControlStyle,
390
- },
391
- React.createElement('option', { value: 'short-answer' }, t({ en: 'Short answer', zh: '简答' })),
392
- React.createElement('option', { value: 'multiple-choice' }, t({ en: 'Multiple choice', zh: '四选一' })),
393
- React.createElement('option', { value: 'mixed' }, t({ en: 'Mixed', zh: '混合' })),
394
- ),
395
- React.createElement('span', { style: fieldHintStyle }, t({ en: 'Mixed chooses a type for each question.', zh: '混合会为每道题随机选题型。' })),
396
- ),
397
- ),
398
- ),
399
- React.createElement(
400
- 'footer',
401
- {
402
- style: {
403
- alignItems: 'center',
404
- borderTop: '1px solid var(--dsw-alias-border-l2)',
405
- display: 'flex',
406
- flexWrap: 'wrap',
407
- gap: 12,
408
- justifyContent: 'space-between',
409
- paddingTop: 16,
410
- },
411
- },
412
- React.createElement('button', {
413
- type: 'button',
414
- disabled: !writable || saving,
415
- onClick: restoreDefaults,
416
- style: secondaryButtonStyle,
417
- }, t({ en: 'Restore defaults', zh: '恢复默认' })),
418
- React.createElement(
419
- 'div',
420
- { style: { display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' } },
421
- React.createElement('button', {
422
- type: 'button',
423
- disabled: !dirty || saving,
424
- onClick: cancel,
425
- style: secondaryButtonStyle,
426
- }, t({ en: 'Cancel', zh: '取消' })),
427
- React.createElement('button', {
428
- type: 'button',
429
- disabled: !dirty || !validSettings(draft) || !writable || saving,
430
- onClick: save,
431
- style: primaryButtonStyle,
432
- }, saving ? t({ en: 'Saving…', zh: '保存中…' }) : t({ en: 'Save', zh: '保存' })),
433
- ),
434
- ),
435
- snapshot?.writable === false
436
- ? React.createElement('p', { role: 'status', style: feedbackStyle }, t({ en: 'These profile settings are read-only.', zh: '这些 profile 设置为只读。' }))
437
- : null,
438
- feedback === null
439
- ? null
440
- : React.createElement('p', {
441
- role: feedback.tone,
442
- style: feedbackStyle,
443
- }, t(feedback.text)),
444
- );
445
- }
446
-
447
- function AboutGroup({ locale }) {
448
- const t = useT(locale);
449
- const [life] = React.useState(() => ({ cancelled: false, timer: null, controller: null }));
450
- const [view, setView] = React.useState({ phase: 'checking' });
451
-
452
- // An answer that arrives after the panel closed writes nothing.
453
- const show = (next) => {
454
- if (!life.cancelled) setView(next);
455
- };
456
-
457
- async function check(force) {
458
- setView({ phase: 'checking' });
459
- let market;
460
- try {
461
- market = await readCapabilities(life);
462
- } catch {
463
- show({ phase: 'unavailable' });
464
- return;
465
- }
466
- try {
467
- const release = await readRelease(market, force, life);
468
- show({ phase: 'ready', market, release, confirming: false, linkedNote: false });
469
- } catch (error) {
470
- show({
471
- phase: 'failed',
472
- market,
473
- release: null,
474
- message: error?.message ?? 'The update check failed.',
475
- retry: 'check',
476
- force: false,
477
- });
478
- }
479
- }
480
-
481
- function follow(market, operationId, release) {
482
- return new Promise((resolve, reject) => {
483
- const tick = async () => {
484
- if (life.cancelled) {
485
- resolve(null);
486
- return;
487
- }
488
- let result;
489
- try {
490
- result = await marketRequest(`${market.endpoints.operations}?operationId=${encodeURIComponent(operationId)}`, {}, life);
491
- } catch (error) {
492
- reject(error);
493
- return;
494
- }
495
- if (life.cancelled) {
496
- resolve(null);
497
- return;
498
- }
499
- if (!result.ok || wrongSchema(result.body)) {
500
- reject(new Error(marketFailure(result, 'The update could not be followed')));
501
- return;
502
- }
503
- const operation = result.body?.operation ?? {};
504
- if (operation.state === 'succeeded') {
505
- resolve(operation);
506
- return;
507
- }
508
- if (operation.state === 'failed' || operation.state === 'cancelled' || operation.state === 'rolled-back') {
509
- const error = new Error(operation.failure?.message ?? operation.failure?.code ?? `The update ${operation.state}.`);
510
- error.code = operation.failure?.code ?? null;
511
- reject(error);
512
- return;
513
- }
514
- show({ phase: 'updating', market, release, progress: operation.state ?? 'running' });
515
- life.timer = setTimeout(tick, POLL_INTERVAL);
516
- };
517
- tick();
518
- });
519
- }
520
-
521
- async function install(market, release, force) {
522
- setView({ phase: 'updating', market, release, progress: 'queued' });
523
- try {
524
- const result = await marketRequest(market.endpoints.updates, {
525
- method: 'POST',
526
- headers: { 'content-type': 'application/json' },
527
- body: JSON.stringify({ packageName: PACKAGE, ...(force ? { force: true } : {}) }),
528
- }, life);
529
- if (!result.ok || wrongSchema(result.body)) {
530
- const error = new Error(marketFailure(result, 'The update did not start'));
531
- error.code = result.body?.failure?.code ?? null;
532
- throw error;
533
- }
534
- const operationId = result.body?.operation?.operationId;
535
- if (typeof operationId !== 'string') throw new Error('DSH Market returned no operation to follow.');
536
- const operation = await follow(market, operationId, release);
537
- if (operation === null) return;
538
- show({
539
- phase: 'installed',
540
- market,
541
- release,
542
- version: operation.installedVersion ?? release.latestVersion,
543
- restartNeeded: operation.outcome?.restartRequired === true,
544
- restarting: false,
545
- restartSent: false,
546
- });
547
- } catch (error) {
548
- const code = error?.code ?? null;
549
- show({
550
- phase: 'failed',
551
- market,
552
- release,
553
- message: error?.message ?? 'The update failed.',
554
- retry: 'install',
555
- force: code === 'RELEASE_TOO_FRESH' || code === 'VERSION_UNCHANGED',
556
- });
557
- }
558
- }
559
-
560
- async function restart(current) {
561
- setView({ ...current, restarting: true });
562
- try {
563
- const result = await marketRequest(current.market.restart, {
564
- method: 'POST',
565
- headers: { 'content-type': 'application/json' },
566
- body: '{}',
567
- }, life);
568
- if (!result.ok) throw new Error(marketFailure(result, 'DSH could not restart'));
569
- show({ ...current, restarting: false, restartSent: true });
570
- } catch (error) {
571
- show({
572
- phase: 'failed',
573
- market: current.market,
574
- release: current.release,
575
- message: error?.message ?? 'DSH could not restart.',
576
- retry: 'check',
577
- force: false,
578
- });
579
- }
580
- }
581
-
582
- React.useEffect(() => {
583
- check(false);
584
- return () => {
585
- life.cancelled = true;
586
- if (life.timer !== null) clearTimeout(life.timer);
587
- if (life.controller !== null) life.controller.abort();
588
- };
589
- }, []);
590
-
591
- const busy = view.phase === 'checking' || view.phase === 'updating' || view.restarting === true;
592
- const button = (text, onClick, disabled = false, primary = false) => React.createElement('button', {
593
- type: 'button',
594
- disabled,
595
- onClick,
596
- style: primary ? primaryButtonStyle : secondaryButtonStyle,
597
- }, text);
598
- const code = (text) => React.createElement('code', { style: commandStyle }, text);
599
- const checkAgain = button(t({ en: 'Check again', zh: '重新检查' }), () => check(true));
600
- const versions = (release) => t({
601
- en: `Installed v${release.installedVersion} · latest v${release.latestVersion}`,
602
- zh: `已安装 v${release.installedVersion} · 最新 v${release.latestVersion}`,
603
- });
604
-
605
- let versionLine = null;
606
- const feedback = [];
607
- let actions = null;
608
-
609
- if (view.phase === 'checking') {
610
- versionLine = t({ en: 'Checking…', zh: '正在检查…' });
611
- } else if (view.phase === 'unavailable') {
612
- feedback.push(t({
613
- en: 'DSH Market is not available on this host, so Nerd Mode cannot check for updates. Run this command in a terminal:',
614
- zh: '此主机上没有 DSH Market,因此 Nerd Mode 无法检查更新。请在终端运行:',
615
- }));
616
- feedback.push(code(manualCommand(null)));
617
- } else if (view.phase === 'failed') {
618
- if (view.release !== null) versionLine = versions(view.release);
619
- feedback.push(t({ en: 'The update did not finish: ', zh: '更新未完成:' }));
620
- feedback.push(view.message);
621
- feedback.push(t({ en: ' Install it by hand with:', zh: ' 可手动安装:' }));
622
- feedback.push(code(manualCommand(view.market?.profile ?? null)));
623
- actions = [button(t({ en: 'Try again', zh: '重试' }), () => {
624
- if (view.retry === 'install') install(view.market, view.release, view.force === true);
625
- else check(true);
626
- })];
627
- } else if (view.phase === 'installed') {
628
- versionLine = t({ en: `Installed v${view.version}`, zh: `已安装 v${view.version}` });
629
- feedback.push(view.restartSent
630
- ? t({ en: `DSH is restarting to load v${view.version}.`, zh: `DSH 正在重启以加载 v${view.version}。` })
631
- : t({ en: `Restart DSH to use v${view.version}.`, zh: `重启 DSH 以使用 v${view.version}。` }));
632
- actions = view.restartNeeded && view.market.restart !== null && view.restartSent !== true
633
- ? [button(
634
- t({ en: view.restarting ? 'Restarting…' : 'Restart now', zh: view.restarting ? '正在重启…' : '立即重启' }),
635
- () => restart(view),
636
- view.restarting === true,
637
- true,
638
- )]
639
- : [checkAgain];
640
- } else if (view.release.source === 'linked') {
641
- versionLine = t({ en: `Local v${view.release.installedVersion}`, zh: `本地 v${view.release.installedVersion}` });
642
- actions = [button(t({ en: 'About local installs', zh: '关于本地安装' }), () => {
643
- setView({ ...view, linkedNote: view.linkedNote !== true });
644
- })];
645
- if (view.linkedNote === true) {
646
- feedback.push(t({ en: 'This copy is linked to a folder. Update it with git, not npm.', zh: '此副本链接到本地文件夹。请用 git 更新,不要用 npm。' }));
647
- }
648
- } else if (view.phase === 'updating') {
649
- versionLine = versions(view.release);
650
- feedback.push(t({ en: `Update in progress (${view.progress}).`, zh: `更新进行中(${view.progress})。` }));
651
- actions = [button(t({ en: 'Updating…', zh: '正在更新…' }), () => {}, true)];
652
- } else if (view.release.updateAvailable === true) {
653
- versionLine = versions(view.release);
654
- if (view.confirming === true) {
655
- feedback.push(t({
656
- en: `Install v${view.release.latestVersion} and restart DSH afterwards?`,
657
- zh: `安装 v${view.release.latestVersion} 并在之后重启 DSH?`,
658
- }));
659
- actions = [
660
- button(t({ en: 'Cancel', zh: '取消' }), () => setView({ ...view, confirming: false })),
661
- button(t({ en: 'Update', zh: '更新' }), () => install(view.market, view.release, false), false, true),
662
- ];
663
- } else {
664
- actions = [button(
665
- t({ en: `Update to v${view.release.latestVersion}`, zh: `更新到 v${view.release.latestVersion}` }),
666
- () => setView({ ...view, confirming: true }),
667
- false,
668
- true,
669
- )];
670
- }
671
- } else if (compareVersions(view.release.installedVersion, view.release.latestVersion) > 0) {
672
- versionLine = t({
673
- en: `Local v${view.release.installedVersion} · npm v${view.release.latestVersion}`,
674
- zh: `本地 v${view.release.installedVersion} · npm v${view.release.latestVersion}`,
675
- });
676
- feedback.push(t({ en: 'This copy is newer than npm. No update.', zh: '此副本比 npm 上的版本更新,无需更新。' }));
677
- actions = [checkAgain];
678
- } else {
679
- versionLine = versions(view.release);
680
- actions = [checkAgain];
681
- }
682
-
683
- return React.createElement(
684
- 'div',
685
- { 'data-settings-group': 'about', style: { display: 'flex', flexDirection: 'column', gap: 8 } },
686
- React.createElement(
687
- 'div',
688
- { style: groupStyle },
689
- React.createElement(
690
- 'div',
691
- { style: { minWidth: 0 } },
692
- React.createElement('h3', { style: groupTitleStyle }, t({ en: 'About', zh: '关于' })),
693
- versionLine === null ? null : React.createElement('p', { style: groupDescriptionStyle }, versionLine),
694
- ),
695
- actions === null ? null : React.createElement(
696
- 'div',
697
- { style: { alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' } },
698
- ...actions,
699
- ),
700
- ),
701
- React.createElement('p', {
702
- role: view.phase === 'failed' ? 'alert' : 'status',
703
- 'aria-busy': busy,
704
- style: feedbackStyle,
705
- }, ...feedback),
706
- );
707
- }
708
-
709
- const settingsColumnStyle = {
710
- boxSizing: 'border-box',
711
- color: 'var(--dsw-alias-label-primary)',
712
- display: 'flex',
713
- flexDirection: 'column',
714
- fontFamily: 'var(--dsw-font-family, inherit)',
715
- gap: 16,
716
- maxWidth: 720,
717
- paddingBottom: 24,
718
- width: '100%',
719
- };
720
-
721
- const groupStyle = {
722
- alignItems: 'center',
723
- background: 'var(--dsw-alias-bg-layer-1)',
724
- border: '1px solid var(--dsw-alias-border-l2)',
725
- borderRadius: 'var(--dsw-radius-sm)',
726
- display: 'flex',
727
- flexWrap: 'wrap',
728
- gap: 18,
729
- justifyContent: 'space-between',
730
- padding: 20,
731
- };
732
- const groupTitleStyle = {
733
- fontSize: 14,
734
- fontWeight: 600,
735
- lineHeight: '22px',
736
- margin: 0,
737
- };
738
- const groupDescriptionStyle = {
739
- color: 'var(--dsw-alias-label-tertiary)',
740
- fontSize: 13,
741
- lineHeight: '20px',
742
- margin: '3px 0 0',
743
- };
744
- const fieldLabelStyle = {
745
- display: 'flex',
746
- flexDirection: 'column',
747
- fontSize: 13,
748
- fontWeight: 500,
749
- gap: 7,
750
- minWidth: 0,
751
- };
752
- const fieldControlStyle = {
753
- background: 'var(--dsw-alias-bg-layer-1)',
754
- border: '1px solid var(--dsw-alias-border-l3)',
755
- borderRadius: 'var(--dsw-radius-sm, 6px)',
756
- boxSizing: 'border-box',
757
- color: 'var(--dsw-alias-label-primary)',
758
- font: 'inherit',
759
- height: 38,
760
- minWidth: 0,
761
- padding: '0 10px',
762
- width: '100%',
763
- };
764
- const fieldHintStyle = {
765
- color: 'var(--dsw-alias-label-tertiary)',
766
- fontSize: 12,
767
- fontWeight: 400,
768
- lineHeight: '18px',
769
- };
770
- const secondaryButtonStyle = {
771
- background: 'transparent',
772
- border: '1px solid var(--dsw-alias-border-l3)',
773
- borderRadius: 'var(--dsw-radius-sm, 6px)',
774
- color: 'var(--dsw-alias-label-primary)',
775
- cursor: 'pointer',
776
- font: 'inherit',
777
- minHeight: 36,
778
- padding: '0 12px',
779
- };
780
- const primaryButtonStyle = {
781
- ...secondaryButtonStyle,
782
- background: 'var(--dsw-alias-bg-layer-1)',
783
- borderColor: 'var(--dsw-alias-state-business-primary)',
784
- color: 'var(--dsw-alias-state-business-primary)',
785
- fontWeight: 600,
786
- };
787
- const feedbackStyle = {
788
- color: 'var(--dsw-alias-label-secondary)',
789
- fontSize: 13,
790
- lineHeight: '20px',
791
- margin: 0,
792
- };
793
- const commandStyle = {
794
- background: 'var(--dsw-alias-bg-layer-2)',
795
- borderRadius: 'var(--dsw-radius-sm, 6px)',
796
- display: 'inline-block',
797
- fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
798
- fontSize: 12,
799
- lineHeight: '18px',
800
- padding: '2px 6px',
801
- wordBreak: 'break-all',
802
- };
803
-
804
- function switchStyle(active, disabled) {
805
- const accent = 'var(--dsw-alias-state-business-primary)';
806
- return {
807
- alignItems: 'center',
808
- background: active ? 'var(--dsw-alias-state-business-tertiary)' : 'var(--dsw-alias-bg-layer-2)',
809
- border: `1px solid ${active ? accent : 'var(--dsw-alias-border-l3)'}`,
810
- borderRadius: 'var(--dsw-radius-sm, 6px)',
811
- color: active ? accent : 'var(--dsw-alias-label-secondary)',
812
- cursor: disabled ? 'not-allowed' : 'pointer',
813
- display: 'inline-flex',
814
- flexShrink: 0,
815
- font: 'inherit',
816
- gap: 8,
817
- justifyContent: 'center',
818
- minHeight: 36,
819
- minWidth: 72,
820
- padding: '0 10px',
821
- transition: 'background 120ms ease, border-color 120ms ease, color 120ms ease',
822
- };
823
- }
824
-
825
- // 16px book, same footprint as the Full Throttle bolt.
826
- const BOOK = React.createElement(
827
- 'svg',
828
- {
829
- 'aria-hidden': 'true',
830
- fill: 'none',
831
- height: 16,
832
- stroke: 'currentColor',
833
- strokeLinecap: 'round',
834
- strokeLinejoin: 'round',
835
- strokeWidth: 1.6,
836
- viewBox: '0 0 16 16',
837
- width: 16,
838
- },
839
- React.createElement('path', {
840
- 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',
841
- }),
842
- React.createElement('path', {
843
- 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',
844
- }),
845
- );
846
-
847
- // The theme owns the colors; only the state picks which alias to read.
848
- function pillStyle(active) {
849
- if (active) {
850
- const business = 'var(--dsw-alias-state-business-primary)';
851
- return {
852
- background: `color-mix(in srgb, ${business} 16%, transparent)`,
853
- boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${business} 55%, transparent)`,
854
- color: business,
855
- };
856
- }
857
- return {
858
- background: 'var(--dsw-alias-fill-tsp-secondary)',
859
- boxShadow: 'none',
860
- color: 'var(--dsw-alias-label-tertiary)',
861
- opacity: 0.65,
862
- };
863
- }
864
-
865
- // Unknown commands resolve as { ok: true, value: undefined }; they never
866
- // reach the command executor and write nothing to the session log.
867
- function failureText(result) {
868
- if (!result?.ok) {
869
- return `Nerd Mode did not switch: ${result?.error?.message ?? 'the host rejected the command'}`;
870
- }
871
- if (result.value == null) {
872
- return 'Nerd Mode did not switch: /nerd-mode is not available in this build.';
873
- }
874
- const outcome = result.value.result;
875
- if (outcome?.kind === 'error') {
876
- return `Nerd Mode did not switch: ${outcome.text ?? 'the command failed'}`;
877
- }
878
- return null;
879
- }
880
-
881
- function NerdModeToggle({ useProjection, execute }) {
882
- const state = useProjection('studyMode');
883
- const active = state?.active === true;
884
- const [busy, setBusy] = React.useState(false);
885
- const [failure, setFailure] = React.useState(null);
886
- const [toast, setToast] = React.useState(null);
887
-
888
- React.useEffect(() => {
889
- setFailure(null);
890
- setToast(null);
891
- }, [active]);
892
-
893
- const onClick = async () => {
894
- if (busy) return;
895
- setBusy(true);
896
- setFailure(null);
897
- setToast(null);
898
- try {
899
- const result = await execute(active ? '/nerd-mode off' : '/nerd-mode on');
900
- const text = failureText(result);
901
- if (text !== null) {
902
- setFailure(text);
903
- setToast({ id: Date.now(), text });
904
- }
905
- } catch (error) {
906
- const text = failureText({ ok: false, error });
907
- setFailure(text);
908
- setToast({ id: Date.now(), text });
909
- } finally {
910
- setBusy(false);
911
- }
912
- };
913
-
914
- return React.createElement(
915
- React.Fragment,
916
- null,
917
- React.createElement(
918
- 'button',
919
- {
920
- type: 'button',
921
- role: 'switch',
922
- 'aria-checked': active,
923
- 'aria-label': LABEL,
924
- 'data-state': active ? 'on' : 'off',
925
- title: failure ?? (active ? 'Nerd Mode is on' : 'Nerd Mode is off'),
926
- disabled: busy,
927
- onMouseDown: (event) => event.preventDefault(),
928
- onClick,
929
- style: {
930
- alignItems: 'center',
931
- border: 'none',
932
- borderRadius: 'var(--dsw-radius-sm)',
933
- cursor: busy ? 'progress' : 'pointer',
934
- display: 'inline-flex',
935
- fontFamily: 'inherit',
936
- fontSize: 13,
937
- fontWeight: 500,
938
- gap: 6,
939
- height: 28,
940
- lineHeight: '20px',
941
- padding: '0 8px',
942
- transition: 'background 120ms ease, color 120ms ease',
943
- ...pillStyle(active),
944
- },
945
- },
946
- BOOK,
947
- React.createElement('span', null, LABEL),
948
- ),
949
- toast === null
950
- ? null
951
- : React.createElement(Toast, {
952
- key: toast.id,
953
- text: toast.text,
954
- onDone: () => setToast(null),
955
- }),
956
- );
957
- }
958
-
959
- // The conversation view bar, driven by the entries in `conversation.view`.
960
- // Live build orders: chat 0, trajectory 10, context 20, fleet 30.
961
- const VIEW_SLOT = 'conversation.view';
962
- const VIEW_ID = 'study';
963
- const VIEW_LABEL = 'Study';
964
- const VIEW_ORDER = 100;
965
- const TRACKER_ROOT = 'study';
966
- // One selection shared by the Study tab and the Lectures tab. This is a
967
- // classic script, so a module-level binding is the whole mechanism.
968
- let activeCourse = null;
969
- const DASH = '—';
970
-
971
- /** Return the Course both tabs show: `activeCourse` while it exists, otherwise the first of the name-sorted `courses`. */
972
- function pickCourse(courses) {
973
- return activeCourse !== null && courses.includes(activeCourse) ? activeCourse : courses[0] ?? null;
974
- }
975
-
976
- // --- Tracker parsing -----------------------------------------------------
977
- // Parsing lives here because the client bundle is a classic script: a
978
- // relative require throws and an export statement is invalid.
979
-
980
- function splitRow(line) {
981
- return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
982
- }
983
-
984
- function tableBlocks(text) {
985
- const blocks = [];
986
- let current = null;
987
- for (const line of String(text ?? '').split(/\r?\n/)) {
988
- if (!line.trim().startsWith('|')) {
989
- current = null;
990
- continue;
991
- }
992
- const cells = splitRow(line);
993
- if (cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell))) continue;
994
- if (current === null) {
995
- current = { header: cells, rows: [] };
996
- blocks.push(current);
997
- } else {
998
- current.rows.push(cells);
999
- }
1000
- }
1001
- return blocks;
1002
- }
1003
-
1004
- const columnOf = (block, name) => block.header
1005
- .findIndex((cell) => cell.toLowerCase() === name.toLowerCase());
1006
-
1007
- function parseDate(value) {
1008
- const match = /^\d{4}-\d{2}-\d{2}/.exec(String(value ?? '').trim());
1009
- return match ? match[0] : null;
1010
- }
1011
-
1012
- function parseGraph(text) {
1013
- const lines = String(text ?? '').split(/\r?\n/);
1014
- let topic = null;
1015
- let book = null;
1016
- const chapters = new Map();
1017
- let inChapters = false;
1018
- for (const line of lines) {
1019
- const trimmed = line.trim();
1020
- // A new graph says Course and an older graph says Topic; both keep their title.
1021
- const topicMatch = /^#\s*(?:Course|Topic):\s*(.+)$/i.exec(trimmed);
1022
- if (topicMatch) topic = topicMatch[1].trim();
1023
- const bookMatch = /^Book:\s*(.+)$/i.exec(trimmed);
1024
- if (bookMatch) book = bookMatch[1].trim();
1025
- if (/^##\s*Chapters\s*$/i.test(trimmed)) {
1026
- inChapters = true;
1027
- continue;
1028
- }
1029
- if (!inChapters) continue;
1030
- const chapterRow = /^(\d+)\.\s*(.+?)(?::.*)?$/.exec(trimmed);
1031
- if (chapterRow) chapters.set(Number(chapterRow[1]), chapterRow[2].trim());
1032
- else if (trimmed !== '') inChapters = false;
1033
- }
1034
-
1035
- const table = tableBlocks(text).find((block) => columnOf(block, 'Concept') !== -1);
1036
- const concepts = [];
1037
- let malformed = 0;
1038
- if (table) {
1039
- const nameAt = columnOf(table, 'Concept');
1040
- const chapterAt = columnOf(table, 'Chapter');
1041
- const sectionAt = columnOf(table, 'Section');
1042
- table.rows.forEach((cells, position) => {
1043
- if (cells.length !== table.header.length || cells[nameAt] === '') {
1044
- malformed += 1;
1045
- return;
1046
- }
1047
- const chapter = chapterAt === -1 ? 1 : Number.parseInt(cells[chapterAt], 10);
1048
- const section = sectionAt === -1 ? concepts.length + 1 : Number.parseInt(cells[sectionAt], 10);
1049
- concepts.push({
1050
- name: cells[nameAt],
1051
- chapter: Number.isInteger(chapter) && chapter > 0 ? chapter : 1,
1052
- section: Number.isInteger(section) && section > 0 ? section : position + 1,
1053
- order: position,
1054
- });
1055
- });
1056
- }
1057
- return { topic, book, chapters, concepts, malformed };
1058
- }
1059
-
1060
- function parseState(text) {
1061
- const table = tableBlocks(text).find((block) => columnOf(block, 'State') !== -1);
1062
- const states = new Map();
1063
- let malformed = 0;
1064
- if (!table) return { states, malformed };
1065
- const nameAt = columnOf(table, 'Concept');
1066
- const stateAt = columnOf(table, 'State');
1067
- const dateAt = columnOf(table, 'Last evidence');
1068
- for (const cells of table.rows) {
1069
- if (cells.length !== table.header.length || cells[nameAt] === '') {
1070
- malformed += 1;
1071
- continue;
1072
- }
1073
- states.set(cells[nameAt], {
1074
- state: cells[stateAt].toLowerCase(),
1075
- date: dateAt === -1 ? null : parseDate(cells[dateAt]),
1076
- });
1077
- }
1078
- return { states, malformed };
1079
- }
1080
-
1081
- function parseLog(text) {
1082
- const blocks = [];
1083
- let current = null;
1084
- for (const line of String(text ?? '').split(/\r?\n/)) {
1085
- const heading = /^##\s+(.+)$/.exec(line.trim());
1086
- if (heading) {
1087
- current = { date: heading[1].trim(), concept: null, result: null };
1088
- blocks.push(current);
1089
- continue;
1090
- }
1091
- if (current === null) continue;
1092
- const field = /^(Concept|Result):\s*(.*)$/i.exec(line.trim());
1093
- if (!field) continue;
1094
- if (field[1].toLowerCase() === 'concept') current.concept = field[2].trim() || null;
1095
- else current.result = field[2].trim() || null;
1096
- }
1097
- return blocks.filter((block) => block.concept !== null || block.result !== null);
1098
- }
1099
-
1100
- function buildSnapshot(topic, graphText, stateText, logText) {
1101
- const graph = parseGraph(graphText);
1102
- const state = parseState(stateText);
1103
- const log = parseLog(logText);
1104
- const chapterTitle = (chapter) => graph.chapters.get(chapter) ?? graph.book ?? graph.topic ?? topic;
1105
-
1106
- const concepts = graph.concepts
1107
- .map((concept) => {
1108
- const row = state.states.get(concept.name) ?? { state: 'new', date: null };
1109
- return { ...concept, ...row, chapterTitle: chapterTitle(concept.chapter) };
1110
- })
1111
- .sort((left, right) => left.chapter - right.chapter
1112
- || left.section - right.section
1113
- || left.order - right.order);
1114
- concepts.forEach((concept, position) => { concept.position = position; });
1115
-
1116
- const skipped = concepts.filter((concept) => concept.state === 'skipped').length;
1117
- const fluent = concepts.filter((concept) => concept.state === 'fluent').length;
1118
- const denominator = concepts.length - skipped;
1119
- const percentage = denominator > 0 ? Math.floor((fluent / denominator) * 100) : null;
1120
-
1121
- const current = concepts
1122
- .filter((concept) => concept.date !== null)
1123
- .sort((left, right) => right.date.localeCompare(left.date))[0] ?? null;
1124
- const previous = current === null ? null : concepts[current.position - 1] ?? null;
1125
- const remaining = current === null ? concepts : concepts.slice(current.position + 1);
1126
- const next = remaining.find((concept) => concept.state !== 'fluent' && concept.state !== 'skipped') ?? null;
1127
- const nextState = next !== null
1128
- ? 'concept'
1129
- : remaining.some((concept) => concept.state === 'skipped') ? 'all-skipped' : 'end';
1130
-
1131
- const shaky = concepts
1132
- .filter((concept) => concept.state === 'shaky')
1133
- .sort((left, right) => (right.date ?? '').localeCompare(left.date ?? ''));
1134
-
1135
- return {
1136
- topic,
1137
- bookTitle: graph.book ?? graph.topic ?? topic,
1138
- total: concepts.length,
1139
- skipped,
1140
- fluent,
1141
- denominator,
1142
- percentage,
1143
- concepts,
1144
- current,
1145
- previous,
1146
- next,
1147
- nextState,
1148
- shaky,
1149
- shakyTotal: shaky.length,
1150
- cycles: log.slice(-3).reverse().map((block) => ({
1151
- date: block.date,
1152
- label: block.result ?? block.concept ?? '',
1153
- })),
1154
- malformed: graph.malformed + state.malformed,
1155
- };
1156
- }
1157
-
1158
- // --- Tracker reads -------------------------------------------------------
1159
-
1160
- async function readText(files, sessionId, path) {
1161
- const result = await files.read(sessionId, path, {});
1162
- if (result.ok) return result.value?.text ?? '';
1163
- // A missing file is an empty input, not a failure.
1164
- if (result.error?.code === 'workspace-file/not-found') return '';
1165
- throw new Error(result.error?.message ?? `${path} could not be read`);
1166
- }
1167
-
1168
- async function loadTopic(files, sessionId, topic, stateText) {
1169
- const base = `${TRACKER_ROOT}/${topic}`;
1170
- const [state, graph, log] = await Promise.all([
1171
- stateText === undefined ? readText(files, sessionId, `${base}/state.md`) : stateText,
1172
- readText(files, sessionId, `${base}/graph.md`),
1173
- readText(files, sessionId, `${base}/log.md`),
1174
- ]);
1175
- const snapshot = buildSnapshot(topic, graph, state, log);
1176
- return snapshot.total === 0 ? { status: 'empty', topic } : { status: 'ready', snapshot };
1177
- }
1178
-
1179
- // Both tabs open on the shared Course, then on the first Course by name.
1180
- async function discoverTopics(files, sessionId) {
1181
- const listing = await files.list(sessionId, TRACKER_ROOT);
1182
- if (!listing.ok) {
1183
- if (listing.error?.code === 'workspace-file/not-found') return { topics: [], content: { status: 'empty' } };
1184
- throw new Error(listing.error?.message ?? 'the Course folder could not be read');
1185
- }
1186
- const topics = (listing.value?.entries ?? [])
1187
- .filter((entry) => entry.type === 'directory')
1188
- .map((entry) => entry.name)
1189
- .sort((left, right) => left.localeCompare(right));
1190
- if (topics.length === 0) return { topics, content: { status: 'empty' } };
1191
- return { topics, content: await loadTopic(files, sessionId, pickCourse(topics)) };
1192
- }
1193
-
1194
- // --- Study view ----------------------------------------------------------
1195
-
1196
- const conceptLabel = (concept) => `${concept.chapterTitle} · Section ${concept.section} · ${concept.name}`;
1197
-
1198
- function progressBlock(snapshot) {
1199
- if (snapshot.percentage === null) {
1200
- return React.createElement('div', { 'data-study-progress': 'none', style: panelProgressStyle },
1201
- React.createElement('p', { style: panelFigureStyle }, DASH),
1202
- React.createElement('p', { style: panelMutedStyle }, 'Every Concept is skipped.'));
1203
- }
1204
- const text = `${snapshot.percentage}% of the Book`;
1205
- return React.createElement('div', { style: panelProgressStyle },
1206
- React.createElement('p', { style: panelFigureRowStyle },
1207
- React.createElement('span', { style: panelFigureStyle }, `${snapshot.percentage}%`),
1208
- ' ',
1209
- React.createElement('span', { style: panelFigureUnitStyle }, 'of the Book')),
1210
- React.createElement('div', {
1211
- role: 'progressbar',
1212
- 'aria-label': 'Book progress',
1213
- 'aria-valuemin': 0,
1214
- 'aria-valuemax': 100,
1215
- 'aria-valuenow': snapshot.percentage,
1216
- 'aria-valuetext': text,
1217
- style: panelTrackStyle,
1218
- }, React.createElement('div', {
1219
- style: { ...panelFillStyle, width: `${snapshot.percentage}%` },
1220
- })),
1221
- snapshot.current === null
1222
- ? React.createElement('p', { style: panelMutedStyle }, 'No evidence yet')
1223
- : null);
1224
- }
1225
-
1226
- function stripBlock(snapshot) {
1227
- const slots = [
1228
- { label: 'Previous', value: snapshot.previous === null ? DASH : conceptLabel(snapshot.previous), muted: snapshot.previous === null },
1229
- { label: 'Current', value: snapshot.current === null ? 'No lesson recorded yet' : conceptLabel(snapshot.current), muted: snapshot.current === null, current: true },
1230
- {
1231
- label: 'Next',
1232
- value: snapshot.next !== null
1233
- ? conceptLabel(snapshot.next)
1234
- : snapshot.nextState === 'all-skipped' ? 'Everything left is skipped' : 'End of Book',
1235
- muted: snapshot.next === null,
1236
- },
1237
- ];
1238
- return React.createElement('dl', { style: panelStripStyle },
1239
- ...slots.map((slot) => React.createElement('div', {
1240
- key: slot.label,
1241
- style: slot.current ? panelStripCellCurrentStyle : panelStripCellStyle,
1242
- },
1243
- React.createElement('dt', { style: panelStripLabelStyle }, slot.label),
1244
- React.createElement('dd', {
1245
- style: slot.muted ? panelStripMutedValueStyle : panelStripValueStyle,
1246
- }, slot.value))));
1247
- }
1248
-
1249
- function topicPicker(topics, value, onTopic) {
1250
- if (topics.length < 2) return null;
1251
- return React.createElement('label', { style: panelFieldStyle },
1252
- React.createElement('span', null, 'Course'),
1253
- React.createElement('select', {
1254
- 'aria-label': 'Course',
1255
- value,
1256
- onChange: (event) => onTopic(event.target.value),
1257
- style: panelSelectStyle,
1258
- }, ...topics.map((topic) => React.createElement('option', { key: topic, value: topic }, topic))));
1259
- }
1260
-
1261
- function readyBody(snapshot, error, topics, onTopic) {
1262
- const nodes = [
1263
- React.createElement('div', { style: panelHeaderStyle },
1264
- React.createElement('h2', { style: panelTitleStyle }, snapshot.bookTitle),
1265
- topicPicker(topics, snapshot.topic, onTopic)),
1266
- ];
1267
- if (error) {
1268
- nodes.push(React.createElement('p', { role: 'alert', style: panelMutedStyle },
1269
- `The Course could not be read: ${error}.`));
1270
- }
1271
- nodes.push(progressBlock(snapshot), stripBlock(snapshot));
1272
-
1273
- const shown = snapshot.shaky.slice(0, 5);
1274
- nodes.push(React.createElement('section', { 'data-study-section': 'shaky', style: panelSectionStyle },
1275
- React.createElement('h3', { style: panelHeadingStyle }, 'Shaky Concepts'),
1276
- shown.length === 0
1277
- ? React.createElement('p', { style: panelMutedStyle }, 'No shaky Concepts.')
1278
- : React.createElement('ul', { style: panelListStyle },
1279
- ...shown.map((concept) => React.createElement('li', { key: concept.name }, conceptLabel(concept)))),
1280
- snapshot.shakyTotal > shown.length
1281
- ? React.createElement('p', { style: panelMutedStyle }, `${snapshot.shakyTotal - shown.length} more not shown.`)
1282
- : null));
1283
-
1284
- nodes.push(React.createElement('section', { 'data-study-section': 'cycles', style: panelSectionStyle },
1285
- React.createElement('h3', { style: panelHeadingStyle }, 'Recent cycles'),
1286
- snapshot.cycles.length === 0
1287
- ? React.createElement('p', { style: panelMutedStyle }, 'No cycles yet.')
1288
- : React.createElement('ul', { style: panelListStyle },
1289
- ...snapshot.cycles.map((cycle, index) => React.createElement('li', { key: `${cycle.date}-${index}` },
1290
- `${cycle.date} · ${cycle.label}`)))));
1291
-
1292
- if (snapshot.malformed > 0) {
1293
- nodes.push(React.createElement('p', { role: 'status', style: panelMutedStyle },
1294
- `${snapshot.malformed} malformed ${snapshot.malformed === 1 ? 'row was' : 'rows were'} ignored.`));
1295
- }
1296
- return nodes;
1297
- }
1298
-
1299
- function StudyView({ sessionId, workspaceFiles }) {
1300
- const [topics, setTopics] = React.useState([]);
1301
- const [selected, setSelected] = React.useState(null);
1302
- const [content, setContent] = React.useState({ status: 'loading' });
1303
- // Read on every render so a selection made in the Lectures tab wins.
1304
- const shared = activeCourse;
1305
-
1306
- React.useEffect(() => {
1307
- let live = true;
1308
- const run = async () => {
1309
- try {
1310
- const found = await discoverTopics(workspaceFiles, sessionId);
1311
- if (!live) return;
1312
- setTopics(found.topics);
1313
- setContent(found.content);
1314
- } catch (error) {
1315
- if (!live) return;
1316
- const message = error?.message ?? 'the Course could not be read';
1317
- // Keep the last good snapshot so a failed reload does not blank the page.
1318
- setContent((previous) => previous.status === 'ready' || previous.status === 'empty'
1319
- ? { ...previous, error: message }
1320
- : { status: 'failed', error: message });
1321
- }
1322
- };
1323
- run();
1324
- return () => { live = false; };
1325
- }, [selected, shared, sessionId, workspaceFiles]);
1326
-
1327
- const choose = (topic) => {
1328
- activeCourse = topic;
1329
- setSelected(topic);
1330
- };
1331
-
1332
- let nodes;
1333
- if (content.status === 'loading') {
1334
- nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, style: panelMutedStyle }, 'Loading the Course…')];
1335
- } else if (content.status === 'failed') {
1336
- nodes = [React.createElement('p', { role: 'alert', style: panelMutedStyle }, `The Course could not be read: ${content.error}.`)];
1337
- } else if (content.status === 'empty') {
1338
- const searched = content.topic ? `${TRACKER_ROOT}/${content.topic}` : `${TRACKER_ROOT}/`;
1339
- const picker = topicPicker(topics, content.topic ?? selected, choose);
1340
- nodes = [
1341
- picker === null ? null : React.createElement('div', { style: panelHeaderStyle }, picker),
1342
- React.createElement('p', { style: panelMutedStyle }, `No Course in ${searched}. Run /study-plus to create one.`),
1343
- ];
1344
- } else {
1345
- nodes = readyBody(content.snapshot, content.error, topics, choose);
1346
- }
1347
- return React.createElement('div', {
1348
- role: 'region',
1349
- 'aria-label': VIEW_LABEL,
1350
- style: panelStyle,
1351
- }, ...nodes);
1352
- }
1353
-
1354
- const panelStyle = {
1355
- boxSizing: 'border-box',
1356
- color: 'var(--dsw-alias-label-primary)',
1357
- display: 'flex',
1358
- flexDirection: 'column',
1359
- fontFamily: 'var(--dsw-font-family, inherit)',
1360
- gap: 28,
1361
- maxWidth: 720,
1362
- // The conversation view area carries no padding of its own.
1363
- padding: '24px 16px 40px',
1364
- width: '100%',
1365
- };
1366
- const panelHeaderStyle = { alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'space-between' };
1367
- const panelTitleStyle = { fontSize: 16, fontWeight: 600, lineHeight: '24px', margin: 0 };
1368
- const panelMutedStyle = { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13, lineHeight: '20px', margin: 0 };
1369
- const panelHeadingStyle = { fontSize: 14, fontWeight: 600, lineHeight: '22px', margin: 0 };
1370
- const panelSectionStyle = { display: 'flex', flexDirection: 'column', gap: 10 };
1371
- const panelListStyle = { display: 'flex', flexDirection: 'column', gap: 8, listStyle: 'none', margin: 0, padding: 0 };
1372
- const panelProgressStyle = { display: 'flex', flexDirection: 'column', gap: 10 };
1373
- const panelFigureRowStyle = { lineHeight: '34px', margin: 0 };
1374
- const panelFigureUnitStyle = {
1375
- color: 'var(--dsw-alias-label-tertiary)',
1376
- fontSize: 13,
1377
- lineHeight: '20px',
1378
- marginLeft: 2,
1379
- };
1380
- const panelTrackStyle = {
1381
- background: 'var(--dsw-alias-border-l3)',
1382
- borderRadius: 999,
1383
- height: 8,
1384
- overflow: 'hidden',
1385
- width: '100%',
1386
- };
1387
- const panelFillStyle = { background: 'var(--dsw-alias-state-business-primary)', height: '100%' };
1388
- const panelStripStyle = {
1389
- alignItems: 'start',
1390
- display: 'grid',
1391
- gap: 12,
1392
- gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
1393
- margin: 0,
1394
- };
1395
- const panelStripCellStyle = {
1396
- border: '1px solid var(--dsw-alias-border-l2)',
1397
- borderRadius: 'var(--dsw-radius-sm)',
1398
- display: 'flex',
1399
- flexDirection: 'column',
1400
- gap: 6,
1401
- padding: '12px 14px',
1402
- };
1403
- // The Current cell is the answer to "where am I", so it is the only filled cell.
1404
- const panelStripCellCurrentStyle = {
1405
- ...panelStripCellStyle,
1406
- background: 'var(--dsw-alias-bg-layer-1)',
1407
- borderColor: 'var(--dsw-alias-border-l3)',
1408
- };
1409
- const panelStripLabelStyle = { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12, lineHeight: '18px', margin: 0 };
1410
- const panelStripValueStyle = { fontSize: 13, lineHeight: '20px', margin: 0 };
1411
- const panelStripMutedValueStyle = {
1412
- ...panelStripValueStyle,
1413
- color: 'var(--dsw-alias-label-tertiary)',
1414
- };
1415
- const panelFigureStyle = {
1416
- fontSize: 28,
1417
- fontWeight: 600,
1418
- fontVariantNumeric: 'tabular-nums',
1419
- lineHeight: '34px',
1420
- margin: 0,
1421
- };
1422
- const panelFieldStyle = { alignItems: 'center', display: 'flex', fontSize: 13, gap: 8 };
1423
- const panelSelectStyle = {
1424
- background: 'var(--dsw-alias-bg-layer-1)',
1425
- border: '1px solid var(--dsw-alias-border-l3)',
1426
- borderRadius: 'var(--dsw-radius-sm, 6px)',
1427
- color: 'var(--dsw-alias-label-primary)',
1428
- font: 'inherit',
1429
- height: 32,
1430
- padding: '0 8px',
1431
- };
1432
-
1433
- // --- Lectures dock page --------------------------------------------------
1434
- const LECTURES_ID = 'dsh-nerd-mode/lectures';
1435
- const LECTURES_KIND = 'lectures';
1436
- const LECTURES_LABEL = 'Lectures';
1437
- const LECTURES_GUIDE_ORDER = 40;
1438
-
1439
- // Same 16px book as the composer pill, drawn at the size the guide asks for.
1440
- function LecturesGlyph({ size = 16 }) {
1441
- return React.createElement(
1442
- 'svg',
1443
- {
1444
- 'aria-hidden': 'true',
1445
- fill: 'none',
1446
- height: size,
1447
- stroke: 'currentColor',
1448
- strokeLinecap: 'round',
1449
- strokeLinejoin: 'round',
1450
- strokeWidth: 1.6,
1451
- viewBox: '0 0 16 16',
1452
- width: size,
1453
- },
1454
- ...BOOK.props.children,
1455
- );
1456
- }
1457
-
1458
- // The leading block only; a `---` rule later in the file stays content.
1459
- // A block without a closing fence, or with a line that is not `key: value`,
1460
- // returns null so the caller can leave the state empty.
1461
- function lectureFrontmatter(lines) {
1462
- if (lines[0]?.trim() !== '---') return {};
1463
- const end = lines.findIndex((line, at) => at > 0 && line.trim() === '---');
1464
- if (end === -1) return null;
1465
- const fields = {};
1466
- for (const line of lines.slice(1, end)) {
1467
- if (line.trim() === '') continue;
1468
- const match = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line.trim());
1469
- if (match === null) return null;
1470
- fields[match[1]] = match[2].trim();
1471
- }
1472
- return fields;
1473
- }
1474
-
1475
- function parseLecture(text, slug) {
1476
- const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
1477
- const fields = lectureFrontmatter(lines);
1478
- const heading = lines.find((line) => /^#\s+\S/.test(line));
1479
- const draft = fields !== null && fields.status === 'draft';
1480
- const date = fields !== null && /^\d{4}-\d{2}-\d{2}$/.test(fields.date ?? '') ? fields.date : null;
1481
- // A draft that also carries a teaching date is malformed, so the block reads as unparsable.
1482
- const valid = fields !== null && !(draft && date !== null);
1483
- return {
1484
- title: heading === undefined ? slug : heading.replace(/^#\s+/, '').trim(),
1485
- concept: valid && /^\d+$/.test(fields.concept ?? '') ? Number(fields.concept) : null,
1486
- date: valid ? date : null,
1487
- draft: valid && draft,
1488
- };
1489
- }
1490
-
1491
- // A missing folder is an empty listing. Any other failure becomes data.
1492
- // `found` tells the caller whether the folder exists, so a missing root and
1493
- // an empty root can render different pages.
1494
- async function listFolder(files, sessionId, path) {
1495
- try {
1496
- const listing = await files.list(sessionId, path);
1497
- if (listing.ok) return { entries: listing.value?.entries ?? [], error: null, found: true };
1498
- if (listing.error?.code === 'workspace-file/not-found') return { entries: [], error: null, found: false };
1499
- return { entries: [], error: listing.error?.message ?? `${path} could not be read`, found: false };
1500
- } catch (error) {
1501
- return { entries: [], error: error?.message ?? `${path} could not be read`, found: false };
1502
- }
1503
- }
1504
-
1505
- /** Return `{ courses, course, entries, error, rootFound }`. `rootFound` is false only when `study/` is missing, `course` is null when the workspace holds none, and `error` is the first read failure or null. */
1506
- async function loadLectures(files, sessionId) {
1507
- const model = { courses: [], course: null, entries: [], error: null, rootFound: false };
1508
- const root = await listFolder(files, sessionId, TRACKER_ROOT);
1509
- model.rootFound = root.found;
1510
- if (root.error !== null) {
1511
- model.error = root.error;
1512
- return model;
1513
- }
1514
- model.courses = root.entries
1515
- .filter((entry) => entry.type === 'directory')
1516
- .map((entry) => entry.name)
1517
- .sort((left, right) => left.localeCompare(right));
1518
- model.course = pickCourse(model.courses);
1519
- if (model.course === null) return model;
1520
- const entries = [];
1521
- const seen = new Set();
1522
- const folders = [
1523
- [`${TRACKER_ROOT}/${model.course}/lectures`, false],
1524
- [`${TRACKER_ROOT}/${model.course}/lectures/archive`, true],
1525
- ];
1526
- for (const [folder, archived] of folders) {
1527
- const listing = await listFolder(files, sessionId, folder);
1528
- if (listing.error !== null && model.error === null) model.error = listing.error;
1529
- for (const item of listing.entries) {
1530
- if (item.type !== 'file') continue;
1531
- const match = /^(\d{2,})-(.+)\.md$/.exec(item.name);
1532
- // One lecture per number: a stale copy in the archive never repeats it.
1533
- if (match === null || seen.has(match[1])) continue;
1534
- let text;
1535
- try {
1536
- text = await readText(files, sessionId, `${folder}/${item.name}`);
1537
- } catch (error) {
1538
- if (model.error === null) model.error = error?.message ?? `${folder}/${item.name} could not be read`;
1539
- continue;
1540
- }
1541
- const parsed = parseLecture(text, match[2]);
1542
- entries.push({
1543
- number: match[1],
1544
- topic: model.course,
1545
- title: parsed.title,
1546
- concept: parsed.concept,
1547
- mark: archived ? 'archived' : parsed.draft ? 'draft' : parsed.date !== null ? 'dated' : null,
1548
- date: parsed.date,
1549
- path: `${folder}/${item.name}`,
1550
- });
1551
- seen.add(match[1]);
1552
- }
1553
- }
1554
- entries.sort((left, right) => Number(left.number) - Number(right.number));
1555
- model.entries = entries;
1556
- return model;
1557
- }
1558
-
1559
- // The picker and Course name stay in view while only the lecture rows
1560
- // scroll. The scroller owns the side padding, so its bar sits at the page
1561
- // edge rather than inside the text.
1562
- const LECTURES_CSS = `.lectures-page{box-sizing:border-box;height:100%;display:flex;flex-direction:column;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,inherit)}
1563
- .lectures-head{display:flex;align-items:center;gap:12px;padding:24px 32px 0}
1564
- .lectures-picker{display:flex;align-items:center;gap:8px;flex:1;min-width:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}
1565
- .lectures-select{flex:1;min-width:0;height:32px;padding:0 8px;border:1px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm,8px);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit}
1566
- .lectures-group{display:flex;flex-direction:column;flex:1;min-height:0}
1567
- .lectures-group-head{display:flex;align-items:center;gap:8px;padding:20px 32px 8px}
1568
- .lectures-group-title{margin:0;font-size:14px;line-height:22px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1569
- .lectures-count{flex:none;font-size:13px;line-height:20px;font-weight:400;color:var(--dsw-alias-label-tertiary)}
1570
- .lectures-scroll{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:2px;padding:0 32px 32px}
1571
- .lectures-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}
1572
- .lectures-item{margin:0 -8px}
1573
- .lectures-row{position:relative;width:100%;box-sizing:border-box;display:grid;grid-template-columns:28px minmax(0,1fr);grid-template-areas:"tile title" "tile meta";column-gap:12px;row-gap:2px;align-items:start;padding:8px;border:none;border-radius:var(--dsw-radius-xl,20px);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;white-space:normal}
1574
- .lectures-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
1575
- .lectures-row:focus-visible{outline:none}
1576
- .lectures-row:focus-visible::after{content:"";position:absolute;inset:0;border-radius:inherit;outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
1577
- .lectures-tile{grid-area:tile;align-self:center;width:28px;height:28px;display:grid;place-items:center;border-radius:6px;background:var(--dsw-alias-fill-tsp-secondary);color:var(--dsw-alias-label-secondary);font:600 11px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums}
1578
- .lectures-title{grid-area:title;font-size:14px;line-height:20px;font-weight:500;overflow-wrap:anywhere}
1579
- .lectures-meta{grid-area:meta;display:flex;align-items:center;gap:6px;min-width:0;font-size:13px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
1580
- .lectures-chip{display:inline-flex;align-items:center;height:18px;padding:0 7px;font-size:11px;line-height:17px;border:.5px solid var(--dsw-alias-border-l3);border-radius:999px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
1581
- .lectures-fold{align-self:flex-start;display:inline-flex;align-items:center;gap:8px;margin-top:12px;padding:0;border:none;background:none;font-size:14px;line-height:22px;font-weight:500;color:var(--dsw-alias-label-primary);cursor:pointer}
1582
- .lectures-fold:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
1583
- .lectures-chev{color:var(--dsw-alias-label-tertiary);transition:transform 120ms}
1584
- .lectures-fold[aria-expanded="false"] .lectures-chev{transform:rotate(-90deg)}
1585
- .lectures-status{margin:0;padding:8px 32px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}
1586
- .lectures-error{display:flex;align-items:center;gap:8px;margin:0;padding:8px 32px;font-size:13px;line-height:20px;color:var(--dsw-alias-state-error-primary)}
1587
- .lectures-dot{flex:none;width:6px;height:6px;border-radius:50%;background:currentColor}
1588
- .lectures-empty{margin:0;padding:48px 20px;text-align:center;font-size:14px;line-height:22px;color:var(--dsw-alias-label-tertiary)}
1589
- .lectures-empty code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;padding:1px 6px}`;
1590
-
1591
- const LECTURE_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
1592
-
1593
- // `2026-10-28` reads as `28 Oct 2026`: shorter than the ISO form and still one date.
1594
- function lectureDate(value) {
1595
- const [year, month, day] = value.split('-');
1596
- const name = LECTURE_MONTHS[Number(month) - 1];
1597
- return name === undefined ? value : `${Number(day)} ${name} ${year}`;
1598
- }
1599
-
1600
- function lectureMark(entry) {
1601
- if (entry.mark === 'archived') return 'Archived';
1602
- if (entry.mark === 'draft') return 'Draft';
1603
- if (entry.mark === 'dated' && entry.date !== null) return lectureDate(entry.date);
1604
- return null;
1605
- }
1606
-
1607
- // Copied verbatim from `@deepseek-ai/dsh-util-workspace-path`, which a client
1608
- // plugin cannot load from the browser module table.
1609
- const FILE_ADDRESS_PREFIX = 'dsh-resource://file/';
1610
- /** Component-encode one id or path segment, keeping `:` literal for drive letters. */
1611
- function encodeSegment(segment) {
1612
- return encodeURIComponent(segment).replace(/%3A/gi, ':');
1613
- }
1614
- /** Encode a `/`-separated path segment by segment. */
1615
- function encodePath(path) {
1616
- return path.split('/').map(encodeSegment).join('/');
1617
- }
1618
- /** Build the address of a file read through one Session. */
1619
- function sessionFileAddress(sessionId, path) {
1620
- const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '');
1621
- return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`;
1622
- }
1623
-
1624
- // The visible title is clipped, so the label repeats it in full.
1625
- function lectureRow(entry, onOpen) {
1626
- const mark = lectureMark(entry);
1627
- const label = mark === null
1628
- ? `${entry.number}, ${entry.title}`
1629
- : `${entry.number}, ${entry.title}, ${mark}`;
1630
- return React.createElement('li', { key: entry.number, className: 'lectures-item' },
1631
- React.createElement('button', {
1632
- type: 'button',
1633
- className: 'lectures-row',
1634
- 'aria-label': label,
1635
- onClick: () => onOpen(entry),
1636
- },
1637
- React.createElement('span', { className: 'lectures-tile', 'aria-hidden': 'true' }, entry.number),
1638
- React.createElement('span', { className: 'lectures-title' }, entry.title),
1639
- mark === null
1640
- ? null
1641
- : React.createElement('span', { className: 'lectures-meta' },
1642
- entry.mark === 'dated'
1643
- ? mark
1644
- : React.createElement('span', { className: 'lectures-chip' }, mark))));
1645
- }
1646
-
1647
- function lectureTopic(group, open, onToggle, onOpen) {
1648
- const main = group.entries.filter((entry) => entry.mark !== 'archived');
1649
- const archived = group.entries.filter((entry) => entry.mark === 'archived');
1650
- const archiveId = `lectures-archive-${group.topic}`;
1651
- const rows = [];
1652
- if (group.entries.length === 0) {
1653
- rows.push(React.createElement('p', { key: 'empty', className: 'lectures-empty' }, 'No lectures yet.'));
1654
- } else if (main.length > 0) {
1655
- rows.push(React.createElement('ul', { key: 'main', className: 'lectures-rows' }, ...main.map((entry) => lectureRow(entry, onOpen))));
1656
- }
1657
- if (archived.length > 0) {
1658
- rows.push(React.createElement('button', {
1659
- key: 'fold',
1660
- type: 'button',
1661
- className: 'lectures-fold',
1662
- 'aria-expanded': open,
1663
- // A closed fold has no list to control, so the reference would dangle.
1664
- 'aria-controls': open ? archiveId : undefined,
1665
- 'aria-label': `Archived lectures ${archived.length}`,
1666
- onClick: () => onToggle(group.topic),
1667
- },
1668
- React.createElement('span', null, 'Archived lectures'),
1669
- React.createElement('span', { className: 'lectures-count' }, String(archived.length)),
1670
- React.createElement('svg', {
1671
- 'aria-hidden': 'true',
1672
- className: 'lectures-chev',
1673
- fill: 'none',
1674
- height: 12,
1675
- stroke: 'currentColor',
1676
- strokeLinecap: 'round',
1677
- strokeLinejoin: 'round',
1678
- strokeWidth: 1.6,
1679
- viewBox: '0 0 16 16',
1680
- width: 12,
1681
- }, React.createElement('path', { d: 'M3.5 6 8 10.5 12.5 6' }))));
1682
- if (open) {
1683
- rows.push(React.createElement('ul', { key: 'archive', className: 'lectures-rows', id: archiveId }, ...archived.map((entry) => lectureRow(entry, onOpen))));
1684
- }
1685
- }
1686
- // The Course key remounts the section on a switch, so its scrollport starts at the top.
1687
- return React.createElement('section', { key: group.topic, className: 'lectures-group' },
1688
- React.createElement('div', { className: 'lectures-group-head' },
1689
- React.createElement('h2', { className: 'lectures-group-title' }, group.topic),
1690
- React.createElement('span', { className: 'lectures-count' }, String(main.length))),
1691
- React.createElement('div', { className: 'lectures-scroll' }, ...rows));
1692
- }
1693
-
1694
- function LecturesPage({ useTabInfo, sessionId, workspaceFiles }) {
1695
- const { tab } = useTabInfo();
1696
- const [content, setContent] = React.useState({ status: 'loading' });
1697
- const [selected, setSelected] = React.useState(null);
1698
- const [foldOpen, setFoldOpen] = React.useState(false);
1699
- // Read on every render so a selection made in the Study tab wins.
1700
- const shared = activeCourse;
1701
-
1702
- React.useEffect(() => {
1703
- let live = true;
1704
- const run = async () => {
1705
- const model = await loadLectures(workspaceFiles, sessionId);
1706
- if (!live) return;
1707
- setContent({ status: 'ready', ...model });
1708
- };
1709
- run();
1710
- return () => { live = false; };
1711
- }, [selected, shared, sessionId, workspaceFiles]);
1712
-
1713
- const choose = (course) => {
1714
- activeCourse = course;
1715
- setFoldOpen(false);
1716
- setSelected(course);
1717
- };
1718
-
1719
- // No placement option: the preview opens as a tab in this pane and the
1720
- // host focuses it when that lecture is already open.
1721
- const open = (entry) => tab.actions.openResource(sessionFileAddress(sessionId, entry.path));
1722
-
1723
- let nodes;
1724
- if (content.status === 'loading') {
1725
- nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, className: 'lectures-status' }, 'Loading the lectures…')];
1726
- } else if (content.error === null && !content.rootFound) {
1727
- nodes = [React.createElement('p', { className: 'lectures-empty' },
1728
- `No Course in ${TRACKER_ROOT}/. Run `,
1729
- React.createElement('code', null, '/study-plus'),
1730
- ' to create one.')];
1731
- } else {
1732
- nodes = content.error === null
1733
- ? []
1734
- : [React.createElement('p', { role: 'alert', className: 'lectures-error' },
1735
- React.createElement('span', { className: 'lectures-dot', 'aria-hidden': 'true' }),
1736
- `The lecture library could not be read: ${content.error}.`)];
1737
- if (content.course !== null) {
1738
- nodes.push(lectureTopic(
1739
- { topic: content.course, entries: content.entries },
1740
- foldOpen,
1741
- () => setFoldOpen((previous) => !previous),
1742
- open,
1743
- ));
1744
- }
1745
- }
1746
-
1747
- // One Course needs no picker: the page already shows that Course.
1748
- const courses = content.courses ?? [];
1749
- const picker = courses.length < 2 ? null : React.createElement('label', { className: 'lectures-picker' },
1750
- React.createElement('span', null, 'Course'),
1751
- React.createElement('select', {
1752
- 'aria-label': 'Course',
1753
- className: 'lectures-select',
1754
- value: content.course ?? selected,
1755
- onChange: (event) => choose(event.target.value),
1756
- }, ...courses.map((course) => React.createElement('option', { key: course, value: course }, course))));
1757
- return React.createElement('div', {
1758
- role: 'region',
1759
- 'aria-label': LECTURES_LABEL,
1760
- className: 'lectures-page',
1761
- }, React.createElement('style', null, LECTURES_CSS),
1762
- React.createElement('header', { className: 'lectures-head' }, picker),
1763
- ...nodes);
1764
- }
1765
-
1766
- // A page type names no resource pattern and no `multiple`, so the host
1767
- // records it at one fixed page address and a reopen focuses that tab.
1768
- const LECTURES_DEFINITION = {
1769
- id: LECTURES_ID,
1770
- kind: LECTURES_KIND,
1771
- title: () => LECTURES_LABEL,
1772
- guide: [{
1773
- id: 'lectures',
1774
- order: LECTURES_GUIDE_ORDER,
1775
- title: () => LECTURES_LABEL,
1776
- description: () => 'Read the lectures saved in this workspace.',
1777
- icon: LecturesGlyph,
1778
- }],
1779
- };
1780
-
1781
- // --- Study Sessions panel ------------------------------------------------
1782
-
1783
- const STUDY_SESSIONS_ID = 'study-sessions';
1784
- const STUDY_SESSIONS_ORDER = 30;
1785
- const STUDY_SESSIONS_API = '/dsh-nerd-mode/api/study-sessions';
1786
- const STUDY_SESSIONS_TITLE = { en: 'Study Sessions', zh: '学习会话' };
1787
- const STUDY_QUICK = { en: 'Quick', zh: '快速' };
1788
- const STUDY_IN_DEPTH = { en: 'In-depth', zh: '深入' };
1789
- const STUDY_PAST = { en: 'Past', zh: '过去' };
1790
-
1791
- // A host without the session package still gets the page and its empty line.
1792
- const NO_SESSIONS_HOOK = () => null;
1793
-
1794
- // A failed rescan or a remount must not blank the panel, so the last good scan outlives the component.
1795
- let studySessionsCache = null;
1796
-
1797
- /**
1798
- * Read one history chunk from the host route.
1799
- *
1800
- * Returns `{ phase, done, total, sessions, cursor }`. Throws an Error whose message names the failure:
1801
- * a missing route (404/405 or a body that is not JSON) and any non-OK reply both count.
1802
- */
1803
- async function readSessionChunk(cursor) {
1804
- const response = await fetch(`${STUDY_SESSIONS_API}?cursor=${cursor}`);
1805
- // The static fallback owns an unregistered path, so it answers with an empty body.
1806
- if (response.status === 404 || response.status === 405) throw new Error('the host route is not available');
1807
- // A rejected request carries plain text, so name the status before the body is parsed.
1808
- if (response.status === 401 || response.status === 403) {
1809
- throw new Error(`the host denied the request (HTTP ${response.status})`);
1810
- }
1811
- const body = await response.json().catch(() => null);
1812
- if (body === null) throw new Error('the host returned no JSON');
1813
- if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`);
1814
- const sessions = body?.sessions;
1815
- if (typeof sessions !== 'object' || sessions === null) throw new Error('the host returned no session history');
1816
- return {
1817
- phase: body.phase === 'ready' ? 'ready' : 'scanning',
1818
- done: Number.isFinite(body.done) ? body.done : 0,
1819
- total: Number.isFinite(body.total) ? body.total : 0,
1820
- sessions,
1821
- // The spec names no cursor field, so accept one or fall back to the processed count.
1822
- cursor: Number.isFinite(body.cursor) ? body.cursor : Number.isFinite(body.done) ? body.done : cursor,
1823
- };
1824
- }
1825
-
1826
- /**
1827
- * Scan the host history index from cursor 0, merging each chunk into `sessions`.
1828
- *
1829
- * Calls `onChunk` after every reply so the page can move its progress line. Returns the final
1830
- * scan state. A host that repeats a cursor cannot make progress, so the loop stops there.
1831
- */
1832
- async function scanSessionHistory(sessions, onChunk) {
1833
- let state = { status: 'scanning', sessions, done: 0, total: 0, cursor: 0 };
1834
- let previous = 0;
1835
- for (;;) {
1836
- const chunk = await readSessionChunk(state.cursor);
1837
- state = {
1838
- status: chunk.phase,
1839
- // A ready reply carries the host's whole index, so it replaces stale cached ids.
1840
- sessions: chunk.phase === 'ready' ? chunk.sessions : { ...state.sessions, ...chunk.sessions },
1841
- done: chunk.done,
1842
- total: chunk.total,
1843
- cursor: chunk.cursor,
1844
- };
1845
- onChunk(state);
1846
- if (state.status === 'ready' || chunk.cursor <= previous) return state;
1847
- previous = chunk.cursor;
1848
- }
1849
- }
1850
-
1851
- /** Return the last folder in a workspace path; Windows and POSIX separators both count. */
1852
- function workspaceName(cwd) {
1853
- if (typeof cwd !== 'string') return '';
1854
- return cwd.split(/[\\/]+/).filter((part) => part !== '').pop() ?? '';
1855
- }
1856
-
1857
- const RELATIVE_UNITS = [
1858
- ['year', 31536000000],
1859
- ['month', 2592000000],
1860
- ['day', 86400000],
1861
- ['hour', 3600000],
1862
- ['minute', 60000],
1863
- ['second', 1000],
1864
- ];
1865
-
1866
- /** Return a locale-aware relative time such as `2 hours ago`; a missing time reads as an empty string. */
1867
- function relativeTime(updatedAt, language) {
1868
- if (!Number.isFinite(updatedAt)) return '';
1869
- const elapsed = updatedAt - Date.now();
1870
- const [unit, size] = RELATIVE_UNITS.find(([, step]) => Math.abs(elapsed) >= step) ?? RELATIVE_UNITS[5];
1871
- return new Intl.RelativeTimeFormat(language, { numeric: 'auto' }).format(Math.round(elapsed / size), unit);
1872
- }
1873
-
1874
- /** Join the live session list with the history index. A history id with no live row is dropped. */
1875
- function sessionRows(snapshot, history) {
1876
- const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
1877
- const byId = snapshot?.byId ?? {};
1878
- const rows = [];
1879
- for (const id of ids) {
1880
- const row = byId[id];
1881
- if (row === undefined || !Object.hasOwn(history, id)) continue;
1882
- rows.push({
1883
- id,
1884
- title: String(row.displayTitle ?? id),
1885
- workspace: workspaceName(row.cwd),
1886
- updatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : 0,
1887
- active: row.projectionValues?.studyMode?.active === true,
1888
- studyPlus: history[id]?.studyPlus === true,
1889
- });
1890
- }
1891
- return rows.sort((left, right) => right.updatedAt - left.updatedAt);
1892
- }
1893
-
1894
- /** Split rows into Quick, In-depth, and Past; a missing live switch value counts as off. */
1895
- function groupSessionRows(rows) {
1896
- const groups = { quick: [], inDepth: [], past: [] };
1897
- for (const row of rows) {
1898
- if (!row.active) groups.past.push(row);
1899
- else if (row.studyPlus) groups.inDepth.push(row);
1900
- else groups.quick.push(row);
1901
- }
1902
- return groups;
1903
- }
1904
-
1905
- // The tile is decoration; the group heading and the row label carry the meaning.
1906
- function studyTile(kind) {
1907
- const glyph = (children) => React.createElement('svg', {
1908
- fill: 'none',
1909
- height: 16,
1910
- stroke: 'currentColor',
1911
- strokeLinecap: 'round',
1912
- strokeLinejoin: 'round',
1913
- strokeWidth: 1.6,
1914
- viewBox: '0 0 16 16',
1915
- width: 16,
1916
- }, ...children);
1917
- if (kind === 'book') return glyph(BOOK.props.children);
1918
- if (kind === 'quick') {
1919
- return glyph([
1920
- React.createElement('path', { d: 'M13.4 8.9a3.7 3.7 0 0 1-3.7 3.7H6.4L3.4 14.6v-2.5A3.7 3.7 0 0 1 1.8 9V7.5a3.7 3.7 0 0 1 3.7-3.7h4.2a3.7 3.7 0 0 1 3.7 3.7Z' }),
1921
- React.createElement('path', { d: 'M5.2 7.3h5.6M5.2 9.3h3.4' }),
1922
- ]);
1923
- }
1924
- return glyph([
1925
- React.createElement('circle', { cx: 8, cy: 8, r: 5.6 }),
1926
- React.createElement('path', { d: 'M8 5.2V8l1.9 1.3' }),
1927
- ]);
1928
- }
1929
-
1930
- // Inline because the client bundle cannot read assets/.
1931
- function StudySessionsIcon({ size = 16, active = false }) {
1932
- return React.createElement(
1933
- 'svg',
1934
- {
1935
- 'aria-hidden': 'true',
1936
- fill: 'none',
1937
- height: size,
1938
- stroke: 'currentColor',
1939
- strokeLinecap: 'round',
1940
- strokeLinejoin: 'round',
1941
- strokeWidth: active ? 2 : 1.6,
1942
- viewBox: '0 0 16 16',
1943
- width: size,
1944
- },
1945
- React.createElement('path', { d: 'M2.5 4h11M2.5 8h11M2.5 12h7' }),
1946
- React.createElement('circle', {
1947
- cx: 12.5,
1948
- cy: 12,
1949
- fill: 'currentColor',
1950
- opacity: active ? 1 : 0.45,
1951
- r: 1.2,
1952
- stroke: 'none',
1953
- }),
1954
- );
1955
- }
1956
-
1957
- function StudySessionsPage({ useSessions, locale, openSession }) {
1958
- const t = useT(locale);
1959
- const language = locale.getSnapshot()?.active ?? 'en';
1960
- const [scan, setScan] = React.useState(() => studySessionsCache === null
1961
- ? { status: 'scanning', sessions: {}, done: 0, total: 0, cursor: 0 }
1962
- : { ...studySessionsCache, status: 'scanning' });
1963
- // Only a folded key reads true: In-depth and Quick start open, Past starts folded.
1964
- const [folded, setFolded] = React.useState({ inDepth: false, quick: false, past: true });
1965
- const snapshot = useSessions((state) => state);
1966
-
1967
- React.useEffect(() => {
1968
- let live = true;
1969
- // A cached list seeds the scan, so rows stay while the host rescans.
1970
- scanSessionHistory(studySessionsCache?.sessions ?? {}, (chunk) => {
1971
- if (!live) return;
1972
- studySessionsCache = chunk;
1973
- setScan(chunk);
1974
- }).catch((error) => {
1975
- if (!live) return;
1976
- setScan((previous) => ({
1977
- ...previous,
1978
- status: 'failed',
1979
- reason: error?.message ?? 'the host did not answer',
1980
- }));
1981
- });
1982
- return () => { live = false; };
1983
- }, []);
1984
-
1985
- const grouped = groupSessionRows(sessionRows(snapshot, scan.sessions));
1986
-
1987
- // The row is one target, so its label names the title, workspace, time, and mode.
1988
- const rowNode = (row, past) => {
1989
- const time = relativeTime(row.updatedAt, language);
1990
- const chip = past ? t(row.studyPlus ? STUDY_IN_DEPTH : STUDY_QUICK) : null;
1991
- const label = [row.title, row.workspace, time, chip]
1992
- .filter((part) => part !== '' && part !== null)
1993
- .join(', ');
1994
- return React.createElement('li', { className: 'study-ss-item', key: row.id },
1995
- React.createElement('button', {
1996
- type: 'button',
1997
- className: 'study-ss-row',
1998
- 'aria-label': label,
1999
- onClick: () => openSession(row.id),
2000
- },
2001
- React.createElement('span', { className: 'study-ss-tile', 'aria-hidden': 'true' },
2002
- studyTile(past ? 'past' : row.studyPlus ? 'book' : 'quick')),
2003
- React.createElement('span', { className: 'study-ss-title' }, row.title),
2004
- React.createElement('span', { className: 'study-ss-meta' },
2005
- row.workspace === '' ? null : React.createElement('span', { className: 'study-ss-workspace' }, row.workspace),
2006
- chip === null ? null : React.createElement('span', {
2007
- className: 'study-ss-chip',
2008
- 'data-study-chip': '',
2009
- }, chip)),
2010
- time === '' ? null : React.createElement('span', { className: 'study-ss-time' }, time)));
2011
- };
2012
-
2013
- const nodes = [];
2014
- if (scan.status === 'scanning') {
2015
- const percent = scan.total > 0 ? Math.round((scan.done / scan.total) * 100) : 0;
2016
- nodes.push(React.createElement('div', { key: 'progress', className: 'study-ss-scan' },
2017
- React.createElement('p', {
2018
- role: 'status',
2019
- 'aria-busy': true,
2020
- className: 'study-ss-status',
2021
- }, t({
2022
- en: `Scanning session history… ${scan.done} of ${scan.total}`,
2023
- zh: `正在扫描会话历史… ${scan.done} / ${scan.total}`,
2024
- })),
2025
- React.createElement('div', { className: 'study-ss-track', 'aria-hidden': 'true' },
2026
- React.createElement('div', { className: 'study-ss-fill', style: { width: `${percent}%` } }))));
2027
- }
2028
- if (scan.status === 'failed') {
2029
- nodes.push(React.createElement('p', {
2030
- key: 'failure',
2031
- role: 'alert',
2032
- className: 'study-ss-error',
2033
- },
2034
- React.createElement('span', { className: 'study-ss-dot', 'aria-hidden': 'true' }),
2035
- t({
2036
- en: `The session index could not be read: ${scan.reason}`,
2037
- zh: `无法读取会话索引:${scan.reason}`,
2038
- })));
2039
- }
2040
- if (scan.status === 'ready' && Object.keys(scan.sessions).length === 0) {
2041
- nodes.push(React.createElement('p', { key: 'empty', className: 'study-ss-empty' },
2042
- t({
2043
- en: 'No study session yet. Turn on Nerd Mode from the composer bar, or run',
2044
- zh: '还没有学习会话。在输入栏打开 Nerd Mode,或运行',
2045
- }),
2046
- ' ',
2047
- React.createElement('code', null, '/nerd-mode on'),
2048
- t({ en: '.', zh: '。' })));
2049
- }
2050
-
2051
- const group = (key, label, list, past) => {
2052
- if (list.length === 0) return null;
2053
- const open = folded[key] === false;
2054
- const listId = `study-ss-rows-${key}`;
2055
- return React.createElement('section', { key, className: 'study-ss-group' },
2056
- React.createElement('h2', { className: 'study-ss-group-title' },
2057
- React.createElement('button', {
2058
- type: 'button',
2059
- className: 'study-ss-fold',
2060
- 'aria-expanded': open,
2061
- // A folded group has no list to control, so the reference would dangle.
2062
- 'aria-controls': open ? listId : undefined,
2063
- 'aria-label': `${t(label)} ${list.length}`,
2064
- onClick: () => setFolded((previous) => ({ ...previous, [key]: !previous[key] })),
2065
- },
2066
- React.createElement('span', null, t(label)),
2067
- React.createElement('span', { className: 'study-ss-count' }, String(list.length)),
2068
- React.createElement('svg', {
2069
- 'aria-hidden': 'true',
2070
- className: 'study-ss-chev',
2071
- fill: 'none',
2072
- height: 12,
2073
- stroke: 'currentColor',
2074
- strokeLinecap: 'round',
2075
- strokeLinejoin: 'round',
2076
- strokeWidth: 1.6,
2077
- viewBox: '0 0 16 16',
2078
- width: 12,
2079
- }, React.createElement('path', { d: 'M3.5 6 8 10.5 12.5 6' })))),
2080
- open
2081
- ? React.createElement('ul', { className: 'study-ss-rows', id: listId },
2082
- ...list.map((row) => rowNode(row, past)))
2083
- : null);
2084
- };
2085
- nodes.push(group('inDepth', STUDY_IN_DEPTH, grouped.inDepth, false));
2086
- nodes.push(group('quick', STUDY_QUICK, grouped.quick, false));
2087
- nodes.push(group('past', STUDY_PAST, grouped.past, true));
2088
-
2089
- return React.createElement('div', {
2090
- role: 'region',
2091
- 'aria-label': t(STUDY_SESSIONS_TITLE),
2092
- className: 'study-ss-page',
2093
- }, React.createElement('style', null, STUDY_SESSIONS_CSS),
2094
- React.createElement('header', { className: 'study-ss-head', 'data-window-drag': '' },
2095
- React.createElement('h1', { className: 'study-ss-title' }, t(STUDY_SESSIONS_TITLE)),
2096
- React.createElement('p', { className: 'study-ss-intro' }, t({
2097
- en: 'Every conversation where Nerd Mode was on.',
2098
- zh: '开启过 Nerd Mode 的会话都在这里。',
2099
- }))),
2100
- ...nodes);
2101
- }
2102
-
2103
- // The page shell, rows, and fold live in CSS so a container query can fold
2104
- // the time under the title on narrow panes. Tokens come from the host theme.
2105
- const STUDY_SESSIONS_CSS = `.study-ss-page{box-sizing:border-box;height:100%;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:32px;padding:0 clamp(24px,4vw,48px) 48px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,inherit);container-type:inline-size}
2106
- .study-ss-page>*{width:100%;max-width:960px}
2107
- .study-ss-head{display:flex;flex-direction:column;padding-top:28px}
2108
- [data-platform="darwin"] .study-ss-head{padding-top:calc(28px + var(--dsh-frame-top-clearance,0px))}
2109
- .study-ss-title{font-size:20px;line-height:28px;font-weight:500;margin:0}
2110
- .study-ss-intro{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);margin:4px 0 0}
2111
- .study-ss-group{display:flex;flex-direction:column;gap:8px}
2112
- .study-ss-group-title{font-size:14px;line-height:22px;font-weight:500;margin:0}
2113
- .study-ss-fold{display:inline-flex;align-items:center;gap:8px;padding:0;background:none;border:none;font:inherit;color:inherit;cursor:pointer}
2114
- .study-ss-fold:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
2115
- .study-ss-count{font-size:13px;line-height:20px;font-weight:400;color:var(--dsw-alias-label-tertiary)}
2116
- .study-ss-chev{color:var(--dsw-alias-label-tertiary);transition:transform 120ms}
2117
- .study-ss-fold[aria-expanded="false"] .study-ss-chev{transform:rotate(-90deg)}
2118
- .study-ss-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}
2119
- .study-ss-item{margin:0 -8px}
2120
- .study-ss-row{position:relative;width:100%;box-sizing:border-box;display:grid;grid-template-columns:32px minmax(0,1fr) auto;grid-template-areas:"tile title time" "tile meta time";column-gap:12px;row-gap:2px;align-items:start;padding:8px;border:none;border-radius:var(--dsw-radius-xl,20px);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;white-space:normal}
2121
- .study-ss-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
2122
- .study-ss-row:focus-visible{outline:none}
2123
- .study-ss-row:focus-visible::after{content:"";position:absolute;inset:0;border-radius:inherit;outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
2124
- .study-ss-tile{grid-area:tile;align-self:center;width:32px;height:32px;display:grid;place-items:center;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary)}
2125
- .study-ss-title{grid-area:title;font-size:14px;line-height:20px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2126
- .study-ss-meta{grid-area:meta;display:flex;align-items:center;gap:6px;min-width:0;font-size:13px;line-height:18px;color:var(--dsw-alias-label-tertiary);flex-wrap:wrap}
2127
- .study-ss-workspace{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2128
- .study-ss-time{grid-area:time;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;margin-top:2px}
2129
- .study-ss-chip{display:inline-flex;align-items:center;height:18px;padding:0 7px;font-size:11px;line-height:17px;border:.5px solid var(--dsw-alias-border-l3);border-radius:999px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
2130
- @container (max-width:520px){.study-ss-row{grid-template-areas:"tile title title" "tile meta time"}.study-ss-time{margin-top:0}}
2131
- .study-ss-scan{display:flex;flex-direction:column;gap:10px}
2132
- .study-ss-status{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);margin:0}
2133
- .study-ss-track{height:2px;background:var(--dsw-alias-border-l3);border-radius:999px;overflow:hidden}
2134
- .study-ss-fill{height:100%;background:var(--dsw-alias-state-business-primary)}
2135
- .study-ss-error{display:flex;align-items:center;gap:8px;font-size:13px;line-height:20px;color:var(--dsw-alias-state-error-primary);margin:0}
2136
- .study-ss-dot{flex:none;width:6px;height:6px;border-radius:50%;background:currentColor}
2137
- .study-ss-empty{text-align:center;padding:48px 20px;font-size:14px;line-height:22px;color:var(--dsw-alias-label-tertiary);margin:0}
2138
- .study-ss-empty code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;padding:1px 6px}`;
2139
-
2140
- return {
2141
- inject: ['slots', 'remote', 'remote.commands', 'remote.workspaceFiles', 'configForms'],
2142
- apply(ctx) {
2143
- ctx.slots.inject(SLOT, () =>
2144
- ctx.slots.register({
2145
- name: SLOT,
2146
- id: 'nerd-mode',
2147
- order: -1,
2148
- registrant: 'dsh-nerd-mode',
2149
- inject: (sessionId) => ({
2150
- execute: (command) => ctx.remote.commands.execute(sessionId, command, []),
2151
- }),
2152
- }, NerdModeToggle),
2153
- );
2154
- ctx.inject(['locale'], (localeCtx) => {
2155
- ctx.slots.inject(SETTINGS_SLOT, () =>
2156
- ctx.slots.register({
2157
- name: SETTINGS_SLOT,
2158
- id: 'dsh-nerd-mode',
2159
- order: 30,
2160
- label: LABEL,
2161
- }, ({ close }) => React.createElement(NerdModeSettingsEntry, {
2162
- close,
2163
- configForms: ctx.configForms,
2164
- locale: localeCtx.locale,
2165
- })),
2166
- );
2167
- // One id addresses both seats: the sidebar row selects the keyed main page.
2168
- ctx.slots.inject('sidebar.panellist', () =>
2169
- ctx.slots.register({
2170
- name: 'sidebar.panellist',
2171
- id: STUDY_SESSIONS_ID,
2172
- order: STUDY_SESSIONS_ORDER,
2173
- // The label owns its translator: the slot's `t` prop belongs to the icon.
2174
- label: () => localeCtx.locale.resolveText(STUDY_SESSIONS_TITLE),
2175
- }, StudySessionsIcon),
2176
- );
2177
- ctx.slots.inject('main', () =>
2178
- ctx.slots.register({
2179
- name: 'main',
2180
- key: STUDY_SESSIONS_ID,
2181
- inject: () => ({
2182
- locale: localeCtx.locale,
2183
- openSession: (id) => {
2184
- try {
2185
- ctx.get('uiWorkspace')?.openSession(id);
2186
- } catch {
2187
- // A stale row must not take the panel down.
2188
- }
2189
- },
2190
- }),
2191
- }, (props) => React.createElement(StudySessionsPage, {
2192
- ...props,
2193
- useSessions: typeof props.useSessions === 'function' ? props.useSessions : NO_SESSIONS_HOOK,
2194
- })),
2195
- );
2196
- });
2197
- ctx.slots.inject(VIEW_SLOT, () =>
2198
- ctx.slots.register({
2199
- name: VIEW_SLOT,
2200
- id: VIEW_ID,
2201
- order: VIEW_ORDER,
2202
- label: VIEW_LABEL,
2203
- inject: (sessionId) => ({
2204
- sessionId,
2205
- workspaceFiles: ctx.remote.workspaceFiles,
2206
- }),
2207
- }, StudyView),
2208
- );
2209
- // A host without the right Sidebar never provides the registry, so this
2210
- // one seat waits. The rest of the plugin keeps working.
2211
- ctx.inject(['sidebarRightTabs'], (dock) => {
2212
- dock.effect(
2213
- () => dock.sidebarRightTabs.register(LECTURES_DEFINITION),
2214
- 'dsh-nerd-mode: lectures tab type',
2215
- );
2216
- });
2217
- ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
2218
- name: 'sidebar.right.pane.tab',
2219
- key: LECTURES_ID,
2220
- inject: (sessionId) => ({ sessionId, workspaceFiles: ctx.remote.workspaceFiles }),
2221
- }, LecturesPage));
2222
- },
2223
- };
2224
- },
2225
- });
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
+ const UPDATE_API = '/dsh-nerd-mode/api/update';
63
+ const CHECK_TIMEOUT = 10000;
64
+ // An install runs npm on the host, which can take minutes.
65
+ const INSTALL_TIMEOUT = 300000;
66
+
67
+ function useT(locale) {
68
+ React.useSyncExternalStore(
69
+ (listener) => locale.subscribe(listener),
70
+ () => locale.getSnapshot(),
71
+ () => locale.getSnapshot(),
72
+ );
73
+ return (text) => locale.resolveText(text);
74
+ }
75
+
76
+ const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
77
+
78
+ // Only a plain `major.minor.patch` pair can prove the local copy is newer.
79
+ function compareVersions(left, right) {
80
+ const first = VERSION.exec(String(left ?? ''));
81
+ const second = VERSION.exec(String(right ?? ''));
82
+ if (first === null || second === null) return 0;
83
+ for (let at = 1; at <= 3; at += 1) {
84
+ const difference = Number(first[at]) - Number(second[at]);
85
+ if (difference !== 0) return difference;
86
+ }
87
+ return 0;
88
+ }
89
+
90
+ function NerdModeSettingsEntry({ configForms, close, locale }) {
91
+ return React.createElement(
92
+ 'div',
93
+ { 'data-nerd-mode-settings': '', style: settingsColumnStyle },
94
+ React.createElement(NerdModeSettingsBody, { configForms, close, locale }),
95
+ React.createElement(AboutGroup, { locale }),
96
+ );
97
+ }
98
+
99
+ function NerdModeSettingsBody({ configForms, close, locale }) {
100
+ const t = useT(locale);
101
+ const description = configForms.describe();
102
+ const described = React.useSyncExternalStore(
103
+ (listener) => description.subscribe(listener),
104
+ () => description.getSnapshot(),
105
+ () => description.getSnapshot(),
106
+ );
107
+ const settingsExist = described.view?.namespaces.some(({ ns }) => ns === SETTINGS_NAMESPACE) === true;
108
+ if (!settingsExist) {
109
+ const loading = (described.status === 'idle' || described.status === 'loading') && !described.error;
110
+ return React.createElement(React.Fragment, null,
111
+ React.createElement('p', {
112
+ role: 'status',
113
+ 'aria-busy': loading,
114
+ style: feedbackStyle,
115
+ }, loading
116
+ ? t({ en: 'Loading Nerd Mode settings…', zh: '正在加载 Nerd Mode 设置…' })
117
+ : described.status === 'ready'
118
+ ? t({ en: 'Nerd Mode settings are unavailable in this profile.', zh: '此 profile 中没有 Nerd Mode 设置。' })
119
+ : t({ en: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.', zh: '无法加载 Nerd Mode 设置。请刷新设置窗口后重试。' })),
120
+ loading ? null : React.createElement('button', {
121
+ type: 'button',
122
+ onClick: close,
123
+ style: secondaryButtonStyle,
124
+ }, t({ en: 'Close', zh: '关闭' })),
125
+ );
126
+ }
127
+ return React.createElement(NerdModeSettings, {
128
+ close,
129
+ locale,
130
+ scope: configForms.get(SETTINGS_NAMESPACE),
131
+ });
132
+ }
133
+
134
+ function NerdModeSettings({ scope, close, locale }) {
135
+ const t = useT(locale);
136
+ const snapshot = React.useSyncExternalStore(
137
+ (listener) => scope.subscribe(listener),
138
+ () => scope.getSnapshot(),
139
+ () => scope.getSnapshot(),
140
+ );
141
+ const [draft, setDraft] = React.useState(() => snapshot?.status === 'ready' ? settingsValues(snapshot) : null);
142
+ const [saving, setSaving] = React.useState(false);
143
+ const [feedback, setFeedback] = React.useState(null);
144
+ React.useEffect(() => {
145
+ if (draft === null && snapshot?.status === 'ready') {
146
+ setDraft(settingsValues(snapshot));
147
+ }
148
+ }, [draft, snapshot?.status]);
149
+ if (snapshot?.status === 'loading' || snapshot?.status === 'idle' || snapshot?.status === 'ready' && draft === null) {
150
+ return React.createElement('p', {
151
+ role: 'status',
152
+ 'aria-busy': true,
153
+ style: feedbackStyle,
154
+ }, t({ en: 'Loading Nerd Mode settings…', zh: '正在加载 Nerd Mode 设置…' }));
155
+ }
156
+ if (snapshot?.status !== 'ready') {
157
+ return React.createElement(React.Fragment, null,
158
+ React.createElement('p', { role: 'status', style: feedbackStyle },
159
+ snapshot?.status === 'unavailable'
160
+ ? t({ en: 'Nerd Mode settings are unavailable in this profile.', zh: '此 profile 中没有 Nerd Mode 设置。' })
161
+ : t({ en: 'Nerd Mode settings could not be loaded. Refresh the Settings window and try again.', zh: '无法加载 Nerd Mode 设置。请刷新设置窗口后重试。' })),
162
+ React.createElement('button', {
163
+ type: 'button',
164
+ onClick: close,
165
+ style: secondaryButtonStyle,
166
+ }, t({ en: 'Close', zh: '关闭' })),
167
+ );
168
+ }
169
+ const current = settingsValues(snapshot);
170
+ const dirty = !sameSettings(draft, current);
171
+ const writable = snapshot.writable === true;
172
+ const updateDraft = (key, value) => {
173
+ setDraft((previous) => ({ ...previous, [key]: value }));
174
+ setFeedback(null);
175
+ };
176
+ const restoreDefaults = () => {
177
+ setDraft({
178
+ ...SETTINGS_DEFAULTS,
179
+ unseenQuestionCount: String(SETTINGS_DEFAULTS.unseenQuestionCount),
180
+ });
181
+ setFeedback(null);
182
+ };
183
+ const cancel = () => {
184
+ setDraft(settingsValues(scope.getSnapshot()));
185
+ setFeedback(null);
186
+ };
187
+ const save = async () => {
188
+ const latest = scope.getSnapshot();
189
+ if (latest?.status !== 'ready' || !validSettings(draft) || latest.writable !== true) return;
190
+ const operations = settingsOperations(draft, latest);
191
+ if (operations.length === 0) return;
192
+ setSaving(true);
193
+ setFeedback(null);
194
+ try {
195
+ const accepted = await scope.mutate(operations, latest.revision);
196
+ if (accepted === false) {
197
+ throw new Error(t({ en: 'The profile rejected these settings.', zh: 'profile 拒绝了这些设置。' }));
198
+ }
199
+ setDraft(settingsValues(scope.getSnapshot()));
200
+ setFeedback({
201
+ tone: 'status',
202
+ text: {
203
+ en: 'Saved to this profile. Changes apply to the next study-plus session.',
204
+ zh: '已保存到此 profile。更改将在下次 study-plus 会话中生效。',
205
+ },
206
+ });
207
+ } catch (error) {
208
+ setFeedback({
209
+ tone: 'alert',
210
+ text: {
211
+ en: `Could not save settings: ${error?.message ?? 'the profile rejected the changes'}`,
212
+ zh: `无法保存设置:${error?.message ?? 'profile 拒绝了这些更改'}`,
213
+ },
214
+ });
215
+ } finally {
216
+ setSaving(false);
217
+ }
218
+ };
219
+
220
+ return React.createElement(
221
+ React.Fragment,
222
+ null,
223
+ React.createElement(
224
+ 'header',
225
+ { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
226
+ React.createElement('h2', {
227
+ style: { fontSize: 15, fontWeight: 600, lineHeight: '22px', margin: 0 },
228
+ }, t({ en: 'Nerd Mode', zh: 'Nerd Mode' })),
229
+ React.createElement('p', {
230
+ style: {
231
+ color: 'var(--dsw-alias-label-tertiary)',
232
+ fontSize: 13,
233
+ lineHeight: '20px',
234
+ margin: 0,
235
+ },
236
+ }, t({ en: 'Profile defaults for new study-plus sessions.', zh: '新 study-plus 会话的 profile 默认值。' })),
237
+ ),
238
+ React.createElement(
239
+ 'section',
240
+ {
241
+ 'data-settings-group': 'recall-session',
242
+ style: groupStyle,
243
+ },
244
+ React.createElement(
245
+ 'div',
246
+ { style: { minWidth: 0 } },
247
+ React.createElement('h3', { style: groupTitleStyle }, t({ en: 'Recall Session', zh: '课前回忆' })),
248
+ React.createElement('p', { style: groupDescriptionStyle }, t({ en: 'One ungraded recall question before teaching.', zh: '讲授前的一次不计分课前回忆。' })),
249
+ ),
250
+ React.createElement(
251
+ 'button',
252
+ {
253
+ type: 'button',
254
+ role: 'switch',
255
+ 'aria-checked': draft.recallSessionEnabled,
256
+ 'aria-label': t({ en: 'Recall Session', zh: '课前回忆' }),
257
+ 'data-setting': 'recallSessionEnabled',
258
+ 'data-state': draft.recallSessionEnabled ? 'on' : 'off',
259
+ disabled: !writable || saving,
260
+ onClick: () => updateDraft('recallSessionEnabled', !draft.recallSessionEnabled),
261
+ style: switchStyle(draft.recallSessionEnabled, !writable || saving),
262
+ },
263
+ React.createElement('span', null, draft.recallSessionEnabled
264
+ ? t({ en: 'On', zh: '开' })
265
+ : t({ en: 'Off', zh: '关' })),
266
+ React.createElement('span', {
267
+ 'aria-hidden': 'true',
268
+ style: {
269
+ background: 'currentColor',
270
+ borderRadius: '50%',
271
+ height: 8,
272
+ opacity: draft.recallSessionEnabled ? 1 : 0.5,
273
+ width: 8,
274
+ },
275
+ }),
276
+ ),
277
+ ),
278
+ React.createElement(
279
+ 'section',
280
+ {
281
+ 'data-settings-group': 'post-lecture-questions',
282
+ // The field grid is taller than the text block, so this row aligns to
283
+ // the top. Centering floats the title into the middle of the card.
284
+ style: { ...groupStyle, alignItems: 'flex-start' },
285
+ },
286
+ React.createElement(
287
+ 'div',
288
+ null,
289
+ React.createElement('h3', { style: groupTitleStyle }, t({ en: 'Post-lecture questions', zh: '课后提问' })),
290
+ React.createElement('p', { style: groupDescriptionStyle }, t({ en: 'Choose how many unseen questions follow each lecture.', zh: '选择每节课后出几道题。' })),
291
+ ),
292
+ React.createElement(
293
+ 'div',
294
+ { style: { display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' } },
295
+ React.createElement(
296
+ 'label',
297
+ { htmlFor: 'nerd-mode-question-count', style: fieldLabelStyle },
298
+ React.createElement('span', null, t({ en: 'Question count', zh: '题数' })),
299
+ React.createElement('input', {
300
+ id: 'nerd-mode-question-count',
301
+ type: 'number',
302
+ min: 0,
303
+ max: 10,
304
+ step: 1,
305
+ inputMode: 'numeric',
306
+ value: draft.unseenQuestionCount,
307
+ 'data-setting': 'unseenQuestionCount',
308
+ disabled: !writable || saving,
309
+ onChange: (event) => updateDraft('unseenQuestionCount', event.target.value),
310
+ style: fieldControlStyle,
311
+ }),
312
+ React.createElement('span', { style: fieldHintStyle }, t({ en: 'Use 0 to skip unseen questions.', zh: '设为 0 可跳过课后提问。' })),
313
+ ),
314
+ React.createElement(
315
+ 'label',
316
+ { htmlFor: 'nerd-mode-question-type', style: fieldLabelStyle },
317
+ React.createElement('span', null, t({ en: 'Question type', zh: '题型' })),
318
+ React.createElement(
319
+ 'select',
320
+ {
321
+ id: 'nerd-mode-question-type',
322
+ value: draft.unseenQuestionType,
323
+ 'data-setting': 'unseenQuestionType',
324
+ disabled: !writable || saving,
325
+ onChange: (event) => updateDraft('unseenQuestionType', event.target.value),
326
+ style: fieldControlStyle,
327
+ },
328
+ React.createElement('option', { value: 'short-answer' }, t({ en: 'Short answer', zh: '简答' })),
329
+ React.createElement('option', { value: 'multiple-choice' }, t({ en: 'Multiple choice', zh: '四选一' })),
330
+ React.createElement('option', { value: 'mixed' }, t({ en: 'Mixed', zh: '混合' })),
331
+ ),
332
+ React.createElement('span', { style: fieldHintStyle }, t({ en: 'Mixed chooses a type for each question.', zh: '混合会为每道题随机选题型。' })),
333
+ ),
334
+ ),
335
+ ),
336
+ React.createElement(
337
+ 'footer',
338
+ {
339
+ style: {
340
+ alignItems: 'center',
341
+ borderTop: '1px solid var(--dsw-alias-border-l2)',
342
+ display: 'flex',
343
+ flexWrap: 'wrap',
344
+ gap: 12,
345
+ justifyContent: 'space-between',
346
+ paddingTop: 24,
347
+ },
348
+ },
349
+ React.createElement('button', {
350
+ type: 'button',
351
+ disabled: !writable || saving,
352
+ onClick: restoreDefaults,
353
+ style: secondaryButtonStyle,
354
+ }, t({ en: 'Restore defaults', zh: '恢复默认' })),
355
+ React.createElement(
356
+ 'div',
357
+ { style: { display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' } },
358
+ React.createElement('button', {
359
+ type: 'button',
360
+ disabled: !dirty || saving,
361
+ onClick: cancel,
362
+ style: secondaryButtonStyle,
363
+ }, t({ en: 'Cancel', zh: '取消' })),
364
+ React.createElement('button', {
365
+ type: 'button',
366
+ disabled: !dirty || !validSettings(draft) || !writable || saving,
367
+ onClick: save,
368
+ style: primaryButtonStyle,
369
+ }, saving ? t({ en: 'Saving…', zh: '保存中…' }) : t({ en: 'Save', zh: '保存' })),
370
+ ),
371
+ ),
372
+ snapshot?.writable === false
373
+ ? React.createElement('p', { role: 'status', style: feedbackStyle }, t({ en: 'These profile settings are read-only.', zh: '这些 profile 设置为只读。' }))
374
+ : null,
375
+ feedback === null
376
+ ? null
377
+ : React.createElement('p', {
378
+ role: feedback.tone,
379
+ style: feedbackStyle,
380
+ }, t(feedback.text)),
381
+ );
382
+ }
383
+
384
+ function AboutGroup({ locale }) {
385
+ const t = useT(locale);
386
+ const [life] = React.useState(() => ({ cancelled: false, controller: null }));
387
+ const [view, setView] = React.useState({ phase: 'checking' });
388
+
389
+ // An answer that arrives after the panel closed writes nothing.
390
+ const show = (next) => {
391
+ if (!life.cancelled) setView(next);
392
+ };
393
+
394
+ // The newest request is the one unmount aborts; the buttons prevent a
395
+ // second request while one runs.
396
+ async function call(url, options, timeout, timeoutText) {
397
+ const controller = new AbortController();
398
+ life.controller = controller;
399
+ const timer = setTimeout(() => controller.abort(), timeout);
400
+ try {
401
+ const response = await fetch(url, {
402
+ credentials: 'same-origin',
403
+ ...options,
404
+ signal: controller.signal,
405
+ });
406
+ const body = await response.json().catch(() => null);
407
+ return { ok: response.ok, status: response.status, body: body ?? {} };
408
+ } catch (error) {
409
+ if (controller.signal.aborted) throw new Error(timeoutText);
410
+ throw error;
411
+ } finally {
412
+ clearTimeout(timer);
413
+ if (life.controller === controller) life.controller = null;
414
+ }
415
+ }
416
+
417
+ async function check(force) {
418
+ setView({ phase: 'checking' });
419
+ try {
420
+ const result = await call(
421
+ `${UPDATE_API}${force ? '?force=1' : ''}`,
422
+ {},
423
+ CHECK_TIMEOUT,
424
+ t({ en: 'The update check did not answer within 10 seconds.', zh: '更新检查在 10 秒内没有响应。' }),
425
+ );
426
+ const body = result.body;
427
+ // A 404 is the host still running the previous build: the route is
428
+ // registered at startup, so only a restart can add it.
429
+ if (result.status === 404) {
430
+ show({
431
+ phase: 'failed',
432
+ info: null,
433
+ installedVersion: null,
434
+ restartNeeded: true,
435
+ message: t({
436
+ en: 'This copy of DSH has not loaded the update route. Restart DSH.',
437
+ zh: '这个 DSH 还没有加载更新接口。请重启 DSH。',
438
+ }),
439
+ });
440
+ return;
441
+ }
442
+ const failure = typeof body.error === 'string' ? body.error : null;
443
+ if (!result.ok || failure !== null) {
444
+ throw new Error(failure ?? t({
445
+ en: `The update check failed (HTTP ${result.status}).`,
446
+ zh: `更新检查失败(HTTP ${result.status})。`,
447
+ }));
448
+ }
449
+ const installedVersion = String(body.installedVersion ?? '');
450
+ if (installedVersion === '') {
451
+ throw new Error(t({ en: 'The host returned no installed version.', zh: '主机没有返回已安装版本。' }));
452
+ }
453
+ show({ phase: 'ready', info: body, installedVersion });
454
+ } catch (error) {
455
+ show({
456
+ phase: 'failed',
457
+ info: null,
458
+ installedVersion: null,
459
+ message: error?.message || t({ en: 'The update check failed.', zh: '更新检查失败。' }),
460
+ });
461
+ }
462
+ }
463
+
464
+ async function install(current) {
465
+ const version = String(current.info.latestVersion ?? '');
466
+ setView({ ...current, phase: 'installing' });
467
+ try {
468
+ const result = await call(UPDATE_API, {
469
+ method: 'POST',
470
+ headers: { 'content-type': 'application/json' },
471
+ body: JSON.stringify({ version, channel: current.info.channel }),
472
+ }, INSTALL_TIMEOUT, t({ en: 'The update did not finish within 5 minutes.', zh: '更新在 5 分钟内没有完成。' }));
473
+ const body = result.body;
474
+ const failure = typeof body.error === 'string' ? body.error : null;
475
+ if (!result.ok || failure !== null) {
476
+ throw new Error(failure ?? t({
477
+ en: `The update failed (HTTP ${result.status}).`,
478
+ zh: `更新失败(HTTP ${result.status})。`,
479
+ }));
480
+ }
481
+ show({ ...current, phase: 'installed', installedVersion: String(body.installedVersion ?? version) });
482
+ } catch (error) {
483
+ show({
484
+ phase: 'failed',
485
+ info: current.info,
486
+ installedVersion: current.installedVersion,
487
+ message: error?.message || t({ en: 'The update failed.', zh: '更新失败。' }),
488
+ });
489
+ }
490
+ }
491
+
492
+ React.useEffect(() => {
493
+ check(false);
494
+ return () => {
495
+ life.cancelled = true;
496
+ if (life.controller !== null) life.controller.abort();
497
+ };
498
+ }, []);
499
+
500
+ const busy = view.phase === 'checking' || view.phase === 'installing';
501
+ const button = (text, onClick, disabled = false, primary = false) => React.createElement('button', {
502
+ type: 'button',
503
+ disabled,
504
+ onClick,
505
+ style: primary ? primaryButtonStyle : secondaryButtonStyle,
506
+ }, text);
507
+ const checkAgain = () => button(t({ en: 'Check again', zh: '重新检查' }), () => check(true));
508
+ const versions = () => t({
509
+ en: `Installed v${view.installedVersion} · latest v${view.info.latestVersion}`,
510
+ zh: `已安装 v${view.installedVersion} · 最新 v${view.info.latestVersion}`,
511
+ });
512
+
513
+ let versionLine = null;
514
+ const feedback = [];
515
+ let actions = null;
516
+
517
+ if (view.phase === 'checking') {
518
+ versionLine = t({ en: 'Checking…', zh: '正在检查…' });
519
+ } else if (view.phase === 'failed') {
520
+ if (view.info !== null) versionLine = versions();
521
+ feedback.push(view.message);
522
+ if (view.restartNeeded !== true) {
523
+ feedback.push(' ');
524
+ feedback.push(t({ en: 'Open Settings → Plugins to update there.', zh: '请在 设置 → 插件 中更新。' }));
525
+ }
526
+ actions = [checkAgain()];
527
+ } else if (view.phase === 'installed') {
528
+ versionLine = t({ en: `Installed v${view.installedVersion}`, zh: `已安装 v${view.installedVersion}` });
529
+ feedback.push(t({ en: `Restart DSH to use v${view.installedVersion}.`, zh: `重启 DSH 以使用 v${view.installedVersion}。` }));
530
+ } else if (view.info.source === 'link') {
531
+ versionLine = t({ en: `Local v${view.installedVersion}`, zh: `本地 v${view.installedVersion}` });
532
+ actions = [button(t({ en: 'About local installs', zh: '关于本地安装' }), () => {
533
+ setView({ ...view, note: view.note !== true });
534
+ })];
535
+ if (view.note === true) {
536
+ feedback.push(t({ en: 'This copy is linked to a folder. Update it with git, not npm.', zh: '此副本链接到本地文件夹。请用 git 更新,不要用 npm。' }));
537
+ }
538
+ } else if (view.info.source === 'github' && view.info.github?.pinned === true) {
539
+ versionLine = t({ en: `Installed v${view.installedVersion}`, zh: `已安装 v${view.installedVersion}` });
540
+ feedback.push(t({
541
+ en: `Pinned to ${view.info.github.ref}. No update check.`,
542
+ zh: `已固定到 ${view.info.github.ref},不检查更新。`,
543
+ }));
544
+ } else if (view.info.source === 'github' && view.info.github?.official !== true) {
545
+ versionLine = t({ en: `Installed v${view.installedVersion}`, zh: `已安装 v${view.installedVersion}` });
546
+ feedback.push(view.info.github?.repo == null
547
+ ? t({ en: 'This copy comes from a repository that cannot be checked.', zh: '这个副本来自一个无法检查的仓库。' })
548
+ : t({
549
+ en: `Installed from ${view.info.github.repo}. Updates are checked against the official repository only.`,
550
+ zh: `来自 ${view.info.github.repo}。更新只查官方仓库。`,
551
+ }));
552
+ } else if (view.phase === 'installing') {
553
+ versionLine = versions();
554
+ feedback.push(t({ en: `Installing v${view.info.latestVersion}…`, zh: `正在安装 v${view.info.latestVersion}…` }));
555
+ actions = [button(t({ en: 'Installing…', zh: '正在安装…' }), () => {}, true)];
556
+ } else if (view.phase === 'confirming') {
557
+ versionLine = versions();
558
+ const from = view.info.channel === 'github' ? 'GitHub' : 'npm';
559
+ feedback.push(t({
560
+ en: `Install v${view.info.latestVersion} from ${from} and restart DSH afterwards?`,
561
+ zh: `从 ${from} 安装 v${view.info.latestVersion},并在之后重启 DSH?`,
562
+ }));
563
+ actions = [
564
+ button(t({ en: 'Cancel', zh: '取消' }), () => setView({ ...view, phase: 'ready' })),
565
+ button(t({ en: 'Update', zh: '更新' }), () => install(view), false, true),
566
+ ];
567
+ } else if (typeof view.info.latestVersion !== 'string') {
568
+ // A host without a latest version cannot offer an update, but it still checked.
569
+ versionLine = t({ en: `Installed v${view.installedVersion}`, zh: `已安装 v${view.installedVersion}` });
570
+ actions = [checkAgain()];
571
+ } else if (compareVersions(view.installedVersion, view.info.latestVersion) > 0) {
572
+ versionLine = t({
573
+ en: `Local v${view.installedVersion} · npm v${view.info.latestVersion}`,
574
+ zh: `本地 v${view.installedVersion} · npm v${view.info.latestVersion}`,
575
+ });
576
+ feedback.push(t({ en: 'This copy is newer than npm. No update.', zh: '此副本比 npm 上的版本更新,无需更新。' }));
577
+ actions = [checkAgain()];
578
+ } else if (compareVersions(view.installedVersion, view.info.latestVersion) < 0) {
579
+ versionLine = versions();
580
+ actions = [button(
581
+ t({ en: `Update to v${view.info.latestVersion}`, zh: `更新到 v${view.info.latestVersion}` }),
582
+ () => setView({ ...view, phase: 'confirming' }),
583
+ false,
584
+ true,
585
+ )];
586
+ } else {
587
+ versionLine = versions();
588
+ actions = [checkAgain()];
589
+ }
590
+
591
+ return React.createElement(
592
+ 'div',
593
+ { 'data-settings-group': 'about', style: { display: 'flex', flexDirection: 'column', gap: 8 } },
594
+ React.createElement(
595
+ 'div',
596
+ { style: groupStyle },
597
+ React.createElement(
598
+ 'div',
599
+ { style: { minWidth: 0 } },
600
+ React.createElement('h3', { style: groupTitleStyle }, t({ en: 'About', zh: '关于' })),
601
+ versionLine === null ? null : React.createElement('p', { style: groupDescriptionStyle }, versionLine),
602
+ ),
603
+ actions === null ? null : React.createElement(
604
+ 'div',
605
+ { style: { alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' } },
606
+ ...actions,
607
+ ),
608
+ ),
609
+ React.createElement('p', {
610
+ role: view.phase === 'failed' ? 'alert' : 'status',
611
+ 'aria-busy': busy,
612
+ style: feedbackStyle,
613
+ }, ...feedback),
614
+ );
615
+ }
616
+
617
+ const settingsColumnStyle = {
618
+ boxSizing: 'border-box',
619
+ color: 'var(--dsw-alias-label-primary)',
620
+ display: 'flex',
621
+ flexDirection: 'column',
622
+ fontFamily: 'var(--dsw-font-family, inherit)',
623
+ gap: 24,
624
+ maxWidth: 720,
625
+ paddingBottom: 24,
626
+ width: '100%',
627
+ };
628
+
629
+ const groupStyle = {
630
+ alignItems: 'center',
631
+ background: 'var(--dsw-alias-bg-layer-1)',
632
+ border: '1px solid var(--dsw-alias-border-l2)',
633
+ borderRadius: 10,
634
+ display: 'flex',
635
+ flexWrap: 'wrap',
636
+ gap: 16,
637
+ justifyContent: 'space-between',
638
+ padding: 24,
639
+ };
640
+ const groupTitleStyle = {
641
+ fontSize: 15,
642
+ fontWeight: 600,
643
+ lineHeight: '22px',
644
+ margin: 0,
645
+ };
646
+ const groupDescriptionStyle = {
647
+ color: 'var(--dsw-alias-label-tertiary)',
648
+ fontSize: 13,
649
+ lineHeight: '20px',
650
+ margin: '4px 0 0',
651
+ };
652
+ const fieldLabelStyle = {
653
+ display: 'flex',
654
+ flexDirection: 'column',
655
+ fontSize: 13,
656
+ fontWeight: 500,
657
+ gap: 8,
658
+ minWidth: 0,
659
+ };
660
+ const fieldControlStyle = {
661
+ background: 'var(--dsw-alias-bg-layer-1)',
662
+ border: '1px solid var(--dsw-alias-border-l3)',
663
+ borderRadius: 6,
664
+ boxSizing: 'border-box',
665
+ color: 'var(--dsw-alias-label-primary)',
666
+ font: 'inherit',
667
+ fontSize: 13,
668
+ height: 32,
669
+ minWidth: 0,
670
+ padding: '0 10px',
671
+ width: '100%',
672
+ };
673
+ const fieldHintStyle = {
674
+ color: 'var(--dsw-alias-label-tertiary)',
675
+ fontSize: 12,
676
+ fontWeight: 400,
677
+ lineHeight: '18px',
678
+ };
679
+ const secondaryButtonStyle = {
680
+ background: 'transparent',
681
+ border: '1px solid var(--dsw-alias-border-l3)',
682
+ borderRadius: 10,
683
+ color: 'var(--dsw-alias-label-primary)',
684
+ cursor: 'pointer',
685
+ font: 'inherit',
686
+ fontSize: 13,
687
+ fontWeight: 500,
688
+ minHeight: 32,
689
+ padding: '0 12px',
690
+ };
691
+ const primaryButtonStyle = {
692
+ ...secondaryButtonStyle,
693
+ background: 'var(--dsw-alias-bg-layer-1)',
694
+ borderColor: 'var(--dsw-alias-state-business-primary)',
695
+ color: 'var(--dsw-alias-state-business-primary)',
696
+ fontWeight: 600,
697
+ };
698
+ const feedbackStyle = {
699
+ color: 'var(--dsw-alias-label-secondary)',
700
+ fontSize: 13,
701
+ lineHeight: '20px',
702
+ margin: 0,
703
+ };
704
+
705
+ function switchStyle(active, disabled) {
706
+ const accent = 'var(--dsw-alias-state-business-primary)';
707
+ return {
708
+ alignItems: 'center',
709
+ background: active ? 'var(--dsw-alias-state-business-tertiary)' : 'var(--dsw-alias-bg-layer-2)',
710
+ border: `1px solid ${active ? accent : 'var(--dsw-alias-border-l3)'}`,
711
+ borderRadius: 10,
712
+ color: active ? accent : 'var(--dsw-alias-label-secondary)',
713
+ cursor: disabled ? 'not-allowed' : 'pointer',
714
+ display: 'inline-flex',
715
+ flexShrink: 0,
716
+ font: 'inherit',
717
+ fontSize: 13,
718
+ gap: 8,
719
+ justifyContent: 'center',
720
+ marginLeft: 'auto',
721
+ minHeight: 32,
722
+ minWidth: 76,
723
+ padding: '0 12px',
724
+ transition: 'background 120ms ease, border-color 120ms ease, color 120ms ease',
725
+ };
726
+ }
727
+
728
+ // 16px book, same footprint as the Full Throttle bolt.
729
+ const BOOK = React.createElement(
730
+ 'svg',
731
+ {
732
+ 'aria-hidden': 'true',
733
+ fill: 'none',
734
+ height: 16,
735
+ stroke: 'currentColor',
736
+ strokeLinecap: 'round',
737
+ strokeLinejoin: 'round',
738
+ strokeWidth: 1.6,
739
+ viewBox: '0 0 16 16',
740
+ width: 16,
741
+ },
742
+ React.createElement('path', {
743
+ 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',
744
+ }),
745
+ React.createElement('path', {
746
+ 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',
747
+ }),
748
+ );
749
+
750
+ // The theme owns the colors; only the state picks which alias to read.
751
+ function pillStyle(active) {
752
+ if (active) {
753
+ const business = 'var(--dsw-alias-state-business-primary)';
754
+ return {
755
+ background: `color-mix(in srgb, ${business} 16%, transparent)`,
756
+ boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${business} 55%, transparent)`,
757
+ color: business,
758
+ };
759
+ }
760
+ return {
761
+ background: 'var(--dsw-alias-fill-tsp-secondary)',
762
+ boxShadow: 'none',
763
+ color: 'var(--dsw-alias-label-tertiary)',
764
+ opacity: 0.65,
765
+ };
766
+ }
767
+
768
+ // Unknown commands resolve as { ok: true, value: undefined }; they never
769
+ // reach the command executor and write nothing to the session log.
770
+ function failureText(result) {
771
+ if (!result?.ok) {
772
+ return `Nerd Mode did not switch: ${result?.error?.message ?? 'the host rejected the command'}`;
773
+ }
774
+ if (result.value == null) {
775
+ return 'Nerd Mode did not switch: /nerd-mode is not available in this build.';
776
+ }
777
+ const outcome = result.value.result;
778
+ if (outcome?.kind === 'error') {
779
+ return `Nerd Mode did not switch: ${outcome.text ?? 'the command failed'}`;
780
+ }
781
+ return null;
782
+ }
783
+
784
+ function NerdModeToggle({ useProjection, execute }) {
785
+ const state = useProjection('studyMode');
786
+ const active = state?.active === true;
787
+ const [busy, setBusy] = React.useState(false);
788
+ const [failure, setFailure] = React.useState(null);
789
+ const [toast, setToast] = React.useState(null);
790
+
791
+ React.useEffect(() => {
792
+ setFailure(null);
793
+ setToast(null);
794
+ }, [active]);
795
+
796
+ const onClick = async () => {
797
+ if (busy) return;
798
+ setBusy(true);
799
+ setFailure(null);
800
+ setToast(null);
801
+ try {
802
+ const result = await execute(active ? '/nerd-mode off' : '/nerd-mode on');
803
+ const text = failureText(result);
804
+ if (text !== null) {
805
+ setFailure(text);
806
+ setToast({ id: Date.now(), text });
807
+ }
808
+ } catch (error) {
809
+ const text = failureText({ ok: false, error });
810
+ setFailure(text);
811
+ setToast({ id: Date.now(), text });
812
+ } finally {
813
+ setBusy(false);
814
+ }
815
+ };
816
+
817
+ return React.createElement(
818
+ React.Fragment,
819
+ null,
820
+ React.createElement(
821
+ 'button',
822
+ {
823
+ type: 'button',
824
+ role: 'switch',
825
+ 'aria-checked': active,
826
+ 'aria-label': LABEL,
827
+ 'data-state': active ? 'on' : 'off',
828
+ title: failure ?? (active ? 'Nerd Mode is on' : 'Nerd Mode is off'),
829
+ disabled: busy,
830
+ onMouseDown: (event) => event.preventDefault(),
831
+ onClick,
832
+ style: {
833
+ alignItems: 'center',
834
+ border: 'none',
835
+ borderRadius: 'var(--dsw-radius-sm)',
836
+ cursor: busy ? 'progress' : 'pointer',
837
+ display: 'inline-flex',
838
+ fontFamily: 'inherit',
839
+ fontSize: 13,
840
+ fontWeight: 500,
841
+ gap: 6,
842
+ height: 28,
843
+ lineHeight: '20px',
844
+ padding: '0 8px',
845
+ transition: 'background 120ms ease, color 120ms ease',
846
+ ...pillStyle(active),
847
+ },
848
+ },
849
+ BOOK,
850
+ React.createElement('span', null, LABEL),
851
+ ),
852
+ toast === null
853
+ ? null
854
+ : React.createElement(Toast, {
855
+ key: toast.id,
856
+ text: toast.text,
857
+ onDone: () => setToast(null),
858
+ }),
859
+ );
860
+ }
861
+
862
+ // The conversation view bar, driven by the entries in `conversation.view`.
863
+ // Live build orders: chat 0, trajectory 10, context 20, fleet 30.
864
+ const VIEW_SLOT = 'conversation.view';
865
+ const VIEW_ID = 'study';
866
+ const VIEW_LABEL = { en: 'Study', zh: '学习' };
867
+ const VIEW_ORDER = 100;
868
+ const TRACKER_ROOT = 'study';
869
+ // One selection shared by the Study tab and the Lectures tab. This is a
870
+ // classic script, so a module-level binding is the whole mechanism.
871
+ let activeCourse = null;
872
+ const DASH = '—';
873
+
874
+ /** Return the Course both tabs show: `activeCourse` while it exists, otherwise the first of the name-sorted `courses`. */
875
+ function pickCourse(courses) {
876
+ return activeCourse !== null && courses.includes(activeCourse) ? activeCourse : courses[0] ?? null;
877
+ }
878
+
879
+ // --- Tracker parsing -----------------------------------------------------
880
+ // Parsing lives here because the client bundle is a classic script: a
881
+ // relative require throws and an export statement is invalid.
882
+
883
+ function splitRow(line) {
884
+ return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
885
+ }
886
+
887
+ function tableBlocks(text) {
888
+ const blocks = [];
889
+ let current = null;
890
+ for (const line of String(text ?? '').split(/\r?\n/)) {
891
+ if (!line.trim().startsWith('|')) {
892
+ current = null;
893
+ continue;
894
+ }
895
+ const cells = splitRow(line);
896
+ if (cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell))) continue;
897
+ if (current === null) {
898
+ current = { header: cells, rows: [] };
899
+ blocks.push(current);
900
+ } else {
901
+ current.rows.push(cells);
902
+ }
903
+ }
904
+ return blocks;
905
+ }
906
+
907
+ const columnOf = (block, name) => block.header
908
+ .findIndex((cell) => cell.toLowerCase() === name.toLowerCase());
909
+
910
+ function parseDate(value) {
911
+ const match = /^\d{4}-\d{2}-\d{2}/.exec(String(value ?? '').trim());
912
+ return match ? match[0] : null;
913
+ }
914
+
915
+ function parseGraph(text) {
916
+ const lines = String(text ?? '').split(/\r?\n/);
917
+ let topic = null;
918
+ let book = null;
919
+ const chapters = new Map();
920
+ let inChapters = false;
921
+ for (const line of lines) {
922
+ const trimmed = line.trim();
923
+ // A new graph says Course and an older graph says Topic; both keep their title.
924
+ const topicMatch = /^#\s*(?:Course|Topic):\s*(.+)$/i.exec(trimmed);
925
+ if (topicMatch) topic = topicMatch[1].trim();
926
+ const bookMatch = /^Book:\s*(.+)$/i.exec(trimmed);
927
+ if (bookMatch) book = bookMatch[1].trim();
928
+ if (/^##\s*Chapters\s*$/i.test(trimmed)) {
929
+ inChapters = true;
930
+ continue;
931
+ }
932
+ if (!inChapters) continue;
933
+ const chapterRow = /^(\d+)\.\s*(.+?)(?::.*)?$/.exec(trimmed);
934
+ if (chapterRow) chapters.set(Number(chapterRow[1]), chapterRow[2].trim());
935
+ else if (trimmed !== '') inChapters = false;
936
+ }
937
+
938
+ const table = tableBlocks(text).find((block) => columnOf(block, 'Concept') !== -1);
939
+ const concepts = [];
940
+ let malformed = 0;
941
+ if (table) {
942
+ const nameAt = columnOf(table, 'Concept');
943
+ const chapterAt = columnOf(table, 'Chapter');
944
+ const sectionAt = columnOf(table, 'Section');
945
+ table.rows.forEach((cells, position) => {
946
+ if (cells.length !== table.header.length || cells[nameAt] === '') {
947
+ malformed += 1;
948
+ return;
949
+ }
950
+ const chapter = chapterAt === -1 ? 1 : Number.parseInt(cells[chapterAt], 10);
951
+ const section = sectionAt === -1 ? concepts.length + 1 : Number.parseInt(cells[sectionAt], 10);
952
+ concepts.push({
953
+ name: cells[nameAt],
954
+ chapter: Number.isInteger(chapter) && chapter > 0 ? chapter : 1,
955
+ section: Number.isInteger(section) && section > 0 ? section : position + 1,
956
+ order: position,
957
+ });
958
+ });
959
+ }
960
+ return { topic, book, chapters, concepts, malformed };
961
+ }
962
+
963
+ function parseState(text) {
964
+ const table = tableBlocks(text).find((block) => columnOf(block, 'State') !== -1);
965
+ const states = new Map();
966
+ let malformed = 0;
967
+ if (!table) return { states, malformed };
968
+ const nameAt = columnOf(table, 'Concept');
969
+ const stateAt = columnOf(table, 'State');
970
+ const dateAt = columnOf(table, 'Last evidence');
971
+ for (const cells of table.rows) {
972
+ if (cells.length !== table.header.length || cells[nameAt] === '') {
973
+ malformed += 1;
974
+ continue;
975
+ }
976
+ states.set(cells[nameAt], {
977
+ state: cells[stateAt].toLowerCase(),
978
+ date: dateAt === -1 ? null : parseDate(cells[dateAt]),
979
+ });
980
+ }
981
+ return { states, malformed };
982
+ }
983
+
984
+ function parseLog(text) {
985
+ const blocks = [];
986
+ let current = null;
987
+ for (const line of String(text ?? '').split(/\r?\n/)) {
988
+ const heading = /^##\s+(.+)$/.exec(line.trim());
989
+ if (heading) {
990
+ current = { date: heading[1].trim(), concept: null, result: null };
991
+ blocks.push(current);
992
+ continue;
993
+ }
994
+ if (current === null) continue;
995
+ const field = /^(Concept|Result):\s*(.*)$/i.exec(line.trim());
996
+ if (!field) continue;
997
+ if (field[1].toLowerCase() === 'concept') current.concept = field[2].trim() || null;
998
+ else current.result = field[2].trim() || null;
999
+ }
1000
+ return blocks.filter((block) => block.concept !== null || block.result !== null);
1001
+ }
1002
+
1003
+ function buildSnapshot(topic, graphText, stateText, logText) {
1004
+ const graph = parseGraph(graphText);
1005
+ const state = parseState(stateText);
1006
+ const log = parseLog(logText);
1007
+ const chapterTitle = (chapter) => graph.chapters.get(chapter) ?? graph.book ?? graph.topic ?? topic;
1008
+
1009
+ const concepts = graph.concepts
1010
+ .map((concept) => {
1011
+ const row = state.states.get(concept.name) ?? { state: 'new', date: null };
1012
+ return { ...concept, ...row, chapterTitle: chapterTitle(concept.chapter) };
1013
+ })
1014
+ .sort((left, right) => left.chapter - right.chapter
1015
+ || left.section - right.section
1016
+ || left.order - right.order);
1017
+ concepts.forEach((concept, position) => { concept.position = position; });
1018
+
1019
+ const skipped = concepts.filter((concept) => concept.state === 'skipped').length;
1020
+ const fluent = concepts.filter((concept) => concept.state === 'fluent').length;
1021
+ const denominator = concepts.length - skipped;
1022
+ const percentage = denominator > 0 ? Math.floor((fluent / denominator) * 100) : null;
1023
+
1024
+ const current = concepts
1025
+ .filter((concept) => concept.date !== null)
1026
+ .sort((left, right) => right.date.localeCompare(left.date))[0] ?? null;
1027
+ const previous = current === null ? null : concepts[current.position - 1] ?? null;
1028
+ const remaining = current === null ? concepts : concepts.slice(current.position + 1);
1029
+ const next = remaining.find((concept) => concept.state !== 'fluent' && concept.state !== 'skipped') ?? null;
1030
+ const nextState = next !== null
1031
+ ? 'concept'
1032
+ : remaining.some((concept) => concept.state === 'skipped') ? 'all-skipped' : 'end';
1033
+
1034
+ const shaky = concepts
1035
+ .filter((concept) => concept.state === 'shaky')
1036
+ .sort((left, right) => (right.date ?? '').localeCompare(left.date ?? ''));
1037
+
1038
+ return {
1039
+ topic,
1040
+ bookTitle: graph.book ?? graph.topic ?? topic,
1041
+ total: concepts.length,
1042
+ skipped,
1043
+ fluent,
1044
+ denominator,
1045
+ percentage,
1046
+ concepts,
1047
+ current,
1048
+ previous,
1049
+ next,
1050
+ nextState,
1051
+ shaky,
1052
+ shakyTotal: shaky.length,
1053
+ cycles: log.slice(-3).reverse().map((block) => ({
1054
+ date: block.date,
1055
+ label: block.result ?? block.concept ?? '',
1056
+ })),
1057
+ malformed: graph.malformed + state.malformed,
1058
+ };
1059
+ }
1060
+
1061
+ // --- Tracker reads -------------------------------------------------------
1062
+
1063
+ async function readText(files, sessionId, path) {
1064
+ const result = await files.read(sessionId, path, {});
1065
+ if (result.ok) return result.value?.text ?? '';
1066
+ // A missing file is an empty input, not a failure.
1067
+ if (result.error?.code === 'workspace-file/not-found') return '';
1068
+ throw new Error(result.error?.message ?? `${path} could not be read`);
1069
+ }
1070
+
1071
+ async function loadTopic(files, sessionId, topic, stateText) {
1072
+ const base = `${TRACKER_ROOT}/${topic}`;
1073
+ const [state, graph, log] = await Promise.all([
1074
+ stateText === undefined ? readText(files, sessionId, `${base}/state.md`) : stateText,
1075
+ readText(files, sessionId, `${base}/graph.md`),
1076
+ readText(files, sessionId, `${base}/log.md`),
1077
+ ]);
1078
+ const snapshot = buildSnapshot(topic, graph, state, log);
1079
+ return snapshot.total === 0 ? { status: 'empty', topic } : { status: 'ready', snapshot };
1080
+ }
1081
+
1082
+ // Both tabs open on the shared Course, then on the first Course by name.
1083
+ async function discoverTopics(files, sessionId) {
1084
+ const listing = await files.list(sessionId, TRACKER_ROOT);
1085
+ if (!listing.ok) {
1086
+ if (listing.error?.code === 'workspace-file/not-found') return { topics: [], content: { status: 'empty' } };
1087
+ throw new Error(listing.error?.message ?? 'the Course folder could not be read');
1088
+ }
1089
+ const topics = (listing.value?.entries ?? [])
1090
+ .filter((entry) => entry.type === 'directory')
1091
+ .map((entry) => entry.name)
1092
+ .sort((left, right) => left.localeCompare(right));
1093
+ if (topics.length === 0) return { topics, content: { status: 'empty' } };
1094
+ return { topics, content: await loadTopic(files, sessionId, pickCourse(topics)) };
1095
+ }
1096
+
1097
+ // --- Study view ----------------------------------------------------------
1098
+
1099
+ const conceptLabel = (concept, t) => `${concept.chapterTitle} · ${t({
1100
+ en: `Section ${concept.section}`,
1101
+ zh: `第 ${concept.section} 节`,
1102
+ })} · ${concept.name}`;
1103
+
1104
+ function progressBlock(snapshot, t) {
1105
+ if (snapshot.percentage === null) {
1106
+ return React.createElement('div', { 'data-study-progress': 'none', style: panelProgressStyle },
1107
+ React.createElement('p', { style: panelFigureStyle }, DASH),
1108
+ React.createElement('p', { style: panelMutedStyle }, t({ en: 'Every Concept is skipped.', zh: '所有概念都已跳过。' })));
1109
+ }
1110
+ const text = t({
1111
+ en: `${snapshot.percentage}% of the Book`,
1112
+ zh: `本书已完成 ${snapshot.percentage}%`,
1113
+ });
1114
+ return React.createElement('div', { style: panelProgressStyle },
1115
+ React.createElement('p', { style: panelFigureRowStyle },
1116
+ React.createElement('span', { style: panelFigureStyle }, `${snapshot.percentage}%`),
1117
+ ' ',
1118
+ React.createElement('span', { style: panelFigureUnitStyle }, t({ en: 'of the Book', zh: '已完成' }))),
1119
+ React.createElement('div', {
1120
+ role: 'progressbar',
1121
+ 'aria-label': t({ en: 'Book progress', zh: '本书进度' }),
1122
+ 'aria-valuemin': 0,
1123
+ 'aria-valuemax': 100,
1124
+ 'aria-valuenow': snapshot.percentage,
1125
+ 'aria-valuetext': text,
1126
+ style: panelTrackStyle,
1127
+ }, React.createElement('div', {
1128
+ style: { ...panelFillStyle, width: `${snapshot.percentage}%` },
1129
+ })),
1130
+ snapshot.current === null
1131
+ ? React.createElement('p', { style: panelMutedStyle }, t({ en: 'No evidence yet', zh: '还没有证据记录' }))
1132
+ : null);
1133
+ }
1134
+
1135
+ function stripBlock(snapshot, t) {
1136
+ const slots = [
1137
+ { label: t({ en: 'Previous', zh: '上一个' }), value: snapshot.previous === null ? DASH : conceptLabel(snapshot.previous, t), muted: snapshot.previous === null },
1138
+ { label: t({ en: 'Current', zh: '当前' }), value: snapshot.current === null ? t({ en: 'No lesson recorded yet', zh: '还没有课程记录' }) : conceptLabel(snapshot.current, t), muted: snapshot.current === null, current: true },
1139
+ {
1140
+ label: t({ en: 'Next', zh: '下一个' }),
1141
+ value: snapshot.next !== null
1142
+ ? conceptLabel(snapshot.next, t)
1143
+ : snapshot.nextState === 'all-skipped' ? t({ en: 'Everything left is skipped', zh: '剩下的概念都已跳过' }) : t({ en: 'End of Book', zh: '本书已结束' }),
1144
+ muted: snapshot.next === null,
1145
+ },
1146
+ ];
1147
+ return React.createElement('dl', { style: panelStripStyle },
1148
+ ...slots.map((slot) => React.createElement('div', {
1149
+ key: slot.label,
1150
+ style: slot.current ? panelStripCellCurrentStyle : panelStripCellStyle,
1151
+ },
1152
+ React.createElement('dt', { style: panelStripLabelStyle }, slot.label),
1153
+ React.createElement('dd', {
1154
+ style: slot.muted ? panelStripMutedValueStyle : panelStripValueStyle,
1155
+ }, slot.value))));
1156
+ }
1157
+
1158
+ function topicPicker(topics, value, onTopic, t) {
1159
+ if (topics.length < 2) return null;
1160
+ return React.createElement('label', { style: panelFieldStyle },
1161
+ React.createElement('span', null, t({ en: 'Course', zh: '课程' })),
1162
+ React.createElement('select', {
1163
+ 'aria-label': t({ en: 'Course', zh: '课程' }),
1164
+ value,
1165
+ onChange: (event) => onTopic(event.target.value),
1166
+ style: panelSelectStyle,
1167
+ }, ...topics.map((topic) => React.createElement('option', { key: topic, value: topic }, topic))));
1168
+ }
1169
+
1170
+ function readyBody(snapshot, error, topics, onTopic, t) {
1171
+ const nodes = [
1172
+ React.createElement('div', { style: panelHeaderStyle },
1173
+ React.createElement('h2', { style: panelTitleStyle }, snapshot.bookTitle),
1174
+ topicPicker(topics, snapshot.topic, onTopic, t)),
1175
+ ];
1176
+ if (error) {
1177
+ nodes.push(React.createElement('p', { role: 'alert', style: panelMutedStyle }, t({
1178
+ en: `The Course could not be read: ${error}.`,
1179
+ zh: `无法读取课程:${error}。`,
1180
+ })));
1181
+ }
1182
+ nodes.push(progressBlock(snapshot, t), stripBlock(snapshot, t));
1183
+
1184
+ const shown = snapshot.shaky.slice(0, 5);
1185
+ nodes.push(React.createElement('section', { 'data-study-section': 'shaky', style: panelSectionStyle },
1186
+ React.createElement('h3', { style: panelHeadingStyle }, t({ en: 'Shaky Concepts', zh: '薄弱概念' })),
1187
+ shown.length === 0
1188
+ ? React.createElement('p', { style: panelMutedStyle }, t({ en: 'No shaky Concepts.', zh: '没有薄弱概念。' }))
1189
+ : React.createElement('ul', { style: panelListStyle },
1190
+ ...shown.map((concept) => React.createElement('li', { key: concept.name }, conceptLabel(concept, t)))),
1191
+ snapshot.shakyTotal > shown.length
1192
+ ? React.createElement('p', { style: panelMutedStyle }, t({
1193
+ en: `${snapshot.shakyTotal - shown.length} more not shown.`,
1194
+ zh: `另有 ${snapshot.shakyTotal - shown.length} 个未显示。`,
1195
+ }))
1196
+ : null));
1197
+
1198
+ nodes.push(React.createElement('section', { 'data-study-section': 'cycles', style: panelSectionStyle },
1199
+ React.createElement('h3', { style: panelHeadingStyle }, t({ en: 'Recent cycles', zh: '最近几轮' })),
1200
+ snapshot.cycles.length === 0
1201
+ ? React.createElement('p', { style: panelMutedStyle }, t({ en: 'No cycles yet.', zh: '还没有记录轮次。' }))
1202
+ : React.createElement('ul', { style: panelListStyle },
1203
+ ...snapshot.cycles.map((cycle, index) => React.createElement('li', { key: `${cycle.date}-${index}` },
1204
+ `${cycle.date} · ${cycle.label}`)))));
1205
+
1206
+ if (snapshot.malformed > 0) {
1207
+ nodes.push(React.createElement('p', { role: 'status', style: panelMutedStyle }, t({
1208
+ en: `${snapshot.malformed} malformed ${snapshot.malformed === 1 ? 'row was' : 'rows were'} ignored.`,
1209
+ zh: `已忽略 ${snapshot.malformed} 行格式错误的记录。`,
1210
+ })));
1211
+ }
1212
+ return nodes;
1213
+ }
1214
+
1215
+ function StudyView({ sessionId, workspaceFiles, locale }) {
1216
+ const t = useT(locale);
1217
+ const [topics, setTopics] = React.useState([]);
1218
+ const [selected, setSelected] = React.useState(null);
1219
+ const [content, setContent] = React.useState({ status: 'loading' });
1220
+ // Read on every render so a selection made in the Lectures tab wins.
1221
+ const shared = activeCourse;
1222
+
1223
+ React.useEffect(() => {
1224
+ let live = true;
1225
+ const run = async () => {
1226
+ try {
1227
+ const found = await discoverTopics(workspaceFiles, sessionId);
1228
+ if (!live) return;
1229
+ setTopics(found.topics);
1230
+ setContent(found.content);
1231
+ } catch (error) {
1232
+ if (!live) return;
1233
+ const message = error?.message ?? 'the Course could not be read';
1234
+ // Keep the last good snapshot so a failed reload does not blank the page.
1235
+ setContent((previous) => previous.status === 'ready' || previous.status === 'empty'
1236
+ ? { ...previous, error: message }
1237
+ : { status: 'failed', error: message });
1238
+ }
1239
+ };
1240
+ run();
1241
+ return () => { live = false; };
1242
+ }, [selected, shared, sessionId, workspaceFiles]);
1243
+
1244
+ const choose = (topic) => {
1245
+ activeCourse = topic;
1246
+ setSelected(topic);
1247
+ };
1248
+
1249
+ let nodes;
1250
+ if (content.status === 'loading') {
1251
+ nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, style: panelMutedStyle }, t({ en: 'Loading the Course…', zh: '正在加载课程…' }))];
1252
+ } else if (content.status === 'failed') {
1253
+ nodes = [React.createElement('p', { role: 'alert', style: panelMutedStyle }, t({
1254
+ en: `The Course could not be read: ${content.error}.`,
1255
+ zh: `无法读取课程:${content.error}。`,
1256
+ }))];
1257
+ } else if (content.status === 'empty') {
1258
+ const searched = content.topic ? `${TRACKER_ROOT}/${content.topic}` : `${TRACKER_ROOT}/`;
1259
+ const picker = topicPicker(topics, content.topic ?? selected, choose, t);
1260
+ nodes = [
1261
+ picker === null ? null : React.createElement('div', { style: panelHeaderStyle }, picker),
1262
+ React.createElement('p', { style: panelMutedStyle }, t({
1263
+ en: `No Course in ${searched}. Run /study-plus to create one.`,
1264
+ zh: `${searched} 中没有课程。运行 /study-plus 创建一个。`,
1265
+ })),
1266
+ ];
1267
+ } else {
1268
+ nodes = readyBody(content.snapshot, content.error, topics, choose, t);
1269
+ }
1270
+ return React.createElement('div', {
1271
+ role: 'region',
1272
+ 'aria-label': t(VIEW_LABEL),
1273
+ style: panelStyle,
1274
+ }, ...nodes);
1275
+ }
1276
+
1277
+ const panelStyle = {
1278
+ boxSizing: 'border-box',
1279
+ color: 'var(--dsw-alias-label-primary)',
1280
+ display: 'flex',
1281
+ flexDirection: 'column',
1282
+ fontFamily: 'var(--dsw-font-family, inherit)',
1283
+ gap: 24,
1284
+ maxWidth: 720,
1285
+ // The conversation view area carries no padding of its own.
1286
+ padding: '24px 32px 32px',
1287
+ width: '100%',
1288
+ };
1289
+ const panelHeaderStyle = { alignItems: 'center', display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'space-between' };
1290
+ const panelTitleStyle = { fontSize: 15, fontWeight: 600, lineHeight: '22px', margin: 0 };
1291
+ const panelMutedStyle = { color: 'var(--dsw-alias-label-tertiary)', fontSize: 13, lineHeight: '20px', margin: 0 };
1292
+ const panelHeadingStyle = { fontSize: 15, fontWeight: 600, lineHeight: '22px', margin: 0 };
1293
+ const panelSectionStyle = {
1294
+ background: 'var(--dsw-alias-bg-layer-1)',
1295
+ border: '1px solid var(--dsw-alias-border-l2)',
1296
+ borderRadius: 10,
1297
+ display: 'flex',
1298
+ flexDirection: 'column',
1299
+ gap: 12,
1300
+ padding: 24,
1301
+ };
1302
+ const panelListStyle = { display: 'flex', flexDirection: 'column', gap: 8, listStyle: 'none', margin: 0, padding: 0 };
1303
+ const panelProgressStyle = { display: 'flex', flexDirection: 'column', gap: 12 };
1304
+ const panelFigureRowStyle = { lineHeight: '24px', margin: 0 };
1305
+ const panelFigureUnitStyle = {
1306
+ color: 'var(--dsw-alias-label-tertiary)',
1307
+ fontSize: 13,
1308
+ lineHeight: '20px',
1309
+ marginLeft: 2,
1310
+ };
1311
+ const panelTrackStyle = {
1312
+ background: 'var(--dsw-alias-border-l3)',
1313
+ borderRadius: 6,
1314
+ height: 8,
1315
+ overflow: 'hidden',
1316
+ width: '100%',
1317
+ };
1318
+ const panelFillStyle = { background: 'var(--dsw-alias-state-business-primary)', height: '100%' };
1319
+ const panelStripStyle = {
1320
+ alignItems: 'start',
1321
+ display: 'grid',
1322
+ gap: 12,
1323
+ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
1324
+ margin: 0,
1325
+ };
1326
+ const panelStripCellStyle = {
1327
+ border: '1px solid var(--dsw-alias-border-l2)',
1328
+ borderRadius: 10,
1329
+ display: 'flex',
1330
+ flexDirection: 'column',
1331
+ gap: 8,
1332
+ padding: '12px 16px',
1333
+ };
1334
+ // The Current cell is the answer to "where am I", so it is the only filled cell.
1335
+ const panelStripCellCurrentStyle = {
1336
+ ...panelStripCellStyle,
1337
+ background: 'var(--dsw-alias-bg-layer-1)',
1338
+ borderColor: 'var(--dsw-alias-border-l3)',
1339
+ };
1340
+ const panelStripLabelStyle = { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12, lineHeight: '18px', margin: 0 };
1341
+ const panelStripValueStyle = { fontSize: 13, lineHeight: '20px', margin: 0 };
1342
+ const panelStripMutedValueStyle = {
1343
+ ...panelStripValueStyle,
1344
+ color: 'var(--dsw-alias-label-tertiary)',
1345
+ };
1346
+ const panelFigureStyle = {
1347
+ fontSize: 15,
1348
+ fontWeight: 600,
1349
+ fontVariantNumeric: 'tabular-nums',
1350
+ lineHeight: '24px',
1351
+ margin: 0,
1352
+ };
1353
+ const panelFieldStyle = { alignItems: 'center', display: 'flex', fontSize: 13, gap: 8 };
1354
+ const panelSelectStyle = {
1355
+ background: 'var(--dsw-alias-bg-layer-1)',
1356
+ border: '1px solid var(--dsw-alias-border-l3)',
1357
+ borderRadius: 6,
1358
+ color: 'var(--dsw-alias-label-primary)',
1359
+ font: 'inherit',
1360
+ fontSize: 13,
1361
+ height: 32,
1362
+ padding: '0 8px',
1363
+ };
1364
+
1365
+ // --- Lectures dock page --------------------------------------------------
1366
+ const LECTURES_ID = 'dsh-nerd-mode/lectures';
1367
+ const LECTURES_KIND = 'lectures';
1368
+ const LECTURES_LABEL = { en: 'Lectures', zh: '讲义' };
1369
+ const LECTURES_GUIDE_ORDER = 40;
1370
+
1371
+ // Same 16px book as the composer pill, drawn at the size the guide asks for.
1372
+ function LecturesGlyph({ size = 16 }) {
1373
+ return React.createElement(
1374
+ 'svg',
1375
+ {
1376
+ 'aria-hidden': 'true',
1377
+ fill: 'none',
1378
+ height: size,
1379
+ stroke: 'currentColor',
1380
+ strokeLinecap: 'round',
1381
+ strokeLinejoin: 'round',
1382
+ strokeWidth: 1.6,
1383
+ viewBox: '0 0 16 16',
1384
+ width: size,
1385
+ },
1386
+ ...BOOK.props.children,
1387
+ );
1388
+ }
1389
+
1390
+ // The leading block only; a `---` rule later in the file stays content.
1391
+ // A block without a closing fence, or with a line that is not `key: value`,
1392
+ // returns null so the caller can leave the state empty.
1393
+ function lectureFrontmatter(lines) {
1394
+ if (lines[0]?.trim() !== '---') return {};
1395
+ const end = lines.findIndex((line, at) => at > 0 && line.trim() === '---');
1396
+ if (end === -1) return null;
1397
+ const fields = {};
1398
+ for (const line of lines.slice(1, end)) {
1399
+ if (line.trim() === '') continue;
1400
+ const match = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line.trim());
1401
+ if (match === null) return null;
1402
+ fields[match[1]] = match[2].trim();
1403
+ }
1404
+ return fields;
1405
+ }
1406
+
1407
+ function parseLecture(text, slug) {
1408
+ const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
1409
+ const fields = lectureFrontmatter(lines);
1410
+ const heading = lines.find((line) => /^#\s+\S/.test(line));
1411
+ const draft = fields !== null && fields.status === 'draft';
1412
+ const date = fields !== null && /^\d{4}-\d{2}-\d{2}$/.test(fields.date ?? '') ? fields.date : null;
1413
+ // A draft that also carries a teaching date is malformed, so the block reads as unparsable.
1414
+ const valid = fields !== null && !(draft && date !== null);
1415
+ return {
1416
+ title: heading === undefined ? slug : heading.replace(/^#\s+/, '').trim(),
1417
+ concept: valid && /^\d+$/.test(fields.concept ?? '') ? Number(fields.concept) : null,
1418
+ date: valid ? date : null,
1419
+ draft: valid && draft,
1420
+ };
1421
+ }
1422
+
1423
+ // A missing folder is an empty listing. Any other failure becomes data.
1424
+ // `found` tells the caller whether the folder exists, so a missing root and
1425
+ // an empty root can render different pages.
1426
+ async function listFolder(files, sessionId, path) {
1427
+ try {
1428
+ const listing = await files.list(sessionId, path);
1429
+ if (listing.ok) return { entries: listing.value?.entries ?? [], error: null, found: true };
1430
+ if (listing.error?.code === 'workspace-file/not-found') return { entries: [], error: null, found: false };
1431
+ return { entries: [], error: listing.error?.message ?? `${path} could not be read`, found: false };
1432
+ } catch (error) {
1433
+ return { entries: [], error: error?.message ?? `${path} could not be read`, found: false };
1434
+ }
1435
+ }
1436
+
1437
+ /** Return `{ courses, course, entries, error, rootFound }`. `rootFound` is false only when `study/` is missing, `course` is null when the workspace holds none, and `error` is the first read failure or null. */
1438
+ async function loadLectures(files, sessionId) {
1439
+ const model = { courses: [], course: null, entries: [], error: null, rootFound: false };
1440
+ const root = await listFolder(files, sessionId, TRACKER_ROOT);
1441
+ model.rootFound = root.found;
1442
+ if (root.error !== null) {
1443
+ model.error = root.error;
1444
+ return model;
1445
+ }
1446
+ model.courses = root.entries
1447
+ .filter((entry) => entry.type === 'directory')
1448
+ .map((entry) => entry.name)
1449
+ .sort((left, right) => left.localeCompare(right));
1450
+ model.course = pickCourse(model.courses);
1451
+ if (model.course === null) return model;
1452
+ const entries = [];
1453
+ const seen = new Set();
1454
+ const folders = [
1455
+ [`${TRACKER_ROOT}/${model.course}/lectures`, false],
1456
+ [`${TRACKER_ROOT}/${model.course}/lectures/archive`, true],
1457
+ ];
1458
+ for (const [folder, archived] of folders) {
1459
+ const listing = await listFolder(files, sessionId, folder);
1460
+ if (listing.error !== null && model.error === null) model.error = listing.error;
1461
+ for (const item of listing.entries) {
1462
+ if (item.type !== 'file') continue;
1463
+ const match = /^(\d{2,})-(.+)\.md$/.exec(item.name);
1464
+ // One lecture per number: a stale copy in the archive never repeats it.
1465
+ if (match === null || seen.has(match[1])) continue;
1466
+ let text;
1467
+ try {
1468
+ text = await readText(files, sessionId, `${folder}/${item.name}`);
1469
+ } catch (error) {
1470
+ if (model.error === null) model.error = error?.message ?? `${folder}/${item.name} could not be read`;
1471
+ continue;
1472
+ }
1473
+ const parsed = parseLecture(text, match[2]);
1474
+ entries.push({
1475
+ number: match[1],
1476
+ topic: model.course,
1477
+ title: parsed.title,
1478
+ concept: parsed.concept,
1479
+ mark: archived ? 'archived' : parsed.draft ? 'draft' : parsed.date !== null ? 'dated' : null,
1480
+ date: parsed.date,
1481
+ path: `${folder}/${item.name}`,
1482
+ });
1483
+ seen.add(match[1]);
1484
+ }
1485
+ }
1486
+ entries.sort((left, right) => Number(left.number) - Number(right.number));
1487
+ model.entries = entries;
1488
+ return model;
1489
+ }
1490
+
1491
+ // The picker and Course name stay in view while only the lecture rows
1492
+ // scroll. The scroller owns the side padding, so its bar sits at the page
1493
+ // edge rather than inside the text.
1494
+ const LECTURES_CSS = `.lectures-page{box-sizing:border-box;height:100%;display:flex;flex-direction:column;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,inherit)}
1495
+ .lectures-head{display:flex;align-items:center;gap:12px;padding:24px 32px 0}
1496
+ .lectures-picker{display:flex;align-items:center;gap:8px;flex:1;min-width:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}
1497
+ .lectures-select{flex:1;min-width:0;height:32px;padding:0 8px;border:1px solid var(--dsw-alias-border-l3);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px}
1498
+ .lectures-group{display:flex;flex-direction:column;flex:1;min-height:0}
1499
+ .lectures-group-head{display:flex;align-items:center;gap:8px;padding:16px 32px 12px}
1500
+ .lectures-group-title{margin:0;font-size:13px;line-height:20px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1501
+ .lectures-count{flex:none;font-size:12px;line-height:18px;font-weight:400;color:var(--dsw-alias-label-tertiary)}
1502
+ .lectures-scroll{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:8px;padding:0 32px 32px}
1503
+ .lectures-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
1504
+ .lectures-item{margin:0}
1505
+ .lectures-row{position:relative;width:100%;box-sizing:border-box;display:grid;grid-template-columns:32px minmax(0,1fr);grid-template-areas:"tile title" "tile meta";column-gap:12px;row-gap:4px;align-items:start;padding:10px 12px;border:none;border-radius:10px;background:var(--dsw-alias-bg-layer-1);color:inherit;font:inherit;text-align:left;cursor:pointer;white-space:normal}
1506
+ .lectures-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
1507
+ .lectures-row:focus-visible{outline:none}
1508
+ .lectures-row:focus-visible::after{content:"";position:absolute;inset:0;border-radius:inherit;outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
1509
+ .lectures-tile{grid-area:tile;align-self:center;width:32px;height:32px;display:grid;place-items:center;border-radius:10px;background:var(--dsw-alias-fill-tsp-secondary);color:var(--dsw-alias-label-secondary);font:600 12px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums}
1510
+ .lectures-title{grid-area:title;font-size:13px;line-height:20px;font-weight:500;overflow-wrap:anywhere}
1511
+ .lectures-meta{grid-area:meta;display:flex;align-items:center;gap:8px;min-width:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
1512
+ .lectures-chip{display:inline-flex;align-items:center;height:20px;padding:0 8px;font-size:12px;line-height:16px;border:.5px solid var(--dsw-alias-border-l3);border-radius:6px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
1513
+ .lectures-fold{align-self:flex-start;display:inline-flex;align-items:center;gap:8px;margin-top:12px;padding:0;border:none;background:none;font-size:15px;line-height:22px;font-weight:600;color:var(--dsw-alias-label-primary);cursor:pointer}
1514
+ .lectures-fold:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
1515
+ .lectures-chev{color:var(--dsw-alias-label-tertiary);transition:transform 120ms}
1516
+ .lectures-fold[aria-expanded="false"] .lectures-chev{transform:rotate(-90deg)}
1517
+ .lectures-status{margin:0;padding:8px 32px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}
1518
+ .lectures-error{display:flex;align-items:center;gap:8px;margin:0;padding:8px 32px;font-size:13px;line-height:20px;color:var(--dsw-alias-state-error-primary)}
1519
+ .lectures-dot{flex:none;width:6px;height:6px;border-radius:50%;background:currentColor}
1520
+ .lectures-empty{margin:0;padding:32px 16px;text-align:center;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary)}
1521
+ .lectures-empty code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;padding:1px 6px}`;
1522
+
1523
+ const LECTURE_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
1524
+
1525
+ // `2026-10-28` reads as `28 Oct 2026`: shorter than the ISO form and still one date.
1526
+ function lectureDate(value, t) {
1527
+ const [year, month, day] = value.split('-');
1528
+ const name = LECTURE_MONTHS[Number(month) - 1];
1529
+ if (name === undefined) return value;
1530
+ return t({
1531
+ en: `${Number(day)} ${name} ${year}`,
1532
+ zh: `${year} 年 ${Number(month)} 月 ${Number(day)} 日`,
1533
+ });
1534
+ }
1535
+
1536
+ function lectureMark(entry, t) {
1537
+ if (entry.mark === 'archived') return t({ en: 'Archived', zh: '已归档' });
1538
+ if (entry.mark === 'draft') return t({ en: 'Draft', zh: '草稿' });
1539
+ if (entry.mark === 'dated' && entry.date !== null) return lectureDate(entry.date, t);
1540
+ return null;
1541
+ }
1542
+
1543
+ // Copied verbatim from `@deepseek-ai/dsh-util-workspace-path`, which a client
1544
+ // plugin cannot load from the browser module table.
1545
+ const FILE_ADDRESS_PREFIX = 'dsh-resource://file/';
1546
+ /** Component-encode one id or path segment, keeping `:` literal for drive letters. */
1547
+ function encodeSegment(segment) {
1548
+ return encodeURIComponent(segment).replace(/%3A/gi, ':');
1549
+ }
1550
+ /** Encode a `/`-separated path segment by segment. */
1551
+ function encodePath(path) {
1552
+ return path.split('/').map(encodeSegment).join('/');
1553
+ }
1554
+ /** Build the address of a file read through one Session. */
1555
+ function sessionFileAddress(sessionId, path) {
1556
+ const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '');
1557
+ return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`;
1558
+ }
1559
+
1560
+ // The visible title is clipped, so the label repeats it in full.
1561
+ function lectureRow(entry, onOpen, t) {
1562
+ const mark = lectureMark(entry, t);
1563
+ const label = mark === null
1564
+ ? `${entry.number}, ${entry.title}`
1565
+ : `${entry.number}, ${entry.title}, ${mark}`;
1566
+ return React.createElement('li', { key: entry.number, className: 'lectures-item' },
1567
+ React.createElement('button', {
1568
+ type: 'button',
1569
+ className: 'lectures-row',
1570
+ 'aria-label': label,
1571
+ onClick: () => onOpen(entry),
1572
+ },
1573
+ React.createElement('span', { className: 'lectures-tile', 'aria-hidden': 'true' }, entry.number),
1574
+ React.createElement('span', { className: 'lectures-title' }, entry.title),
1575
+ mark === null
1576
+ ? null
1577
+ : React.createElement('span', { className: 'lectures-meta' },
1578
+ entry.mark === 'dated'
1579
+ ? mark
1580
+ : React.createElement('span', { className: 'lectures-chip' }, mark))));
1581
+ }
1582
+
1583
+ function lectureTopic(group, open, onToggle, onOpen, t) {
1584
+ const main = group.entries.filter((entry) => entry.mark !== 'archived');
1585
+ const archived = group.entries.filter((entry) => entry.mark === 'archived');
1586
+ const archiveId = `lectures-archive-${group.topic}`;
1587
+ const rows = [];
1588
+ if (group.entries.length === 0) {
1589
+ rows.push(React.createElement('p', { key: 'empty', className: 'lectures-empty' }, t({ en: 'No lectures yet.', zh: '还没有讲义。' })));
1590
+ } else if (main.length > 0) {
1591
+ rows.push(React.createElement('ul', { key: 'main', className: 'lectures-rows' }, ...main.map((entry) => lectureRow(entry, onOpen, t))));
1592
+ }
1593
+ if (archived.length > 0) {
1594
+ rows.push(React.createElement('button', {
1595
+ key: 'fold',
1596
+ type: 'button',
1597
+ className: 'lectures-fold',
1598
+ 'aria-expanded': open,
1599
+ // A closed fold has no list to control, so the reference would dangle.
1600
+ 'aria-controls': open ? archiveId : undefined,
1601
+ 'aria-label': `${t({ en: 'Archived lectures', zh: '已归档讲义' })} ${archived.length}`,
1602
+ onClick: () => onToggle(group.topic),
1603
+ },
1604
+ React.createElement('span', null, t({ en: 'Archived lectures', zh: '已归档讲义' })),
1605
+ React.createElement('span', { className: 'lectures-count' }, String(archived.length)),
1606
+ React.createElement('svg', {
1607
+ 'aria-hidden': 'true',
1608
+ className: 'lectures-chev',
1609
+ fill: 'none',
1610
+ height: 12,
1611
+ stroke: 'currentColor',
1612
+ strokeLinecap: 'round',
1613
+ strokeLinejoin: 'round',
1614
+ strokeWidth: 1.6,
1615
+ viewBox: '0 0 16 16',
1616
+ width: 12,
1617
+ }, React.createElement('path', { d: 'M3.5 6 8 10.5 12.5 6' }))));
1618
+ if (open) {
1619
+ rows.push(React.createElement('ul', { key: 'archive', className: 'lectures-rows', id: archiveId }, ...archived.map((entry) => lectureRow(entry, onOpen, t))));
1620
+ }
1621
+ }
1622
+ // The Course key remounts the section on a switch, so its scrollport starts at the top.
1623
+ return React.createElement('section', { key: group.topic, className: 'lectures-group' },
1624
+ React.createElement('div', { className: 'lectures-group-head' },
1625
+ React.createElement('h2', { className: 'lectures-group-title' }, group.topic),
1626
+ React.createElement('span', { className: 'lectures-count' }, String(main.length))),
1627
+ React.createElement('div', { className: 'lectures-scroll' }, ...rows));
1628
+ }
1629
+
1630
+ function LecturesPage({ useTabInfo, sessionId, workspaceFiles, locale }) {
1631
+ const t = useT(locale);
1632
+ const { tab } = useTabInfo();
1633
+ const [content, setContent] = React.useState({ status: 'loading' });
1634
+ const [selected, setSelected] = React.useState(null);
1635
+ const [foldOpen, setFoldOpen] = React.useState(false);
1636
+ // Read on every render so a selection made in the Study tab wins.
1637
+ const shared = activeCourse;
1638
+
1639
+ React.useEffect(() => {
1640
+ let live = true;
1641
+ const run = async () => {
1642
+ const model = await loadLectures(workspaceFiles, sessionId);
1643
+ if (!live) return;
1644
+ setContent({ status: 'ready', ...model });
1645
+ };
1646
+ run();
1647
+ return () => { live = false; };
1648
+ }, [selected, shared, sessionId, workspaceFiles]);
1649
+
1650
+ const choose = (course) => {
1651
+ activeCourse = course;
1652
+ setFoldOpen(false);
1653
+ setSelected(course);
1654
+ };
1655
+
1656
+ // No placement option: the preview opens as a tab in this pane and the
1657
+ // host focuses it when that lecture is already open.
1658
+ const open = (entry) => tab.actions.openResource(sessionFileAddress(sessionId, entry.path));
1659
+
1660
+ let nodes;
1661
+ if (content.status === 'loading') {
1662
+ nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, className: 'lectures-status' }, t({ en: 'Loading the lectures…', zh: '正在加载讲义…' }))];
1663
+ } else if (content.error === null && !content.rootFound) {
1664
+ nodes = [React.createElement('p', { className: 'lectures-empty' },
1665
+ t({ en: `No Course in ${TRACKER_ROOT}/. Run `, zh: `${TRACKER_ROOT}/ 中没有课程。运行 ` }),
1666
+ React.createElement('code', null, '/study-plus'),
1667
+ t({ en: ' to create one.', zh: ' 创建一个。' }))];
1668
+ } else {
1669
+ nodes = content.error === null
1670
+ ? []
1671
+ : [React.createElement('p', { role: 'alert', className: 'lectures-error' },
1672
+ React.createElement('span', { className: 'lectures-dot', 'aria-hidden': 'true' }),
1673
+ t({
1674
+ en: `The lecture library could not be read: ${content.error}.`,
1675
+ zh: `无法读取讲义库:${content.error}。`,
1676
+ }))];
1677
+ if (content.course !== null) {
1678
+ nodes.push(lectureTopic(
1679
+ { topic: content.course, entries: content.entries },
1680
+ foldOpen,
1681
+ () => setFoldOpen((previous) => !previous),
1682
+ open,
1683
+ t,
1684
+ ));
1685
+ }
1686
+ }
1687
+
1688
+ // One Course needs no picker: the page already shows that Course.
1689
+ const courses = content.courses ?? [];
1690
+ const picker = courses.length < 2 ? null : React.createElement('label', { className: 'lectures-picker' },
1691
+ React.createElement('span', null, t({ en: 'Course', zh: '课程' })),
1692
+ React.createElement('select', {
1693
+ 'aria-label': t({ en: 'Course', zh: '课程' }),
1694
+ className: 'lectures-select',
1695
+ value: content.course ?? selected,
1696
+ onChange: (event) => choose(event.target.value),
1697
+ }, ...courses.map((course) => React.createElement('option', { key: course, value: course }, course))));
1698
+ return React.createElement('div', {
1699
+ role: 'region',
1700
+ 'aria-label': t(LECTURES_LABEL),
1701
+ className: 'lectures-page',
1702
+ }, React.createElement('style', null, LECTURES_CSS),
1703
+ React.createElement('header', { className: 'lectures-head' }, picker),
1704
+ ...nodes);
1705
+ }
1706
+
1707
+ // A page type names no resource pattern and no `multiple`, so the host
1708
+ // records it at one fixed page address and a reopen focuses that tab.
1709
+ const LECTURES_DEFINITION = (locale) => ({
1710
+ id: LECTURES_ID,
1711
+ kind: LECTURES_KIND,
1712
+ title: () => locale.resolveText(LECTURES_LABEL),
1713
+ guide: [{
1714
+ id: 'lectures',
1715
+ order: LECTURES_GUIDE_ORDER,
1716
+ title: () => locale.resolveText(LECTURES_LABEL),
1717
+ description: () => locale.resolveText({
1718
+ en: 'Read the lectures saved in this workspace.',
1719
+ zh: '阅读此工作区保存的讲义。',
1720
+ }),
1721
+ icon: LecturesGlyph,
1722
+ }],
1723
+ });
1724
+
1725
+ // --- Study Sessions panel ------------------------------------------------
1726
+
1727
+ const STUDY_SESSIONS_ID = 'study-sessions';
1728
+ const STUDY_SESSIONS_ORDER = 30;
1729
+ const STUDY_SESSIONS_API = '/dsh-nerd-mode/api/study-sessions';
1730
+ const STUDY_SESSIONS_TITLE = { en: 'Study Sessions', zh: '学习会话' };
1731
+ const STUDY_QUICK = { en: 'Quick', zh: '快速' };
1732
+ const STUDY_IN_DEPTH = { en: 'In-depth', zh: '深入' };
1733
+ const STUDY_PAST = { en: 'Past', zh: '过去' };
1734
+
1735
+ // A host without the session package still gets the page and its empty line.
1736
+ const NO_SESSIONS_HOOK = () => null;
1737
+
1738
+ // A failed rescan or a remount must not blank the panel, so the last good scan outlives the component.
1739
+ let studySessionsCache = null;
1740
+
1741
+ /**
1742
+ * Read one history chunk from the host route.
1743
+ *
1744
+ * Returns `{ phase, done, total, sessions, cursor }`. Throws an Error whose message names the failure:
1745
+ * a missing route (404/405 or a body that is not JSON) and any non-OK reply both count.
1746
+ */
1747
+ async function readSessionChunk(cursor) {
1748
+ const response = await fetch(`${STUDY_SESSIONS_API}?cursor=${cursor}`);
1749
+ // The static fallback owns an unregistered path, so it answers with an empty body.
1750
+ if (response.status === 404 || response.status === 405) throw new Error('the host route is not available');
1751
+ // A rejected request carries plain text, so name the status before the body is parsed.
1752
+ if (response.status === 401 || response.status === 403) {
1753
+ throw new Error(`the host denied the request (HTTP ${response.status})`);
1754
+ }
1755
+ const body = await response.json().catch(() => null);
1756
+ if (body === null) throw new Error('the host returned no JSON');
1757
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`);
1758
+ const sessions = body?.sessions;
1759
+ if (typeof sessions !== 'object' || sessions === null) throw new Error('the host returned no session history');
1760
+ return {
1761
+ phase: body.phase === 'ready' ? 'ready' : 'scanning',
1762
+ done: Number.isFinite(body.done) ? body.done : 0,
1763
+ total: Number.isFinite(body.total) ? body.total : 0,
1764
+ sessions,
1765
+ // The spec names no cursor field, so accept one or fall back to the processed count.
1766
+ cursor: Number.isFinite(body.cursor) ? body.cursor : Number.isFinite(body.done) ? body.done : cursor,
1767
+ };
1768
+ }
1769
+
1770
+ /**
1771
+ * Scan the host history index from cursor 0, merging each chunk into `sessions`.
1772
+ *
1773
+ * Calls `onChunk` after every reply so the page can move its progress line. Returns the final
1774
+ * scan state. A host that repeats a cursor cannot make progress, so the loop stops there.
1775
+ */
1776
+ async function scanSessionHistory(sessions, onChunk) {
1777
+ let state = { status: 'scanning', sessions, done: 0, total: 0, cursor: 0 };
1778
+ let previous = 0;
1779
+ for (;;) {
1780
+ const chunk = await readSessionChunk(state.cursor);
1781
+ state = {
1782
+ status: chunk.phase,
1783
+ // A ready reply carries the host's whole index, so it replaces stale cached ids.
1784
+ sessions: chunk.phase === 'ready' ? chunk.sessions : { ...state.sessions, ...chunk.sessions },
1785
+ done: chunk.done,
1786
+ total: chunk.total,
1787
+ cursor: chunk.cursor,
1788
+ };
1789
+ onChunk(state);
1790
+ if (state.status === 'ready' || chunk.cursor <= previous) return state;
1791
+ previous = chunk.cursor;
1792
+ }
1793
+ }
1794
+
1795
+ /** Return the last folder in a workspace path; Windows and POSIX separators both count. */
1796
+ function workspaceName(cwd) {
1797
+ if (typeof cwd !== 'string') return '';
1798
+ return cwd.split(/[\\/]+/).filter((part) => part !== '').pop() ?? '';
1799
+ }
1800
+
1801
+ const RELATIVE_UNITS = [
1802
+ ['year', 31536000000],
1803
+ ['month', 2592000000],
1804
+ ['day', 86400000],
1805
+ ['hour', 3600000],
1806
+ ['minute', 60000],
1807
+ ['second', 1000],
1808
+ ];
1809
+
1810
+ /** Return a locale-aware relative time such as `2 hours ago`; a missing time reads as an empty string. */
1811
+ function relativeTime(updatedAt, language) {
1812
+ if (!Number.isFinite(updatedAt)) return '';
1813
+ const elapsed = updatedAt - Date.now();
1814
+ const [unit, size] = RELATIVE_UNITS.find(([, step]) => Math.abs(elapsed) >= step) ?? RELATIVE_UNITS[5];
1815
+ return new Intl.RelativeTimeFormat(language, { numeric: 'auto' }).format(Math.round(elapsed / size), unit);
1816
+ }
1817
+
1818
+ /** Join the live session list with the history index. A history id with no live row is dropped. */
1819
+ function sessionRows(snapshot, history) {
1820
+ const ids = Array.isArray(snapshot?.ids) ? snapshot.ids : [];
1821
+ const byId = snapshot?.byId ?? {};
1822
+ const rows = [];
1823
+ for (const id of ids) {
1824
+ const row = byId[id];
1825
+ if (row === undefined || !Object.hasOwn(history, id)) continue;
1826
+ rows.push({
1827
+ id,
1828
+ title: String(row.displayTitle ?? id),
1829
+ workspace: workspaceName(row.cwd),
1830
+ updatedAt: Number.isFinite(row.updatedAt) ? row.updatedAt : 0,
1831
+ active: row.projectionValues?.studyMode?.active === true,
1832
+ studyPlus: history[id]?.studyPlus === true,
1833
+ });
1834
+ }
1835
+ return rows.sort((left, right) => right.updatedAt - left.updatedAt);
1836
+ }
1837
+
1838
+ /** Split rows into Quick, In-depth, and Past; a missing live switch value counts as off. */
1839
+ function groupSessionRows(rows) {
1840
+ const groups = { quick: [], inDepth: [], past: [] };
1841
+ for (const row of rows) {
1842
+ if (!row.active) groups.past.push(row);
1843
+ else if (row.studyPlus) groups.inDepth.push(row);
1844
+ else groups.quick.push(row);
1845
+ }
1846
+ return groups;
1847
+ }
1848
+
1849
+ // The tile is decoration; the group heading and the row label carry the meaning.
1850
+ function studyTile(kind) {
1851
+ const glyph = (children) => React.createElement('svg', {
1852
+ fill: 'none',
1853
+ height: 16,
1854
+ stroke: 'currentColor',
1855
+ strokeLinecap: 'round',
1856
+ strokeLinejoin: 'round',
1857
+ strokeWidth: 1.6,
1858
+ viewBox: '0 0 16 16',
1859
+ width: 16,
1860
+ }, ...children);
1861
+ if (kind === 'book') return glyph(BOOK.props.children);
1862
+ if (kind === 'quick') {
1863
+ return glyph([
1864
+ React.createElement('path', { d: 'M13.4 8.9a3.7 3.7 0 0 1-3.7 3.7H6.4L3.4 14.6v-2.5A3.7 3.7 0 0 1 1.8 9V7.5a3.7 3.7 0 0 1 3.7-3.7h4.2a3.7 3.7 0 0 1 3.7 3.7Z' }),
1865
+ React.createElement('path', { d: 'M5.2 7.3h5.6M5.2 9.3h3.4' }),
1866
+ ]);
1867
+ }
1868
+ return glyph([
1869
+ React.createElement('circle', { cx: 8, cy: 8, r: 5.6 }),
1870
+ React.createElement('path', { d: 'M8 5.2V8l1.9 1.3' }),
1871
+ ]);
1872
+ }
1873
+
1874
+ // Inline because the client bundle cannot read assets/.
1875
+ function StudySessionsIcon({ size = 16, active = false }) {
1876
+ return React.createElement(
1877
+ 'svg',
1878
+ {
1879
+ 'aria-hidden': 'true',
1880
+ fill: 'none',
1881
+ height: size,
1882
+ stroke: 'currentColor',
1883
+ strokeLinecap: 'round',
1884
+ strokeLinejoin: 'round',
1885
+ strokeWidth: active ? 2 : 1.6,
1886
+ viewBox: '0 0 16 16',
1887
+ width: size,
1888
+ },
1889
+ React.createElement('path', { d: 'M2.5 4h11M2.5 8h11M2.5 12h7' }),
1890
+ React.createElement('circle', {
1891
+ cx: 12.5,
1892
+ cy: 12,
1893
+ fill: 'currentColor',
1894
+ opacity: active ? 1 : 0.45,
1895
+ r: 1.2,
1896
+ stroke: 'none',
1897
+ }),
1898
+ );
1899
+ }
1900
+
1901
+ function StudySessionsPage({ useSessions, locale, openSession }) {
1902
+ const t = useT(locale);
1903
+ const language = locale.getSnapshot()?.active ?? 'en';
1904
+ const [scan, setScan] = React.useState(() => studySessionsCache === null
1905
+ ? { status: 'scanning', sessions: {}, done: 0, total: 0, cursor: 0 }
1906
+ : { ...studySessionsCache, status: 'scanning' });
1907
+ // Only a folded key reads true: In-depth and Quick start open, Past starts folded.
1908
+ const [folded, setFolded] = React.useState({ inDepth: false, quick: false, past: true });
1909
+ const snapshot = useSessions((state) => state);
1910
+
1911
+ React.useEffect(() => {
1912
+ let live = true;
1913
+ // A cached list seeds the scan, so rows stay while the host rescans.
1914
+ scanSessionHistory(studySessionsCache?.sessions ?? {}, (chunk) => {
1915
+ if (!live) return;
1916
+ studySessionsCache = chunk;
1917
+ setScan(chunk);
1918
+ }).catch((error) => {
1919
+ if (!live) return;
1920
+ setScan((previous) => ({
1921
+ ...previous,
1922
+ status: 'failed',
1923
+ reason: error?.message ?? 'the host did not answer',
1924
+ }));
1925
+ });
1926
+ return () => { live = false; };
1927
+ }, []);
1928
+
1929
+ const grouped = groupSessionRows(sessionRows(snapshot, scan.sessions));
1930
+
1931
+ // The row is one target, so its label names the title, workspace, time, and mode.
1932
+ const rowNode = (row, past) => {
1933
+ const time = relativeTime(row.updatedAt, language);
1934
+ const chip = past ? t(row.studyPlus ? STUDY_IN_DEPTH : STUDY_QUICK) : null;
1935
+ const label = [row.title, row.workspace, time, chip]
1936
+ .filter((part) => part !== '' && part !== null)
1937
+ .join(', ');
1938
+ return React.createElement('li', { className: 'study-ss-item', key: row.id },
1939
+ React.createElement('button', {
1940
+ type: 'button',
1941
+ className: 'study-ss-row',
1942
+ 'aria-label': label,
1943
+ onClick: () => openSession(row.id),
1944
+ },
1945
+ React.createElement('span', { className: 'study-ss-tile', 'aria-hidden': 'true' },
1946
+ studyTile(past ? 'past' : row.studyPlus ? 'book' : 'quick')),
1947
+ React.createElement('span', { className: 'study-ss-title' }, row.title),
1948
+ React.createElement('span', { className: 'study-ss-meta' },
1949
+ row.workspace === '' ? null : React.createElement('span', { className: 'study-ss-workspace' }, row.workspace),
1950
+ chip === null ? null : React.createElement('span', {
1951
+ className: 'study-ss-chip',
1952
+ 'data-study-chip': '',
1953
+ }, chip)),
1954
+ time === '' ? null : React.createElement('span', { className: 'study-ss-time' }, time)));
1955
+ };
1956
+
1957
+ const nodes = [];
1958
+ if (scan.status === 'scanning') {
1959
+ const percent = scan.total > 0 ? Math.round((scan.done / scan.total) * 100) : 0;
1960
+ nodes.push(React.createElement('div', { key: 'progress', className: 'study-ss-scan' },
1961
+ React.createElement('p', {
1962
+ role: 'status',
1963
+ 'aria-busy': true,
1964
+ className: 'study-ss-status',
1965
+ }, t({
1966
+ en: `Scanning session history… ${scan.done} of ${scan.total}`,
1967
+ zh: `正在扫描会话历史… ${scan.done} / ${scan.total}`,
1968
+ })),
1969
+ React.createElement('div', { className: 'study-ss-track', 'aria-hidden': 'true' },
1970
+ React.createElement('div', { className: 'study-ss-fill', style: { width: `${percent}%` } }))));
1971
+ }
1972
+ if (scan.status === 'failed') {
1973
+ nodes.push(React.createElement('p', {
1974
+ key: 'failure',
1975
+ role: 'alert',
1976
+ className: 'study-ss-error',
1977
+ },
1978
+ React.createElement('span', { className: 'study-ss-dot', 'aria-hidden': 'true' }),
1979
+ t({
1980
+ en: `The session index could not be read: ${scan.reason}`,
1981
+ zh: `无法读取会话索引:${scan.reason}`,
1982
+ })));
1983
+ }
1984
+ if (scan.status === 'ready' && Object.keys(scan.sessions).length === 0) {
1985
+ nodes.push(React.createElement('p', { key: 'empty', className: 'study-ss-empty' },
1986
+ t({
1987
+ en: 'No study session yet. Turn on Nerd Mode from the composer bar, or run',
1988
+ zh: '还没有学习会话。在输入栏打开 Nerd Mode,或运行',
1989
+ }),
1990
+ ' ',
1991
+ React.createElement('code', null, '/nerd-mode on'),
1992
+ t({ en: '.', zh: '。' })));
1993
+ }
1994
+
1995
+ const group = (key, label, list, past) => {
1996
+ if (list.length === 0) return null;
1997
+ const open = folded[key] === false;
1998
+ const listId = `study-ss-rows-${key}`;
1999
+ return React.createElement('section', { key, className: 'study-ss-group' },
2000
+ React.createElement('h2', { className: 'study-ss-group-title' },
2001
+ React.createElement('button', {
2002
+ type: 'button',
2003
+ className: 'study-ss-fold',
2004
+ 'aria-expanded': open,
2005
+ // A folded group has no list to control, so the reference would dangle.
2006
+ 'aria-controls': open ? listId : undefined,
2007
+ 'aria-label': `${t(label)} ${list.length}`,
2008
+ onClick: () => setFolded((previous) => ({ ...previous, [key]: !previous[key] })),
2009
+ },
2010
+ React.createElement('span', null, t(label)),
2011
+ React.createElement('span', { className: 'study-ss-count' }, String(list.length)),
2012
+ React.createElement('svg', {
2013
+ 'aria-hidden': 'true',
2014
+ className: 'study-ss-chev',
2015
+ fill: 'none',
2016
+ height: 12,
2017
+ stroke: 'currentColor',
2018
+ strokeLinecap: 'round',
2019
+ strokeLinejoin: 'round',
2020
+ strokeWidth: 1.6,
2021
+ viewBox: '0 0 16 16',
2022
+ width: 12,
2023
+ }, React.createElement('path', { d: 'M3.5 6 8 10.5 12.5 6' })))),
2024
+ open
2025
+ ? React.createElement('ul', { className: 'study-ss-rows', id: listId },
2026
+ ...list.map((row) => rowNode(row, past)))
2027
+ : null);
2028
+ };
2029
+ nodes.push(group('inDepth', STUDY_IN_DEPTH, grouped.inDepth, false));
2030
+ nodes.push(group('quick', STUDY_QUICK, grouped.quick, false));
2031
+ nodes.push(group('past', STUDY_PAST, grouped.past, true));
2032
+
2033
+ return React.createElement('div', {
2034
+ role: 'region',
2035
+ 'aria-label': t(STUDY_SESSIONS_TITLE),
2036
+ className: 'study-ss-page',
2037
+ }, React.createElement('style', null, STUDY_SESSIONS_CSS),
2038
+ React.createElement('header', { className: 'study-ss-head', 'data-window-drag': '' },
2039
+ React.createElement('h1', { className: 'study-ss-title' }, t(STUDY_SESSIONS_TITLE)),
2040
+ React.createElement('p', { className: 'study-ss-intro' }, t({
2041
+ en: 'Every conversation where Nerd Mode was on.',
2042
+ zh: '开启过 Nerd Mode 的会话都在这里。',
2043
+ }))),
2044
+ ...nodes);
2045
+ }
2046
+
2047
+ // The page shell, rows, and fold live in CSS so a container query can fold
2048
+ // the time under the title on narrow panes. Tokens come from the host theme.
2049
+ const STUDY_SESSIONS_CSS = `.study-ss-page{box-sizing:border-box;height:100%;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:32px;padding:0 clamp(24px,4vw,32px) 32px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,inherit);container-type:inline-size}
2050
+ .study-ss-page>*{width:100%;max-width:960px}
2051
+ .study-ss-head{display:flex;flex-direction:column;padding-top:32px}
2052
+ [data-platform="darwin"] .study-ss-head{padding-top:calc(32px + var(--dsh-frame-top-clearance,0px))}
2053
+ .study-ss-title{font-size:15px;line-height:22px;font-weight:600;margin:0}
2054
+ .study-ss-intro{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);margin:4px 0 0}
2055
+ .study-ss-group{display:flex;flex-direction:column;gap:12px}
2056
+ .study-ss-group-title{font-size:13px;line-height:20px;font-weight:600;margin:0}
2057
+ .study-ss-fold{display:inline-flex;align-items:center;gap:8px;padding:0;background:none;border:none;font:inherit;color:inherit;cursor:pointer}
2058
+ .study-ss-fold:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
2059
+ .study-ss-count{font-size:12px;line-height:18px;font-weight:400;color:var(--dsw-alias-label-tertiary)}
2060
+ .study-ss-chev{color:var(--dsw-alias-label-tertiary);transition:transform 120ms}
2061
+ .study-ss-fold[aria-expanded="false"] .study-ss-chev{transform:rotate(-90deg)}
2062
+ .study-ss-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
2063
+ .study-ss-item{margin:0}
2064
+ .study-ss-row{position:relative;width:100%;box-sizing:border-box;display:grid;grid-template-columns:32px minmax(0,1fr) auto;grid-template-areas:"tile title time" "tile meta time";column-gap:12px;row-gap:4px;align-items:start;padding:10px 12px;border:none;border-radius:10px;background:var(--dsw-alias-bg-layer-1);color:inherit;font:inherit;text-align:left;cursor:pointer;white-space:normal}
2065
+ .study-ss-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
2066
+ .study-ss-row:focus-visible{outline:none}
2067
+ .study-ss-row:focus-visible::after{content:"";position:absolute;inset:0;border-radius:inherit;outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
2068
+ .study-ss-tile{grid-area:tile;align-self:center;width:32px;height:32px;display:grid;place-items:center;border-radius:10px;background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary)}
2069
+ .study-ss-title{grid-area:title;font-size:13px;line-height:20px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2070
+ .study-ss-meta{grid-area:meta;display:flex;align-items:center;gap:8px;min-width:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);flex-wrap:wrap}
2071
+ .study-ss-workspace{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2072
+ .study-ss-time{grid-area:time;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}
2073
+ .study-ss-chip{display:inline-flex;align-items:center;height:20px;padding:0 8px;font-size:12px;line-height:16px;border:.5px solid var(--dsw-alias-border-l3);border-radius:6px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
2074
+ @container (max-width:520px){.study-ss-row{grid-template-areas:"tile title title" "tile meta time"}}
2075
+ .study-ss-scan{display:flex;flex-direction:column;gap:12px}
2076
+ .study-ss-status{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);margin:0}
2077
+ .study-ss-track{height:8px;background:var(--dsw-alias-border-l3);border-radius:6px;overflow:hidden}
2078
+ .study-ss-fill{height:100%;background:var(--dsw-alias-state-business-primary)}
2079
+ .study-ss-error{display:flex;align-items:center;gap:8px;font-size:13px;line-height:20px;color:var(--dsw-alias-state-error-primary);margin:0}
2080
+ .study-ss-dot{flex:none;width:6px;height:6px;border-radius:50%;background:currentColor}
2081
+ .study-ss-empty{text-align:center;padding:32px 16px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary);margin:0}
2082
+ .study-ss-empty code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;padding:1px 6px}`;
2083
+
2084
+ return {
2085
+ inject: ['slots', 'remote', 'remote.commands', 'remote.workspaceFiles', 'configForms'],
2086
+ apply(ctx) {
2087
+ ctx.slots.inject(SLOT, () =>
2088
+ ctx.slots.register({
2089
+ name: SLOT,
2090
+ id: 'nerd-mode',
2091
+ order: -1,
2092
+ registrant: 'dsh-nerd-mode',
2093
+ inject: (sessionId) => ({
2094
+ execute: (command) => ctx.remote.commands.execute(sessionId, command, []),
2095
+ }),
2096
+ }, NerdModeToggle),
2097
+ );
2098
+ ctx.inject(['locale'], (localeCtx) => {
2099
+ ctx.slots.inject(SETTINGS_SLOT, () =>
2100
+ ctx.slots.register({
2101
+ name: SETTINGS_SLOT,
2102
+ id: 'dsh-nerd-mode',
2103
+ order: 30,
2104
+ label: LABEL,
2105
+ }, ({ close }) => React.createElement(NerdModeSettingsEntry, {
2106
+ close,
2107
+ configForms: ctx.configForms,
2108
+ locale: localeCtx.locale,
2109
+ })),
2110
+ );
2111
+ // One id addresses both seats: the sidebar row selects the keyed main page.
2112
+ ctx.slots.inject('sidebar.panellist', () =>
2113
+ ctx.slots.register({
2114
+ name: 'sidebar.panellist',
2115
+ id: STUDY_SESSIONS_ID,
2116
+ order: STUDY_SESSIONS_ORDER,
2117
+ // The label owns its translator: the slot's `t` prop belongs to the icon.
2118
+ label: () => localeCtx.locale.resolveText(STUDY_SESSIONS_TITLE),
2119
+ }, StudySessionsIcon),
2120
+ );
2121
+ ctx.slots.inject('main', () =>
2122
+ ctx.slots.register({
2123
+ name: 'main',
2124
+ key: STUDY_SESSIONS_ID,
2125
+ inject: () => ({
2126
+ locale: localeCtx.locale,
2127
+ openSession: (id) => {
2128
+ try {
2129
+ ctx.get('uiWorkspace')?.openSession(id);
2130
+ } catch {
2131
+ // A stale row must not take the panel down.
2132
+ }
2133
+ },
2134
+ }),
2135
+ }, (props) => React.createElement(StudySessionsPage, {
2136
+ ...props,
2137
+ useSessions: typeof props.useSessions === 'function' ? props.useSessions : NO_SESSIONS_HOOK,
2138
+ })),
2139
+ );
2140
+ ctx.slots.inject(VIEW_SLOT, () =>
2141
+ ctx.slots.register({
2142
+ name: VIEW_SLOT,
2143
+ id: VIEW_ID,
2144
+ order: VIEW_ORDER,
2145
+ label: () => localeCtx.locale.resolveText(VIEW_LABEL),
2146
+ inject: (sessionId) => ({
2147
+ sessionId,
2148
+ workspaceFiles: ctx.remote.workspaceFiles,
2149
+ locale: localeCtx.locale,
2150
+ }),
2151
+ }, StudyView),
2152
+ );
2153
+ // A host without the right Sidebar never provides the registry, so this
2154
+ // one seat waits. The rest of the plugin keeps working.
2155
+ ctx.inject(['sidebarRightTabs'], (dock) => {
2156
+ dock.effect(
2157
+ () => dock.sidebarRightTabs.register(LECTURES_DEFINITION(localeCtx.locale)),
2158
+ 'dsh-nerd-mode: lectures tab type',
2159
+ );
2160
+ });
2161
+ ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
2162
+ name: 'sidebar.right.pane.tab',
2163
+ key: LECTURES_ID,
2164
+ inject: (sessionId) => ({ sessionId, workspaceFiles: ctx.remote.workspaceFiles, locale: localeCtx.locale }),
2165
+ }, LecturesPage));
2166
+ });
2167
+ },
2168
+ };
2169
+ },
2170
+ });