dsh-plugin-lookatstudy 0.21.1 → 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)}
@@ -1117,6 +1133,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1117
1133
  .lks-acard-guess-opt.picked{border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-tertiary)}
1118
1134
  .lks-acard-guess-wait{font-size:12px;color:var(--dsw-alias-label-tertiary)}
1119
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)}
1120
1145
  .lks-acard-stage .lks-acard{margin:0}
1121
1146
  .lks14-zone .lks-acard{margin:0 0 8px}
1122
1147
  /* notebook tab badge (unseen artifacts) */
@@ -1770,17 +1795,39 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1770
1795
  this.refresh();
1771
1796
  }
1772
1797
  /** Record the dsh session backing one lesson (the simplified thread system). */
1773
- 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) {
1774
1804
  await fetchJson("/lookatstudy/api/lesson-session", {
1775
1805
  method: "POST",
1776
1806
  headers: { "content-type": "application/json" },
1777
1807
  body: JSON.stringify({
1778
1808
  lessonId,
1779
- sessionId
1809
+ sessionId,
1810
+ title
1780
1811
  })
1781
1812
  });
1782
1813
  this.refresh();
1783
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
1826
+ })
1827
+ });
1828
+ this.refresh();
1829
+ return res;
1830
+ }
1784
1831
  /** Delete one course (and its proposals) from the host state. */
1785
1832
  async deleteCourse(courseId) {
1786
1833
  await fetchJson("/lookatstudy/api/course/delete", {
@@ -1993,7 +2040,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1993
2040
  examRegenerate: studyStore.examRegenerate.bind(studyStore),
1994
2041
  addUserNote: studyStore.addUserNote.bind(studyStore),
1995
2042
  recordReview: studyStore.recordReview.bind(studyStore),
1996
- bindLessonSession: studyStore.bindLessonSession.bind(studyStore)
2043
+ bindLessonSession: studyStore.bindLessonSession.bind(studyStore),
2044
+ lessonThreadOp: studyStore.lessonThreadOp.bind(studyStore)
1997
2045
  };
1998
2046
  }
1999
2047
  //#endregion
@@ -2051,6 +2099,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2051
2099
  "artifact.guess.title": "猜一猜",
2052
2100
  "artifact.guess.pick": "我选「{label}」",
2053
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": "示意图",
2054
2108
  "artifact.sedimented": "有新的学习产物沉淀进笔记",
2055
2109
  "zone.artifacts": "AI 产物",
2056
2110
  "note.quote.ask": "提问这段",
@@ -2170,6 +2224,16 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2170
2224
  "palette.empty": "没有匹配项",
2171
2225
  "threads.open": "{n} 条对话",
2172
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}",
2173
2237
  "threads.label": "课时线程",
2174
2238
  "meter.label": "上下文用量",
2175
2239
  "meter.aria": "上下文已用 {pct}%",
@@ -2398,6 +2462,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2398
2462
  "artifact.guess.title": "Take a guess",
2399
2463
  "artifact.guess.pick": "I pick \"{label}\"",
2400
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",
2401
2471
  "artifact.sedimented": "A new artifact settled into the notebook",
2402
2472
  "zone.artifacts": "AI artifacts",
2403
2473
  "note.quote.ask": "Ask about this",
@@ -2518,6 +2588,16 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2518
2588
  "threads.label": "Lesson threads",
2519
2589
  "threads.open": "{n} threads",
2520
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}",
2521
2601
  "meter.label": "Context usage",
2522
2602
  "meter.aria": "{pct}% of context used",
2523
2603
  "meter.brk.system": "System prompt",
@@ -3627,6 +3707,28 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3627
3707
  return stored === "rail" || stored === "chat" || stored === "note" ? stored : "chat";
3628
3708
  }
