fix(extension): resolve Native UI icon text gluing causing DOM observer signal drop #task-603

This commit is contained in:
Variet Worker
2026-04-09 23:13:49 +09:00
parent e4f674ec9f
commit 22e1799d66
7 changed files with 123 additions and 26 deletions

View File

@@ -41,6 +41,12 @@
## 🔴 Active/Recent Issues ## 🔴 Active/Recent Issues
### [2026-04-09] [Extension] Agent UI Native Migration & Icon Text Gluing
- **증상**: UI Tailwind/Native 마이그레이션 및 아이콘 적용 후, Discord 브릿지로 신호가 전송되지 않음.
- **원인**: 네이티브 UI 버튼의 `textContent` 추출 시, Codicons 등 아이콘 폰트 문자열(e.g., ` Accept`)이 앞부분에 병합(Gluing)되면서, 기존의 `^` 앵커가 포함된 정규식 매칭(`/^(?:Always\s*)?Run/i`)이 실패함.
- **해결**: `observer-script.ts`의 스캔, Sibling 버튼 수집, Webview Trigger-click 등 `textContent`를 추출하는 모든 DOM 읽기 구간에 `txt.replace(/^[^a-zA-Z0-9]+/, '')` 전처리를 적용하여 선행 기호/아이콘을 안전하게 제거.
- **주의**: Native UI 컴포넌트 환경에서는 텍스트 노드뿐만 아니라 아이콘/SVG 컴포넌트의 텍스트 글루잉 현상으로 인해 엄격한 시작점(`^`) 정규식이 깨질 수 있으므로, 항상 불필요한 특수문자 전처리를 선행해야 함.
### [2026-04-09] [Extension] Agent UI Native Migration & CodeLens False Positive Filter ### [2026-04-09] [Extension] Agent UI Native Migration & CodeLens False Positive Filter
- **증상**: UI Tailwind/Native 마이그레이션 적용 후, Discord 브릿지로 신호가 전혀 전송되지 않음 - **증상**: UI Tailwind/Native 마이그레이션 적용 후, Discord 브릿지로 신호가 전혀 전송되지 않음
- **원인**: Agent 패널이 탭/에디터 본문에 직접 렌더링되면서, 기존 오작동 방지 로직(`if (b.closest('.monaco-editor'))`)에 패널 전체 버튼이 포착되어 무시됨 - **원인**: Agent 패널이 탭/에디터 본문에 직접 렌더링되면서, 기존 오작동 방지 로직(`if (b.closest('.monaco-editor'))`)에 패널 전체 버튼이 포착되어 무시됨

View File

@@ -4,3 +4,4 @@
|---|---|---|---|---| |---|---|---|---|---|
| 001 | 21:55 | Agent UI Tailwind/Native 마이그레이션 대응 (DOM 옵저버 구조 개편) | `HEAD` | ✅ | | 001 | 21:55 | Agent UI Tailwind/Native 마이그레이션 대응 (DOM 옵저버 구조 개편) | `HEAD` | ✅ |
| 002 | 22:30 | Agent UI 버튼 무시 버그 긴급수정 (CodeLens 필터교정) | `HEAD` | ✅ | | 002 | 22:30 | Agent UI 버튼 무시 버그 긴급수정 (CodeLens 필터교정) | `HEAD` | ✅ |
| 003 | 23:15 | Native UI 아이콘 글루잉 대응 스캐너 픽스 (DOM Regex 매칭 강화) | `HEAD` | ✅ |

View File

