Skip to content

Commit 954b5d0

Browse files
dd3xpclaude
andcommitted
feat(desktop): robust attachment placeholders — atomic delete + input reconcile + send cleanup
Composer attachment placeholders ([Image #N]/[File #N]) are plain editable text with no sync to pendingFiles, so deleting/breaking one silently dropped the file (chip lingered) or sent broken text to the agent. Hardened with three pieces: - #2 atomic delete: Backspace/Delete adjacent to a placeholder removes the whole token + its file at once (a <textarea> can't make a substring non-editable, so this delivers the same UX without a contenteditable rewrite). - #1 input reconcile: on every edit, drop any pending file whose valid placeholder is gone/broken — removes the chip and DELETEs the uploaded file on the bridge. - #3 send cleanup: expandFilePlaceholders strips dangling placeholders (no backing file) instead of leaving them, so no garbage reaches the agent. - Refactor: extract removePendingFile() shared by the chip × button and the above. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0f556cd commit 954b5d0

1 file changed

Lines changed: 60 additions & 17 deletions

File tree

  • frontends/desktop/static

frontends/desktop/static/app.js

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1653,10 +1653,14 @@ sendBtn.addEventListener('click', (e) => {
16531653
if (sess && rt(sess).busy) { cancelPrompt(); return; } // 运行中:发送键是录制键 → 纯停止
16541654
submitInput();
16551655
});
1656-
inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); submitInput(); } });
1656+
inputEl.addEventListener('keydown', (e) => {
1657+
if (atomicPlaceholderDelete(e)) return; // #2 紧邻占位符时整块删除
1658+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); submitInput(); }
1659+
});
16571660
inputEl.addEventListener('input', () => {
16581661
inputEl.style.height = 'auto';
16591662
inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + 'px';
1663+
reconcilePendingFiles(); // #1 占位符被删/改坏 → 同步清理附件
16601664
});
16611665
function showSystem(text) {
16621666
const sess = activeSess(); if (!sess) return;
@@ -2067,7 +2071,7 @@ function removePlaceholderFromComposer(file) {
20672071
function expandFilePlaceholders(text) {
20682072
return text.replace(/\[(Image|File) #(\d+)\]/g, (m, kind, n) => {
20692073
const f = state.pendingFiles.find(x => x.sid === Number(n));
2070-
return (f && f.path) ? f.path : m;
2074+
return (f && f.path) ? f.path : ''; // #3 悬空占位符(无对应文件)→ 删掉,不把垃圾发给 agent
20712075
});
20722076
}
20732077

@@ -2081,6 +2085,59 @@ function collectUsedFiles(text) {
20812085
return used;
20822086
}
20832087

2088+
// ── 附件占位符健壮性 ─────────────────────────────────────────────
2089+
// 统一移除一个待发附件:出列 + (可选)抹占位符 + 重绘 + 删 bridge 上的文件
2090+
function removePendingFile(sid, { stripPlaceholder = false } = {}) {
2091+
const idx = state.pendingFiles.findIndex(f => f.sid === sid);
2092+
if (idx < 0) return;
2093+
const removed = state.pendingFiles.splice(idx, 1)[0];
2094+
if (stripPlaceholder) removePlaceholderFromComposer(removed);
2095+
renderThumbStrip();
2096+
if (removed.path) {
2097+
fetch(`http://${location.hostname}:14168/upload`, {
2098+
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
2099+
body: JSON.stringify({ path: removed.path }),
2100+
}).catch(() => {});
2101+
}
2102+
}
2103+
// 文本里现存的"完整有效"占位符的 sid 集合
2104+
function placeholderSidsInText(text) {
2105+
const ids = new Set(); const re = /\[(?:Image|File) #(\d+)\]/g; let m;
2106+
while ((m = re.exec(text)) !== null) ids.add(Number(m[1]));
2107+
return ids;
2108+
}
2109+
// #1 对账:占位符被删掉或改坏 → 同步移除对应附件(缩略图 + 磁盘文件)
2110+
function reconcilePendingFiles() {
2111+
if (!state.pendingFiles.length) return;
2112+
const present = placeholderSidsInText(inputEl.value);
2113+
for (const f of state.pendingFiles.filter(x => !present.has(x.sid))) {
2114+
removePendingFile(f.sid, { stripPlaceholder: false });
2115+
}
2116+
}
2117+
// #2 原子删除:光标紧邻占位符时 Backspace/Delete 整块删(连同文件),杜绝"删一半"
2118+
function atomicPlaceholderDelete(e) {
2119+
if (e.key !== 'Backspace' && e.key !== 'Delete') return false;
2120+
if (inputEl.selectionStart !== inputEl.selectionEnd) return false; // 有选区交给默认行为 + 对账兜底
2121+
const pos = inputEl.selectionStart;
2122+
const val = inputEl.value;
2123+
const re = /\[(?:Image|File) #(\d+)\]/g; let m;
2124+
while ((m = re.exec(val)) !== null) {
2125+
const start = m.index, end = start + m[0].length, sid = Number(m[1]);
2126+
const hit = e.key === 'Backspace' ? end === pos : start === pos;
2127+
if (!hit) continue;
2128+
e.preventDefault();
2129+
let s = start, eOut = end; // 顺带吃掉紧邻的一个空格,避免残留双空格
2130+
if (e.key === 'Backspace' && val[s - 1] === ' ') s -= 1;
2131+
else if (e.key === 'Delete' && val[eOut] === ' ') eOut += 1;
2132+
inputEl.value = val.slice(0, s) + val.slice(eOut);
2133+
inputEl.setSelectionRange(s, s);
2134+
removePendingFile(sid, { stripPlaceholder: false });
2135+
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
2136+
return true;
2137+
}
2138+
return false;
2139+
}
2140+
20842141
async function uploadOne(name, dataUrl, sid) {
20852142
const res = await fetch(`http://${location.hostname}:14168/upload`, {
20862143
method: 'POST',
@@ -2153,21 +2210,7 @@ if (imgInput) imgInput.addEventListener('change', () => {
21532210
if (thumbStrip) thumbStrip.addEventListener('click', (e) => {
21542211
const x = e.target.closest('.x');
21552212
if (x) {
2156-
const sid = Number(x.dataset.sid);
2157-
const idx = state.pendingFiles.findIndex(f => f.sid === sid);
2158-
if (idx >= 0) {
2159-
const removed = state.pendingFiles[idx];
2160-
state.pendingFiles.splice(idx, 1);
2161-
removePlaceholderFromComposer(removed);
2162-
renderThumbStrip();
2163-
if (removed.path) {
2164-
fetch(`http://${location.hostname}:14168/upload`, {
2165-
method: 'DELETE',
2166-
headers: { 'Content-Type': 'application/json' },
2167-
body: JSON.stringify({ path: removed.path }),
2168-
}).catch(() => {});
2169-
}
2170-
}
2213+
removePendingFile(Number(x.dataset.sid), { stripPlaceholder: true });
21712214
return;
21722215
}
21732216
const fileChip = e.target.closest('.file-chip.pending');

0 commit comments

Comments
 (0)