dsh-plugin-lookatstudy 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -481,7 +481,11 @@ window.__ModuleLoader__.load({
481
481
  .lks-ui .lks14-board-head svg{color:var(--ink-muted);flex:none}
482
482
  .lks-ui .lks14-board-stage{flex:1;min-height:0;padding:0 8px 8px}
483
483
  .lks-ui .lks14-board-stage> .lks14-stage{border-radius:14px;border:1px solid var(--border-faint);background:rgb(var(--surface0-rgb,17 17 20)/0.6);overflow:hidden}
484
- .lks-ui .lks14-board-artifact{padding:20px;width:max-content;max-width:none}
484
+ .lks-ui .lks14-board-artifact{padding:20px;width:fit-content;max-width:1200px}
485
+ /* issue #10: the board stage handles oversize via zoom — the table lays out
486
+ normally inside a bounded card (display:block + overflow was made for the
487
+ narrow chat column; on the canvas it broke cells into overlap) */
488
+ .lks-ui .lks14-board-artifact .lks-acard-table{display:table;width:100%;max-width:100%;overflow:visible}
485
489
  .lks-ui .lks14-board-artifact .lks-acard{min-width:560px;background:var(--surface-0);border:1px solid var(--border-faint);border-radius:14px}
486
490
  .lks-ui .lks14-cmapwrap{position:relative}
487
491
  .lks-ui .lks14-cmap-expand{position:absolute;top:10px;right:10px;z-index:5}
@@ -771,6 +775,18 @@ window.__ModuleLoader__.load({
771
775
  .lks-ui .lks14-threadmenu-row.on{background:rgb(var(--brand-rgb)/0.1)}
772
776
  .lks-ui .lks14-threadmenu-row.on .lks14-threadmenu-title{color:var(--brand);font-weight:700}
773
777
  .lks-ui .lks14-threadmenu-dot{flex:none;width:6px;height:6px;border-radius:999px;background:var(--ink-faint)}
778
+ .lks-ui .lks14-threadmenu-row.new{margin-top:3px;border-top:1px solid var(--border-faint);border-radius:0 0 7px 7px;padding-top:7px}
779
+ .lks-ui .lks14-threadmenu-row{position:relative}
780
+ .lks-ui .lks14-threadmenu-gear{flex:none;margin-left:auto;border:none;background:none;color:var(--ink-faint);font-size:13px;line-height:1;cursor:pointer;padding:2px 5px;border-radius:5px;opacity:0;transition:opacity .12s,color .12s}
781
+ .lks-ui .lks14-threadmenu-row:hover .lks14-threadmenu-gear,.lks-ui .lks14-threadmenu-gear:focus-visible{opacity:1}
782
+ .lks-ui .lks14-threadmenu-gear:hover{background:rgb(var(--ink-rgb)/0.08);color:var(--ink)}
783
+ .lks-ui .lks14-threadmenu-actions{position:absolute;right:4px;top:calc(100% - 2px);z-index:70;min-width:118px;display:flex;flex-direction:column;padding:4px;border-radius:9px;background:var(--surface-0);border:1px solid var(--border);box-shadow:0 12px 32px -8px rgb(var(--shadow-rgb)/0.5)}
784
+ .lks-ui .lks14-threadmenu-actions button{border:none;background:none;font:inherit;font-size:12px;color:var(--ink);text-align:left;padding:6px 8px;border-radius:6px;cursor:pointer}
785
+ .lks-ui .lks14-threadmenu-actions button:hover{background:rgb(var(--ink-rgb)/0.06)}
786
+ .lks-ui .lks14-threadmenu-actions button.danger{color:#d2483c}
787
+ .lks-ui .lks14-threadmenu-actions button.danger:hover{background:rgb(210 72 60/0.1)}
788
+ .lks-ui .lks14-threadmenu-rename{flex:1;min-width:0;font:inherit;font-size:12px;padding:3px 6px;border:1px solid var(--brand);border-radius:6px;background:var(--surface-0);color:var(--ink);outline:none}
789
+ .lks-ui .lks14-threadmenu-plus{flex:none;width:13px;height:13px;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid var(--brand);color:var(--brand);font-size:10px;line-height:1}
774
790
  .lks-ui .lks14-threadmenu-row.on .lks14-threadmenu-dot{background:var(--brand)}
775
791
  .lks-ui .lks14-threadmenu-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
776
792
  .lks-ui .lks14-threadmenu-now{flex:none;font-size:10px;font-weight:800;color:var(--brand)}
@@ -937,6 +953,12 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
937
953
  .lks14-composer{flex:none;display:flex;gap:6px;align-items:flex-end;padding:8px 12px 10px;border-top:1px solid var(--dsw-alias-border-l1)}
938
954
  .lks14-composertext{flex:1;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:7px 10px;resize:none;box-sizing:border-box}
939
955
  .lks14-composertext:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}
956
+ /* issue #7: the composer's pending-attachment chip (thumbnail + name + remove) */
957
+ .lks14-attachchip{flex:none;display:flex;align-items:center;gap:8px;margin:0 0 6px;padding:5px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-2);max-width:100%}
958
+ .lks14-attachchip-thumb{flex:none;width:44px;height:44px;object-fit:cover;border-radius:7px;border:1px solid var(--dsw-alias-border-l1)}
959
+ .lks14-attachchip-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--dsw-alias-label-primary)}
960
+ .lks14-attachchip-x{flex:none;border:none;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;cursor:pointer;border-radius:6px;padding:2px 6px}
961
+ .lks14-attachchip-x:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
940
962
 
941
963
  /* ============================================================
942
964
  * v0.29 pane-resize 拖拽手柄(PaneResizeHandle)——
@@ -1111,6 +1133,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1111
1133
  .lks-acard-guess-opt.picked{border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-tertiary)}
1112
1134
  .lks-acard-guess-wait{font-size:12px;color:var(--dsw-alias-label-tertiary)}
1113
1135
  .lks-acard-stage{margin-top:14px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}
1136
+ /* issue #8: every artifact card wears a fold bar — folded = one thin line */
1137
+ .lks-acard-foldwrap{margin:0}
1138
+ .lks-acard-fold{display:flex;align-items:center;gap:7px;width:100%;text-align:left;font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:9px;padding:4px 10px;cursor:pointer}
1139
+ .lks-acard-fold:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
1140
+ .lks-acard-fold-caret{flex:none;color:var(--dsw-alias-label-tertiary)}
1141
+ .lks-acard-fold-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1142
+ .lks-acard-foldwrap:not(.folded) .lks-acard-fold{border-radius:9px 9px 0 0;border-bottom:none}
1143
+ .lks-acard-foldwrap:not(.folded) .lks-acard{border-radius:0 0 9px 9px}
1144
+ .lks-acard-guess-wait.revealed{color:var(--dsw-alias-state-success-primary)}
1114
1145
  .lks-acard-stage .lks-acard{margin:0}
1115
1146
  .lks14-zone .lks-acard{margin:0 0 8px}
1116
1147
  /* notebook tab badge (unseen artifacts) */
@@ -1764,16 +1795,38 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1764
1795
  this.refresh();
1765
1796
  }
1766
1797
  /** Record the dsh session backing one lesson (the simplified thread system). */
