Stage 383: PR #2530

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
nesquena-hermes
2026-05-18 16:44:36 +00:00
3 changed files with 95 additions and 4 deletions

View File

@@ -8,6 +8,7 @@
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
### Fixed
- Board selector menu labels no longer overlap board titles when board metadata uses a longer text label in the icon/color slot.
@@ -42,6 +43,11 @@
- **PR #2528** (closes #2518) — Guard New Conversation creation while a previous `/api/session/new` request is still in flight, so cold model/provider catalog resolution gives immediate pending feedback and rapid repeated clicks reuse the same create request instead of enqueueing duplicate blank sessions.
>>>>>>> pr-2528
=======
### Fixed
- Keep Kanban drag/drop status updates from also opening the task detail pane via a trailing card click or the generic task-update helper (refs #2529).
>>>>>>> pr-2530
## [v0.51.89] — 2026-05-18 — Release BM (stage-382 — 6-PR full sweep batch — runtime adapter approval/clarify seam + SOUL.md memory panel + #1855 resolve_model_provider fast-path + PWA sidebar spinner fix + /model active-provider preference + contributor contract docs index)

View File

@@ -13,6 +13,7 @@ let _kanbanCurrentBoard = null;
let _kanbanBoardsList = null;
let _kanbanBoardMenuOpen = false;
let _kanbanIsDispatching = false;
let _kanbanSuppressCardClickUntil = 0;
// SSE event stream — replaces the 30s polling cadence with a long-lived
// /api/kanban/events/stream connection. Falls back to polling when the
// EventSource fails to connect (proxy that strips text/event-stream, etc).
@@ -1264,12 +1265,33 @@ async function quickKanbanCardAction(event, taskId, status){
return updateKanbanTask(taskId, {status});
}
function _kanbanSuppressNextCardClick(){
_kanbanSuppressCardClickUntil = Date.now() + 700;
}
function dragKanbanTask(event, taskId){
_kanbanSuppressNextCardClick();
if (!event.dataTransfer) return;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', taskId);
}
function finishKanbanDrag(event){
if (event) _kanbanSuppressNextCardClick();
}
function openKanbanCard(event, taskId){
if (Date.now() < _kanbanSuppressCardClickUntil) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
return false;
}
loadKanbanTask(taskId);
return false;
}
function allowKanbanDrop(event){
// Don't accept drops into the 'running' column. Entering 'running' is owned
// by the dispatcher/claim_task path (sets claim_lock + claim_expires +
@@ -1290,10 +1312,13 @@ function clearKanbanDrop(event){
}
async function dropKanbanTask(event, status){
_kanbanSuppressNextCardClick();
event.preventDefault();
event.stopPropagation();
clearKanbanDrop(event);
const taskId = event.dataTransfer ? event.dataTransfer.getData('text/plain') : '';
if (taskId && status) await updateKanbanTask(taskId, {status});
if (taskId && status) await updateKanbanTask(taskId, {status}, {openDetail: false});
_kanbanSuppressNextCardClick();
}
function _kanbanLaneNames(columns){
@@ -1356,7 +1381,7 @@ function _kanbanCard(task, status){
const stale = _kanbanCardStalenessClass(task);
const body = _kanbanTaskBody(task);
const assignee = task.assignee ? `<span class="kanban-card-assignee">@${esc(task.assignee)}</span>` : `<span class="kanban-card-unassigned">${esc(t('kanban_unassigned'))}</span>`;
return `<article class="kanban-card ${esc(stale)}" data-kanban-task-id="${esc(task.id)}" draggable="true" ondragstart="dragKanbanTask(event, '${esc(task.id)}')" onclick="loadKanbanTask('${esc(task.id)}')" tabindex="0" role="button" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();loadKanbanTask('${esc(task.id)}')}">
return `<article class="kanban-card ${esc(stale)}" data-kanban-task-id="${esc(task.id)}" draggable="true" ondragstart="dragKanbanTask(event, '${esc(task.id)}')" ondragend="finishKanbanDrag(event)" onclick="return openKanbanCard(event, '${esc(task.id)}')" tabindex="0" role="button" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();loadKanbanTask('${esc(task.id)}')}">
<div class="kanban-card-topline"><span class="kanban-card-id">${esc(task.id || '')}</span>${priority ? `<span class="kanban-badge priority">P${priority}</span>` : ''}${task.tenant ? `<span class="kanban-badge tenant">${esc(task.tenant)}</span>` : ''}</div>
<div class="kanban-card-title">${esc(_kanbanTaskTitle(task))}</div>
${body ? `<div class="kanban-card-body">${_kanbanRenderMarkdown(body)}</div>` : ''}
@@ -2269,15 +2294,16 @@ async function submitKanbanTaskModal(){
}
}
async function updateKanbanTask(taskId, patch){
async function updateKanbanTask(taskId, patch, opts){
if (!taskId || !patch) return;
try {
const openDetail = !opts || opts.openDetail !== false;
const updated = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + _kanbanBoardQuery(), {
method: 'PATCH',
body: JSON.stringify(patch),
});
await loadKanban(true);
await loadKanbanTask((updated && updated.task && updated.task.id) || taskId);
if (openDetail) await loadKanbanTask((updated && updated.task && updated.task.id) || taskId);
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
}

View File

@@ -692,6 +692,65 @@ def test_kanban_ui_parity_polish_adds_card_metadata_quick_actions_and_swimlanes(
assert "javascript:" not in PANELS.lower()
def test_kanban_dragging_card_does_not_open_detail_on_drop_click():
"""Regression: drag/drop should move a card without opening task detail."""
assert "function _kanbanSuppressNextCardClick" in PANELS
assert "let _kanbanSuppressCardClickUntil" in PANELS
assert "function openKanbanCard" in PANELS
assert "function finishKanbanDrag" in PANELS
drag_fn = re.search(r"function dragKanbanTask\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
assert drag_fn, "dragKanbanTask() not found"
assert "_kanbanSuppressNextCardClick" in drag_fn.group(1), (
"drag start must arm the click suppressor so the trailing click after "
"drop cannot open the task detail pane"
)
finish_fn = re.search(r"function finishKanbanDrag\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
assert finish_fn, "finishKanbanDrag() not found"
assert "_kanbanSuppressNextCardClick" in finish_fn.group(1), (
"drag end must refresh the suppressor window before browsers emit a "
"trailing synthetic click"
)
drop_fn = re.search(r"async function dropKanbanTask\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
assert drop_fn, "dropKanbanTask() not found"
drop_body = drop_fn.group(1)
assert "_kanbanSuppressNextCardClick" in drop_body
assert "event.stopPropagation()" in drop_body
assert "updateKanbanTask(taskId, {status}, {openDetail: false})" in drop_body, (
"drag/drop status updates must refresh the board without opening the task detail"
)
update_fn = re.search(r"async function updateKanbanTask\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
assert update_fn, "updateKanbanTask() not found"
update_body = update_fn.group(1)
assert "const openDetail = !opts || opts.openDetail !== false;" in update_body
assert "if (openDetail) await loadKanbanTask" in update_body
card_template = re.search(r"return `<article class=\"kanban-card.*?</article>`;", PANELS, re.DOTALL)
assert card_template, "Kanban card template not found"
card_html = card_template.group(0)
assert "ondragend=\"finishKanbanDrag(event)\"" in card_html
assert "onclick=\"return openKanbanCard(event," in card_html
assert "onclick=\"loadKanbanTask" not in card_html, (
"Kanban cards must not call loadKanbanTask directly from onclick; "
"drag/drop needs a guarded click path"
)
open_fn = re.search(r"function openKanbanCard\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
assert open_fn, "openKanbanCard() not found"
open_body = open_fn.group(1)
for token in (
"Date.now()",
"_kanbanSuppressCardClickUntil",
"preventDefault",
"stopPropagation",
"loadKanbanTask",
):
assert token in open_body
def test_kanban_lifecycle_controls_do_not_offer_manual_running_start():
assert "quickKanbanCardAction(event,'${id}','running')" not in PANELS
assert "kanban_card_start" not in PANELS