Commit 13e9660c331a85d9e45ed07186eb735b86a02ed6

Authored by yibo
1 parent 6d81a868

feat: company workspace page + HTML preview + navbar cleanup

1. Workspace page: left file tree + right detail/editor panel
   - File info: name, path, size, created/modified timestamps
   - Three view modes: Code / Preview / Edit with save
   - HTML files auto-open in Preview tab (sandboxed iframe)

2. Backend APIs:
   - GET/PUT /:companyId/workspace/files (list + content + save)
   - PUT /:companyId/files/content (company file save)

3. Navbar: removed Skills/Jobs/Pipeline, added Workspace
4. All nav links open in new tabs (target=_blank)
server/routes/company.js
... ... @@ -1431,6 +1431,138 @@ router.get('/:companyId/files/content', (req, res) => {
1431 1431 }
1432 1432 });
1433 1433  
  1434 +// PUT /api/company/:companyId/files/content — 保存文件内容
  1435 +router.put('/:companyId/files/content', (req, res) => {
  1436 + try {
  1437 + const DATA_DIR = path.resolve(__dirname, '../../data/companies');
  1438 + const filePath = req.body.path;
  1439 + if (!filePath) return res.status(400).json({ error: 'path 参数为必填' });
  1440 +
  1441 + const absPath = path.resolve(DATA_DIR, req.params.companyId, filePath);
  1442 + const safeBoundary = path.resolve(DATA_DIR, req.params.companyId);
  1443 + if (!absPath.startsWith(safeBoundary)) {
  1444 + return res.status(403).json({ error: '路径越界' });
  1445 + }
  1446 +
  1447 + const dir = path.dirname(absPath);
  1448 + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  1449 +
  1450 + fs.writeFileSync(absPath, req.body.content || '', 'utf-8');
  1451 + console.log(`[Company] 💾 已保存文件 ${req.params.companyId}/${filePath}`);
  1452 + res.json({ success: true, path: filePath });
  1453 + } catch (err) {
  1454 + res.status(500).json({ error: err.message });
  1455 + }
  1456 +});
  1457 +
  1458 +// ═══════════════════════════════════════
  1459 +// Company Workspace — 工作区文件管理
  1460 +// ═══════════════════════════════════════
  1461 +
  1462 +// GET /api/company/:companyId/workspace/files — 列出 workspace 文件树
  1463 +router.get('/:companyId/workspace/files', (req, res) => {
  1464 + try {
  1465 + const DATA_DIR = path.resolve(__dirname, '../../data/companies');
  1466 + const wsDir = path.join(DATA_DIR, req.params.companyId, 'workspace');
  1467 +
  1468 + if (!fs.existsSync(wsDir)) {
  1469 + return res.json({ tree: [], totalFiles: 0 });
  1470 + }
  1471 +
  1472 + function scanDir(dirPath, relativePath = '') {
  1473 + const entries = fs.readdirSync(dirPath, { withFileTypes: true });
  1474 + const nodes = [];
  1475 + for (const entry of entries) {
  1476 + const fullPath = path.join(dirPath, entry.name);
  1477 + const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
  1478 + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
  1479 +
  1480 + if (entry.isDirectory()) {
  1481 + const children = scanDir(fullPath, relPath);
  1482 + nodes.push({
  1483 + name: entry.name, path: relPath, type: 'directory', children,
  1484 + fileCount: children.reduce((sum, c) => sum + (c.type === 'file' ? 1 : (c.fileCount || 0)), 0),
  1485 + });
  1486 + } else {
  1487 + const stat = fs.statSync(fullPath);
  1488 + nodes.push({
  1489 + name: entry.name, path: relPath, type: 'file',
  1490 + size: stat.size,
  1491 + createdAt: stat.birthtime.toISOString(),
  1492 + modifiedAt: stat.mtime.toISOString(),
  1493 + });
  1494 + }
  1495 + }
  1496 + return nodes.sort((a, b) => {
  1497 + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
  1498 + return a.name.localeCompare(b.name);
  1499 + });
  1500 + }
  1501 +
  1502 + const tree = scanDir(wsDir);
  1503 + const countFiles = (nodes) => nodes.reduce((sum, n) => sum + (n.type === 'file' ? 1 : countFiles(n.children || [])), 0);
  1504 + res.json({ tree, totalFiles: countFiles(tree) });
  1505 + } catch (err) {
  1506 + res.status(500).json({ error: err.message });
  1507 + }
  1508 +});
  1509 +
  1510 +// GET /api/company/:companyId/workspace/files/content?path=xxx — 读取 workspace 文件内容
  1511 +router.get('/:companyId/workspace/files/content', (req, res) => {
  1512 + try {
  1513 + const DATA_DIR = path.resolve(__dirname, '../../data/companies');
  1514 + const filePath = String(req.query.path || '');
  1515 + if (!filePath) return res.status(400).json({ error: 'path 参数为必填' });
  1516 +
  1517 + const absPath = path.resolve(DATA_DIR, req.params.companyId, 'workspace', filePath);
  1518 + const safeBoundary = path.resolve(DATA_DIR, req.params.companyId, 'workspace');
  1519 + if (!absPath.startsWith(safeBoundary)) {
  1520 + return res.status(403).json({ error: '路径越界' });
  1521 + }
  1522 + if (!fs.existsSync(absPath)) {
  1523 + return res.status(404).json({ error: '文件不存在' });
  1524 + }
  1525 +
  1526 + const stat = fs.statSync(absPath);
  1527 + if (stat.size > 512 * 1024) {
  1528 + return res.json({ content: '(文件过大,跳过预览)', truncated: true, size: stat.size });
  1529 + }
  1530 +
  1531 + const content = fs.readFileSync(absPath, 'utf-8');
  1532 + res.json({
  1533 + content, size: stat.size, path: filePath,
  1534 + createdAt: stat.birthtime.toISOString(),
  1535 + modifiedAt: stat.mtime.toISOString(),
  1536 + });
  1537 + } catch (err) {
  1538 + res.status(500).json({ error: err.message });
  1539 + }
  1540 +});
  1541 +
  1542 +// PUT /api/company/:companyId/workspace/files/content — 保存 workspace 文件
  1543 +router.put('/:companyId/workspace/files/content', (req, res) => {
  1544 + try {
  1545 + const DATA_DIR = path.resolve(__dirname, '../../data/companies');
  1546 + const filePath = req.body.path;
  1547 + if (!filePath) return res.status(400).json({ error: 'path 参数为必填' });
  1548 +
  1549 + const absPath = path.resolve(DATA_DIR, req.params.companyId, 'workspace', filePath);
  1550 + const safeBoundary = path.resolve(DATA_DIR, req.params.companyId, 'workspace');
  1551 + if (!absPath.startsWith(safeBoundary)) {
  1552 + return res.status(403).json({ error: '路径越界' });
  1553 + }
  1554 +
  1555 + const dir = path.dirname(absPath);
  1556 + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  1557 +
  1558 + fs.writeFileSync(absPath, req.body.content || '', 'utf-8');
  1559 + console.log(`[Workspace] 💾 已保存 ${req.params.companyId}/workspace/${filePath}`);
  1560 + res.json({ success: true, path: filePath });
  1561 + } catch (err) {
  1562 + res.status(500).json({ error: err.message });
  1563 + }
  1564 +});
  1565 +
