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
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)" + )