feat(ui): 侧边栏项目树(项目/其他会话)+配置按钮+追加项目; 气泡与输入框对齐; 空态发送后清除; 移除底部新建项目与项目管理菜单
This commit is contained in:
@@ -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
|
||||
其他会话
|
||||
· 需求D(project="")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1:chat_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 2:chat.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 3:chat.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 4:chat.js 分组渲染 + 绑定调整 + 空态清理
|
||||
- `refreshSessions()` 改拉全量并按 `groupSessionsByProject` 分组 → `renderProjectTree()` + `renderOtherSessions()`。
|
||||
- 抽 `renderSessItem(s)`(复用现有会话行逻辑)。
|
||||
- `renderProjectTree()`:项目行点击选中+展开/收起(`collapsedProjects` Set,localStorage 持久化);`⚙` → `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: 待用户批准后实施
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 为空者入 other(req 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 };
|
||||
}
|
||||
|
||||
// 上传类型下拉始终可见,让用户能补传任何类型。
|
||||
// (P0‑B:取消"绑项目即隐藏 select / 强改 file_type=requirements"的产品错误。
|
||||
// "项目已预置 template 时再上传 template"的安全约束改在 chat.html 上传处用
|
||||
@@ -78,5 +92,6 @@
|
||||
buildWelcome,
|
||||
emptyHeadline,
|
||||
quickPrompts,
|
||||
groupSessionsByProject,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user