1767
- async bindLessonSession(lessonId, sessionId) {
1798
+ /**
1799
+ * Bind a session into the lesson's thread group (issue #11): a new session
1800
+ * id appends a thread (titled by `title` — first-message auto-naming);
1801
+ * `null` clears the active pointer (+新建 — the next send mints fresh).
1802
+ */
1803
+ async bindLessonSession(lessonId, sessionId, title) {
1768
1804
  await fetchJson("/lookatstudy/api/lesson-session", {
1769
1805
  method: "POST",
1770
1806
  headers: { "content-type": "application/json" },
1771
1807
  body: JSON.stringify({
1772
1808
  lessonId,
1773
- sessionId
1809
+ sessionId,
1810
+ title
1811
+ })
1812
+ });
1813
+ this.refresh();
1814
+ }
1815
+ /** Issue #11 management (upstream gear menu): rename/archive/delete one
1816
+ * thread. Returns the route's post-op snapshot (active + thread count). */
1817
+ async lessonThreadOp(lessonId, sessionId, op, extra) {
1818
+ const res = await fetchJson("/lookatstudy/api/lesson-thread", {
1819
+ method: "POST",
1820
+ headers: { "content-type": "application/json" },
1821
+ body: JSON.stringify({
1822
+ lessonId,
1823
+ sessionId,
1824
+ op,
1825
+ ...extra
1774
1826
  })
1775
1827
  });
1776
1828
  this.refresh();
1829
+ return res;
1777
1830
  }
1778
1831
  /** Delete one course (and its proposals) from the host state. */
1779
1832
  async deleteCourse(courseId) {
@@ -1987,7 +2040,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1987
2040
  examRegenerate: studyStore.examRegenerate.bind(studyStore),
1988
2041
  addUserNote: studyStore.addUserNote.bind(studyStore),
1989
2042
  recordReview: studyStore.recordReview.bind(studyStore),
1990
- bindLessonSession: studyStore.bindLessonSession.bind(studyStore)
2043
+ bindLessonSession: studyStore.bindLessonSession.bind(studyStore),
2044
+ lessonThreadOp: studyStore.lessonThreadOp.bind(studyStore)
1991
2045
  };
1992
2046
  }
1993
2047
  //#endregion
@@ -2045,6 +2099,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2045
2099
  "artifact.guess.title": "猜一猜",
2046
2100
  "artifact.guess.pick": "我选「{label}」",
2047
2101
  "artifact.guess.wait": "已记下你的直觉——导师下回合揭晓,看看你想得对不对",
2102
+ "artifact.guess.revealed": "已揭晓——结论见导师的下一条回复",
2103
+ "artifact.fold": "收起卡片(笔记区仍保留)",
2104
+ "artifact.unfold": "展开卡片",
2105
+ "artifact.kind.compare_table": "对照表",
2106
+ "artifact.kind.code_walkthrough": "代码走查",
2107
+ "artifact.kind.diagram": "示意图",
2048
2108
  "artifact.sedimented": "有新的学习产物沉淀进笔记",
2049
2109
  "zone.artifacts": "AI 产物",
2050
2110
  "note.quote.ask": "提问这段",
@@ -2164,6 +2224,16 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2164
2224
  "palette.empty": "没有匹配项",
2165
2225
  "threads.open": "{n} 条对话",
2166
2226
  "threads.now": "当前",
2227
+ "threads.new": "新建对话(下一条消息开新线)",
2228
+ "threads.actions": "线程操作",
2229
+ "rail.export.pack": "导出课程包(可再导入/分享的 Markdown)",
2230
+ "rail.export.done": "课程包已下载:{file}",
2231
+ "threads.rename": "重命名",
2232
+ "threads.archive": "归档",
2233
+ "threads.delete": "删除",
2234
+ "threads.delete.confirm": "删除「{name}」这条对话线?插件侧记录会移除;宿主会话列表中的原会话保留。",
2235
+ "review.banner": "正在复习:{lesson}",
2236
+ "review.threadTitle": "复习:{lesson}",
2167
2237
  "threads.label": "课时线程",
2168
2238
  "meter.label": "上下文用量",
2169
2239
  "meter.aria": "上下文已用 {pct}%",
@@ -2179,6 +2249,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2179
2249
  "settings.budget.on": "开启历史预算",
2180
2250
  "settings.budget.off": "关闭历史预算",
2181
2251
  "attach.sent": "我发了一个文件:{name}(工作区路径 {path})——请读取它再继续。",
2252
+ "attach.remove": "移除待发附件",
2182
2253
  "attach.fail": "附件上传失败",
2183
2254
  "attach.label": "附件(≤20MiB,存入学习工作区)",
2184
2255
  "rail.mastered": "毕业 {mastered}/{total} 课",
@@ -2391,6 +2462,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2391
2462
  "artifact.guess.title": "Take a guess",
2392
2463
  "artifact.guess.pick": "I pick \"{label}\"",
2393
2464
  "artifact.guess.wait": "Your instinct is noted — the tutor reveals next turn",
2465
+ "artifact.guess.revealed": "Revealed — see the tutor reply below for the answer",
2466
+ "artifact.fold": "Fold card (kept in the notes zone)",
2467
+ "artifact.unfold": "Unfold card",
2468
+ "artifact.kind.compare_table": "Compare table",
2469
+ "artifact.kind.code_walkthrough": "Code walkthrough",
2470
+ "artifact.kind.diagram": "Diagram",
2394
2471
  "artifact.sedimented": "A new artifact settled into the notebook",
2395
2472
  "zone.artifacts": "AI artifacts",
2396
2473
  "note.quote.ask": "Ask about this",
@@ -2511,6 +2588,16 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2511
2588
  "threads.label": "Lesson threads",
2512
2589
  "threads.open": "{n} threads",
2513
2590
  "threads.now": "current",
2591
+ "threads.new": "New thread (your next message starts it)",
2592
+ "threads.actions": "Thread actions",
2593
+ "rail.export.pack": "Export course pack (re-importable, shareable Markdown)",
2594
+ "rail.export.done": "Course pack downloaded: {file}",
2595
+ "threads.rename": "Rename",
2596
+ "threads.archive": "Archive",
2597
+ "threads.delete": "Delete",
2598
+ "threads.delete.confirm": "Delete the thread \"{name}\"? The plugin-side record is removed; the original session stays in the host session list.",
2599
+ "review.banner": "Reviewing: {lesson}",
2600
+ "review.threadTitle": "Review: {lesson}",
2514
2601
  "meter.label": "Context usage",
2515
2602
  "meter.aria": "{pct}% of context used",
2516
2603
  "meter.brk.system": "System prompt",
@@ -2525,6 +2612,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2525
2612
  "settings.budget.on": "Enable history budget",
2526
2613
  "settings.budget.off": "Disable history budget",
2527
2614
  "attach.sent": "I attached a file: {name} (workspace path {path}) — read it before continuing.",
2615
+ "attach.remove": "Remove pending attachment",
2528
2616
  "attach.fail": "Attachment upload failed",
2529
2617
  "attach.label": "Attach (≤20MiB, lands in the study workspace)",
2530
2618
  "rail.mastered": "{mastered}/{total} lessons graduated",
@@ -2765,10 +2853,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2765
2853
  }
2766
2854
  /** Render inline markup (code, bold, italic, links, images) over escaped text.
2767
2855
  * Image src allowlist: https?:// or data:image/ — folder imports inline local
2768
- * images as data URLs (capped), GitHub imports reference jsDelivr; anything
2769
- * else stays literal text (no broken relative images, no exotic schemes). */
2856
+ * images as data URLs (capped), GitHub imports reference jsDelivr; the
2857
+ * plugin's own attachment route (/lookatstudy/api/attachment/<name>, issue #7
2858
+ * — same-origin, server-side validated) renders workspace attachments;
2859
+ * anything else stays literal text (no broken relative images, no exotic
2860
+ * schemes). */
2770
2861
  function inline(escaped) {
2771
- return escaped.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+|data:image\/[^)\s]+)\)/g, (_m, alt, src) => `<img src="${src}" alt="${alt}" loading="lazy">`).replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<a href=\"$2\" target=\"_blank\" rel=\"noreferrer\">$1</a>");
2862
+ return escaped.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/!\[([^\]]*)\]\((https?:\/\/[^)\s]+|data:image\/[^)\s]+|\/lookatstudy\/api\/attachment\/[^)\s]+)\)/g, (_m, alt, src) => `<img src="${src}" alt="${alt}" loading="lazy">`).replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<a href=\"$2\" target=\"_blank\" rel=\"noreferrer\">$1</a>");
2772
2863
  }
