Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions packages/ui-mac/src/renderer/alpha-ui/alpha-new-session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 时算出的逐字节
* 相同,身份来源只有这一个。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
Expand All @@ -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 改判)。这里是**呈现**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ function Shell(props: { search?: boolean }) {
surfaces={{ home: leaf("home"), session: leaf("session"), newSession: leaf("new-session") }}
>
<RouteProbe />
<AlphaSidebar projects={projects} />
{/* #925:侧栏与搜索同源 —— 都消费 projects store 连着的那个 server 的 key。 */}
<AlphaSidebar projects={projects} serverKey={serverKey} />
{props.search === false ? null : <AlphaSessionSearch projects={projects} serverKey={serverKey} />}
</AppInterface>
</AppBaseProviders>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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 () => {
Expand Down
22 changes: 18 additions & 4 deletions packages/ui-mac/src/renderer/automations/automation-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<ListedTask[]>([])
const [pausedAll, setPausedAll] = createSignal(false)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions packages/ui-mac/src/renderer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,9 @@ render(() => {
<Inner />
</AlphaBoundary>
<AlphaBoundary name="AlphaSidebar">
<AlphaSidebar projects={alphaProjects} />
{/* #925(第三个消费者):侧栏的会话 href / 新会话导航 / draft 的 server 段。
侧栏列的与建的会话全在这份 store 的 server 上,身份只能是这个 key。 */}
<AlphaSidebar projects={alphaProjects} serverKey={projectsServerKey} />
</AlphaBoundary>
{/* REQ-085:AlphaHome 不再作为 children Portal 注入 —— 它是正式 `home` surface
(见上方 surfaceComponents);legacy 模式下 upstream Home 原样呈现。 */}
Expand All @@ -541,7 +543,9 @@ render(() => {
<ExtensionHub server={sidebarServer} open={extHubOpen} onClose={() => setExtHubOpen(false)} />
</AlphaBoundary>
<AlphaBoundary name="AutomationPanel">
<AutomationPanel />
{/* #925(第四个消费者):「回跳会话」。自动化 run 的会话由主进程建在内嵌
sidecar 上,renderer 侧那台机器的身份就是这个 key。 */}
<AutomationPanel serverKey={projectsServerKey} />
</AlphaBoundary>
{/* REQ-126 AC3(#654):产物工作台不再全页挂载 —— 产物只经会话右栏 artifacts 面板到达。 */}
{/* REQ-125 C7/C8:会话页 composer = AlphaComposer 经 seam 会话页直挂(session surface
Expand Down
70 changes: 61 additions & 9 deletions packages/ui-mac/src/renderer/sidebar/alpha-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -171,7 +171,15 @@ function until(condition: () => boolean): { promise: Promise<void>; 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()
Expand Down Expand Up @@ -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 {}
})

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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") })
}

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -1139,9 +1191,9 @@ export function AlphaSidebar(props: { projects: AlphaProjectsApi }) {
<>
<a
class="alpha-session"
href={sessionHref(session.directory, session.id)}
href={sessionLink(session.id)}
data-active={route().sessionId === session.id ? "" : undefined}
onClick={(e) => openSession(e, session.directory, session.id)}
onClick={(e) => openSession(e, session.id)}
>
<span class="alpha-session-title">{sessionDisplayTitle(session.title)}</span>
</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<>
<AlphaSidebar projects={props.projects} />
<AlphaSidebar projects={props.projects} serverKey={serverKey} />
<ExtensionHub server={server} open={extHubOpen} onClose={() => setExtHubOpen(false)} />
<AutomationPanel />
<AutomationPanel serverKey={serverKey} />
{p.children}
</>
)
Expand Down
13 changes: 9 additions & 4 deletions packages/ui-mac/src/renderer/sidebar/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading