dsh-nerd-mode 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -1556,34 +1556,37 @@ window.__ModuleLoader__.load({
1556
1556
  return model;
1557
1557
  }
1558
1558
 
1559
- // The rows and the fold are the only elements the host cannot style for us.
1560
- const LECTURES_CSS = '.lectures-entry:focus-visible,.lectures-fold:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary, currentColor);outline-offset:2px}';
1561
- const lectureRowStyle = {
1562
- background: 'none',
1563
- border: 'none',
1564
- borderRadius: 'var(--dsw-radius-sm, 6px)',
1565
- color: 'inherit',
1566
- cursor: 'pointer',
1567
- font: 'inherit',
1568
- lineHeight: '22px',
1569
- overflowWrap: 'anywhere',
1570
- padding: '2px 4px',
1571
- textAlign: 'left',
1572
- whiteSpace: 'normal',
1573
- width: '100%',
1574
- };
1575
- const lectureFoldStyle = {
1576
- background: 'none',
1577
- border: '1px solid var(--dsw-alias-border-l2)',
1578
- borderRadius: 'var(--dsw-radius-sm, 6px)',
1579
- color: 'var(--dsw-alias-label-tertiary)',
1580
- cursor: 'pointer',
1581
- font: 'inherit',
1582
- lineHeight: '22px',
1583
- padding: '6px 10px',
1584
- textAlign: 'left',
1585
- width: '100%',
1586
- };
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}`;
1587
1590
 
1588
1591
  const LECTURE_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
1589
1592
 
@@ -1618,41 +1621,74 @@ window.__ModuleLoader__.load({
1618
1621
  return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`;
1619
1622
  }
1620
1623
 
1621
- // The whole row is the button, so its text is also its accessible name.
1624
+ // The visible title is clipped, so the label repeats it in full.
1622
1625
  function lectureRow(entry, onOpen) {
1623
1626
  const mark = lectureMark(entry);
1624
- return React.createElement('li', { key: entry.number },
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' },
1625
1631
  React.createElement('button', {
1626
1632
  type: 'button',
1627
- className: 'lectures-entry',
1633
+ className: 'lectures-row',
1634
+ 'aria-label': label,
1628
1635
  onClick: () => onOpen(entry),
1629
- style: lectureRowStyle,
1630
- }, `${entry.number} · ${entry.title}${mark === null ? '' : ` · ${mark}`}`));
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))));
1631
1645
  }
1632
1646
 
1633
1647
  function lectureTopic(group, open, onToggle, onOpen) {
1634
1648
  const main = group.entries.filter((entry) => entry.mark !== 'archived');
1635
1649
  const archived = group.entries.filter((entry) => entry.mark === 'archived');
1636
- const nodes = [React.createElement('h2', { key: 'heading', style: panelHeadingStyle }, group.topic)];
1650
+ const archiveId = `lectures-archive-${group.topic}`;
1651
+ const rows = [];
1637
1652
  if (group.entries.length === 0) {
1638
- nodes.push(React.createElement('p', { key: 'empty', style: panelMutedStyle }, 'No lectures yet.'));
1653
+ rows.push(React.createElement('p', { key: 'empty', className: 'lectures-empty' }, 'No lectures yet.'));
1639
1654
  } else if (main.length > 0) {
1640
- nodes.push(React.createElement('ul', { key: 'main', style: panelListStyle }, ...main.map((entry) => lectureRow(entry, onOpen))));
1655
+ rows.push(React.createElement('ul', { key: 'main', className: 'lectures-rows' }, ...main.map((entry) => lectureRow(entry, onOpen))));
1641
1656
  }
1642
1657
  if (archived.length > 0) {
1643
- nodes.push(React.createElement('button', {
1658
+ rows.push(React.createElement('button', {
1644
1659
  key: 'fold',
1645
1660
  type: 'button',
1646
1661
  className: 'lectures-fold',
1647
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}`,
1648
1666
  onClick: () => onToggle(group.topic),
1649
- style: lectureFoldStyle,
1650
- }, `Archived lectures (${archived.length})`));
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' }))));
1651
1682
  if (open) {
1652
- nodes.push(React.createElement('ul', { key: 'archive', style: panelListStyle }, ...archived.map((entry) => lectureRow(entry, onOpen))));
1683
+ rows.push(React.createElement('ul', { key: 'archive', className: 'lectures-rows', id: archiveId }, ...archived.map((entry) => lectureRow(entry, onOpen))));
1653
1684
  }
1654
1685
  }
1655
- return React.createElement('section', { key: group.topic, style: panelSectionStyle }, ...nodes);
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));
1656
1692
  }
1657
1693
 
1658
1694
  function LecturesPage({ useTabInfo, sessionId, workspaceFiles }) {
@@ -1680,22 +1716,24 @@ window.__ModuleLoader__.load({
1680
1716
  setSelected(course);
1681
1717
  };
1682
1718
 
1683
- const open = (entry) => tab.actions.openResource(
1684
- sessionFileAddress(sessionId, entry.path),
1685
- { preferNewPane: true },
1686
- );
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));
1687
1722
 
1688
1723
  let nodes;
1689
1724
  if (content.status === 'loading') {
1690
- nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, style: panelMutedStyle }, 'Loading the lectures…')];
1725
+ nodes = [React.createElement('p', { role: 'status', 'aria-busy': true, className: 'lectures-status' }, 'Loading the lectures…')];
1691
1726
  } else if (content.error === null && !content.rootFound) {
1692
- nodes = [React.createElement('p', { style: panelMutedStyle }, `No Course in ${TRACKER_ROOT}/. Run /study-plus to create one.`)];
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.')];
1693
1731
  } else {
1694
1732
  nodes = content.error === null
1695
1733
  ? []
1696
- : [React.createElement('p', { role: 'alert', style: panelMutedStyle }, `The lecture library could not be read: ${content.error}.`)];
1697
- const picker = topicPicker(content.courses, content.course ?? selected, choose);
1698
- if (picker !== null) nodes.push(React.createElement('div', { style: panelHeaderStyle }, picker));
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}.`)];
1699
1737
  if (content.course !== null) {
1700
1738
  nodes.push(lectureTopic(
1701
1739
  { topic: content.course, entries: content.entries },
@@ -1705,11 +1743,24 @@ window.__ModuleLoader__.load({
1705
1743
  ));
1706
1744
  }
1707
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))));
1708
1757
  return React.createElement('div', {
1709
1758
  role: 'region',
1710
1759
  'aria-label': LECTURES_LABEL,
1711
- style: panelStyle,
1712
- }, React.createElement('style', null, LECTURES_CSS), ...nodes);
1760
+ className: 'lectures-page',
1761
+ }, React.createElement('style', null, LECTURES_CSS),
1762
+ React.createElement('header', { className: 'lectures-head' }, picker),
1763
+ ...nodes);
1713
1764
  }
1714
1765
 
1715
1766
  // A page type names no resource pattern and no `multiple`, so the host
@@ -1727,6 +1778,365 @@ window.__ModuleLoader__.load({
1727
1778
  }],
1728
1779
  };
1729
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
+
1730
2140
  return {
1731
2141
  inject: ['slots', 'remote', 'remote.commands', 'remote.workspaceFiles', 'configForms'],
1732
2142
  apply(ctx) {
@@ -1754,6 +2164,35 @@ window.__ModuleLoader__.load({
1754
2164
  locale: localeCtx.locale,
1755
2165
  })),
1756
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
+ );
1757
2196
  });
1758
2197
  ctx.slots.inject(VIEW_SLOT, () =>
1759
2198
  ctx.slots.register({