3629
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
+ /**
3630
3732
  * Default expansion for one rail section: collapsed when every study lesson is
3631
3733
  * done (mastered) or not yet reachable (locked). The focus lesson's section
3632
3734
  * stays open; exam nodes never force a section open. Pure.
@@ -4537,6 +4639,27 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4537
4639
  }
4538
4640
  return depth > 0 || liveChunkOrphan;
4539
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
+ }
4540
4663
  /** The window's highest event seq (the stop watermark's source). */
4541
4664
  function feedLastSeq(window) {
4542
4665
  if (window === void 0 || window.entries === void 0) return 0;
@@ -5854,6 +5977,54 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5854
5977
  storage.setItem(seenArtifactsKey(lessonId), JSON.stringify([.../* @__PURE__ */ new Set([...seen, ...ids])]));
5855
5978
  } catch {}
5856
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
+ }
5857
6028
  /** The cards render mermaid through the shared CDN pipeline
5858
6029
  * (enhanceDiagrams consumes pre > code.lang-mermaid blocks). */
5859
6030
  async function renderMermaidInto(el, code, onError) {
@@ -5960,13 +6131,16 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5960
6131
  })))) : null);
5961
6132
  }
5962
6133
  /** The opening guess: two big options, click = pick (sent to the tutor, who
5963
- * reveals next turn); unscored — no right/wrong language anywhere. */
5964
- 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 }) {
5965
6139
  const prompt = typeof artifact.data.prompt === "string" ? artifact.data.prompt : "";
5966
6140
  const options = artifact.data.options ?? [];
5967
- const [picked, setPicked] = (0, react.useState)(null);
6141
+ const [picked, setPicked] = (0, react.useState)(() => guessPickedOf(artifact.id));
5968
6142
  (0, react.useEffect)(() => {
5969
- setPicked(null);
6143
+ setPicked(guessPickedOf(artifact.id));
5970
6144
  }, [artifact.id]);
5971
6145
  return (0, react.createElement)("div", {
5972
6146
  className: "lks-acard lks-acard-guess",
@@ -5977,23 +6151,67 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5977
6151
  disabled: picked !== null,
5978
6152
  onClick: () => {
5979
6153
  setPicked(opt.id);
6154
+ markGuessPicked(artifact.id, opt.id);
5980
6155
  send(tr("artifact.guess.pick", { label: opt.label }));
5981
6156
  }
5982
- }, 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);
5983
6158
  }
5984
6159
  /** Dispatch one artifact row to its card. */
