瀏覽代碼

feat: 文件拖拽到整个聊天区即可上传

用户要求:不必精确拖到输入框,拖到聊天区域内就能上传。

- dragover/dragleave/drop 从 composer 提升到 .main-col(会话区)
- 新增 dragging 信号 + ::after 虚线高亮蒙层(「松开即可添加到本条消息」)
- 用 dataTransfer.types.includes('Files') 过滤,只接管拖文件
- 必须 preventDefault,否则浏览器会把 drop 当成「打开文件」让 SPA 跳走

同时记录两台容器最终状态:
- 上限均为 800MB(有 env 显式设置与运行时日志佐证)
- 产生 mxc 均为 0 处,mxc 进来会被拒收并回复用户
- 差异:fmode-deliverable-hook.py 只有 lj-test 有

验证:tsc ✅ / 1783 SUCCESS / build ✅ / 线上 main-YVR3T26G.js 一致

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 10 小時之前
父節點
當前提交
b78234af61

+ 14 - 0
docs/00-tracking/操作追踪.md

@@ -8271,3 +8271,17 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 | **容器·40MB 真相** | 该限制在 **`fmode-deliverable-hook.py`**,是交付物进队的门槛(超限就不进队列 → 不产生 https → 出站报 `no-preuploaded-url`)。**注意:这台文件 kangshifu 侧不存在**,两台底座确有差异 |
 | **验证** | `tsc` ✅ / `npm test` **1783 SUCCESS** / `build` ✅ / 线上 `main-4GSYDIKS.js` 与本地一致 |
 | **未完成** | kangshifu 侧的 40MB 定位与 mxc 分支删除仍在进行;两台最终一致性尚未比对 |
+
+## 2026-11-14(续十一)· 拖拽上传范围扩大 + 两台容器最终状态核实
+
+| 项 | 内容 |
+|---|---|
+| **用户疑问 1「新会话为何有历史冲突」** | **不是历史冲突**。截图里容器显示 `/opt/data/cache/documents/doc_883f63b2cc97_计划.md`,与 16:11 那条同名同大小。原因:Hermes 每轮会加载**矩阵房间的历史消息**,同一 DM 房间里 16:11 发过 `计划.md`,16:43 再发同名文件,容器自然说「最初发来的」。属容器的对话上下文行为,非 App bug |
+| **用户疑问 2「为什么说停一停/最初」** | 「停一停」是容器按此前约定的「打断后先停当前任务」逻辑;「最初」同上一条的上下文引用 |
+| **拖拽优化** | 用户要求:文件拖到**整个聊天区域**即可上传,不必精确拖到输入框 |
+| **实现** | `dragover`/`dragleave`/`drop` 从 composer 提升到 `.main-col`(会话区);新增 `dragging` 信号 + `::after` 虚线高亮蒙层(「松开即可添加到本条消息」,`--z-float` 层级);`dataTransfer.types.includes('Files')` 过滤,只接管拖文件 |
+| **🔴 关键点** | 必须 `preventDefault()` —— 否则浏览器把 drop 当「用浏览器打开文件」,整个 SPA 跳走 |
+| **两台最终状态** | lj-test:上限 **800MB**(`.env:559,560` 显式设置,进程 environ 已确认)、产生 mxc **0 处**、超限**会回复用户**。kangshifu:上限 **800MB**(`gateway.log` 实测 `838860800 -> 838860800 (800MB)`,已成功接收过一次 100MB)、产生 mxc **0 处** |
+| **两台差异** | `fmode-deliverable-hook.py` 只有 lj-test 有(40MB 限制在那台,已改 800MB);kangshifu 全盘 find 无此文件。**Hermes 内核相同,`/opt/data/scripts/` 业务脚本各写各的** |
+| **纠正自己的错误** | 我两次给的路径 `/opt/hermes/gateway/relay/adapter.py` **不存在**;真实路径是 `/opt/hermes/plugins/platforms/matrix/adapter.py`(两台都纠正了我) |
+| **验证** | `tsc` ✅ / `npm test` **1783 SUCCESS** / `build` ✅ / 线上 `main-YVR3T26G.js` 与本地一致 |

+ 199 - 0
docs/03-interface/ksf-最终现状-回答.md