1434 1566 // ═══════════════════════════════════════
1435 1567 // Company Messages — 全局消息时间线
1436 1568 // ═══════════════════════════════════════
... ...
web/src/components/AppNav.vue
... ... @@ -5,21 +5,21 @@
5 5 <span class="nav-title">LemonBoss</span>
6 6 </div>
7 7 <div class="nav-links">
8   - <router-link to="/agent/master" class="nav-link" :class="{ active: $route.path.startsWith('/agent') }">
  8 + <a href="/agent/master" target="_blank" class="nav-link" :class="{ active: $route.path.startsWith('/agent') }">
9 9 <span>🤖</span> System Agents
10   - </router-link>
  10 + </a>
11 11  
12   - <router-link to="/templates" class="nav-link" :class="{ active: $route.path.startsWith('/templates') }">
  12 + <a href="/templates" target="_blank" class="nav-link" :class="{ active: $route.path.startsWith('/templates') }">
13 13 <span>📦</span> Templates
14   - </router-link>
  14 + </a>
15 15  
16   - <router-link to="/tasks" class="nav-link" :class="{ active: $route.path.startsWith('/tasks') }">
  16 + <a href="/tasks" target="_blank" class="nav-link" :class="{ active: $route.path.startsWith('/tasks') }">
17 17 <span>📜</span> Task Logs
18   - </router-link>
  18 + </a>