5985
- function ArtifactCard({ artifact, send }) {
6160
+ function ArtifactCard({ artifact, send, revealed = false }) {
5986
6161
  switch (artifact.artifactType) {
5987
6162
  case "compare_table": return (0, react.createElement)(CompareTableCard, { artifact });
5988
6163
  case "code_walkthrough": return (0, react.createElement)(CodeWalkthroughCard, { artifact });
5989
6164
  case "diagram": return (0, react.createElement)(DiagramCard, { artifact });
5990
6165
  case "guess": return (0, react.createElement)(GuessCard, {
5991
6166
  artifact,
5992
- send
6167
+ send,
6168
+ revealed
5993
6169
  });
5994
6170
  default: return null;
5995
6171
  }
5996
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
+ }
5997
6215
  //#endregion
5998
6216
  //#region src/client/highlights.ts
5999
6217
  /**
@@ -7106,7 +7324,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7106
7324
  };
7107
7325
  }
7108
7326
  function StudyPanelBody({ ctx }) {
7109
- const { data, activate, setMode, setFocus, searchLessons, deleteCourse, deleteNote, bindLessonSession, uploadAttachment } = useStudy();
7327
+ const { data, activate, setMode, setFocus, searchLessons, deleteCourse, deleteNote, bindLessonSession, lessonThreadOp, uploadAttachment } = useStudy();
7110
7328
  (0, react.useEffect)(() => {
7111
7329
  wireCodeBlockCopy();
7112
7330
  }, []);
@@ -7199,6 +7417,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7199
7417
  const [feedGen, setFeedGen] = (0, react.useState)(false);
7200
7418
  const lastSeqRef = (0, react.useRef)(0);
7201
7419
  const stopWatermarkRef = (0, react.useRef)(null);
7420
+ const lastAdvanceAtRef = (0, react.useRef)(0);
7202
7421
  const examSessionRef = (0, react.useRef)({
7203
7422
  active: false,
7204
7423
  terminate: null
@@ -7252,6 +7471,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7252
7471
  const lesson = data?.lesson ?? null;
7253
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 ?? []);
7254
7473
  const localBound = (0, react.useRef)(null);
7474
+ const boundSessionOf = (lessonId) => lessonId !== null && localBound.current?.lessonId === lessonId ? localBound.current.sessionId : null;
7255
7475
  const streamEl = (0, react.useRef)(null);
7256
7476
  const touchStart = (0, react.useRef)(null);
7257
7477
  const activeFace = (0, react.useRef)(null);
@@ -7260,7 +7480,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7260
7480
  try {
7261
7481
  let face = activeFace.current;
7262
7482
  if (face === null) {
7263
- 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);
7264
7484
  if (sessionId !== null && sessionKnown(ctx, sessionId)) {
7265
7485
  const actx = ctx.sessions.scope(sessionId);
7266
7486
  face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx) ?? null;
@@ -7296,7 +7516,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7296
7516
  } else celebrate(ev.kind);
7297
7517
  prevCelebrationRef.current = snapshot;
7298
7518
  }, [data]);
7299
- 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);
7300
7520
  const nudged = (0, react.useRef)(false);
7301
7521
  (0, react.useEffect)(() => {
7302
7522
  if (nudged.current || (data?.dueCount ?? 0) === 0) return;
@@ -7400,8 +7620,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7400
7620
  const update = () => {
7401
7621
  const snap = source.getSnapshot();
7402
7622
  setRows(feedRows(snap));
7403
- lastSeqRef.current = feedLastSeq(snap);
7404
- 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()));
7405
7629
  };
7406
7630
  update();
7407
7631
  setFeedAttached(true);
@@ -7455,16 +7679,31 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7455
7679
  const el = streamEl.current;
7456
7680
  if (el !== null) el.scrollTop = el.scrollHeight;
7457
7681
  }, [rows]);
7458
- /** The one send path: lazily stage the lesson thread, then prompt. */
7459
- 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) => {
7460
7696
  (async () => {
7461
7697
  if (busy || lesson === null || text.trim() === "") return;
7462
7698
  setBusy(true);
7463
7699
  setSendError(null);
7464
7700
  stopWatermarkRef.current = null;
7701
+ lastAdvanceAtRef.current = Date.now();
7465
7702
  try {
7466
7703
  if (data?.active !== true) await activate(true);
7467
- 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;
7468
7707
  if (sessionId !== null && !sessionKnown(ctx, sessionId)) sessionId = null;
7469
7708
  if (sessionId === null) {
7470
7709
  const area = await fetch("/lookatstudy/api/study-workspace");
@@ -7472,9 +7711,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7472
7711
  const { path } = await area.json();
7473
7712
  const workspace = await ctx.workspaces.create({ path });
7474
7713
  sessionId = await ctx.sessions.create({ workspaceId: workspace.workspaceId });
7475
- localBound.current = sessionId;
7476
- await bindLessonSession(lesson.lessonId, sessionId);
7477
- }
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
+ };
7478
7723
  const shell = PANEL_SHELL.current;
7479
7724
  if (shell !== null) shell.suppressHandBack(() => {
7480
7725
  ctx.sessions.open(sessionId);
@@ -7508,6 +7753,50 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7508
7753
  window.removeEventListener("lookatstudy-exam-generate", onGenerate);
7509
7754
  };
7510
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
+ };
7511
7800
  const progress = data?.progress ?? null;
7512
7801
  const body = (0, react.createElement)("div", {
7513
7802
  className: "lks14-body",
@@ -7598,10 +7887,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7598
7887
  stop,
7599
7888
  setMode,
7600
7889
  narrowPane,
7601
- onThreadJump: (id) => {
7890
+ onThreadSwitch: (sessionId) => {
7602
7891
  setNarrowPane("chat");
7603
- guardedSetFocus(id);
7892
+ switchLessonThread(sessionId);
7893
+ },
7894
+ onThreadNew: () => {
7895
+ setNarrowPane("chat");
7896
+ startNewThread();
7604
7897
  },
7898
+ onThreadRename: renameLessonThreadUi,
7899
+ onThreadArchive: archiveLessonThreadUi,
7900
+ onThreadDelete: deleteLessonThreadUi,
7605
7901
  contextMeter,
7606
7902
  contextBreakdown: breakdown,
7607
7903
  uploadAttachment
@@ -8067,7 +8363,30 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8067
8363
  bottom: r.bottom
8068
8364
  } });
8069
8365
  }
8070
- }, (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", {
8071
8390
  className: `lks14-masteryhair${course.avgMasteryPct === 100 ? " gold" : ""}`,
8072
8391
  title: course.avgMasteryPct === null ? tr("rail.avg.none") : tr("rail.avg", { pct: course.avgMasteryPct })
8073
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", {
@@ -8230,8 +8549,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8230
8549
  }, (0, react.createElement)(IconRefreshOutline16, { size: 13 }), tr("review.random")), (0, react.createElement)("button", {
8231
8550
  className: "lks-btn primary",
8232
8551
  onClick: () => {
8552
+ const first = data.due[0];
8233
8553
  setReviewOpen(false);
8234
- 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"));
8235
8558
  }
8236
8559
  }, tr("rail.due.start"))) : (0, react.createElement)("div", { className: "lks14-empty" }, tr("rail.due.none"))) : null;
