改进工作目录可搜索选择

This commit is contained in:
caoqianming 2026-07-29 13:48:16 +08:00
parent d75df5a4b4
commit 03ee37e201
5 changed files with 170 additions and 41 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.6 — 2026-07-29
- 新对话的工作目录选择改为可直接输入检索的下拉框,不再额外占用一行搜索框;同时支持键盘快速选择。
## 0.60.5 — 2026-07-29 ## 0.60.5 — 2026-07-29
- 新对话仍会明确指定文件保存位置,同时目录选择更快捷:最近使用的目录会自动选中并置顶,目录较多时可以直接搜索。 - 新对话仍会明确指定文件保存位置,同时目录选择更快捷:最近使用的目录会自动选中并置顶,目录较多时可以直接搜索。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-29(新对话目录选择提速,bump 0.60.5) 最后更新:2026-07-29(新对话可搜索组合框,bump 0.60.6)
--- ---
@ -23,6 +23,7 @@
### 2026-07-29 ### 2026-07-29
- **07-29 / 0.60.6 / 新对话目录选择改可搜索组合框**:将上一版独立搜索框 + 原生 select 合并为单一 combobox同一输入区完成已选目录展示、即时过滤和下拉选择并补方向键、Enter、Esc、失焦恢复与 ARIA 状态;最近目录置顶、明确选择 working_dir 和新建目录入口均保持不变。纯前端改动无依赖、API、schema 或 migration全量 387 测试通过17 项按环境跳过)。
- **07-29 / 0.60.5 / 新对话目录选择提速**:保留首发前明确选择 working_dir 的归档约束;本机最近实际使用目录在候选中自动选中、置顶并标记,目录达到 6 个时按需显示搜索框,目录较少时维持原有简洁界面;同步精简“文件保存位置”文案。纯前端改动,无 API、schema、migration 或目录绑定语义变化;相关静态资源与无 DB 路由 18 项测试通过。 - **07-29 / 0.60.5 / 新对话目录选择提速**:保留首发前明确选择 working_dir 的归档约束;本机最近实际使用目录在候选中自动选中、置顶并标记,目录达到 6 个时按需显示搜索框,目录较少时维持原有简洁界面;同步精简“文件保存位置”文案。纯前端改动,无 API、schema、migration 或目录绑定语义变化;相关静态资源与无 DB 路由 18 项测试通过。
- **07-29 / 0.60.4 / 工具失败信号分层 + run_python 同错保护 + wire 健康观测**:管理端保留近7天低阈值全量事实默认把近24h活跃数据拆成跨任务系统性故障、单任务反复失败、按设计非零退出的质量门已归零记录沉入历史区空输出 shell 关联最近 assistant tool_call 补 `search/no match` / `dependency probe` 等稳定类别。RepeatGuard 仅将 `run_python` traceback+非零退出纳入同错 streak普通 shell exit 1 保持原语义。新增只读 `/v1/admin/tool-wire-health`,按 model_profile+tool 对既有 `tool_salvaged/tool_malformed` 计算24h/7d抢救率无新表、migration、索引或执行策略变化。全量387测试通过17项按环境跳过 - **07-29 / 0.60.4 / 工具失败信号分层 + run_python 同错保护 + wire 健康观测**:管理端保留近7天低阈值全量事实默认把近24h活跃数据拆成跨任务系统性故障、单任务反复失败、按设计非零退出的质量门已归零记录沉入历史区空输出 shell 关联最近 assistant tool_call 补 `search/no match` / `dependency probe` 等稳定类别。RepeatGuard 仅将 `run_python` traceback+非零退出纳入同错 streak普通 shell exit 1 保持原语义。新增只读 `/v1/admin/tool-wire-health`,按 model_profile+tool 对既有 `tool_salvaged/tool_malformed` 计算24h/7d抢救率无新表、migration、索引或执行策略变化。全量387测试通过17项按环境跳过

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.60.5" __version__ = "0.60.6"

View File