2773
2864
  /** True when the line opens a GFM table row (pipes with a delimiter row next). */
2774
2865
  function isTableRow(line) {
@@ -3616,6 +3707,28 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3616
3707
  return stored === "rail" || stored === "chat" || stored === "note" ? stored : "chat";
3617
3708
  }
3618
3709
  /**
3710
+ * Issue #11 (upstream first-message auto-naming): a fresh thread's title is
3711
+ * the sending text, single-lined and length-capped — the switcher chip
3712
+ * truncates via CSS, storage keeps a sane whole title. Pure.
3713
+ */
3714
+ function threadAutoTitle(text, max = 80) {
3715
+ const oneLine = text.trim().replace(/\s+/g, " ");
3716
+ return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
3717
+ }
3718
+ /**
3719
+ * Issue #11: the switcher's rows = the CURRENT lesson's thread group (upstream
3720
+ * v0.5 节点 = 会话组) — every thread, freshest first, the active one marked.
3721
+ * Pure.
3722
+ */
3723
+ function threadGroupPills(group) {
3724
+ if (group === void 0) return [];
3725
+ return group.threads.filter((t) => t.status !== "archived").slice().sort((a, b) => a.lastAt < b.lastAt ? 1 : -1).map((t) => ({
3726
+ id: t.id,
3727
+ title: t.title,
3728
+ current: t.id === group.active
3729
+ }));
3730
+ }
3731
+ /**
3619
3732
  * Default expansion for one rail section: collapsed when every study lesson is
3620
3733
  * done (mastered) or not yet reachable (locked). The focus lesson's section
3621
3734
  * stays open; exam nodes never force a section open. Pure.
@@ -4526,6 +4639,27 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4526
4639
  }
4527
4640
  return depth > 0 || liveChunkOrphan;
4528
4641
  }
4642
+ /**
4643
+ * Issue #9: how long a turn-liveness window may sit WITHOUT its top seq
4644
+ * advancing before the panel reads it as stalled and unlocks the composer.
4645
+ * Generous on purpose: a legit in-flight tool call journals nothing for up to
4646
+ * its own timeout (the import tools run 180 s), so anything past 240 s with a
4647
+ * motionless window is a dead turn (cancel paths never journal turn/end).
4648
+ */
4649
+ const TURN_STALL_MS = 24e4;
4650
+ /**
4651
+ * The stall decision (pure — the wall clock lives at the poll call site):
4652
+ * an ACTIVE fold stays active while the window's top seq keeps advancing;
4653
+ * once it has been frozen for longer than stallMs the panel treats the turn
4654
+ * as over. `lastAdvanceAt === 0` means "first observation" — the clock starts
4655
+ * now, so a freshly mounted window with an old unpaired turn/start does not
4656
+ * insta-clear (it clears one stall window later if truly dead).
4657
+ */
4658
+ function turnStalled(active, lastSeq, prevLastSeq, lastAdvanceAt, now, stallMs = TURN_STALL_MS) {
4659
+ if (!active) return false;
4660
+ if (lastSeq !== prevLastSeq || lastAdvanceAt === 0) return false;
4661
+ return now - lastAdvanceAt > stallMs;
4662
+ }
4529
4663
  /** The window's highest event seq (the stop watermark's source). */
4530
4664
  function feedLastSeq(window) {
4531
4665
  if (window === void 0 || window.entries === void 0) return 0;
@@ -5843,6 +5977,54 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5843
5977
  storage.setItem(seenArtifactsKey(lessonId), JSON.stringify([.../* @__PURE__ */ new Set([...seen, ...ids])]));
5844
5978
  } catch {}
5845
5979
  }
5980
+ /** localStorage: artifact id → the picked guess option id. The pick outlives
5981
+ * the component — a remount must not re-enable the buttons (and re-send). */
5982
+ const GUESS_PICKED_KEY = "dsh-plugin-lookatstudy:guess-picked";
5983
+ function guessPickedOf(artifactId, storage = localStorage) {
5984
+ try {
5985
+ const parsed = JSON.parse(storage.getItem(GUESS_PICKED_KEY) ?? "{}");
5986
+ if (parsed !== null && typeof parsed === "object") {
5987
+ const v = parsed[artifactId];
5988
+ if (typeof v === "string") return v;
5989
+ }
5990
+ } catch {}
5991
+ return null;
5992
+ }
5993
+ function markGuessPicked(artifactId, optionId, storage = localStorage) {
5994
+ try {
5995
+ let map = {};
5996
+ try {
5997
+ const parsed = JSON.parse(storage.getItem(GUESS_PICKED_KEY) ?? "{}");
5998
+ if (parsed !== null && typeof parsed === "object") map = parsed;
5999
+ } catch {}
6000
+ map[artifactId] = optionId;
6001
+ storage.setItem(GUESS_PICKED_KEY, JSON.stringify(map));
6002
+ } catch {}
6003
+ }
6004
+ /** localStorage: artifact id → explicitly toggled fold state. Absent = the
6005
+ * mount's defaultFolded (feed cards open, sedimented surfaces folded). */
6006
+ const ACARD_FOLD_KEY = "dsh-plugin-lookatstudy:acard-folded";
6007
+ function acardFoldStoredOf(artifactId, storage = localStorage) {
6008
+ try {
6009
+ const parsed = JSON.parse(storage.getItem(ACARD_FOLD_KEY) ?? "{}");
6010
+ if (parsed !== null && typeof parsed === "object") {
6011
+ const v = parsed[artifactId];
6012
+ if (typeof v === "boolean") return v;
6013
+ }
6014
+ } catch {}
6015
+ return null;
6016
+ }
6017
+ function markAcardFoldStored(artifactId, folded, storage = localStorage) {
6018
+ try {
6019
+ let map = {};
6020
+ try {
6021
+ const parsed = JSON.parse(storage.getItem(ACARD_FOLD_KEY) ?? "{}");
6022
+ if (parsed !== null && typeof parsed === "object") map = parsed;
6023
+ } catch {}
6024
+ map[artifactId] = folded;
6025
+ storage.setItem(ACARD_FOLD_KEY, JSON.stringify(map));
6026
+ } catch {}
6027
+ }
5846
6028
  /** The cards render mermaid through the shared CDN pipeline
5847
6029
  * (enhanceDiagrams consumes pre > code.lang-mermaid blocks). */
5848
6030
  async function renderMermaidInto(el, code, onError) {
@@ -5949,13 +6131,16 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5949
6131
  })))) : null);
5950
6132
  }
