dsh-plugin-lookatstudy 0.20.1 → 0.21.1

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
@@ -817,6 +817,11 @@ window.__ModuleLoader__.load({
817
817
  .lks-ui[data-lks-theme='light'] .lks14-threadmenu{background:rgb(255 255 255/0.98);border-color:var(--border-faint);box-shadow:0 12px 32px -8px rgb(0 0 0/0.18)}
818
818
  .lks-ui[data-lks-theme='light'] .lks14-ctxpanel{background:rgb(255 255 255/0.98);box-shadow:0 8px 24px rgb(0 0 0/0.12)}
819
819
  /* ambient glyph motion sleeps under reduced-motion (A7 never had a guard) */
820
+ /* v0.29 pane-resize: the grip line lights the upstream accent under the skin
821
+ (base sheet's host-token color is the no-skin fallback; the handle itself
822
+ is structural lks14- and lives in styles.ts) */
823
+ .lks-ui .lks14-panehandle:hover::after,.lks-ui .lks14-panehandle[data-dragging='true']::after{background:color-mix(in srgb,var(--accent) 55%,transparent)}
824
+
820
825
  @media (prefers-reduced-motion: reduce){
821
826
  .lks-ui .lks-lessorow.st-mastered .lks-lessorow-glyph svg,.lks-ui .lks-hdr-xp .lks-hdr-glyph,.lks-ui .lks-hdr-streak .lks-hdr-glyph{animation:none}
822
827
  }
@@ -887,7 +892,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
887
892
  .lks14-col{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
888
893
 
889
894
  /* 左 rail: course picker, tree, review, import */
890
- .lks14-rail{flex:0 0 300px;border-right:none;padding:0;overflow:hidden;position:relative}
895
+ .lks14-rail{flex:0 0 var(--lks-rail-w,300px);border-right:none;padding:0;overflow:hidden;position:relative;transition:flex-basis 150ms ease}
891
896
  .lks14-railhead{display:flex;align-items:center;gap:6px;margin-top:10px}
892
897
  .lks14-railtitle{font-weight:600;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
893
898
  .lks14-railsub{font-size:12px;color:var(--dsw-alias-label-secondary);margin:6px 0}
@@ -902,7 +907,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
902
907
  .lks14-hint{font-size:11.5px;color:var(--dsw-alias-label-tertiary);margin-top:6px;line-height:1.6}
903
908
 
904
909
  /* 中 chat: the tutor stream + its own composer (upstream ChatStream/ChatComposer) */
905
- .lks14-chat{flex:0 0 auto;width:clamp(480px,45%,800px);min-width:0;border-right:none}
910
+ .lks14-chat{flex:0 0 auto;width:var(--lks-chat-w,clamp(480px,45%,800px));min-width:0;border-right:none;transition:width 150ms ease}
906
911
  .lks14-chatlesson{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:400;color:var(--dsw-alias-label-tertiary)}
907
912
  .lks14-pills{display:inline-flex;gap:2px}
908
913
  .lks14-pill{border:none;background:none;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:12px;padding:2px 8px;border-radius:7px;cursor:pointer}
@@ -932,6 +937,28 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
932
937
  .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)}
933
938
  .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}
934
939
  .lks14-composertext:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}
