diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1cbc5185..a8e047df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,14 @@
## [Unreleased]
+## [v0.51.244] — 2026-06-03 — Release HL (stage-q16 — workspace OS-import drop + composer drop-zone polish)
+
+### Added
+- **Drop OS files/folders onto a specific workspace folder row or breadcrumb segment** to upload into that directory (not only the current directory). OS folder drops are traversed via `webkitGetAsEntry`/`readEntries` and their nested structure is preserved on upload. Composer `@path` drags (#1097), the internal tree-move (#3402), and OS-drop isolation (#3411) are all preserved. (#3402, #3424, @pamnard)
+
+### Fixed
+- The composer drop-zone overlay no longer looks garbled when you drag a workspace file (or OS file) over the footer. Previously the translucent overlay let the textarea, attach/mic icons, and model/profile chips bleed through and collide with the hint text. The overlay is now a clean, fully-opaque box with a single centered, context-aware label — **"Drop to insert workspace reference"** when dragging a workspace file (which inserts an `@path` reference) vs **"Drop files to attach"** for an OS file (which attaches it to the message).
+
## [v0.51.243] — 2026-06-03 — Release HK (stage-q15 — drag-to-move files within the workspace)
### Added
diff --git a/static/index.html b/static/index.html
index 10588bf2..e964b8de 100644
--- a/static/index.html
+++ b/static/index.html
@@ -546,7 +546,7 @@
- Drop files to upload to workspace
+ Drop files to attach
Listening…
diff --git a/static/panels.js b/static/panels.js
index 320a2772..3d787b94 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -5594,7 +5594,18 @@ async function loadMemory(force) {
// Drag and drop
const wrap=$('composerWrap');let dragCounter=0;
document.addEventListener('dragover',e=>e.preventDefault());
-document.addEventListener('dragenter',e=>{e.preventDefault();if(e.dataTransfer.types.includes('Files')||e.dataTransfer.types.includes('application/ws-path')){dragCounter++;wrap.classList.add('drag-over');}});
+document.addEventListener('dragenter',e=>{e.preventDefault();
+ const isWsPath=e.dataTransfer.types.includes('application/ws-path');
+ const isFiles=e.dataTransfer.types.includes('Files');
+ if(isFiles||isWsPath){
+ dragCounter++;
+ // Context-aware hint: a workspace-file drag inserts an @path reference;
+ // an OS-file drag attaches the file to the message.
+ const hint=$('dropHintText');
+ if(hint) hint.textContent=isWsPath?'Drop to insert workspace reference':'Drop files to attach';
+ wrap.classList.add('drag-over');
+ }
+});
document.addEventListener('dragleave',e=>{dragCounter--;if(dragCounter<=0){dragCounter=0;wrap.classList.remove('drag-over');}});
document.addEventListener('drop',e=>{
e.preventDefault();dragCounter=0;wrap.classList.remove('drag-over');
diff --git a/static/style.css b/static/style.css
index ee65221e..fa64ac90 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1763,9 +1763,10 @@
.composer-wrap{padding:12px 20px 16px;background:var(--bg);flex-shrink:0;}
.composer-box{max-width:clamp(780px,60vw,1100px);margin:0 auto;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;z-index:2;}
.composer-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);}
- .composer-wrap.drag-over .composer-box{border-color:var(--accent-text);background:var(--accent-bg);}
- .drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:var(--accent-bg);border:2px dashed var(--accent);border-radius:14px;font-size:14px;color:var(--accent-text);pointer-events:none;z-index:10;flex-direction:column;gap:8px;}
+ .composer-wrap.drag-over .composer-box{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);}
+ .drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;gap:10px;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:2px dashed var(--accent);border-radius:16px;font-size:13.5px;font-weight:600;color:var(--accent-text);pointer-events:none;z-index:30;flex-direction:column;text-align:center;padding:8px;}
.composer-wrap.drag-over .drop-hint{display:flex;}
+ .drop-hint svg{opacity:.85;}
.attach-tray{display:none;flex-wrap:wrap;gap:6px;padding:10px 14px 0;}
.attach-tray.has-files{display:flex;}
.attach-chip{display:flex;align-items:center;gap:5px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:8px;padding:4px 10px;font-size:11px;font-weight:500;color:var(--accent-text);}
@@ -2095,6 +2096,7 @@
.file-item.active{background:var(--accent-bg);color:var(--accent-text);}
.file-item.dragging{opacity:.35;border:1px dashed var(--accent-bg-strong);}
.file-item.drag-over,.breadcrumb-seg.drag-over{background:var(--accent-bg);color:var(--accent-text);outline:1px solid var(--accent-bg-strong);outline-offset:-1px;}
+ .file-item.drag-over-upload,.breadcrumb-seg.drag-over-upload{background:var(--blue,#3b82f6);color:#fff;outline:1px dashed rgba(255,255,255,.5);outline-offset:-1px;}
.file-tree-toggle{font-size:10px;color:var(--muted);flex-shrink:0;width:var(--file-tree-toggle-width);text-align:center;line-height:1;}
.file-tree-toggle-placeholder{display:inline-block;flex:0 0 var(--file-tree-toggle-width);width:var(--file-tree-toggle-width);line-height:1;}
.file-item.file-empty{color:var(--muted);opacity:.5;font-style:italic;cursor:default;font-size:11px;}
diff --git a/static/ui.js b/static/ui.js
index 5d059ce7..87c7fd53 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -8537,6 +8537,7 @@ function renderBreadcrumb(){
root.textContent='~';
root.onclick=()=>loadDir('.');
_bindWorkspaceMoveDropTarget(root,'.');
+ _bindWorkspaceOsUploadDropTarget(root,'.');
bar.appendChild(root);
// Path segments
const parts=S.currentDir.split('/');
@@ -8553,6 +8554,7 @@ function renderBreadcrumb(){
const target=accumulated;
seg.onclick=()=>loadDir(target);
_bindWorkspaceMoveDropTarget(seg,target);
+ _bindWorkspaceOsUploadDropTarget(seg,target);
} else {
seg.className='breadcrumb-seg breadcrumb-current';
}
@@ -8936,6 +8938,7 @@ function _renderTreeItems(container, entries, depth){
const el=document.createElement('div');el.className='file-item';
el.style.paddingLeft=(8+depth*16)+'px';
el.setAttribute('draggable','true');
+ el.dataset.wsType=item.type;
el.oncontextmenu=(e)=>{e.preventDefault();e.stopPropagation();_showFileContextMenu(e,item);};
el.ondragstart=(e)=>{e.dataTransfer.setData('application/ws-path',item.path);e.dataTransfer.setData('application/ws-type',item.type);e.dataTransfer.effectAllowed='copy';el.classList.add('dragging');};
el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();};
@@ -9054,6 +9057,7 @@ function _renderTreeItems(container, entries, depth){
if(item.type==='dir'){
_bindWorkspaceMoveDropTarget(el,item.path);
+ _bindWorkspaceOsUploadDropTarget(el,item.path);
// Single-click toggles expand/collapse
el.onclick=async(e)=>{
e.stopPropagation();
diff --git a/static/workspace.js b/static/workspace.js
index db4f6f38..c8c7fde0 100644
--- a/static/workspace.js
+++ b/static/workspace.js
@@ -860,6 +860,121 @@ async function uploadToWorkspace(file, dir) {
}
}
+function _isOsFilesDrag(e) {
+ return !!(e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files'));
+}
+
+function _joinWorkspacePath(base, rel) {
+ const b = base || '.';
+ const r = (rel || '').replace(/^\/+|\/+$/g, '');
+ if (!r) return b;
+ return b === '.' ? r : `${b}/${r}`;
+}
+
+function _targetDirForRelDir(destDir, relDir) {
+ const dirPart = (relDir || '').replace(/\/+$/, '');
+ if (!dirPart) return destDir || '.';
+ return _joinWorkspacePath(destDir, dirPart);
+}
+
+async function _readAllDirectoryEntries(reader) {
+ const entries = [];
+ while (true) {
+ const batch = await new Promise((resolve, reject) => {
+ reader.readEntries(resolve, reject);
+ });
+ if (!batch.length) break;
+ entries.push(...batch);
+ }
+ return entries;
+}
+
+async function _collectFilesFromEntry(entry, relPrefix) {
+ if (entry.isFile) {
+ const file = await new Promise((resolve, reject) => {
+ entry.file(resolve, reject);
+ });
+ return [{ file, relDir: relPrefix || '' }];
+ }
+ if (!entry.isDirectory) return [];
+ const reader = entry.createReader();
+ const children = await _readAllDirectoryEntries(reader);
+ const dirPrefix = `${relPrefix || ''}${entry.name}/`;
+ let out = [];
+ for (const child of children) {
+ out = out.concat(await _collectFilesFromEntry(child, dirPrefix));
+ }
+ return out;
+}
+
+async function _collectOsDropUploads(dataTransfer) {
+ const out = [];
+ const items = dataTransfer.items ? [...dataTransfer.items] : [];
+ if (items.length && typeof items[0].webkitGetAsEntry === 'function') {
+ for (const item of items) {
+ if (item.kind !== 'file') continue;
+ const entry = item.webkitGetAsEntry();
+ if (!entry) continue;
+ out.push(...await _collectFilesFromEntry(entry, ''));
+ }
+ if (out.length) return out;
+ }
+ for (const file of dataTransfer.files) {
+ out.push({ file, relDir: '' });
+ }
+ return out;
+}
+
+async function uploadOsDropToWorkspace(dataTransfer, destDir) {
+ if (!S.session || !dataTransfer) return;
+ const uploads = await _collectOsDropUploads(dataTransfer);
+ for (const { file, relDir } of uploads) {
+ await uploadToWorkspace(file, _targetDirForRelDir(destDir, relDir));
+ }
+ if (S.session) await loadDir(S.currentDir);
+}
+
+function _clearWorkspaceOsUploadDragOver() {
+ document.querySelectorAll('.file-item.drag-over-upload,.breadcrumb-seg.drag-over-upload').forEach((el) => {
+ el.classList.remove('drag-over-upload');
+ });
+}
+
+function _bindWorkspaceOsUploadDropTarget(el, destDir) {
+ // Use addEventListener (not on-property assignment) so these OS-upload
+ // handlers COMPOSE with the workspace tree-MOVE handlers bound by
+ // _bindWorkspaceMoveDropTarget() on the same element. A property assignment
+ // for the drop handler here would overwrite the move handler, and a
+ // workspace-file drag would fall through to the document drop (inserting
+ // @path into the composer) instead of moving the file. Each handler gates on
+ // its own drag type (_isOsFilesDrag vs _isWorkspaceTreeMoveDrag), so only the
+ // matching one acts.
+ el.addEventListener('dragenter', (e) => {
+ if (!_isOsFilesDrag(e)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ el.classList.add('drag-over-upload');
+ });
+ el.addEventListener('dragover', (e) => {
+ if (!_isOsFilesDrag(e)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ e.dataTransfer.dropEffect = 'copy';
+ el.classList.add('drag-over-upload');
+ });
+ el.addEventListener('dragleave', (e) => {
+ if (el.contains(e.relatedTarget)) return;
+ el.classList.remove('drag-over-upload');
+ });
+ el.addEventListener('drop', async (e) => {
+ if (!_isOsFilesDrag(e)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ el.classList.remove('drag-over-upload');
+ await uploadOsDropToWorkspace(e.dataTransfer, destDir);
+ });
+}
+
// Drag-and-drop files onto workspace file tree
if (typeof document !== 'undefined') {
const _wsUploadInit = () => {
@@ -875,22 +990,22 @@ if (typeof document !== 'undefined') {
if (e.dataTransfer && e.dataTransfer.types && e.dataTransfer.types.includes('Files')) {
e.preventDefault();
e.stopPropagation();
+ if (e.target.closest('.file-item[data-ws-type="dir"],.breadcrumb-seg')) return;
e.dataTransfer.dropEffect = 'copy';
tree.classList.add('drag-over-upload');
}
});
- tree.addEventListener('dragleave', () => {
+ tree.addEventListener('dragleave', (e) => {
+ if (tree.contains(e.relatedTarget)) return;
tree.classList.remove('drag-over-upload');
});
tree.addEventListener('drop', async (e) => {
tree.classList.remove('drag-over-upload');
if (!e.dataTransfer || !e.dataTransfer.types || !e.dataTransfer.types.includes('Files')) return;
+ if (e.target.closest('.file-item[data-ws-type="dir"],.breadcrumb-seg')) return;
e.preventDefault();
e.stopPropagation();
- for (const file of e.dataTransfer.files) {
- await uploadToWorkspace(file, S.currentDir || '.');
- }
- if (S.session) loadDir(S.currentDir);
+ await uploadOsDropToWorkspace(e.dataTransfer, S.currentDir || '.');
});
};
if (document.readyState === 'loading') {
diff --git a/tests/test_issue3402_workspace_os_import.py b/tests/test_issue3402_workspace_os_import.py
new file mode 100644
index 00000000..d39a7f0e
--- /dev/null
+++ b/tests/test_issue3402_workspace_os_import.py
@@ -0,0 +1,83 @@
+"""Tests for #3402 part B — OS file/folder import into workspace tree targets."""
+import json
+import shutil
+import subprocess
+
+
+def _src(name: str) -> str:
+ with open(f"static/{name}") as f:
+ return f.read()
+
+
+WORKSPACE_JS = _src("workspace.js")
+UI_JS = _src("ui.js")
+
+
+class TestIssue3402WorkspaceOsImportUi:
+ def test_folder_rows_bind_os_upload_drop(self):
+ assert "_bindWorkspaceOsUploadDropTarget(el,item.path)" in UI_JS
+
+ def test_breadcrumb_binds_os_upload_drop(self):
+ assert "_bindWorkspaceOsUploadDropTarget(root,'.')" in UI_JS
+ assert "_bindWorkspaceOsUploadDropTarget(seg,target)" in UI_JS
+
+ def test_os_upload_helpers_exist(self):
+ assert "function uploadOsDropToWorkspace" in WORKSPACE_JS
+ assert "function _collectOsDropUploads" in WORKSPACE_JS
+ assert "webkitGetAsEntry" in WORKSPACE_JS
+
+ def test_os_folder_drop_stops_propagation(self):
+ block = WORKSPACE_JS[
+ WORKSPACE_JS.index("function _bindWorkspaceOsUploadDropTarget"):
+ WORKSPACE_JS.index("// Drag-and-drop files onto workspace file tree")
+ ]
+ assert block.count("e.stopPropagation()") >= 3
+
+ def test_tree_drop_skips_folder_rows(self):
+ assert 'closest(\'.file-item[data-ws-type="dir"]' in WORKSPACE_JS
+
+ def test_file_items_expose_ws_type_dataset(self):
+ assert "el.dataset.wsType=item.type" in UI_JS
+
+ def test_os_upload_highlight_css(self):
+ css = open("static/style.css", encoding="utf-8").read()
+ assert ".file-item.drag-over-upload" in css
+ assert ".breadcrumb-seg.drag-over-upload" in css
+
+
+def test_join_workspace_path_node():
+ node = shutil.which("node")
+ if not node:
+ return
+ js = r"""
+const { joinWorkspacePath, targetDirForRelDir } = (() => {
+ function joinWorkspacePath(base, rel) {
+ const b = base || '.';
+ const r = (rel || '').replace(/^\/+|\/+$/g, '');
+ if (!r) return b;
+ return b === '.' ? r : `${b}/${r}`;
+ }
+ function targetDirForRelDir(destDir, relDir) {
+ const dirPart = (relDir || '').replace(/\/+$/, '');
+ if (!dirPart) return destDir || '.';
+ return joinWorkspacePath(destDir, dirPart);
+ }
+ return { joinWorkspacePath, targetDirForRelDir };
+})();
+
+const cases = [
+ [joinWorkspacePath('.', ''), '.'],
+ [joinWorkspacePath('docs', ''), 'docs'],
+ [joinWorkspacePath('.', 'docs/reports'), 'docs/reports'],
+ [joinWorkspacePath('src', 'lib/utils'), 'src/lib/utils'],
+ [targetDirForRelDir('projects', ''), 'projects'],
+ [targetDirForRelDir('projects', 'bundle/'), 'projects/bundle'],
+ [targetDirForRelDir('.', 'bundle/sub/'), 'bundle/sub'],
+];
+console.log(JSON.stringify(cases.map(([a,b]) => b)));
+"""
+ out = subprocess.check_output([node, "-e", js], text=True).strip()
+ assert json.loads(out) == [
+ ".", "docs", "docs/reports", "src/lib/utils",
+ "projects", "projects/bundle", "bundle/sub",
+ ]
diff --git a/tests/test_issue3424_composer_drop_hint_polish.py b/tests/test_issue3424_composer_drop_hint_polish.py
new file mode 100644
index 00000000..0d0d6651
--- /dev/null
+++ b/tests/test_issue3424_composer_drop_hint_polish.py
@@ -0,0 +1,97 @@
+"""Regression: composer drop-zone overlay is clean + context-aware (#3424 polish).
+
+The composer drop hint used to be a translucent overlay with hardcoded text
+("Drop files to upload to workspace") — when you dragged a workspace file over
+the footer, the composer's own controls (textarea, chips, icons) bled through
+and collided with the hint text, looking garbled. The fix:
+ 1. The overlay background is fully opaque (stacked input-bg over --bg), so
+ nothing behind it shows through.
+ 2. The hint text is context-aware: a workspace-file drag (application/ws-path,
+ which inserts an @path reference) says "insert workspace reference"; an
+ OS-file drag (which attaches the file) says "attach".
+
+Source-contract assertions (the project has no JS DOM-test runtime).
+"""
+
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
+PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
+INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
+
+
+def test_drop_hint_overlay_is_opaque():
+ """The .drop-hint overlay must use an opaque background (stacked over --bg)
+ so the composer controls behind it cannot bleed through. A bare translucent
+ token like `background:var(--input-bg)` (which is ~0.04 alpha in dark theme)
+ is the regression we're guarding against."""
+ import re
+ m = re.search(r"\.drop-hint\{[^}]*\}", STYLE_CSS)
+ assert m, ".drop-hint rule not found"
+ rule = m.group(0)
+ assert "linear-gradient(var(--input-bg),var(--input-bg)),var(--bg)" in rule, (
+ ".drop-hint must use an opaque stacked background so composer controls "
+ "don't bleed through during a drag; got: " + rule
+ )
+
+
+def test_drop_hint_has_dedicated_text_span():
+ """The hint text lives in its own #dropHintText span so the handler can
+ swap it per drag type."""
+ assert 'id="dropHintText"' in INDEX_HTML, (
+ "index.html must give the drop-hint label its own #dropHintText span"
+ )
+ # The stale hardcoded copy must be gone.
+ assert "Drop files to upload to workspace" not in INDEX_HTML, (
+ "the old hardcoded drop-hint text should be replaced by the dynamic span"
+ )
+
+
+def test_drop_hint_text_is_context_aware():
+ """The composer dragenter handler sets the hint text based on drag type:
+ workspace-file (ws-path) -> insert reference; OS file -> attach."""
+ # Find the dragenter handler block.
+ idx = PANELS_JS.find("addEventListener('dragenter'")
+ assert idx != -1, "composer dragenter handler not found"
+ block = PANELS_JS[idx:idx + 700]
+ assert "application/ws-path" in block, "handler must distinguish the ws-path drag"
+ assert "dropHintText" in block, "handler must update the #dropHintText label"
+ assert "workspace reference" in block, "ws-path drag hint must mention inserting a reference"
+ assert "attach" in block, "OS-file drag hint must mention attaching"
+
+
+def test_os_upload_binder_composes_not_overwrites_move_binder():
+ """Regression (Codex CORE catch): _bindWorkspaceOsUploadDropTarget must use
+ addEventListener, NOT el.on*= property assignment. The move-drop binder
+ (_bindWorkspaceMoveDropTarget) and the OS-upload binder run on the SAME
+ folder-row / breadcrumb element; if the OS-upload binder assigned el.ondrop
+ it would clobber the move handler and a workspace-file drag would fall
+ through to the composer (@path insert) instead of moving the file."""
+ WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
+ start = WORKSPACE_JS.find("function _bindWorkspaceOsUploadDropTarget(")
+ assert start != -1, "_bindWorkspaceOsUploadDropTarget not found"
+ # Bound the slice to the end of THIS function: scan brace depth from the
+ # opening '{' so we don't spill into the following top-level code.
+ open_brace = WORKSPACE_JS.find("{", start)
+ depth = 0
+ end = open_brace
+ for i in range(open_brace, len(WORKSPACE_JS)):
+ c = WORKSPACE_JS[i]
+ if c == "{":
+ depth += 1
+ elif c == "}":
+ depth -= 1
+ if depth == 0:
+ end = i + 1
+ break
+ body = WORKSPACE_JS[start:end]
+ # Must register via addEventListener for drop (composes with move handler)...
+ assert "addEventListener('drop'" in body, (
+ "OS-upload binder must use addEventListener('drop', ...) so it composes "
+ "with the move-drop handler instead of overwriting el.ondrop"
+ )
+ # ...and must NOT use the clobbering property-assignment form.
+ assert "el.ondrop" not in body and "el.ondragover" not in body, (
+ "OS-upload binder must not assign el.on* (it would overwrite the move binder)"
+ )