5951
6133
  /** The opening guess: two big options, click = pick (sent to the tutor, who
5952
- * reveals next turn); unscored — no right/wrong language anywhere. */
5953
- function GuessCard({ artifact, send }) {
6134
+ * reveals next turn); unscored — no right/wrong language anywhere.
6135
+ * Issue #8 lifecycle: the pick persists per artifact (a remount keeps the
6136
+ * buttons disabled — re-clicking used to re-send a message); once a later
6137
+ * assistant reply exists (`revealed`), the wait line becomes the reveal note. */
6138
+ function GuessCard({ artifact, send, revealed = false }) {
5954
6139
  const prompt = typeof artifact.data.prompt === "string" ? artifact.data.prompt : "";
5955
6140
  const options = artifact.data.options ?? [];
5956
- const [picked, setPicked] = (0, react.useState)(null);
6141
+ const [picked, setPicked] = (0, react.useState)(() => guessPickedOf(artifact.id));
5957
6142
  (0, react.useEffect)(() => {
5958
- setPicked(null);
6143
+ setPicked(guessPickedOf(artifact.id));
5959
6144
  }, [artifact.id]);
5960
6145
  return (0, react.createElement)("div", {
5961
6146
  className: "lks-acard lks-acard-guess",
@@ -5966,23 +6151,67 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5966
6151
  disabled: picked !== null,
5967
6152
  onClick: () => {
5968
6153
  setPicked(opt.id);
6154
+ markGuessPicked(artifact.id, opt.id);
5969
6155
  send(tr("artifact.guess.pick", { label: opt.label }));
5970
6156
  }
5971
- }, opt.label))), picked !== null ? (0, react.createElement)("div", { className: "lks-acard-guess-wait" }, tr("artifact.guess.wait")) : null);
6157
+ }, opt.label))), picked !== null ? (0, react.createElement)("div", { className: `lks-acard-guess-wait${revealed ? " revealed" : ""}` }, revealed ? tr("artifact.guess.revealed") : tr("artifact.guess.wait")) : null);
5972
6158
  }
5973
6159
  /** Dispatch one artifact row to its card. */