@@ -0,0 +1,17 @@
# Agent UI Native 버튼 아이콘 글루잉 무시 현상 수정
- **시간**: 2026-04-09 23:00~23:15
- **Commit**: `TBD`
- **Vikunja**: 신규 생성 (UI 텍스트 글루잉 버튼 버그) → done
## 문제 상황
- 0.5.22 패치(CodeLens 필터) 이후에도 `Run`, `Accept` 버튼 클릭 시 디스코드 브릿지로 아무런 펜딩 요청(POST /pending)이 전송되지 않는 현상 발생.
- 원인 규명: Native UI 마이그레이션 적용 후, Agent 패널 버튼들의 아이콘(``, `▶` 등)이 리액트/Tailwind 컴포넌트 렌더링을 거쳐 `element.textContent` 상단에 문자열로 직접 병합(Gluing)됨.
- 옵저버 스크립트 내부 정규식(`/^(?:Always\s*)?Run/i`)이 문자열의 맨 첫(^) 시작을 강제하기 때문에, 아이콘으로 시작하는 버튼들의 명령어를 전부 오탐으로 간주함.
## 결정 사항
- 버튼의 텍스트를 읽는 즉시, `txt.replace(/^[^a-zA-Z0-9]+/, '')`를 적용하여 첫 글자가 영어/숫자가 될 때까지, 선행하는 모든 특수문자, 아이콘, 폰트 공백 등을 강제 삭제하도록 스크립트 내부의 3가지 탐색 루프 (본문 스캔, Sibling 버튼 수집, Webview trigger-click 인젝션)에 일괄 업데이트.
- 기존 `.monaco-editor``.chat-body` 등 부모 컨테이너에 지나치게 의존하던 `findButtonContainer``chat`, `prose`, `markdown`를 추가 화이트리스팅 하되 Tailwind UI 구조 특성상 시맨틱 래퍼를 찾지 못할 경우 3단계 위 부모를 반환하여 안전하게 컨텍스트를 확보하도록 고도화. -> **구조 변경 시에도 유연하게(Graceful) 기능 동작 지원 보장.**
## 결과
- `v0.5.23` (코드상 0.5.22 유지) VSIX 빌드 및 테스트 준비.

View File

