feat(ui): 侧边栏项目树(项目/其他会话)+配置按钮+追加项目; 气泡与输入框对齐; 空态发送后清除; 移除底部新建项目与项目管理菜单

This commit is contained in:
lhl
2026-09-12 17:09:17 +08:00
parent 7b8ffea564
commit 2def01563e
6 changed files with 318 additions and 74 deletions
@@ -0,0 +1,99 @@
# 侧边栏项目树 + 消息区对齐 + 空态清理 — 实施计划
配套需求:用户 7 条前端优化(侧边栏信息架构重构 + 气泡对齐 + 空态清理)
目标文件:`src/genesis/server/static/{chat.html,chat.css,chat.js,chat_state.js}`
前端无框架、无构建;后端零改动。
## 已确认决策
- D1 气泡对齐到**文本输入框**:左 16px;右留发送按钮宽度(40+10=50px);**删除 assistant 头像点**。
- D2 点击侧边栏项目名 = **选中该项目(设为当前项目)+ 展开/收起**
- D3 抽屉左侧项目列表**保留**(仅删除「+新建项目」项)。
- D4 侧边栏折叠态**隐藏「项目/其他会话」两段**。
- D5 req2 的「提示语及提示词」= 空态主标题 + 建议卡,发送后一并清除。
## 目标侧边栏
```
[G Genesis 概要设计书做成Agent]
[+ 新会话]
项目 + ← 仅"+"hover「追加项目」
▾ 股票系统 ⚙ ← 展开显示其会话;⚙ hover「配置」
· 需求A
· 需求B
▾ 人事系统 ⚙
· 需求C
其他会话
· 需求Dproject=""
```
---
## Task 1chat_state.js 分组纯函数 + node 测试
- 新增 `groupSessionsByProject(sessions)``{ byProject: { "<name>": [s...] }, other: [s...] }``project` 为空者入 `other`)。
- `tests/test_chat_state.js` 增加断言:空项目归 other;两项目分别归组。
- 运行 `node --test tests/test_chat_state.js`
## Task 2chat.html 侧边栏结构 + 移除底部新建项目按钮
- 删除 `<h2>会话历史</h2>``<div id="session-list"></div>`
- 新增 `#sec-projects``side-head`:标题「项目」+ `#side-add-project`「+」)与 `#project-tree`
- 新增 `#sec-others`(标题「其他会话」)与 `#other-session-list`
- 删除 `#tb-newproject` 按钮。
## Task 3chat.css 侧边栏树 + 消息区对齐 + 折叠态
- 新增 `.side-section/.side-head/.side-title/.side-add/.proj-group/.proj-row/.proj-caret/.proj-name/.proj-config/.proj-sessions` 样式;嵌套 `.sess` 缩进。
- 折叠态:`#sidebar.collapsed .side-section { display: none; }`
- 消息区:`#chat { max-width: none; margin: 0; padding: var(--sp-6) var(--sp-4); }``.msg { padding-right: 50px; }``.msg .bubble { max-width: 100%; }`**删除 `.msg.assistant::before`**。
## Task 4chat.js 分组渲染 + 绑定调整 + 空态清理
- `refreshSessions()` 改拉全量并按 `groupSessionsByProject` 分组 → `renderProjectTree()` + `renderOtherSessions()`
-`renderSessItem(s)`(复用现有会话行逻辑)。
- `renderProjectTree()`:项目行点击选中+展开/收起(`collapsedProjects` SetlocalStorage 持久化);`⚙``openProjectDrawer(name)`
- `#side-add-project``openProjectDrawer(null)`(带脏守卫)。
- `renderProjectSwitcher()`:删除 `ps-manage`「项目管理(打开抽屉)」项及绑定。
- `renderDrawerList()`:删除 `di-create`「+新建项目」项。
- `send()`:发消息前 `chatEl.querySelector('.empty-state')?.remove()`
- 删除 `#tb-newproject` 绑定。
## Task 5:验证与提交
- `node --check``node --test``python -m pytest`(≥99%)。
- 手动核对:气泡对齐、空态清理、项目树/⚙/+、底部无新建项目、未选项目时新会话入「其他会话」。
- 更新 `_AI_USAGE_LOG.md` 并提交。
## 风险
- `#sidebar.collapsed``.sess` 旧样式成为死代码(无害)。
- 分组渲染依赖 `GET /api/sessions` 全量返回(已支持 `project` 可选)。
- 若某会话的 project 已从项目列表删除,仍会以其 project 名建组渲染(保证不丢会话)。
---
## 评审结论与计划调整(plan-eng-review
### 采纳的修正
- **F1(正确性,req 5)显式「不选择项目」持久化**
- `chat_state.js``autoBindProject(draftProject, projects, explicitNone = false)` —— `explicitNone` 为真时原样返回 `draftProject`(不移除用户选择);否则维持原自动绑定逻辑。新增 node 断言。
- `chat.js`:读写 `localStorage['genesis_project_explicit_none']``loadProjects()` 传入该标记;选任意项目 → 清除标记;选「不选择项目」→ 设标记。保证「无项目选择 → 新会话进『其他会话』」成立。
- **F2(UX)打开会话所在分组高亮**
- `renderProjectTree()`:组内任一会话 `session_id === sid` 时给 `.proj-group``.has-active`(与 `.current` = 选中项目 区分)。
- **F3(DRY)对齐余量单一来源**
- 新增 CSS 变量:`--send-size: 40px``--composer-gap: 10px``--composer-gutter: calc(var(--send-size) + var(--composer-gap))`
- `#send { width/height: var(--send-size) }``#composer-inner { gap: var(--composer-gap) }``.msg { padding-right: var(--composer-gutter) }`
- **F6(清理)删除折叠态死样式**
- 移除 `#sidebar.collapsed .sess*``#sidebar h2` 相关规则(折叠态整段隐藏、h2 已不存在)。
### 接受现状(标注,不改)
- F4 project 已删的会话以原 project 名成组渲染(不丢会话)。
- F5 DOM 渲染层无自动化测试;靠纯函数 `groupSessionsByProject` 断言 + `node --check` + 手动。
- F7 项目行沿用 `div.onclick`(非键盘可达),与会话行一致。
- F8 `refreshSessions` 每次拉全量;规模增大时再考虑限制。
### 受影响任务更新
- Task 1 追加:`autoBindProject` 第三参数 + `groupSessionsByProject` + node 断言。
- Task 3 追加:`--send-size/--composer-gap/--composer-gutter` 变量;删除折叠死样式。
- Task 4 追加:`explicit_none` 读写与联动;`.has-active` 渲染。
## GSTACK REVIEW REPORT
- STATUS: DONE_WITH_CONCERNS
- REVIEWED: docs/superpowers/plans/2026-09-12-sidebar-project-tree.md
- FINDINGS: F1 正确性(已采纳修复) / F2 UX(已采纳) / F3 DRY(已采纳) / F6 清理(已采纳)F4/F5/F7/F8 接受
- KEY DECISION: 显式「不选择项目」持久化修复(否则 req 5 不成立)
- CONCERN: 渲染层无自动化测试(无 DOM harness);折叠态样式清理后需回归核对
- NEXT: 待用户批准后实施
+51 -27
View File
@@ -37,6 +37,11 @@
/* === 动效 (spec §一) === */
--ease: cubic-bezier(.2, 0, 0, 1);
--dur: .18s;
/* 输入区几何(单一来源,供消息区对齐) */
--send-size: 40px;
--composer-gap: 10px;
--composer-gutter: calc(var(--send-size) + var(--composer-gap));
}
/* === 全局 === */
@@ -64,7 +69,6 @@ body {
}
#sidebar.collapsed { width: 56px; }
#sidebar.collapsed #sidebar-toggle { /* 折叠态位置不再切换,按钮统一在侧边栏内下边 */ }
#sidebar.collapsed #session-list { padding-bottom: 52px; }
.brand {
display: flex; align-items: center; gap: 12px;
padding: 16px 12px 12px 16px; margin: 0 8px 8px;
@@ -104,13 +108,47 @@ body {
#sidebar.collapsed #new-chat { margin: var(--sp-2) 10px var(--sp-3); padding: 10px 0; }
#new-chat .ic-plus { display: inline-block; font-weight: 900; }
#sidebar.collapsed #new-chat .nc-text { display: none; }
#sidebar h2 {
font-family: var(--font-mono);
font-size: 11px; font-weight: 500; letter-spacing: .12em; text-transform: uppercase;
color: var(--text-muted); margin: var(--sp-3) var(--sp-4) var(--sp-2);
/* 侧边栏分区(项目 / 其他会话) */
.side-section { display: flex; flex-direction: column; min-height: 0; }
#sidebar.collapsed .side-section { display: none; }
.side-head {
display: flex; align-items: center; justify-content: space-between;
margin: var(--sp-3) var(--sp-4) var(--sp-2);
}
#sidebar.collapsed h2 { display: none; }
#session-list { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
.side-title {
font-family: var(--font-mono); font-size: 11px; font-weight: 500;
letter-spacing: .12em; text-transform: uppercase; color: var(--text-muted);
}
.side-add {
background: transparent; border: 1px solid var(--border-strong); border-radius: var(--r-sm);
color: var(--text-secondary); cursor: pointer; width: 22px; height: 22px; line-height: 1;
font-size: 15px; display: flex; align-items: center; justify-content: center;
transition: all var(--dur) var(--ease);
}
.side-add:hover { border-color: var(--accent); color: var(--accent); }
#project-tree, #other-session-list { padding: 0 8px 8px; overflow-y: auto; }
#project-tree { flex: 0 1 auto; }
#sec-others { flex: 1; min-height: 0; }
/* 项目分组 */
.proj-group { margin: 2px 0; }
.proj-row {
display: flex; align-items: center; gap: 6px; padding: 7px 8px; border-radius: var(--r-md);
cursor: pointer; transition: all var(--dur) var(--ease); border: 1px solid transparent;
}
.proj-row:hover { background: var(--bg-elevated); }
.proj-group.current .proj-row { background: var(--bg-elevated); border-color: var(--accent); }
.proj-group.has-active .proj-row .proj-name { color: var(--accent); font-weight: 700; }
.proj-caret { font-size: 10px; color: var(--text-muted); width: 12px; flex-shrink: 0; }
.proj-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 13px; color: var(--text-primary); }
.proj-config {
flex-shrink: 0; opacity: 0; transition: opacity .15s ease; background: transparent;
border: none; color: var(--text-muted); cursor: pointer; width: 22px; height: 22px; border-radius: 6px;
font-size: 13px; display: flex; align-items: center; justify-content: center;
}
.proj-row:hover .proj-config { opacity: .8; }
.proj-config:hover { opacity: 1 !important; background: var(--bg-base); color: var(--accent); }
.proj-sessions { padding-left: 14px; }
.sess {
padding: 9px 12px; border-radius: var(--r-md); margin: 2px 0;
cursor: pointer; position: relative; border: 1px solid transparent;
@@ -139,16 +177,7 @@ body {
}
.sess:hover .del { opacity: .7; }
.sess .del:hover { opacity: 1 !important; background: var(--danger); color: var(--bg-base); }
#sidebar.collapsed .sess { justify-content: center; padding: 9px 6px; }
#sidebar.collapsed .sess .info, #sidebar.collapsed .sess .del { display: none; }
#sidebar.collapsed .sess .avatar {
display: flex; align-items: center; justify-content: center;
width: 8px; height: 8px; border-radius: 50%;
background: var(--text-muted);
}
.sess .avatar { display: none; }
#sidebar.collapsed .sess .avatar { display: flex; }
#sidebar.collapsed .sess.active .avatar { background: var(--accent); box-shadow: 0 0 8px var(--accent-soft); }
/* === 主区 + 顶栏 (spec §2.2) === */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg-base); }
@@ -226,10 +255,10 @@ header h1 { font-size: 16px; font-weight: 700; letter-spacing: -.02em; margin: 0
#rag-bar button:hover:not(:disabled) { box-shadow: 0 0 16px var(--accent-soft); }
/* === 聊天区 (spec §2.3) === */
#chat { flex: 1; overflow-y: auto; padding: var(--sp-6) var(--sp-6) var(--sp-6); max-width: 880px; width: 100%; margin: 0 auto; }
.msg { display: flex; margin: var(--sp-3) 0; animation: zoomIn .25s var(--ease); position: relative; }
#chat { flex: 1; overflow-y: auto; padding: var(--sp-6) var(--sp-4); max-width: none; width: 100%; margin: 0; }
.msg { display: flex; margin: var(--sp-3) 0; padding-right: var(--composer-gutter); animation: zoomIn .25s var(--ease); position: relative; }
.msg .bubble {
max-width: 74%; padding: 11px 16px; border-radius: var(--r-lg);
max-width: 100%; padding: 11px 16px; border-radius: var(--r-lg);
white-space: pre-wrap; line-height: 1.65; font-size: 14px; letter-spacing: .01em;
}
.msg.user { justify-content: flex-end; }
@@ -247,11 +276,7 @@ header h1 { font-size: 16px; font-weight: 700; letter-spacing: -.02em; margin: 0
border-bottom-left-radius: 6px;
color: var(--text-primary);
}
.msg.assistant::before {
content: ""; width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
margin: 18px 10px 0 0; background: var(--accent);
box-shadow: 0 0 8px var(--accent-soft);
}
.msg.progress .bubble {
background: var(--bg-input); border: 1px dashed var(--accent-soft);
font-size: 13px; color: var(--text-secondary);
@@ -303,7 +328,7 @@ label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px
/* === 输入区 (spec §2.5) === */
#composer { border-top: none; background: transparent; padding: var(--sp-3) var(--sp-4) var(--sp-4); }
#composer-inner { max-width: none; margin: 0; display: flex; gap: 10px; align-items: flex-end; }
#composer-inner { max-width: none; margin: 0; display: flex; gap: var(--composer-gap); align-items: flex-end; }
#input {
flex: 1; border: 1px solid var(--border-strong); border-radius: var(--r-md);
padding: 11px 14px; font-size: 14px; outline: none; background: var(--bg-input);
@@ -315,7 +340,7 @@ label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px
#input::placeholder { color: var(--text-muted); }
#send {
background: var(--accent); color: var(--bg-base); border: none; border-radius: 50%;
width: 40px; height: 40px; padding: 0; cursor: pointer; flex-shrink: 0;
width: var(--send-size); height: var(--send-size); padding: 0; cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: all var(--dur) var(--ease);
}
@@ -464,5 +489,4 @@ label.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px
font-size: 13px; font-family: var(--font-ui); cursor: pointer; text-align: left; }
.sess-menu .sm-item:hover { background: var(--bg-elevated); color: var(--accent); }
.sess-menu .sm-ic { color: var(--accent); }
#sidebar.collapsed .sess .kebab { display: none; }
@media (max-width: 640px) { .empty-state .suggests { grid-template-columns: 1fr; } }
+11 -3
View File
@@ -14,8 +14,17 @@
<div class="brand-name">Genesis<span class="brand-sub">概要设计书做成Agent</span></div>
</div>
<button id="new-chat" title="新建会话"><span class="ic-plus">+</span><span class="nc-text">新会话</span></button>
<h2>会话历史</h2>
<div id="session-list"></div>
<div class="side-section" id="sec-projects">
<div class="side-head">
<span class="side-title">项目</span>
<button id="side-add-project" class="side-add" type="button" title="追加项目">+</button>
</div>
<div id="project-tree"></div>
</div>
<div class="side-section" id="sec-others">
<div class="side-head"><span class="side-title">其他会话</span></div>
<div id="other-session-list"></div>
</div>
</div>
<div id="main">
@@ -40,7 +49,6 @@
</button>
<div class="ps-menu" id="ps-menu" role="menu"></div>
</div>
<button class="tb-btn" id="tb-newproject" type="button" title="新建项目"><span class="tb-ic"></span><span class="tb-text">新建项目</span></button>
<button class="tb-btn" id="tb-upload" type="button" title="上传资料"><span class="tb-ic">📎</span><span class="tb-text">上传资料</span></button>
<div id="rag-bar" hidden>
<label class="rag-switch">
+125 -43
View File
@@ -62,39 +62,128 @@ async function api(method, url, body) {
// ---------- 会话列表 ----------
async function refreshSessions() {
const box = document.getElementById('session-list');
if (draftProject === null) { box.innerHTML = ''; return; } // 未选项目 → 无历史
const list = await api('GET', '/api/sessions?user_id=default&project=' + encodeURIComponent(draftProject));
let all = [];
try { all = await api('GET', '/api/sessions?user_id=default'); } catch (e) { all = []; }
all.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
const grouped = GenesisState.groupSessionsByProject(all);
renderProjectTree(grouped.byProject);
renderOtherSessions(grouped.other);
}
// 单个会话行(项目组 / 其他会话共用)
function renderSessItem(s) {
const el = document.createElement('div');
el.className = 'sess' + (s.session_id === sid ? ' active' : '');
const rawName = (s.name || '新').trim();
const avatar = rawName ? Array.from(rawName)[0] : '新';
const palette = ['#1a73e8', '#34a853', '#fbbc05', '#ea4335', '#8e24aa', '#0097a7', '#5f6368'];
let h = 0;
for (let i = 0; i < rawName.length; i++) h = (h * 31 + rawName.charCodeAt(i)) | 0;
const color = palette[Math.abs(h) % palette.length];
el.innerHTML =
'<div class="avatar" style="background:' + color + ';">' + esc(avatar) + '</div>' +
'<div class="info">' +
'<div class="name">' + esc(s.name || '新会话') + '</div>' +
'<div class="meta">' + esc(s.status) + '</div>' +
'</div>' +
'<button class="kebab" title="更多" aria-label="更多操作">⋯</button>' +
'<button class="del" title="删除会话" aria-label="删除会话 ' + esc(rawName) + '">×</button>';
el.querySelector('.info').onclick = () => loadSession(s.session_id);
const delBtn = el.querySelector('.del');
delBtn.onclick = (ev) => { ev.stopPropagation(); deleteSession(s.session_id); };
const kebab = el.querySelector('.kebab');
kebab.onclick = (ev) => openSessMenu(ev, s.session_id, s.name || '新会话', el);
return el;
}
// 项目展开/收起状态
let collapsedProjects = null;
function loadCollapsedProjects() {
if (collapsedProjects) return collapsedProjects;
collapsedProjects = new Set();
try {
const raw = localStorage.getItem('genesis_collapsed_projects');
if (raw) collapsedProjects = new Set(JSON.parse(raw));
} catch (e) { collapsedProjects = new Set(); }
return collapsedProjects;
}
function saveCollapsedProjects() {
try { localStorage.setItem('genesis_collapsed_projects', JSON.stringify(Array.from(loadCollapsedProjects()))); } catch (e) { /* 忽略 */ }
}
// 显式「不选择项目」标记(F1
const EXPLICIT_NONE_KEY = 'genesis_project_explicit_none';
function readExplicitNone() {
try { return localStorage.getItem(EXPLICIT_NONE_KEY) === '1'; } catch (e) { return false; }
}
function setExplicitNone(v) {
try { if (v) localStorage.setItem(EXPLICIT_NONE_KEY, '1'); else localStorage.removeItem(EXPLICIT_NONE_KEY); } catch (e) { /* 忽略 */ }
}
// 项目树渲染
function renderProjectTree(byProj) {
const box = document.getElementById('project-tree');
if (!box) return;
const collapsed = loadCollapsedProjects();
const names = [];
for (const p of projects) names.push(p.name);
for (const k of Object.keys(byProj || {})) { if (k && names.indexOf(k) < 0) names.push(k); }
box.innerHTML = '';
list.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''));
for (const s of list) {
const el = document.createElement('div');
el.className = 'sess' + (s.session_id === sid ? ' active' : '');
const proj = s.project ? ' · ' + esc(s.project) : '';
// P3‑1:取首字符(含中文/日文/英文)作 avatar,并用名字 hash 选色,避免重名混淆
const rawName = (s.name || '新').trim();
const avatar = rawName ? Array.from(rawName)[0] : '';
const palette = ['#1a73e8', '#34a853', '#fbbc05', '#ea4335', '#8e24aa', '#0097a7', '#5f6368'];
let h = 0;
for (let i = 0; i < rawName.length; i++) h = (h * 31 + rawName.charCodeAt(i)) | 0;
const color = palette[Math.abs(h) % palette.length];
el.innerHTML =
'<div class="avatar" style="background:' + color + ';">' + esc(avatar) + '</div>' +
'<div class="info">' +
'<div class="name">' + esc(s.name || '新会话') + '</div>' +
'<div class="meta">' + esc(s.status) + proj + '</div>' +
'</div>' +
'<button class="kebab" title="更多" aria-label="更多操作">⋯</button>' +
'<button class="del" title="删除会话" aria-label="删除会话 ' + esc(rawName) + '">×</button>';
el.querySelector('.info').onclick = () => loadSession(s.session_id);
const delBtn = el.querySelector('.del');
delBtn.onclick = (ev) => { ev.stopPropagation(); deleteSession(s.session_id); };
const kebab = el.querySelector('.kebab');
kebab.onclick = (ev) => openSessMenu(ev, s.session_id, s.name || '新会话', el);
box.appendChild(el);
for (const name of names) {
const sessList = (byProj && byProj[name]) || [];
const isCurrent = (draftProject === name);
const hasActive = sessList.some(s => s.session_id === sid);
const isCollapsed = collapsed.has(name);
const disp = (((projects || []).find(p => p.name === name)) || {}).display_name || name;
const grp = document.createElement('div');
grp.className = 'proj-group' + (isCurrent ? ' current' : '') + (hasActive ? ' has-active' : '');
const row = document.createElement('div');
row.className = 'proj-row';
row.innerHTML = '<span class="proj-caret">' + (isCollapsed ? '▸' : '▾') + '</span>'
+ '<span class="proj-name">' + esc(disp) + '</span>'
+ '<button class="proj-config" type="button" title="配置">⚙</button>';
grp.appendChild(row);
if (!isCollapsed) {
const sessBox = document.createElement('div');
sessBox.className = 'proj-sessions';
for (const s of sessList) sessBox.appendChild(renderSessItem(s));
grp.appendChild(sessBox);
}
row.onclick = (ev) => {
if (ev.target && ev.target.closest && ev.target.closest('.proj-config')) return;
onProjectRowClick(name);
};
row.querySelector('.proj-config').onclick = (ev) => {
ev.stopPropagation();
if (!guardDrawerDirty()) return;
openProjectDrawer(name);
};
box.appendChild(grp);
}
}
function onProjectRowClick(name) {
if (!guardDrawerDirty()) return;
if (draftProject !== name) {
draftProject = name;
setExplicitNone(false);
applyProjectContext();
renderProjectSwitcher();
updateUploadBar();
}
const collapsed = loadCollapsedProjects();
if (collapsed.has(name)) collapsed.delete(name); else collapsed.add(name);
saveCollapsedProjects();
refreshSessions();
}
function renderOtherSessions(list) {
const box = document.getElementById('other-session-list');
if (!box) return;
box.innerHTML = '';
for (const s of (list || [])) box.appendChild(renderSessItem(s));
}
async function deleteSession(targetSid) {
if (!confirm('确认删除该会话?此操作不可撤销。')) return;
try {
@@ -119,7 +208,7 @@ async function loadProjects() {
projects = [];
}
// 启动自动绑首项 / 已删项目回退首项(统一由 GenesisState.autoBindProject 处理)
draftProject = GenesisState.autoBindProject(draftProject, projects);
draftProject = GenesisState.autoBindProject(draftProject, projects, readExplicitNone());
applyProjectContext();
renderProjectSwitcher();
renderDrawerList();
@@ -137,29 +226,21 @@ function renderProjectSwitcher() {
html += '<div class="ps-item' + (p.name === draftProject ? ' active' : '') + '" data-name="' + esc(p.name) + '">'
+ '<span>' + esc(p.display_name || p.name) + '</span><span class="ps-check">✓</span></div>';
}
html += '<div class="ps-sep"></div>'
+ '<div class="ps-item ps-manage" data-manage="1">+ 项目管理(打开抽屉)</div>';
psMenu.innerHTML = html;
psMenu.querySelectorAll('.ps-item[data-name]').forEach(it => {
it.onclick = () => {
if (!guardDrawerDirty()) return;
draftProject = it.dataset.name === '' ? null : it.dataset.name;
setExplicitNone(draftProject === null);
applyProjectContext();
renderProjectSwitcher();
updateUploadBar();
toggleProjectSwitcher(false);
};
});
psMenu.querySelectorAll('.ps-item[data-manage]').forEach(it => {
it.onclick = () => {
if (!guardDrawerDirty()) return;
lastTriggerEl = it;
toggleProjectSwitcher(false);
openProjectDrawer(drawerSelected);
};
});
}
function toggleProjectSwitcher(force) {
const willOpen = force === undefined ? !psMenu.classList.contains('open') : !!force;
psMenu.classList.toggle('open', willOpen);
@@ -265,8 +346,7 @@ function flagPfInvalidByMessage(message) {
.forEach(id => document.getElementById(id).addEventListener('input', (e) => e.target.classList.remove('invalid')));
function renderDrawerList() {
let html = '<div class="drawer-item di-create' + (drawerSelected === null ? ' active' : '') + '" data-name="">'
+ '<span class="di-new"></span><span class="di-name">新建项目</span></div>';
let html = '';
for (const p of projects) {
html += '<div class="drawer-item' + (p.name === drawerSelected ? ' active' : '') + '" data-name="' + esc(p.name) + '">'
+ '<span class="di-name">' + esc(p.display_name || p.name) + '</span></div>';
@@ -562,6 +642,8 @@ async function send() {
});
inputEl.value = '';
inputEl.style.height = '';
const _es = chatEl.querySelector('.empty-state');
if (_es) _es.remove();
addMsg('user', esc(text));
const typing = showTyping();
sendBtn.disabled = true;
@@ -603,7 +685,7 @@ document.getElementById('ps-current').addEventListener('click', (e) => { e.stopP
document.getElementById('drawer-close').addEventListener('click', closeProjectDrawer);
document.getElementById('pf-save').addEventListener('click', saveDrawerProject);
document.getElementById('pf-delete').addEventListener('click', deleteDrawerProject);
document.getElementById('tb-newproject').addEventListener('click', () => {
document.getElementById('side-add-project').addEventListener('click', () => {
if (!guardDrawerDirty()) return;
openProjectDrawer(null);
});
+16 -1
View File
@@ -15,7 +15,9 @@
// 启动自动绑定:draftProject 为 null(未初始化)且有项目时绑首项;
// 指向已删除项目时回退首项;否则保持。
function autoBindProject(draftProject, projects) {
function autoBindProject(draftProject, projects, explicitNone) {
// 用户显式选择「不选择项目」→ 不覆写(F1:保证无项目选择可持久)
if (explicitNone) return draftProject;
if (draftProject === null && projects.length > 0) return projects[0].name;
if (draftProject && !projects.find(p => p.name === draftProject)) {
return projects.length > 0 ? projects[0].name : null;
@@ -23,6 +25,18 @@
return draftProject;
}
// 会话按项目分组:project 为空者入 otherreq 3/5
function groupSessionsByProject(sessions) {
const byProject = {};
const other = [];
for (const s of (sessions || [])) {
const k = (s && s.project) ? s.project : '';
if (k) (byProject[k] = byProject[k] || []).push(s);
else other.push(s);
}
return { byProject: byProject, other: other };
}
// 上传类型下拉始终可见,让用户能补传任何类型。
// (P0B:取消"绑项目即隐藏 select / 强改 file_type=requirements"的产品错误。
// "项目已预置 template 时再上传 template"的安全约束改在 chat.html 上传处用
@@ -78,5 +92,6 @@
buildWelcome,
emptyHeadline,
quickPrompts,
groupSessionsByProject,
};
});
+16
View File
@@ -57,3 +57,19 @@ test('quickPrompts: 返回 4 条快捷提示且含影响调查', () => {
assert.equal(p.length, 4);
assert.ok(p.includes('影响调查'));
});
test('autoBindProject: explicitNone=true 时不覆写(保留 null', () => {
assert.equal(S.autoBindProject(null, [{ name: 'A' }], true), null);
});
test('groupSessionsByProject: 按项目分组,空项目入 other', () => {
const r = S.groupSessionsByProject([
{ session_id: '1', project: 'A' },
{ session_id: '2', project: 'B' },
{ session_id: '3', project: '' },
{ session_id: '4' },
]);
assert.equal(r.byProject.A.length, 1);
assert.equal(r.byProject.B.length, 1);
assert.equal(r.other.length, 2);
});