@ -772,9 +772,41 @@
.new-chat-start h2 { margin: 0 0 8px; font-size: 20px; } .new-chat-start h2 { margin: 0 0 8px; font-size: 20px; }
.new-chat-start .lead { margin: 0 0 24px; color: var(--muted); font-size: 13px; } .new-chat-start .lead { margin: 0 0 24px; color: var(--muted); font-size: 13px; }
.new-chat-start label { display:block; margin-bottom:7px; font-size:13px; font-weight:600; } .new-chat-start label { display:block; margin-bottom:7px; font-size:13px; font-weight:600; }
.new-chat-dir-search { width:100%; margin-bottom:8px; }
.new-chat-dir-row { display:flex; gap:8px; align-items:center; } .new-chat-dir-row { display:flex; gap:8px; align-items:center; }
.new-chat-dir-row select, .new-chat-dir-row input { min-width:0; flex:1; } .new-chat-dir-combobox { position:relative; min-width:0; flex:1; }
.new-chat-dir-picker-wrap { position:relative; }
.new-chat-dir-picker-wrap input { width:100%; padding-right:34px; }
.new-chat-dir-picker-wrap button {
position:absolute; inset:1px 1px 1px auto; width:32px; padding:0; border:0;
background:transparent;
}
.new-chat-dir-picker-wrap button::after {
content:""; display:block; width:7px; height:7px; margin:auto;
border-right:1.5px solid var(--muted); border-bottom:1.5px solid var(--muted);
transform:translateY(-2px) rotate(45deg); transition:transform .15s;
}
.new-chat-dir-combobox.open .new-chat-dir-picker-wrap button::after {
transform:translateY(2px) rotate(225deg);
}
.new-chat-dir-options {
display:none; position:absolute; z-index:12; top:calc(100% + 5px); left:0; right:0;
max-height:260px; overflow:auto; padding:5px; border:1px solid var(--border);
border-radius:8px; background:var(--panel); box-shadow:0 8px 24px rgba(0,0,0,.16);
}
.new-chat-dir-combobox.open .new-chat-dir-options { display:block; }
.new-chat-dir-option {
display:flex; align-items:center; justify-content:space-between; gap:12px;
width:100%; padding:8px 10px; border:0; border-radius:6px; background:transparent;
color:var(--text); text-align:left;
}
.new-chat-dir-option:hover, .new-chat-dir-option.active { background:var(--hover); }
.new-chat-dir-option.selected { color:var(--accent); }
.new-chat-dir-option small { flex:none; color:var(--muted); font-size:11px; }
.new-chat-dir-recent {
margin-left:7px; padding:1px 5px; border-radius:4px;
background:var(--accent-soft); color:var(--accent); font-size:10px;
}
.new-chat-dir-empty { padding:12px 10px; color:var(--muted); font-size:12px; text-align:center; }
.new-chat-new-dir { display:none; margin-top:10px; } .new-chat-new-dir { display:none; margin-top:10px; }
.new-chat-new-dir.show { display:block; } .new-chat-new-dir.show { display:block; }
.new-chat-new-dir input { width:100%; } .new-chat-new-dir input { width:100%; }

View File