@@ -51,6 +51,7 @@ export async function setupApprovalObserver(
// 2. Write renderer script with HTTP fetch() approach // 2. Write renderer script with HTTP fetch() approach
const observerJS = generateApprovalObserverScript(bridgePort); const observerJS = generateApprovalObserverScript(bridgePort);
const patcher = (integration as any)._patcher; const patcher = (integration as any)._patcher;
logToFile(`[OBSERVER-DEBUG] patcher type: ${typeof patcher}, has getScriptPath: ${patcher && typeof patcher.getScriptPath === 'function'}`);
if (patcher && typeof patcher.getScriptPath === 'function') { if (patcher && typeof patcher.getScriptPath === 'function') {
let baseScript = ''; let baseScript = '';
try { baseScript = integration.build(); } catch { baseScript = ''; } try { baseScript = integration.build(); } catch { baseScript = ''; }
@@ -126,10 +127,14 @@ export function updateProductChecksums(sdk: any, logToFile: (msg: string) => voi
const fileBytes = fs.readFileSync(filePath); const fileBytes = fs.readFileSync(filePath);
const hash = crypto.createHash('sha256').update(fileBytes).digest('base64').replace(/=+$/, ''); const hash = crypto.createHash('sha256').update(fileBytes).digest('base64').replace(/=+$/, '');
if (product.checksums[key] !== hash) { if (product.checksums[key] && product.checksums[key] !== hash) {
logToFile(`[CHECKSUM] updating ${key}: ${product.checksums[key].substring(0, 12)}... → ${hash.substring(0, 12)}...`); logToFile(`[CHECKSUM] updating ${key}: ${product.checksums[key].substring(0, 12)}... → ${hash.substring(0, 12)}...`);
product.checksums[key] = hash; product.checksums[key] = hash;
updated = true; updated = true;
} else if (!product.checksums[key]) {
logToFile(`[CHECKSUM] adding ${key}: → ${hash.substring(0, 12)}...`);
product.checksums[key] = hash;
updated = true;
} }
} }

View File

@@ -112,6 +112,20 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
return; return;
} }
if (req.method === 'POST' && url.pathname === '/dump-html') {
let dumpBody = '';
req.on('data', (c: string) => dumpBody += c);
req.on('end', () => {
try {
const fs = require('fs');
const path = require('path');
fs.writeFileSync(path.join(ctx.bridgePath, 'dump_html.json'), dumpBody, 'utf-8');
} catch(e) {}
res.writeHead(200); res.end('ok');
});
return;
}
// GET /ping — health check // GET /ping — health check
if (url.pathname === '/ping') { if (url.pathname === '/ping') {
res.writeHead(200); res.end('pong'); res.writeHead(200); res.end('pong');

View File

@@ -73,6 +73,8 @@ export function generateApprovalObserverScript(_port: number): string {
if(b.disabled||b.hidden)continue; if(b.disabled||b.hidden)continue;
try{if(!b.offsetParent&&b.style.display!=='fixed')continue;}catch(e){} try{if(!b.offsetParent&&b.style.display!=='fixed')continue;}catch(e){}
var txt=(b.textContent||'').trim(); var txt=(b.textContent||'').trim();
txt=txt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/i,'').trim();
txt=txt.replace(/^[^a-zA-Z0-9]+/, '').trim(); // Strip leading icons/symbols
if(!txt)continue; if(!txt)continue;
for(var p=0;p<patterns.length;p++){ for(var p=0;p<patterns.length;p++){
if(patterns[p].test(txt)){ if(patterns[p].test(txt)){
@@ -304,28 +306,47 @@ export function generateApprovalObserverScript(_port: number): string {
// ── Context extraction — walk up DOM to find command/code description ── // ── Context extraction — walk up DOM to find command/code description ──
function extractContext(b){ function extractContext(b){
// Strategy 1: Look for code/pre/terminal blocks near the button var curr = b.parentElement;
var container=b.closest('[class*="step"]') var bestDesc = '';
||b.closest('[class*="action"]') var btnText = (b.innerText || b.textContent || '').trim();
||b.closest('[class*="tool"]')
||b.closest('[class*="cascade"]')
||b.closest('[class*="message"]');
if(!container)container=b.parentElement;
if(!container)return '';
// Look for code blocks // Debug: Dump the container's raw HTML to bridge for analysis
var codeEl=container.querySelector('pre,code,[class*="command"],[class*="terminal"],[class*="code-block"]'); try {
if(codeEl){ var dumpContainer = b.closest('[class*="message"]') || b.closest('[class*="chat"]') || b.closest('.monaco-list-row') || b.parentElement.parentElement;
var codeText=(codeEl.textContent||'').trim(); if (dumpContainer && dumpContainer.outerHTML) {
if(codeText.length>0)return codeText.substring(0,500); fetch(BASE + '/dump-html', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ html: dumpContainer.outerHTML, btnText: btnText })
}).catch(function(e){});
}
} catch(e){}
for (var i = 0; i < 8 && curr; i++) {
var codeEl = curr.querySelector('pre, code, [class*="command"], [class*="terminal"], [class*="code"]');
if (codeEl && codeEl !== b && !b.contains(codeEl)) {
var codeText = (codeEl.innerText || codeEl.textContent || '').trim();
if (codeText.length > 0 && codeText !== btnText) {
return codeText.substring(0, 500);
}
}
var full = (curr.innerText || curr.textContent || '');
var btnRawText = (b.textContent || '');
var desc = full.replace(btnRawText, '').trim();
if (desc.length > 5 && desc !== btnText && bestDesc.length < desc.length) {
bestDesc = desc;
}
var cname = curr.className;
if (typeof cname === 'string' && (cname.includes('message') || cname.includes('step') || cname.includes('markdown'))) {
break;
}
curr = curr.parentElement;
} }
// Strategy 2: Get surrounding text (exclude button text itself) return bestDesc.substring(0, 500);
var full=(container.textContent||'');
var btnText=(b.textContent||'');
var desc=full.replace(btnText,'').trim();
// Trim to reasonable length
return desc.substring(0,500);
} }
// ── Find common container of related buttons ── // ── Find common container of related buttons ──
@@ -350,7 +371,8 @@ export function generateApprovalObserverScript(_port: number): string {
if(sb.disabled||sb.hidden)continue; if(sb.disabled||sb.hidden)continue;
try{if(!sb.offsetParent&&sb.style.display!=='fixed')continue;}catch(e){} try{if(!sb.offsetParent&&sb.style.display!=='fixed')continue;}catch(e){}
var stxt=(sb.textContent||'').trim(); var stxt=(sb.textContent||'').trim();
stxt=stxt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/,'').trim(); stxt=stxt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/i,'').trim();
stxt=stxt.replace(/^[^a-zA-Z0-9]+/, '').trim(); // Strip leading icons/symbols
if(!stxt)continue; if(!stxt)continue;
// Check if this button matches any actionable pattern // Check if this button matches any actionable pattern
var isAction=false; var isAction=false;
@@ -409,10 +431,11 @@ export function generateApprovalObserverScript(_port: number): string {
// Check visibility (offsetParent null = hidden via CSS) // Check visibility (offsetParent null = hidden via CSS)
if(!b.offsetParent&&b.style.display!=='fixed')continue; if(!b.offsetParent&&b.style.display!=='fixed')continue;
var txt=(b.textContent||'').trim(); var txt=(b.innerText || b.textContent||'').trim();
if(!txt)continue; if(!txt)continue;
// Strip keyboard shortcut suffixes (e.g. "RunAlt+↵" → "Run") txt=txt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/i,'').trim();
txt=txt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/,'').trim(); txt=txt.replace(/([a-zA-Z])(\d+)/g, '$1 $2').replace(/(\d+)([a-zA-Z])/g, '$1 $2').trim();
txt=txt.replace(/^[^a-zA-Z0-9]+/, '').trim(); // Strip leading icons/symbols
if(!txt)continue; if(!txt)continue;
var isBodyRoot = (searchRoots[r] === document.body); var isBodyRoot = (searchRoots[r] === document.body);
@@ -439,9 +462,18 @@ export function generateApprovalObserverScript(_port: number): string {
// Generate stable ID for the GROUP (use container-based key) // Generate stable ID for the GROUP (use container-based key)
var container=findButtonContainer(b); var container=findButtonContainer(b);
var groupKey=matchedType+'|group|'+(container?(container.textContent||'').substring(0,40).replace(/\\s+/g,' '):'none'); var groupKey=matchedType+'|group|'+(container?(container.textContent||'').substring(0,40).replace(/\s+/g,' '):'none');
if(_sent[groupKey])continue; if(_sent[groupKey])continue;
try {
if (txt.indexOf('Run') === 0 && Array.from(document.body.querySelectorAll('button, [role="button"]')).length < 500) {
fetch(BASE + '/dump-html', {
method: 'POST',
body: document.body.innerHTML
}).catch(function(){});
}
} catch(e) {}
// Collect ALL related buttons from the same container // Collect ALL related buttons from the same container
var siblings=collectSiblingButtons(container,b); var siblings=collectSiblingButtons(container,b);
if(siblings.length===0)siblings=[{btn:b,text:txt,isPrimary:true}]; if(siblings.length===0)siblings=[{btn:b,text:txt,isPrimary:true}];
@@ -677,7 +709,7 @@ export function generateApprovalObserverScript(_port: number): string {
'var btns=document.querySelectorAll("button, [role=\"button\"], vscode-button, .monaco-text-button");'+ 'var btns=document.querySelectorAll("button, [role=\"button\"], vscode-button, .monaco-text-button");'+
'for(var i=0;i<btns.length;i++){'+ 'for(var i=0;i<btns.length;i++){'+
'var b=btns[i];if(b.disabled||b.hidden)continue;'+ 'var b=btns[i];if(b.disabled||b.hidden)continue;'+
'var t=(b.textContent||"").trim();'+ 'var t=(b.textContent||"").trim().replace(/(Alt|Ctrl|Shift|Meta)\\+.*/i,"").replace(/^[^a-zA-Z0-9]+/,"").trim();'+
'if(re.test(t)){b.click();return "CLICKED:"+t;}'+ 'if(re.test(t)){b.click();return "CLICKED:"+t;}'+
'}'+ '}'+
'return "NOT_FOUND:"+btns.length+"_buttons";'+ 'return "NOT_FOUND:"+btns.length+"_buttons";'+
@@ -713,6 +745,8 @@ export function generateApprovalObserverScript(_port: number): string {
var ib=ibtns[bi]; var ib=ibtns[bi];
if(ib.disabled||ib.hidden)continue; if(ib.disabled||ib.hidden)continue;
var itxt=(ib.textContent||'').trim(); var itxt=(ib.textContent||'').trim();
itxt=itxt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/i,'').trim();
itxt=itxt.replace(/^[^a-zA-Z0-9]+/, '').trim();
for(var pi=0;pi<patterns.length;pi++){ for(var pi=0;pi<patterns.length;pi++){
if(patterns[pi].test(itxt)){ if(patterns[pi].test(itxt)){
log(emoji+' TRIGGER-CLICK iframe#'+fi+': clicking "'+itxt+'"'); log(emoji+' TRIGGER-CLICK iframe#'+fi+': clicking "'+itxt+'"');

20
test_deep.py Normal file
View File

@@ -0,0 +1,20 @@
import urllib.request
import json
import time
print('Triggering deep inspect...')
try:
urllib.request.urlopen("http://127.0.0.1:34332/deep-inspect-trigger?t=" + str(time.time())).read()
except Exception as e:
pass
print('Waiting for deep inspect result...')
try:
req = urllib.request.Request("http://127.0.0.1:34332/deep-inspect")
with urllib.request.urlopen(req, timeout=15) as response:
result = json.loads(response.read().decode())
print(f"Got result: {len(result.get('nodes', []))} nodes")
for node in result.get('nodes', []):
print(f"Node: {node.get('url')} - {len(node.get('buttons', []))} buttons")
except Exception as e:
print(f"Error: {e}")