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') {