19 19  
20   - <router-link to="/quota" class="nav-link" :class="{ active: $route.path === '/quota' }">
  20 + <a href="/quota" target="_blank" class="nav-link" :class="{ active: $route.path === '/quota' }">
21 21 <span>📊</span> Quota
22   - </router-link>
  22 + </a>
23 23  
24 24 <div class="nav-divider"></div>
25 25  
... ... @@ -49,45 +49,27 @@
49 49  
50 50 <!-- Contextual Company Navigation -->
51 51 <template v-if="currentCompany">
52   - <router-link :to="`/${currentCompany.id}/overview`" class="nav-link" :class="{ active: $route.params.tab === 'overview' }">
  52 + <a :href="`/${currentCompany.id}/overview`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'overview' }">
53 53 📊 Overview
54   - </router-link>
55   - <router-link :to="`/${currentCompany.id}/agents`" class="nav-link" :class="{ active: $route.params.tab === 'agents' }">
  54 + </a>
  55 + <a :href="`/${currentCompany.id}/agents`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'agents' }">
56 56 👥 Team
57   - </router-link>
58   - <router-link :to="`/${currentCompany.id}/pipelines`" class="nav-link" :class="{ active: $route.params.tab === 'pipelines' || $route.params.tab === 'sop' }">
  57 + </a>
  58 + <a :href="`/${currentCompany.id}/workspace`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'workspace' }">
  59 + 💼 Workspace
  60 + </a>
  61 + <a :href="`/${currentCompany.id}/pipelines`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'pipelines' || $route.params.tab === 'sop' }">
59 62 ⚙️ SOP
60   - </router-link>
61   - <router-link :to="`/${currentCompany.id}/goals`" class="nav-link" :class="{ active: $route.params.tab === 'goals' || $route.params.tab === 'okr' }">
  63 + </a>
  64 + <a :href="`/${currentCompany.id}/goals`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'goals' || $route.params.tab === 'okr' }">
62 65 🎯 OKR
63   - </router-link>
64   - <router-link :to="`/${currentCompany.id}/files`" class="nav-link" :class="{ active: $route.params.tab === 'files' }">
  66 + </a>
  67 + <a :href="`/${currentCompany.id}/files`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'files' }">
65 68 📁 Files
66   - </router-link>
67   - <router-link :to="`/${currentCompany.id}/messages`" class="nav-link" :class="{ active: $route.params.tab === 'messages' }">
  69 + </a>
  70 + <a :href="`/${currentCompany.id}/messages`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'messages' }">
68 71 💬 Messages
69   - </router-link>
70   - <router-link :to="`/${currentCompany.id}/skills`" class="nav-link" :class="{ active: $route.params.tab === 'skills' }">
71   - 🧪 Skills
72   - </router-link>
73   - <router-link :to="`/${currentCompany.id}/jobs`" class="nav-link" :class="{ active: $route.params.tab === 'jobs' }">
74   - 📋 Jobs
75   - </router-link>
76   - <router-link to="/pipelines" class="nav-link" :class="{ active: $route.path === '/pipelines' }">
77   - 🔗 Pipeline
78   - </router-link>
79   - </template>
80   - <!-- Global Links (no company) -->
81   - <template v-else>
82   - <router-link to="/skills" class="nav-link" :class="{ active: $route.path === '/skills' }">
83   - 🛒 Skills 市场
84   - </router-link>
85   - <router-link to="/jobs" class="nav-link" :class="{ active: $route.path === '/jobs' }">
86   - 📋 任务面板
87   - </router-link>
88   - <router-link to="/pipelines" class="nav-link" :class="{ active: $route.path === '/pipelines' }">
89   - 🔗 Pipeline 编辑器
90   - </router-link>
  72 + </a>
