sanjang 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,22 +1,31 @@
1
1
  # 산장 (Sanjang)
2
2
 
3
- > 바이브코더를 위한 로컬 개발 환경 매니저
3
+ > 비개발자가 AI로 코드를 고치고 PR을 보낼 수 있는 로컬 개발 환경
4
4
 
5
- 브랜치 하나로 격리된 dev 환경을 띄우고, 프리뷰하고, 관리하는 도구입니다.
6
- Git worktree 기반으로 동시에 여러 작업 환경(캠프)을 운영할 수 있습니다.
5
+ "로그인 버튼 색상 변경"이라고 입력하면, 격리된 작업 환경(캠프)이 만들어지고, AI가 코드를 고치고, 결과를 바로 프리뷰하고, 팀에 PR로 보낼 수 있습니다.
7
6
 
8
7
  ## 주요 기능
9
8
 
10
- - **포털 홈**: 이어하기(열린 PR + 캠프)와 새로 시작(자연어 퀵스타트)로 대시보드 첫 화면 구성
11
- - **캠프 생성**: 브랜치 선택 자동 worktree + 의존성 설치 + dev 서버 시작
12
- - **의존성 캐시**: `init` node_modules 프리빌드, 캠프 생성 캐시 클론으로 수초 만에 완료
13
- - **멀티앱 감지**: 모노레포에서 여러 앱을 자동 감지하고 init 시 인터랙티브 선택
14
- - **포트 자동 관리**: 캠프마다 다른 포트 자동 할당, 충돌 없음
15
- - **대시보드**: 브라우저에서 모든 캠프 상태 확인 + 시작/중지/삭제
16
- - **스냅샷**: 현재 상태 저장/복원 (git stash 기반)
17
- - **일 시키기**: 대시보드에서 Claude에게 작업 지시 (claude -p 연동)
18
- - **팀에 보내기**: 변경사항 commit + push PR 생성 플로우
19
- - **프로젝트 자동 감지**: Next.js, Vite, SvelteKit, Angular, shadow-cljs, Turborepo 자동 인식
9
+ ### 만들기
10
+ - **자연어 퀵스타트**: "대시보드 필터 추가"라고 입력하면 브랜치+캠프 자동 생성
11
+ - **캠프 격리**: 작업이 독립된 환경에서 실행 서로 영향 없음
12
+ - **프로젝트 자동 감지**: Next.js, Vite, SvelteKit, Angular, Turborepo 인식
13
+
14
+ ### 확인하기
15
+ - **내장 프리뷰**: 대시보드 안에서 바로 결과 확인 (프록시 기반, iframe)
16
+ - **비교 모드**: 원본(main)과 변경을 나란히 비교
17
+ - **AI 변경 리포트**: "로그인 버튼이 보라색으로 바뀌었어요" 비개발자도 이해할 수 있는 설명
18
+ - **화면 항목 프리뷰 이동**: 리포트에서 UI 항목 클릭하면 해당 화면으로 바로 이동
19
+
20
+ ### 고치기
21
+ - **자가 치유**: 서버 에러 자동 감지 → 패턴 매칭 → 자동 수정 → 재시작
22
+ - **고쳐줘 버튼**: 브라우저 에러 + 서버 로그를 모아 Claude Code용 프롬프트로 클립보드 복사
23
+ - **세이브 & 되돌리기**: 작업을 세이브하고, 문제가 생기면 원클릭 되돌리기
24
+ - **오토세이브**: 5분간 변경 없으면 자동 세이브
25
+
26
+ ### 보내기
27
+ - **팀에 보내기**: 세이브 → commit + push → PR 생성
28
+ - **AI PR 설명**: diff를 분석해서 리뷰어가 이해하기 쉬운 PR 본문 자동 생성
20
29
 
21
30
  ---
22
31
 