940
+ /* issue #7: the composer's pending-attachment chip (thumbnail + name + remove) */
941
+ .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%}
942
+ .lks14-attachchip-thumb{flex:none;width:44px;height:44px;object-fit:cover;border-radius:7px;border:1px solid var(--dsw-alias-border-l1)}
943
+ .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)}
944
+ .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}
945
+ .lks14-attachchip-x:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
946
+
947
+ /* ============================================================
948
+ * v0.29 pane-resize 拖拽手柄(PaneResizeHandle)——
949
+ * 栏间 6px 竖条,视觉握线默认隐形、hover/拖拽亮 accent;
950
+ * 命中区触屏 44px(触屏密度红线),细指针收窄 12px(不吃栏内容点击)。
951
+ * pane 分隔语汇 = surface 色阶不加描边:握线是 hover 才现身的交互
952
+ * affordance,不是常驻分隔线,不违反 v0.6「pane 间无 border」。
953
+ * ============================================================ */
954
+ .lks14-panehandle{position:relative;flex:none;width:6px;cursor:col-resize;touch-action:none;z-index:5}
955
+ .lks14-panehandle::before{content:"";position:absolute;top:0;bottom:0;left:50%;width:44px;transform:translateX(-50%)}
956
+ @media (pointer:fine){.lks14-panehandle::before{width:12px}}
957
+ .lks14-panehandle::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:2px;transform:translateX(-50%);border-radius:1px;background:transparent;transition:background 120ms}
958
+ .lks14-panehandle:hover::after,.lks14-panehandle[data-dragging='true']::after{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 55%,transparent)}
959
+ /* 拖拽中:全局列宽光标 + 禁文本选择(宽度直写不经过 React) */
960
+ body.pane-dragging{cursor:col-resize;user-select:none}
961
+ body.pane-dragging *{cursor:col-resize}
935
962
 
936
963
  /* 右 notebook: 讲解/概念图/笔记 (upstream NotebookPanel) */
937
964
  .lks14-note{flex:1 1 auto;min-width:440px;padding:0;overflow-y:auto}
@@ -1001,6 +1028,7 @@ html[data-dsh-lookatstudy-active] [class*='centerCol'] > :not([data-dsh-lookatst
1001
1028
  .lks14-body[data-pane='rail'] .lks14-righthalf{display:none}
1002
1029
  .lks14-body[data-pane='chat'] .lks14-rail,.lks14-body[data-pane='chat'] .lks14-note,
1003
1030
  .lks14-body[data-pane='note'] .lks14-rail,.lks14-body[data-pane='note'] .lks14-chat{display:none}
1031
+ .lks14-panehandle{display:none}
1004
1032
  .lks14-body[data-pane='rail'] .lks14-rail{display:flex;max-height:none;flex:1 1 auto}
1005
1033
  .lks14-body[data-pane='note'] .lks14-note{display:flex}
1006
1034
  }
@@ -2129,6 +2157,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2129
2157
  "note.retryRender": "重试渲染",
2130
2158
  "zoom.in": "放大面板字号",
2131
2159
  "zoom.out": "缩小面板字号",
2160
+ "pane.rail.aria": "调整课程栏宽度",
2161
+ "pane.chat.aria": "调整对话栏宽度",
2162
+ "pane.tip": "拖拽调宽,双击重置",
2132
2163
  "palette.label": "命令面板",
2133
2164
  "palette.placeholder": "搜索课时/课程,或选择动作…",
2134
2165
  "palette.lessons": "课时",
@@ -2154,6 +2185,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2154
2185
  "settings.budget.on": "开启历史预算",
2155
2186
  "settings.budget.off": "关闭历史预算",
2156
2187
  "attach.sent": "我发了一个文件:{name}(工作区路径 {path})——请读取它再继续。",
2188
+ "attach.remove": "移除待发附件",
2157
2189
  "attach.fail": "附件上传失败",
2158
2190
  "attach.label": "附件(≤20MiB,存入学习工作区)",
2159
2191
  "rail.mastered": "毕业 {mastered}/{total} 课",
@@ -2472,6 +2504,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2472
2504
  "note.retryRender": "Retry render",
2473
2505
  "zoom.in": "Increase panel font size",
2474
2506
  "zoom.out": "Decrease panel font size",
2507
+ "pane.rail.aria": "Resize the course pane width",
2508
+ "pane.chat.aria": "Resize the chat pane width",
2509
+ "pane.tip": "Drag to resize, double-click to reset",
2475
2510
  "palette.label": "Command palette",
2476
2511
  "palette.placeholder": "Search lessons/courses or pick an action…",
2477
2512
  "palette.lessons": "Lesson",
@@ -2497,6 +2532,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2497
2532
  "settings.budget.on": "Enable history budget",
2498
2533
  "settings.budget.off": "Disable history budget",
2499
2534
  "attach.sent": "I attached a file: {name} (workspace path {path}) — read it before continuing.",
2535
+ "attach.remove": "Remove pending attachment",
2500
2536
  "attach.fail": "Attachment upload failed",
2501
2537
  "attach.label": "Attach (≤20MiB, lands in the study workspace)",
2502
2538
  "rail.mastered": "{mastered}/{total} lessons graduated",
@@ -2737,10 +2773,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2737
2773
  }