91 73 </template>
92 74 </div>
93 75  
... ...
web/src/router/index.js
... ... @@ -33,7 +33,7 @@ const routes = [
33 33 component: () => import('@/views/CompanyDashboard.vue'),
34 34 },
35 35 {
36   - path: '/:id/:tab(overview|agents|pipelines|goals|sop|okr|team|files|messages|skills|jobs)',
  36 + path: '/:id/:tab(overview|agents|pipelines|goals|sop|okr|team|files|messages|skills|jobs|workspace)',
37 37 name: 'CompanyDetail',
38 38 component: () => import('@/views/CompanyDashboard.vue'),
39 39 props: true
... ...
web/src/views/CompanyDashboard.vue
... ... @@ -445,6 +445,95 @@
445 445 </div>
446 446 </div>
447 447  
  448 + <!-- Tab: Workspace -->
  449 + <div v-if="activeTab === 'workspace'" class="tab-content">
  450 + <div class="ws-layout">
  451 + <!-- Left: File Tree -->
  452 + <div class="ws-tree-panel">
  453 + <div class="panel-title">
  454 + 💼 Workspace
  455 + <span class="file-count-badge">{{ wsFiles?.totalFiles || 0 }} 文件</span>
  456 + <button class="refresh-btn" @click="loadWorkspaceFiles(selectedCompany.id)" title="刷新">🔄</button>
  457 + </div>
  458 + <div class="ws-tree-scroll">
  459 + <div v-if="wsFiles?.tree?.length > 0">
  460 + <template v-for="node in wsFiles.tree" :key="node.path">
  461 + <CompanyFileNode :node="node" :depth="0" :selected="wsSelectedPath" @select="selectWorkspaceFile" />
  462 + </template>
  463 + </div>
  464 + <div v-else class="empty-mini">
  465 + <div style="padding:24px; text-align:center; opacity:0.6;">
  466 + <div style="font-size:32px; margin-bottom:8px;">📂</div>
  467 + <div>Workspace 为空</div>
  468 + <div style="font-size:11px; margin-top:4px;">Agent 工作产出将出现在这里</div>
  469 + </div>
  470 + </div>
  471 + </div>
  472 + </div>
  473 + <!-- Right: Detail + Editor -->
  474 + <div class="ws-detail-panel">
  475 + <template v-if="wsSelectedFile">
  476 + <!-- File Info Bar -->
  477 + <div class="ws-file-info">
  478 + <div class="ws-file-info-header">
  479 + <span class="ws-file-icon">{{ wsFileIcon(wsSelectedFile.name) }}</span>
  480 + <div class="ws-file-name-area">
  481 + <span class="ws-file-name">{{ wsSelectedFile.name }}</span>
  482 + <span class="ws-file-path">workspace/{{ wsSelectedFile.path }}</span>
  483 + </div>
  484 + <div class="ws-file-actions">
  485 + <button v-if="wsViewMode === 'edit'" class="btn-ws-save" @click="saveWorkspaceFile" :disabled="wsSaving">
  486 + {{ wsSaving ? '⏳ 保存中...' : '💾 保存' }}
  487 + </button>
  488 + </div>
  489 + </div>
  490 + <div class="ws-file-meta">
  491 + <span class="ws-meta-item">📏 {{ formatFileSize(wsSelectedFile.size) }}</span>
  492 + <span class="ws-meta-item">📅 创建: {{ formatDate(wsSelectedFile.createdAt) }}</span>
  493 + <span class="ws-meta-item">🔄 修改: {{ formatDate(wsSelectedFile.modifiedAt) }}</span>
  494 + </div>
  495 + <!-- View Mode Tabs -->
  496 + <div class="ws-view-tabs">
  497 + <button class="ws-view-tab" :class="{ active: wsViewMode === 'code' }" @click="wsViewMode = 'code'">
  498 + 📄 代码
  499 + </button>
  500 + <button v-if="wsIsHtml" class="ws-view-tab" :class="{ active: wsViewMode === 'preview' }" @click="wsViewMode = 'preview'">
  501 + 🌐 预览
  502 + </button>
  503 + <button class="ws-view-tab" :class="{ active: wsViewMode === 'edit' }" @click="wsViewMode = 'edit'">
  504 + ✏️ 编辑
  505 + </button>
  506 + </div>
  507 + </div>
  508 + <!-- File Content -->
  509 + <div class="ws-content-area">
  510 + <!-- Code View -->
  511 + <pre v-if="wsViewMode === 'code'" class="ws-preview-code">{{ wsContent }}</pre>
  512 + <!-- Edit View -->
  513 + <textarea
  514 + v-else-if="wsViewMode === 'edit'"
  515 + v-model="wsContent"
  516 + class="ws-editor"
  517 + spellcheck="false"
  518 + ></textarea>
  519 + <!-- HTML Preview -->
  520 + <iframe
  521 + v-else-if="wsViewMode === 'preview'"
  522 + class="ws-html-preview"
  523 + :srcdoc="wsContent"
  524 + sandbox="allow-scripts allow-same-origin"
  525 + ></iframe>
  526 + </div>
  527 + </template>
  528 + <div v-else class="ws-empty-state">
  529 + <div style="font-size:48px; margin-bottom:12px; opacity:0.7;">💼</div>
  530 + <h3>Workspace 文件浏览器</h3>
  531 + <p>从左侧选择一个文件查看详情与内容</p>
  532 + </div>
  533 + </div>
  534 + </div>
  535 + </div>
  536 +
448 537 <!-- Tab: Skills -->
449 538 <div v-if="activeTab === 'skills'" class="tab-content">
450 539 <div class="section-header">
... ... @@ -667,6 +756,19 @@ const selectedSkillDetail = ref(null)
667 756 const companyJobs = ref([])
668 757 const jobStatusFilter = ref('')
669 758  
  759 +// Workspace tab
  760 +const wsFiles = ref(null)
  761 +const wsSelectedPath = ref(null)
  762 +const wsSelectedFile = ref(null)
  763 +const wsContent = ref('')
  764 +const wsViewMode = ref('code') // 'code' | 'edit' | 'preview'
  765 +const wsSaving = ref(false)
  766 +
  767 +const wsIsHtml = computed(() => {
  768 + if (!wsSelectedFile.value?.name) return false
  769 + return /\.(html?|htm)$/i.test(wsSelectedFile.value.name)
  770 +})
  771 +
670 772 function statusLabel(status) {
671 773 const map = { creating: '创建中', running: '运行中', paused: '已暂停', shutdown: '已关停' }
672 774 return map[status] || status
... ... @@ -708,6 +810,7 @@ async function loadCompanyData(id, tab) {
708 810 if (activeTab.value === 'messages') await loadMessages(id)
709 811 if (activeTab.value === 'skills') await loadSkillsList(id)
710 812 if (activeTab.value === 'jobs') await loadCompanyJobs(id)
  813 + if (activeTab.value === 'workspace') await loadWorkspaceFiles(id)
711 814 } catch (e) { console.error(e) }
712 815 }
713 816  
... ... @@ -946,6 +1049,68 @@ function viewJobDetail(job) {
946 1049 alert(`任务详情:\nID: ${job.id}\n状态: ${job.status}\n创建: ${formatDate(job.createdAt)}`)
947 1050 }
948 1051  
  1052 +// ═══ Workspace Tab Functions ═══
  1053 +async function loadWorkspaceFiles(id) {
  1054 + try {
  1055 + const { data } = await axios.get(`/api/company/${id}/workspace/files`)
  1056 + wsFiles.value = data
  1057 + } catch (e) { console.error(e) }
  1058 +}
  1059 +
  1060 +async function selectWorkspaceFile(filePath) {
  1061 + if (!selectedCompany.value) return
  1062 + wsSelectedPath.value = filePath
  1063 + wsViewMode.value = 'code'
  1064 + try {
  1065 + const { data } = await axios.get(`/api/company/${selectedCompany.value.id}/workspace/files/content`, { params: { path: filePath } })
  1066 + wsContent.value = data.content
  1067 + wsSelectedFile.value = {
  1068 + name: filePath.split('/').pop(),
  1069 + path: filePath,
  1070 + size: data.size,
  1071 + createdAt: data.createdAt,
  1072 + modifiedAt: data.modifiedAt,
  1073 + }
  1074 + // HTML 文件自动切到预览
  1075 + if (/\.(html?|htm)$/i.test(filePath)) {
  1076 + wsViewMode.value = 'preview'
  1077 + }
  1078 + } catch {
  1079 + wsContent.value = '(加载失败)'
  1080 + wsSelectedFile.value = null
  1081 + }
  1082 +}
  1083 +
  1084 +async function saveWorkspaceFile() {
  1085 + if (!selectedCompany.value || !wsSelectedPath.value) return
  1086 + wsSaving.value = true
  1087 + try {
  1088 + await axios.put(`/api/company/${selectedCompany.value.id}/workspace/files/content`, {
  1089 + path: wsSelectedPath.value,
  1090 + content: wsContent.value,
  1091 + })
  1092 + // Refresh file info
  1093 + await selectWorkspaceFile(wsSelectedPath.value)
  1094 + wsViewMode.value = 'code'
  1095 + } catch (e) {
  1096 + alert(`❌ 保存失败: ${e.message}`)
  1097 + }
  1098 + wsSaving.value = false
  1099 +}
  1100 +
  1101 +function wsFileIcon(name) {
  1102 + if (!name) return '📄'
  1103 + const ext = name.split('.').pop()?.toLowerCase()
  1104 + const icons = {
  1105 + json: '📋', md: '📝', js: '⚡', ts: '🔷', py: '🐍',
  1106 + html: '🌐', css: '🎨', sh: '⚙️', yml: '📐', yaml: '📐',
  1107 + txt: '📄', log: '📜', csv: '📊', xml: '📰',
  1108 + jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', svg: '🖼️',
  1109 + mp4: '🎬', mp3: '🎵', pdf: '📕', zip: '📦',
  1110 + }
  1111 + return icons[ext] || '📄'
  1112 +}
  1113 +
949 1114 // switchTab removed, handled by goToTab and router watcher
950 1115  
951 1116 // ═══ Files Tab Functions ═══
... ... @@ -1678,6 +1843,103 @@ function closeDropdown() {
1678 1843 flex-shrink: 0;
1679 1844 }
1680 1845  
  1846 +/* ═══ Workspace Tab ═══ */
  1847 +.ws-layout {
  1848 + display: flex; gap: 0; height: calc(100vh - 200px);
  1849 + border: 1px solid var(--border-primary); border-radius: 12px; overflow: hidden;
  1850 + background: var(--bg-surface);
  1851 +}
  1852 +.ws-tree-panel {
  1853 + width: 300px; min-width: 240px; border-right: 1px solid var(--border-primary);
  1854 + display: flex; flex-direction: column; background: var(--bg-overlay);
  1855 +}
  1856 +.ws-tree-scroll { flex: 1; overflow-y: auto; padding: 6px; }
  1857 +.ws-detail-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; }
  1858 +.ws-file-info {
  1859 + padding: 12px 16px; border-bottom: 1px solid var(--border-primary);
  1860 + background: var(--bg-overlay);
  1861 +}
  1862 +.ws-file-info-header {
  1863 + display: flex; align-items: center; gap: 10px;
  1864 +}
  1865 +.ws-file-icon { font-size: 28px; flex-shrink: 0; }
  1866 +.ws-file-name-area { flex: 1; min-width: 0; }
  1867 +.ws-file-name {
  1868 + font-size: 15px; font-weight: 700; color: var(--text-primary);
  1869 + display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  1870 +}
  1871 +.ws-file-path {
  1872 + font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace;
  1873 +}
  1874 +.ws-file-actions { display: flex; gap: 6px; flex-shrink: 0; }
  1875 +.btn-ws-save {
  1876 + padding: 5px 14px; border-radius: 8px; font-size: 12px; font-weight: 700;
  1877 + border: 1px solid rgba(16,185,129,0.3); background: rgba(16,185,129,0.1);
  1878 + color: #34d399; cursor: pointer; transition: all 0.2s;
  1879 +}
  1880 +.btn-ws-save:hover:not(:disabled) { background: rgba(16,185,129,0.2); box-shadow: 0 2px 8px rgba(16,185,129,0.15); }
  1881 +.btn-ws-edit {
  1882 + padding: 5px 14px; border-radius: 8px; font-size: 12px; font-weight: 700;
  1883 + border: 1px solid var(--border-primary); background: var(--bg-surface);
  1884 + color: var(--text-secondary); cursor: pointer; transition: all 0.2s;
  1885 +}
  1886 +.btn-ws-edit:hover { background: var(--bg-surface-hover); border-color: var(--border-active); }
  1887 +.ws-file-meta {
  1888 + display: flex; gap: 16px; margin-top: 8px; flex-wrap: wrap;
  1889 +}
  1890 +.ws-meta-item {
  1891 + font-size: 11px; color: var(--text-muted); font-weight: 600;
  1892 + padding: 2px 8px; background: var(--bg-surface); border-radius: 6px;
  1893 + border: 1px solid var(--border-primary);
  1894 +}
  1895 +.ws-content-area { flex: 1; overflow: auto; position: relative; }
  1896 +.ws-editor {
  1897 + width: 100%; height: 100%; border: none; resize: none; padding: 12px 16px;
  1898 + font-family: 'JetBrains Mono', 'Menlo', monospace; font-size: 12px;
  1899 + line-height: 1.7; color: var(--text-primary); background: var(--bg-primary);
  1900 + outline: none; tab-size: 2;
  1901 +}
  1902 +.ws-preview-code {
  1903 + font-family: 'JetBrains Mono', 'Menlo', monospace; font-size: 12px;
  1904 + line-height: 1.7; color: var(--text-secondary); white-space: pre-wrap;
  1905 + word-break: break-all; margin: 0; padding: 12px 16px;
  1906 +}
  1907 +.ws-empty-state {
  1908 + flex: 1; display: flex; flex-direction: column; align-items: center;
  1909 + justify-content: center; color: var(--text-muted); text-align: center; padding: 40px;
  1910 +}
  1911 +.ws-empty-state h3 { font-size: 16px; margin: 0 0 4px; color: var(--text-secondary); }
  1912 +.ws-empty-state p { font-size: 13px; margin: 0; opacity: 0.7; }
  1913 +
  1914 +/* View Mode Tabs */
  1915 +.ws-view-tabs {
  1916 + display: flex; gap: 2px; margin-top: 10px;
  1917 + background: var(--bg-surface); border-radius: 8px; padding: 3px;
  1918 + border: 1px solid var(--border-primary);
  1919 +}
  1920 +.ws-view-tab {
  1921 + padding: 5px 14px; border-radius: 6px; font-size: 12px; font-weight: 700;
  1922 + border: none; background: transparent; color: var(--text-muted);
  1923 + cursor: pointer; transition: all 0.2s; flex: 1; text-align: center;
  1924 +}
  1925 +.ws-view-tab:hover { color: var(--text-secondary); background: var(--bg-surface-hover); }
  1926 +.ws-view-tab.active {
  1927 + background: var(--accent-bg, rgba(59,130,246,0.12)); color: var(--accent);
  1928 + box-shadow: 0 1px 4px rgba(0,0,0,0.08);
  1929 +}
  1930 +
  1931 +/* HTML Preview iframe */
  1932 +.ws-html-preview {
  1933 + width: 100%; height: 100%; border: none;
  1934 + background: #fff; border-radius: 0;
  1935 +}
  1936 +
  1937 +.refresh-btn {
  1938 + background: none; border: none; cursor: pointer; font-size: 13px;
  1939 + padding: 2px 4px; border-radius: 4px; transition: all 0.2s;
  1940 +}
  1941 +.refresh-btn:hover { background: var(--bg-surface-hover); transform: scale(1.1); }
  1942 +
1681 1943 /* ═══ Messages Tab ═══ */
1682 1944 .msg-controls {
1683 1945 display: flex; gap: 8px; align-items: center;
... ...