diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index da5fa70..25d93df 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -813,8 +813,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } if (models.length > 0 && (!defaultModel || !models.includes(defaultModel))) { - defaultModel = models[0]; - await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global); + // [State Persistence Fix] 저장된 모델이 목록에 없을 때: + // 즉시 강제 리셋하는 대신, 현재 모델 목록의 첫 번째를 '폴백 후보'로만 사용. + // 단, defaultModel이 완전히 없는 경우에만 실제로 저장함. + if (!defaultModel) { + defaultModel = models[0]; + await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global); + } else { + // 저장된 모델명은 유지하고, UI에는 첫 번째 모델을 보여주되 + // 설정은 건드리지 않아 다음 번에 같은 모델이 다시 로드될 경우 복원 가능하도록 함 + logInfo('Saved model not found in current list, using first available as fallback.', { saved: defaultModel, fallback: models[0] }); + defaultModel = models[0]; + } } const defaultIdx = models.indexOf(defaultModel); @@ -1036,6 +1046,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn .input-box { background: var(--input-bg); border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px; display: flex; flex-direction: column; gap: 8px; transition: 0.2s; + position: relative; /* Toast 위치 앙커 */ } .input-box:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); } @@ -1065,6 +1076,56 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn } .send-btn:disabled { opacity: 0.3; cursor: not-allowed; } + .footer-right { display: flex; align-items: center; gap: 6px; } + + .cancel-btn { + background: transparent; color: var(--text-dim); border: 1px solid var(--border); + padding: 6px 10px; border-radius: 6px; font-weight: 600; font-size: 11px; + cursor: pointer; align-items: center; gap: 4px; transition: all 0.15s ease; + } + .cancel-btn:hover { background: rgba(248, 81, 73, 0.12); color: var(--error); border-color: var(--error); } + + .stop-btn { + background: rgba(248, 81, 73, 0.15); color: var(--error); border: 1px solid var(--error); + padding: 6px 14px; border-radius: 6px; font-weight: 700; font-size: 12px; + cursor: pointer; display: inline-flex; align-items: center; gap: 5px; + animation: stopPulse 1.2s ease-in-out infinite; + } + .stop-btn:hover { background: rgba(248, 81, 73, 0.3); } + @keyframes stopPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(248,81,73,0.4); } + 50% { box-shadow: 0 0 0 5px rgba(248,81,73,0); } + } + + /* --- Drag and Drop Overlay --- */ + body.drag-over::after { + content: '📂 파일을 여기에 놓으세요'; + position: fixed; top: 0; left: 0; width: 100%; height: 100%; + background: rgba(88, 166, 255, 0.2); + backdrop-filter: blur(4px); + border: 2px dashed var(--accent); + display: flex; align-items: center; justify-content: center; + color: var(--text-bright); font-size: 18px; font-weight: 700; + z-index: 9999; pointer-events: none; + animation: fadeIn 0.2s ease-out; + } + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + + /* --- Toast Notification --- */ + .toast-notif { + position: absolute; bottom: calc(100% + 8px); left: 50%; + transform: translateX(-50%) translateY(4px); + background: var(--surface); border: 1px solid var(--border-bright); + color: var(--text-primary); font-size: 11px; font-weight: 600; + padding: 7px 14px; border-radius: 20px; white-space: nowrap; + opacity: 0; pointer-events: none; + transition: opacity 0.2s ease, transform 0.2s ease; + z-index: 500; box-shadow: 0 4px 16px rgba(0,0,0,0.3); + } + .toast-notif.toast-visible { opacity: 1; transform: translateX(-50%) translateY(0); } + .toast-notif.toast-warn { border-color: var(--error); color: var(--error); background: rgba(248,81,73,0.08); } + .toast-notif.toast-success { border-color: var(--success); color: var(--success); background: rgba(35,134,54,0.08); } + /* --- Overlays & Others --- */ .thinking-bar { height: 2px; background: transparent; position: relative; overflow: hidden; margin-top: -1px; } .thinking-bar.active { @@ -1301,8 +1362,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn Ready - + +
@@ -1317,10 +1383,62 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn const chat = document.getElementById('chat'); const input = document.getElementById('input'); const sendBtn = document.getElementById('sendBtn'); + const stopBtn = document.getElementById('stopBtn'); + const cancelBtn = document.getElementById('cancelBtn'); + const toastNotif = document.getElementById('toastNotif'); const thinkingBar = document.getElementById('thinkingBar'); const statusLabel = document.getElementById('statusLabel'); const stepper = document.getElementById('stepper'); + // --- Draft State Management --- + let isDraftActive = false; + let _toastTimer = null; + + function showToast(msg, type = 'info') { + toastNotif.textContent = msg; + toastNotif.className = 'toast-notif toast-' + type + ' toast-visible'; + if (_toastTimer) clearTimeout(_toastTimer); + _toastTimer = setTimeout(() => { + toastNotif.classList.remove('toast-visible'); + }, 2500); + } + + function setDraftActive(active) { + isDraftActive = active; + cancelBtn.style.display = active ? 'inline-flex' : 'none'; + } + + // 생성 중/완료 시 Send ⇔ Stop 전환 + function setGenerating(generating) { + if (generating) { + sendBtn.style.display = 'none'; + stopBtn.style.display = 'inline-flex'; + // 생성 중에는 Clear 버튼 숨김 + cancelBtn.style.display = 'none'; + } else { + stopBtn.style.display = 'none'; + sendBtn.style.display = 'inline-flex'; + sendBtn.disabled = false; + // Draft 상태에 따라 Clear 버튼 복원 + if (isDraftActive) cancelBtn.style.display = 'inline-flex'; + } + } + + function clearDraft() { + // Step 1: 상태 초기화 (Draft State Reset) + setDraftActive(false); + // Step 2: UI 반영 (Input + Attachments 초기화) + input.value = ''; + input.style.height = 'auto'; + pendingFiles = []; + renderAttachments(); + input.focus(); + // Step 3: Toast 알림으로 즉각적 피드백 + showToast('✕ 작성 내용이 초기화되었습니다.', 'warn'); + Sound.warn(); + } + + // --- Sound Manager --- const Sound = { ctx: null, @@ -1494,7 +1612,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn break; case 'streamEnd': if (streamBody) streamBody.classList.remove('stream-active'); - streamBody = null; sendBtn.disabled = false; + streamBody = null; + // \uc0dd\uc131 \uc644\ub8cc \uc2dc Stop \ubc84\ud2bc \uc228\uae30\uace0 Send \ubcf5\uad50 + setGenerating(false); resetStepper(); Sound.success(); break; @@ -1518,16 +1638,28 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn case 'focusInput': input.focus(); break; - case 'modelsList': + case 'modelsList': { modelSel.innerHTML = ''; + // [State Persistence - Tier 2] LocalStorage에서 마지막 선택 모델 복원 시도 + const _savedModel = localStorage.getItem('g1nation_last_model'); + // 서버 추천 모델 vs 로컬 저장 모델 중 우선순위 결정 + // LocalStorage에 저장된 값이 현재 목록에 있으면 그것을 우선 사용 (Tier 2 우선) + const _preferredModel = (_savedModel && msg.value.models.includes(_savedModel)) + ? _savedModel + : msg.value.selected; msg.value.models.forEach(m => { const o = document.createElement('option'); o.value = m; o.innerText = m; - if (m === msg.value.selected) o.selected = true; + if (m === _preferredModel) o.selected = true; modelSel.appendChild(o); }); + // LocalStorage에 저장된 모델이 실제로 적용된 경우, 백엔드 설정도 동기화 + if (_savedModel && _savedModel !== msg.value.selected && msg.value.models.includes(_savedModel)) { + vscode.postMessage({ type: 'model', value: _savedModel }); + } if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder(); - statusLabel.innerText = \`Model: \${msg.value.selected}\`; + statusLabel.innerText = \`Model: \${_preferredModel}\`; break; + } case 'brainProfiles': brainSel.innerHTML = ''; msg.value.profiles.forEach(p => { @@ -1602,21 +1734,61 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn attachPreview.appendChild(chip); }); } - window.removeFile = (i) => { pendingFiles.splice(i, 1); renderAttachments(); }; - attachBtn.onclick = () => fileInput.click(); - fileInput.onchange = () => { - Array.from(fileInput.files).forEach(file => { + window.removeFile = (i) => { + pendingFiles.splice(i, 1); + renderAttachments(); + // 파일 삭제 후 Draft State 재평가 + setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0); + }; + function processFiles(files) { + if (!files || files.length === 0) return; + + Array.from(files).forEach(file => { const reader = new FileReader(); reader.onload = () => { const base64 = reader.result.split(',')[1]; pendingFiles.push({ name: file.name, type: file.type, data: base64 }); renderAttachments(); + setDraftActive(true); }; reader.readAsDataURL(file); }); + showToast(`${files.length}개의 파일이 추가되었습니다.`, 'success'); + Sound.success(); + } + + attachBtn.onclick = () => fileInput.click(); + fileInput.onchange = () => { + processFiles(fileInput.files); fileInput.value = ''; }; + // --- Drag and Drop Implementation --- + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + document.body.addEventListener(eventName, e => { + e.preventDefault(); + e.stopPropagation(); + }, false); + }); + + ['dragenter', 'dragover'].forEach(eventName => { + document.body.addEventListener(eventName, () => { + document.body.classList.add('drag-over'); + }, false); + }); + + ['dragleave', 'drop'].forEach(eventName => { + document.body.addEventListener(eventName, () => { + document.body.classList.remove('drag-over'); + }, false); + }); + + document.body.addEventListener('drop', e => { + const dt = e.dataTransfer; + const files = dt.files; + processFiles(files); + }, false); + function send() { const val = input.value.trim(); if (!val && pendingFiles.length === 0) return; @@ -1631,7 +1803,10 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn negativePrompt: negativePrompt.value.trim() || undefined }); input.value = ''; input.style.height = 'auto'; pendingFiles = []; renderAttachments(); - sendBtn.disabled = true; thinkingBar.classList.add('active'); + // 전송 완료 후 Draft State 리셋 + Stop 버튼 표시 + setDraftActive(false); + setGenerating(true); + thinkingBar.classList.add('active'); } sendBtn.onclick = send; @@ -1642,7 +1817,21 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn send(); } }); - input.addEventListener('input', () => { input.style.height = 'auto'; input.style.height = input.scrollHeight + 'px'; }); + input.addEventListener('input', () => { + input.style.height = 'auto'; + input.style.height = input.scrollHeight + 'px'; + // Draft State: 내용이 있으면 cancelBtn 표시 + setDraftActive(input.value.trim().length > 0 || pendingFiles.length > 0); + }); + + cancelBtn.onclick = () => clearDraft(); + stopBtn.onclick = () => { + vscode.postMessage({ type: 'stopGeneration' }); + setGenerating(false); + thinkingBar.classList.remove('active'); + showToast('■ 생성이 중단되었습니다.', 'warn'); + Sound.warn(); + }; const startNewChat = () => { Sound.play(660, 'sine', 0.1); vscode.postMessage({ type: 'newChat' }); }; document.getElementById('newChatBtn').onclick = startNewChat; @@ -1671,8 +1860,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn }; modelSel.onchange = () => { - vscode.postMessage({ type: 'model', value: modelSel.value }); + const _selectedModel = modelSel.value; + // [State Persistence - Tier 2] 모델 변경 시 LocalStorage에 즉시 저장 (클라이언트 측 지속성) + try { + localStorage.setItem('g1nation_last_model', _selectedModel); + } catch(e) { + console.warn('[G1nation] LocalStorage 저장 실패:', e); + } + // [State Persistence - Tier 1] VS Code 전역 설정에 동기화 (영구 저장) + vscode.postMessage({ type: 'model', value: _selectedModel }); updateInputPlaceholder(); + // 상태 레이블 즉시 업데이트 + statusLabel.innerText = \`Model: \${_selectedModel}\`; }; brainSel.onchange = () => { if (brainSel.value === 'new') {