Release v0.51.244 — Release HL (stage-q16) (#3509)

## Release v0.51.244 — Release HL (stage-q16)

UX-approved direction via Telegram (workspace drag-drop polish you requested). All 4 drag-drop flows verified live in-browser.

### Added
| PR | Author | Feature |
|----|--------|---------|
| #3402 / #3424 | @pamnard | **Drop OS files/folders onto a specific workspace folder row or breadcrumb** to upload into that directory (not just the current dir). OS folder drops are traversed (`webkitGetAsEntry`/`readEntries`) preserving nested structure. Uploads via the existing `/api/workspace/upload` (no new backend). |

### Fixed
- **Composer drop-zone jank**: dragging a workspace file (or OS file) over the composer footer rendered a translucent overlay that let the textarea/chips/icons bleed through and collide with the hint text. Now a clean, fully-opaque box with a single centered **context-aware** label — *"Drop to insert workspace reference"* (workspace file → `@path` insert) vs *"Drop files to attach"* (OS file → message attach).
- **Drag-drop handler coexistence (CORE, caught in review)**: #3424's OS-upload binding assigned `el.ondrop` on folder rows, which **overwrote** the drag-to-move handler from #3422 (also `el.ondrop`) — silently breaking move-to-folder (the ws-path drop fell through to the composer as an `@path` insert). Fixed by binding the OS-upload handlers via `addEventListener` so they compose; each handler gates on its own drag type.

### Drag-drop matrix — all verified LIVE in-browser (real drag→drop, asserted on disk)
| Flow | Result |
|------|--------|
| OS image → composer footer | ✓ attaches |
| workspace file → composer footer | ✓ inserts `@path` |
| workspace file → workspace folder | ✓ moves on disk (report.md → docs/) |
| OS file → workspace folder | ✓ uploads into target folder |

### Scope note
#3424's PR branch carried the OLD pre-hardening `_handle_file_move`. Applied **frontend-only** — master's hardened move backend (v0.51.243, TOCTOU/symlink fixes) is untouched (Codex confirmed no `api/routes.py` diff).

### Gate
- Full pytest suite: **7542 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): CORE handler-clobber → fixed → **SAFE TO SHIP**

Co-authored-by: pamnard <pamnard@users.noreply.github.com>
This commit is contained in:
nesquena-hermesandpamnard authored and GitHub committed 2026-06-03 18:19:09 -07:00
1 parent a7f60689a8
commit c8f8ca3428
8 files changed
+329 -9

No files matched your search

+8
View File
@@ -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
+1 -1
View File
@@ -546,7 +546,7 @@
<div class="cmd-dropdown" id="cmdDropdown"></div>
<div class="drop-hint" id="dropHint">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Drop files to upload to workspace
<span id="dropHintText">Drop files to attach</span>
</div>
<div class="attach-tray" id="attachTray"></div>
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> Listening…</div>
+12 -1
View File
@@ -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');
+4 -2
View File
@@ -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;}
+4
View File
@@ -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();
+120 -5
View File
@@ -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') {
@@ -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",
]
@@ -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)"
)