2738
2774
  /** Render inline markup (code, bold, italic, links, images) over escaped text.
2739
2775
  * Image src allowlist: https?:// or data:image/ — folder imports inline local
2740
- * images as data URLs (capped), GitHub imports reference jsDelivr; anything
2741
- * else stays literal text (no broken relative images, no exotic schemes). */
2776
+ * images as data URLs (capped), GitHub imports reference jsDelivr; the
2777
+ * plugin's own attachment route (/lookatstudy/api/attachment/<name>, issue #7
2778
+ * — same-origin, server-side validated) renders workspace attachments;
2779
+ * anything else stays literal text (no broken relative images, no exotic
2780
+ * schemes). */
2742
2781
  function inline(escaped) {
2743
- 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>");
2782
+ 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>");
2744
2783
  }
2745
2784
  /** True when the line opens a GFM table row (pipes with a delimiter row next). */
2746
2785
  function isTableRow(line) {
@@ -3327,6 +3366,149 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3327
3366
  }
3328
3367
  return out.join("\n");
3329
3368
  }
3369
+ const CHAT_MAX = 1100;
3370
+ const clampNum = (v, min, max) => Math.min(max, Math.max(min, v));
3371
+ /** 对话栏未定制时的默认宽数值形式(= clamp(480px,45%,800px) 在 rowW 下;rowW = 容器宽 − rail 有效宽)。 */
3372
+ function defaultChatWidth(rowW) {
3373
+ return clampNum(rowW * .45, 480, 800);
3374
+ }
3375
+ /** 课程栏拖拽候选钳制:reserved = 对话栏有效宽 + NOTE_MIN + 手柄。 */
3376
+ function clampRailCandidate(next, containerW, reserved) {
3377
+ return clampNum(next, 240, Math.min(480, Math.max(240, containerW - reserved)));
3378
+ }
3379
+ /** 对话栏拖拽候选钳制:reserved = 课程栏有效宽 + NOTE_MIN;极端挤压允许压到硬底。 */
3380
+ function clampChatCandidate(next, rowW, reserved) {
3381
+ return clampNum(next, 480, Math.min(CHAT_MAX, Math.max(320, rowW - reserved)));
3382
+ }
3383
+ /** 存储值解析:空/非数/超区间 → null(回默认;历史脏值不硬吃)。 */
3384
+ function parseStoredWidth(v, min, max) {
3385
+ if (v == null || v.trim() === "") return null;
3386
+ const n = Number(v);
3387
+ if (!Number.isFinite(n) || n < min || n > max) return null;
3388
+ return Math.round(n);
3389
+ }
3390
+ /**
3391
+ * The panel root's font-zoom factor (E7 zoom scales the px-fixed layout) —
3392
+ * getBoundingClientRect returns VISUAL px under zoom while flex-basis/width
3393
+ * apply in LAYOUT px, so every drag measurement divides by this (upstream had
3394
+ * no zoom; this is the one place our port must normalize).
3395
+ */
3396
+ function panelZoomOf(el) {
3397
+ const z = el?.closest(".lks14")?.style.zoom;
3398
+ const n = z === void 0 || z === "" ? NaN : Number(z);
3399
+ return Number.isFinite(n) && n > 0 ? n : 1;
3400
+ }
3401
+ /**
3402
+ * 渲染时有效宽度求解(持久化值 × 当前容器宽):拖拽钳制保证提交时刻不溢出,
3403
+ * 但宿主列后来变窄会破坏旧组合 —— 存储值按当前容器重新压回预算。
3404
+ * 返回 null = 未定制(挂 CSS 默认)。求解序 acyclic:rail 基准(默认 300 是
3405
+ * 常数)→ row 宽 → chat 默认(% 对 row)→ rail 预算按 chat 有效宽。
3406
+ */
3407
+ function solvePaneWidths(railStored, chatStored, containerW) {
3408
+ const rowW = Math.max(0, containerW - (railStored ?? 300) - 12);
3409
+ const chat = chatStored == null ? null : clampChatCandidate(chatStored, rowW, 440);
3410
+ const chatBasis = chat ?? defaultChatWidth(rowW);
3411
+ return {
3412
+ rail: railStored == null ? null : clampRailCandidate(railStored, containerW, chatBasis + 440 + 12),
3413
+ chat
3414
+ };
3415
+ }
3416
+ //#endregion
3417
+ //#region src/client/paneresize.tsx
3418
+ /**
3419
+ * PaneResizeHandle —— 栏间拖拽手柄(ported from upstream LookatStudy v0.29
3420
+ * issue #14, src/renderer/components/PaneResizeHandle.tsx @ v0.33.2, MIT —
3421
+ * adapted 2026-09-12; structural deviations recorded here):
3422
+ * - the drag target is the handle's OWN previousElementSibling (the column it
3423
+ * follows in the DOM), not a document.querySelector testid anchor;
3424
+ * - rail widths apply through flex-basis (its class is flex:0 0 basis), chat
3425
+ * widths through width (flex-basis:auto) — the side prop picks the channel;
3426
+ * - on commit the parent's layout effect clears the inline width once the
3427
+ * container-level CSS vars carry the truth (same paint, no flash), so this
3428
+ * component only writes during the drag and hands over on finish.
3429
+ *
3430
+ * 命中区触屏 44px / 细指针 12px(styles.ts ::before);拖拽期宽度 imperative
3431
+ * 直写目标元素(对话栏是消息流整树,逐帧 setState 不可接受),100ms 节流,
3432
+ * 松手终值必达;双击 = 重置回响应式默认(清 inline + onCommit(null),类/clamp
3433
+ * 接管)。a11y:role=separator + aria-label;键盘调整不在本期范围。
3434
+ */
3435
+ /** 拖拽中宽度应用节流(松手必应用终值,不受节流影响)。 */
3436
+ const DRAG_APPLY_MIN_MS = 100;
3437
+ /** Inline width ← px;null clears it (class/var/CSS default takes over). */
3438
+ function applyWidth(el, side, px) {
3439
+ if (side === "rail") el.style.flexBasis = px === null ? "" : `${String(px)}px`;
3440
+ else el.style.width = px === null ? "" : `${String(px)}px`;
3441
+ }
3442
+ function PaneResizeHandle(props) {
3443
+ const [dragging, setDragging] = (0, react.useState)(false);
3444
+ const dragRef = (0, react.useRef)(null);
3445
+ /** props 进 effect 闭包会滞后:拖拽全程用 ref 取最新。 */
3446
+ const propsRef = (0, react.useRef)(props);
3447
+ propsRef.current = props;
3448
+ (0, react.useEffect)(() => {
3449
+ if (!dragging) return;
3450
+ document.body.classList.add("pane-dragging");
3451
+ const onMove = (e) => {
3452
+ const d = dragRef.current;
3453
+ if (d == null) return;
3454
+ const now = performance.now();
3455
+ if (now - d.lastAppliedAt < DRAG_APPLY_MIN_MS) return;
3456
+ d.lastAppliedAt = now;
3457
+ applyWidth(d.targetEl, propsRef.current.side, propsRef.current.clampLive(d.startW + (e.clientX - d.startX)));
3458
+ };
3459
+ const finish = (e) => {
3460
+ const d = dragRef.current;
3461
+ dragRef.current = null;
3462
+ setDragging(false);
3463
+ if (d == null) return;
3464
+ d.targetEl.style.transition = "";
3465
+ const finalW = propsRef.current.clampLive(d.startW + (e.clientX - d.startX));
3466
+ applyWidth(d.targetEl, propsRef.current.side, finalW);
3467
+ propsRef.current.onCommit(finalW);
3468
+ };
3469
+ window.addEventListener("pointermove", onMove);
3470
+ window.addEventListener("pointerup", finish);
3471
+ window.addEventListener("pointercancel", finish);
3472
+ return () => {
3473
+ document.body.classList.remove("pane-dragging");
3474
+ window.removeEventListener("pointermove", onMove);
3475
+ window.removeEventListener("pointerup", finish);
3476
+ window.removeEventListener("pointercancel", finish);
3477
+ };
3478
+ }, [dragging]);
3479
+ const startDrag = (e) => {
3480
+ if (e.button !== 0) return;
3481
+ const target = e.currentTarget.previousElementSibling;
3482
+ if (!(target instanceof HTMLElement)) return;
3483
+ e.preventDefault();
3484
+ dragRef.current = {
3485
+ startX: e.clientX,
3486
+ startW: target.getBoundingClientRect().width / panelZoomOf(target),
3487
+ lastAppliedAt: 0,
3488
+ targetEl: target
3489
+ };
3490
+ target.style.transition = "none";
3491
+ setDragging(true);
3492
+ try {
3493
+ e.currentTarget.setPointerCapture(e.pointerId);
3494
+ } catch {}
3495
+ };
3496
+ return (0, react.createElement)("div", {
3497
+ role: "separator",
3498
+ "aria-orientation": "vertical",
3499
+ "aria-label": props.side === "rail" ? tr("pane.rail.aria") : tr("pane.chat.aria"),
3500
+ "data-tooltip": tr("pane.tip"),
3501
+ "data-testid": `pane-handle-${props.side}`,
3502
+ "data-dragging": dragging ? "true" : void 0,
3503
+ className: "lks14-panehandle",
3504
+ onPointerDown: startDrag,
3505
+ onDoubleClick: (e) => {
3506
+ const target = e.currentTarget.previousElementSibling;
3507
+ if (target instanceof HTMLElement) applyWidth(target, props.side, null);
3508
+ props.onCommit(null);
3509
+ }
3510
+ });
3511
+ }
3330
3512
  //#endregion
