feat(web-shell): support upload directory and hard-disable drag-in when fileUploadEnabled=false - #9382
Conversation
…en fileUploadEnabled=false
fileUploadEnabled={false} previously still admitted file drags onto the
inline image/text lane, producing attachment tags with no upload. It now
disables file drag-and-drop entirely — no drag highlight, no drop ingestion,
no upload — while clipboard paste stays enabled. The composer core gates its
drag/drop handlers behind a new fileDragEnabled option; ChatEditor cancels
the drop outright.
A new fileUploadDirectory prop (relative to the workspace root) sets the
drag-upload target directory; the root remains the default. The daemon upload
route now materializes a missing target directory (recursively, depth-capped)
via a new WorkspaceFileSystem.mkdir, so a configured drop folder needs no
manual setup. mkdir follows the existing write-path safeguards: trust gate,
generation guard, path lock, audit, and symlink-swap re-checks per created
component and its parent.
|
✅ Qwen Triage finished — CI landed green on ✅ Qwen Triage 已完成 —— |
|
Thanks for the PR! Template looks good ✓ Problem: real, and I verified it in the base code rather than taking the description at face value. With Direction: aligned. This refines an already-shipped web-shell capability (host control + upload routing) instead of adding a parallel mechanism. No auth/model-selection/telemetry/release surface is touched; the daemon-side change stays inside the existing trust model (capability-gated, trusted workspace, qualified routes). The widened directory-creation surface is explicitly documented in the design doc, including the Size: 252 production lines (227 code + the audit Approach: the scope feels right. Gating in the composer core (not just ChatEditor) is the correct layer — the drag highlight and the capture-phase drop ingestion live there, so a ChatEditor-only early return would have left both leaking. The new Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk paths. Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题:真实存在,且我已在基础代码中核实(而非仅凭描述)。 方向:对齐。这是对已上线的 web-shell 能力(宿主控制 + 上传路由)的完善,而不是新增平行机制。未触及 auth/模型选择/遥测/发布面;daemon 侧改动仍在现有信任模型内(capability 门控、受信任工作区、qualified 路由)。拓宽的建目录能力已在设计文档中明确说明,包括对 规模:生产代码 252 行(227 行代码 + audit 的 方案:范围合理。在合成器核心层(而非仅在 ChatEditor)做门控是正确的层级——拖拽高亮与 capture 阶段的 drop 摄入都在那里,只在 ChatEditor 提前返回无法堵住这两处泄漏。新的 风险:无升级风险信号——改动文件均未命中与 revert 相关的高风险路径。 进入代码审查 🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewI read the full diff against the base code. No blockers found — the implementation is careful and the test coverage is genuinely behavioral, not ceremonial. Notes below. Daemon side. The new The route change is placed correctly: the 64-component cap fires before buffering and before the concurrency gate, and One suggestion (non-blocking). The depth cap counts Web-shell side. The gate sits at the right layer in both places. I verified the wiring in the base code: the composer core's capture-phase drag handlers are spread onto the surface and fire before ChatEditor's drop handler, so gating only in ChatEditor would have left both the drag highlight and the inline-lane ingestion leaking — the new Tests. The suite pins the behavior rather than the implementation: the route test flips from "rejects a missing parent" to "creates it and uploads into it" (matching the intended behavior change), a 65-component request asserts rejection before any tree exists, the symlink-race tests swap the final component and an intermediate ancestor respectively, and the DOM test asserts an inert drag end-to-end (no highlight, Files changed (14 of 14 shown)
Test evidence (PR's own CI — I do not run PR code)No failures so far. The main unit suite is still in flight at review time — Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Sandboxed verification would settle the remaining gap: Not verified: real-browser drag-and-drop behavior (unit DOM tests only), and Windows/Linux runtime behavior — author reports macOS tested, Windows/Linux untested. 中文说明代码审查:未发现阻塞问题。daemon 侧新增的 测试证据:引自 PR 自身的 CI(不运行 PR 代码)。截至目前无失败;主单元测试(ubuntu Test、Serve A/B、web-shell 视觉快照)仍在运行,表格将由 finalize 任务在 CI 落定后原地更新。macOS/Windows 测试任务为 skipped(fork PR 常态),其余已完成检查全部通过。沙盒验证可补齐剩余缺口: — Qwen Code · qwen3.8-max Reviewed at |
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterℹ️ No screenshot changed against the PR base — but this PR edits 3 render-shaping files:
Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to Full-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
🩺 serve daemon A/BBuilt the PR base vs this PR head ✅ No response changes against the PR base across 4 scenario(s). — Qwen Code · serve A/B |
|
Confidence: 4/5 — clean, well-tested change that closes a real leak in an existing host-control gate; the only nit is the Windows depth-cap counting, which is bounded anyway. Stepping back: this PR does one coherent thing — make the web-shell's file-upload host controls actually hold — and it does it at the right layers. Before reading the diff I would have proposed exactly this shape: gate the composer core's capture-phase drag handlers behind the existing flag, cancel drops without letting the browser navigate, and back the new directory prop with a hardened mkdir in the fs layer rather than a route-level Reservations, all non-blocking: the Windows separator counting noted in my review; empty directories left behind by failed uploads (disclosed, acceptable for a drop folder); and real-browser drag-and-drop remains untested beyond DOM unit tests — the sandboxed Approval is deferred until CI lands green on 中文说明总体判断:这是一个内聚的改动——让 web-shell 文件上传的宿主控制真正生效——且层级选择正确。读 diff 之前我的独立方案与此一致:在合成器核心的 capture 阶段门控拖拽处理器、取消 drop 但不让浏览器导航、用 fs 层加固的 mkdir 支撑新的目录属性,而不是在路由里走 保留意见(均不阻塞):review 中提到的 Windows 分隔符计数问题(影响有界);失败上传可能留下空目录(已披露,对上传目录可接受);真实浏览器拖拽在 DOM 单元测试之外尚未验证——如需在合并前补齐,可由 maintainer 触发上面提到的沙盒 批准暂缓至 CI 在 — Qwen Code · qwen3.8-max Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
chiga0
left a comment
There was a problem hiding this comment.
Review — feat(web-shell): support upload directory and hard-disable drag-in
Verdict: Approve — solid implementation with thorough security design and strong test coverage. Two nits below.
Cross-Validation
| Finding | CI bot | My Assessment |
|---|---|---|
| General LGTM | ✅ APPROVED | Confirmed — reviewed independently across all 14 files |
The CI bot left no inline findings, so this section tracks my independent audit dimensions against its approval.
Additional Audit Coverage
Independent areas I verified beyond the green-CI signal:
-
ensureResolvedDirectorysecurity chain — traced the full safeguard sequence for both recursive and non-recursive paths: trust gate → generation guard → path lock (on final path) → per-componentlstatre-check immediately after eachmkdir→ parentlstatre-check before the nextmkdir. Concurrent-create races (EEXIST) are caught and re-verified rather than silently swallowed. The two symlink-swap race tests (final component and intermediate ancestor) exercise the real code paths correctly. -
fileDragEnabledhandler completeness — verified all four drag event handlers inuseComposerCore.ts(onDragEnterCapture,onDragOverCapture,onDragLeaveCapture,onDropCapture) consistently gate onfileDragEnabledRef.current. The mid-draguseEffectcleanup handles the case wherefileUploadEnabledflips tofalsewhile a drag is active (depth counter would otherwise never drain). The ChatEditor's own early-return guard is a correct second layer, not redundancy: the two handlers sit on different elements (outer surface capture vs. editor bubble phase). -
Depth cap correctness —
dir.split('/').filter(Boolean).length > 64is a conservative upper bound:.components count but create no directories;..components count but are rejected byfs.resolvedownstream. The cap is a pre-admission coarse filter; the actual security boundary remainsfs.resolve. Verifiedfilter(Boolean)correctly handles leading slashes and empty segments. -
bridge-file-system-adapter.tstype boundary — confirmedcreateBridgeFileSystemAdapterreturnsBridgeFileSystem, notWorkspaceFileSystem, so it does not need amkdirimplementation. Themkdir: vi.fn()additions in the test are for unrelatedWorkspaceFileSystemmock objects (needed because the interface now declaresmkdir). -
Audit record position —
recordAccessis called insidepathLocks.runExclusive, afterensureResolvedDirectorycompletes and the generation guard is re-asserted. Thestarttimestamp is captured before the lock (consistent withwriteBytesAtomicand other write primitives), sodurationMscovers the full wall-clock cost including lock contention. -
Clipboard paste unaffected — confirmed
fileDragEnabled=falseonly gatesonDragEnterCapture / onDragOverCapture / onDragLeaveCapture / onDropCapture; paste (onPaste) is a separate code path not touched by this PR.
Nits
N1 · ChatEditor.tsx:1749 — empty-string fileUploadDirectory passes through ??
uploadFiles(files, fileUploadDirectory ?? '.', insertUploadReference);?? only substitutes when the value is null | undefined; an explicit '' reaches uploadFiles as an empty string. The server-side behavior (fs.resolve('', 'write')) is not documented and the path through fileUploadAdmission for dir = '' is implicit (resolves to workspace root in practice, but relies on path.dirname turning '' into .). || '.' instead of ?? '.' would make the intent explicit, or a comment noting the empty-string case is intentionally treated as root.
N2 · App.tsx — no client-side warning for leading-slash fileUploadDirectory
The docs and server both reject paths starting with / ("outside the workspace"), but there is no client-side guard. A host accidentally passing '/uploads' would only discover the failure on the first drag-and-drop attempt. A console.warn on mount (when the prop is non-nullish and starts with /) would improve developer-facing DX without any runtime cost.
Neither nit affects correctness or security.
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline.
Not explored to full depth (tool budget reached): "agent 1b": none.** All planned checks completed within budget..
中文说明
已审查。 建议见行内评论。
未探索到全部深度(达到工具调用预算):"agent 1b":none.** All planned checks completed within budget.。
— qwen3.8-max via Qwen Code /review (v0.21.13)
| await fs.mkdir(resolvedDir, { recursive: true }); | ||
| dirStat = await fs.stat(resolvedDir); |
There was a problem hiding this comment.
[Suggestion] R1-1: Recursive directory creation runs ahead of the upload concurrency gate — this fs.mkdir(..., { recursive: true }) runs in the fileUploadAdmission middleware, registered BEFORE fileUploadConcurrencyGate, so directory materialization is not throttled by MAX_CONCURRENT_UPLOADS = 4, and nothing rolls a created tree back when the request later fails. — Failure scenario: an authenticated client floods POST /file/upload with unique deep paths and oversized/empty bodies → each request materializes up to ~39 missing directories in admission, then 413/429s without ever holding a gate slot; sustained flooding exhausts workspace inodes and every rejected request leaves empty directories behind.
Witness (probe against this commit):
chunked 51 MiB upload to a 30-deep path → 413 file_too_large, directoriesMaterialized: 30, treeExistsAfter413: true
8 concurrent 4 MiB uploads with unique 20-deep trees → statuses {201: 4, 429: 4}; all four 429'd requests had dirCreated: true
Suggested fix: move the fs.mkdir + re-stat out of admission into handlePostFileUpload after the gate lease is acquired (admission keeps the cheap fs.resolve boundary validation), so directory creation is subject to the same concurrency bound and rejected/aborted requests do not materialize trees — or document the accepted aggregate risk in the design doc.
中文说明
[Suggestion] R1-1:递归建目录发生在上传并发门控之前 —— 此处的 fs.mkdir(..., { recursive: true }) 运行在 fileUploadAdmission 中间件中,而该中间件注册在 fileUploadConcurrencyGate 之前,因此目录创建不受 MAX_CONCURRENT_UPLOADS = 4 的限制;请求随后失败时也没有任何回滚,已创建的目录树会被留下。— 失败场景:已认证客户端用唯一的深层路径加超大/空 body 轰炸 POST /file/upload → 每个请求在 admission 阶段创建最多约 39 个缺失目录,然后在从未持有门控槽位的情况下返回 413/429;持续轰炸会耗尽工作区 inode,且每个被拒绝的请求都会留下空目录。
证据(针对本 commit 的探针):分块 51 MiB 上传到 30 层深路径 → 413 file_too_large,directoriesMaterialized: 30,treeExistsAfter413: true;8 个并发 4 MiB 上传(唯一的 20 层深树)→ 状态 {201: 4, 429: 4},四个 429 请求全部 dirCreated: true。
建议修复:把 fs.mkdir + 重新 stat 从 admission 移到获取门控租约之后的 handlePostFileUpload 中(admission 保留低成本的 fs.resolve 边界校验),使目录创建受同样的并发限制,被拒绝/中止的请求不会创建目录树 —— 或者在设计文档中明确记录这一已接受的总体风险。
— qwen3.8-max via Qwen Code /review (v0.21.13)
| * body is buffered, so a single request must not be able to spin up an | ||
| * unbounded directory tree ahead of the concurrency gate). | ||
| */ | ||
| const MAX_UPLOAD_DIR_DEPTH = 64; |
There was a problem hiding this comment.
[Suggestion] R1-2: MAX_UPLOAD_DIR_DEPTH = 64 overlaps inconsistently with the pre-existing MAX_ANCESTOR_HOPS = 40 in fs/paths.ts — resolve's ENOENT fallback (findExistingAncestor) walks at most 40 hops for a not-yet-existing directory, which is exactly the path fs.resolve(dir, 'write') takes here, so the advertised 40-64 create range is a dead zone (effective create range: ≤39 fully-missing components). — Failure scenario: a host-configured drop folder 45 components deep (all missing) passes the static cap (45 ≤ 64), then fs.resolve exhausts its 40-hop budget and the upload fails with 404 path_not_found ("path traversal exceeded 40 hops while finding ancestor") instead of a created directory or the documented 400 depth-cap parse_error.
Witness (probe):
resolveWithinWorkspace on fully-missing dirs: 38/39 → resolved; 40/45/64 → threw path_not_found ("path traversal exceeded 40 hops")
route-level: 39 missing components → 201 created; 40 → 404; 45 → 404 "path traversal exceeded 40 hops while finding ancestor"
Suggested fix: align the two bounds — derive MAX_UPLOAD_DIR_DEPTH from MAX_ANCESTOR_HOPS so they cannot drift, or document the interaction explicitly; optionally add a route test at ~45 missing components pinning whichever behavior is chosen.
中文说明
[Suggestion] R1-2:MAX_UPLOAD_DIR_DEPTH = 64 与 fs/paths.ts 中既有的 MAX_ANCESTOR_HOPS = 40 重叠且不一致 —— resolve 的 ENOENT 回退(findExistingAncestor)对尚不存在的目录最多向上查找 40 跳,而这里的 fs.resolve(dir, 'write') 走的正是该路径,因此宣传的 40-64 创建区间是死区(有效创建范围为 ≤39 个完全缺失的组件)。— 失败场景:宿主配置了 45 层深(全部缺失)的上传目录:静态上限检查通过(45 ≤ 64),随后 fs.resolve 耗尽 40 跳预算,上传以 404 path_not_found("path traversal exceeded 40 hops while finding ancestor")失败,而不是创建目录或返回文档所述的 400 深度上限 parse_error。
建议修复:对齐两个边界 —— 从 MAX_ANCESTOR_HOPS 推导 MAX_UPLOAD_DIR_DEPTH 使两者不会漂移,或明确记录这一相互作用;可选地在约 45 个缺失组件处加一个路由测试来固定所选行为。
— qwen3.8-max via Qwen Code /review (v0.21.13)
| // 65 components exceeds MAX_UPLOAD_DIR_DEPTH; the request must fail | ||
| // before any directory tree is materialized. |
There was a problem hiding this comment.
[Suggestion] R1-3: Inclusive depth-cap boundary untested — this test pins only the 65-component rejection, and the check is > (workspace-file-write.ts:621), so exactly 64 components (the documented maximum) is accepted today but the > → >= off-by-one ships green. — Failure scenario: under that mutation, a host whose configured drop folder sits at exactly the cap gets spurious 400 parse_error rejections with no CI signal.
Witness (probe):
exactly 64 components (25 pre-existing + 39 missing) → 201 on current code
mutation `>` → `>=` → the same request flips to 400 parse_error; the full route suite passes 66/66 under the mutation
Suggested fix: add the paired boundary test — upload into a directory with exactly 64 components (with enough pre-existing ancestors to stay under resolve's hop budget) and expect 201 with the tree created.
中文说明
[Suggestion] R1-3:深度上限的闭边界(恰好 64 个组件)没有测试 —— 本测试只固定了 65 个组件被拒绝的情况,而检查用的是 >(workspace-file-write.ts:621),因此恰好 64 个组件(文档规定的最大值)目前会被接受,但 > → >= 的 off-by-one 变异可以让全部测试绿灯通过。— 失败场景:在该变异下,宿主配置的上传目录恰好位于上限深度时会收到错误的 400 parse_error 拒绝,且 CI 毫无信号。
建议修复:补充成对的边界测试 —— 上传到恰好 64 个组件的目录(有足够的已存在祖先以保持在 resolve 跳数预算内),期望 201 且目录树被创建。
— qwen3.8-max via Qwen Code /review (v0.21.13)
| it('creates a missing directory', async () => { | ||
| const r = await h.fs.resolve('new-dir', 'write'); | ||
| await h.fs.mkdir(r); | ||
| const st = await fsp.stat(path.join(h.workspace, 'new-dir')); |
There was a problem hiding this comment.
[Suggestion] R1-4: Documented 0o755 create mode asserted by no test — the interface JSDoc added by this diff and the design doc both state directories are created at 0o755 (modulo umask), and ensureResolvedDirectory passes { mode: 0o755 } in both create branches, but none of the eight new mkdir tests asserts the resulting mode (the same file pins 0o600 for created files at lines 1272 and 2389-2394). — Failure scenario: mutating the mode to 0o700 or 0o777 ships silently; under umask 002, 0o777 yields group/world-writable daemon-created directories, contradicting the documented contract.
Suggested fix:
// pin umask in the harness so the assert is environment-independent
process.umask(0o022);
// in the create tests (single and recursive):
expect(st.mode & 0o777).toBe(0o755);中文说明
[Suggestion] R1-4:文档所述的 0o755 创建模式没有任何测试断言 —— 本 diff 新增的接口 JSDoc 和设计文档都声明目录以 0o755(受 umask 影响)创建,ensureResolvedDirectory 的两个创建分支也都传入 { mode: 0o755 },但新增的 8 个 mkdir 测试没有一个断言实际模式(同一文件在 1272 行和 2389-2394 行为创建的文件固定了 0o600)。— 失败场景:把模式变异为 0o700 或 0o777 可以悄无声息地合入;在 umask 002 下 0o777 会产生组/全局可写的 daemon 创建目录,与文档契约矛盾。
建议修复:在测试框架中固定 umask(如 process.umask(0o022)),并在单级与递归创建测试中断言 expect(st.mode & 0o777).toBe(0o755)。
— qwen3.8-max via Qwen Code /review (v0.21.13)
| } | ||
| } | ||
|
|
||
| async mkdir(p: ResolvedPath, opts?: { recursive?: boolean }): Promise<void> { |
There was a problem hiding this comment.
[Suggestion] R1-5: mkdir lacks the untrusted-workspace rejection test every sibling primitive has — the trust guard is present below (assertTrustedForIntent(..., 'write')), so this is a missing-test finding, not a missing-guard one; but deleting the guard leaves the whole suite green, while writeTextOverwrite/writeBytesAtomic/edit all pin their rejections (lines 1323, 1726, 1735, 2397). — Failure scenario: a future or regressed caller of fs.mkdir materializes directory trees in untrusted workspaces with no failing test to notice.
Suggested fix — mirror the sibling pattern:
it('denies mkdir with untrusted_workspace', async () => {
const h = await makeHarness({ trusted: false });
const r = await h.fs.resolve('new-dir', 'write');
const err = await h.fs.mkdir(r).catch((e: unknown) => e);
expect(isFsError(err)).toBe(true);
expect((err as { kind: string }).kind).toBe('untrusted_workspace');
});中文说明
[Suggestion] R1-5:mkdir 缺少其他所有变更原语都有的 untrusted-workspace 拒绝测试 —— 信任守卫确实存在(下方的 assertTrustedForIntent(..., 'write')),因此这是缺测试而非缺守卫;但删除该守卫整个测试套件依然绿灯,而 writeTextOverwrite/writeBytesAtomic/edit 都固定了各自的拒绝行为(1323、1726、1735、2397 行)。— 失败场景:未来或回归的 fs.mkdir 调用方会在不受信任的工作区中创建目录树,而没有任何失败的测试能发现。
建议修复:仿照兄弟测试模式补充上面的用例。
— qwen3.8-max via Qwen Code /review (v0.21.13)
| if (fileDragEnabled === false) clearImageDragState(); | ||
| }, [clearImageDragState, fileDragEnabled]); |
There was a problem hiding this comment.
[Suggestion] R1-6: This mid-drag cleanup effect has no test — deleting it ships green, yet it covers the scenario its own comment names: a host flipping fileUploadEnabled to false mid-drag gates the leave/drop handlers, and OS-originated file drags never fire dragend in the page (the window listener only sees page-originated drags). — Failure scenario: without this effect imageDragActive stays true indefinitely — the composer keeps advertising a drop target that no longer accepts drops, until unmount.
Witness (jsdom probe):
fileDragEnabled=true + dragenter/dragover (Files payload) → dragActiveBeforeFlip: true
flip to false + rerender → dragActiveAfterFlip: false
with the useEffect deleted → dragActiveAfterFlip: true (stuck highlight); 151/151 tests still pass under the mutation
Suggested fix: add a DOM test — mount with file drag enabled, dispatch dragenter with a Files payload and assert imageDragActive === true; re-render with fileDragEnabled: false and assert imageDragActive === false.
中文说明
[Suggestion] R1-6:这个拖拽中途清理 effect 没有测试 —— 删除它所有测试依然绿灯,而它覆盖的正是其注释所述的场景:宿主在拖拽中途把 fileUploadEnabled 翻转为 false 时,leave/drop 处理器被门控,而来自操作系统的文件拖拽不会在页面内触发 dragend(window 监听器只能看到页面内发起的拖拽)。— 失败场景:没有这个 effect,imageDragActive 会一直为 true —— 合成器会持续显示一个不再接受 drop 的放置目标,直到卸载。
建议修复:补一个 DOM 测试 —— 以启用文件拖拽挂载,派发带 Files 负载的 dragenter 并断言 imageDragActive === true;以 fileDragEnabled: false 重新渲染并断言 imageDragActive === false。
— qwen3.8-max via Qwen Code /review (v0.21.13)
|
Released in v0.21.14. |
What this PR does
Web Shell file upload gets two changes.
First,
fileUploadEnabled={false}now disables file drag-and-drop entirely. Previously a file dragged onto the composer still landed on the inline image/text lane, producing an attachment tag even though no upload happened. Now the composer surface ignores file drags completely — no drag highlight, no drop ingestion, no upload — while clipboard paste of images and text stays enabled. The composer core gates its drag/drop handlers behind a newfileDragEnabledoption, and the editor cancels the drop outright, so the browser cannot navigate to a dropped file.Second, a new
fileUploadDirectoryprop (relative to the workspace root) sets where drag-and-dropped files upload; the workspace root remains the default. The daemon upload route now materializes a missing target directory — recursively, with a 64-component depth cap — via a newWorkspaceFileSystem.mkdirprimitive, so a configured drop folder needs no manual setup.Why it's needed
Hosts that disable file upload expect drag-in to be fully inert; leaking attachment tags through the inline lane contradicts that. Hosts that do enable upload need a way to route dropped files into a specific folder (e.g. an
uploads/directory) without pre-creating it, which is what the new prop plus auto-created directories provide.Reviewer Test Plan
How to verify
cd packages/cli && npx vitest run src/serve/fs/workspace-file-system.test.ts src/serve/routes/workspace-file-write.test.tsandcd packages/web-shell && npx vitest run client/components/ChatEditor.test.tsx client/hooks/useComposerCore.dom.test.tsx client/App.test.tsx.fileUploadEnabled={false}: drag a file (and separately an image) onto the composer — no drop highlight, no overlay, no tag, no upload strip appears; Ctrl/Cmd+V of an image still attaches it.fileUploadDirectory="uploads"(folder does not exist): drag a file into the composer — it uploads intouploads/, the directory is created automatically, and the composer inserts an@uploads/<name>reference.parse_errorbefore any directory is created.Evidence (Before & After)
N/A — behavior is covered by the unit tests above (drag-in gating, directory plumbing, auto-creation, depth cap).
Tested on
Environment (optional)
npm run dev:daemonwith the standalone web-shell page, verifying drag-in disable and directory upload end-to-end.Risk & Scope
docs/design/web-shell-file-upload.md.fileUploadDirectory); leading-slash directory values are intentionally rejected as outside the workspace.Linked Issues
None.
中文说明
本 PR 做了什么
Web Shell 文件上传功能有两项改动。
第一项:
fileUploadEnabled={false}现在会完全禁用文件拖入。此前文件拖到合成器上仍会落入 inline 图片/文本通道,产生附件 tag(尽管并没有真正上传)。现在合成器表面会完全忽略文件拖拽——没有拖拽高亮、没有 drop 摄入、没有上传——而剪贴板粘贴图片和文本仍然可用。合成器核心在新增的fileDragEnabled选项后面门控了它的拖拽/drop 处理器,编辑器也会直接取消 drop,避免浏览器导航到被拖入的文件。第二项:新增
fileUploadDirectory属性(相对于工作区根目录),用于设置拖入文件的上传目标目录;默认仍是工作区根目录。daemon 上传路由现在会通过新的WorkspaceFileSystem.mkdir原语自动创建缺失的目标目录(递归创建,组件数上限 64),因此配置好的上传目录无需手动预先创建。为什么需要
禁用文件上传的宿主希望拖入完全失效;通过 inline 通道泄漏附件 tag 与该预期矛盾。启用上传的宿主则需要一种方式把拖入的文件路由到指定文件夹(例如
uploads/目录)而无需预先创建,这正是新属性加上自动建目录所提供的。Reviewer 测试计划
如何验证
cd packages/cli && npx vitest run src/serve/fs/workspace-file-system.test.ts src/serve/routes/workspace-file-write.test.ts以及cd packages/web-shell && npx vitest run client/components/ChatEditor.test.tsx client/hooks/useComposerCore.dom.test.tsx client/App.test.tsx。fileUploadEnabled={false}:把文件(以及单独的图片)拖到合成器上——不应出现拖拽高亮、overlay、tag 或上传条;Ctrl/Cmd+V 粘贴图片仍可附加。fileUploadDirectory="uploads"(目录不存在):拖一个文件进合成器——文件上传到uploads/,目录被自动创建,合成器插入@uploads/<name>引用。parse_error被拒绝。Evidence (Before & After)
N/A —— 行为已由上述单元测试覆盖(拖入门控、目录贯通、自动创建、深度上限)。
测试环境
运行环境(可选)
npm run dev:daemon配合独立 web-shell 页面,端到端验证拖入禁用与目录上传。风险与范围
docs/design/web-shell-file-upload.md中说明。fileUploadDirectory);前导斜杠的目录值会被有意拒绝(视为工作区外)。关联 Issues
无。