5974
- function ArtifactCard({ artifact, send }) {
6160
+ function ArtifactCard({ artifact, send, revealed = false }) {
5975
6161
  switch (artifact.artifactType) {
5976
6162
  case "compare_table": return (0, react.createElement)(CompareTableCard, { artifact });
5977
6163
  case "code_walkthrough": return (0, react.createElement)(CodeWalkthroughCard, { artifact });
5978
6164
  case "diagram": return (0, react.createElement)(DiagramCard, { artifact });
5979
6165
  case "guess": return (0, react.createElement)(GuessCard, {
5980
6166
  artifact,
5981
- send
6167
+ send,
6168
+ revealed
5982
6169
  });
5983
6170
  default: return null;
5984
6171
  }
5985
6172
  }
6173
+ /** The fold bar's kind label (fallback when the artifact carries no title). */
6174
+ const KIND_LABEL_KEY = {
6175
+ compare_table: "artifact.kind.compare_table",
6176
+ code_walkthrough: "artifact.kind.code_walkthrough",
6177
+ diagram: "artifact.kind.diagram",
6178
+ guess: "artifact.guess.title"
6179
+ };
6180
+ /**
6181
+ * Issue #8: every artifact card wears a fold bar — cards sediment into the
6182
+ * notebook by design, so nothing is deleted; folding keeps long conversations
6183
+ * readable (a folded card is one thin line). The explicit toggle persists per
6184
+ * artifact; absent a stored choice, `defaultFolded` decides (feed cards open,
6185
+ * the teach-stage and backlog surfaces start folded — the lesson prose owns
6186
+ * the first screen, issue #10).
6187
+ */
6188
+ function FoldableArtifactCard({ artifact, send, defaultFolded = false, revealed = false }) {
6189
+ const [folded, setFolded] = (0, react.useState)(() => acardFoldStoredOf(artifact.id) ?? defaultFolded);
6190
+ const toggle = () => {
6191
+ setFolded((cur) => {
6192
+ const next = !cur;
6193
+ markAcardFoldStored(artifact.id, next);
6194
+ return next;
6195
+ });
6196
+ };
6197
+ const title = artifact.title.trim() !== "" ? artifact.title : tr(KIND_LABEL_KEY[artifact.artifactType] ?? "artifact.fold");
6198
+ return (0, react.createElement)("div", {
6199
+ className: `lks-acard-foldwrap${folded ? " folded" : ""}`,
6200
+ "data-lks-artifact": artifact.id
6201
+ }, (0, react.createElement)("button", {
6202
+ className: "lks-acard-fold",
6203
+ "aria-expanded": String(!folded),
6204
+ "data-tooltip": folded ? tr("artifact.unfold") : tr("artifact.fold"),
6205
+ onClick: toggle
6206
+ }, (0, react.createElement)("span", {
6207
+ className: "lks-acard-fold-caret",
6208
+ "aria-hidden": "true"
6209
+ }, folded ? "▸" : "▾"), (0, react.createElement)("span", { className: "lks-acard-fold-title" }, title)), folded ? null : (0, react.createElement)(ArtifactCard, {
6210
+ artifact,
6211
+ send,
6212
+ revealed
6213
+ }));
6214
+ }
5986
6215
  //#endregion
5987
6216
  //#region src/client/highlights.ts
5988
6217
  /**
@@ -7095,7 +7324,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7095
7324
  };
7096
7325
  }
7097
7326
  function StudyPanelBody({ ctx }) {
7098
- const { data, activate, setMode, setFocus, searchLessons, deleteCourse, deleteNote, bindLessonSession, uploadAttachment } = useStudy();
7327
+ const { data, activate, setMode, setFocus, searchLessons, deleteCourse, deleteNote, bindLessonSession, lessonThreadOp, uploadAttachment } = useStudy();
7099
7328
  (0, react.useEffect)(() => {
7100
7329
  wireCodeBlockCopy();
7101
7330
  }, []);
@@ -7188,6 +7417,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7188
7417
  const [feedGen, setFeedGen] = (0, react.useState)(false);
7189
7418
  const lastSeqRef = (0, react.useRef)(0);
7190
7419
  const stopWatermarkRef = (0, react.useRef)(null);
7420
+ const lastAdvanceAtRef = (0, react.useRef)(0);
7191
7421
  const examSessionRef = (0, react.useRef)({
7192
7422
  active: false,
7193
7423
  terminate: null
@@ -7241,6 +7471,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7241
7471
  const lesson = data?.lesson ?? null;
7242
7472
  const examLessonActive = lesson !== null && lesson.kind === "exam" && examOpen(data?.courses.flatMap((c) => c.sections).find((s) => s.lessons.some((l) => l.id === lesson.lessonId))?.lessons ?? []);
7243
7473
  const localBound = (0, react.useRef)(null);
7474
+ const boundSessionOf = (lessonId) => lessonId !== null && localBound.current?.lessonId === lessonId ? localBound.current.sessionId : null;
7244
7475
  const streamEl = (0, react.useRef)(null);
7245
7476
  const touchStart = (0, react.useRef)(null);
7246
7477
  const activeFace = (0, react.useRef)(null);
@@ -7249,7 +7480,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7249
7480
  try {
7250
7481
  let face = activeFace.current;
7251
7482
  if (face === null) {
7252
- const sessionId = localBound.current ?? (lesson !== null ? data?.lessonSessions[lesson.lessonId] ?? null : null);
7483
+ const sessionId = boundSessionOf(lesson !== null ? lesson.lessonId : null) ?? (lesson !== null ? data?.lessonThreads[lesson.lessonId]?.active ?? data?.lessonSessions[lesson.lessonId] ?? null : null);
7253
7484
  if (sessionId !== null && sessionKnown(ctx, sessionId)) {
7254
7485
  const actx = ctx.sessions.scope(sessionId);
7255
7486
  face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx) ?? null;
@@ -7285,7 +7516,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7285
7516
  } else celebrate(ev.kind);
7286
7517
  prevCelebrationRef.current = snapshot;
7287
7518
  }, [data]);
7288
- const boundId = localBound.current ?? (lesson !== null ? data?.lessonSessions[lesson.lessonId] ?? null : null);
7519
+ const boundId = boundSessionOf(lesson !== null ? lesson.lessonId : null) ?? (lesson !== null ? data?.lessonThreads[lesson.lessonId]?.active ?? data?.lessonSessions[lesson.lessonId] ?? null : null);
7289
7520
  const nudged = (0, react.useRef)(false);
7290
7521
  (0, react.useEffect)(() => {
7291
7522
  if (nudged.current || (data?.dueCount ?? 0) === 0) return;
@@ -7389,8 +7620,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7389
7620
  const update = () => {
7390
7621
  const snap = source.getSnapshot();
7391
7622
  setRows(feedRows(snap));
7392
- lastSeqRef.current = feedLastSeq(snap);
7393
- setFeedGen(feedTurnActive(snap, stopWatermarkRef.current));
7623
+ const lastSeq = feedLastSeq(snap);
7624
+ const prevSeq = lastSeqRef.current;
7625
+ if (lastSeq !== prevSeq || lastAdvanceAtRef.current === 0) lastAdvanceAtRef.current = Date.now();
7626
+ lastSeqRef.current = lastSeq;
7627
+ const active = feedTurnActive(snap, stopWatermarkRef.current);
7628
+ setFeedGen(active && !turnStalled(active, lastSeq, prevSeq, lastAdvanceAtRef.current, Date.now()));
7394
7629
  };
7395
7630
  update();
7396
7631
  setFeedAttached(true);
@@ -7444,16 +7679,31 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7444
7679
  const el = streamEl.current;
7445
7680
  if (el !== null) el.scrollTop = el.scrollHeight;
7446
7681
  }, [rows]);
7447
- /** The one send path: lazily stage the lesson thread, then prompt. */
7448
- const send = (text) => {
7682
+ (0, react.useEffect)(() => {
7683
+ if (!feedGen) return;
7684
+ const iv = setInterval(() => {
7685
+ if (Date.now() - lastAdvanceAtRef.current > 24e4) setFeedGen(false);
7686
+ }, 5e3);
7687
+ return () => {
7688
+ clearInterval(iv);
7689
+ };
7690
+ }, [feedGen]);
7691
+ /** The one send path: lazily stage the lesson thread, then prompt.
7692
+ * `targetLessonId` (issue #11 review routing) sends into ANOTHER lesson's
7693
+ * group: focus moves there first (the tutor's snapshot follows), then the
7694
+ * target's active thread — or a fresh titled one — carries the prompt. */
7695
+ const send = (text, threadTitle, targetLessonId) => {
7449
7696
  (async () => {
7450
7697
  if (busy || lesson === null || text.trim() === "") return;
7451
7698
  setBusy(true);
7452
7699
  setSendError(null);
7453
7700
  stopWatermarkRef.current = null;
7701
+ lastAdvanceAtRef.current = Date.now();
7454
7702
  try {
7455
7703
  if (data?.active !== true) await activate(true);
7456
- let sessionId = localBound.current ?? data?.lessonSessions[lesson.lessonId] ?? null;
7704
+ if (targetLessonId !== void 0 && targetLessonId !== lesson.lessonId) await setFocus(targetLessonId).catch(() => {});
7705
+ const bindId = targetLessonId ?? lesson.lessonId;
7706
+ let sessionId = boundSessionOf(bindId) ?? data?.lessonThreads[bindId]?.active ?? data?.lessonSessions[bindId] ?? null;
7457
7707
  if (sessionId !== null && !sessionKnown(ctx, sessionId)) sessionId = null;
7458
7708
  if (sessionId === null) {
7459
7709
  const area = await fetch("/lookatstudy/api/study-workspace");
@@ -7461,9 +7711,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7461
7711
  const { path } = await area.json();
7462
7712
  const workspace = await ctx.workspaces.create({ path });
7463
7713
  sessionId = await ctx.sessions.create({ workspaceId: workspace.workspaceId });
7464
- localBound.current = sessionId;
7465
- await bindLessonSession(lesson.lessonId, sessionId);
7466
- }
7714
+ localBound.current = {
7715
+ lessonId: bindId,
7716
+ sessionId
7717
+ };
7718
+ await bindLessonSession(bindId, sessionId, threadTitle ?? threadAutoTitle(text));
7719
+ } else localBound.current = {
7720
+ lessonId: bindId,
7721
+ sessionId
7722
+ };
7467
7723
  const shell = PANEL_SHELL.current;
7468
7724
  if (shell !== null) shell.suppressHandBack(() => {
7469
7725
  ctx.sessions.open(sessionId);
@@ -7497,6 +7753,50 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7497
7753
  window.removeEventListener("lookatstudy-exam-generate", onGenerate);
7498
7754
  };
7499
7755
  }, [send]);
7756
+ const switchLessonThread = (sessionId) => {
7757
+ if (lesson === null) return;
7758
+ if (localBound.current?.lessonId === lesson.lessonId && localBound.current.sessionId === sessionId) return;
7759
+ localBound.current = {
7760
+ lessonId: lesson.lessonId,
7761
+ sessionId
7762
+ };
7763
+ bindLessonSession(lesson.lessonId, sessionId);
7764
+ const shell = PANEL_SHELL.current;
7765
+ if (shell !== null) shell.suppressHandBack(() => {
7766
+ ctx.sessions.open(sessionId);
7767
+ });
7768
+ else ctx.sessions.open(sessionId);
7769
+ };
7770
+ const startNewThread = () => {
7771
+ if (lesson === null) return;
7772
+ localBound.current = null;
7773
+ bindLessonSession(lesson.lessonId, null);
7774
+ setRows([]);
7775
+ setFeedGen(false);
7776
+ };
7777
+ const renameLessonThreadUi = (sessionId, title) => {
7778
+ if (lesson === null) return;
7779
+ lessonThreadOp(lesson.lessonId, sessionId, "rename", { title }).then(() => {
7780
+ const actx = ctx.sessions.scope(sessionId);
7781
+ (actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx))?.rename?.(title).catch(() => {});
7782
+ }, (err) => {
7783
+ setSendError(friendlyError(err, "action"));
7784
+ });
7785
+ };
7786
+ const archiveLessonThreadUi = (sessionId) => {
7787
+ if (lesson === null) return;
7788
+ if (localBound.current?.lessonId === lesson.lessonId && localBound.current.sessionId === sessionId) localBound.current = null;
7789
+ lessonThreadOp(lesson.lessonId, sessionId, "archive", { archived: true }).catch((err) => {
7790
+ setSendError(friendlyError(err, "action"));
7791
+ });
7792
+ };
7793
+ const deleteLessonThreadUi = (sessionId) => {
7794
+ if (lesson === null) return;
7795
+ if (localBound.current?.lessonId === lesson.lessonId && localBound.current.sessionId === sessionId) localBound.current = null;
7796
+ lessonThreadOp(lesson.lessonId, sessionId, "delete").catch((err) => {
7797
+ setSendError(friendlyError(err, "action"));
7798
+ });
7799
+ };
7500
7800
  const progress = data?.progress ?? null;
7501
7801
  const body = (0, react.createElement)("div", {
7502
7802
  className: "lks14-body",
@@ -7587,10 +7887,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7587
7887
  stop,
7588
7888
  setMode,
7589
7889
  narrowPane,
7590
- onThreadJump: (id) => {
7890
+ onThreadSwitch: (sessionId) => {
7591
7891
  setNarrowPane("chat");
7592
- guardedSetFocus(id);
7892
+ switchLessonThread(sessionId);
7893
+ },
7894
+ onThreadNew: () => {
7895
+ setNarrowPane("chat");
7896
+ startNewThread();
7593
7897
  },
7898
+ onThreadRename: renameLessonThreadUi,
7899
+ onThreadArchive: archiveLessonThreadUi,
7900
+ onThreadDelete: deleteLessonThreadUi,
7594
7901
  contextMeter,
7595
7902
  contextBreakdown: breakdown,
7596
7903
  uploadAttachment
@@ -8056,7 +8363,30 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8056
8363
  bottom: r.bottom
8057
8364
  } });
8058
8365
  }
8059
- }, (0, react.createElement)(IconTrashOutline16, { size: 14 }))), (0, react.createElement)("div", {
8366
+ }, (0, react.createElement)(IconTrashOutline16, { size: 14 })), (0, react.createElement)("button", {
8367
+ className: "lks-btn ghost",
8368
+ "data-tooltip": tr("rail.export.pack"),
8369
+ "data-testid": "course-export",
8370
+ onClick: () => {
8371
+ if (courseId === null) return;
8372
+ (async () => {
8373
+ try {
8374
+ const r = await fetch(`/lookatstudy/api/course-pack?courseId=${encodeURIComponent(courseId)}`);
8375
+ const body = await r.json();
8376
+ if (!r.ok || body.ok !== true || body.markdown === void 0) throw new Error(`HTTP ${String(r.status)}`);
8377
+ const blob = new Blob([body.markdown], { type: "text/markdown;charset=utf-8" });
8378
+ const a = document.createElement("a");
8379
+ a.href = URL.createObjectURL(blob);
8380
+ a.download = body.fileName ?? "course-pack.md";
8381
+ a.click();
8382
+ URL.revokeObjectURL(a.href);
8383
+ showStudyToast(tr("rail.export.done", { file: a.download }), { severity: "success" });
8384
+ } catch (err) {
8385
+ showStudyToast(friendlyError(err, "action"), { severity: "error" });
8386
+ }
8387
+ })();
8388
+ }
8389
+ }, (0, react.createElement)(IconDownloadOutline16, { size: 14 }))), (0, react.createElement)("div", {
8060
8390
  className: `lks14-masteryhair${course.avgMasteryPct === 100 ? " gold" : ""}`,
8061
8391
  title: course.avgMasteryPct === null ? tr("rail.avg.none") : tr("rail.avg", { pct: course.avgMasteryPct })
8062
8392
  }, (0, react.createElement)("i", { style: { transform: `scaleX(${(course.avgMasteryPct ?? 0) / 100})` } })), (0, react.createElement)("div", { className: "lks14-railcard-row tools" }, (0, react.createElement)("button", {
@@ -8219,8 +8549,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8219
8549
  }, (0, react.createElement)(IconRefreshOutline16, { size: 13 }), tr("review.random")), (0, react.createElement)("button", {
8220
8550
  className: "lks-btn primary",
8221
8551
  onClick: () => {
8552
+ const first = data.due[0];
8222
8553
  setReviewOpen(false);
8223
- send(tr("prompt.review"));
8554
+ if (first !== void 0) {
8555
+ showStudyToast(tr("review.banner", { lesson: first.lessonTitle }), { severity: "info" });
8556
+ send(tr("prompt.review"), tr("review.threadTitle", { lesson: first.lessonTitle }), first.lessonId);
8557
+ } else send(tr("prompt.review"));
8224
8558
  }
8225
8559
  }, tr("rail.due.start"))) : (0, react.createElement)("div", { className: "lks14-empty" }, tr("rail.due.none"))) : null;