3331
3513
  //#region src/client/views.tsx
3332
3514
  /**
@@ -6948,6 +7130,56 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6948
7130
  localStorage.setItem("dsh-plugin-lookatstudy:zoom", String(clamped));
6949
7131
  } catch {}
6950
7132
  };
7133
+ const [railW, setRailW] = (0, react.useState)(() => parseStoredWidth(localStorage.getItem("dsh-plugin-lookatstudy:pane-rail"), 240, 480));
7134
+ const [chatW, setChatW] = (0, react.useState)(() => parseStoredWidth(localStorage.getItem("dsh-plugin-lookatstudy:pane-chat"), 480, CHAT_MAX));
7135
+ const [paneBoxW, setPaneBoxW] = (0, react.useState)(0);
7136
+ const bodyEl = (0, react.useRef)(null);
7137
+ const rowEl = (0, react.useRef)(null);
7138
+ const panesCustom = railW !== null || chatW !== null;
7139
+ (0, react.useEffect)(() => {
7140
+ const el = bodyEl.current;
7141
+ if (el === null || !panesCustom) return;
7142
+ const ro = new ResizeObserver((entries) => {
7143
+ for (const entry of entries) setPaneBoxW(Math.round(entry.contentRect.width));
7144
+ });
7145
+ ro.observe(el);
7146
+ return () => {
7147
+ ro.disconnect();
7148
+ };
7149
+ }, [panesCustom]);
7150
+ const solvedPanes = solvePaneWidths(railW, chatW, paneBoxW > 0 ? paneBoxW : bodyEl.current?.clientWidth ?? 0);
7151
+ (0, react.useLayoutEffect)(() => {
7152
+ const el = bodyEl.current;
7153
+ if (el === null) return;
7154
+ if (solvedPanes.rail !== null) el.style.setProperty("--lks-rail-w", `${String(solvedPanes.rail)}px`);
7155
+ else el.style.removeProperty("--lks-rail-w");
7156
+ if (solvedPanes.chat !== null) el.style.setProperty("--lks-chat-w", `${String(solvedPanes.chat)}px`);
7157
+ else el.style.removeProperty("--lks-chat-w");
7158
+ for (const col of el.querySelectorAll(".lks14-rail, .lks14-chat")) {
7159
+ col.style.flexBasis = "";
7160
+ col.style.width = "";
7161
+ }
7162
+ }, [solvedPanes.rail, solvedPanes.chat]);
7163
+ const commitPaneWidth = (which, px) => {
7164
+ const key = which === "rail" ? "dsh-plugin-lookatstudy:pane-rail" : "dsh-plugin-lookatstudy:pane-chat";
7165
+ const value = px === null ? null : Math.round(px);
7166
+ if (which === "rail") setRailW(value);
7167
+ else setChatW(value);
7168
+ try {
7169
+ if (value === null) localStorage.removeItem(key);
7170
+ else localStorage.setItem(key, String(value));
7171
+ } catch {}
7172
+ };
7173
+ const railClampLive = (px) => {
7174
+ const el = bodyEl.current;
7175
+ const containerW = (el?.getBoundingClientRect().width ?? 1280) / panelZoomOf(el);
7176
+ const rowW = Math.max(0, containerW - 300 - 12);
7177
+ return clampRailCandidate(px, containerW, (solvedPanes.chat ?? defaultChatWidth(rowW)) + 440 + 12);
7178
+ };
7179
+ const chatClampLive = (px) => {
7180
+ const el = rowEl.current;
7181
+ return clampChatCandidate(px, (el?.getBoundingClientRect().width ?? 900) / panelZoomOf(el), 440);
7182
+ };
6951
7183
  const paletteBus = (0, react.useRef)({});
6952
7184
  const [paletteOpen, setPaletteOpen] = (0, react.useState)(false);
6953
7185
  (0, react.useEffect)(() => {
@@ -7279,6 +7511,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7279
7511
  const progress = data?.progress ?? null;
7280
7512
  const body = (0, react.createElement)("div", {
7281
7513
  className: "lks14-body",
7514
+ ref: bodyEl,
7282
7515
  "data-pane": narrowPane,
7283
7516
  onTouchStart: (e) => {
7284
7517
  const t = e.touches[0];
@@ -7310,6 +7543,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7310
7543
  importProgress,
7311
7544
  stop,
7312
7545
  paletteBus
7546
+ }), (0, react.createElement)(PaneResizeHandle, {
7547
+ side: "rail",
7548
+ clampLive: railClampLive,
7549
+ onCommit: (px) => commitPaneWidth("rail", px)
7313
7550
  }), (0, react.createElement)("div", { className: "lks14-righthalf" }, (0, react.createElement)("div", { className: "lks14-appheader" }, (0, react.createElement)("span", { className: "lks-hdr-title" }, lesson?.courseTitle ?? tr("tab.label")), (0, react.createElement)("span", {
7314
7551
  className: "lks-hdr-xp",
7315
7552
  "data-tooltip": tr("header.xp", { xp: progress?.totalXp ?? 0 })
@@ -7339,7 +7576,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7339
7576
  onClick: () => {
7340
7577
  setZoom(zoomTier + .1);
7341
7578
  }
7342
- }, "A+"))), (0, react.createElement)("div", { className: "lks14-row" }, examLessonActive ? (0, react.createElement)("div", { className: "lks14-col lks14-chat" }, (0, react.createElement)(ExamView, {
7579
+ }, "A+"))), (0, react.createElement)("div", {
7580
+ className: "lks14-row",
7581
+ ref: rowEl
7582
+ }, examLessonActive ? (0, react.createElement)("div", { className: "lks14-col lks14-chat" }, (0, react.createElement)(ExamView, {
7343
7583
  lessonId: lesson.lessonId,
7344
7584
  sectionTitle: lesson.sectionTitle,
7345
7585
  paused: examLeave !== null,
@@ -7365,6 +7605,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7365
7605
  contextMeter,
7366
7606
  contextBreakdown: breakdown,
7367
7607
  uploadAttachment
7608
+ }), (0, react.createElement)(PaneResizeHandle, {
7609
+ side: "chat",
7610
+ clampLive: chatClampLive,
7611
+ onCommit: (px) => commitPaneWidth("chat", px)
7368
7612
  }), (0, react.createElement)(NotebookPane, {
7369
7613
  data,
7370
7614
  deleteNote,
@@ -8271,6 +8515,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8271
8515
  const stuck = (0, react.useRef)(true);
8272
8516
  const [showFab, setShowFab] = (0, react.useState)(false);
8273
8517
  const [threadsOpen, setThreadsOpen] = (0, react.useState)(false);
8518
+ const [pendingAttach, setPendingAttach] = (0, react.useState)(null);
8274
8519
  const onStreamScroll = () => {
8275
8520
  const el = streamEl.current;
8276
8521
  if (el === null) return;
@@ -8522,18 +8767,31 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8522
8767
  for (let i = 0; i < bytes.length; i += CHUNK) bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
8523
8768
  return uploadAttachment(file.name, btoa(bin));
8524
8769
  }).then((path) => {
8525
- send(tr("attach.sent", {
8770
+ setPendingAttach({
8526
8771
  name: file.name,
8527
- path
8528
- }));
8772
+ path,
8773
+ image: /\.(?:png|jpe?g|gif|webp|bmp)$/i.test(file.name)
8774
+ });
8529
8775
  }, () => {
8530
8776
  showStudyToast(tr("attach.fail"), { severity: "error" });
8531
8777
  });
8532
8778
  };
8779
+ /** One send = draft + pending attachment merged into a single message (issue #7). */
8780
+ const submitComposer = () => {
8781
+ if (busy) return;
8782
+ const text = draft.trim();
8783
+ if (text === "" && pendingAttach === null) return;
8784
+ const attachLine = pendingAttach === null ? "" : tr("attach.sent", {
8785
+ name: pendingAttach.name,
8786
+ path: pendingAttach.path
8787
+ });
8788
+ setPendingAttach(null);
8789
+ send(attachLine === "" ? text : text === "" ? attachLine : `${text}\n\n${attachLine}`);
8790
+ };
8533
8791
  const onComposerKey = (e) => {
8534
8792
  if (e.key === "Enter" && !e.shiftKey) {
8535
8793
  e.preventDefault();
8536
- if (draft.trim() !== "" && !busy) send(draft);
8794
+ if ((draft.trim() !== "" || pendingAttach !== null) && !busy) submitComposer();
8537
8795
  }
8538
8796
  };
