fix(extension): resolve Native UI icon text gluing causing DOM observer signal drop #task-603
This commit is contained in:
@@ -41,6 +41,12 @@
|
||||
|
||||
## 🔴 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
|
||||
- **증상**: UI Tailwind/Native 마이그레이션 적용 후, Discord 브릿지로 신호가 전혀 전송되지 않음
|
||||
- **원인**: Agent 패널이 탭/에디터 본문에 직접 렌더링되면서, 기존 오작동 방지 로직(`if (b.closest('.monaco-editor'))`)에 패널 전체 버튼이 포착되어 무시됨
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
|---|---|---|---|---|
|
||||
| 001 | 21:55 | Agent UI Tailwind/Native 마이그레이션 대응 (DOM 옵저버 구조 개편) | `HEAD` | ✅ |
|
||||
| 002 | 22:30 | Agent UI 버튼 무시 버그 긴급수정 (CodeLens 필터교정) | `HEAD` | ✅ |
|
||||
| 003 | 23:15 | Native UI 아이콘 글루잉 대응 스캐너 픽스 (DOM Regex 매칭 강화) | `HEAD` | ✅ |
|
||||
|
||||
17
docs/devlog/entries/20260409-003.md
Normal file
17
docs/devlog/entries/20260409-003.md
Normal 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 빌드 및 테스트 준비.
|
||||
@@ -51,6 +51,7 @@ export async function setupApprovalObserver(
|
||||
// 2. Write renderer script with HTTP fetch() approach
|
||||
const observerJS = generateApprovalObserverScript(bridgePort);
|
||||
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') {
|
||||
let 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 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)}...`);
|
||||
product.checksums[key] = hash;
|
||||
updated = true;
|
||||
} else if (!product.checksums[key]) {
|
||||
logToFile(`[CHECKSUM] adding ${key}: → ${hash.substring(0, 12)}...`);
|
||||
product.checksums[key] = hash;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,20 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
|
||||
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
|
||||
if (url.pathname === '/ping') {
|
||||
res.writeHead(200); res.end('pong');
|
||||
|
||||
@@ -73,6 +73,8 @@ export function generateApprovalObserverScript(_port: number): string {
|
||||
if(b.disabled||b.hidden)continue;
|
||||
try{if(!b.offsetParent&&b.style.display!=='fixed')continue;}catch(e){}
|
||||
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;
|
||||
for(var p=0;p<patterns.length;p++){
|
||||
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 ──
|
||||
function extractContext(b){
|
||||
// Strategy 1: Look for code/pre/terminal blocks near the button
|
||||
var container=b.closest('[class*="step"]')
|
||||
||b.closest('[class*="action"]')
|
||||
||b.closest('[class*="tool"]')
|
||||
||b.closest('[class*="cascade"]')
|
||||
||b.closest('[class*="message"]');
|
||||
if(!container)container=b.parentElement;
|
||||
if(!container)return '';
|
||||
var curr = b.parentElement;
|
||||
var bestDesc = '';
|
||||
var btnText = (b.innerText || b.textContent || '').trim();
|
||||
|
||||
// Look for code blocks
|
||||
var codeEl=container.querySelector('pre,code,[class*="command"],[class*="terminal"],[class*="code-block"]');
|
||||
if(codeEl){
|
||||
var codeText=(codeEl.textContent||'').trim();
|
||||
if(codeText.length>0)return codeText.substring(0,500);
|
||||
// Debug: Dump the container's raw HTML to bridge for analysis
|
||||
try {
|
||||
var dumpContainer = b.closest('[class*="message"]') || b.closest('[class*="chat"]') || b.closest('.monaco-list-row') || b.parentElement.parentElement;
|
||||
if (dumpContainer && dumpContainer.outerHTML) {
|
||||
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)
|
||||
var full=(container.textContent||'');
|
||||
var btnText=(b.textContent||'');
|
||||
var desc=full.replace(btnText,'').trim();
|
||||
// Trim to reasonable length
|
||||
return desc.substring(0,500);
|
||||
return bestDesc.substring(0, 500);
|
||||
}
|
||||
|
||||
// ── Find common container of related buttons ──
|
||||
@@ -350,7 +371,8 @@ export function generateApprovalObserverScript(_port: number): string {
|
||||
if(sb.disabled||sb.hidden)continue;
|
||||
try{if(!sb.offsetParent&&sb.style.display!=='fixed')continue;}catch(e){}
|
||||
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;
|
||||
// Check if this button matches any actionable pattern
|
||||
var isAction=false;
|
||||
@@ -409,10 +431,11 @@ export function generateApprovalObserverScript(_port: number): string {
|
||||
// Check visibility (offsetParent null = hidden via CSS)
|
||||
if(!b.offsetParent&&b.style.display!=='fixed')continue;
|
||||
|
||||
var txt=(b.textContent||'').trim();
|
||||
var txt=(b.innerText || b.textContent||'').trim();
|
||||
if(!txt)continue;
|
||||
// Strip keyboard shortcut suffixes (e.g. "RunAlt+↵" → "Run")
|
||||
txt=txt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/,'').trim();
|
||||
txt=txt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/i,'').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;
|
||||
|
||||
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)
|
||||
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;
|
||||
|
||||
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
|
||||
var siblings=collectSiblingButtons(container,b);
|
||||
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");'+
|
||||
'for(var i=0;i<btns.length;i++){'+
|
||||
'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;}'+
|
||||
'}'+
|
||||
'return "NOT_FOUND:"+btns.length+"_buttons";'+
|
||||
@@ -713,6 +745,8 @@ export function generateApprovalObserverScript(_port: number): string {
|
||||
var ib=ibtns[bi];
|
||||
if(ib.disabled||ib.hidden)continue;
|
||||
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++){
|
||||
if(patterns[pi].test(itxt)){
|
||||
log(emoji+' TRIGGER-CLICK iframe#'+fi+': clicking "'+itxt+'"');
|
||||
|
||||
20
test_deep.py
Normal file
20
test_deep.py
Normal 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}")
|
||||
Reference in New Issue
Block a user