8226
8560
  const effectivePanel = data !== null && data.courses.length === 0 ? "import" : panel;
@@ -8500,10 +8834,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8500
8834
  return cut <= 0 ? path : norm.slice(0, cut);
8501
8835
  }
8502
8836
  /** 中栏:the tutor chat stream with its own composer (upstream ChatStream + ChatComposer). */
8503
- function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, onThreadJump, contextMeter, contextBreakdown, uploadAttachment }) {
8837
+ function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, onThreadSwitch, onThreadNew, onThreadRename, onThreadArchive, onThreadDelete, contextMeter, contextBreakdown, uploadAttachment }) {
8504
8838
  const stuck = (0, react.useRef)(true);
8505
8839
  const [showFab, setShowFab] = (0, react.useState)(false);
8506
8840
  const [threadsOpen, setThreadsOpen] = (0, react.useState)(false);
8841
+ const [gearFor, setGearFor] = (0, react.useState)(null);
8842
+ const [renaming, setRenaming] = (0, react.useState)(null);
8843
+ const [confirmThreadDel, setConfirmThreadDel] = (0, react.useState)(null);
8844
+ const [pendingAttach, setPendingAttach] = (0, react.useState)(null);
8507
8845
  const onStreamScroll = () => {
8508
8846
  const el = streamEl.current;
8509
8847
  if (el === null) return;
@@ -8701,7 +9039,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8701
9039
  return ids;
8702
9040
  }, [rowsView]);
8703
9041
  const backlog = (0, react.useMemo)(() => lesson !== null ? sedimentBacklog(lesson.artifacts, inlineIds, unseenArtifacts(lesson.lessonId, lesson.artifacts)) : [], [lesson, inlineIds]);
8704
- const inlineArtifactCard = (row) => {
9042
+ const inlineArtifactCard = (row, revealed = false) => {
8705
9043
  if (lesson === null || row.artifactId === void 0) return null;
8706
9044
  const artifact = lesson.artifacts.find((a) => a.id === row.artifactId);
8707
9045
  if (artifact === void 0) return chatRow({
@@ -8716,26 +9054,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8716
9054
  masteryPct: lesson.masteryPct,
8717
9055
  send,
8718
9056
  onFinished: () => {}
8719
- }) : (0, react.createElement)(ArtifactCard, {
9057
+ }) : (0, react.createElement)(FoldableArtifactCard, {
8720
9058
  artifact,
8721
- send
9059
+ send,
9060
+ revealed
8722
9061
  }));
8723
9062
  };
8724
- const threadPills = [];
8725
- if (data !== null) {
8726
- const titles = /* @__PURE__ */ new Map();
8727
- for (const c of data.courses) for (const s of c.sections) for (const l of s.lessons) titles.set(l.id, l.title);
8728
- for (const [lessonId, sessionId] of Object.entries(data.lessonSessions)) {
8729
- if (sessionId === void 0) continue;
8730
- const title = titles.get(lessonId);
8731
- if (title === void 0) continue;
8732
- threadPills.push({
8733
- id: lessonId,
8734
- title,
8735
- current: lesson !== null && lesson.lessonId === lessonId
8736
- });
8737
- }
8738
- }
9063
+ const threadPills = threadGroupPills(lesson !== null && data !== null ? data.lessonThreads[lesson.lessonId] : void 0);
8739
9064
  const lastAssistant = rowsView.reduce((acc, row, i) => row.role === "assistant" ? i : acc, -1);
8740
9065
  const [error, setError] = (0, react.useState)(null);
8741
9066
  const fire = (action) => {
@@ -8755,18 +9080,31 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8755
9080
  for (let i = 0; i < bytes.length; i += CHUNK) bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
8756
9081
  return uploadAttachment(file.name, btoa(bin));
8757
9082
  }).then((path) => {
8758
- send(tr("attach.sent", {
9083
+ setPendingAttach({
8759
9084
  name: file.name,
8760
- path
8761
- }));
9085
+ path,
9086
+ image: /\.(?:png|jpe?g|gif|webp|bmp)$/i.test(file.name)
9087
+ });
8762
9088
  }, () => {
8763
9089
  showStudyToast(tr("attach.fail"), { severity: "error" });
8764
9090
  });