8539
8797
  const proposal = data?.pendingProposals[0] ?? null;
@@ -8660,7 +8918,18 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8660
8918
  onClick: () => {
8661
8919
  if (!dormant) fire(setMode(mode.id));
8662
8920
  }
8663
- }, 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", {
8921
+ }, 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", {
8922
+ className: "lks14-attachchip-thumb",
8923
+ src: `/lookatstudy/api/attachment/${encodeURIComponent(pendingAttach.path.split("/").pop() ?? "")}`,
8924
+ alt: pendingAttach.name
8925
+ }) : null, (0, react.createElement)("span", { className: "lks14-attachchip-name" }, pendingAttach.name), (0, react.createElement)("button", {
8926
+ className: "lks14-attachchip-x",
8927
+ "aria-label": tr("attach.remove"),
8928
+ "data-tooltip": tr("attach.remove"),
8929
+ onClick: () => {
8930
+ setPendingAttach(null);
8931
+ }
8932
+ }, "✕")) : null, (0, react.createElement)("textarea", {
8664
8933
  className: "lks14-composertext",
8665
8934
  placeholder: busy ? tr("composer.busy") : tr("tutor.empty.hint"),
8666
8935
  value: draft,
@@ -8750,9 +9019,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8750
9019
  }, (0, react.createElement)(IconCloseFill16, { size: 14 })) : (0, react.createElement)("button", {
8751
9020
  className: "lks-btn-send",
8752
9021
  "aria-label": tr("composer.send"),
8753
- disabled: dormant || draft.trim() === "",
9022
+ disabled: dormant || draft.trim() === "" && pendingAttach === null,
8754
9023
  onClick: () => {
8755
- send(draft);
9024
+ submitComposer();
8756
9025
  }
8757
9026
  }, (0, react.createElement)(IconArrowUpFill16, { size: 18 }))), showFab ? (0, react.createElement)("button", {
8758
9027
  className: `lks14-scrollfab${busy ? " streaming" : ""}`,
@@ -8775,6 +9044,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8775
9044
  const [zoneOverrides, setZoneOverrides] = (0, react.useState)({});
8776
9045
  const zoneCounts = (0, react.useRef)({});
8777
9046
  const [editingNote, setEditingNote] = (0, react.useState)(null);
9047
+ const [noteRev, setNoteRev] = (0, react.useState)(0);
8778
9048
  const [editDraft, setEditDraft] = (0, react.useState)("");
8779
9049
  const [pendingLocate, setPendingLocate] = (0, react.useState)(null);
8780
9050
  const [cmapExpanded, setCmapExpanded] = (0, react.useState)(false);
@@ -9198,7 +9468,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9198
9468
  }));