@@ -183,6 +192,9 @@ export default {
183
192
  | **산장** | 이 도구 전체. 대시보드 서버 + 캠프 매니저 |
184
193
  | **캠프** | 개별 작업 환경. git worktree + dev 서버 |
185
194
  | **포털** | 대시보드 첫 화면. 이어하기 + 새로 시작 |
195
+ | **세이브** | 작업 저장 (git commit). 되돌리기 가능 |
196
+ | **변경 리포트** | AI가 생성하는 변경사항 요약. 카테고리별 설명 |
197
+ | **자가 치유** | 에러 패턴 감지 → 자동 수정 → 재시작 |
186
198
  | **스냅샷** | 캠프의 현재 상태를 저장한 것 (git stash) |
187
199
  | **캐시** | 의존성 프리빌드. 캠프 생성 속도 향상 |
188
200
 
package/dashboard/app.js CHANGED
@@ -12,6 +12,62 @@ const playgrounds = new Map();
12
12
  /** @type {Map<string, Array<{text: string, source: string}>>} logs keyed by playground name */
13
13
  const logs = new Map();
14
14
 
15
+ // ---------------------------------------------------------------------------
16
+ // Route inference — map file paths to preview routes
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Infer a preview route from a file path.
21
+ * Only works for file-based routing patterns (pages/, app/, views/).
22
+ * Returns null if no route can be inferred.
23
+ */
24
+ function inferRouteFromPath(filePath) {
25
+ const lower = filePath.toLowerCase();
26
+
27
+ // Match pages/xxx.tsx, app/xxx/page.tsx, views/xxx.vue etc.
28
+ const patterns = [
29
+ // Next.js app router: app/dashboard/page.tsx → /dashboard
30
+ { regex: /(?:^|[/\\])app[/\\](.+?)[/\\]page\.[^.]+$/, transform: m => '/' + m },
31
+ // Next.js app router: app/page.tsx → /
32
+ { regex: /(?:^|[/\\])app[/\\]page\.[^.]+$/, transform: () => '/' },
33
+ // Next.js/Nuxt pages router: pages/login.tsx → /login
34
+ { regex: /(?:^|[/\\])pages[/\\](.+?)(?:\.[^.]+)$/, transform: m => '/' + m },
35
+ // views/: views/Login.vue → /login
36
+ { regex: /(?:^|[/\\])views[/\\](.+?)(?:\.[^.]+)$/, transform: m => '/' + m },
37
+ ];
38
+
39
+ for (const { regex, transform } of patterns) {
40
+ const match = regex.exec(lower);
41
+ if (match) {
42
+ let route = transform(match[1] || '');
43
+ // Clean up: remove index, trailing slash dupes
44
+ route = route.replace(/\/index$/, '/').replace(/\/+/g, '/');
45
+ // Remove dynamic route brackets for navigation: [id] → placeholder
46
+ route = route.replace(/\[([^\]]+)\]/g, '1');
47
+ return route || '/';
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Navigate the preview iframe to a given route.
55
+ */
56
+ function navigatePreview(route) {
57
+ const iframe = document.querySelector('#ws-preview iframe');
58
+ if (!iframe) return;
59
+ try {
60
+ const base = new URL(iframe.src);
61
+ base.pathname = route;
62
+ iframe.contentWindow.location.href = base.toString();
63
+ toast(`${route} 로 이동`, 'info');
64
+ } catch {
65
+ // cross-origin — reload with new path
66
+ const src = iframe.src.replace(/\/preview\/(\d+)\/.*/, `/preview/$1${route}`);
67
+ iframe.src = src;
68
+ }
69
+ }
70
+
15
71
  /** @type {Map<string, Array>} diagnostics keyed by playground name */
16
72
  const diagnostics = new Map();
17
73
 
@@ -22,6 +78,11 @@ let currentWorkspace = null;
22
78
  /** @type {number|null} polling interval for workspace changes */
23
79
  let wsPollingInterval = null;
24
80
 
81
+ /** @type {object|null} 마지막 리포트 캐시 — 세이브 후 축소 표시용 */
82
+ let lastReport = null;
83
+
84
+ let compareMode = false;
85
+
25
86
  const SHERPA_QUOTES = [
26
87
  "요구사항 또 바뀌었댜... 뭐 그러려니 하쥬",
27
88
  "'간단한 건데~' 그 말이 제일 무섭댜",
@@ -218,6 +279,7 @@ function handleWsMessage(msg) {
218
279
  case 'playground-saved': {
219
280
  if (!name) break;
220
281
  toast(`💾 세이브됨: ${data?.message || ''}`, 'success');
282
+ if (currentWorkspace === name) transitionReportToSaved();
221
283
  break;
222
284
  }
223
285
 
@@ -225,6 +287,7 @@ function handleWsMessage(msg) {
225
287
  if (!name) break;
226
288
  toast('💾 오토세이브 완료', 'success');
227
289
  if (currentWorkspace === name) {
290
+ transitionReportToSaved();
228
291
  api('POST', `/api/playgrounds/${name}/enter`).then(renderWorkspace).catch(() => {});
229
292
  }
230
293
  break;
@@ -263,8 +326,8 @@ function handleWsMessage(msg) {
263
326
  </div>`;
264
327
  }).join('');
265
328
  renderBlocks(data.files);
266
- // Debounced AI summary fetch
267
- debounceSummaryFetch(name);
329
+ // Debounced AI report fetch
330
+ debounceReportFetch(name);
268
331
  }
269
332
 
270
333
  updateChangeSummary(data.count, data.ts);
@@ -272,6 +335,19 @@ function handleWsMessage(msg) {
272
335
  debouncePreviewRefresh();
273
336
  break;
274
337
  }
338
+
339
+ case 'compare-ready': {
340
+ if (!compareMode) break;
341
+ const mainPreview = document.getElementById('ws-preview-main');
342
+ if (mainPreview && data?.port) {
343
+ const proxyUrl = `/preview/${data.port}/`;
344
+ mainPreview.innerHTML = `
345
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
346
+ <iframe src="${escHtml(proxyUrl)}" class="ws-preview-iframe"></iframe>`;
347
+ toast('원본 프리뷰 준비 완료!', 'success');
348
+ }
349
+ break;
350
+ }
275
351
  }
276
352
  }
277
353
 
@@ -1153,6 +1229,14 @@ function enterWorkspace(name) {
1153
1229
  }
1154
1230
 
1155
1231
  function exitWorkspace() {
1232
+ compareMode = false;
1233
+ const compareBtn = document.getElementById('ws-compare-btn');
1234
+ if (compareBtn) compareBtn.classList.remove('btn-active');
1235
+ const mainPreview = document.getElementById('ws-preview-main');
1236
+ if (mainPreview) mainPreview.classList.add('hidden');
1237
+ const container = document.getElementById('ws-preview-container');
1238
+ if (container) container.classList.remove('ws-split-view');
1239
+ lastReport = null;
1156
1240
  currentWorkspace = null;
1157
1241
  if (wsPollingInterval) { clearInterval(wsPollingInterval); wsPollingInterval = null; }
1158
1242
  document.getElementById('workspace').classList.add('hidden');
@@ -1164,6 +1248,108 @@ function exitWorkspace() {
1164
1248
  }
1165
1249
  window.exitWorkspace = exitWorkspace;
1166
1250
 
1251
+ async function fetchAndRenderReport(campName, withAi = false) {
1252
+ const section = document.getElementById('ws-report-section');
1253
+ if (!section) return;
1254
+
1255
+ try {
1256
+ const report = await api('GET', `/api/playgrounds/${campName}/change-report${withAi ? '?ai=true' : ''}`);
1257
+
1258
+ if (report.totalCount === 0) {
1259
+ if (lastReport && lastReport.summary) {
1260
+ section.style.display = '';
1261
+ section.classList.add('ws-report-saved');
1262
+ document.getElementById('ws-report-summary').innerHTML =
1263
+ `<div class="ws-report-desc ws-report-saved-desc">✅ 마지막 세이브: ${escHtml(lastReport.summary)}</div>`;
1264
+ document.getElementById('ws-report-warnings').innerHTML = '';
1265
+ document.getElementById('ws-report-categories').innerHTML = '';
1266
+ } else {
1267
+ section.style.display = 'none';
1268
+ }
1269
+ return;
1270
+ }
1271
+
1272
+ lastReport = report;
1273
+ section.style.display = '';
1274
+ section.classList.remove('ws-report-saved');
1275
+
1276
+ const summaryEl = document.getElementById('ws-report-summary');
1277
+ const changeSummaryText = document.getElementById('ws-changes-summary-text');
1278
+ if (report.humanDescription) {
1279
+ summaryEl.innerHTML = `<div class="ws-report-desc">${escHtml(report.humanDescription)}</div>`;
1280
+ } else if (report.summary) {
1281
+ summaryEl.innerHTML = `<div class="ws-report-desc">${escHtml(report.summary)}</div>`;
1282
+ } else {
1283
+ summaryEl.innerHTML = `<div class="ws-report-desc">${report.totalCount}개 파일 변경됨</div>`;
1284
+ }
1285
+
1286
+ if (changeSummaryText && report.summary) {
1287
+ changeSummaryText.textContent = `⚠️ 저장 안 됨 — ${report.summary}`;
1288
+ }
1289
+
1290
+ const warningsEl = document.getElementById('ws-report-warnings');
1291
+ if (report.warnings.length > 0) {
1292
+ warningsEl.innerHTML = report.warnings.map(w =>
1293
+ `<div class="ws-report-warning">
1294
+ <span class="ws-report-warning-icon">⚠️</span>
1295
+ <span>${escHtml(w.message)}</span>
1296
+ </div>`
1297
+ ).join('');
1298
+ } else {
1299
+ warningsEl.innerHTML = '';
1300
+ }
1301
+
1302
+ const categoryNames = { ui: '🎨 화면', api: '⚙️ 서버', config: '🔧 설정', test: '🧪 테스트', docs: '📝 문서', other: '📦 기타' };
1303
+ const categoriesEl = document.getElementById('ws-report-categories');
1304
+ const details = report.categoryDetails || {};
1305
+ categoriesEl.innerHTML = Object.entries(report.byCategory).map(([cat, files]) => {
1306
+ const items = details[cat];
1307
+ const hasDetails = items && items.length > 0;
1308
+ return `<div class="ws-report-cat-group">
1309
+ <div class="ws-report-cat-header">
1310
+ <span class="ws-report-cat-label">${categoryNames[cat] || cat}</span>
1311
+ <span class="ws-report-cat-count">${files.length}</span>
1312
+ </div>
1313
+ ${hasDetails
1314
+ ? `<ul class="ws-report-cat-items">${items.map((item, idx) => {
1315
+ const file = files[idx];
1316
+ const route = cat === 'ui' && file ? inferRouteFromPath(file.path) : null;
1317
+ return route
1318
+ ? `<li class="ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(file.path)} → ${escHtml(route)}">${escHtml(item)} <span class="ws-report-nav-hint">→ 보기</span></li>`
1319
+ : `<li>${escHtml(item)}</li>`;
1320
+ }).join('')}</ul>`
1321
+ : `<ul class="ws-report-cat-items">${files.map(f => {
1322
+ const route = cat === 'ui' ? inferRouteFromPath(f.path) : null;
1323
+ const label = `${escHtml(f.path.split('/').pop() || f.path)} ${f.status === '새 파일' ? '추가됨' : '수정됨'}`;
1324
+ return route
1325
+ ? `<li class="ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(f.path)} → ${escHtml(route)}">${label} <span class="ws-report-nav-hint">→ 보기</span></li>`
1326
+ : `<li>${label}</li>`;
1327
+ }).join('')}</ul>`
1328
+ }
1329
+ </div>`;
1330
+ }).join('');
1331
+
1332
+ } catch {
1333
+ section.style.display = 'none';
1334
+ }
1335
+ }
1336
+
1337
+ function transitionReportToSaved() {
1338
+ const section = document.getElementById('ws-report-section');
1339
+ if (!section || !lastReport) return;
1340
+
1341
+ if (lastReport.summary) {
1342
+ section.style.display = '';
1343
+ section.classList.add('ws-report-saved');
1344
+ document.getElementById('ws-report-summary').innerHTML =
1345
+ `<div class="ws-report-desc ws-report-saved-desc">✅ 마지막 세이브: ${escHtml(lastReport.summary)}</div>`;
1346
+ document.getElementById('ws-report-warnings').innerHTML = '';
1347
+ document.getElementById('ws-report-categories').innerHTML = '';
1348
+ } else {
1349
+ section.style.display = 'none';
1350
+ }
1351
+ }
1352
+
1167
1353
  function renderWorkspace(data) {
1168
1354
  const { camp, changes, warpInstalled, previewUrl, autosave } = data;
1169
1355
 
@@ -1187,6 +1373,7 @@ function renderWorkspace(data) {
1187
1373
  saveBtn.style.display = 'none';
1188
1374
  changesEl.innerHTML = '';
1189
1375
  renderBlocks([]);
1376
+ fetchAndRenderReport(camp.name);
1190
1377
  } else {
1191
1378
  unsavedSection.classList.remove('ws-no-changes');
1192
1379
  summaryTextEl.textContent = `⚠️ 저장 안 됨 — ${changes.count}개 파일 수정 중`;
@@ -1200,10 +1387,9 @@ function renderWorkspace(data) {
1200
1387
  </div>`
1201
1388
  ).join('');
1202
1389
  renderBlocks(changes.files);
1203
- // Fetch AI summary
1204
- api('GET', `/api/playgrounds/${camp.name}/changes-summary`).then(data => {
1205
- if (data.summary) summaryTextEl.textContent = `⚠️ 저장 안 됨 — ${data.summary}`;
1206
- }).catch(() => {});
1390
+ // 먼저 fallback으로 빠르게 렌더, 이어서 AI 업그레이드
1391
+ fetchAndRenderReport(camp.name);
1392
+ fetchAndRenderReport(camp.name, true);
1207
1393
  }
1208
1394
 
1209
1395
  // Actions — show commits as work history
@@ -1211,12 +1397,22 @@ function renderWorkspace(data) {
1211
1397
  const commitList = data.commits || [];
1212
1398
  if (commitList.length > 0) {
1213
1399
  actionsEl.innerHTML = commitList.map(c =>
1214
- `<div class="ws-commit-item">
1215
- <span class="ws-commit-msg">${escHtml(c.message)}</span>
1216
- <span class="ws-commit-date">${escHtml(c.date)}</span>
1217
- <button class="btn btn-ghost btn-sm ws-revert-btn" onclick="revertCommit('${escHtml(c.hash)}')" title="이 세이브 되돌리기">↩</button>
1218
- </div>`
1400
+ `<details class="ws-commit-item" data-hash="${escHtml(c.hash)}">
1401
+ <summary class="ws-commit-summary">
1402
+ <span class="ws-commit-arrow">▶</span>
1403
+ <span class="ws-commit-msg">${escHtml(c.message)}</span>
1404
+ <span class="ws-commit-date">${escHtml(c.date)}</span>
1405
+ <button class="btn btn-ghost btn-sm ws-revert-btn" onclick="event.stopPropagation();event.preventDefault();revertCommit('${escHtml(c.hash)}')" title="이 세이브 되돌리기">↩</button>
1406
+ </summary>
1407
+ <div class="ws-commit-report"></div>
1408
+ </details>`
1219
1409
  ).join('');
1410
+ // 펼칠 때 자동으로 리포트 로드
1411
+ actionsEl.querySelectorAll('.ws-commit-item').forEach(el => {
1412
+ el.addEventListener('toggle', function() {
1413
+ if (this.open) loadCommitReport(this, this.dataset.hash);
1414
+ });
1415
+ });
1220
1416
  } else if (changes.count > 0) {
1221
1417
  actionsEl.innerHTML = '<span style="color:var(--text-muted);font-size:13px">아직 커밋 없음 (작업 중)</span>';
1222
1418
  } else {
@@ -1426,15 +1622,57 @@ function updateQuestProgress(hasChanges, hasSaves) {
1426
1622
  }
1427
1623
  }
1428
1624
 
1429
- let summaryFetchTimer = null;
1430
- function debounceSummaryFetch(campName) {
1431
- if (summaryFetchTimer) clearTimeout(summaryFetchTimer);
1432
- summaryFetchTimer = setTimeout(() => {
1433
- api('GET', `/api/playgrounds/${campName}/changes-summary`).then(data => {
1434
- const el = document.getElementById('ws-changes-summary-text');
1435
- if (data.summary && el) el.textContent = data.summary;
1436
- }).catch(() => {});
1437
- }, 3000);
1625
+ async function loadCommitReport(el, hash) {
1626
+ const reportEl = el.querySelector('.ws-commit-report');
1627
+ if (!reportEl || reportEl.dataset.loaded) return;
1628
+
1629
+ reportEl.innerHTML = '<div class="ws-commit-report-loading">불러오는 중...</div>';
1630
+
1631
+ try {
1632
+ const report = await api('GET', `/api/playgrounds/${currentWorkspace}/commit-report/${hash}?ai=true`);
1633
+ const categoryNames = { ui: '🎨 화면', api: '⚙️ 서버', config: '🔧 설정', test: '🧪 테스트', docs: '📝 문서', other: '📦 기타' };
1634
+ const details = report.categoryDetails || {};
1635
+
1636
+ if (report.totalCount === 0) {
1637
+ reportEl.innerHTML = '<div class="ws-commit-report-empty">변경 내용 없음</div>';
1638
+ return;
1639
+ }
1640
+
1641
+ reportEl.innerHTML = Object.entries(report.byCategory).map(([cat, files]) => {
1642
+ const items = details[cat];
1643
+ const hasDetails = items && items.length > 0;
1644
+ return `<div class="ws-commit-cat">
1645
+ <span class="ws-commit-cat-label">${categoryNames[cat] || cat}</span>
1646
+ ${hasDetails
1647
+ ? items.map((item, idx) => {
1648
+ const file = files[idx];
1649
+ const route = cat === 'ui' && file ? inferRouteFromPath(file.path) : null;
1650
+ return route
1651
+ ? `<div class="ws-commit-cat-item ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(file.path)} → ${escHtml(route)}">${escHtml(item)} <span class="ws-report-nav-hint">→ 보기</span></div>`
1652
+ : `<div class="ws-commit-cat-item">${escHtml(item)}</div>`;
1653
+ }).join('')
1654
+ : files.map(f => {
1655
+ const route = cat === 'ui' ? inferRouteFromPath(f.path) : null;
1656
+ const label = `${escHtml(f.path.split('/').pop() || f.path)} ${f.status === '새 파일' ? '추가됨' : '수정됨'}`;
1657
+ return route
1658
+ ? `<div class="ws-commit-cat-item ws-report-nav-item" onclick="navigatePreview('${escHtml(route)}')" title="${escHtml(f.path)} → ${escHtml(route)}">${label} <span class="ws-report-nav-hint">→ 보기</span></div>`
1659
+ : `<div class="ws-commit-cat-item">${label}</div>`;
1660
+ }).join('')
1661
+ }
1662
+ </div>`;
1663
+ }).join('');
1664
+ reportEl.dataset.loaded = 'true';
1665
+ } catch {
1666
+ reportEl.innerHTML = '<div class="ws-commit-report-empty">불러오기 실패</div>';
1667
+ }
1668
+ }
1669
+
1670
+ let reportFetchTimer = null;
1671
+ function debounceReportFetch(campName) {
1672
+ if (reportFetchTimer) clearTimeout(reportFetchTimer);
1673
+ reportFetchTimer = setTimeout(() => {
1674
+ fetchAndRenderReport(campName, true);
1675
+ }, 5000);
1438
1676
  }
1439
1677
 
1440
1678
  let previewRefreshTimer = null;
@@ -1515,15 +1753,21 @@ function renderBrowserErrors() {
1515
1753
  const panel = document.getElementById('ws-browser-errors');
1516
1754
  if (!panel) return;
1517
1755
  const badge = document.getElementById('ws-browser-error-badge');
1756
+ const fixBtn = document.getElementById('ws-fix-btn');
1757
+ // Show fix button if there are browser errors OR server error logs
1758
+ const serverHasErrors = currentWorkspace && (logs.get(currentWorkspace) ?? [])
1759
+ .some(l => l.source !== 'frontend' && l.source !== 'task' && /error|ERR|ENOENT|ECONNREFUSED|TypeError|SyntaxError|Cannot find/i.test(l.text));
1518
1760
  if (browserErrors.length === 0) {
1519
1761
  panel.innerHTML = '<span style="color:var(--text-muted);font-size:12px">에러 없음</span>';
1520
1762
  if (badge) badge.style.display = 'none';
1763
+ if (fixBtn) fixBtn.style.display = serverHasErrors ? '' : 'none';
1521
1764
  return;
1522
1765
  }
1523
1766
  if (badge) {
1524
1767
  badge.style.display = '';
1525
1768
  badge.textContent = browserErrors.length;
1526
1769
  }
1770
+ if (fixBtn) fixBtn.style.display = '';
1527
1771
  panel.innerHTML = browserErrors.slice(-20).reverse().map(e => {
1528
1772
  const loc = e.source ? ` <span style="color:var(--text-muted)">${escHtml(e.source.split('/').pop())}:${e.line || ''}</span>` : '';
1529
1773
  return `<div class="ws-browser-error-item">
@@ -1556,13 +1800,99 @@ window.revertCommit = async function revertCommit(hash) {
1556
1800
  }
1557
1801
  };
1558
1802
 
1803
+ /**
1804
+ * Build a structured prompt from browser errors + server logs
1805
+ * that Claude Code can use to diagnose and fix the issue.
1806
+ */
1807
+ window.copyFixPrompt = async function copyFixPrompt() {
1808
+ const name = currentWorkspace;
1809
+ if (!name) return;
1810
+
1811
+ const sections = [];
1812
+
1813
+ // 1. Browser errors
1814
+ if (browserErrors.length > 0) {
1815
+ const errs = browserErrors.slice(-10).map(e => {
1816
+ let line = `[${e.level}] ${e.message}`;
1817
+ if (e.source) line += `\n 위치: ${e.source}${e.line ? ':' + e.line : ''}${e.col ? ':' + e.col : ''}`;
1818
+ return line;
1819
+ }).join('\n\n');
1820
+ sections.push(`## 브라우저 에러 (${browserErrors.length}개)\n\n${errs}`);
1821
+ }
1822
+
1823
+ // 2. Server logs (last 20 lines, stderr/error only)
1824
+ const serverLines = (logs.get(name) ?? [])
1825
+ .filter(l => l.source !== 'frontend' && l.source !== 'task')
1826
+ .slice(-20)
1827
+ .map(l => l.text.trim())
1828
+ .filter(Boolean);
1829
+ if (serverLines.length > 0) {
1830
+ sections.push(`## 서버 로그 (최근)\n\n${serverLines.join('\n')}`);
1831
+ }
1832
+
1833
+ // 3. Current changes context
1834
+ try {
1835
+ const changes = await api('GET', `/api/playgrounds/${name}/changes`);
1836
+ if (changes.files?.length > 0) {
1837
+ const fileList = changes.files.map(f => `- ${f.status} ${f.path}`).join('\n');
1838
+ sections.push(`## 현재 수정된 파일\n\n${fileList}`);
1839
+ }
1840
+ } catch { /* ignore */ }
1841
+
1842
+ if (sections.length === 0) {
1843
+ toast('복사할 에러가 없습니다', 'info');
1844
+ return;
1845
+ }
1846
+
1847
+ const prompt = `아래 에러를 분석하고 수정해줘.
1848
+
1849
+ 에러를 읽고 근본 원인을 먼저 파악한 다음, 최소한의 변경으로 고쳐줘.
1850
+ 추측하지 말고 에러 메시지와 스택 트레이스를 근거로 진단해.
1851
+ 수정 후 관련 파일만 변경하고, 변경 이유를 간단히 설명해줘.
1852
+
1853
+ ${sections.join('\n\n---\n\n')}`;
1854
+
1855
+ try {
1856
+ await navigator.clipboard.writeText(prompt);
1857
+ toast('📋 에러 프롬프트 복사 완료 — Claude Code에 붙여넣기', 'success');
1858
+ } catch {
1859
+ // Fallback for non-HTTPS
1860
+ const ta = document.createElement('textarea');
1861
+ ta.value = prompt;
1862
+ ta.style.cssText = 'position:fixed;left:-9999px';
1863
+ document.body.appendChild(ta);
1864
+ ta.select();
1865
+ document.execCommand('copy');
1866
+ ta.remove();
1867
+ toast('📋 에러 프롬프트 복사 완료 — Claude Code에 붙여넣기', 'success');
1868
+ }
1869
+ };
1870
+
1559
1871
  function clearBrowserErrors() {
1560
1872
  browserErrors.length = 0;
1561
1873
  renderBrowserErrors();
1562
1874
  }
1563
1875
 
1564
- window.wsShip = function() {
1876
+ window.wsShip = async function() {
1565
1877
  if (!currentWorkspace) return;
1878
+ // Fetch report for ship confirmation
1879
+ try {
1880
+ const report = await api('GET', `/api/playgrounds/${currentWorkspace}/change-report?ai=true`);
1881
+ const reportPreview = document.getElementById('ship-report-preview');
1882
+ if (reportPreview && report.totalCount > 0) {
1883
+ let html = '';
1884
+ if (report.humanDescription) {
1885
+ html += `<div class="ship-report-desc">${escHtml(report.humanDescription)}</div>`;
1886
+ }
1887
+ if (report.warnings.length > 0) {
1888
+ html += report.warnings.map(w =>
1889
+ `<div class="ws-report-warning"><span>⚠️</span> ${escHtml(w.message)}</div>`
1890
+ ).join('');
1891
+ }
1892
+ reportPreview.innerHTML = html;
1893
+ reportPreview.style.display = html ? '' : 'none';
1894
+ }
1895
+ } catch { /* non-blocking */ }
1566
1896
  openShipModal(currentWorkspace);
1567
1897
  };
1568
1898
 
@@ -1641,6 +1971,48 @@ window.togglePanel = function() {
1641
1971
  document.getElementById('ws-panel')?.classList.toggle('open');
1642
1972
  };
1643
1973
 
1974
+ window.toggleCompare = async function() {
1975
+ const container = document.getElementById('ws-preview-container');
1976
+ const mainPreview = document.getElementById('ws-preview-main');
1977
+ const compareBtn = document.getElementById('ws-compare-btn');
1978
+ if (!container || !mainPreview) return;
1979
+
1980
+ compareMode = !compareMode;
1981
+
1982
+ if (compareMode) {
1983
+ compareBtn.classList.add('btn-active');
1984
+ mainPreview.classList.remove('hidden');
1985
+ container.classList.add('ws-split-view');
1986
+
1987
+ toast('원본 서버를 준비하고 있어요...', 'info');
1988
+ try {
1989
+ const state = await api('POST', '/api/compare/start');
1990
+ if (state.status === 'running' && state.port) {
1991
+ const proxyUrl = `/preview/${state.port}/`;
1992
+ mainPreview.innerHTML = `
1993
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
1994
+ <iframe src="${escHtml(proxyUrl)}" class="ws-preview-iframe"></iframe>`;
1995
+ } else if (state.status === 'starting') {
1996
+ mainPreview.innerHTML = `
1997
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
1998
+ <div class="ws-preview-loading">준비 중...</div>`;
1999
+ } else {
2000
+ mainPreview.innerHTML = `
2001
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
2002
+ <div class="ws-preview-loading">원본 서버를 시작하지 못했어요</div>`;
2003
+ }
2004
+ } catch {
2005
+ mainPreview.innerHTML = `
2006
+ <div class="ws-preview-label">🏔️ 원본 (main)</div>
2007
+ <div class="ws-preview-loading">원본 서버를 시작하지 못했어요</div>`;
2008
+ }
2009
+ } else {
2010
+ compareBtn.classList.remove('btn-active');
2011
+ mainPreview.classList.add('hidden');
2012
+ container.classList.remove('ws-split-view');
2013
+ }
2014
+ };
2015
+
1644
2016
  // ---------------------------------------------------------------------------
1645
2017
  // Init
1646
2018
  // ---------------------------------------------------------------------------
@@ -2467,9 +2839,11 @@ async function loadActivityTrail() {
2467
2839
  }
2468
2840
  });
2469
2841
 
2470
- // Tents on rest days (0 commits, not first/last)
2842
+ // Tents on rest days limit density when most days are rest days
2843
+ const activeDays = days.filter(d => d.commits > 0).length;
2844
+ const tentChance = activeDays < 5 ? 0.85 : 0.5; // fewer active days → fewer tents
2471
2845
  heights.forEach((h, i) => {
2472
- if (days[i].commits === 0 && i > 0 && i < days.length - 1 && Math.random() > 0.5) {
2846
+ if (days[i].commits === 0 && i > 0 && i < days.length - 1 && Math.random() > tentChance) {
2473
2847
  const x = 20 + i * dayW + dayW / 2 - 4;
2474
2848
  decorations += `
2475
2849
  <rect x="${x + 3}" y="${ground - 8}" width="2" height="2" fill="#6b7394"/>
@@ -2485,9 +2859,9 @@ async function loadActivityTrail() {
2485
2859
  if (datePrs && datePrs.length > 0) {
2486
2860
  const x = 20 + i * dayW + dayW / 2 - 4;
2487
2861
  const h = heights[i];
2488
- const tooltipText = datePrs.map(p => `#${p.number} ${p.title}`).join('\n');
2862
+ const tooltipText = datePrs.map(p => `#${p.number} ${escHtml(p.title)}`).join('\n');
2489
2863
  prMarkers += `
2490
- <g class="pr-marker" data-tooltip="${tooltipText.replace(/"/g, '&quot;')}">
2864
+ <g class="pr-marker" data-tooltip="${escHtml(tooltipText)}">
2491
2865
  <rect x="${x}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
2492
2866
  <rect x="${x + 4}" y="${h - 4}" width="2" height="2" fill="#8B4513"/>
2493
2867
  <rect x="${x + 2}" y="${h - 8}" width="2" height="4" fill="#ff6600"/>
@@ -2520,8 +2894,8 @@ async function loadActivityTrail() {
2520
2894
  <polygon points="${x + 2},${highest.h} ${x + 8},${highest.h + 3} ${x + 2},${highest.h + 6}" fill="#f59e0b"/>`;
2521
2895
  }
2522
2896
 
2523
- // Sherpa at today (last position)
2524
- const lastX = 20 + (days.length - 1) * dayW + dayW / 2 - 4;
2897
+ // Sherpa at today (last position, clamped to SVG bounds)
2898
+ const lastX = Math.min(20 + (days.length - 1) * dayW + dayW / 2 - 4, svgW - 24);
2525
2899
  const lastH = heights[heights.length - 1];
2526
2900
  const sherpaY = lastH - 16;
2527
2901
  const sherpa = `