8237
8560
  const effectivePanel = data !== null && data.courses.length === 0 ? "import" : panel;
@@ -8511,10 +8834,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8511
8834
  return cut <= 0 ? path : norm.slice(0, cut);
8512
8835
  }
8513
8836
  /** 中栏:the tutor chat stream with its own composer (upstream ChatStream + ChatComposer). */
8514
- 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 }) {
8515
8838
  const stuck = (0, react.useRef)(true);
8516
8839
  const [showFab, setShowFab] = (0, react.useState)(false);
8517
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);
8518
8844
  const [pendingAttach, setPendingAttach] = (0, react.useState)(null);
8519
8845
  const onStreamScroll = () => {
8520
8846
  const el = streamEl.current;
@@ -8713,7 +9039,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8713
9039
  return ids;
8714
9040
  }, [rowsView]);
8715
9041
  const backlog = (0, react.useMemo)(() => lesson !== null ? sedimentBacklog(lesson.artifacts, inlineIds, unseenArtifacts(lesson.lessonId, lesson.artifacts)) : [], [lesson, inlineIds]);
8716
- const inlineArtifactCard = (row) => {
9042
+ const inlineArtifactCard = (row, revealed = false) => {
8717
9043
  if (lesson === null || row.artifactId === void 0) return null;
8718
9044
  const artifact = lesson.artifacts.find((a) => a.id === row.artifactId);
8719
9045
  if (artifact === void 0) return chatRow({
@@ -8728,26 +9054,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8728
9054
  masteryPct: lesson.masteryPct,
8729
9055
  send,
8730
9056
  onFinished: () => {}
8731
- }) : (0, react.createElement)(ArtifactCard, {
9057
+ }) : (0, react.createElement)(FoldableArtifactCard, {
8732
9058
  artifact,
8733
- send
9059
+ send,
9060
+ revealed
8734
9061
  }));
8735
9062
  };