@@ -0,0 +1,199 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T08:40:31.524Z
+- 房间:`!ForKmvAujxDv0-iTd6Nm2ZGG_nbw-WatPsT2O3LMTeU`(临时房间,可保留查阅)
+- 消息统计:正文 5 段 / 工具过程 1 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 最终验证:三件事,逐项给真实结果
+
+## 背景:两台底座不同,先各自报现状
+
+kangshifu 已确认:它这台**没有** `fmode-deliverable-hook.py`(全盘 find 0 命中)。
+lj-test 那台有,而且已经改成 800MB。
+
+所以两台的上传路径本来就不是同一套 `/opt/data/scripts/`。**各自确认自己的现状即可。**
+
+---
+
+# 验证 1:上限现在是多少
+
+```bash
+echo "=== 你这边所有大小上限 ==="
+grep -rn "1024 \* 1024\|1024\*1024\|MAX_BYTES\|max_media_bytes" \
+  /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | grep -v ".pyc" | head -20
+
+echo "=== 内核默认值 ==="
+grep -n "_max_media_bytes" /opt/hermes/gateway/relay/adapter.py 2>/dev/null | head -5
+
+echo "=== env 里显式设置了吗 ==="
+grep -n "MATRIX_MAX_MEDIA_BYTES\|FMODE_DELIVERABLE_MAX_BYTES" /opt/data/.env 2>/dev/null || echo "(未设置)"
+```
+
+**要回答**:当前生效的上限是几 MB?在哪个文件哪一行?
+
+---
+
+# 验证 2:mxc 分支删干净了吗
+
+```bash
+grep -rn "mxc://\|upload_media\|needs_encrypted" \
+  /opt/data/plugins/ /opt/data/scripts/ 2>/dev/null | grep -v ".pyc" | head -20
+```
+
+**要回答**:还有几处会产生或接受 mxc?**目标:0。**
+
+---
+
+# 验证 3:超限时的提示
+
+```bash
+grep -rn "too-large\|Rejecting oversized\|oversize" \
+  /opt/data/plugins/ /opt/data/scripts/ 2>/dev/null | grep -v ".pyc" | head -10
+```
+
+**要回答**:超限时会不会**回复用户**?还是只写日志?
+
+---
+
+# 回答格式
+
+```
+## 验证 1 上限
+生效值 = ? MB
+文件:行号 = ?
+env 显式设置 = 有/无
+
+## 验证 2 mxc
+还剩几处 = ?
+列出文件:行号 = ?
+
+## 验证 3 超限提示
+会回复用户吗 = 会/不会
+代码位置 = ?
+```
+
+🔴 只要事实,原样贴 grep 输出。
+🔴 **不要改任何东西**,先把现状报给我。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+收到,三件只读验证,马上跑,不改任何文件。
+```
+
+### 2【prose】
+
+```text
+三处已出原始输出。为了回答「会回复用户吗」和「实际生效值」,我需要再拉几段代码上下文(仍然只读)。
+```
+
+### 3【prose】
+
+```text
+```
+echo "=== V2 mxc ===" ...
+```
+```
+echo "=== outbound 205-235 ===" ...
+```
+```
+
+### 4【prose】
+
+```text
+```
+echo "=== inbound 425,455 ===" ...
+```
+```
+echo "=== 插件加载日志(确认哪个补丁真生效) ===" ...
+```
+```
+
+### 5【prose】
+
+```text
+三件全部跑完,原样贴事实。
+
+## 验证 1 上限
+
+**生效值 = 800 MB**(838860800 字节)
+
+**文件:行号 =**
+-  → `DEFAULT_MAX_MEDIA_BYTES = 800 * 1024 * 1024`(出站)
+-  → `DEFAULT_MAX_MEDIA_BYTES = 800 * 1024 * 1024`(入站)
+- `/opt/hermes/plugins/platforms/matrix/adapter.py:884` → `self._max_media_bytes = _env_number("MATRIX_MAX_MEDIA_BYTES", 100 * 1024 * 1024, int)` ← **内核默认为 100MB,未设置 env 时生效**
+
+**env 显式设置 = 有**
+```
+
+```
+
+运行日志实时确认生效(`gateway.log`):
+```
+10949:... matrix-inbound-s3: _max_media_bytes 838860800 -> 838860800 (800MB)
+10949 之后 inbound 接管过一次 100MB 文件成功
+```
+
+补充事实:你给的第一个 grep 在 `/opt/hermes/gateway/relay/adapter.py` 中命中 0 次。原因是该路径不存在,内核实际文件在 `/opt/hermes/plugins/platforms/matrix/adapter.py`。`security.py:48` 还有一个 `MAX_FILE_BYTES = 8MB`,但那是**只读扫描**的上限,与媒体收发无关。
+
+---
+
+## 验证 2 mxc
+
+**还剩 0 处**会产生或接受 mxc 的**活代码**。
+
+grep 命中的 20 条,按性质分三类:
+
+**① 注释,只说明「已删除」(不是代码)**
+- `plugin-matrix-outbound-s3/__init__.py:10,12,26`
+- `plugin-matrix-inbound-s3/__init__.py:8,13,420`,以及 `plugin.yaml:3`
+
+**② 拒绝逻辑(mxc 进来后被挡掉,属于「不接受」)**
+- `plugin-matrix-inbound-s3/__init__.py:72,78` → 白名单函数对 `mxc://` 返回 False
+- `plugin-matrix-inbound-s3/__init__.py:204,246` → 命中 mxc 走拒绝并回话分支
+
+**③ 不是活代码:归档脚本 + .orig-dedup 备份**
+- (测试桩)
+-  ← **文件名不是 `.py`,Python 不加载**
+
+原本的 `needs_encrypted` 实际函数名是 `_room_needs_encrypted_upload`,仅残留在 `.orig-dedup` 备份里;活文件中没有这个调用。
+
+**如实说明一点**:`head -20` 已截断,上面只给了前 20 条;如果你要 100% 全量清单,我可以去掉 head 再跑一次。
+
+---
+
+## 验证 3 超限提示
+
+**入站:会回复用户**
+-  写日志,紧接 `438-441` 调用 `_notify_user(...)`,回复原文:
+  `⚠️ 附件「%s」有 %.1f MB,超过 800MB 上限,未接收。`
+
+**出站:不直接回复用户,只把错误返回给上层**
+-  写 `logger.warning("REJECT oversized outbound ...")`
+- `218-223` 构造 `return SendResult(success=False, error="file too large: %s is %.1f MB (limit %.0f MB)")`
+
+判断依据:出站是我方主动发送文件,因此返回 `SendResult(success=False)`,由我这边收下错误、由我在对话里告知你——插件自己不发消息。这是设计如此,不算静默丢弃。
+
+---
+
+**写入影响面 = 0**:全程只读(grep / sed -n / ls / 读日志),未改任何文件、未动 .env、未重启进程。
+```
+
+### 6【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== 上限 grep ===" ... ````

+ 160 - 0
docs/03-interface/ljtest-最终现状-回答.md

@@ -0,0 +1,160 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-14T08:40:31.550Z
+- 房间:`!jM953JxoHPBxS1int2DZxEu5Z9GMiWmBV1xhJ7ThpsI`(临时房间,可保留查阅)
+- 消息统计:正文 2 段 / 工具过程 4 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 最终验证:三件事,逐项给真实结果
+
+## 背景:两台底座不同,先各自报现状
+
+kangshifu 已确认:它这台**没有** `fmode-deliverable-hook.py`(全盘 find 0 命中)。
+lj-test 那台有,而且已经改成 800MB。
+
+所以两台的上传路径本来就不是同一套 `/opt/data/scripts/`。**各自确认自己的现状即可。**
+
+---
+
+# 验证 1:上限现在是多少
+
+```bash
+echo "=== 你这边所有大小上限 ==="
+grep -rn "1024 \* 1024\|1024\*1024\|MAX_BYTES\|max_media_bytes" \
+  /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | grep -v ".pyc" | head -20
+
+echo "=== 内核默认值 ==="
+grep -n "_max_media_bytes" /opt/hermes/gateway/relay/adapter.py 2>/dev/null | head -5
+
+echo "=== env 里显式设置了吗 ==="
+grep -n "MATRIX_MAX_MEDIA_BYTES\|FMODE_DELIVERABLE_MAX_BYTES" /opt/data/.env 2>/dev/null || echo "(未设置)"
+```
+
+**要回答**:当前生效的上限是几 MB?在哪个文件哪一行?
+
+---
+
+# 验证 2:mxc 分支删干净了吗
+
+```bash
+grep -rn "mxc://\|upload_media\|needs_encrypted" \
+  /opt/data/plugins/ /opt/data/scripts/ 2>/dev/null | grep -v ".pyc" | head -20
+```
+
+**要回答**:还有几处会产生或接受 mxc?**目标:0。**
+
+---
+
+# 验证 3:超限时的提示
+
+```bash
+grep -rn "too-large\|Rejecting oversized\|oversize" \
+  /opt/data/plugins/ /opt/data/scripts/ 2>/dev/null | grep -v ".pyc" | head -10
+```
+
+**要回答**:超限时会不会**回复用户**?还是只写日志?
+
+---
+
+# 回答格式
+
+```
+## 验证 1 上限
+生效值 = ? MB
+文件:行号 = ?
+env 显式设置 = 有/无
+
+## 验证 2 mxc
+还剩几处 = ?
+列出文件:行号 = ?
+
+## 验证 3 超限提示
+会回复用户吗 = 会/不会
+代码位置 = ?
+```
+
+🔴 只要事实,原样贴 grep 输出。
+🔴 **不要改任何东西**,先把现状报给我。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+terminal · echo "=== 1. 所有大小上限 ==="; grep -rn "1024 \* 1024\|1024\*1024…
+```
+
+### 2【prose】
+
+```text
+## 验证 1 上限
+
+**生效值 = 800 MB**(838860800 字节)
+
+**文件:行号 =**
+- `/opt/data/scripts/fmode-deliverable-hook.py:124` — `MAX_BYTES = int(os.environ.get("FMODE_DELIVERABLE_MAX_BYTES", str(800 * 1024 * 1024)))`
+- `/opt/data/plugins/plugin-matrix-outbound-s3/__init__.py:219` — `_MAX_MEDIA_BYTES = 800 * 1024 * 1024`
+- 内核默认:`/opt/hermes/plugins/platforms/matrix/adapter.py:905` — `self._max_media_bytes = _env_number("MATRIX_MAX_MEDIA_BYTES", 100 * 1024 * 1024, int)`(默认 100MB,会被 env 覆盖)
+
+注意:你给的 `/opt/hermes/gateway/relay/adapter.py` grep 无输出(0 命中),MATRIX adapter 真实路径是 `/opt/hermes/plugins/platforms/matrix/adapter.py`。
+
+**env 显式设置 = 有**
+```
+559:MATRIX_MAX_MEDIA_BYTES=838860800
+560:FMODE_DELIVERABLE_MAX_BYTES=838860800
+```
+运行时回读(`/proc/156766/environ`):`MATRIX_MAX_MEDIA_BYTES=838860800` ,实打实进到进程里了。
+
+两个 .bak 文件残留旧的 40MB(`fmode-deliverable-hook.py.bak-20261114-153807:124` / `.bak-800mb-162930:124`),不参与运行。
+
+## 验证 2 mxc
+
+**还剩几处 = 0 处会"产生"mxc;另有 1 处"接受"分支 = 拒绝闸门(按设计保留,会回消息拒收)**
+
+grep 命中 18 条,逐条性质:
+- `plugin-matrix-outbound-s3/plugin.yaml:3`、`__init__.py:1,11,14` — 文档/注释描述「不再产生 mxc」
+- `__init__.py:191,194` — 已注释掉的 legacy 函数(`#   MatrixAdapter._upload_media_to_object_storage`)
+- `__init__.py:210,211,212` — 注释说明改前/改后语义
+- `__init__.py:253,254,258` — **入站闸门**:`if url.startswith("mxc://")` → 拒绝并回「mxc:// 已停用」
+- `__init__.py:336,374` — 注释说明不注入 E2EE 分支
+- `fmode_outbound_media.py:4` + 同名 `.bak-20261114-153807:4` — 文件头注释
+
+真正的 `upload_media()` 调用 = 0 处;`needs_encrypted` 只出现在注释里(336 行)。**产生方全部走 
+
+## 验证 3 超限提示
+
+**会回复用户吗 = 会**(两条路径都回,不是只写日志)
+
+**代码位置 =**
+
+出站(交付文件超限):
+- `/opt/data/scripts/fmode-deliverable-hook.py:913` `_too_large_note()` 生成提示文案
+- `:936-940` `_emit()` 里 `note = _too_large_note()` → 拼进 `body` → `print(json.dumps({"response_text": body}))`
+- `:358` 记录 `TOO-LARGE-NOTICE`,`:914` 注释写明「2026-11-14 用户裁决:超限必须有提示,不许静默丢弃」
+- 文案:`- 文件「xxx」有 %.1f MB,超过 800MB 交付上限,未上传云端。`
+
+入站(用户发来附件超限):
+- `/opt/data/plugins/plugin-matrix-outbound-s3/__init__.py:270-278` — 先 `logger.warning("REJECT oversized inbound...")`,**紧接着** `await _notify_user_blocked(...)` 回「⚠️ 附件「x」有 N MB,超过 800MB 上限,未接收。」
+- `:232-240` `_notify_user_blocked()` 用 `_send_content_event` 发 m.text,异常不上抛
+
+未修改任何文件。
+```
+
+### 3【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== 1. 所有大小上限 ==="; grep -rn "1... ````
+- `* 💻 terminal ``` echo "=== 1. 所有大小上限 ==="; grep -rn "1... ``` ``` echo "=== 2. mxc 残留 ==="; grep -rn "m... ``` ``` echo "=== adapter.py 实际路径确认 ==="; ls ... ````
+- `* 💻 terminal ``` echo "=== 1. 所有大小上限 ==="; grep -rn "1... ``` ``` echo "=== 2. mxc 残留 ==="; grep -rn "m... ``` ``` echo "=== adapter.py 实际路径确认 ==="; ls ... ``` ``` echo "=== _MAX_MEDIA_BYTES 上下文 ==="`
+- `* 💻 terminal ``` echo "=== 1. 所有大小上限 ==="; grep -rn "1... ``` ``` echo "=== 2. mxc 残留 ==="; grep -rn "m... ``` ``` echo "=== adapter.py 实际路径确认 ==="; ls ... ``` ``` echo "=== _MAX_MEDIA_BYTES 上下文 ==="`

+ 70 - 0
docs/03-interface/最终验证-100MB与900MB.md

@@ -0,0 +1,70 @@
+# 最终验证:三件事,逐项给真实结果
+
+## 背景:两台底座不同,先各自报现状
+
+kangshifu 已确认:它这台**没有** `fmode-deliverable-hook.py`(全盘 find 0 命中)。
+lj-test 那台有,而且已经改成 800MB。
+
+所以两台的上传路径本来就不是同一套 `/opt/data/scripts/`。**各自确认自己的现状即可。**
+
+---
+
+# 验证 1:上限现在是多少
+
+```bash
+echo "=== 你这边所有大小上限 ==="
+grep -rn "1024 \* 1024\|1024\*1024\|MAX_BYTES\|max_media_bytes" \
+  /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | grep -v ".pyc" | head -20
+
+echo "=== 内核默认值 ==="
+grep -n "_max_media_bytes" /opt/hermes/gateway/relay/adapter.py 2>/dev/null | head -5
+
+echo "=== env 里显式设置了吗 ==="
+grep -n "MATRIX_MAX_MEDIA_BYTES\|FMODE_DELIVERABLE_MAX_BYTES" /opt/data/.env 2>/dev/null || echo "(未设置)"
+```
+
+**要回答**:当前生效的上限是几 MB?在哪个文件哪一行?
+
+---
+
+# 验证 2:mxc 分支删干净了吗
+
+```bash
+grep -rn "mxc://\|upload_media\|needs_encrypted" \
+  /opt/data/plugins/ /opt/data/scripts/ 2>/dev/null | grep -v ".pyc" | head -20
+```
+
+**要回答**:还有几处会产生或接受 mxc?**目标:0。**
+
+---
+
+# 验证 3:超限时的提示
+
+```bash
+grep -rn "too-large\|Rejecting oversized\|oversize" \
+  /opt/data/plugins/ /opt/data/scripts/ 2>/dev/null | grep -v ".pyc" | head -10
+```
+
+**要回答**:超限时会不会**回复用户**?还是只写日志?
+
+---
+
+# 回答格式
+
+```
+## 验证 1 上限
+生效值 = ? MB
+文件:行号 = ?
+env 显式设置 = 有/无
+
+## 验证 2 mxc
+还剩几处 = ?
+列出文件:行号 = ?
+
+## 验证 3 超限提示
+会回复用户吗 = 会/不会
+代码位置 = ?
+```
+
+🔴 只要事实,原样贴 grep 输出。
+🔴 **不要改任何东西**,先把现状报给我。

+ 78 - 1
src/app/desktop/pages/chat/chat.ts

@@ -180,7 +180,19 @@ interface ConversationRow {
       -->
 
       <!-- ===== 聊天区 ===== -->
-      <section class="col main-col" aria-label="消息">
+      <!--
+        2026-11-14 用户要求:文件拖到**整个聊天区域**即可上传,不必精确拖到输入框。
+        原实现只把 dragover/drop 绑在 composer 上,用户拖到消息列表就被浏览器
+        当成「打开文件」跳走(或什么都不发生),体验很差。
+      -->
+      <section
+        class="col main-col"
+        aria-label="消息"
+        [class.drag-over]="dragging()"
+        (dragover)="onAreaDragOver($event)"
+        (dragleave)="onAreaDragLeave($event)"
+        (drop)="onAreaDrop($event)"
+      >
         @if (chat.state() === 'loading') {
           <!--
             🔴 会话加载中 → 骨架屏(2026-11-14 用户要求「加载完成之后才显示」)。
@@ -850,6 +862,27 @@ interface ConversationRow {
       .main-col {
         background: var(--surface);
         border-right: 1px solid var(--edge);
+        /* 拖拽高亮需要定位上下文(::after 铺满整块) */
+        position: relative;
+      }
+      /*
+       * 拖文件进入聊天区 → 整块蒙一层提示(2026-11-14 用户要求)。
+       * 用 ::after 而不是额外 DOM:不干扰内部布局与滚动。
+       */
+      .main-col.drag-over::after {
+        content: '松开即可添加到本条消息';
+        position: absolute;
+        inset: var(--sp-3);
+        display: grid;
+        place-items: center;
+        border: 2px dashed var(--primary);
+        border-radius: var(--r-lg);
+        background: color-mix(in srgb, var(--primary-subtle) 88%, transparent);
+        color: var(--primary);
+        font-size: var(--fs-14);
+        font-weight: 600;
+        pointer-events: none;
+        z-index: var(--z-float);
       }
       .rail-col {
         background: var(--rail-surface);
@@ -3470,6 +3503,50 @@ export class DkChatPage implements OnDestroy {
     this.chat.sessionBusy() ? '发送将打断当前任务,容器会立即开始处理这条' : '',
   );
 
+  /**
+   * 整块聊天区域的文件拖放(2026-11-14 用户要求)。
+   *
+   * ## 为什么不在 composer 上做
+   *
+   * 原实现把 `dragover`/`drop` 只绑在输入框上。用户拖文件到**消息列表**
+   * 这种更自然的位置时,浏览器会把 drop 当成「用浏览器打开这个文件」,
+   * 整个 SPA 直接跳走 —— 丢状态、看起来像崩溃。
+   *
+   * ## 现在的行为
+   *
+   * 拖进**整个聊天区**(`.main-col`)就高亮并接受,落到哪都算数。
+   * 输入框那条路径保留(两侧都能用,不会重复触发 —— 冒泡到 section 时
+   * composer 已经 preventDefault,这里再兜一次也无害)。
+   */
+  protected readonly dragging = signal(false);
+
+  protected onAreaDragOver(e: DragEvent): void {
+    /* 只接管「拖文件」;拖文本/链接保持浏览器默认 */
+    if (!e.dataTransfer?.types.includes('Files')) return;
+    /* 🔴 必须 preventDefault:否则浏览器把 drop 当「打开文件」,页面直接跳走 */
+    e.preventDefault();
+    e.dataTransfer.dropEffect = 'copy';
+    if (!this.dragging()) this.dragging.set(true);
+  }
+
+  protected onAreaDragLeave(e: DragEvent): void {
+    /*
+     * 只在**真正离开整个区域**时取消高亮。
+     * 子元素之间移动也会触发 dragleave,用 relatedTarget 判断可避免闪烁。
+     */
+    const to = e.relatedTarget as Node | null;
+    if (to && (e.currentTarget as HTMLElement).contains(to)) return;
+    this.dragging.set(false);
+  }
+
+  protected onAreaDrop(e: DragEvent): void {
+    if (!e.dataTransfer?.types.includes('Files')) return;
+    e.preventDefault();
+    this.dragging.set(false);
+    const files = e.dataTransfer.files;
+    if (files && files.length > 0) this.chat.stageFiles(files);
+  }
+
   retryOpen(): void {
     const id = this.chat.agentId();
     if (id) void this.chat.open(id, this.chat.sessionId() || undefined, true);