9199
9469
  }
9200
9470
  }, 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", {
9201
- key: n.id,
9471
+ key: `${n.id}:${String(noteRev)}`,
9202
9472
  className: `lks-note${n.pinned ? " pinned" : ""}`
9203
9473
  }, (0, react.createElement)("span", { className: "lks-note-src" }, tr("note.src." + n.source)), (0, react.createElement)("button", {
9204
9474
  className: "lks-note-act",
@@ -9259,6 +9529,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9259
9529
  disabled: editDraft.trim() === "",
9260
9530
  onClick: () => {
9261
9531
  setEditingNote(null);
9532
+ setNoteRev((r) => r + 1);
9262
9533
  fire(editNote(lesson.lessonId, n.id, editDraft));
9263
9534
  }
9264
9535
  }, tr("note.save")))) : (0, react.createElement)(ContentBoundary, {
@@ -9266,7 +9537,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9266
9537
  boundaryKey: `note-${n.id}`
9267
9538
  }, (0, react.createElement)("div", {
9268
9539
  className: "lks-note-text",
9269
- dangerouslySetInnerHTML: { __html: renderMarkdown(n.text) }
9540
+ ref: (el) => {
9541
+ if (el !== null) enhanceRendered(el).catch(() => {});
9542
+ },
9543
+ dangerouslySetInnerHTML: { __html: renderMarkdown(normalizeMathNotation(n.text)) }
9270
9544
  })), n.quote !== null ? (0, react.createElement)("div", { className: "lks-note-q" }, `'${n.quote}'`) : null)) : []);
9271
9545
  })));
9272
9546
  return (0, react.createElement)("div", { className: "lks14-col lks14-note" }, body, confirmNoteDel !== null ? (0, react.createElement)(ConfirmCard, {