Code editor UI: per-file streaming bubbles, options UI, terminal error button, model card

- Per-file streaming: text shows while AI works, collapses on tool call, ✅완료 at end
- Options UI: numbered choice buttons with number/keyword input selection
- Number input in chat (1~6) selects corresponding option chip
- Terminal error 🤖 button permanently visible in header bar
- Model card moved to tabs bar top-right, synced with panel width
- Removed 코드 저장 디렉토리 from settings; merged save/folder into sidebar
- Removed language selector from chat header
- Bottom cards anchored to sidebar bottom
- Simplified planInstruction prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-08 23:14:00 +09:00
co-authored by Claude Sonnet 4.6
parent ac518bc2a5
commit 4ead0750b9
3 changed files with 128 additions and 118 deletions
+2 -2
View File
@@ -11241,7 +11241,7 @@ app.post('/api/chat', async (req, res) => {
// Record whether this code-editor turn uses the browser's local folder. The
// server then skips disk file-tool execution (client is source of truth).
if (/^(code_ai_|proj-gen-|proj-plan-)/.test(String(sessionId || ''))) {
if (/^code_ai_/.test(String(sessionId || ''))) {
if (codeAiLocal) codeAiLocalSessions.add(String(sessionId));
else codeAiLocalSessions.delete(String(sessionId));
}
@@ -11370,7 +11370,7 @@ app.post('/api/chat', async (req, res) => {
const isCodeEditorSession = /^(code_ai_|proj-plan-|proj-gen-)/.test(String(sessionId));
const histOverride = (isCodeEditorSession && Array.isArray(clientHistory)) ? clientHistory : undefined;
// Code editor chat runs on its own clean tool loop — no multi-agent orchestration.
const isCodeAiChat = /^(code_ai_|proj-gen-|proj-plan-)/.test(String(sessionId));
const isCodeAiChat = /^code_ai_/.test(String(sessionId));
// Client sends useTools:false for plan/ask phase — honour it so the model doesn't
// loop on list_files when it should just generate a text response.
const codeAiUseTools = useTools !== false;
+12 -36
View File
@@ -210,6 +210,7 @@
<span id="csb-folder-label" style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em">폴더 없음</span>
<div style="display:flex;gap:4px">
<button id="code-local-btn" onclick="openLocalFolder()" title="로컬 폴더 열기" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 3px">🖥️</button>
<button onclick="codeSaveFile()" title="파일 저장 (Ctrl+S)" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:13px;line-height:1;padding:0 3px">💾</button>
<button onclick="codeNewFile()" title="새 파일" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:16px;line-height:1;padding:0 3px">+</button>
<button onclick="csbNewFolderRoot()" title="새 폴더" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:13px;line-height:1;padding:0 3px">📁</button>
<button onclick="codeSbRefresh()" title="새로고침" style="background:none;border:none;cursor:pointer;color:var(--muted);font-size:13px;line-height:1;padding:0 3px">↺</button>
@@ -229,18 +230,9 @@
</div>
<!-- 하단: 모델 설정 패널 -->
<div style="flex-shrink:0;height:46%;min-height:160px;display:flex;flex-direction:column;overflow:hidden">
<div style="flex-shrink:0;display:flex;flex-direction:column;overflow:hidden;border-top:1px solid var(--line)">
<!-- 모델 선택 -->
<div style="display:flex;flex-direction:column;flex:1;overflow-y:auto;padding:10px 12px;gap:8px">
<div class="csb-model-card">
<div class="csb-model-label">
<span class="csb-model-dot"></span>
🤖 Coder AI 모델
</div>
<div class="csb-model-select-wrap">
<select id="csb-model-select" onchange="csbModelChanged()"></select>
</div>
</div>
<div id="csb-session-project-row" class="csb-project-card" style="display:none">
<div class="csb-project-label">📁 프로젝트</div>
<div class="csb-project-select-wrap">
@@ -284,15 +276,6 @@
</div>
<div style="font-size:10px;color:var(--muted);margin-top:4px">새 터미널 탭 열면 이 경로로 자동 이동</div>
</div>
<div>
<div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">코드 저장 디렉토리</div>
<div style="display:flex;align-items:center;gap:6px">
<input type="text" id="csb-codedir" value="code" onchange="csbCodeDirChanged(this.value)"
style="flex:1;font-size:12px;padding:6px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;font-family:'IBM Plex Mono',monospace">
<button onclick="csbCodeDirSave()" title="저장" style="background:var(--brand);color:#fff;border:none;border-radius:8px;padding:6px 10px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap">저장</button>
</div>
<div style="font-size:10px;color:var(--muted);margin-top:4px">워크스페이스 하위 폴더 (기본: code)</div>
</div>
<div>
<div style="font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">폰트 크기</div>
<div style="display:flex;align-items:center;gap:8px">
@@ -338,14 +321,15 @@
<div id="code-view" class="active">
<!-- File tabs bar -->
<div class="code-tabs-bar">
<div id="code-file-tabs" style="display:flex;align-items:stretch"></div>
<button class="code-new-tab-btn" onclick="codeNewFile()" title="새 파일">+</button>
<div class="code-toolbar">
<button id="code-folder-btn" class="code-tool-btn btn-folder" onclick="toggleCodeFolderBrowser(event)" title="파일 탐색기 열기"
style="gap:4px;display:flex;align-items:center">
📁 <span>workspace/code/</span> ▾
</button>
<button class="code-tool-btn btn-save" onclick="codeSaveFile()" title="파일 저장 (Ctrl+S)">💾 저장</button>
<div id="code-file-tabs" style="display:flex;align-items:stretch;flex:1"></div>
<div id="code-ai-model-card" style="display:flex;align-items:center;gap:7px;padding:3px 10px 3px 12px;border-left:1px solid #1a4a35;flex-shrink:0;align-self:stretch">
<span style="font-size:16px;line-height:1">🤖</span>
<div style="display:flex;flex-direction:column;gap:1px">
<span style="font-size:9px;font-weight:700;color:#22c55e;text-transform:uppercase;letter-spacing:.07em;line-height:1">Coder AI</span>
<div class="csb-model-select-wrap" style="min-width:120px">
<select id="csb-model-select" onchange="csbModelChanged()" style="font-size:12px;font-weight:700"></select>
</div>
</div>
</div>
</div>
<!-- Diff review bar (full-width, above editor) -->
@@ -360,15 +344,6 @@
<div id="code-editor-container"></div>
<div class="code-vresize" id="code-vresize"></div>
<div id="code-ai-panel">
<div class="code-ai-header">
<span id="code-ai-title">💻 Coder AI</span>
<div class="code-ai-tech-selector" id="code-ai-tech-selector">
<button class="code-ai-tech-btn active" data-tech="auto" onclick="setCodeAiTech('auto')">🤖 자동</button>
<button class="code-ai-tech-btn" data-tech="html" onclick="setCodeAiTech('html')">🌐 HTML</button>
<button class="code-ai-tech-btn" data-tech="python" onclick="setCodeAiTech('python')">🐍 Python</button>
<button class="code-ai-tech-btn" data-tech="node" onclick="setCodeAiTech('node')">🟢 Node</button>
</div>
</div>
<div id="code-ai-messages">
<div class="code-ai-msg thinking">코드를 선택하고 "AI에게 전송"을 누르거나, 아래에 질문을 입력하세요.</div>
</div>
@@ -407,6 +382,7 @@
<div id="code-term-tabs" style="display:flex;align-items:stretch;height:100%;flex:1"></div>
<button onclick="addLocalShell()" id="local-shell-btn" title="로컬(companion) 탭 추가" style="background:none;border:none;color:#22c55e;cursor:pointer;font-size:16px;padding:0 8px;line-height:1;flex-shrink:0">+</button>
<button onclick="addCodeTermShell()" title="서버 터미널 탭 추가" style="background:none;border:none;color:#6b7280;cursor:pointer;font-size:11px;padding:0 6px;line-height:1;flex-shrink:0;font-family:'IBM Plex Mono',monospace">☁️</button>
<button id="code-term-ai-error-btn" onclick="_sendActiveTermErrorToAI()" title="터미널 출력을 AI에게 전송" style="background:#1a3a28;color:#22c55e;border:1px solid #2a5a3a;border-radius:4px;padding:1px 7px;font-size:10px;font-weight:700;cursor:pointer;flex-shrink:0;margin-right:2px">🤖</button>
<button onclick="codeTermKill()" title="실행 중단 (Ctrl+C)" style="background:none;border:none;color:#f87171;cursor:pointer;font-size:13px;padding:0 6px;line-height:1;flex-shrink:0">⏹</button>
<button onclick="toggleCodeTerminal()" title="닫기" style="background:none;border:none;color:#3d6b4f;cursor:pointer;font-size:14px;padding:0 8px;line-height:1;flex-shrink:0">&times;</button>
</div>
+114 -80
View File
@@ -105,8 +105,6 @@ function codeDetectLang(name) {
function updateCodeAiLabel() {
const label = codeAiModel || 'AI';
const title = document.getElementById('code-ai-title');
if (title) title.textContent = '💻 Coder-' + label;
const input = document.getElementById('code-ai-input');
if (input) input.placeholder = label + '에게 질문... (예: 게임 만들어줘)';
}
@@ -114,15 +112,11 @@ function updateCodeAiLabel() {
function setCodeAiTech(tech) {
codeAiTech = tech;
localStorage.setItem('codeAiTech', tech);
document.querySelectorAll('.code-ai-tech-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tech === tech);
});
updateCodeAiLabel();
}
function initCodeView() {
updateCodeAiLabel();
setCodeAiTech(codeAiTech); // Initialize tech selector UI
setCodeAiTech(codeAiTech);
if (monacoReady) { setTimeout(() => { if (monacoEditor) monacoEditor.layout(); }, 50); return; }
if (typeof require === 'undefined') return;
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' } });
@@ -489,18 +483,8 @@ function _termDetectAndShowError(shellId) {
}
function _showTerminalErrorButton(shellId, errorText) {
// Remove old button if present
const old = document.getElementById('code-term-ai-error-btn');
if (old) old.remove();
const footer = document.querySelector('#code-terminal-panel .term-footer, #code-terminal-panel');
if (!footer) return;
const btn = document.createElement('button');
btn.id = 'code-term-ai-error-btn';
btn.textContent = '🤖 에러 AI에게 전송';
btn.style.cssText = 'position:absolute;bottom:4px;right:90px;z-index:10;background:#dc2626;color:#fff;border:none;border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer;font-weight:600;animation:pulse-red 1.5s infinite';
btn.onclick = () => { _sendTerminalErrorToAI(errorText); btn.remove(); };
footer.style.position = 'relative';
footer.appendChild(btn);
const btn = document.getElementById('code-term-ai-error-btn');
if (btn) { btn.style.background = '#dc2626'; btn.style.color = '#fff'; btn.dataset.shellId = shellId; }
}
function _sendTerminalErrorToAI(errorText) {
@@ -514,12 +498,21 @@ function _sendTerminalErrorToAI(errorText) {
codeAiSend();
}
function _sendActiveTermErrorToAI() {
const btn = document.getElementById('code-term-ai-error-btn');
const shellId = btn?.dataset.shellId || _lastErrorShellId;
const buf = (shellId && _termBuffers[shellId]) || Object.values(_termBuffers).at(-1);
const text = buf ? buf.slice(-40).join('\n') : _lastErrorText;
if (text) _sendTerminalErrorToAI(text);
if (btn) { btn.style.background = '#1a3a28'; btn.style.color = '#22c55e'; }
}
// Called when a run completes successfully (exit code 0) — clear error button
function _termOnRunSuccess(shellId) {
_lastErrorShellId = null;
_lastErrorText = '';
const btn = document.getElementById('code-term-ai-error-btn');
if (btn) btn.remove();
if (btn) btn.style.display = 'none';
}
// ── Companion WebSocket (local Python execution) ───────────────────────────
@@ -614,8 +607,6 @@ function _companionCdProject(shellId) {
}, 800);
}
function csbCodeDirChanged(val) {}
function csbCodeDirSave() {}
function csbLoadCodeDir() {}
async function codeSaveFile() {
@@ -2219,9 +2210,23 @@ async function codeAiSend() {
if (codeAiAbort) codeAiAbort.abort();
return;
}
// Don't consume the click if there's nothing to send.
const inp = document.getElementById('code-ai-input');
if (!inp || !inp.value.trim()) return;
// 승인 대기 중인 계획 결정 카드가 있으면 입력을 가로채서 버튼 클릭으로 처리
const pendingCard = document.querySelector('.code-ai-msg.system button:not([disabled])');
if (pendingCard) {
const txt = inp.value.trim().toLowerCase();
const isYes = /^(응|예|ㅇ|ok|yes|실행|승인|go|ㄱ|ㅇㅇ)$/.test(txt);
const isNo = /^(아니|취소|no|ㄴ|cancel)$/.test(txt);
const numMatch = txt.match(/^(\d+)$/);
if (isNo) { inp.value = ''; document.querySelector('.code-ai-msg.system button[style*="#dc3545"]:not([disabled])')?.click(); return; }
if (numMatch) {
const optBtn = document.querySelector(`.code-ai-msg.system button[data-opt-num="${numMatch[1]}"]:not([disabled])`);
if (optBtn) { inp.value = ''; optBtn.click(); return; }
}
const confirmBtn = document.querySelector('.code-ai-msg.system button[style*="#28a745"]:not([disabled])');
if (isYes && confirmBtn) { inp.value = ''; confirmBtn.click(); return; }
}
_codeAiSending = true;
_setSendBtnBusy(true);
try {
@@ -2458,7 +2463,7 @@ async function _codeAiSendImpl() {
}
// ── Code 모드: Phase 1 (계획) → confirm → Phase 2 (실행) ──────────────────
const planInstruction = '\n\n[편집 계획] 지금은 계획 단계 — 도구를 실행하지 말고 무엇을 할지만 설명해. 코드를 수정/생성/이동해야 한다면: 어떤 파일을 어떻게 바꿀지 한 줄씩 나열해 (예: "move_file: game.js → js/game.js", "edit_file: index.html — script 경로 수정"). 질문이거나 설명만 필요하면: 바로 답해. 승인되면 실행 단계에서 도구로 직접 처리함.';
const planInstruction = '\n\n[계획 단계] 실행하지 말고 무엇을 어떻게 바꿀지만 설명해. 질문이면 바로 답해.';
const planThinkEl = codeAiAppend('thinking', '계획 중...');
let planText = '';
@@ -2534,11 +2539,15 @@ async function _codeAiSendImpl() {
for (const line of planText.split('\n')) {
const m = line.match(/^(\d+)[.)]\s+(.+)/);
if (!m) continue;
if (parseInt(m[1]) !== opts.length + 1) break; // must be sequential
if (parseInt(m[1]) !== opts.length + 1) break;
opts.push({ num: parseInt(m[1]), label: m[2].replace(/\*\*/g, '').trim() });
if (opts.length === 5) break;
if (opts.length === 6) break;
}
return opts.length >= 2 ? opts : [];
if (opts.length < 2) return [];
// 파일 작업 키워드가 포함된 항목이 있으면 실행 단계 목록 → 선택지 아님
const fileOpRe = /edit_file|create_file|move_file|delete_file|replace_lines|insert_after|\.[a-zA-Z]{2,4}(?:\s|$|—|:)/;
if (opts.some(o => fileOpRe.test(o.label))) return [];
return opts;
})();
// Show confirm / cancel / modify buttons and wait for user decision
@@ -2570,63 +2579,69 @@ async function _codeAiSendImpl() {
resolve(result);
};
// ── Option chips (shown when model presented numbered alternatives) ──────
let modInput = null;
if (_parsedOptions.length) {
const optLabel = document.createElement('div');
optLabel.style.cssText = 'font-size:11px;color:var(--muted);margin-bottom:4px';
optLabel.textContent = '어떤 방법으로 진행할까요?';
card.appendChild(optLabel);
if (_parsedOptions.length) {
// ── 선택지 모드 ──────────────────────────────────────────────────────────
const optLabel = document.createElement('div');
optLabel.style.cssText = 'font-size:12px;color:var(--muted);margin-bottom:6px';
optLabel.textContent = '방법을 선택하세요:';
card.appendChild(optLabel);
const optRow = document.createElement('div');
optRow.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;margin-bottom:6px';
for (const opt of _parsedOptions) {
const short = opt.label.length > 28 ? opt.label.slice(0, 28) + '…' : opt.label;
const ob = mkBtn(`${opt.num}. ${short}`, '#0d6efd', 'text-align:left;white-space:normal;max-width:220px;line-height:1.3;padding:5px 10px');
const short = opt.label.length > 32 ? opt.label.slice(0, 32) + '…' : opt.label;
const ob = mkBtn(`${opt.num}. ${short}`, '#0d6efd', 'text-align:left;white-space:normal;max-width:200px;line-height:1.4;padding:5px 10px');
ob.title = opt.label;
ob.dataset.optNum = String(opt.num);
ob.onclick = () => done({ option: opt.num, label: opt.label });
allBtns.push(ob);
optRow.appendChild(ob);
}
card.appendChild(optRow);
}
const cancelBtn = mkBtn('❌ 취소', '#dc3545');
allBtns.push(cancelBtn);
cancelBtn.onclick = () => done(false);
card.appendChild(cancelBtn);
} else {
// ── 일반 모드: 실행 / 수정 / 취소 ──────────────────────────────────────
const hint = document.createElement('div');
hint.style.cssText = 'font-size:12px;color:var(--muted);margin-bottom:6px';
hint.textContent = '위 계획대로 실행할까요?';
card.appendChild(hint);
// ── Main action row ──────────────────────────────────────────────────────
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:8px;align-items:center;flex-wrap:wrap';
const hint = _parsedOptions.length
? '<span style="font-size:12px;color:var(--muted)">또는:</span>'
: '<span style="font-size:12px;color:var(--muted)">위 계획대로 편집을 실행할까요?</span>';
btnRow.innerHTML = hint;
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display:flex;gap:8px;align-items:center;flex-wrap:wrap';
const confirmBtn = mkBtn('✅ 실행', '#28a745');
const modifyBtn = mkBtn('✏️ 수정', '#6c757d');
const cancelBtn = mkBtn('❌ 취소', '#dc3545');
allBtns.push(confirmBtn, modifyBtn, cancelBtn);
const confirmBtn = mkBtn('✅ 실행', '#28a745');
const modifyBtn = mkBtn('✏️ 수정', '#6c757d');
const cancelBtn = mkBtn('❌ 취소', '#dc3545');
allBtns.push(confirmBtn, modifyBtn, cancelBtn);
// Modify row (hidden until ✏️ clicked)
const modRow = document.createElement('div');
modRow.style.cssText = 'display:none;margin-top:6px;gap:6px;align-items:flex-start;flex-direction:column';
modInput = document.createElement('textarea');
modInput.placeholder = '수정 요청 입력 (예: 버튼 색상은 파란색으로)';
modInput.style.cssText = 'width:100%;box-sizing:border-box;padding:5px 8px;font-size:12px;border:1px solid var(--border,#444);border-radius:4px;background:var(--panel-2,#1e1e2e);color:var(--text,#ccc);resize:vertical;min-height:54px';
const replanBtn = mkBtn('🔄 다시 계획', '#0d6efd');
allBtns.push(replanBtn);
modRow.appendChild(modInput);
modRow.appendChild(replanBtn);
// Modify row (hidden until ✏️ clicked)
const modRow = document.createElement('div');
modRow.style.cssText = 'display:none;margin-top:6px;gap:6px;align-items:flex-start;flex-direction:column';
modInput = document.createElement('textarea');
modInput.placeholder = '수정 요청 입력 (예: 버튼 색상은 파란색으로)';
modInput.style.cssText = 'width:100%;box-sizing:border-box;padding:5px 8px;font-size:12px;border:1px solid var(--border,#444);border-radius:4px;background:var(--panel-2,#1e1e2e);color:var(--text,#ccc);resize:vertical;min-height:54px';
const replanBtn = mkBtn('🔄 다시 계획', '#0d6efd');
allBtns.push(replanBtn);
modRow.appendChild(modInput);
modRow.appendChild(replanBtn);
confirmBtn.onclick = () => done(true);
cancelBtn.onclick = () => done(false);
modifyBtn.onclick = () => { modRow.style.display = 'flex'; modifyBtn.style.display = 'none'; modInput.focus(); };
replanBtn.onclick = () => { const t = modInput.value.trim(); done(t ? { modify: t } : false); };
modInput.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) replanBtn.click(); });
confirmBtn.onclick = () => done(true);
cancelBtn.onclick = () => done(false);
modifyBtn.onclick = () => { modRow.style.display = 'flex'; modifyBtn.style.display = 'none'; modInput.focus(); };
replanBtn.onclick = () => { const t = modInput.value.trim(); done(t ? { modify: t } : false); };
modInput.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) replanBtn.click(); });
btnRow.appendChild(confirmBtn);
btnRow.appendChild(modifyBtn);
btnRow.appendChild(cancelBtn);
card.appendChild(btnRow);
card.appendChild(modRow);
} // end else (normal mode)
if (signal) signal.addEventListener('abort', onAbort, { once: true });
btnRow.appendChild(confirmBtn);
btnRow.appendChild(modifyBtn);
btnRow.appendChild(cancelBtn);
card.appendChild(btnRow);
card.appendChild(modRow);
msgs.appendChild(card);
codeAiScrollBottom();
});
@@ -2692,8 +2707,9 @@ async function _codeAiSendImpl() {
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '', reply = '', _preambleBuf = '', _firstToolSeen = false;
thinkEl.textContent = '';
thinkEl.className = 'code-ai-msg assistant';
let liveTextEl = null, segReply = '';
thinkEl.textContent = '⏳';
thinkEl.className = 'code-ai-msg thinking';
// Live streaming state (mirrored into the editor via codeLiveStreamUpdate)
// Pin the diff target to the file that was active when the user hit send —
// that's the file we attached as "[현재 파일: …]" context, so any returned
@@ -2717,11 +2733,17 @@ async function _codeAiSendImpl() {
if (ev.type === 'usage') { _codeAiOnUsage(ev); continue; }
if (ev.type === 'token' && (ev.content || ev.text)) token = ev.content || ev.text;
else if (ev.type === 'text_delta' && ev.delta) token = ev.delta;
else if (ev.type === 'final' && (ev.text || ev.content)) { if (!reply) reply = ev.text || ev.content; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
else if (ev.type === 'done' && ev.reply) { if (!reply) reply = ev.reply; thinkEl.textContent = reply; codeAiScrollBottom(); continue; }
else if (ev.type === 'final' && (ev.text || ev.content)) { if (!reply) reply = ev.text || ev.content; (liveTextEl || thinkEl).textContent = reply; codeAiScrollBottom(); continue; }
else if (ev.type === 'done' && ev.reply) { if (!reply) reply = ev.reply; (liveTextEl || thinkEl).textContent = reply; codeAiScrollBottom(); continue; }
// Handle tool events for live editor update + action cards
else if (ev.type === 'tool_call') {
_firstToolSeen = true;
if (!_firstToolSeen) { _firstToolSeen = true; thinkEl.style.display = 'none'; }
// Close current text bubble before showing tool card
if (liveTextEl) {
if (!segReply.trim()) liveTextEl.remove();
else { liveTextEl.classList.remove('streaming'); codeAiRenderMarkdown(liveTextEl, segReply); }
liveTextEl = null; segReply = '';
}
const toolName = ev.action || ev.name || '';
const toolArgs = ev.args || {};
liveState.toolCallCount = (liveState.toolCallCount || 0) + 1;
@@ -2749,12 +2771,11 @@ async function _codeAiSendImpl() {
}
}
if (token) {
if (!_firstToolSeen) {
// Buffer pre-tool text — likely plan repetition; suppress from UI
_preambleBuf += token;
} else {
reply += token; thinkEl.textContent = reply; codeAiScrollBottom();
try { codeLiveStreamUpdate(reply, liveState); } catch(e) { console.error('[codeLiveStream] error:', e); thinkEl.textContent += '\n⚠️ 에디터 스트리밍 오류: ' + (e.message || e); }
reply += token; segReply += token;
if (!liveTextEl) { liveTextEl = codeAiAppend('assistant', ''); liveTextEl.classList.add('streaming'); }
liveTextEl.textContent = segReply; codeAiScrollBottom();
if (_firstToolSeen) {
try { codeLiveStreamUpdate(segReply, liveState); } catch(e) { console.error('[codeLiveStream] error:', e); liveTextEl.textContent += '\n⚠️ 에디터 스트리밍 오류: ' + (e.message || e); }
}
}
} catch(e) { console.error('[codeAiStream] parse error:', e.message || e, 'data:', data?.slice(0, 80)); }
@@ -3116,9 +3137,18 @@ async function _codeAiSendImpl() {
if (activeProjectName) saveProjectSession(activeProjectName);
try { updateSessionPanel(); } catch (_) {}
thinkEl.classList.remove('streaming');
if (!reply) { thinkEl.textContent = '(응답 없음)'; }
else codeAiRenderMarkdown(thinkEl, reply);
if (_firstToolSeen) {
thinkEl.style.display = 'none';
if (liveTextEl) { liveTextEl.classList.remove('streaming'); codeAiRenderMarkdown(liveTextEl, segReply); }
const doneEl = codeAiAppend('system', '✅ 완료');
doneEl.style.cssText = 'font-weight:600;color:#4ade80;';
} else {
thinkEl.className = 'code-ai-msg assistant';
if (!reply) { thinkEl.textContent = '(응답 없음)'; thinkEl.style.display = ''; }
else { codeAiRenderMarkdown(thinkEl, reply); thinkEl.style.display = ''; }
}
} catch(e) {
thinkEl.style.display = '';
thinkEl.className = 'code-ai-msg thinking';
const errMsg = (e.name === 'AbortError' || (e.message || '').includes('aborted'))
? '요청이 취소되었습니다. 다시 시도해주세요.'
@@ -5467,6 +5497,8 @@ function setupCodeVResize() {
const handle = document.getElementById('code-vresize');
const aiPanel = document.getElementById('code-ai-panel');
if (!handle || !aiPanel) return;
const modelCard = document.getElementById('code-ai-model-card');
const syncModelCard = w => { if (modelCard) modelCard.style.width = w + 'px'; };
let startX = 0, startW = 0;
handle.addEventListener('mousedown', function(e) {
startX = e.clientX; startW = aiPanel.offsetWidth;
@@ -5478,6 +5510,7 @@ function setupCodeVResize() {
const dx = startX - e.clientX;
const newW = Math.max(220, Math.min(window.innerWidth * 0.6, startW + dx));
aiPanel.style.width = newW + 'px';
syncModelCard(newW);
if (monacoEditor) monacoEditor.layout();
}
function onVUp() {
@@ -5502,6 +5535,7 @@ function setupCodeVResize() {
try { updateSessionPanel(); } catch (e) {}
try { updateCodeAiModeBtn(); } catch (e) {}
try { _fetchModelCtxMax(codeAiModel); } catch (e) {}
try { const p = document.getElementById('code-ai-panel'), c = document.getElementById('code-ai-model-card'); if (p && c) c.style.width = p.offsetWidth + 'px'; } catch (e) {}
try { codeSbRefresh(); } catch (e) {}
try { companionConnect(); } catch (e) {}
// 히스토리 없으면 세션 목록 표시