8736
- const threadPills = [];
8737
- if (data !== null) {
8738
- const titles = /* @__PURE__ */ new Map();
8739
- for (const c of data.courses) for (const s of c.sections) for (const l of s.lessons) titles.set(l.id, l.title);
8740
- for (const [lessonId, sessionId] of Object.entries(data.lessonSessions)) {
8741
- if (sessionId === void 0) continue;
8742
- const title = titles.get(lessonId);
8743
- if (title === void 0) continue;
8744
- threadPills.push({
8745
- id: lessonId,
8746
- title,
8747
- current: lesson !== null && lesson.lessonId === lessonId
8748
- });
8749
- }
8750
- }
9063
+ const threadPills = threadGroupPills(lesson !== null && data !== null ? data.lessonThreads[lesson.lessonId] : void 0);
8751
9064
  const lastAssistant = rowsView.reduce((acc, row, i) => row.role === "assistant" ? i : acc, -1);
8752
9065
  const [error, setError] = (0, react.useState)(null);
8753
9066
  const fire = (action) => {
@@ -8848,19 +9161,115 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8848
9161
  }, tr("threads.open", { n: String(threadPills.length) }), (0, react.createElement)("span", { className: "lks14-threadchip-caret" }, threadsOpen ? "▾" : "▸")), threadsOpen ? (0, react.createElement)("div", {
8849
9162
  className: "lks14-threadmenu",
8850
9163
  role: "menu"
8851
- }, ...threadPills.map((p) => (0, react.createElement)("button", {
9164
+ }, ...threadPills.map((p) => (0, react.createElement)("div", {
8852
9165
  key: p.id,
8853
9166
  className: `lks14-threadmenu-row${p.current ? " on" : ""}`,
8854
9167
  role: "menuitem",
9168
+ tabIndex: 0,
8855
9169
  "aria-current": p.current ? "true" : void 0,
8856
9170
  onClick: () => {
9171
+ if (renaming !== null && renaming.id === p.id) return;
8857
9172
  setThreadsOpen(false);
8858
- if (!p.current) onThreadJump(p.id);
9173
+ setGearFor(null);
9174
+ if (!p.current) onThreadSwitch(p.id);
8859
9175
  }
8860
9176
  }, (0, react.createElement)("span", {
8861
9177
  className: "lks14-threadmenu-dot",
8862
9178
  "aria-hidden": "true"
8863
- }), (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", {
8864
9273
  className: "lks14-stream",
8865
9274
  onScroll: onStreamScroll,
8866
9275
  ref: streamEl,
@@ -8871,7 +9280,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8871
9280
  onClick: () => {
8872
9281
  send(starters[0]?.message ?? tr("prompt.start"));
8873
9282
  }
8874
- }, 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" ? {
8875
9284
  playing: msgAudio?.key === row.key,
8876
9285
  index: msgAudio?.index ?? 0,
8877
9286
  total: msgAudio?.total ?? 0,
@@ -8885,10 +9294,11 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8885
9294
  masteryPct: lesson.masteryPct,
8886
9295
  send,
8887
9296
  onFinished: () => {}
8888
- })), ...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, {
8889
9298
  key: a.id,
8890
9299
  artifact: a,
8891
- send
9300
+ send,
9301
+ defaultFolded: true
8892
9302
  })), rows.length > 0 ? (0, react.createElement)("div", { className: "lks14-starters" }, ...starters.map((s) => (0, react.createElement)("button", {
8893
9303
  key: s.label,
8894
9304
  className: "lks14-starter",
@@ -9381,9 +9791,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9381
9791
  }
9382
9792
  setQuoteBtn(null);
9383
9793
  window.getSelection()?.removeAllRanges();
9384
- } }, 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, {
9385
9795
  artifact: latestHeavy,
9386
- send: () => {}
9796
+ send: () => {},
9797
+ defaultFolded: true
9387
9798
  })) : null) : tab === "board" ? (() => {
9388
9799
  const heavy = artifacts.filter((a) => a.artifactType === "compare_table" || a.artifactType === "diagram" || a.artifactType === "code_walkthrough");
9389
9800
  const latest = heavy.length > 0 ? heavy[heavy.length - 1] : null;