8765
9091
  };
9092
+ /** One send = draft + pending attachment merged into a single message (issue #7). */
9093
+ const submitComposer = () => {
9094
+ if (busy) return;
9095
+ const text = draft.trim();
9096
+ if (text === "" && pendingAttach === null) return;
9097
+ const attachLine = pendingAttach === null ? "" : tr("attach.sent", {
9098
+ name: pendingAttach.name,
9099
+ path: pendingAttach.path
9100
+ });
9101
+ setPendingAttach(null);
9102
+ send(attachLine === "" ? text : text === "" ? attachLine : `${text}\n\n${attachLine}`);
9103
+ };
8766
9104
  const onComposerKey = (e) => {
8767
9105
  if (e.key === "Enter" && !e.shiftKey) {
8768
9106
  e.preventDefault();
8769
- if (draft.trim() !== "" && !busy) send(draft);
9107
+ if ((draft.trim() !== "" || pendingAttach !== null) && !busy) submitComposer();
8770
9108
  }
8771
9109
  };
8772
9110
  const proposal = data?.pendingProposals[0] ?? null;
@@ -8823,19 +9161,115 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8823
9161
  }, tr("threads.open", { n: String(threadPills.length) }), (0, react.createElement)("span", { className: "lks14-threadchip-caret" }, threadsOpen ? "▾" : "▸")), threadsOpen ? (0, react.createElement)("div", {
8824
9162
  className: "lks14-threadmenu",
8825
9163
  role: "menu"
8826
- }, ...threadPills.map((p) => (0, react.createElement)("button", {
9164
+ }, ...threadPills.map((p) => (0, react.createElement)("div", {
8827
9165
  key: p.id,
8828
9166
  className: `lks14-threadmenu-row${p.current ? " on" : ""}`,
8829
9167
  role: "menuitem",
9168
+ tabIndex: 0,
8830
9169
  "aria-current": p.current ? "true" : void 0,
8831
9170
  onClick: () => {
9171
+ if (renaming !== null && renaming.id === p.id) return;
8832
9172
  setThreadsOpen(false);
8833
- if (!p.current) onThreadJump(p.id);
9173
+ setGearFor(null);
9174
+ if (!p.current) onThreadSwitch(p.id);
8834
9175
  }
8835
9176
  }, (0, react.createElement)("span", {
8836
9177
  className: "lks14-threadmenu-dot",
8837
9178
  "aria-hidden": "true"
8838
- }), (0, react.createElement)("span", { className: "lks14-threadmenu-title" }, p.title), p.current ? (0, react.createElement)("span", { className: "lks14-threadmenu-now" }, tr("threads.now")) : null))) : null) : null) : null, (0, react.createElement)("div", {
9179
+ }), renaming !== null && renaming.id === p.id ? (0, react.createElement)("input", {
9180
+ className: "lks14-threadmenu-rename",
9181
+ value: renaming.value,
9182
+ autoFocus: true,
9183
+ "data-testid": "thread-rename-input",
9184
+ onChange: (e) => {
9185
+ setRenaming({
9186
+ id: p.id,
9187
+ value: e.target.value
9188
+ });
9189
+ },
9190
+ onClick: (e) => {
9191
+ e.stopPropagation();
9192
+ },
9193
+ onKeyDown: (e) => {
9194
+ if (e.key === "Enter") {
9195
+ const v = renaming.value.trim();
9196
+ if (v !== "") onThreadRename(p.id, v);
9197
+ setRenaming(null);
9198
+ }
9199
+ if (e.key === "Escape") setRenaming(null);
9200
+ },
9201
+ onBlur: () => {
9202
+ const v = renaming.value.trim();
9203
+ if (v !== "" && v !== p.title) onThreadRename(p.id, v);
9204
+ setRenaming(null);
9205
+ }
9206
+ }) : (0, react.createElement)("span", { className: "lks14-threadmenu-title" }, p.title), p.current ? (0, react.createElement)("span", { className: "lks14-threadmenu-now" }, tr("threads.now")) : null, (0, react.createElement)("button", {
9207
+ className: "lks14-threadmenu-gear",
9208
+ "aria-label": tr("threads.actions"),
9209
+ "data-tooltip": tr("threads.actions"),
9210
+ "data-testid": `thread-gear`,
9211
+ onClick: (e) => {
9212
+ e.stopPropagation();
9213
+ setGearFor(gearFor === p.id ? null : p.id);
9214
+ }
9215
+ }, "⋯"), gearFor === p.id ? (0, react.createElement)("div", {
9216
+ className: "lks14-threadmenu-actions",
9217
+ role: "menu",
9218
+ "data-testid": "thread-actions",
9219
+ onClick: (e) => {
9220
+ e.stopPropagation();
9221
+ }
9222
+ }, (0, react.createElement)("button", { onClick: () => {
9223
+ setGearFor(null);
9224
+ setRenaming({
9225
+ id: p.id,
9226
+ value: p.title
9227
+ });
9228
+ } }, tr("threads.rename")), (0, react.createElement)("button", { onClick: () => {
9229
+ setGearFor(null);
9230
+ onThreadArchive(p.id);
9231
+ } }, tr("threads.archive")), (0, react.createElement)("button", {
9232
+ className: "danger",
9233
+ "data-testid": "thread-delete",
9234
+ onClick: (e) => {
9235
+ const r = e.currentTarget.getBoundingClientRect();
9236
+ setConfirmThreadDel({
9237
+ id: p.id,
9238
+ title: p.title,
9239
+ rect: {
9240
+ left: r.left,
9241
+ top: r.top,
9242
+ right: r.right,
9243
+ bottom: r.bottom
9244
+ }
9245
+ });
9246
+ setGearFor(null);
9247
+ }
9248
+ }, tr("threads.delete"))) : null)), (0, react.createElement)("button", {
9249
+ key: "__new__",
9250
+ className: "lks14-threadmenu-row new",
9251
+ role: "menuitem",
9252
+ onClick: () => {
9253
+ setThreadsOpen(false);
9254
+ onThreadNew();
9255
+ }
9256
+ }, (0, react.createElement)("span", {
9257
+ className: "lks14-threadmenu-plus",
9258
+ "aria-hidden": "true"
9259
+ }, "+"), (0, react.createElement)("span", { className: "lks14-threadmenu-title" }, tr("threads.new"))), confirmThreadDel !== null ? (0, react.createElement)(ConfirmCard, {
9260
+ anchor: confirmThreadDel.rect,
9261
+ message: tr("threads.delete.confirm", { name: confirmThreadDel.title }),
9262
+ danger: true,
9263
+ confirmLabel: tr("threads.delete"),
9264
+ onConfirm: () => {
9265
+ const id = confirmThreadDel.id;
9266
+ setConfirmThreadDel(null);
9267
+ onThreadDelete(id);
9268
+ },
9269
+ onCancel: () => {
9270
+ setConfirmThreadDel(null);
9271
+ }
9272
+ }) : null) : null) : null) : null, (0, react.createElement)("div", {
8839
9273
  className: "lks14-stream",
8840
9274
  onScroll: onStreamScroll,
8841
9275
  ref: streamEl,
@@ -8846,7 +9280,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8846
9280
  onClick: () => {
8847
9281
  send(starters[0]?.message ?? tr("prompt.start"));
8848
9282
  }
8849
- }, tr("chat.start")) : null) : rowsView.map((row, i) => row.role === "artifact" ? inlineArtifactCard(row) : chatRow(row, !dormant && i === lastAssistant ? { send } : void 0, row.role === "assistant" ? {
9283
+ }, tr("chat.start")) : null) : rowsView.map((row, i) => row.role === "artifact" ? inlineArtifactCard(row, rowsView.slice(i + 1).some((r) => r.role === "assistant")) : chatRow(row, !dormant && i === lastAssistant ? { send } : void 0, row.role === "assistant" ? {
8850
9284
  playing: msgAudio?.key === row.key,
8851
9285
  index: msgAudio?.index ?? 0,
8852
9286
  total: msgAudio?.total ?? 0,
@@ -8860,10 +9294,11 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8860
9294
  masteryPct: lesson.masteryPct,
8861
9295
  send,
8862
9296
  onFinished: () => {}
8863
- })), ...backlog.filter((a) => a.artifactType !== "quiz").map((a) => (0, react.createElement)(ArtifactCard, {
9297
+ })), ...backlog.filter((a) => a.artifactType !== "quiz").map((a) => (0, react.createElement)(FoldableArtifactCard, {
8864
9298
  key: a.id,
8865
9299
  artifact: a,
8866
- send
9300
+ send,
9301
+ defaultFolded: true
8867
9302
  })), rows.length > 0 ? (0, react.createElement)("div", { className: "lks14-starters" }, ...starters.map((s) => (0, react.createElement)("button", {
8868
9303
  key: s.label,
8869
9304
  className: "lks14-starter",
@@ -8893,7 +9328,18 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8893
9328
  onClick: () => {
8894
9329
  if (!dormant) fire(setMode(mode.id));
8895
9330
  }
8896
- }, mode.id === "direct" ? (0, react.createElement)(IconBoltFill16, { size: 12 }) : mode.id === "guide" ? (0, react.createElement)(IconGoalOutline16, { size: 12 }) : (0, react.createElement)(IconBookFill16, { size: 12 }), tr(mode.labelKey))))), (0, react.createElement)("textarea", {
9331
+ }, mode.id === "direct" ? (0, react.createElement)(IconBoltFill16, { size: 12 }) : mode.id === "guide" ? (0, react.createElement)(IconGoalOutline16, { size: 12 }) : (0, react.createElement)(IconBookFill16, { size: 12 }), tr(mode.labelKey))))), pendingAttach !== null ? (0, react.createElement)("div", { className: "lks14-attachchip" }, pendingAttach.image ? (0, react.createElement)("img", {
9332
+ className: "lks14-attachchip-thumb",
9333
+ src: `/lookatstudy/api/attachment/${encodeURIComponent(pendingAttach.path.split("/").pop() ?? "")}`,
9334
+ alt: pendingAttach.name
9335
+ }) : null, (0, react.createElement)("span", { className: "lks14-attachchip-name" }, pendingAttach.name), (0, react.createElement)("button", {
9336
+ className: "lks14-attachchip-x",
9337
+ "aria-label": tr("attach.remove"),
9338
+ "data-tooltip": tr("attach.remove"),
9339
+ onClick: () => {
9340
+ setPendingAttach(null);
9341
+ }
9342
+ }, "✕")) : null, (0, react.createElement)("textarea", {
8897
9343
  className: "lks14-composertext",
8898
9344
  placeholder: busy ? tr("composer.busy") : tr("tutor.empty.hint"),
8899
9345
  value: draft,
@@ -8983,9 +9429,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8983
9429
  }, (0, react.createElement)(IconCloseFill16, { size: 14 })) : (0, react.createElement)("button", {
8984
9430
  className: "lks-btn-send",
8985
9431
  "aria-label": tr("composer.send"),
8986
- disabled: dormant || draft.trim() === "",
9432
+ disabled: dormant || draft.trim() === "" && pendingAttach === null,
8987
9433
  onClick: () => {
8988
- send(draft);
9434
+ submitComposer();
8989
9435
  }
8990
9436
  }, (0, react.createElement)(IconArrowUpFill16, { size: 18 }))), showFab ? (0, react.createElement)("button", {
8991
9437
  className: `lks14-scrollfab${busy ? " streaming" : ""}`,
@@ -9008,6 +9454,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9008
9454
  const [zoneOverrides, setZoneOverrides] = (0, react.useState)({});
9009
9455
  const zoneCounts = (0, react.useRef)({});
9010
9456
  const [editingNote, setEditingNote] = (0, react.useState)(null);
9457
+ const [noteRev, setNoteRev] = (0, react.useState)(0);
9011
9458
  const [editDraft, setEditDraft] = (0, react.useState)("");
9012
9459
  const [pendingLocate, setPendingLocate] = (0, react.useState)(null);
9013
9460
  const [cmapExpanded, setCmapExpanded] = (0, react.useState)(false);
@@ -9344,9 +9791,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9344
9791
  }
9345
9792
  setQuoteBtn(null);
9346
9793
  window.getSelection()?.removeAllRanges();
9347
- } }, tr("note.quote.save"))) : null), latestHeavy !== null ? (0, react.createElement)("div", { className: "lks-acard-stage" }, (0, react.createElement)(ArtifactCard, {
9794
+ } }, tr("note.quote.save"))) : null), latestHeavy !== null ? (0, react.createElement)("div", { className: "lks-acard-stage" }, (0, react.createElement)(FoldableArtifactCard, {
9348
9795
  artifact: latestHeavy,
9349
- send: () => {}
9796
+ send: () => {},
9797
+ defaultFolded: true
9350
9798
  })) : null) : tab === "board" ? (() => {
9351
9799
  const heavy = artifacts.filter((a) => a.artifactType === "compare_table" || a.artifactType === "diagram" || a.artifactType === "code_walkthrough");
9352
9800
  const latest = heavy.length > 0 ? heavy[heavy.length - 1] : null;
@@ -9431,7 +9879,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9431
9879
  }));