@ -398,6 +398,8 @@ function draftWorkingDir() {
return (state.draftWorkingDir || "").trim(); return (state.draftWorkingDir || "").trim();
} }
let _draftFolderActiveIndex = -1;
function syncDraftAction() { function syncDraftAction() {
const ready = !!draftWorkingDir(); const ready = !!draftWorkingDir();
setActionMode(ready ? "idle" : "loading"); setActionMode(ready ? "idle" : "loading");
@ -411,10 +413,13 @@ function syncDraftAction() {
} }
function renderDraftFolderOptions() { function renderDraftFolderOptions() {
const sel = $("new-chat-dir-select"); const combo = $("new-chat-dir-combobox");
if (!sel) return; const input = $("new-chat-dir-picker");
const search = $("new-chat-dir-search"); const list = $("new-chat-dir-options");
const query = search ? search.value.trim().toLocaleLowerCase() : ""; if (!combo || !input || !list) return;
const query = input.dataset.searching === "1"
? input.value.trim().toLocaleLowerCase()
: "";
const recent = localStorage.getItem(LS_RECENT_WORKING_DIR) || ""; const recent = localStorage.getItem(LS_RECENT_WORKING_DIR) || "";
const folders = [...(state.folders || [])]; const folders = [...(state.folders || [])];
// API 已按最近使用时间排序;再把本机刚选过的目录固定到第一项,避免 // API 已按最近使用时间排序;再把本机刚选过的目录固定到第一项,避免
@ -424,50 +429,128 @@ function renderDraftFolderOptions() {
? folders.filter(f => f.name.toLocaleLowerCase().includes(query)) ? folders.filter(f => f.name.toLocaleLowerCase().includes(query))
: folders; : folders;
const selected = state.draftNewDirMode ? "" : draftWorkingDir(); const selected = state.draftNewDirMode ? "" : draftWorkingDir();
const options = visible.map(f => { list.innerHTML = visible.length
? visible.map((f, i) => {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : "空目录"; const tag = f.n_tasks ? `${f.n_tasks} 个任务` : "空目录";
const recentTag = f.name === recent ? "最近使用 · " : ""; const recentTag = f.name === recent
return `<option value="${escapeHtml(f.name)}">${recentTag}${escapeHtml(f.name)}${tag}</option>`; ? `<span class="new-chat-dir-recent">最近使用</span>`
: "";
const chosen = f.name === selected ? " selected" : "";
return `<button type="button" id="new-chat-dir-option-${i}"
class="new-chat-dir-option${chosen}" role="option"
aria-selected="${f.name === selected}"
data-name="${escapeHtml(f.name)}">
<span>${escapeHtml(f.name)}${recentTag}</span>
<small>${tag}</small>
</button>`;
}).join("")
: `<div class="new-chat-dir-empty">${query ? "没有匹配的目录" : "暂无已有目录"}</div>`;
if (input.dataset.searching !== "1") input.value = selected;
_draftFolderActiveIndex = -1;
input.setAttribute("aria-activedescendant", "");
list.querySelectorAll(".new-chat-dir-option").forEach(option => {
option.onmousedown = e => e.preventDefault();
option.onclick = () => selectDraftFolder(option.dataset.name || "");
}); });
// 搜索词不匹配当前选择时仍保留它,避免仅仅输入搜索词就悄悄清掉目录。 }
if (selected && !visible.some(f => f.name === selected)) {
options.unshift(`<option value="${escapeHtml(selected)}">当前选择 · ${escapeHtml(selected)}</option>`); function setDraftFolderOpen(open) {
const combo = $("new-chat-dir-combobox");
const input = $("new-chat-dir-picker");
if (!combo || !input) return;
combo.classList.toggle("open", open);
input.setAttribute("aria-expanded", String(open));
if (!open) {
input.dataset.searching = "";
input.value = state.draftNewDirMode ? "" : draftWorkingDir();
_draftFolderActiveIndex = -1;
input.setAttribute("aria-activedescendant", "");
} }
if (!visible.length && query) { }
options.push(`<option value="" disabled>没有匹配的目录</option>`);
} function selectDraftFolder(name) {
sel.innerHTML = [`<option value="">选择已有目录…</option>`, ...options].join(""); if (!name) return;
if (folders.some(f => f.name === selected)) sel.value = selected; state.draftNewDirMode = false;
if (search) search.hidden = folders.length < 6; state.draftWorkingDir = name;
const box = $("new-chat-new-dir");
if (box) box.classList.remove("show");
setDraftFolderOpen(false);
state.filesPath = name;
loadFiles();
syncDraftAction();
renderDraftFolderOptions();
}
function moveDraftFolderActive(delta) {
const input = $("new-chat-dir-picker");
const options = [...document.querySelectorAll("#new-chat-dir-options .new-chat-dir-option")];
if (!input || !options.length) return;
_draftFolderActiveIndex = (
_draftFolderActiveIndex + delta + options.length
) % options.length;
options.forEach((option, i) => option.classList.toggle("active", i === _draftFolderActiveIndex));
const active = options[_draftFolderActiveIndex];
input.setAttribute("aria-activedescendant", active.id);
active.scrollIntoView({ block: "nearest" });
} }
function bindDraftControls() { function bindDraftControls() {
const sel = $("new-chat-dir-select"); const combo = $("new-chat-dir-combobox");
const search = $("new-chat-dir-search"); const picker = $("new-chat-dir-picker");
const toggle = $("new-chat-dir-toggle");
const add = $("new-chat-dir-add"); const add = $("new-chat-dir-add");
const box = $("new-chat-new-dir"); const box = $("new-chat-new-dir");
const input = $("new-chat-dir-input"); const input = $("new-chat-dir-input");
if (!sel || !add || !box || !input) return; if (!combo || !picker || !toggle || !add || !box || !input) return;
renderDraftFolderOptions(); renderDraftFolderOptions();
box.classList.toggle("show", state.draftNewDirMode); box.classList.toggle("show", state.draftNewDirMode);
if (state.draftNewDirMode) input.value = draftWorkingDir(); if (state.draftNewDirMode) input.value = draftWorkingDir();
sel.onchange = () => { picker.onfocus = () => {
state.draftNewDirMode = false; picker.dataset.searching = "";
state.draftWorkingDir = sel.value || ""; setDraftFolderOpen(true);
box.classList.remove("show"); renderDraftFolderOptions();
if (state.draftWorkingDir) { picker.select();
state.filesPath = state.draftWorkingDir;
loadFiles();
}
syncDraftAction();
}; };
if (search) { picker.oninput = () => {
search.oninput = () => renderDraftFolderOptions(); picker.dataset.searching = "1";
setDraftFolderOpen(true);
renderDraftFolderOptions();
};
picker.onkeydown = e => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setDraftFolderOpen(true);
moveDraftFolderActive(e.key === "ArrowDown" ? 1 : -1);
} else if (e.key === "Enter" && combo.classList.contains("open")) {
const options = [...document.querySelectorAll("#new-chat-dir-options .new-chat-dir-option")];
const active = options[_draftFolderActiveIndex];
const exact = options.find(o => o.dataset.name === picker.value.trim());
const target = active || exact || (options.length === 1 ? options[0] : null);
if (target) {
e.preventDefault();
selectDraftFolder(target.dataset.name || "");
} }
} else if (e.key === "Escape") {
e.preventDefault();
setDraftFolderOpen(false);
picker.blur();
}
};
picker.onblur = () => setTimeout(() => setDraftFolderOpen(false), 0);
toggle.onmousedown = e => e.preventDefault();
toggle.onclick = () => {
const open = !combo.classList.contains("open");
setDraftFolderOpen(open);
if (open) {
picker.focus();
picker.select();
renderDraftFolderOptions();
}
};
add.onclick = () => { add.onclick = () => {
state.draftNewDirMode = true; state.draftNewDirMode = true;
state.draftWorkingDir = ""; state.draftWorkingDir = "";
sel.value = ""; setDraftFolderOpen(false);
box.classList.add("show"); box.classList.add("show");
input.value = ""; input.value = "";
input.focus(); input.focus();
@ -499,11 +582,20 @@ export async function showNewConversationDraft() {
<div class="new-chat-start"> <div class="new-chat-start">
<h2>开始一段新对话</h2> <h2>开始一段新对话</h2>
<p class="lead">选择文件保存位置然后告诉助手你想做什么</p> <p class="lead">选择文件保存位置然后告诉助手你想做什么</p>
<label for="new-chat-dir-select">文件保存位置</label> <label for="new-chat-dir-picker">文件保存位置</label>
<input type="search" id="new-chat-dir-search" class="new-chat-dir-search"
placeholder="搜索工作目录" autocomplete="off" hidden />
<div class="new-chat-dir-row"> <div class="new-chat-dir-row">
<select id="new-chat-dir-select"><option value="">加载目录</option></select> <div id="new-chat-dir-combobox" class="new-chat-dir-combobox">
<div class="new-chat-dir-picker-wrap">
<input type="text" id="new-chat-dir-picker" role="combobox"
aria-autocomplete="list" aria-expanded="false"
aria-controls="new-chat-dir-options"
placeholder="选择或搜索已有目录" autocomplete="off" />
<button type="button" id="new-chat-dir-toggle" tabindex="-1"
aria-label="展开工作目录"></button>
</div>
<div id="new-chat-dir-options" class="new-chat-dir-options"
role="listbox"></div>
</div>
<button type="button" id="new-chat-dir-add">+ 新建工作目录</button> <button type="button" id="new-chat-dir-add">+ 新建工作目录</button>
</div> </div>
<div id="new-chat-new-dir" class="new-chat-new-dir"> <div id="new-chat-new-dir" class="new-chat-new-dir">