feat: 自制下拉列表——文件夹分组标题(📁+小字)与1px细分隔线,弃用原生select/optgroup

This commit is contained in:
Aisland
2026-08-18 12:37:40 +08:00
parent a84cb12c5f
commit e1b082796b
4 changed files with 101 additions and 54 deletions
+61 -28
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Terminal } from 'xterm'
import 'xterm/css/xterm.css'
import MarkdownIt from 'markdown-it'
@@ -1245,6 +1245,51 @@ function TextViewer(props: { path: string; fileUrl: string; isMd: boolean }) {
)
}
/** 自制下拉列表(原生 select 无法美化):文件夹分组标题 + 1px 细分隔线 + 选项列表 */
function SelectPop(props: {
value: string | null
groups: { title: string; items: { id: string; label: string; isCurrent?: boolean }[] }[]
placeholder?: string
onChange: (id: string) => void
}) {
const [open, setOpen] = useState(false)
const flat = props.groups.flatMap((g) => g.items)
const selected = flat.find((i) => i.id === props.value)
return (
<div className="dsh-wt_select">
<button type="button" className="dsh-wt_selectBtn" onClick={() => setOpen((v) => !v)}>
<span className={'dsh-wt_selectVal' + (selected ? '' : ' dsh-wt_selectPh')}>{selected?.label ?? props.placeholder ?? ''}</span>
<span className="dsh-wt_selectCaret" aria-hidden>▾</span>
</button>
{open && (
<div className="dsh-wt_selectList">
{props.groups.map((g, gi) => (
<Fragment key={g.title || 'g' + gi}>
{g.title && (
<>
<div className="dsh-wt_selectDivider" />
<div className="dsh-wt_selectGroup">📁 {g.title}</div>
</>
)}
{g.items.map((it) => (
<button
key={it.id}
type="button"
className={'dsh-wt_selectItem' + (it.id === props.value ? ' dsh-wt_selectItemOn' : '')}
onClick={() => { props.onChange(it.id); setOpen(false) }}
>
<span className="dsh-wt_selectItemTitle">{it.label}</span>
{it.isCurrent && <span className="dsh-wt_selectCurrent">{T('custom.sessionCurrent')}</span>}
</button>
))}
</Fragment>
))}
</div>
)}
</div>
)
}
/** 自定义窗口:居中对话框。两种模式:新建专属会话 / 发送到已有会话(默认当前会话)。 */
function CustomPane() {
const custom = splitEnv?.custom
@@ -1320,37 +1365,25 @@ function CustomPane() {
{mode === 'existing' && (
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.session')}</span>
<select
className="dsh-wt_customSelect"
value={sessionId ?? ''}
onChange={(e) => setSessionId(e.target.value)}
>
{sessionGroups.map((g) => g.title
? (
<optgroup key={g.title} label={g.title}>
{g.sessions.map((s) => (
<option key={s.id} value={s.id}>{s.title}{s.isCurrent ? ' ' + T('custom.sessionCurrent') : ''}</option>
))}
</optgroup>
)
: g.sessions.map((s) => (
<option key={s.id} value={s.id}>{s.title}{s.isCurrent ? ' ' + T('custom.sessionCurrent') : ''}</option>
)),
)}
</select>
<SelectPop
value={sessionId}
groups={sessionGroups.map((g) => ({
title: g.title,
items: g.sessions.map((s) => ({ id: s.id, label: s.title, isCurrent: s.isCurrent })),
}))}
placeholder={T('custom.session')}
onChange={setSessionId}
/>
</div>
)}
<div className="dsh-wt_customRow">
<span className="dsh-wt_customLabel">{T('custom.project')}</span>
<select
className="dsh-wt_customSelect"
value={projectId ?? ''}
onChange={(e) => setProjectId(e.target.value)}
>
{projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<SelectPop
value={projectId}
groups={[{ title: '', items: projects.map((p) => ({ id: p.id, label: p.name })) }]}
placeholder={T('custom.project')}
onChange={setProjectId}
/>
</div>
<button
type="button"
+15
View File
@@ -205,6 +205,21 @@ export const css = [
'.dsh-wt_customLabel{flex:none;font-size:11.5px;color:var(--dsw-alias-label-secondary,#9aa4b2)}',
'.dsh-wt_customSelect{flex:1;min-width:0;padding:5px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;outline:none}',
'.dsh-wt_customSelect option{background-color:var(--dsw-alias-bg-base,#0b0e14);color:var(--dsw-alias-label-primary,#e6e8eb)}',
// 自制下拉(文件夹分组 + 细分隔线)
'.dsh-wt_select{position:relative;flex:1;min-width:0}',
'.dsh-wt_selectBtn{display:flex;align-items:center;gap:6px;width:100%;box-sizing:border-box;padding:5px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px;cursor:pointer}',
'.dsh-wt_selectBtn:hover{border-color:var(--dsw-alias-border-l2,#3a4150)}',
'.dsh-wt_selectVal{flex:1;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_selectPh{color:var(--dsw-alias-label-tertiary,#6e7683)}',
'.dsh-wt_selectCaret{flex:none;color:var(--dsw-alias-label-tertiary,#6e7683);font-size:10px;line-height:1}',
'.dsh-wt_selectList{margin-top:4px;max-height:180px;overflow:auto;border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:8px;background:var(--dsw-alias-bg-base,#0b0e14);padding:4px;box-shadow:var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.4))}',
'.dsh-wt_selectItem{display:flex;align-items:center;gap:6px;width:100%;padding:5px 8px;border:none;border-radius:5px;background:transparent;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:17px;cursor:pointer;text-align:left}',
'.dsh-wt_selectItem:hover{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
'.dsh-wt_selectItemOn{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06));color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
'.dsh-wt_selectItemTitle{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_selectCurrent{flex:none;font-size:10px;color:var(--dsw-alias-label-tertiary,#6e7683)}',
'.dsh-wt_selectDivider{height:1px;background:var(--dsw-alias-border-l1,#262b36);margin:4px 2px}',
'.dsh-wt_selectGroup{display:flex;align-items:center;gap:5px;padding:2px 8px 1px;font-size:10px;letter-spacing:.04em;color:var(--dsw-alias-label-tertiary,#6e7683);user-select:none}',
'.dsh-wt_customSend{align-self:flex-end;padding:6px 14px;border:none;border-radius:6px;background:var(--dsw-alias-state-accent-primary,#4f8ef7);color:#fff;font:inherit;font-size:12px;cursor:pointer}',
'.dsh-wt_customSend:disabled{opacity:.55;cursor:default}',
'.dsh-wt_customFail{font-size:11px;color:var(--dsw-alias-state-danger,#e5484d);margin:0}',
+10 -14
View File
@@ -639,18 +639,14 @@
原生视图内部是浏览器扩展页,插件注入不了按钮、拦不到鼠标,也驱动不了内部滚动——
向用户如实说明后按指示回退);② 视图选项改名「设置」,点开直接内嵌「排序方式 + 管理项目
展开列表」,不再二次点击;③ 管理项目里给现有项目加「变更视图」:换预设拓扑,
## 补记(会话列表按用户面板分组 + 排除后台会话)
## 补记(自制下拉列表:文件夹分组标题 + 细分隔线)
- 用户反馈:会话下拉里出现看不到的会话(如「你是一个只读调查员」等后台/子代理对话、乱码标题);
应只显示用户面板里可见的对话,并按面板结构分组(Projects / DeepSeek Harness 两个工作区),
带分隔符。
- 处理:
① 服务端新增只读路由 /api/worktable/workspaces:读宿主 ~/.dsh/storages/workspace.json
(工作区 id→title+sessionIds + archived 列表);
② 客户端 getSessions 改为异步:拉工作区分组 → 排除 archived 与子代理会话(subagentsByParent),
标题取自活快照 byId,当前会话标记;路由不可用时回退平铺(排除子代理);
③ CustomPane 会话下拉用 optgroup 按工作区分组(分隔符),默认选中当前会话;
④ 清理临时探针导出。
- 验证(functional-diag STEP20 扩展):回退态 sessionOptionCount=16、sessionHasCurrent=true;
workspaces 路由 404 已加入待重启过滤;全 20 STEP ERROR_COUNT: 0。
- 备注:含服务端新路由,**需重启一次 dsh web** 后分组生效;重启前为平铺回退。
- 用户反馈:① 工作区文件夹名(Projects/DeepSeek Harness)在原生 optgroup 里看起来像对话项,
需要明确区分是分组名;② 分隔符是白色大横线太丑,要很细的一条。
- 处理:弃用原生 select/optgroup(无法美化),新写 SelectPop 自制下拉:
组标题 = 📁 图标 + 小号暗色大写文字(不可点击、明显是文件夹名);组间 1px 细分隔线;
选项可点选、当前会话带「(当前)」角标、选中项蓝色高亮;列表 max-height 内滚动。
项目下拉同样换用(顺带根治原生 option 配色问题)。修复过程中两次测试脚本语法损坏已恢复。
- 验证(STEP20 重写断言):selectBtnCount=2、listOpen、groupHeaders=2、dividers=2、
itemCount=13(子代理/archived 已排除)、currentBadge=1;全 20 STEP ERROR_COUNT: 0。
- 备注:纯客户端改动,F5 即生效。
+15 -12
View File
@@ -651,23 +651,26 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
out.tabOpened=st.spec.main[0].tabs.length===1;
out.tabType=st.spec.main[0].tabs[0].content.type;
out.dialog=!!vis('.dsh-wt_customCard');
out.textarea=!!vis('.dsh-wt_customInput');
var hint=vis('.dsh-wt_customHint');
out.hint=hint?String(hint.textContent).slice(0,26):null;
var sel=vis('.dsh-wt_customSelect');
out.selectValue=sel?sel.value:null;
out.defaultIsCurrent=sel?sel.value==='t-custom':false;
var sel=vis('.dsh-wt_selectBtn');
out.selectValue=sel?String(sel.textContent).trim():null;
out.defaultIsCurrent=false;
var send=vis('.dsh-wt_customSend');
out.sendDisabled=send?send.disabled:null;
// 切到「发送到会话」模式:出现会话下拉,默认当前会话
// 切到「发送到会话」模式:出现自制会话下拉,点开看分组与当前标记
var modeBtns=[].slice.call(document.querySelectorAll('.dsh-wt_customModeBtn')).filter(function(el){return (getComputedStyle(el).visibility!=='hidden');});
if(modeBtns[1]){ modeBtns[1].click(); }
await new Promise(function(r){setTimeout(r,250)});
var ssel=[].slice.call(document.querySelectorAll('.dsh-wt_customSelect')).filter(function(el){return (getComputedStyle(el).visibility!=='hidden');})[0];
out.sessionSelect=!!ssel;
out.sessionOptGroups=ssel?ssel.querySelectorAll('optgroup').length:0;
out.sessionOptionCount=ssel?ssel.options.length:0;
out.sessionHasCurrent=ssel?String(ssel.textContent).indexOf('\u5f53\u524d')>=0||String(ssel.textContent).indexOf('current')>=0:false;
var sbtns=[].slice.call(document.querySelectorAll('.dsh-wt_selectBtn')).filter(function(el){return (getComputedStyle(el).visibility!=='hidden');});
out.selectBtnCount=sbtns.length;
out.sessionSelect=sbtns.length>=2;
if(sbtns[0]){ sbtns[0].click(); }
await new Promise(function(r){setTimeout(r,250)});
var list=[].slice.call(document.querySelectorAll('.dsh-wt_selectList')).find(function(el){return (getComputedStyle(el).visibility!=='hidden');});
out.listOpen=!!list;
out.groupHeaders=list?list.querySelectorAll('.dsh-wt_selectGroup').length:0;
out.dividers=list?list.querySelectorAll('.dsh-wt_selectDivider').length:0;
out.itemCount=list?list.querySelectorAll('.dsh-wt_selectItem').length:0;
out.currentBadge=list?list.querySelectorAll('.dsh-wt_selectCurrent').length:0;
st.close();
}catch(err){ out.err=String(err) }
return JSON.stringify(out);