9432
9880
  }
9433
9881
  }, tr(labelKey), (0, react.createElement)("span", { className: "lks14-zonecount" }, String(zoneNotes.length)), (0, react.createElement)("span", { className: "lks14-zonecaret" }, open ? "▾" : "▸")), ...open ? zoneNotes.map((n) => (0, react.createElement)("div", {
9434
- key: n.id,
9882
+ key: `${n.id}:${String(noteRev)}`,
9435
9883
  className: `lks-note${n.pinned ? " pinned" : ""}`
9436
9884
  }, (0, react.createElement)("span", { className: "lks-note-src" }, tr("note.src." + n.source)), (0, react.createElement)("button", {
9437
9885
  className: "lks-note-act",
@@ -9492,6 +9940,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9492
9940
  disabled: editDraft.trim() === "",
9493
9941
  onClick: () => {
9494
9942
  setEditingNote(null);
9943
+ setNoteRev((r) => r + 1);
9495
9944
  fire(editNote(lesson.lessonId, n.id, editDraft));
9496
9945
  }
9497
9946
  }, tr("note.save")))) : (0, react.createElement)(ContentBoundary, {
@@ -9499,7 +9948,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9499
9948
  boundaryKey: `note-${n.id}`
9500
9949
  }, (0, react.createElement)("div", {
9501
9950
  className: "lks-note-text",
9502
- dangerouslySetInnerHTML: { __html: renderMarkdown(n.text) }
9951
+ ref: (el) => {
9952
+ if (el !== null) enhanceRendered(el).catch(() => {});
9953
+ },
9954
+ dangerouslySetInnerHTML: { __html: renderMarkdown(normalizeMathNotation(n.text)) }
9503
9955
  })), n.quote !== null ? (0, react.createElement)("div", { className: "lks-note-q" }, `'${n.quote}'`) : null)) : []);
9504
9956
  })));
9505
9957
  return (0, react.createElement)("div", { className: "lks14-col lks14-note" }, body, confirmNoteDel !== null ? (0, react.createElement)(ConfirmCard, {