Conversation
- WSL runtime probe, distro enumeration (local + online), per-distro probes and opencode version checks with UAC-elevated installers - server lifecycle jobs (start/install/remove) with generation-guarded state, UTF-16 console decoding, full state push via wsl-state event - deterministic server ids (wsl:<distro>), startup warm-up - opencode command surface extended for WSL sidecar lifecycle
- wslStore syncs runtime into serverStore: ready servers register with auth and auto-subscribe to the multi-server sidebar (WSL badge) - connections list renders one card per WSL server combining connection and lifecycle management (switch, health, default, install/update opencode, retry, remove); add dialog with detection progress state and terminal entry - default-server preference persisted (boot auto-switch, death- rebirth restore after sidecar restart); WSL ids excluded from localStorage persistence; SSE resubscribes on same-id runtime change
- upsertServer broadcasts server-runtime-updated on any endpoint change (first registration included), decoupled from the active server - SidePanel subscribed filter tracks the serverStore snapshot so the WSL group appears the moment the server registers, no manual re-subscribe - per-server SSE rebuilds the single affected connection on runtime change: the boot-window fallback connection (WSL not yet registered) stays healthy on the wrong address and never self-heals via auto-reconnect
|
感谢pr,最近工作太忙,更新比较慢,抽空我会看看的 |
- initialize_wsl 瘦身为仅恢复配置+拉起已有服务器,删启动全量 opencode 检查与预热块 - 新增 prewarm_wsl 命令,设置页打开时按需补齐 runtime/发行版列表/opencode 检查 - 在线目录 stale-while-revalidate 缓存(wsl-online-cache.json, TTL 24h):联网目录持久化,过期先展示后后台刷新 - 对话框「重新检测」按钮走 force=true 绕过缓存,autoProbePlan 走缓存 - 未添加过 WSL 服务器的机器启动路径零开销
- collectActiveServerIds 过滤未注册服务器,消灭 WSL 就绪前请求回退 local 的数据串服 - 注册事件订阅 serverStore,WSL 就绪时自动入集触发接入 - upsertServer 无变更短路,wsl-state 全量推送不再触发 localStorage 写入与全体重渲染 - 订阅集合改增量 diff(Map 按 serverId),集合变化不再全量拆建 SSE 与全量重拉 - useModels 按 reason 门控,非 active 服务器端点变化不再触发模型重拉 - 补增量接入与定向重建订阅的契约测试
- useSessions 重试改为显式循环,loading 生命周期=循环生命周期,重试等待期不落地 - 重试耗尽才落 error 终态,空态文案不再伪装成「没有对话」的加载失败 - FolderRecentList 空态前增加 error 分支,加载失败显示可点击重试 - 新增 locale 键 sidebar.loadFailed - 补重试期保持 loading 与耗尽落 error 的契约测试
- 后台 revalidate 加在途标志,避免每次 ServeStale 并发拉起会挂住的 wsl --list --online - prewarm 只补未就绪发行版的 opencode 检查,就绪服务器由 Ready 挂钩刷新,不再重复 spawn - 单元测试去掉冗余 target_os 门控(模块本身已按 Windows 编译)
- collectActiveServerIds 过滤后为空时兜底回退 active server,避免 SSE 全灭(补回归测试) - useSessions 重试等待期检查 enabledRef,懒加载闸门关闭时中断在途重试 - 重试终态改为 return 而非 throw 到空 catch,消除空 catch 块
lehhair
left a comment
There was a problem hiding this comment.
Review:功能方向很对,架构方向也对,但现在还不能合(4 个 blocker)
先说结论:这个 PR 的价值我认可——WSL 里 opencode serve 端口/密码每次启动都变,做成"像本地服务一样托管生命周期"是对的方向,#161 的痛点是真的。分层也符合项目既有风格(Rust runtime/命令/类型三层 + 纯函数决策 + job 代次;前端 wslStore 只做粘合 + settings-model.ts 纯函数 + serverStore 持久化边界),Windows 隔离干净(commands/mod.rs 三个模块全部 #[cfg(target_os = "windows")],12 个命令合并进同一个 generate_handler!,lib.rs 无第二处注册——描述里提到的 invoke_handler 覆盖 bug 确实已经修掉了)。
但我把分支拉到本地跑通、并对关键路径做了可复现验证后,发现 4 个 blocker,其中 2 个会造成"跨服务器数据串扰 / 幽灵连接",2 个会造成"服务永久卡在启动中 / 孤儿进程无法回收"——恰好都是这个 PR 自己宣称要消灭的失败类型。修复量都很小(合计约 30~50 行),所以我建议 Request changes,改完再合,而不是重做。
我实际跑过的校验(在 PR head 上)
| 命令 | 结果 |
|---|---|
npm run typecheck |
0 错误 |
npm run lint |
0 错误 |
npx vitest run |
94 文件 / 644 测试全过 |
npm run build |
vite 生产构建通过 |
cargo check --all-targets |
通过,0 警告 |
cargo test |
16/16 通过(描述里的测试数准确) |
顺带说明:dev、main、origin/dev、origin/main 都在 d823f189,与 PR base e3605b57 只差一个 fast-uri 依赖 bump,git merge-tree 无冲突 —— 与描述一致。
但请特别注意:现有 644 个测试一个都没抓到底下的 blocker。 下面每条我都标了证据等级(实测 = 我写了临时探针测试跑出来的原始输出;静态可证 = 控制流/可达性上确定;需真机确认 = 我无法在本机复现,只能审代码)。
Blocker 1(实测)subscribeToEvents 会把"活动服务器事件流"迁移到非活动服务器
src/api/events.ts:1039-1047
if (newServerId === currentServerId && reason !== 'server-runtime-updated') return
unsubscribe()
currentServerId = newServerId
unsubscribe = subscribeToServerEvents(currentServerId, callbacks)ids 不同时这个守卫完全不生效,而 serverStore.upsertServer 是对所有服务器无条件广播 server-runtime-updated 的(serverStore.ts:506-511)。于是:任何一台 WSL sidecar 就绪(含应用启动时 initialize_wsl 拉起全部已添加服务器的那一轮,首次注册 previous 为空 → runtimeChanged = true),都会在 active 是别的服务器时,把 active 的 SSE 拆掉、迁到那台 WSL 服务器上,并把 currentServerId 改成它。
我用真实 events.ts + 真实 serverStore(active = local,只调一次 upsertServer({id:'wsl:Ubuntu'}))跑出来:
PROBE active server at subscribe time: local
PROBE fetch #1: http://srv.test/local/global/event
PROBE fetch calls after WSL ready: ["http://srv.test/local/global/event","http://srv.test/wsl:Ubuntu/global/event"]
PROBE active server unchanged: local
active 还是 local,但事件流已经连到 wsl:Ubuntu 了。消费方随即拿到错误服务器的事件:SessionContext.tsx:153(按裸 session.id 入列表,无 server 作用域)、useSessions.ts:218、useGitWorkspaceCatalog.ts:158、WorktreePanel.tsx:108;正在用的那台服务器实时更新中断,直到下一次真正的服务器切换。这正是 PR 要消灭的"数据串服"。
最小修法:
if (reason === 'server-runtime-updated') {
// 端点变化只关心 active 自己;非 active 服务器的端点变化与本订阅无关
if (newServerId !== serverStore.getActiveServerId()) return
} else if (newServerId === currentServerId) return(这个分支 events.test.ts 完全没覆盖。)
Blocker 2(实测)删除 WSL 服务器后,serverStore 留下幽灵服务器
src/store/wslStore.ts:150-183
_syncServers 只遍历 state.servers,而"未就绪 → serverStore.removeServer"那条路对已删除的服务器永远不会执行:后端 remove_wsl_server 是把配置 retain 掉再推送全量状态(wsl_commands.rs:1159),stop_sidecar_internal 又刻意不改 runtime,监督任务的 cancel 分支直接 return(wsl_commands.rs:1046-1052)——前端再也不会看到这个 id。
实测(ready 注册 → 设为 active → 后端推送删除后的空状态):
PROBE after ready -> serverStore: [ 'local', 'wsl:Ubuntu' ] subscribed: [ 'wsl:Ubuntu' ] active: wsl:Ubuntu
PROBE after delete -> serverStore: [ 'local', 'wsl:Ubuntu' ] subscribed: [ 'wsl:Ubuntu' ] active: wsl:Ubuntu
三个后果:
- 删掉正在用的 WSL 服务器后,active 仍指向死地址,
removeServer里新加的 server-switch 兜底(serverStore.ts:578-580)不会被触发; - 残留条目带着一次性密码和死端口继续被
multiServerStore白名单订阅——WslServerRow.tsx:231-234的删除只调wslApi.removeServer,没有像普通服务器删除那样清白名单(ServersSettings.tsx:684-690)→ 侧边栏出现永远连不上的 WSL 分组; - 设置页对"有 id 但没有 wsl 状态"的条目
return null(ServersSettings.tsx:655-659)→ 用户在 UI 里再也看不到、也删不掉它。
最小修法:在 _syncServers 里记住上一次同步过的 id 集合,对本轮缺失的 id 做对账:
for (const id of this._knownIds) {
if (state.servers.some(s => s.config.id === id)) continue
multiServerStore.setSubscribed(id, false)
serverStore.removeServer(id) // 顺带触发 active 回退
if (serverStore.getDefaultServerId() === id) serverStore.setDefaultServer(null)
}
this._knownIds = new Set(state.servers.map(s => s.config.id))顺带建议在 serverStore.removeServer 里清掉指向被删 id 的 defaultServerId(现在的 defaultServerId 是只增不减的)。
Blocker 3(静态可证)健康检查轮询没有超时,30s 超时分支是不可达死代码
src-tauri/src/app/commands/wsl_commands.rs:971-1011
while !ready && failure.is_none() {
if !is_current() { ...; return; } // 出口 1
if let Ok(Some(status)) = child.try_wait() { failure = Some(...); break; } // 出口 2
if is_service_running_with_auth(&url, ...).await { ready = true; break; } // 出口 3
sleep(100ms).await;
}
if !ready && failure.is_none() { // ← 恒为 false,30s 超时提示写在这里循环只能从 ready / failure / return 退出,所以出循环时必有 ready == true 或 failure.is_some(),health_timeout_ms(973)只在这个死分支里被用到——注释里承诺的 Promise.race([health, exit, timedOut]) 的超时臂从来没写。
后果:如果 opencode serve 活着但 127.0.0.1:<port> 永远不响应(WSL localhost 转发失效是真实且常见的故障),任务会以 10Hz 永远轮询,服务器永远停在 Starting、UI 所有按钮永久禁用、任务永不回收(每轮健康检查本身还带 3s connect + 5s request 超时,单轮最坏 ~8s)。修法:循环内用 tokio::time::Instant deadline,超时落 Failed,同时删掉不可达分支。
Blocker 4(静态,交错分析成立;触发率需真机确认)cleanup_sidecar 会删掉别人的句柄 → 孤儿进程
src-tauri/src/app/commands/wsl_commands.rs:1076-1081
/// 从 sidecars 移除本次启动的句柄(仅当 token 匹配时) // 注释承诺了校验
fn cleanup_sidecar(state: &tauri::State<'_, WslState>, id: &str) {
if let Ok(mut sidecars) = state.sidecars.lock() { sidecars.remove(id); } // 实际无条件
}remove(id) 是无条件的,而 1055-1059 行已经算好了正确的 attempt 归属判定(owned = sidecars.get(id).map(|h| h.attempt == attempt))却没用在删除上。可达交错:attempt N 正卡在最长 5s 的健康检查 HTTP 调用里 → 用户点"重试启动"或安装 opencode → attempt N+1 走完 stop_sidecar_internal、Starting、解析路径、分配端口、spawn、注册新句柄 → N 醒来发现 !is_current(),child.kill() 后调 cleanup_sidecar 删掉 N+1 的句柄。此后 stop_wsl_server / remove_wsl_server / 退出清理都找不到 token,opencode serve 永久留在发行版里占着端口,而删除命令还返回 Ok(())。
最小修法:给 cleanup_sidecar 加上 attempt 参数,只在仍属于自己时才 remove;让 stop_sidecar_internal 成为唯一的无条件移除点。
建议同批修的重要问题
| # | 位置 | 问题 |
|---|---|---|
| I1 | wsl_commands.rs:633-647 |
install_wsl_distro 里 match (installed, online, probe) 任一 Err 都整体 Err(e):联网拉在线目录失败会把已经安装成功的 installed + probe 一起丢弃,前端报错但发行版其实装好了。这与描述里"本地列表和在线目录解耦容错,谁失败只报谁"直接矛盾(refresh_distros_inner 才是容错的)。装上发行版不会改变在线目录,这里也可以直接不拉 |
| I2 | main.tsx:63、SessionContext.tsx:221、useSessions.ts:285、useProject.ts:79、useVcsInfo.ts:81、BottomPanel.tsx:102、pinnedSessionsStore.ts:61、modelVisibilityStore.ts:24 等 |
约 10/12 个 onServerChange 消费方忽略 reason。非 active 的 WSL 服务器重启会波及 active 路径:main.tsx:63 会 resetPathModeCache() + 重载 auto-approve + 强制重连 active 的 SSE(重连窗口内事件会丢),SessionContext 会清空并重拉整个会话列表(可见闪烁)。目前只有 useModels.ts 与 DirectoryContext.tsx 处理了 reason |
| I3 | useGlobalEvents.ts:812-818 |
这段"端点变了要拆旧建新"实际是空操作:subscribeToServerEvents 是引用计数的(events.ts:1011-1029),先订阅后取消 → size 1→2→1,既不 disconnect 也不 connect。实测:set-then-unsubscribe 后 fetch 次数没变(1),而 reconnectServerSSE 会真的重连(2)。建议直接调 reconnectServerSSE(changedId) 或先取消后订阅 |
| I4 | app/mod.rs:525-551、opencode.rs:400-406 |
退出路径没有 RunEvent::Exit/ExitRequested 钩子,stop_all_wsl_servers 只在 confirm_close_app 里被调,而它全仓库只有 useCloseServiceDialog.ts:35 一处调用、且只在"本应用启动过 Windows 本地服务"时才走 → 直接关窗会留下所有 WSL opencode serve;sidecars 是进程内 map,下次启动也回收不了(initialize_wsl 对空 map 调 stop) |
| I5 | useSessions.ts:126-164 |
重试循环无法在 unmount 时中断(base 里 retryTimerRef 会清),卸载后仍会多发最多 3 次请求(~5s);另外终态不再 setSessions([])/setHasMore(false) 是全平台契约变更,而 FolderRecentList.tsx:1094-1102 只在列表为空时显示错误 → 有旧数据时"刷新失败"完全无提示,且 :1165 仍显示"显示更多" |
| I6 | wslStore.ts:26-33,71-79 |
defaultServerId/bootTarget/pendingRestoreId 对已删除服务器从不清理;WSL id 是确定性的,重新添加同一发行版会被静默切为 active/default;pendingRestoreId 无过期,会把用户主动切走的服务器"拽回来" |
| I7 | wsl_commands.rs:1124-1133、1046-1073、901-912 |
add_wsl_server 先改内存后持久化(写盘失败留幽灵 Starting);watcher 用 if let Ok(status),wait() 返回 Err 时既不清理也不改状态 → 永久 Ready 挂着死句柄;sidecar Command 缺 kill_on_drop(true)(run_process 有,sidecar 没有) |
| I8 | wsl_runtime.rs:131-137 |
kill() 只杀 wsl.exe,发行版内进程可能存活;在最坏路径(15 分钟安装超时)上可能出现并发安装器。这条我没法在本机复现,需要真机确认,如果确认建议在发行版内也 kill |
UI:基本符合项目设计,但有几处要收一下
做得好的:完全复用 Dialog/Button/ConfirmDialog/DropdownMenu/SettingsSection/SettingRow 与语义 token;删除用 ConfirmDialog variant="danger",与普通服务器一致;ServerHealthButton 抽出来共用是干净的复用;行内嵌按钮的 e.target !== e.currentTarget 处理正确;settings-model.ts 的纯函数 + 探针计划 + 失败门控很符合仓库风格;i18n 我核对过 en/zh 各 444 键完全对称、wsl 38 键、组件用到的键 0 缺失、无硬编码中英文串;docker-desktop 过滤、版本化 Ubuntu 去重、stale-while-revalidate + 强制"重新检测"这些产品判断都不错。
需要改:
FolderRecentList.tsx:1099新加的text-error-100 hover:text-error-200:src/index.css的@theme里只有--color-danger-*,没有--color-error-*,这两行 class 不生成任何 CSS → "加载失败,点击重试"根本不会变红,跟空态文案视觉上没区别(MultiServerFolderList.tsx:72、SearchResults.tsx:32是既有同类隐患)。请改text-danger-100。MultiServerFolderList.tsx:224的 WSL 徽标用text-blue-400 bg-blue-400/10——全项目唯一使用 Tailwind 原生调色板的地方,其它一律语义 token,两套主题下都不受控。WslServerRow.tsx:213-224用字面×做删除,而同一个"移除服务器"动作在ServersSettings.tsx:151-162是TrashIcon size={13}→ 请统一。- 窄窗口溢出:操作区是
shrink-0+ 全文字按钮,zh 下固有最小宽度约 456px;设置弹窗是min(97vw,1040px)减 204px 导航,而默认窗口是 800×600(tauri.conf.json:19-20,无minWidth)→ 内容列 ~520px,active + default + 需要更新的一行会横向溢出。建议次要动作改图标按钮(与ServerItem的PencilIcon/TrashIcon一致)或允许换行。 - 同一张卡上两个视觉完全相同的 Wi-Fi 状态(左侧 runtime
StatusDot与右侧ServerHealthButton),且stopped与failed同形同色,用户无法区分"我停的"和"它崩了"。 - "设为默认服务器"只长在 WSL 卡片上,但
defaultServerId是全局启动偏好(serverStore对普通 id 同样生效、还会进备份文件)→ 概念只在 WSL 处露出,删掉那台服务器后偏好变成不可见、不可清除的孤儿状态。建议要么全局可见可管理,要么明确限定为 WSL 专属并在文案/文档里说清。 - "安装 WSL"会直接弹 UAC 提权(
DialogAddWslServer.tsx:202→wsl_runtime.rs:168Start-Process -Verb RunAs),但文案没有一句提前说明"将请求管理员权限",用户拒绝后只会看到一个英文stderr原样抛出的提示。建议加一句预告 + 把"取消/拒绝提权"单独识别成一条可读文案。 - 硬编码尺寸:
settings-model.ts:240的width: '138px'/'129px'、DialogAddWslServer.tsx:264的width: '99px'是按 zh 字宽写死的,英文或更长语言会挤/裁。 DialogAddWslServer.tsx:467的fill="#DBDBDB"内联 SVG 在浅色主题面板上几乎不可见;另有两个自绘 SVG 重复了Icons里的DownloadIcon/ChevronRightIcon。- 小项:禁用发行版行
tabIndex={undefined}导致键盘用户读不到"WSL 2 required"的原因;探测中状态没有role="status"/aria-live(仓库ModelSelector.tsx、SettingsSearch.tsx、ChatArea.tsx已有该模式);{t('common:add')} ▾缺aria-haspopup/aria-expanded;DialogAddWslServer.tsx:186-206的unavailable非 installable 分支没有"重新检测"入口,只能关掉重开。
与 PR 描述不一致的 3 处(以代码为准比较稳妥)
- "动态端口分配并持久化复用" ——
WslServerConfig只有{id, distro},没有端口字段,每次启动都重新allocate_port()(wsl_commands.rs:713),代码注释自己写的也是"无端口字段"。 - "所有跨语言结构体统一
#[serde(rename_all = "camelCase")],命名有机械保证" ——WslJob(wsl_types.rs:96-104)用的是 enum 上的rename_all = "kebab-case",它只改 variant 名、不改 variant 字段。实测:{"kind":"runtime","started_at":7},而src/features/wsl/types.ts:66声明startedAt。当前只有kind/distro/distros被消费,所以是潜伏雷,建议加rename_all_fields = "camelCase"。 - "补回归测试防再犯(Stdio::piped)" ——
test_run_process_captures_output只覆盖run_process,sidecar 是内联自己配的 Stdio(wsl_commands.rs:901-912),漏配不会被这个测试抓到。
合前请顺手清理
.gitignore里的.hermes/ .omo/ opencode/ nul与eslint.config.js/vitest.config.ts排除本地参考目录 —— 这些是贡献者本机环境,建议不要进主干(opencode/这个名字尤其容易被误解)。src-tauri/src/app/commands/mod.rs被改成了 CRLF(main 是 LF,我做了字节级核对:PR blob CR=11 / main CR=0),导致 3 行改动显示成 11 增 5 删。建议转回 LF 再合,否则以后每次改动都有噪音。
建议补的 4 个针对性测试(正好都在 blocker 上)
events.ts的server-runtime-updated归属:active 是 A 时收到 B 的 runtime 变更,不得迁移订阅。wslStore._syncServers的删除对账:状态里消失的 id 必须从serverStore/白名单中清除。- Rust 健康检查超时:把"是否超时"抽成纯函数测试(与
decide_online_action同一风格)。 cleanup_sidecar的归属校验:旧 attempt 不得删掉新 attempt 的句柄。
合并方式
改完请在同一分支追加提交(不要 force push 重写历史),我会按仓库规范 merge --no-ff 合并,这样 GitHub 上这个 PR 会正常显示为 Merged、你的原始 commit 也保留在历史里(rebase/cherry-pick 会改 hash,PR 不会被识别为已合并)。
关于审查方式:本次审查由 AI 辅助完成(自动化跑通全套校验、逐行追踪、并用临时探针测试实测复现了上面 B1/B2/I3 与 serde 字段名四条),结论经我确认后发布。凡标注"需真机确认"的项(B4 的实际触发率、I8 发行版内进程回收)我没有在本机复现,只做了代码级分析,欢迎你用真机数据反驳或补充。
整体我很喜欢这个 PR 的架构取舍和纯函数拆分,尤其是 settings-model.ts 的探针计划 + 失败门控、reduceWslRestore 的启动/死亡-复活恢复状态机,以及 decide_online_action 这类把决策从 IO 里剥出来的写法。上面这些改完(预计 30~50 行)我很乐意直接合。
- .gitignore / eslint.config.js / vitest.config.ts:移除与本功能无关的本地条目 - mod.rs:CRLF 归一为 LF,内容零变化(git diff --ignore-cr-at-eol 验证为空)
- server-runtime-updated 事件仅在变更服务器就是 active 时触发订阅迁移, 非 active 的 WSL 服务器重启不再劫持 active 连接 - 补 3 项回归测试(events.test.ts 5→8)
- serverStore.removeServer:默认服务器指向被删 id 时清除该偏好; 仅限非 WSL(WSL 删除常因瞬时未就绪触发、配置仍在后端,其回收由 wslStore 处理) - wslStore:runtime 事件中被移除的服务器经 _reclaimRemoved 回收绑定与默认项, removeServer 复用 active 优雅降级 + server-switch 广播(幂等) - 补 5 项回归测试(wslStore 13→16、serverStore 30→32)
- 健康轮询截止时间改用 tokio::time::Instant(单调时钟,改系统表不影响判定), 超时阈值收敛为共享常量 HEALTH_TIMEOUT_MS,生产与测试同源 - cleanup_sidecar 按 attempt 句柄归属判等,防止误杀新 sidecar 实例 - 安装完成判定抽为纯函数 decide_install_finish,不再拉取在线发行版目录 - add_wsl_server 先持久化成功后再改内存态;watcher 意外 Err 按进程退出处理; sidecar Command 补 kill_on_drop 防句柄泄漏 - WslJob 补 serde rename_all_fields = "camelCase",并加字段序列化契约测试 锚定前端 TS 类型 - wsl_commands.rs 测试 6→15,cargo test 25/25 全绿
- 端点变化改走 reconnectServerSSE 定向真重连:引用计数下旧的 「先 subscribe 后 unsubscribe」是同服务器 1→2→1 空操作,连接从未换过端点 - 8 个 onServerChange 消费方(main/SessionContext/useSessions/useProject/ useVcsInfo/BottomPanel/pinnedSessionsStore/modelVisibilityStore)统一门控: 非 active 服务器的端点变化不重置 active 数据、不重连 active SSE - useSessions 重试退避做成可取消:unmount 清 timer 并唤醒循环, 卸载后不再发重试请求 - 补 6 项行为测试并改写 1 项锚定旧空操作的断言(旧实现上红、新实现上绿)
- 死色 token 换为 @theme 实存语义 token(text-error-100 → text-danger-100、 blue-400 徽章 → info-100) - WSL 行删除按钮由文本 × 统一为 TrashIcon,与 ServersSettings 一致; 操作区 flex-wrap + ml-auto,窄窗口(800×600)折行防溢出 - 会话列表刷新失败时露出失败态并给重试入口,不再被「显示更多」按钮吞掉 - WSL 引导对话框:权限请求提示与 UAC 拒绝文案(en/zh 对称补键)、 探测失败可「重新检测」、role=status + aria-live 播报、 禁用发行版行经 aria-describedby 指向原因、SVG 图标改 currentColor - settings-model 固定 width 改 minWidth,按钮随长文案自然扩展不裁切
|
问题全部认账,已在原分支追加 6 个提交修复(7096f48d → 31f95a6),没有 force push。逐条说明: B1 SSE 订阅迁移:确认。 B2 服务器删除回收:确认。 B3 健康轮询 timeout 腿:确认,这个最隐蔽。 B4 句柄所有权:确认。 正文描述不实三处:端口"持久化复用"不实—— 重连空操作(评审顺带确认):引用计数下"先 subscribe 后 unsubscribe"对同一服务器只是 1→2→1,连接从未换到新端点。改走 事件消费方门控:8 个 Rust 容错一组: UI 一组:死色 token 换成 合前清理: 证据:
接线说明(证据分级):Rust 侧超时判定与句柄归属是纯函数直测;它们与轮询循环、5 处调用点的接线是静态核对 + 编译器对签名变更的兜底(漏改一处编不过),没有真进程级并发集成测试。 剩余:窄窗口布局重设计、双连接状态区分、"设为默认"的全局可见性属于要先定设计方向的界面项,sidecar kill 升级路径需要真机时序验证,这些我另开 follow-up PR 跟进。 |
起因
我在 Windows 上主力用 WSL 跑 opencode(Linux 环境确实更顺手),但 OpenCodeUI 桌面端想连上 WSL 里的服务只能手动「添加服务器」填地址——而 opencode serve 每次启动端口和密码都会变,等于每次都要去 WSL 里查一遍再手填一遍,没法日常用(也就是 #161 描述的痛点)。
官方 OpenCode 桌面端已经有完整的一套 WSL 服务器管理,所以我对着它的实现(packages/desktop/src/main/wsl 和 packages/app/src/wsl)逐项对照,给 OpenCodeUI 补上了这块能力。这个功能的目标是让 opencode 真正跑在 WSL 里、像管理本地服务一样管理它的完整生命周期,而不是从 Windows 侧拼个地址远程连过去的包装。
这是 Windows 桌面端(Tauri)专属功能,Web / Docker 部署完全不受影响(WSL 命令只在 Windows 编译分支注册)。
现在能做到什么
设置 → 服务器里:
实现方式挑重点
截图
后续改进(启动性能与加载体验)
功能做完真机用了一阵,发现初始启动的加载明显变慢(侧边栏会话列表、模型选择栏都要等更久),用 DevTools Network 看了一遍请求时序,定位到三组问题,挨个修掉了:
1. WSL 探测全部改为按需触发
原来的实现是应用一启动就在后台无条件跑一轮 WSL 探测:runtime 探测、发行版列表、联网拉微软在线目录、逐个发行版查 opencode——哪怕你从没添加过 WSL 服务器也照跑,等于为用不到的功能在启动路径上付成本。现在启动只做「恢复上次状态」(读配置 + 拉起已添加的服务器),所有探测改到打开设置页时按需触发;联网的在线目录加了 24 小时缓存,先展示后刷新,点「重新检测」才强制联网。
2. 启动级联:全量拆建改增量接入
WSL 服务器就绪注册时,原实现会把所有服务器的 SSE 连接全拆全建、所有会话数据全量重拉一遍,同一份数据在启动窗口期要加载 2~3 次;另外未注册的 WSL 服务器 id 会静默回退到本地端点,把本地数据写到 WSL 的键下(数据串服)。现在未注册服务器不订阅(注册即自动接入),订阅集合按服务器增量 diff——加入只连新的,移出只拆旧的;wsl-state 全量推送的重复同步也加了短路,无变更零副作用。
3. 会话列表的加载态与错误态
网络抖动时每个文件夹的重试间隙会闪现「此文件夹中没有对话」,重试耗尽后失败还会被永久伪装成空数据。现在重试期间保持加载态,「此文件夹中没有对话」只在真的确认没有会话时出现,加载失败如实显示「加载失败,点击重试」。
提交前对这批改动做了一轮独立审查,顺手修了几个边界问题:pane 里还开着已失效服务器时 SSE 集合可能被清空(现在兜底回退 active server)、离线机器上后台刷新会反复拉起挂住的联网进程(加了在途去重)等。
踩过的坑(挑几个印象深的)
#[serde(rename_all = "camelCase")],命名有机械保证。wsl --list --online要联网:网络慢时超时会把已经成功的本地列表一起丢掉,还伪装成「没有发行版」。现在本地列表和在线目录解耦容错,谁失败只报谁。Stdio::piped():子进程输出全拿空串,探测「成功」但数据全空,上层误判成没数据就无限自动刷新,整个弹窗卡死。run_process已补回归测试;sidecar 是内联 Stdio 配置,不被该测试覆盖。invoke_handler注册 WSL 命令——Tauri 的 invoke_handler 是整体替换语义,第二次调用会把第一次注册的全部命令覆盖掉,安装包表现为「进程在但窗口永远不出来」。现在 WSL 命令合并进同一个generate_handler!列表。wsl.exe的管道输出是 UTF-16LE(可能带 BOM)和 UTF-8 混杂,不解码就是乱码。验证
提交组织
为方便审计拆成九个提交:
chore: exclude local opencode reference dir from git, eslint and vitest—— 排除我做官方源码对照用的本地目录feat(wsl): backend server management commands for Windows desktop—— src-tauri 全部(7 文件)feat(wsl): frontend server sync, settings UI and connections integration—— src 全部 + package.json(21 文件)fix(wsl): sidebar group and SSE follow WSL server registered after boot—— WSL 服务器启动后就绪后自动注册进侧边栏并接通事件流perf(wsl): 按需预热——启动零探测、设置页意图驱动、在线目录 TTL 缓存—— 启动路径只恢复状态,探测按需perf(sessions): 修复启动级联——未注册服务器过滤、upsert 短路、订阅集合增量管理—— 消灭启动窗口期的重复加载与数据串服fix(sidebar): 会话列表加载态与错误态——重试间隙不再闪现空态文案fix(wsl): 评审修正——在线目录 revalidate 去重、prewarm 跳过就绪服务器、测试门控简化fix(sessions): 评审修正——过滤后兜底 active server、enabled 关闭中断重试、去除空 catch前后端拆开但没拆更细的原因:wslStore 和 serverStore 互相引用对方本次新增的方法,行级拆分会产生无法通过 typecheck 的中间提交;性能与体验修复(5-9)按主题拆分,每个提交都能独立通过校验。
Closes #161
关于 AI 辅助
这次的排查和实现过程使用了 AI 辅助(对照官方源码做差距清单、定位跨语言序列化问题等),但上面每一个功能点都是我在真机上逐一实测验收过的。