From c4bba24da5236a1be3a11ca894cb65329539aeb2 Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Tue, 11 Aug 2026 22:01:44 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(ui-mac):=20#925=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E9=92=89=E5=9C=A8=E7=9C=9F=E6=AD=A3=E6=8C=81?= =?UTF-8?q?=E6=9C=89=E4=BC=9A=E8=AF=9D=E7=9A=84=20server=20=E4=B8=8A=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20legacy=20sessionHref=20=E4=BA=A7=E7=94=9F?= =?UTF-8?q?=E5=99=A8=E5=88=A0=E9=99=A4,=E4=BA=94=E4=B8=AA=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E8=80=85=E5=85=A8=E9=83=A8=E6=B6=88=E8=B4=B9=20projec?= =?UTF-8?q?ts=20server=20=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../renderer/alpha-ui/alpha-new-session.tsx | 10 +- .../alpha-session-search-test-runtime.tsx | 3 +- .../renderer/automations/automation-panel.tsx | 22 ++- packages/ui-mac/src/renderer/index.tsx | 8 +- .../src/renderer/sidebar/alpha-sidebar.tsx | 70 +++++++-- .../sidebar/overlay-close-test-runtime.tsx | 7 +- packages/ui-mac/src/renderer/sidebar/route.ts | 13 +- .../sidebar/shell-commands-test-runtime.tsx | 146 +++++++++++++++-- .../renderer/sidebar/shell-commands.test.ts | 147 +++++++++++++++++- .../sidebar/start-draft-test-runtime.tsx | 4 +- .../test-component/overlay-close.cases.ts | 11 +- scripts/gate-files.tsv | 2 +- 12 files changed, 396 insertions(+), 47 deletions(-) diff --git a/packages/ui-mac/src/renderer/alpha-ui/alpha-new-session.tsx b/packages/ui-mac/src/renderer/alpha-ui/alpha-new-session.tsx index 5970cccc04b3..b3b80da52b26 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/alpha-new-session.tsx +++ b/packages/ui-mac/src/renderer/alpha-ui/alpha-new-session.tsx @@ -28,11 +28,11 @@ export function AlphaNewSession(props: { projects: AlphaProjectsApi /** #891:与首页同一条线 —— 本页发第一条也走 `props.projects.startChat`,会话因此落在**那份 * store 连着的 server** 上,composer 拿这个 key 给新会话登记开局档位/只读档。 - * 刻意**不用** `tabs.draft(draftId).server`:那是建 draft 时的 active server(`alpha-sidebar` - * 的 `newDraft({ server: server.key, … })`),与 store 实际连的 sidecar 在 WSL/remote 下不是 - * 同一个;拿它当身份,登记就落在一把会话页永远算不出来的钥匙下面。 - * (`promoteDraft` 今天仍按 `draft.server` 建 session tab —— 那是**导航**的缺陷,与 `#894` - * 同一类,不在本票边界内。) */ + * 刻意**不用** `tabs.draft(draftId).server`:登记的钥匙必须与会话页 adopt 时算出的逐字节 + * 相同,身份来源只有这一个。(#925 起 `alpha-sidebar` 的 `startDraft` 已把 draft 的 server + * 段也钉到 projects store 的 key 上 —— 两者在生产里同值,upstream `promoteDraft` 按 + * `draft.server` 建的 session tab 与导航因此落在真正持有会话的那台;但那是**导航**那条线的 + * 正确性,composer 的登记身份仍以本 prop 为准,不回头读 draft。) */ serverKey: () => string | undefined draftId: string promoteDraft: (session: { directory: string; sessionId: string }) => void diff --git a/packages/ui-mac/src/renderer/alpha-ui/alpha-session-search-test-runtime.tsx b/packages/ui-mac/src/renderer/alpha-ui/alpha-session-search-test-runtime.tsx index 8eecfc52efb4..9acf9ab30cde 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/alpha-session-search-test-runtime.tsx +++ b/packages/ui-mac/src/renderer/alpha-ui/alpha-session-search-test-runtime.tsx @@ -140,7 +140,8 @@ function Shell(props: { search?: boolean }) { surfaces={{ home: leaf("home"), session: leaf("session"), newSession: leaf("new-session") }} > - + {/* #925:侧栏与搜索同源 —— 都消费 projects store 连着的那个 server 的 key。 */} + {props.search === false ? null : } diff --git a/packages/ui-mac/src/renderer/automations/automation-panel.tsx b/packages/ui-mac/src/renderer/automations/automation-panel.tsx index b93ce68154ee..60e799adb305 100644 --- a/packages/ui-mac/src/renderer/automations/automation-panel.tsx +++ b/packages/ui-mac/src/renderer/automations/automation-panel.tsx @@ -13,7 +13,7 @@ import type { AutomationRunRecord, AutomationSchedule, AutomationTask } from ".. import { AUTOMATION_DEFAULTS } from "../../shared/automation-types" import { describeSchedule, isValidCron, parseCron } from "../../shared/automation-schedule" import { parseAutomationText } from "../../shared/automation-nl" -import { sessionHref } from "../sidebar/route" +import { hrefFor } from "../../shared/route-manifest" import { subscribeAuthState } from "../auth-recovery" import { Svg } from "../extensions/ext-presentation" import { automationOpen, setAutomationOpen } from "./automation-state" @@ -100,7 +100,14 @@ function statusDot(r: AutomationRunRecord | undefined): { cls: string; label: st return { cls: "err", label: r.status === "timeout" ? t("alpha.auto.lastTimeout") : t("alpha.auto.lastFailed") } } -export function AutomationPanel() { +export function AutomationPanel(props: { + /** #925:自动化 run 的会话建在**主进程内嵌 sidecar** 上(automation-scheduler.ts 的 + * `awaitServer` 等的就是 serverReady 那台)——renderer 侧它的身份就是 `projectsServerKey` + * (renderer/index.tsx 由 store 的 baseUrl 反查)。「回跳会话」的 href 必须钉在它上面: + * 过去走 legacy `/{目录}/session/{id}`,壳按「点击时的 active server」反推,WSL/remote 下 + * 跳去的机器上根本没有这条 run 的会话。 */ + serverKey: () => string | undefined +}) { const navigate = useNavigate() const [tasks, setTasks] = createSignal([]) const [pausedAll, setPausedAll] = createSignal(false) @@ -313,10 +320,17 @@ export function AutomationPanel() { backToList() } - const openSession = (task: ListedTask, rec: AutomationRunRecord) => { + const openSession = (_task: ListedTask, rec: AutomationRunRecord) => { if (!rec.sessionID) return + // #925:key 在点击那一刻取。缺席 ⇒ 引擎侧身份不成立(sidecar 未就绪/重启中)——run 列表走 + // IPC 照样能显示,所以这里**到得了**;如实提示,不挑一个 server 瞎跳(fail-closed)。 + const key = props.serverKey() + if (!key) { + pushToast({ kind: "error", title: t("alpha.home.engineUnavailable") }) + return + } setAutomationOpen(false) - navigate(sessionHref(task.target.projectDir, rec.sessionID)) + navigate(hrefFor.session(key, rec.sessionID)) } const projectName = (dir: string) => dir.split("/").filter(Boolean).pop() ?? dir diff --git a/packages/ui-mac/src/renderer/index.tsx b/packages/ui-mac/src/renderer/index.tsx index 2589865a218d..5242006214f0 100644 --- a/packages/ui-mac/src/renderer/index.tsx +++ b/packages/ui-mac/src/renderer/index.tsx @@ -523,7 +523,9 @@ render(() => { - + {/* #925(第三个消费者):侧栏的会话 href / 新会话导航 / draft 的 server 段。 + 侧栏列的与建的会话全在这份 store 的 server 上,身份只能是这个 key。 */} + {/* REQ-085:AlphaHome 不再作为 children Portal 注入 —— 它是正式 `home` surface (见上方 surfaceComponents);legacy 模式下 upstream Home 原样呈现。 */} @@ -541,7 +543,9 @@ render(() => { setExtHubOpen(false)} /> - + {/* #925(第四个消费者):「回跳会话」。自动化 run 的会话由主进程建在内嵌 + sidecar 上,renderer 侧那台机器的身份就是这个 key。 */} + {/* REQ-126 AC3(#654):产物工作台不再全页挂载 —— 产物只经会话右栏 artifacts 面板到达。 */} {/* REQ-125 C7/C8:会话页 composer = AlphaComposer 经 seam 会话页直挂(session surface diff --git a/packages/ui-mac/src/renderer/sidebar/alpha-sidebar.tsx b/packages/ui-mac/src/renderer/sidebar/alpha-sidebar.tsx index fbebb5584c16..1386898c010e 100644 --- a/packages/ui-mac/src/renderer/sidebar/alpha-sidebar.tsx +++ b/packages/ui-mac/src/renderer/sidebar/alpha-sidebar.tsx @@ -19,8 +19,8 @@ import { Mark } from "../logo-alpha" import { ALPHA_PATHS } from "../../shared/alpha-config" import { useAlphaEndpoints } from "../use-alpha-endpoints" import { subscribeAuthState } from "../auth-recovery" -import { homeHref, projectLabel, sessionHref } from "./route" -import { parseRoute } from "../../shared/route-manifest" +import { homeHref, projectLabel } from "./route" +import { hrefFor, parseRoute } from "../../shared/route-manifest" import { clearHiddenProjects, hiddenProjects, @@ -171,7 +171,15 @@ function until(condition: () => boolean): { promise: Promise; cancel: () = return { promise, cancel } } -export function AlphaSidebar(props: { projects: AlphaProjectsApi }) { +export function AlphaSidebar(props: { + projects: AlphaProjectsApi + /** #925:`props.projects` 这份 store 连着的那个 server 的 key(`renderer/index.tsx` 的 + * `projectsServerKey`,由 store 自己的 baseUrl 反查)。侧栏列的会话、`createSession` 建的 + * 会话**全部**长在这个 server 上 —— 它们的 href 因此必须钉在这个 key 上。这里刻意**不读** + * `useServer().key`(当前 active server):WSL/remote 下两者不是同一个,按 active 拼 href + * 就是 #894 修掉的那个缺陷(跳去没有该会话的机器;那边若恰好有同 id 会话,污染的是它)。 */ + serverKey: () => string | undefined +}) { const { store, reload, createSession, renameSession, shareSession, deleteSession, copySession } = props.projects const routerNavigate = useNavigate() const location = useLocation() @@ -465,6 +473,17 @@ export function AlphaSidebar(props: { projects: AlphaProjectsApi }) { const r = parseRoute(location.pathname) if (r.kind === "directory") return { dir: r.directory } if (r.kind === "session" && r.directory) return { dir: r.directory, sessionId: r.id } + // #925:canonical `/server/:serverKey/session/:id` 路径里**没有**目录段(这正是它的意义: + // 身份是 server+id,不再靠目录反推)。会话属于哪个项目改由 store 反查 —— 否则落在 canonical + // 路由上时高亮/已读水位/自动展开全部失灵(#659/#894 起搜索与首页已经这么导航了)。 + if (r.kind === "session" && r.id) { + const sid = r.id + for (const project of store.projects) { + const match = project.sessions.find((session) => session.id === sid) + if (match) return { dir: match.directory, sessionId: sid } + } + return { sessionId: sid } + } return {} }) @@ -647,7 +666,20 @@ export function AlphaSidebar(props: { projects: AlphaProjectsApi }) { pushToast({ kind: "error", title: t("alpha.sidebar.newChatWorkspaceFailed") }) return } - await tabs.newDraft({ server: server.key, directory: target.directory }) + // #925:draft 的 server 段 = **projects store 连着的那个 server**,不再是 active server。 + // 新对话页(AlphaNewSession)发第一条走 `props.projects.startChat` —— 会话**恒**建在 + // store 连着的 server 上,与 active 是谁无关(alpha-composer.tsx home 模式,#891 勘破)。 + // 而上游 `promoteDraft`(packages/app/src/app.tsx createDraftRoute)按 `draft.server` 建 + // session tab 并导航到 `/server/{draft.server}/session/{id}`:draft 带着 active server + // 时,tab 与导航都落在一台**没有这个会话**的机器上 —— 这正是 #894 那个缺陷在 draft + // 晋升这条线上的样子。key 缺席(sidecar 未就绪)⇒ 不建 draft、不猜:那时 store 无 + // client,startChat 也发不出去,建出来的只会是一个必然坏掉的对话页。 + const projectsKey = props.serverKey() + if (!projectsKey) { + pushToast({ kind: "error", title: t("alpha.sidebar.newChatFailed") }) + return + } + await tabs.newDraft({ server: ServerConnection.Key.make(projectsKey), directory: target.directory }) } finally { draftInFlight = false } @@ -688,11 +720,21 @@ export function AlphaSidebar(props: { projects: AlphaProjectsApi }) { // 关闭属于**导航意图**,不属于导航结果,所以提到第一个 await 之前。 closeAllOverlays() setProjectExpanded(worktree, true) + // #925:server 身份在**发起创建那一刻**定格 —— `createSession` 在 store 连着的 server 上建 + // 会话,await 期间 store 可能改连别处(sidecar 重启换端口),完成后再读就是别人的 key。 + // key 缺席 ⇒ store 无 client,创建注定失败:如实报失败,不建、不猜。 + const submittedServerKey = props.serverKey() + if (!submittedServerKey) { + pushToast({ kind: "error", title: t("alpha.sidebar.newChatFailed") }) + return + } const id = await createSession(worktree) // REQ-126 不变量 1(#656):失败**不再**回退到 `newSessionHref(worktree)` —— 那个 href 就是 // legacy admission 路由(sidebar/route.ts 的 directorySession),回退等于给 alpha 自己留了 // 第二条进 admission 的入口。失败就如实说失败,不假装"已经给你开了个草稿"。 - if (id) navigate(sessionHref(worktree, id)) + // #925:导航消费创建那一刻的 server 快照,拼 canonical 的 `/server/:serverKey/session/:id`, + // 不再走 legacy `/{目录}/session/{id}` 让壳按 active server 反推。 + if (id) navigate(hrefFor.session(submittedServerKey, id)) else pushToast({ kind: "error", title: t("alpha.sidebar.newChatFailed") }) } @@ -746,9 +788,19 @@ export function AlphaSidebar(props: { projects: AlphaProjectsApi }) { void props.projects.reload() } - const openSession = (e: MouseEvent, directory: string, id: string) => { + // #925:会话 href 钉在**持有这个会话的那个 server**(= props.serverKey,store 反查出来的 + // 身份)上,canonical 形态,壳不再反推。key 缺席 ⇒ 返回 undefined / 不导航:侧栏的会话行 + // 本来就来自那份 store,store 没连上时一行都不会渲染出来 —— 真到了这里只说明身份不成立, + // 随便挑一个 server 跳过去就是在制造 #894 那个事故(fail-closed,不猜)。 + const sessionLink = (id: string): string | undefined => { + const key = props.serverKey() + return key ? hrefFor.session(key, id) : undefined + } + + const openSession = (e: MouseEvent, id: string) => { e.preventDefault() - navigate(sessionHref(directory, id)) + const href = sessionLink(id) + if (href) navigate(href) } return ( @@ -1139,9 +1191,9 @@ export function AlphaSidebar(props: { projects: AlphaProjectsApi }) { <> openSession(e, session.directory, session.id)} + onClick={(e) => openSession(e, session.id)} > {sessionDisplayTitle(session.title)} diff --git a/packages/ui-mac/src/renderer/sidebar/overlay-close-test-runtime.tsx b/packages/ui-mac/src/renderer/sidebar/overlay-close-test-runtime.tsx index b841107bebb6..b9c04e607b32 100644 --- a/packages/ui-mac/src/renderer/sidebar/overlay-close-test-runtime.tsx +++ b/packages/ui-mac/src/renderer/sidebar/overlay-close-test-runtime.tsx @@ -21,12 +21,15 @@ export type { MemoryHistory } export function OverlayCloseHarness(props: { history: MemoryHistory; projects: AlphaProjectsApi }): JSX.Element { const server = (): ServerInfo | undefined => ({ baseUrl: "http://127.0.0.1:65535" }) + // #925:生产里两者的 serverKey 都是 renderer/index.tsx 的 `projectsServerKey`;本闸门只测 + // 「覆盖层随导航关闭」,server 身份不是被测语义,给与 providers 替身一致的 "sidecar"。 + const serverKey = () => "sidecar" // 侧栏与两个覆盖层在生产里是 AppInterface 下的兄弟(renderer/index.tsx:500-508),这里同构。 const Shell = (p: { children?: JSX.Element }) => ( <> - + setExtHubOpen(false)} /> - + {p.children} ) diff --git a/packages/ui-mac/src/renderer/sidebar/route.ts b/packages/ui-mac/src/renderer/sidebar/route.ts index 4461c71c756c..95fd4a141d81 100644 --- a/packages/ui-mac/src/renderer/sidebar/route.ts +++ b/packages/ui-mac/src/renderer/sidebar/route.ts @@ -16,10 +16,15 @@ export function base64UrlDecode(value: string): string { return decoded } -/** Route to an existing session inside a project directory. */ -export function sessionHref(directory: string, sessionID: string): string { - return hrefFor.legacySession(directory, sessionID) -} +// #925:这里曾有 `sessionHref(directory, sessionID)` —— legacy 会话 href(`/{目录}/session/{id}`, +// 路径里没有 server 段)在 ui-mac 的**唯一**产生器。它产出的 href 逼着壳事后反推 server +// (`packages/app/src/utils/session-route.ts` 的 `legacySessionServer`:同 id 的 tab,否则回落到 +// 「完成时的 active server」),多 server(WSL/remote)下会把会话导向没有它的机器;那台机器上若 +// 恰好有同 id 会话,打开并污染的是那个无关会话(#894 修的首页那一条,与本器的四个消费者同形)。 +// 咽喉在产生器:该导出已**删除**,不是改签名 —— 两个参数都是 string,改签名会让漏改的调用点把 +// 目录当 serverKey 编进 canonical 路由,编译期抓不住。要给会话拼 href,用 +// `shared/route-manifest` 的 `hrefFor.session(serverKey, sessionId)`:没有 server 身份就拼不出来, +// 对新调用点默认拒绝。 /** * Route to a project with no session id. Under the new layout opencode's SessionRoute diff --git a/packages/ui-mac/src/renderer/sidebar/shell-commands-test-runtime.tsx b/packages/ui-mac/src/renderer/sidebar/shell-commands-test-runtime.tsx index 98da48fbbaca..d6b90ec2ee49 100644 --- a/packages/ui-mac/src/renderer/sidebar/shell-commands-test-runtime.tsx +++ b/packages/ui-mac/src/renderer/sidebar/shell-commands-test-runtime.tsx @@ -23,7 +23,9 @@ import { AlphaSessionSearch } from "../alpha-ui/alpha-session-search" import { setSettingsOpen, settingsOpen } from "../alpha-ui/settings-state" import { PermChip } from "../alpha-ui/alpha-composer" import { READONLY_AGENT, buildPromptRequest, composerPerm, setComposerPerm } from "../alpha-ui/composer-state" -import { setSidebarCollapsed } from "./sidebar-state" +import { setSidebarCollapsed, setProjectExpanded } from "./sidebar-state" +import { AutomationPanel } from "../automations/automation-panel" +import { setAutomationOpen } from "../automations/automation-state" import type { AlphaProject, AlphaProjectsApi } from "./use-projects" import { ALPHA_SETTINGS_DEFAULTS } from "../../shared/settings-adapters" import { hrefFor } from "../../shared/route-manifest" @@ -35,6 +37,11 @@ export { PermChip } export const FIXTURE_DIRECTORY = "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/repos/alpha-code" export const PICKED_DIRECTORY = "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/repos/picked-by-user" export const CREATED_SESSION_ID = "ses_created" +/** #925:draft 晋升探针交给上游 `promoteDraft` 的会话 id(与 CREATED_SESSION_ID 相异,免得 + * 两条判据互相蒙混)。 */ +export const PROMOTED_SESSION_ID = "ses_promoted" +/** #925:自动化 run 记录里的会话 id。 */ +export const AUTOMATION_SESSION_ID = "ses_auto_run" const connection: ServerConnection.Any = { displayName: "Local Server", @@ -43,6 +50,33 @@ const connection: ServerConnection.Any = { http: { url: "http://127.0.0.1:4096" }, } +/* ── #925:多 server 配置 ──────────────────────────────────────────────────────── + legacy sessionHref 那一类缺陷只在「active server ≠ 会话真正所在的 server」时可见,单 server + 壳结构上测不出它。这里给三台互不相同的 WSL 连接(key 分别是 wsl:ubuntu / wsl:fedora / + wsl:arch),按用例组合成不同的 {active, projects} 对 —— 各用例**点击那一刻**的 projects key + 互不相同,把 href 写死成任何单个字面量的实现无法同时满足(#894 R1 审计那条形态⑨)。 */ +const wslUbuntu: ServerConnection.Any = { + displayName: "WSL Ubuntu", + type: "sidecar", + variant: "wsl", + distro: "ubuntu", + http: { url: "http://127.0.0.1:5096" }, +} +const wslFedora: ServerConnection.Any = { + displayName: "WSL Fedora", + type: "sidecar", + variant: "wsl", + distro: "fedora", + http: { url: "http://127.0.0.1:6096" }, +} +const wslArch: ServerConnection.Any = { + displayName: "WSL Arch", + type: "sidecar", + variant: "wsl", + distro: "arch", + http: { url: "http://127.0.0.1:7096" }, +} + const fixtureProject: AlphaProject = { id: "prj_alpha-code", worktree: FIXTURE_DIRECTORY, @@ -63,8 +97,6 @@ const [routerPath, setRouterPath] = createSignal("") const [navigationIntents, setNavigationIntents] = createSignal([]) export { pickerCalls, createdIn, exportedLogs, routerPath, navigationIntents } -/** 该连接的 canonical serverKey(会话搜索结果的来源身份)。 */ -const SERVER_KEY = ServerConnection.key(connection) /** 目录选择器的下一次返回值:`null` = 用户取消。 */ const [pickerResult, setPickerResult] = createSignal(PICKED_DIRECTORY) @@ -85,6 +117,38 @@ const projects = { }, } as unknown as AlphaProjectsApi +/** #925:展开侧栏里某个项目(生产状态函数;会话行只在展开后渲染)。 */ +export function expandProject(worktree: string) { + setProjectExpanded(worktree, true) +} + +/** #925:打开自动化面板(生产模块信号;面板本体与「回跳会话」按钮都是生产组件)。 */ +export function openAutomationPanel() { + setAutomationOpen(true) +} + +/** #925:自动化任务夹具 —— 一条已完成的 run,带会话 id(「回跳会话」按钮的渲染条件)。 */ +const automationTaskFixture = { + id: "auto-1", + name: "夜间总结", + nlText: "每天 21 点总结当天工作", + schedule: { kind: "cron", expr: "0 21 * * *" }, + target: { projectDir: FIXTURE_DIRECTORY, agent: "alpha-automation" }, + prompt: "总结当天工作", + execution: "local", + permissionProfile: "readonly", + budget: { maxDurationMin: 30 }, + overlapPolicy: "skip", + catchUpPolicy: "skip", + notify: { system: true }, + enabled: true, + createdAt: "2026-08-10T21:00:00.000Z", + lastRun: { at: "2026-08-10T21:00:00.000Z", status: "ok", sessionID: AUTOMATION_SESSION_ID, summary: "已完成" }, + history: [{ at: "2026-08-10T21:00:00.000Z", status: "ok", sessionID: AUTOMATION_SESSION_ID, summary: "已完成" }], + nextFireAt: null, + running: false, +} + function RouteProbe() { const location = useLocation() // 导航请求必须在这里取,不能只看 location:上游会对 legacy 会话/草稿路由立刻再 redirect 一次, @@ -170,6 +234,25 @@ const platform: Platform = { /** 叶不在本闸门射程内:给最小标记件,免得把三棵叶子树一起拖进来。 */ const leaf = (name: string) => () =>
+/** #925:新对话叶的最小探针。`promoteDraft` 这个 prop 是**上游生产 wrapper** + * (packages/app/src/app.tsx createDraftRoute)交下来的 —— 点这个按钮走的是真实 + * tabs.promoteDraft:真实 tab 交换 + 真实导航(tabHref 按 tab.server 拼 canonical 路由)。 + * 叶只发起,判据落在真实 router 收到的落点上。 */ +const NewSessionPromoteProbe = (props: { + draftId: string + promoteDraft: (session: { directory: string; sessionId: string }) => void +}) => ( +
+ +
+) + /** * 设置面的**权威读**必须真的落定:设置页里那张快捷键表也是一批「指向命令」的入口(用户能改、 * 能存),而上游只对**已注册**的命令应用自定义键位 —— 表里留一条已退休的 id,就是又一个 @@ -200,7 +283,12 @@ export function installPreloadStub() { start: async () => {}, logout: async () => {}, }, - automations: { list: async () => ({ tasks: [] }), onEvent: noop }, + automations: { + // #925:完整形状(list 消费 r.state.pausedAll / r.loginItem);一条带 run 历史的任务, + // 「回跳会话」用例点的就是它。 + list: async () => ({ tasks: [automationTaskFixture], state: { pausedAll: false }, loginItem: false }), + onEvent: noop, + }, account: { summary: async () => null }, contracts: { health: async () => null, subscribe: noop }, endpoints: async () => undefined, @@ -272,29 +360,48 @@ export function resetHarness() { setComposerPerm("ask") setRegisteredCommands([]) setDraftTabs([]) + // #925:自动化面板的开合是模块级信号,跨用例残留。 + setAutomationOpen(false) triggerCommand = undefined } -function Shell(props: { sidebar?: boolean; legacySettingsEntry?: boolean }) { +function Shell(props: { + sidebar?: boolean + legacySettingsEntry?: boolean + /** #925:壳里挂的全部连接;缺省 = 单 sidecar(既有用例的世界)。 */ + servers?: ServerConnection.Any[] + /** #925:active(default)server;缺省 sidecar。 */ + activeServer?: ServerConnection.Any + /** #925:`projects` 这份 store「连着」的 server —— 生产里 `projectsServerKey` 反查出来的那台。 */ + projectsServer?: ServerConnection.Any +}) { + const servers = props.servers ?? [connection] + const active = props.activeServer ?? connection + const projectsServer = props.projectsServer ?? connection + // 与生产同构:serverKey 是「由连接算 key」的 accessor(renderer/index.tsx 的 projectsServerKey); + // 判据侧的锚点用独立字面量,不读这里(否则是自指等价链)。 + const projectsServerKey = () => ServerConnection.key(projectsServer) return ( - {props.sidebar === false ? null : } + {props.sidebar === false ? null : } {/* 与 renderer/index.tsx 的 settings surface 同一接法。 */} setSettingsOpen(false)} api={settingsApi} /> {/* `command.palette` 的唯一注册点(#659)。壳组成必须与 renderer/index.tsx 一致 —— 设置页快捷键表里就有这一条,少挂它会把「产品少注册了一条」误判成真。 负对照那个壳同样不挂它(它复现的正是「alpha 壳级注册还不存在」的世界)。 */} - {props.sidebar === false ? null : SERVER_KEY} />} + {props.sidebar === false ? null : } + {/* #925:自动化面板与生产同构地挂在壳上(renderer/index.tsx),「回跳会话」用例点它。 */} + {props.sidebar === false ? null : } {props.legacySettingsEntry ? : null} @@ -317,6 +424,25 @@ export function AlphaShell() { return } +/* ── #925:多 server 壳 ───────────────────────────────────────────────────────── + 三个变体的 {active, projects} 对各不相同,且 projects key 三者互异("sidecar" / + "wsl:fedora" / "wsl:arch")—— 判据锚在各自的独立字面量上,写死任何单值的实现无法全绿。 */ + +/** active = wsl:ubuntu,projects store 连的是本地 sidecar("sidecar")。 */ +export function AlphaShellRemoteActive() { + return +} + +/** active = sidecar,projects store 连的是 wsl:fedora。 */ +export function AlphaShellRemoteProjects() { + return +} + +/** active = wsl:ubuntu,projects store 连的是 wsl:arch(自动化「回跳会话」用例)。 */ +export function AlphaShellAutomationRemote() { + return +} + /** * 负对照 = **本票之前的那个世界**:真实壳、首页路由,但不挂 alpha 侧栏(于是没有 alpha 的壳级 * 注册),只放一个按旧接线发 `settings.open` 的入口。上游那三处注册全在已被 alpha 顶替的叶里, diff --git a/packages/ui-mac/src/renderer/sidebar/shell-commands.test.ts b/packages/ui-mac/src/renderer/sidebar/shell-commands.test.ts index b7fd70b21994..629ea4b6ccea 100644 --- a/packages/ui-mac/src/renderer/sidebar/shell-commands.test.ts +++ b/packages/ui-mac/src/renderer/sidebar/shell-commands.test.ts @@ -24,7 +24,8 @@ import { build } from "vite" import type { render } from "solid-js/web" import type * as Runtime from "./shell-commands-test-runtime" import { RETIRED_MENU_COMMANDS, publishedMenuCommands } from "../../shared/desktop-menu-policy" -import { homeHref, newSessionHref, sessionHref } from "./route" +import { homeHref, newSessionHref } from "./route" +import { parseRoute } from "../../shared/route-manifest" type TestRuntime = typeof Runtime & { render: typeof render } @@ -127,6 +128,20 @@ async function openAccountMenu() { await flush() } +/** #925:真实 router 收到的导航请求里,凡**解析成会话路由**的,抽出落点三元组。编码(生产 + * `hrefFor`)与解码(生产 `parseRoute`)是两条相反的路,不构成自指等价链;判据锚在独立的 + * server key 字面量上。 */ +function sessionLandings(intents: string[]) { + return intents + .map((href) => parseRoute(href)) + .filter((route) => route.kind === "session") + .map((route) => ({ + routeId: route.identity.routeId, + serverKey: (route as { serverKey?: string }).serverKey, + id: (route as { id?: string }).id, + })) +} + describe("侧栏账户菜单「设置」:改接 alpha 自有设置面,与路由无关", () => { test("在首页点设置 → 真实的 alpha 设置面出现在 DOM 里", async () => { // 首页是「原来死得最彻底」的那条路由:上游三叶已被顶替、legacy layout 不挂载。 @@ -218,9 +233,14 @@ describe("空项目态「打开项目」:改走 alpha 自己的目录选择", () expect(runtime.pickerCalls()).toBe(1) expect(runtime.createdIn()).toEqual([runtime.PICKED_DIRECTORY]) // 真实 router 真的收到了去那条会话路由的导航。判据取 router 自己的 beforeLeave,而不是 - // 最终 location:上游对 legacy 会话路由会立刻再 redirect 一次,中间那一站在同一批次里就被 - // 覆盖掉了(这条 admission 跳转正是 REQ-126 CODE-C 在处置的东西,不是本票的命题)。 - expect(runtime.navigationIntents()).toContain(sessionHref(runtime.PICKED_DIRECTORY, runtime.CREATED_SESSION_ID)) + // 最终 location。#925 起落点是 canonical 的 `/server/:serverKey/session/:id`(单 server 壳, + // projects key = "sidecar");legacy 形状(壳按 active 反推的那种)一条都不许有 —— + // 多 server 下的判别在下方 #925 一节(那里各用例的 key 互不相同)。 + const landed = sessionLandings(runtime.navigationIntents()).find((route) => route.id === runtime.CREATED_SESSION_ID) + expect(landed).toBeDefined() + expect(landed!.routeId).toBe("session") + expect(landed!.serverKey).toBe("sidecar") + expect(sessionLandings(runtime.navigationIntents()).filter((route) => route.routeId === "legacy-session")).toEqual([]) }) test("用户取消选择 → 不建会话、不跳转", async () => { @@ -340,6 +360,10 @@ describe("桌面菜单:发布面上的每一条在真实壳里都接得住", () expect(runtime.navigationIntents()).not.toContain(newSessionHref(runtime.FIXTURE_DIRECTORY)) }) + // #925 注:上面 session.new 那条断言 `added[0].server === "sidecar"` 在单 server 壳里 + // active 与 projects key 同值,判别不了「draft 的 server 段来自谁」;多 server 的判别在 + // 下方 #925 一节的 draft 晋升用例(active=sidecar、projects=wsl:fedora,两值相异)。 + // common.goBack / common.goForward 已从桌面菜单**退休**(shared/desktop-menu-policy.ts), // 所以这里没有它们的用例:上游 Titlebar 在首页/新对话页抢先注册同名 id 并走只有 `["/"]` 的私有 // history —— 那不是「语义相同、谁赢都行」,是同一菜单项在不同路由两种行为、其中一种还是空转。 @@ -347,3 +371,118 @@ describe("桌面菜单:发布面上的每一条在真实壳里都接得住", () // 行为用例:只在退休那两条用例里断言过左上工具簇仍有 3 个按钮,从未点击并观察导航。这是刻意的 // 诚实分层,已在 docs/architecture/upstream-integration.md 的「Known not covered」列明。 }) + +/* ── #925:legacy sessionHref 的全部生产者清零 —— 会话导航钉在真正持有会话的 server 上 ──── + 缺陷同形于 #894:legacy `/{目录}/session/{id}` 路径里没有 server 段,壳只能事后反推 + (`packages/app/src/utils/session-route.ts` 的 `legacySessionServer`:同 id 的 tab,否则回落 + 「完成时的 active server」)。多 server(WSL/remote)下反推恒给错机器;那台机器上若恰好有 + 同 id 会话,打开并污染的是那个无关会话。修法是删掉产生器(sidebar/route.ts 的 + `sessionHref`)并让四个消费者(侧栏点击 / 侧栏锚点 / 新会话导航 / 自动化回跳)+ draft 的 + server 段全部消费 projects store 的 server 身份。 + + 判据纪律: + · 落点判据 = 真实 router 收到的 href 经生产 `parseRoute` 解回来的 {routeId, serverKey, id} + (编码与解码两条相反的路);锚点是**本文件的独立字面量**,不 import 生产常量。 + · 各用例点击那一刻的 projects key 互不相同("sidecar" / "wsl:fedora" / "wsl:arch"),且都与 + 该壳的 active server 相异 —— 写死任何单值、或按 active 反推的实现,至少两条当场红。 + · 每条都扫一遍「legacy 形状的会话导航 = 0」:那是反推入口本身。 */ +describe("#925 多 server 下的会话导航:落在真正持有该会话的 server 上", () => { + test("点侧栏里没开过 tab 的会话(active=wsl:ubuntu,store 连 sidecar)→ 锚点与真实落点都钉在 sidecar", async () => { + await mountShell(() => runtime.AlphaShellRemoteActive()) + runtime.expandProject(runtime.FIXTURE_DIRECTORY) + await settle() + + // ① 锚点本身(右键复制/中键打开拿到的就是它):canonical 会话路由,server 段 = sidecar。 + const anchor = document.querySelector("a.alpha-session") + expect(anchor).not.toBeNull() + const anchorLanding = parseRoute(anchor!.getAttribute("href")!) + expect({ kind: anchorLanding.kind, routeId: anchorLanding.identity.routeId }).toEqual({ + kind: "session", + routeId: "session", + }) + expect((anchorLanding as { serverKey?: string }).serverKey).toBe("sidecar") + + // ② 真点击:真实 router 收到的落点同样钉在 sidecar,而不是 active 的 wsl:ubuntu。 + const before = runtime.navigationIntents().length + anchor!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })) + await settle() + const landed = sessionLandings(runtime.navigationIntents().slice(before)).find((route) => route.id === "ses_one") + expect(landed).toBeDefined() + expect(landed!.routeId).toBe("session") + expect(landed!.serverKey).toBe("sidecar") + expect(landed!.serverKey).not.toBe("wsl:ubuntu") + + // ③ 反推入口清零:全程一条 legacy 形状的会话导航都没有。 + expect(sessionLandings(runtime.navigationIntents()).filter((route) => route.routeId === "legacy-session")).toEqual([]) + }) + + test("「打开项目」开出的新会话(active=sidecar,store 连 wsl:fedora)→ 落点钉在 wsl:fedora", async () => { + runtime.setHasProjects(false) + await mountShell(() => runtime.AlphaShellRemoteProjects()) + + openProjectButton()!.click() + await settle() + + expect(runtime.createdIn()).toEqual([runtime.PICKED_DIRECTORY]) + const landed = sessionLandings(runtime.navigationIntents()).find((route) => route.id === runtime.CREATED_SESSION_ID) + expect(landed).toBeDefined() + expect(landed!.routeId).toBe("session") + // 与上一条用例点击那一刻的 key("sidecar")相异 —— 写死单值的实现两条不可能同时绿。 + expect(landed!.serverKey).toBe("wsl:fedora") + expect(landed!.serverKey).not.toBe("sidecar") + expect(sessionLandings(runtime.navigationIntents()).filter((route) => route.routeId === "legacy-session")).toEqual([]) + }) + + test("draft 晋升(active=sidecar,store 连 wsl:fedora):draft 带 store 的 server,上游 promoteDraft 的真实导航落在 wsl:fedora", async () => { + await mountShell(() => runtime.AlphaShellRemoteProjects()) + + // 启动导航自己建 draft(#656)。#925:draft 的 server 段 = store 的 server,不再是 active —— + // 会话是 `projects.startChat` 在 store 的 server 上建的,draft 带 active 时,上游 + // `promoteDraft`(packages/app createDraftRoute)会把 session tab 与导航都落到一台没有 + // 这个会话的机器上。 + await waitFor(() => runtime.draftTabs().length > 0, "启动草稿落地") + const draft = runtime.draftTabs().at(-1)! + expect(draft.server).toBe("wsl:fedora") + expect(draft.server).not.toBe("sidecar") + await waitFor(() => runtime.routerPath() === runtime.draftHref(draft.draftID), "壳落在新草稿页") + + // 探针把「已建成的会话」交回**上游生产 promoteDraft**:真实 tabs 交换 + 真实导航。 + const probe = document.querySelector("[data-harness-promote]") + expect(probe).not.toBeNull() + probe!.click() + await waitFor( + () => sessionLandings(runtime.navigationIntents()).some((route) => route.id === runtime.PROMOTED_SESSION_ID), + "晋升导航落地", + ) + const landed = sessionLandings(runtime.navigationIntents()).find((route) => route.id === runtime.PROMOTED_SESSION_ID) + expect(landed!.routeId).toBe("session") + expect(landed!.serverKey).toBe("wsl:fedora") + expect(landed!.serverKey).not.toBe("sidecar") + expect(sessionLandings(runtime.navigationIntents()).filter((route) => route.routeId === "legacy-session")).toEqual([]) + }) + + test("自动化「回跳会话」(active=wsl:ubuntu,store 连 wsl:arch)→ 落点钉在 wsl:arch", async () => { + await mountShell(() => runtime.AlphaShellAutomationRemote()) + runtime.openAutomationPanel() + await waitFor(() => document.querySelector(".alpha-auto-row") !== null, "自动化任务列表渲染") + + // 进任务详情 → 运行历史 → 点「打开会话」(带 sessionID 的那条 run 才渲染这个按钮)。 + document + .querySelector(".alpha-auto-row")! + .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })) + await waitFor(() => document.querySelector(".alpha-auto-hist .alpha-ext-link") !== null, "运行历史的回跳按钮渲染") + document.querySelector(".alpha-auto-hist .alpha-ext-link")!.click() + await settle() + + const landed = sessionLandings(runtime.navigationIntents()).find( + (route) => route.id === runtime.AUTOMATION_SESSION_ID, + ) + expect(landed).toBeDefined() + expect(landed!.routeId).toBe("session") + // run 的会话由主进程建在 projects store 连着的那台上;三个 #925 用例的 key 各不相同。 + expect(landed!.serverKey).toBe("wsl:arch") + expect(landed!.serverKey).not.toBe("wsl:ubuntu") + expect(landed!.serverKey).not.toBe("sidecar") + expect(sessionLandings(runtime.navigationIntents()).filter((route) => route.routeId === "legacy-session")).toEqual([]) + }) +}) diff --git a/packages/ui-mac/src/renderer/sidebar/start-draft-test-runtime.tsx b/packages/ui-mac/src/renderer/sidebar/start-draft-test-runtime.tsx index bbcce95f62f4..72a338ef8b61 100644 --- a/packages/ui-mac/src/renderer/sidebar/start-draft-test-runtime.tsx +++ b/packages/ui-mac/src/renderer/sidebar/start-draft-test-runtime.tsx @@ -392,7 +392,9 @@ export function AlphaShell(props: { connection?: ServerConnection.Any }) { }} > - + {/* #925:本 harness 是单连接世界 —— `projects` 就连着这一个连接(见上方 #891 注), + 所以侧栏的 serverKey 与它同源;draft 的 server 段自此取自这里,不再读 active。 */} + ServerConnection.key(connection)} /> diff --git a/packages/ui-mac/test-component/overlay-close.cases.ts b/packages/ui-mac/test-component/overlay-close.cases.ts index 603a05cd93bd..95a6239c8918 100644 --- a/packages/ui-mac/test-component/overlay-close.cases.ts +++ b/packages/ui-mac/test-component/overlay-close.cases.ts @@ -71,7 +71,9 @@ mock.module("../src/renderer/alpha-ui/providers", () => ({ useCommand: () => ({ options: [], register: () => {}, trigger: () => {}, show: () => {}, hide: () => {} }), // 侧栏用 `ServerConnection.key` 派生内嵌 sidecar 的稳定 key(不硬编码字面量);这里给同结果的 // 最小替身,与下面 useServer 的 key 对齐,否则同源判定会把本闸门的 server 当成"别的 server"。 - ServerConnection: { key: () => "sidecar" }, + // #925:startDraft 用 `ServerConnection.Key.make(props.serverKey())` 给 draft 钉 server 段; + // 替身与上游同义(Key.make 是 brand 构造,运行时即恒等)。 + ServerConnection: { key: () => "sidecar", Key: { make: (value: string) => value } }, useTabs: () => ({ ready: Object.assign(() => true, { promise: undefined }), newDraft: () => new Promise(() => {}), @@ -124,7 +126,6 @@ function apiNode(path: string[]): unknown { // —— 生产组合体(真实侧栏 + 真实两个覆盖层 + 真实路由)————————————————————————— const runtime = await import("../src/renderer/sidebar/overlay-close-test-runtime") -const { sessionHref } = await import("../src/renderer/sidebar/route") const { hrefFor } = await import("../src/shared/route-manifest") const extHubState = await import("../src/renderer/extensions/ext-hub-state") const automationState = await import("../src/renderer/automations/automation-state") @@ -237,8 +238,10 @@ async function open(overlay: (typeof OVERLAYS)[number]) { await flush() } -const CURRENT_HREF = sessionHref(DIR, CURRENT_SESSION) -const OTHER_HREF = sessionHref(DIR, OTHER_SESSION) +// #925:侧栏会话锚点已是 canonical 的 `/server/:serverKey/session/:id`(legacy 产生器已删); +// key 与 harness 传给侧栏的 serverKey 同值("sidecar")。 +const CURRENT_HREF = hrefFor.session("sidecar", CURRENT_SESSION) +const OTHER_HREF = hrefFor.session("sidecar", OTHER_SESSION) const DRAFT_A = hrefFor.newSession("draft-a") const DRAFT_B = hrefFor.newSession("draft-b") diff --git a/scripts/gate-files.tsv b/scripts/gate-files.tsv index 1c4b76f6d915..b507d72cb608 100644 --- a/scripts/gate-files.tsv +++ b/scripts/gate-files.tsv @@ -108,7 +108,7 @@ 2 packages/ui-mac src/renderer/alpha-ui/reduced-motion-ratchet.test.ts - reduced-motion 棘轮:新动效必须尊重降级偏好 10 packages/ui-mac src/renderer/alpha-ui/alpha-session-search.test.ts - REQ-126 CODE-F:真实壳里 command.palette 确实被注册且侧栏按钮真能触发,跳转 href 带结果来源 server 身份 12 packages/ui-mac src/renderer/sidebar/start-draft.test.ts - REQ-126 AC1/AC4/AC5:新对话与冷启动落地都不再经上游 legacy admission 路由。挂真实 AppInterface + 生产 AlphaSidebar,点真实「新对话」,以 MutationObserver + 每轮采样断言「出现 data-alpha-new-session 之前,上游左栏与 alpha 会话工作区一次都没挂过」;首条用例是控制组(直接进 //session 断言两者确实会出现),没有它后面全是空绿。另含同源 fail-closed(WSL / loopback http 隧道)与两处等待的 barrier 用例。下界 = 实际条数 12,不留余量:留余量则删一格仍能过 -11 packages/ui-mac src/renderer/sidebar/shell-commands.test.ts - REQ-126 AC7:真实壳(生产 AlphaSidebar+AlphaSettings+AlphaSessionSearch)里点**处置表所列的那些入口**,断言可观察结果(设置面 DOM / 目录选择被调 / 真实路由变化);退休入口断言 DOM 不存在且容器仍在;设置页快捷键表断言不含退休 id 且每条都真注册;桌面菜单发布面逐条要求真注册。未覆盖的见 docs/architecture/upstream-integration.md「Known not covered」。下界=实际条数 +15 packages/ui-mac src/renderer/sidebar/shell-commands.test.ts - REQ-126 AC7:真实壳(生产 AlphaSidebar+AlphaSettings+AlphaSessionSearch+AutomationPanel)里点**处置表所列的那些入口**,断言可观察结果(设置面 DOM / 目录选择被调 / 真实路由变化);退休入口断言 DOM 不存在且容器仍在;设置页快捷键表断言不含退休 id 且每条都真注册;桌面菜单发布面逐条要求真注册。#925 增四条:多 server 壳(active≠projects,三个用例的 projects key 互异)下侧栏点击/锚点/「打开项目」/draft 晋升(上游生产 promoteDraft 真跑)/自动化「回跳会话」全部落在真正持有会话的 server 上,且 legacy 形状的会话导航清零 —— 删掉它们,legacy 反推(按完成时 active server 猜)复活时无物变红。未覆盖的见 docs/architecture/upstream-integration.md「Known not covered」。下界=实际条数 1 packages/ui-mac src/main/desktop-menu-publication.test.ts - REQ-126 AC7:起子进程跑 test-component/desktop-menu-publication.cases.ts(mock electron 跑真 createMenu,把真原生菜单**整个**点一遍,发出的命令 id 集必须恰好等于发布面 shared/desktop-menu-policy)。本文件直接执行判据并钉住子进程用例条数下界 11,故 delegates_to = '-' 1 packages/ui-mac src/renderer/sidebar/overlay-close.test.ts - REQ-126 AC2 覆盖层随导航关闭:挂真实覆盖层宿主 × 真实侧栏点击的参数化矩阵(含「点当前正在看的那个会话」这条 URL 不变的路径)。判据在 test-component/overlay-close.cases.ts,由本文件起子进程**直接执行**并钉住条数下界 —— 不是委派,故 delegates_to = '-' 19 packages/ui-mac src/shared/platform-pricing-authority-ratchet.test.ts packages/ui-mac/src/renderer/alpha-ui/model-picker-core.test.ts,packages/ui-mac/src/renderer/alpha-ui/model-default-core.test.ts,packages/ui-mac/src/renderer/alpha-ui/alpha-composer-model.component.test.ts #679/ADR-039 平台计价权威**源码**棘轮 —— 抬头自陈主判据不在本文件(它只守住「被删掉的本地档位轴没在目录面重新长出来」),故三个行为级受托方必须在册。含扫描器自测:正则写错的棘轮会报零违例、看起来和通过一模一样。下界=实际条数 19,不留余量 From 5c5b793bc89f85ee73418352a71c6fdb1ea86055 Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Tue, 11 Aug 2026 22:34:21 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(ui-mac):=20#925=20=E5=AE=A1=E8=AE=A1=20?= =?UTF-8?q?R1=20=E2=80=94=E2=80=94=20=E6=96=B0=E5=AF=B9=E8=AF=9D=E9=A1=B5?= =?UTF-8?q?=E5=9C=A8=E5=94=AF=E4=B8=80=E6=B6=88=E8=B4=B9=E8=80=85=E5=A4=84?= =?UTF-8?q?=E6=8A=8A=20draft.server=20=E9=92=89=E5=9B=9E=20projects=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游 Titlebar 的「+ 新标签」/ mod+t(openNewTab 五个分支)建的 draft 仍带 active/兜底 server,而会话恒经 projects.startChat 建在 store 连着的 server 上 —— promoteDraft 按 draft.server 建 tab 并导航,落到没有该会话的机器。逐生产者补不完,收口在 AlphaNewSession: serverKey 可用且与 draft.server 不同即 updateDraft 钉回;keyed 重挂经暂存不吞内容; key 缺席不猜。同时收回 #925 注释里「两者在生产里同值」的全称断言,改写为事实。 判据:new-session-workspace.cases.ts 新增 4 条(两组 projects key 字面量互异、负向 夹具两个非退化 server、晋升读值 = 上游 promoteDraft 的那次读取);harness 默认 draft 改带 store key(生产两头收口后的稳态),#891 用例改为等收口落定再开档。 --- .../renderer/alpha-ui/alpha-new-session.tsx | 26 +++- .../new-session-workspace.cases.ts | 119 ++++++++++++++++-- 2 files changed, 126 insertions(+), 19 deletions(-) diff --git a/packages/ui-mac/src/renderer/alpha-ui/alpha-new-session.tsx b/packages/ui-mac/src/renderer/alpha-ui/alpha-new-session.tsx index b3b80da52b26..bee8ae677dce 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/alpha-new-session.tsx +++ b/packages/ui-mac/src/renderer/alpha-ui/alpha-new-session.tsx @@ -11,9 +11,9 @@ // - 该写入会让上游按 `server\0directory` keyed 的 draft 叶**整叶重挂**,所以 composer 的草稿 // 经 newSessionDraftStash(叶之外)往返,切目录不吞已输入内容(基线不变量 6)。 -import { createMemo, createSignal, Show, untrack } from "solid-js" +import { createEffect, createMemo, createSignal, Show, untrack } from "solid-js" import { useSearchParams } from "@solidjs/router" -import { useTabs } from "@opencode-ai/app" +import { ServerConnection, useTabs } from "@opencode-ai/app" import { type AlphaProjectsApi } from "../sidebar/use-projects" import { AlphaComposer } from "./alpha-composer" import { createDefaultWorkspaceDir } from "./default-workspace" @@ -29,10 +29,10 @@ export function AlphaNewSession(props: { /** #891:与首页同一条线 —— 本页发第一条也走 `props.projects.startChat`,会话因此落在**那份 * store 连着的 server** 上,composer 拿这个 key 给新会话登记开局档位/只读档。 * 刻意**不用** `tabs.draft(draftId).server`:登记的钥匙必须与会话页 adopt 时算出的逐字节 - * 相同,身份来源只有这一个。(#925 起 `alpha-sidebar` 的 `startDraft` 已把 draft 的 server - * 段也钉到 projects store 的 key 上 —— 两者在生产里同值,upstream `promoteDraft` 按 - * `draft.server` 建的 session tab 与导航因此落在真正持有会话的那台;但那是**导航**那条线的 - * 正确性,composer 的登记身份仍以本 prop 为准,不回头读 draft。) */ + * 相同,身份来源只有这一个。draft 的 server 段也**不保证**天生等于本 prop —— alpha-sidebar + * 的 `startDraft` 自 #925 起钉到 projects key,但生产者不止它一个(见下面收口 effect 的 + * 注释);两者不同时由本组件把 draft 钉回本 prop,upstream `promoteDraft` 按 `draft.server` + * 建的 session tab 与导航才落在真正持有会话的那台。 */ serverKey: () => string | undefined draftId: string promoteDraft: (session: { directory: string; sessionId: string }) => void @@ -41,6 +41,20 @@ export function AlphaNewSession(props: { const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const { store } = props.projects + // #925 审计 Major:draft 的生产者不止 alpha-sidebar 一个 —— 上游 Titlebar 的「+ 新标签」/ + // mod+t(packages/app/src/components/titlebar.tsx `openNewTab`,五个分支)建的 draft 带的是 + // active(或兜底)server,而本页的会话恒经 `props.projects.startChat` 建在 projects store + // 连着的 server 上。逐个生产者补不完,在唯一消费者这里收口:两者不同即把 draft 的 server 段 + // 钉回 projects key。上游 `createDraftRoute` 按 `${server}\0${directory}` keyed ⇒ 这次写入会 + // 整叶重挂一次;composer 内容经 newSessionDraftStash 在卸载时捕获、重挂后取回(与切目录同一 + // 条路),不丢。写成 effect 而不是挂载一次:sidecar 迟就绪时 key 从 undefined 变可用,那扇窗 + // 里建的 draft 也要钉住;key 缺席时不猜 —— 那时 store 无 client,`startChat` 本来也发不出去。 + createEffect(() => { + const key = props.serverKey() + if (!key || tabs.draft(props.draftId).server === key) return + tabs.updateDraft(props.draftId, { server: ServerConnection.Key.make(key) }) + }) + const projects = createMemo(() => visibleWorkspaces(store.projects)) const directory = createMemo(() => tabs.draft(props.draftId).directory) // 未显式选择时落默认对话目录 `~/Alpha`(ADR-025 §3,owner 2026-07-28 改判)。这里是**呈现** diff --git a/packages/ui-mac/test-component/new-session-workspace.cases.ts b/packages/ui-mac/test-component/new-session-workspace.cases.ts index 606255717e91..e1359e623dae 100644 --- a/packages/ui-mac/test-component/new-session-workspace.cases.ts +++ b/packages/ui-mac/test-component/new-session-workspace.cases.ts @@ -49,10 +49,13 @@ Bun.plugin({ AlphaNewSession reads the draft through upstream `useTabs()` and clears `?prompt=` through the router; both are replaced with observable fakes so the assertions can be about *what the page asked for*, not about internal calls. */ -// #891:两个「当会话身份用就是错的」的值,在本文件里被**故意钉成与 store 的 server 不同**: -// `useServer().key`(当前 active server)与 `tabs.draft().server`(建 draft 那一刻的 active -// server)。首页/新对话页的会话是 `projects.startChat` 在 **store 连着的那个 server** 上建的, -// 身份只能是后者。两个错值都取 ACTIVE_SERVER_KEY,任一叶回头去读它们,判据当场红。 +// #891:「当会话身份用就是错的」的值在本文件里被**故意钉成与 store 的 server 不同**: +// `useServer().key`(当前 active server)恒为 ACTIVE_SERVER_KEY,任一叶回头去读它,判据当场红。 +// 首页/新对话页的会话是 `projects.startChat` 在 **store 连着的那个 server** 上建的,身份只能是 +// 后者。draft 的 server 段:#925 起 alpha-sidebar 建的 draft 已钉在 projects key 上,且 +// AlphaNewSession 在唯一消费者处把不同值的 draft 收口钉回(#925 审计 Major,上游 Titlebar 的 +// 「+ 新标签」/ mod+t 仍按 active server 建 draft)—— 所以本文件默认 draft 带 STORE_SERVER_KEY +// (生产两头收口后的稳态),「上游入口建的 wrong-server draft」由 #925 那组用例显式铺设。 const ACTIVE_SERVER_KEY = "wsl:ubuntu" /** `projects` 这份 store 真正连着的 server(生产由 `index.tsx` 的 `projectsServerKey` 反查)。 */ const STORE_SERVER_KEY = "sidecar" @@ -66,7 +69,7 @@ const LATER_SERVER_KEY = "wsl:debian" const SUBMIT_TIME_SERVER_KEY = "wsl:fedora" type DraftRecord = { server: string; directory: string } -const [draft, setDraft] = createSignal({ server: ACTIVE_SERVER_KEY, directory: "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/ws/a" }) +const [draft, setDraft] = createSignal({ server: STORE_SERVER_KEY, directory: "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/ws/a" }) // 上游 tabs.store 的最小形状:哪些 draft 还活着(关掉 = 从这里消失)。 const [liveDrafts, setLiveDrafts] = createSignal(["draft-1"]) const updateDraftCalls: Array<{ draftID: string; patch: Partial }> = [] @@ -87,7 +90,13 @@ const server = { return ACTIVE_SERVER_KEY }, } -mock.module("@opencode-ai/app", () => ({ useTabs: () => tabs, useServer: () => server })) +mock.module("@opencode-ai/app", () => ({ + useTabs: () => tabs, + useServer: () => server, + // #925:生产的 `ServerConnection.Key.make` 就是 brand cast(packages/app/src/context/server.tsx:238), + // 这里如实照抄 —— 收口 effect 写回 draft 的值不经任何变换。 + ServerConnection: { Key: { make: (v: string) => v } }, +})) /** #894:叶真正跳去了哪里。路由本体不参与 —— 记的是叶交给 `useNavigate()` 的那个 href, * 再由生产的 `parseRoute` 读回去,编码与解码是两条相反的路,不构成自指等价链。 */ const navigateCalls: string[] = [] @@ -335,8 +344,12 @@ function pasteFile(el: HTMLTextAreaElement, file: File) { el.dispatchEvent(event) } -function DraftLeaf(props: { projects: AlphaProjectsApi; draftId?: string }) { - // 上游 createDraftRoute 的 keyed 包装(packages/app/src/app.tsx):directory 变 ⇒ 整叶重挂。 +/** #925:上游 `createDraftRoute` 的 `promoteDraft` 按 **`props.draft.server` 晋升那一刻的值**建 + * session tab 并导航(packages/app/src/app.tsx:272-277)—— 这里如实照抄那次读取并记下来。 + * 它就是「打开的是哪台机器」的决定值:断言落在它身上,而不是某个内部 memo。 */ +const promoteCalls: Array<{ server: string; sessionId: string }> = [] +function DraftLeaf(props: { projects: AlphaProjectsApi; draftId?: string; serverKey?: () => string | undefined }) { + // 上游 createDraftRoute 的 keyed 包装(packages/app/src/app.tsx):server/directory 变 ⇒ 整叶重挂。 return createComponent(solid.Show, { get when() { return `${draft().server}\0${draft().directory}` @@ -346,10 +359,13 @@ function DraftLeaf(props: { projects: AlphaProjectsApi; draftId?: string }) { return createComponent(AlphaNewSession, { projects: props.projects, // #891:生产由 index.tsx 的 `projectsServerKey` 供给(store 的 baseUrl 反查),这里同源: - // 这份 `projects` 连的就是 STORE_SERVER_KEY,而 `draft().server` 是另一个值。 - serverKey: () => STORE_SERVER_KEY, + // 这份 `projects` 连的就是 STORE_SERVER_KEY(#925 的用例用自己的 key 覆盖,两组字面量 + // 互不相同 —— 写死单值的实现过不了)。 + serverKey: props.serverKey ?? (() => STORE_SERVER_KEY), draftId: props.draftId ?? "draft-1", - promoteDraft: () => {}, + promoteDraft: (session: { sessionId: string }) => { + promoteCalls.push({ server: draft().server, sessionId: session.sessionId }) + }, }) }, }) @@ -367,7 +383,8 @@ beforeEach(() => { pickedDirectory = undefined defaultWorkspaceGate = undefined setLiveDrafts(["draft-1"]) - setDraft({ server: ACTIVE_SERVER_KEY, directory: "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/ws/a" }) + setDraft({ server: STORE_SERVER_KEY, directory: "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/ws/a" }) + promoteCalls.splice(0) document.body.replaceChildren() }) @@ -415,7 +432,7 @@ describe("REQ-126 CODE-D 新对话页工作区选择器", () => { }) test("未显式选择(draft 无目录)⇒ chip 与标题都显示默认对话目录 ~/Alpha", async () => { - setDraft({ server: ACTIVE_SERVER_KEY, directory: "" }) + setDraft({ server: STORE_SERVER_KEY, directory: "" }) const host = mount(() => createComponent(DraftLeaf, { projects: projectsApi([project("alpha-code", "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/ws/a")]) })) await flush() await flush() @@ -932,8 +949,12 @@ describe("#891 首页/新对话页:开局档位登记在 store 连着的那个 s test("新对话页同一条线:draft.server 不是身份,登记仍在 store 的 server 名下", async () => { const projects = projectsApi([project("alpha-code", "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/ws/a")]) + // draft 生来带 active server(上游入口的形态)。#925 收口会先钉回 projects key 并整叶重挂 + // 一次 —— 等它落定再开档位:开在旧实例上的档随重挂消失,那是收口的已知代价(内容经暂存 + // 保住,档位是 composer 实例状态),不是本用例的命题。 setDraft({ server: ACTIVE_SERVER_KEY, directory: DEFAULT_WORKSPACE }) const leaf = mountDisposable(() => createComponent(DraftLeaf, { projects })) + for (let i = 0; i < 20 && draft().server !== STORE_SERVER_KEY; i++) await flush() for (let i = 0; i < 20 && !textarea(leaf.host); i++) await flush() textarea(leaf.host)!.dispatchEvent( @@ -1120,3 +1141,75 @@ describe("#894 首页提交后的导航:落地的是真正创建会话的那个 home.dispose() }) }) + +/* ── #925 审计 Major:draft 的生产者不止 alpha-sidebar 一个 ──────────────────────────── + 上游 Titlebar 的「+ 新标签」/ mod+t(packages/app/src/components/titlebar.tsx `openNewTab`, + 五个分支)建的 draft 带的是 active(或兜底)server;而新对话页发第一条恒经 + `props.projects.startChat` 把会话建在 projects store 连着的 server 上。收口在唯一消费者: + AlphaNewSession 挂载/取到 key 后把不同值的 draft.server 钉回 projects key,上游 `promoteDraft` + 按 `draft.server` 建的 session tab 与导航才落在真正持有会话的那台。 + 判据纪律:两组用例的 projects key 用**不同**字面量(STORE / LATER),写死单值的实现两条同时 + 过不了;负向夹具的 draft.server 也取两个互不相同的非退化值(ACTIVE / SUBMIT_TIME)。 */ +describe("#925 上游入口建的 draft(带 active server)在唯一消费者处被钉回 projects key", () => { + test("draft 带 active server ⇒ server 段被钉回,晋升读到的就是 projects key;收口重挂不吞已有内容", async () => { + // 用户此前在这条 draft 里输入过(经暂存注入 —— 与切目录内容保护同一条路)。 + newSessionDraftStash.capture("draft-1", { text: "上游入口建的草稿", mentions: [], attachments: [], pendingReads: [] }) + setDraft({ server: ACTIVE_SERVER_KEY, directory: "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/ws/a" }) + const host = mount(() => createComponent(DraftLeaf, { projects: projectsApi([project("alpha-code", "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/ws/a")]) })) + for (let i = 0; i < 20 && draft().server !== STORE_SERVER_KEY; i++) await flush() + + expect(updateDraftCalls).toEqual([{ draftID: "draft-1", patch: { server: STORE_SERVER_KEY } }]) + expect(draft().server).toBe(STORE_SERVER_KEY) + // 收口引发的重挂不吞内容(卸载捕获 → 重挂取回,与切目录同一条暂存路)。 + for (let i = 0; i < 20 && !textarea(host)?.value; i++) await flush() + expect(textarea(host)!.value).toBe("上游入口建的草稿") + + // 用户可观察的终点:晋升(上游按 draft.server 建 tab + 导航)读到的 server 是 projects key, + // 不是建 draft 那一刻的 active server —— 打开的才是真正持有这个会话的机器。 + textarea(host)!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })) + for (let i = 0; i < 30 && promoteCalls.length === 0; i++) await flush() + expect(startChatCalls).toHaveLength(1) + expect(promoteCalls).toEqual([{ server: STORE_SERVER_KEY, sessionId: "session-1" }]) + }) + + test("钉回的是**这份 projects 连着的那个** key,不是任何单一字面量(第二组 key 全不同)", async () => { + setDraft({ server: SUBMIT_TIME_SERVER_KEY, directory: "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/ws/a" }) + mount(() => + createComponent(DraftLeaf, { + projects: projectsApi([project("alpha-code", "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/ws/a")]), + serverKey: () => LATER_SERVER_KEY, + }), + ) + for (let i = 0; i < 20 && draft().server !== LATER_SERVER_KEY; i++) await flush() + expect(updateDraftCalls).toEqual([{ draftID: "draft-1", patch: { server: LATER_SERVER_KEY } }]) + expect(draft().server).toBe(LATER_SERVER_KEY) + }) + + test("draft.server 已等于 projects key ⇒ 零写入、零重挂(收口是条件的,不是每次挂载都拍一下)", async () => { + const host = mount(() => createComponent(DraftLeaf, { projects: projectsApi([project("alpha-code", "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/ws/a")]) })) + const before = textarea(host)! + for (let i = 0; i < 5; i++) await flush() + expect(updateDraftCalls).toEqual([]) + expect(draft().server).toBe(STORE_SERVER_KEY) + expect(textarea(host)).toBe(before) // 没重挂 + }) + + test("挂载时 key 还没就绪(sidecar 迟启动)⇒ 不猜不写;就绪那一刻补钉", async () => { + setDraft({ server: ACTIVE_SERVER_KEY, directory: "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/ws/a" }) + const [lateKey, setLateKey] = createSignal(undefined) + mount(() => + createComponent(DraftLeaf, { + projects: projectsApi([project("alpha-code", "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/ws/a")]), + serverKey: lateKey, + }), + ) + for (let i = 0; i < 5; i++) await flush() + expect(updateDraftCalls).toEqual([]) // key 缺席:不写 undefined,也不拿 active 兜底 + expect(draft().server).toBe(ACTIVE_SERVER_KEY) + + setLateKey(STORE_SERVER_KEY) + for (let i = 0; i < 20 && draft().server !== STORE_SERVER_KEY; i++) await flush() + expect(updateDraftCalls).toEqual([{ draftID: "draft-1", patch: { server: STORE_SERVER_KEY } }]) + expect(draft().server).toBe(STORE_SERVER_KEY) + }) +}) From a02724880b7c9ea6cf339cebca8f3cf24c93a0fe Mon Sep 17 00:00:00 2001 From: jinjunnn Date: Tue, 11 Aug 2026 23:12:01 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(route):=20=E4=BB=8E=20hrefFor=20?= =?UTF-8?q?=E6=92=A4=E6=8E=89=20legacySession=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E9=80=A0=20legacy=20=E5=BD=A2=E7=8A=B6=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=98=AF=E9=A1=BA=E6=89=8B=E5=B0=B1=E8=83=BD=E6=8B=BF=E5=88=B0?= =?UTF-8?q?=E7=9A=84=E9=BB=98=E8=AE=A4=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #925 R1 审计 Minor。ui-mac 生产侧在本 PR 之后已零消费 hrefFor.legacySession,但它仍导出、 仍可达、且没有任何东西拦住新调用点用它 —— 对新成员是默认放行,与本类票「咽喉对新成员默认 拒绝」的目标直接冲突。 改法:hrefFor 里删掉它;navFor.legacySession 保留(parseRoute 仍要解析 packages/app 今天 仍在产的那批 legacy URL:notification.tsx / dialog-fork.tsx / prompt-input/submit.ts)。 判据正向钉住「它不在」("legacySession" in hrefFor === false),否则谁加回来无人变红。 诚实边界:这不是完整咽喉,只是把默认路径撤掉 —— 真正的默认拒绝要等 packages/app 那批 生产者按 ADR-034 补丁序列迁完(票面 openRisk ①)。 Refs #925 --- .../src/renderer/alpha-ui/alpha-session-search.test.ts | 4 ++-- packages/ui-mac/src/shared/route-manifest.test.ts | 8 +++++--- packages/ui-mac/src/shared/route-manifest.ts | 9 ++++++++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/ui-mac/src/renderer/alpha-ui/alpha-session-search.test.ts b/packages/ui-mac/src/renderer/alpha-ui/alpha-session-search.test.ts index 135700facad2..f60a2f7f4413 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/alpha-session-search.test.ts +++ b/packages/ui-mac/src/renderer/alpha-ui/alpha-session-search.test.ts @@ -20,7 +20,7 @@ import { pathToFileURL } from "node:url" import { build } from "vite" import type { render } from "solid-js/web" import type * as Runtime from "./alpha-session-search-test-runtime" -import { decodeDirectory, hrefFor } from "../../shared/route-manifest" +import { decodeDirectory, hrefFor, navFor } from "../../shared/route-manifest" type TestRuntime = typeof Runtime & { render: typeof render } @@ -199,7 +199,7 @@ describe("按标题搜会话,跳转 href 钉在结果来源的 server 上", () = expect(segments[3]).toBe("session") expect(segments[4]).toBe("ses_new") // legacy `sessionHref(dir, id)` 在无 tab 映射时回退当前 active server —— 明确不是它。 - expect(href).not.toBe(hrefFor.legacySession(runtime.FIXTURE_DIRECTORY, "ses_new")) + expect(href).not.toBe(navFor.legacySession(runtime.FIXTURE_DIRECTORY, "ses_new").href) }) test("换一个来源 server,href 跟着换 —— server 身份是取来的,不是常量", async () => { diff --git a/packages/ui-mac/src/shared/route-manifest.test.ts b/packages/ui-mac/src/shared/route-manifest.test.ts index 5e266c881ea1..46237b78853e 100644 --- a/packages/ui-mac/src/shared/route-manifest.test.ts +++ b/packages/ui-mac/src/shared/route-manifest.test.ts @@ -277,9 +277,11 @@ describe("manifest-derived parse, href, and navigation", () => { expect(hrefFor.sessionAdmission("/Users/dev/proj", "continue here")).toBe( "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/L1VzZXJzL2Rldi9wcm9q/session?prompt=continue%20here", ) - expect(hrefFor.legacySession("/Users/dev/proj", "ses_123")).toBe( - "/L1VzZXJzL2Rldi9wcm9q/session/ses_123", - ) + // `#925`:`hrefFor` 里**故意没有** legacySession —— legacy 形状不带 server 段,是 `#894`/`#925` + // 那个「落到没有该会话的机器」缺陷的产生器。这里正向钉住「它不在」,否则谁顺手加回来无人变红。 + expect("legacySession" in hrefFor).toBe(false) + // 形状本身仍要能造出来(`parseRoute` 要解析 packages/app 今天仍在产的那批 URL),只是不在 hrefFor 里。 + expect(navFor.legacySession("/Users/dev/proj", "ses_123").href).toBe("/L1VzZXJzL2Rldi9wcm9q/session/ses_123") expect(hrefFor.session("sidecar", "ses_123")).toBe("/server/c2lkZWNhcg/session/ses_123") expect(hrefFor.newSession("d 1", "a+b")).toBe("/new-session?draftId=d%201&prompt=a%2Bb") expect(hrefFor.newSession("d1")).toBe("/new-session?draftId=d1") diff --git a/packages/ui-mac/src/shared/route-manifest.ts b/packages/ui-mac/src/shared/route-manifest.ts index 560fd0a68e61..51384e444b92 100644 --- a/packages/ui-mac/src/shared/route-manifest.ts +++ b/packages/ui-mac/src/shared/route-manifest.ts @@ -555,7 +555,14 @@ export const hrefFor = { directory: (directory: string) => requireHref(navFor.directory(directory)), sessionAdmission: (directory: string, prompt?: string) => requireHref(navFor.sessionAdmission(directory, prompt)), directorySession: (directory: string, prompt?: string) => requireHref(navFor.sessionAdmission(directory, prompt)), - legacySession: (directory: string, sessionId: string) => requireHref(navFor.legacySession(directory, sessionId)), + // `#925`:**故意没有 `legacySession`**。legacy 形状(`/{目录}/session/{id}`)不带 server 段, + // 壳只能事后按「完成时的 active server」或「同 id 的 tab」反推 —— 多 server 下那就是落到没有该 + // 会话的机器,对面若恰好有同 id 会话,打开并污染的是那个无关会话(`#894` / `#925` 的用户可观察缺陷)。 + // ui-mac 生产侧已零消费,故把这个**顺手就能拿到的**产生器从这里撤掉:新写的调用点要造出 legacy + // 形状,必须绕到 `navFor.legacySession(...).href` 去 —— 那是一个需要解释的动作,不再是默认路径。 + // 诚实边界:这不是完整的咽喉 —— `navFor.legacySession` 仍在(`parseRoute` 要解析 `packages/app` + // 今天仍在产的那批 legacy URL:notification.tsx / dialog-fork.tsx / prompt-input/submit.ts)。 + // 真正的默认拒绝要等那批生产者按 ADR-034 的补丁序列迁完,见票面 openRisk ①。 session: (serverKey: string, sessionId: string) => requireHref(navFor.session(serverKey, sessionId)), newSession: (draftId: string, prompt?: string) => requireHref(navFor.newSession(draftId, prompt)), } as const