From 406cd3602bc8d004b5e14fee07e8283f5e0d9198 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Wed, 29 Jul 2026 11:21:13 +0800 Subject: [PATCH 1/4] fix(web): close details when current session changes --- ...29-web-details-session-lifecycle.i18n.yaml | 6 ++ ...026-07-29-web-details-session-lifecycle.md | 29 +++++++ ...-07-29-web-details-session-lifecycle.zh.md | 29 +++++++ apps/web/tests/lifecycle-chrome.e2e.ts | 47 ++++++++++ packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 4 +- packages/client/ui-layout/README.zh.md | 4 +- .../client/ui-layout/src/client/AppFrame.tsx | 28 +++++- .../client/ui-layout/tests/app-frame.spec.tsx | 85 ++++++++++++++----- 9 files changed, 210 insertions(+), 26 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml new file mode 100644 index 0000000000..5ba31e77f2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md +2026-07-29-web-details-session-lifecycle.md: 3483720ef642e87bf2f3ffa0d4cf9677711a3354 +2026-07-29-web-details-session-lifecycle.zh.md: 7570f4ad045be7607beb98295551bb50403620c3 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md new file mode 100644 index 0000000000..3483720ef6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md @@ -0,0 +1,29 @@ +# Agent Note: Web details follow the current Session lifecycle + +Status: implemented + +English | [中文](2026-07-29-web-details-session-lifecycle.zh.md) + +## Problem + +The details entry is Session-scoped, but its grid width is root-scoped and persisted. Changing the current Session replaced or removed the details content without closing that root column, so New Session could show its composer beside an empty details panel that still consumed 360 pixels. The same ownership gap applied to ordinary Session switches and to selection invalidation after a Session disappeared. + +## Decision + +`AppFrame` derives one details owner from the authoritative Session projection after the Session baseline is ready: the current Session must still exist and must not be blank. The first ready active Session is baseline restoration, so an open details width may survive a browser refresh. A first ready New Session state has no details owner and closes stale persisted state. + +After baseline restoration, every details-owner change closes the panel through the layout store before paint. This covers active-to-active navigation, active-to-blank New Session, clearing the current selection, and invalidation after deletion. Returning to the earlier Session keeps details closed because the root store records the close; the per-Session chat selection remains owned by the session-scoped store described by the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md). + +Manual close and reopen inside one unchanged active Session retain their existing behavior. The lifecycle effect changes neither sidebar actions nor the [Workspace-owned New Session flow](../feature/2026-07-25-workspace-ui-product-flow.md), composer drafts, Session navigation, or concession-chain resizing. + +## Alternatives considered + +**Close details in the New Session click handler.** Rejected because top-level New Session, Workspace row actions, the Workspace picker, ordinary Session rows, and removal can all change the owner. An entry-point patch would leave the shared lifecycle inconsistent. + +**Persist panel geometry per Session.** Rejected because the product contract needs stale context removed, not a new map of remembered widths. Per-Session geometry would also reopen details when users return, contrary to the chosen close-on-leave behavior. + +**Only hide the details component when no Session is current.** Rejected because a blank Session is still current, and removing content without zeroing the grid track is the reported defect. + +## Consequences + +Leaving an active Session forgets any dragged details width, since the existing close action writes zero and reopening uses the contract default. Refreshing an active Session preserves its open panel, while refreshing New Session clears stale persisted geometry. The layout behavior test covers active, blank, missing, switch-back, and baseline-restore states; the keyless browser e2e drives the shipped composition from an active Session through New Session and back while checking the full grid track and browser errors. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md new file mode 100644 index 0000000000..7570f4ad04 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web 详情栏遵循当前会话生命周期 + +Status: implemented + +[English](2026-07-29-web-details-session-lifecycle.md) | 中文 + +## 问题 + +详情入口由会话作用域拥有,而其网格宽度由根作用域拥有并持久化。切换当前会话时,系统会替换或移除详情内容,却不会关闭根布局中的该列。因此,New Session 可能在空白详情面板旁显示 composer,而该面板仍占用 360 像素。普通会话切换,以及会话消失后选中状态失效,同样存在这一所有权缺口。 + +## 决策 + +会话基线就绪后,`AppFrame` 会从权威会话投影派生唯一的详情 owner:当前会话必须仍然存在,且不得为 blank。首次就绪的活动会话属于基线恢复,因此浏览器刷新后可以保留已打开的详情宽度。若首次就绪时处于 New Session,则不存在详情 owner,系统会关闭陈旧的持久化状态。 + +基线恢复后,详情 owner 每次变化都会先通过布局 store 关闭面板,再进行绘制。这涵盖活动会话之间的导航、从活动会话进入 blank New Session、清除当前选中项,以及删除后选中状态失效。返回先前的会话后,详情仍保持关闭,因为根 store 已记录这次关闭;逐会话的聊天选中项继续由 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md)所述的会话作用域 store 拥有。 + +在同一个未变化的活动会话内手动关闭和重新打开详情栏,仍保持原有行为。该生命周期 effect 既不改变侧边栏操作,也不改变 [Workspace 拥有的 New Session 动线](../feature/2026-07-25-workspace-ui-product-flow.md)、composer 草稿、会话导航或让步链缩放。 + +## 考虑过的替代方案 + +**在 New Session 点击处理器中关闭详情栏。** 之所以否决:顶层 New Session、Workspace 行操作、Workspace picker、普通会话行和移除操作均可改变 owner。入口级补丁会使共享生命周期继续保持不一致。 + +**按会话持久化面板几何信息。** 之所以否决:产品契约需要移除陈旧上下文,而不是新增一张保存各宽度的映射。按会话保存几何信息还会在用户返回时重新打开详情栏,与选定的离开即关闭行为相悖。 + +**仅在当前没有会话时隐藏详情组件。** 之所以否决:blank 会话仍是当前会话;只移除内容而不将网格轨道归零,正是本次报告的缺陷。 + +## 后果 + +离开活动会话会忘记拖动后的详情宽度,因为现有关闭操作会写入零值,重新打开时则使用契约默认值。刷新活动会话会保留已打开的面板,而刷新 New Session 会清除陈旧的持久化几何信息。布局行为测试覆盖 active、blank、missing、切回和基线恢复状态;无密钥浏览器 e2e 则驱动已交付的组合从活动会话进入 New Session 再返回,同时检查完整网格轨道和浏览器错误。 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 4b54242495..2c2fdd4c83 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -32,6 +32,14 @@ const MODE = webSnapshotMode() const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' +/** Last AppFrame grid track in CSS pixels. */ +async function detailsTrack(page: Page): Promise { + return await page.locator('[class*="frame"]').first().evaluate((element) => { + const tracks = getComputedStyle(element).gridTemplateColumns.split(' ') + return Number.parseFloat(tracks.at(-1) ?? 'NaN') + }) +} + describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => { let scaffold: WebScaffold let browser: Browser @@ -99,6 +107,45 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed') }, 60_000) + it.skipIf(MODE === 'record')('closes details for New Session and keeps it closed when returning', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle')) + // Keep the scenario focused-runnable: the full file already sent this + // turn, while `-t` starts from the connected blank Session in beforeAll. + if (await page.getByText('LIGHTHOUSE', { exact: true }).count() === 0) { + const settled = scaffold.whenTurnSettled() + const input = page.locator('textarea').first() + await input.fill(PROMPT) + await input.press('Enter') + await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + } + // Rehydrate the production layout action's persisted result. The active + // Session survives reload, so its details panel remains valid and open. + await page.evaluate(() => { + localStorage.setItem('dsh.layout.panels', JSON.stringify({ sidebar: 280, details: 360 })) + }) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + expect(await detailsTrack(page)).toBe(360) + expect(await page.getByText('详情', { exact: true }).count()).toBe(1) + + await page.getByRole('button', { name: 'New session', exact: true }).last().click() + await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 }) + expect(await page.locator('[class*="frame"]').first().getAttribute('data-details-collapsed')).not.toBeNull() + await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) + expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) + + const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first() + await original.click() + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) + it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload')) // Fold a layout preference into the same reload: collapse the sidebar diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 589f78a23c..ef1d66f060 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md -README.md: 26e909b96412985792eeae72d51a2ab2a315c943 -README.zh.md: 2e5799fd32c41328f8ca8b9e1a439fbccb3cdba2 +README.md: 836100066039e3695e314a4a4bbfaba8fb20c652 +README.zh.md: ffef7511b6cfdc3109203be86199766073bf5efd diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 26e909b964..8361000660 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body). -AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face. +AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The first ready active Session may restore an open details width across reload; New Session and every later current-Session change close details before paint, including selection invalidation after deletion. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face. The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal; tests import internals through `/src`. @@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Details open/width state is global** — it does not follow the session (arbitrated for P-I); the per-session keyed upgrade slot is reserved. +- **Details width is global, not retained per Session** — changing or losing its active Session closes the panel and forgets a dragged width; returning to that Session does not reopen it. - **Concession-chain auto-close derives a zero width without touching the persisted open flag** — the panel restores itself when the window widens; consumers must not read `details.open` as the rendered truth. - **Scroll anchoring during squeeze reflow is not implemented** — deferred with the virtualized-list project. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 2e5799fd32..ffef7511b6 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -4,7 +4,7 @@ 外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。 -AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 则通过 `SessionProvider` 渲染。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed` 和 `width`;导航操作属于侧边栏自身注入的服务表层。 +AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 则通过 `SessionProvider` 渲染。首次就绪的活动会话可在重新加载后恢复已打开的详情宽度;New Session 以及后续每次当前会话变化,都会在绘制前关闭详情栏,包括删除后选中状态失效的情况。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed` 和 `width`;导航操作属于侧边栏自身注入的服务表层。 `/client` 导出表层包含插件主体(`apply`/`inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部;测试通过 `/src` 导入内部实现。 @@ -18,6 +18,6 @@ AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态, ## 已知限制与暂缓事项 -- **详情栏打开/宽度状态是全局状态**:它不会随会话变化(P-I 已裁定);为逐会话键控升级预留了 slot。 +- **详情宽度是全局状态,不按会话保留**:切换或失去当前活动会话会关闭详情栏,并忘记拖动后的宽度;返回该会话时不会重新打开详情栏。 - **让步链自动关闭通过推导零宽度实现,不会改动持久化的打开标志**:窗口变宽时面板会自行恢复;消费方禁止把 `details.open` 当作实际渲染状态。 - **挤压重排期间尚未实现滚动锚定**:与虚拟化列表项目一并暂缓。 diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index a7c7696222..da7636de9c 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -10,7 +10,7 @@ * through the three framework shares — zero cordis or framework imports, * zero self-made hooks. */ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { computeColumns } from './columns.ts' @@ -86,13 +86,39 @@ function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: /** The three-column frame (see module doc). */ export function AppFrame({ useStore, + useSessions, actions, renderSlot, }: AppFrameProps) { const panels = useStore(s => s) + const sessionsPhase = useSessions(s => s.phase) + const detailsSession = useSessions((s) => { + const current = s.current + if (current === undefined) return undefined + const session = s.byId[current] + return session !== undefined && !session.blank ? current : undefined + }) const frameRef = useRef(null) const [viewport, setViewport] = useState(() => window.innerWidth) + // The first ready active Session is baseline restoration, so its persisted + // panel may remain open. New Session has no inspectable selection, and any + // later details owner change closes the root-scoped column before paint. + const detailsBaselineReady = useRef(false) + const previousDetailsSession = useRef(detailsSession) + useLayoutEffect(() => { + if (sessionsPhase !== 'ready') return + if (!detailsBaselineReady.current) { + detailsBaselineReady.current = true + previousDetailsSession.current = detailsSession + if (detailsSession === undefined) actions.closeDetails() + return + } + if (previousDetailsSession.current === detailsSession) return + previousDetailsSession.current = detailsSession + actions.closeDetails() + }, [actions, detailsSession, sessionsPhase]) + // Track the frame's own box (not the window): rAF-throttled ResizeObserver. useEffect(() => { const el = frameRef.current diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index 4d5f6de30d..54c23688dc 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -21,8 +21,10 @@ import type { SessionId, SessionListState, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -// Session-mode switch for the SessionProvider stub prop. -const sessionMode = { current: true } +// Session selection controls for the SessionProvider and useSessions stubs. +const selectedSession = { current: 's-test' as SessionId | undefined } +const selectedSessionBlank = { current: false } +const sessionsPhase = { current: 'ready' as SessionListState['phase'] } const baselinesReady = { current: true } // Render-prop contract stub fed through the standard seat prop (the renderer @@ -31,7 +33,7 @@ const baselinesReady = { current: true } // shape. Typed as the seat's own component type so the branded sessionId // parameter stays contract-checked. const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) => - sessionMode.current ? <>{children('s-test' as Parameters[0])} : <>{empty?.() ?? null} + selectedSession.current === undefined ? <>{empty?.() ?? null} : <>{children(selectedSession.current)} /** Observer stub: captures the callback so tests can fire resizes manually. */ @@ -64,32 +66,35 @@ function mountFrame() { if (key === 'conversation.empty') return
return
}) as AppFrameProps['renderSlot'] - const sessionId = 's-test' as SessionId - const sessionState = { - ids: sessionMode.current ? [sessionId] : [], - byId: sessionMode.current - ? { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: false, updatedAt: 1 } } - : {}, - current: sessionMode.current ? sessionId : undefined, - phase: 'ready', - } as SessionListState - const useSessions = ((sel: (s: SessionListState) => unknown) => sel(sessionState)) as never + const useSessions = ((sel: (s: SessionListState) => unknown) => { + const current = selectedSession.current + const sessionState = { + ids: current === undefined ? [] : [current], + byId: current === undefined + ? {} + : { [current]: { id: current, displayTitle: 'Test', running: false, blank: selectedSessionBlank.current, updatedAt: 1 } }, + current, + phase: sessionsPhase.current, + } as SessionListState + return sel(sessionState) + }) as never const workspaceState: WorkspaceListState = { items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: baselinesReady.current, recentWorkspaceId: undefined, } - const utils = render( + const element = () => ( unknown) => sel(workspaceState)) as never} SessionProvider={SessionProviderStub} - />, + /> ) + const utils = render(element()) const frame = utils.container.firstElementChild as HTMLElement - return { instance, frame, slotCalls, ...utils } + return { instance, frame, slotCalls, rerenderFrame: () => { utils.rerender(element()) }, ...utils } } function tracks(frame: HTMLElement): number[] { @@ -109,7 +114,9 @@ function drag(handle: Element, fromX: number, toX: number): void { beforeEach(() => { frameWidth = 1920 - sessionMode.current = true + selectedSession.current = 's-test' as SessionId + selectedSessionBlank.current = false + sessionsPhase.current = 'ready' baselinesReady.current = true localStorage.clear() // the layout store persists; instances must not bleed across tests vi.useFakeTimers() @@ -154,7 +161,7 @@ describe('AppFrame', () => { it('keeps the conversation slot mounted while no session is current', () => { // No current session: the session-maybe conversation shell owns the New // Session view itself — the center column renders it unconditionally. - sessionMode.current = false + selectedSession.current = undefined const { slotCalls, getByTestId } = mountFrame() expect(getByTestId('center-content')).toBeTruthy() expect(slotCalls.map(c => c.key)).toContain('conversation') @@ -169,6 +176,46 @@ describe('AppFrame', () => { expect(slotCalls.map(c => c.key)).toContain('details') }) + it('closes details when the ready current Session changes, including New Session, and keeps it closed on return', () => { + const { frame, instance, rerenderFrame } = mountFrame() + expect(tracks(frame)).toEqual([280, 360]) + + selectedSession.current = 's-next' as SessionId + act(() => { rerenderFrame() }) + expect(tracks(frame)).toEqual([280, 0]) + + act(() => { instance.actions.openDetails() }) + selectedSession.current = 's-blank' as SessionId + selectedSessionBlank.current = true + act(() => { rerenderFrame() }) + expect(tracks(frame)).toEqual([280, 0]) + + selectedSession.current = 's-test' as SessionId + selectedSessionBlank.current = false + act(() => { rerenderFrame() }) + expect(tracks(frame)).toEqual([280, 0]) + + act(() => { instance.actions.openDetails() }) + selectedSession.current = undefined + act(() => { rerenderFrame() }) + expect(tracks(frame)).toEqual([280, 0]) + }) + + it('preserves open details across active-session baseline restore but closes it for an initial New Session view', () => { + sessionsPhase.current = 'pending' + const active = mountFrame() + expect(tracks(active.frame)).toEqual([280, 360]) + sessionsPhase.current = 'ready' + act(() => { active.rerenderFrame() }) + expect(tracks(active.frame)).toEqual([280, 360]) + active.unmount() + + selectedSession.current = 's-blank' as SessionId + selectedSessionBlank.current = true + const blank = mountFrame() + expect(tracks(blank.frame)).toEqual([280, 0]) + }) + it('sidebar slot receives live concession output as owner props', () => { const { slotCalls } = mountFrame() expect(slotCalls.find(c => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 }) From f280a97c5643de17edfccbda1756dfc38acecc95 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Wed, 29 Jul 2026 11:32:03 +0800 Subject: [PATCH 2/4] test(web): isolate details session lifecycle e2e --- .../tests/details-session-lifecycle.e2e.ts | 85 +++++++++++++++++++ apps/web/tests/lifecycle-chrome.e2e.ts | 47 ---------- apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 4 files changed, 87 insertions(+), 47 deletions(-) create mode 100644 apps/web/tests/details-session-lifecycle.e2e.ts diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts new file mode 100644 index 0000000000..c066077edf --- /dev/null +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -0,0 +1,85 @@ +// Keyless browser regression for the details column's Session ownership. +// The real shipped composition owns the state transition: an active Session +// rehydrates an open panel, New Session replaces the details owner, and the +// root layout must release the third grid track before the next paint. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + acknowledgeReloadConnectionLoss, fixtureUserPrompts, launchWebScaffold, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url)) +const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' +const MODE = webSnapshotMode() + +/** Last AppFrame grid track in CSS pixels. */ +async function detailsTrack(page: Page): Promise { + return await page.locator('[class*="frame"]').first().evaluate((element) => { + const tracks = getComputedStyle(element).gridTemplateColumns.split(' ') + return Number.parseFloat(tracks.at(-1) ?? 'NaN') + }) +} + +describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5 }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('removes the details track for New Session and keeps it closed when returning', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle')) + const settled = scaffold.whenTurnSettled() + const input = page.locator('textarea').first() + await input.fill(PROMPT) + await input.press('Enter') + await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + + // Rehydrate the production layout action's persisted result. The active + // Session survives reload, so its details panel remains valid and open. + await page.evaluate(() => { + localStorage.setItem('dsh.layout.panels', JSON.stringify({ sidebar: 280, details: 360 })) + }) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + expect(await detailsTrack(page)).toBe(360) + expect(await page.getByText('详情', { exact: true }).count()).toBe(1) + + await page.getByRole('button', { name: 'New session', exact: true }).last().click() + await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 }) + expect(await page.locator('[class*="frame"]').first().getAttribute('data-details-collapsed')).not.toBeNull() + await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) + expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) + + const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first() + await original.click() + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 2c2fdd4c83..4b54242495 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -32,14 +32,6 @@ const MODE = webSnapshotMode() const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' -/** Last AppFrame grid track in CSS pixels. */ -async function detailsTrack(page: Page): Promise { - return await page.locator('[class*="frame"]').first().evaluate((element) => { - const tracks = getComputedStyle(element).gridTemplateColumns.split(' ') - return Number.parseFloat(tracks.at(-1) ?? 'NaN') - }) -} - describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => { let scaffold: WebScaffold let browser: Browser @@ -107,45 +99,6 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed') }, 60_000) - it.skipIf(MODE === 'record')('closes details for New Session and keeps it closed when returning', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle')) - // Keep the scenario focused-runnable: the full file already sent this - // turn, while `-t` starts from the connected blank Session in beforeAll. - if (await page.getByText('LIGHTHOUSE', { exact: true }).count() === 0) { - const settled = scaffold.whenTurnSettled() - const input = page.locator('textarea').first() - await input.fill(PROMPT) - await input.press('Enter') - await settled - await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) - } - // Rehydrate the production layout action's persisted result. The active - // Session survives reload, so its details panel remains valid and open. - await page.evaluate(() => { - localStorage.setItem('dsh.layout.panels', JSON.stringify({ sidebar: 280, details: 360 })) - }) - const warningStart = tripwire.warnings.length - await page.reload({ waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - acknowledgeReloadConnectionLoss(tripwire, warningStart) - await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) - expect(await detailsTrack(page)).toBe(360) - expect(await page.getByText('详情', { exact: true }).count()).toBe(1) - - await page.getByRole('button', { name: 'New session', exact: true }).last().click() - await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 }) - expect(await page.locator('[class*="frame"]').first().getAttribute('data-details-collapsed')).not.toBeNull() - await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) - expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) - - const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first() - await original.click() - await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) - await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - }, 90_000) - it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload')) // Fold a layout preference into the same reload: collapse the sidebar diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1b0f807d5f..dbbe67f98b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -28,6 +28,7 @@ "tests/steering.e2e.ts", "tests/navigation-panes.e2e.ts", "tests/lifecycle-chrome.e2e.ts", + "tests/details-session-lifecycle.e2e.ts", "tests/settings-chrome.e2e.ts", "tests/workspace-management.e2e.ts", "tests/replay-round-trip.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index e2112b7f6a..599931c2d6 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -15,6 +15,7 @@ "apps/web/tests/steering.e2e.ts", "apps/web/tests/navigation-panes.e2e.ts", "apps/web/tests/lifecycle-chrome.e2e.ts", + "apps/web/tests/details-session-lifecycle.e2e.ts", "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", From 173a1a83198141ced8578508aefc651784f7afb4 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 29 Jul 2026 13:38:55 +0800 Subject: [PATCH 3/4] fix(dev-infra): migrate copied worktree hooks --- ...26-07-27-worktree-local-lefthook.i18n.yaml | 4 +- .../2026-07-27-worktree-local-lefthook.md | 4 +- .../2026-07-27-worktree-local-lefthook.zh.md | 4 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/install-lefthook.mjs | 24 ++++++++-- scripts/install-lefthook.spec.ts | 48 +++++++++++++++++++ .../request-response.expected.json | 4 +- 9 files changed, 81 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml index 34dcf42c4f..4eb85b7d70 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md -2026-07-27-worktree-local-lefthook.md: d18f6c1bf8fe240759ad48f67ca6b231000eaf2c -2026-07-27-worktree-local-lefthook.zh.md: 42a1625a3b2ec7b00942dc46b0c9c64058ecd2fc +2026-07-27-worktree-local-lefthook.md: 75dfd47087356c34005ec4673e174e451a72c660 +2026-07-27-worktree-local-lefthook.zh.md: bc4902769561c3d33d2101de55e28e70d114f39b diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md index d18f6c1bf8..75dfd47087 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md @@ -16,9 +16,9 @@ Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, t Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking. -Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. +Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Git seeds a new linked worktree's `config.worktree` from the main worktree; when that seed contains the marker-backed reserved hook path of a registered worktree, the installer replaces only the new worktree's config with its own path. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. -If Lefthook fails after changing `core.hooksPath`, the installer restores the previous worktree value; a rollback failure is reported alongside the installation failure. Existing files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten. Focused installer tests pin isolation, migration refusal, ownership and relocation, concurrent installation, custom paths, and rollback. +If Lefthook fails after changing `core.hooksPath`, the installer restores the previous worktree value; a rollback failure is reported alongside the installation failure. Existing files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten. Focused installer tests pin isolation, copied new-worktree configuration, migration refusal, ownership and relocation, concurrent installation, custom paths, and rollback. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md index 42a1625a3b..bc49027695 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md @@ -16,9 +16,9 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空且尚未生效的 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须手动恢复,不会被自动破坏。 -每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。 +每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Git 会以主 worktree 的配置为新链接 worktree 初始化 `config.worktree`;当该初始配置包含某个已注册 worktree 中由所有权标记佐证的保留钩子路径时,安装程序只会在新 worktree 的配置中将其替换为新 worktree 自有的路径。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。 -若 Lefthook 在更改 `core.hooksPath` 后失败,安装程序会恢复先前的 worktree 值;若回滚失败,会与安装失败一并报告。`$GIT_COMMON_DIR/hooks` 中的现有文件绝不会被移除或改写。聚焦的安装程序测试固定了隔离、迁移拒绝、所有权和检出目录移动、并发安装、自定义路径及回滚行为。 +若 Lefthook 在更改 `core.hooksPath` 后失败,安装程序会恢复先前的 worktree 值;若回滚失败,会与安装失败一并报告。`$GIT_COMMON_DIR/hooks` 中的现有文件绝不会被移除或改写。聚焦的安装程序测试固定了隔离、复制的新 worktree 配置、迁移拒绝、所有权和检出目录移动、并发安装、自定义路径及回滚行为。 ## 考虑过的替代方案 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 74d9dfbea5..311f872b4a 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 32339fa2af8c1b6005d9e0b8165d57966a4145ca -development.zh.md: c74a81346639c6f95568cbd86b401d134d5eb7fc +development.md: 0a18e29d3da4f694707521e230017e6b22cad740 +development.zh.md: 885b51c701267215cc50d31ecd1694ae2c9af9ca diff --git a/docs/development.md b/docs/development.md index 32339fa2af..0a18e29d3d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,7 +27,7 @@ If hooks are missing because dependencies were restored from cache or `postinsta node scripts/install-lefthook.mjs ``` -The wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly. +The wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly. Before enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files. diff --git a/docs/development.zh.md b/docs/development.zh.md index c74a813466..885b51c701 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -27,7 +27,7 @@ pnpm install node scripts/install-lefthook.mjs ``` -包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。 +包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。 启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。 diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 49c246df69..f94fb3bf11 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto' import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' -import { isAbsolute, join, resolve } from 'node:path' +import { dirname, isAbsolute, join, resolve } from 'node:path' const MINIMUM_GIT = [2, 26, 0] const HOOKS_DIRECTORY = 'dsh-hooks' @@ -437,6 +437,15 @@ function inspectOwnedHooksDirectory(hooksPath) { return { markerPath, ...marker } } +function isRegisteredOwnedHooksPath(commonDirectory, hooksPath) { + const normalizedHooksPath = normalizedPath(hooksPath) + const isRegistered = registeredWorktreeConfigPaths(commonDirectory).some( + configPath => normalizedPath(join(dirname(configPath), HOOKS_DIRECTORY)) === normalizedHooksPath, + ) + if (!isRegistered) return false + return inspectOwnedHooksDirectory(hooksPath)?.hooksPath === hooksPath +} + function ensureOwnedHooksDirectory(hooksPath) { const inspected = inspectOwnedHooksDirectory(hooksPath) if (inspected !== undefined) return inspected @@ -561,14 +570,22 @@ async function main() { 'worktree core.hooksPath', ) let ownedHooksDirectory + let copiedWorktreePathIsOwned = false if (worktreePath !== undefined && worktreePath !== hooksPath) { ownedHooksDirectory = inspectOwnedHooksDirectory(hooksPath) - if (ownedHooksDirectory === undefined || ownedHooksDirectory.hooksPath !== worktreePath) { + const worktreePathIsRelocated = ownedHooksDirectory?.hooksPath === worktreePath + copiedWorktreePathIsOwned = !worktreePathIsRelocated + && isRegisteredOwnedHooksPath(commonDirectory, worktreePath) + if (!worktreePathIsRelocated && !copiedWorktreePathIsOwned) { refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath }) } } const directWorktreePathIsOwned = worktreePath !== undefined - && (worktreePath === hooksPath || ownedHooksDirectory?.hooksPath === worktreePath) + && ( + worktreePath === hooksPath + || ownedHooksDirectory?.hooksPath === worktreePath + || copiedWorktreePathIsOwned + ) const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath') if (effectiveEntry !== undefined) { const effectivePathIsOwned = effectiveEntry.scope === 'worktree' @@ -593,6 +610,7 @@ async function main() { worktreePath !== undefined && worktreePath !== hooksPath && ownedHooksDirectory.hooksPath !== worktreePath + && !copiedWorktreePathIsOwned ) { throw new Error(`hooks directory ownership changed while relocating ${JSON.stringify(worktreePath)}`) } diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 7e30c887ec..a33c245e3d 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -262,6 +262,30 @@ describe('worktree-local Lefthook installer', () => { expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n') }) + it('replaces the owned hook path Git copies into a newly added worktree', async () => { + const fixture = createFixture() + const mainInstall = await runInstaller(fixture, fixture.main) + expect(mainInstall.status, mainInstall.stderr).toBe(0) + const mainHooks = hooksPath(fixture, fixture.main) + const mainHookBefore = readFileSync(join(mainHooks, 'pre-commit'), 'utf8') + const lateLinked = join(fixture.container, 'late-linked') + git(fixture, fixture.main, ['worktree', 'add', '-b', 'late-linked', lateLinked]) + write(join(lateLinked, 'lefthook.yml'), 'late-linked-worktree-config\n') + installFakeLefthook(lateLinked) + expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks) + + const linkedInstall = await runInstaller(fixture, lateLinked) + + expect(linkedInstall.status, linkedInstall.stderr).toBe(0) + const linkedHooks = hooksPath(fixture, lateLinked) + expect(linkedHooks).not.toBe(mainHooks) + expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks) + expect(readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')).toContain( + '# config=late-linked-worktree-config', + ) + expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBefore) + }) + it('serializes concurrent installs and keeps repeated output stable', async () => { const fixture = createFixture() const delayed = { DSH_TEST_LEFTHOOK_DELAY_MS: '150' } @@ -509,6 +533,30 @@ describe('worktree-local Lefthook installer', () => { expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks') }) + it('does not trust an ownership marker outside a registered worktree hook path', async () => { + const fixture = createFixture() + const mainInstall = await runInstaller(fixture, fixture.main) + expect(mainInstall.status, mainInstall.stderr).toBe(0) + const externalHooks = join(fixture.container, 'external-owned-hooks') + write( + join(externalHooks, '.dsh-lefthook-owned'), + `${JSON.stringify({ + version: 1, + owner: 'deepseek-harness worktree-local lefthook hooks', + hooksPath: externalHooks, + })}\n`, + 0o600, + ) + git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', externalHooks]) + + const result = await runInstaller(fixture, fixture.linked) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('worktree-scoped core.hooksPath') + expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(externalHooks) + expect(existsSync(hooksPath(fixture, fixture.linked))).toBe(false) + }) + it('refuses to activate a sibling worktree dormant hook path', async () => { const fixture = createFixture() const linkedConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree') diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 96eaad8c33..96d2dc805f 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user", From 66d650e4fb3bb06bfe073f563ec5c2d4527ce510 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:52:41 +0800 Subject: [PATCH 4/4] refactor: simplify sidebar logics --- ...29-web-details-session-lifecycle.i18n.yaml | 4 +- ...026-07-29-web-details-session-lifecycle.md | 16 +++--- ...-07-29-web-details-session-lifecycle.zh.md | 16 +++--- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- .../tests/details-session-lifecycle.e2e.ts | 52 +++++++++++-------- apps/web/tests/lifecycle-chrome.e2e.ts | 8 --- apps/web/tests/smoke-real.e2e.ts | 4 +- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 6 +-- packages/client/ui-layout/README.zh.md | 6 +-- .../client/ui-layout/src/client/AppFrame.tsx | 30 ++++------- .../client/ui-layout/src/client/columns.ts | 6 +-- .../client/ui-layout/src/client/stores.ts | 15 +++--- packages/client/ui-layout/src/invariant.ts | 4 +- .../client/ui-layout/tests/app-frame.spec.tsx | 37 ++++++------- .../ui-layout/tests/layout-store.spec.ts | 22 ++++---- 18 files changed, 113 insertions(+), 125 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml index 5ba31e77f2..060de23ba3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md -2026-07-29-web-details-session-lifecycle.md: 3483720ef642e87bf2f3ffa0d4cf9677711a3354 -2026-07-29-web-details-session-lifecycle.zh.md: 7570f4ad045be7607beb98295551bb50403620c3 +2026-07-29-web-details-session-lifecycle.md: d9e0255768f165bed0631b9324e971b57ec7dcae +2026-07-29-web-details-session-lifecycle.zh.md: 09452ba80ff240ddca76df239b40ea661566f8e2 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md index 3483720ef6..d9e0255768 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md @@ -6,24 +6,24 @@ English | [中文](2026-07-29-web-details-session-lifecycle.zh.md) ## Problem -The details entry is Session-scoped, but its grid width is root-scoped and persisted. Changing the current Session replaced or removed the details content without closing that root column, so New Session could show its composer beside an empty details panel that still consumed 360 pixels. The same ownership gap applied to ordinary Session switches and to selection invalidation after a Session disappeared. +The details entry is Session-scoped, but its preferred grid width is root-scoped. Selecting a different Session replaced the details content without closing that root preference, so the new owner inherited stale viewing geometry. Hero and other unselected states render no Session-scoped details; they need a derived zero track without becoming false owners in the comparison. ## Decision -`AppFrame` derives one details owner from the authoritative Session projection after the Session baseline is ready: the current Session must still exist and must not be blank. The first ready active Session is baseline restoration, so an open details width may survive a browser refresh. A first ready New Session state has no details owner and closes stale persisted state. +`AppFrame` reads the current Session id and its `blank` summary flag from the authoritative Session projection. It records the last non-blank selected id only when that Session can own details, so hero and other unselected states neither trigger closure nor replace the last Session owner; their rendered details track derives as zero without changing the stored preference. The first Session keeps the default details width; returning to the same Session restores its current width; selecting a different Session closes the root-scoped details preference through the layout store before paint. The per-Session chat selection remains owned by the session-scoped store described by the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md). -After baseline restoration, every details-owner change closes the panel through the layout store before paint. This covers active-to-active navigation, active-to-blank New Session, clearing the current selection, and invalidation after deletion. Returning to the earlier Session keeps details closed because the root store records the close; the per-Session chat selection remains owned by the session-scoped store described by the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md). - -Manual close and reopen inside one unchanged active Session retain their existing behavior. The lifecycle effect changes neither sidebar actions nor the [Workspace-owned New Session flow](../feature/2026-07-25-workspace-ui-product-flow.md), composer drafts, Session navigation, or concession-chain resizing. +The layout store is transient and starts details at its default width. It neither reads nor writes `localStorage`, so reload resets both panel widths and needs no Session-baseline exception. Manual close and reopen inside one unchanged Session retain their existing behavior. The lifecycle effect changes neither the [Workspace-owned New Session flow](../feature/2026-07-25-workspace-ui-product-flow.md), composer drafts, Session navigation, nor concession-chain resizing. ## Alternatives considered -**Close details in the New Session click handler.** Rejected because top-level New Session, Workspace row actions, the Workspace picker, ordinary Session rows, and removal can all change the owner. An entry-point patch would leave the shared lifecycle inconsistent. +**Close details in the New Session click handler.** Rejected because an unselected surface has no Session-scoped details and must not mutate geometry. Closure belongs to the later comparison between two defined Session owners. **Persist panel geometry per Session.** Rejected because the product contract needs stale context removed, not a new map of remembered widths. Per-Session geometry would also reopen details when users return, contrary to the chosen close-on-leave behavior. -**Only hide the details component when no Session is current.** Rejected because a blank Session is still current, and removing content without zeroing the grid track is the reported defect. +**Preserve persisted layout after the Session baseline is ready.** Rejected because it duplicates startup lifecycle in a presentation component solely to validate stale viewing state. Transient defaults make reload deterministic without a readiness flag. + +**Treat every current-projection change as a Session switch.** Rejected because startup materialization, hero, clearing selection, and invalidation are not transitions between two Session owners. ## Consequences -Leaving an active Session forgets any dragged details width, since the existing close action writes zero and reopening uses the contract default. Refreshing an active Session preserves its open panel, while refreshing New Session clears stale persisted geometry. The layout behavior test covers active, blank, missing, switch-back, and baseline-restore states; the keyless browser e2e drives the shipped composition from an active Session through New Session and back while checking the full grid track and browser errors. +Details is open by default, including when the first Session materializes. Switching to a different Session forgets the dragged details width because close writes zero and reopen uses the contract default. Unselected states derive a zero rendered track while leaving the preferred geometry unchanged; returning to the same Session through one of those states restores its width. Reload forgets sidebar and details geometry. The layout behavior test covers initial defaults, first materialization, direct and hero-mediated Session switches, same-Session return, and the absence of layout storage; the keyless browser e2e drives the same owner transitions through the shipped composition while checking the full grid track and browser errors. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md index 7570f4ad04..09452ba80f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md @@ -6,24 +6,24 @@ Status: implemented ## 问题 -详情入口由会话作用域拥有,而其网格宽度由根作用域拥有并持久化。切换当前会话时,系统会替换或移除详情内容,却不会关闭根布局中的该列。因此,New Session 可能在空白详情面板旁显示 composer,而该面板仍占用 360 像素。普通会话切换,以及会话消失后选中状态失效,同样存在这一所有权缺口。 +详情入口由会话作用域拥有,而其首选网格宽度由根作用域拥有。选择不同会话时,系统会替换详情内容,却不会关闭根作用域的该首选宽度,因此新 owner 会继承陈旧的查看几何信息。hero 和其他未选中状态不会渲染会话作用域的详情;其轨道需派生为零宽度,但不能因此在比较中成为伪 owner。 ## 决策 -会话基线就绪后,`AppFrame` 会从权威会话投影派生唯一的详情 owner:当前会话必须仍然存在,且不得为 blank。首次就绪的活动会话属于基线恢复,因此浏览器刷新后可以保留已打开的详情宽度。若首次就绪时处于 New Session,则不存在详情 owner,系统会关闭陈旧的持久化状态。 +`AppFrame` 从权威会话投影读取当前会话 id 及其摘要中的 `blank` 标志。它只在该会话能够拥有详情时记录最后一个选中的非 blank 会话 id,因此 hero 和其他未选中状态既不会触发关闭,也不会替换最后一个会话 owner;这些状态下,详情栏轨道的渲染宽度派生为零,但存储的首选宽度不变。首个会话保留详情栏的默认宽度;返回同一会话时恢复其当前宽度;选择不同会话时,系统会先通过布局 store 关闭根作用域存储的详情栏首选宽度,再进行绘制。逐会话的聊天选中项继续由 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md)所述的会话作用域 store 拥有。 -基线恢复后,详情 owner 每次变化都会先通过布局 store 关闭面板,再进行绘制。这涵盖活动会话之间的导航、从活动会话进入 blank New Session、清除当前选中项,以及删除后选中状态失效。返回先前的会话后,详情仍保持关闭,因为根 store 已记录这次关闭;逐会话的聊天选中项继续由 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md)所述的会话作用域 store 拥有。 - -在同一个未变化的活动会话内手动关闭和重新打开详情栏,仍保持原有行为。该生命周期 effect 既不改变侧边栏操作,也不改变 [Workspace 拥有的 New Session 动线](../feature/2026-07-25-workspace-ui-product-flow.md)、composer 草稿、会话导航或让步链缩放。 +布局 store 是瞬时状态,详情栏以默认宽度启动。它既不读取也不写入 `localStorage`,因此重新加载会重置两个面板的宽度,无需会话基线例外。在同一个未变化的会话内手动关闭和重新打开详情栏,仍保持原有行为。该生命周期 effect 不改变 [Workspace 拥有的 New Session 动线](../feature/2026-07-25-workspace-ui-product-flow.md)、composer 草稿、会话导航或让步链缩放。 ## 考虑过的替代方案 -**在 New Session 点击处理器中关闭详情栏。** 之所以否决:顶层 New Session、Workspace 行操作、Workspace picker、普通会话行和移除操作均可改变 owner。入口级补丁会使共享生命周期继续保持不一致。 +**在 New Session 点击处理器中关闭详情栏。** 之所以否决:未选中表面没有会话作用域的详情,不得修改几何信息。详情栏是否关闭,应由随后对两个已定义会话 owner 的比较决定。 **按会话持久化面板几何信息。** 之所以否决:产品契约需要移除陈旧上下文,而不是新增一张保存各宽度的映射。按会话保存几何信息还会在用户返回时重新打开详情栏,与选定的离开即关闭行为相悖。 -**仅在当前没有会话时隐藏详情组件。** 之所以否决:blank 会话仍是当前会话;只移除内容而不将网格轨道归零,正是本次报告的缺陷。 +**在会话基线就绪后保留持久化布局。** 之所以否决:这会仅为验证陈旧的查看状态,在呈现组件中重复实现启动生命周期。瞬时默认值无需就绪标志即可使重新加载具有确定性。 + +**将当前投影的每次变化都视为会话切换。** 之所以否决:启动时的物化、hero、清除选中项和选中状态失效都不是两个会话 owner 之间的过渡。 ## 后果 -离开活动会话会忘记拖动后的详情宽度,因为现有关闭操作会写入零值,重新打开时则使用契约默认值。刷新活动会话会保留已打开的面板,而刷新 New Session 会清除陈旧的持久化几何信息。布局行为测试覆盖 active、blank、missing、切回和基线恢复状态;无密钥浏览器 e2e 则驱动已交付的组合从活动会话进入 New Session 再返回,同时检查完整网格轨道和浏览器错误。 +详情栏默认打开,首次会话物化时亦然。切换到不同会话会忘记拖动后的详情宽度,因为关闭操作会写入零值,重新打开时则使用契约默认值。未选中状态会将轨道的渲染宽度派生为零,同时保持首选几何信息不变;经由这些状态返回同一会话时,会恢复其宽度。重新加载会忘记侧边栏与详情栏的几何信息。布局行为测试覆盖初始默认值、首次物化、直接及经 hero 中转的会话切换、返回同一会话,以及不存在布局存储的情况;无密钥浏览器 e2e 则通过已交付的组合驱动相同的 owner 过渡,同时检查完整网格轨道和浏览器错误。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 43ca03dc7a..0283559c9c 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: d9e0a9660ecd6aeb75e835e68f92c0a268423872 -2026-07-24-web-gui-browser-e2e-lane.zh.md: e8c7d1c4596f20d88bd08423549fb6a9f7b0654b +2026-07-24-web-gui-browser-e2e-lane.md: ce59dcce270d548c91e3719eee8e9c83aea0c154 +2026-07-24-web-gui-browser-e2e-lane.zh.md: bad3dd15ed7b98cc17340666a6c1094d0de057b1 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index d9e0a9660e..ce59dcce27 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -42,7 +42,7 @@ The typecheck plane split is structural: the host scaffold, its support module, ### Coverage contract -The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout persistence, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. +The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. ### CI stance diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index e8c7d1c459..bad3dd15ed 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -42,7 +42,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### 覆盖契约 -该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局持久化、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。 +该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。 ### CI 立场 diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index c066077edf..c4d6483245 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -1,30 +1,33 @@ // Keyless browser regression for the details column's Session ownership. -// The real shipped composition owns the state transition: an active Session -// rehydrates an open panel, New Session replaces the details owner, and the -// root layout must release the third grid track before the next paint. +// The shipped composition retains geometry through unselected states and closes it only when a different Session takes ownership. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { - acknowledgeReloadConnectionLoss, fixtureUserPrompts, launchWebScaffold, watchConsole, - webSnapshotMode, type WebScaffold, + fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url)) +const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' const MODE = webSnapshotMode() /** Last AppFrame grid track in CSS pixels. */ async function detailsTrack(page: Page): Promise { - return await page.locator('[class*="frame"]').first().evaluate((element) => { + return await appFrame(page).evaluate((element) => { const tracks = getComputedStyle(element).gridTemplateColumns.split(' ') return Number.parseFloat(tracks.at(-1) ?? 'NaN') }) } +/** AppFrame is the only product element with an inline grid track template. */ +function appFrame(page: Page) { + return page.locator('[style*="grid-template-columns"]').first() +} + describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => { let scaffold: WebScaffold let browser: Browser @@ -32,13 +35,15 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S let tripwire: ReturnType beforeAll(async () => { - expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + const fixture = await readFile(FIXTURE, 'utf8') + expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5 }) + await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed') browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await appFrame(page).waitFor({ timeout: 30_000 }) await connectFreshWorkspace(page) }, 120_000) @@ -47,7 +52,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await scaffold?.close() }) - it('removes the details track for New Session and keeps it closed when returning', async () => { + it('retains geometry through hero and closes it for a different Session', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle')) const settled = scaffold.whenTurnSettled() const input = page.locator('textarea').first() @@ -56,28 +61,33 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await settled await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) - // Rehydrate the production layout action's persisted result. The active - // Session survives reload, so its details panel remains valid and open. - await page.evaluate(() => { - localStorage.setItem('dsh.layout.panels', JSON.stringify({ sidebar: 280, details: 360 })) - }) - const warningStart = tripwire.warnings.length - await page.reload({ waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - acknowledgeReloadConnectionLoss(tripwire, warningStart) - await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) - expect(await detailsTrack(page)).toBe(360) + await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(360) expect(await page.getByText('详情', { exact: true }).count()).toBe(1) await page.getByRole('button', { name: 'New session', exact: true }).last().click() await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 }) - expect(await page.locator('[class*="frame"]').first().getAttribute('data-details-collapsed')).not.toBeNull() await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first() await original.click() await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(360) + expect(await page.getByText('详情', { exact: true }).count()).toBe(1) + + const ungrouped = page.getByText('Ungrouped', { exact: true }) + const ungroupedRow = ungrouped.locator('..').locator('..') + const ungroupedSection = ungroupedRow.locator('..') + await expect.poll(async () => { + if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { + await ungrouped.click() + await page.waitForTimeout(50) + } + return await ungroupedRow.getAttribute('aria-expanded') + }, { timeout: 5_000 }).toBe('true') + const seeded = ungroupedSection.locator('[role="treeitem"]').nth(1) + await seeded.click() + await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 4b54242495..a07db275fd 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -101,23 +101,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload')) - // Fold a layout preference into the same reload: collapse the sidebar - // (persisted under dsh.layout.panels) before reloading. - await page.getByRole('button', { name: 'Collapse sidebar' }).click() - await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1) const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) - // Layout persisted: the sidebar comes back collapsed. - await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1) // Selection persisted (dsh.sessions.current) and history replayed: the // recorded turn re-renders from session.history with zero model calls — // the replay cursor was fully consumed before the reload, so any stray // request would fail the scenario loudly at close(). await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) - // Expand back and confirm the tree still lists the materialized session. - await page.getByRole('button', { name: 'Open sidebar' }).click() await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1) // Golden of the recovered conversation region: rebuilt from the log, it // must render the same settled transcript the live turn produced. diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 7b7721ce2c..42b73c1a31 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -469,7 +469,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await screen(page, '09-details-closed') }, 150_000) - it('6 sidebar drag widens the column and persists across reload', async () => { + it('6 sidebar drag widens the column and resets across reload', async () => { onTestFailed(() => saveFailureShot(page, 'w5-drag')) const before = await firstTrack(page) const handle = page.locator('[class*="handle"]').first() @@ -484,7 +484,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await screen(page, '10-sidebar-dragged') await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - expect(await firstTrack(page)).toBe(after) + expect(await firstTrack(page)).toBe(before) }) it('7 dark mode: the body attribute cascades the token sheets', async () => { diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index ef1d66f060..eff1fbe925 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md -README.md: 836100066039e3695e314a4a4bbfaba8fb20c652 -README.zh.md: ffef7511b6cfdc3109203be86199766073bf5efd +README.md: 9354f4b79f7b1af7d8a20a295e77913ff443c2e4 +README.zh.md: c949236557e7eb3eed0c698566fb5aa9e9cdd18a diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 8361000660..9354f4b79f 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body). -AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The first ready active Session may restore an open details width across reload; New Session and every later current-Session change close details before paint, including selection invalidation after deletion. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face. +AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts both panels at their default widths and never reads or writes `localStorage`. Hero and other unselected states derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session opens at the default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces. The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal; tests import internals through `/src`. @@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Details width is global, not retained per Session** — changing or losing its active Session closes the panel and forgets a dragged width; returning to that Session does not reopen it. -- **Concession-chain auto-close derives a zero width without touching the persisted open flag** — the panel restores itself when the window widens; consumers must not read `details.open` as the rendered truth. +- **Panel geometry is transient** — reload restores both panels to their defaults; switching between distinct Session ids closes details and forgets its dragged width, while unselected surfaces render details at zero width without modifying geometry. +- **Concession-chain auto-close derives a zero width without touching the preferred width** — the panel restores itself when the window widens; consumers must not read the stored details width as the rendered truth. - **Scroll anchoring during squeeze reflow is not implemented** — deferred with the virtualized-list project. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index ffef7511b6..c949236557 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -4,7 +4,7 @@ 外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。 -AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 则通过 `SessionProvider` 渲染。首次就绪的活动会话可在重新加载后恢复已打开的详情宽度;New Session 以及后续每次当前会话变化,都会在绘制前关闭详情栏,包括删除后选中状态失效的情况。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed` 和 `width`;导航操作属于侧边栏自身注入的服务表层。 +AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,两个面板均以默认宽度启动,且从不读写 `localStorage`。hero 和其他未选中状态会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话以默认宽度打开;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。 `/client` 导出表层包含插件主体(`apply`/`inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部;测试通过 `/src` 导入内部实现。 @@ -18,6 +18,6 @@ AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态, ## 已知限制与暂缓事项 -- **详情宽度是全局状态,不按会话保留**:切换或失去当前活动会话会关闭详情栏,并忘记拖动后的宽度;返回该会话时不会重新打开详情栏。 -- **让步链自动关闭通过推导零宽度实现,不会改动持久化的打开标志**:窗口变宽时面板会自行恢复;消费方禁止把 `details.open` 当作实际渲染状态。 +- **面板几何信息是瞬时状态**:重新加载会将两个面板恢复为默认值;在不同会话 id 之间切换会关闭详情栏,并忘记拖动后的宽度,而未选中表面会以零宽度渲染详情栏,但不会修改几何信息。 +- **让步链自动关闭通过推导零宽度实现,不会改动首选宽度**:窗口变宽时面板会自行恢复;消费方禁止把 store 中的详情宽度当作实际渲染状态。 - **挤压重排期间尚未实现滚动锚定**:与虚拟化列表项目一并暂缓。 diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index da7636de9c..8aa16d8675 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -91,33 +91,21 @@ export function AppFrame({ renderSlot, }: AppFrameProps) { const panels = useStore(s => s) - const sessionsPhase = useSessions(s => s.phase) const detailsSession = useSessions((s) => { const current = s.current - if (current === undefined) return undefined - const session = s.byId[current] - return session !== undefined && !session.blank ? current : undefined + return current !== undefined && s.byId[current]?.blank === false ? current : undefined }) const frameRef = useRef(null) const [viewport, setViewport] = useState(() => window.innerWidth) - // The first ready active Session is baseline restoration, so its persisted - // panel may remain open. New Session has no inspectable selection, and any - // later details owner change closes the root-scoped column before paint. - const detailsBaselineReady = useRef(false) - const previousDetailsSession = useRef(detailsSession) + const lastSession = useRef(detailsSession) useLayoutEffect(() => { - if (sessionsPhase !== 'ready') return - if (!detailsBaselineReady.current) { - detailsBaselineReady.current = true - previousDetailsSession.current = detailsSession - if (detailsSession === undefined) actions.closeDetails() - return + if (detailsSession === undefined) return + if (lastSession.current !== undefined && lastSession.current !== detailsSession) { + actions.closeDetails() } - if (previousDetailsSession.current === detailsSession) return - previousDetailsSession.current = detailsSession - actions.closeDetails() - }, [actions, detailsSession, sessionsPhase]) + lastSession.current = detailsSession + }, [actions, detailsSession]) // Track the frame's own box (not the window): rAF-throttled ResizeObserver. useEffect(() => { @@ -139,12 +127,12 @@ export function AppFrame({ } }, []) - const cols = computeColumns(viewport, panels.sidebar, panels.details) + const cols = computeColumns(viewport, panels.sidebar, detailsSession === undefined ? 0 : panels.details) const colsRef = useRef(cols) colsRef.current = cols // The drag base is the rendered width captured at drag start (grabbing a - // concession-clamped panel must not jump back to the persisted preference); + // concession-clamped panel must not jump back to the stored preference); // it stays frozen for the whole gesture so dx deltas do not compound. const sidebarBase = useRef(0) const detailsBase = useRef(0) diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index 7cd5f8c2d8..125bb92a70 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -1,7 +1,7 @@ /** * Pure concession-chain column solver for the three-column AppFrame. * Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking - * details, then auto-closing it (derived zero width — persisted width + * details, then auto-closing it (derived zero width — preferred width * preferences are never rewritten, so widening the window restores them). * The sidebar never concedes: its rendered width is always the drag * preference (or the collapsed rail), and center absorbs any remaining @@ -45,8 +45,8 @@ export function clampWidth(px: number, min: number, max: number): number { /** * Solve the three column widths for one viewport frame. Pure: no hysteresis — * the output is a function of (viewport, preferences) only, so recovery on - * re-widening is automatic. Preferences re-clamp here because they cross a - * durable boundary (localStorage rehydration may carry stale ranges). + * re-widening is automatic. Preferences re-clamp here because they cross the + * store boundary and callers may still supply stale ranges. * @param viewport - available frame width in px. * @param sidebar - sidebar width preference in px (0 = closed). * @param details - details width preference in px (0 = closed). diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts index 06bcbe5ae3..01115c12a5 100644 --- a/packages/client/ui-layout/src/client/stores.ts +++ b/packages/client/ui-layout/src/client/stores.ts @@ -1,7 +1,7 @@ /** - * The root entry's layout store: panel geometry as plain widths in px - * (0 = closed), persisted across reloads. Module level exports the factory - * only — a module-level handle would pin the store's identity in the module + * The root entry's transient layout store: panel geometry as plain widths in + * px (0 = closed). Module level exports the factory only — a module-level + * handle would pin the store's identity in the module * cache (a de-facto singleton surviving plugin reloads). register() receives * the factory (exclusive use: the framework instantiates per entry), AppFrame * derives its PropsStore share from the return type, and the service face @@ -29,17 +29,16 @@ type LayoutActions = { } /** - * Create the layout panel store handle. The persisted preference IS the - * width, so closing a panel forgets its drag width — reopening restores the - * contract default. Actions are the complete write set: drag writes clamp + * Create the layout panel store handle. The preference IS the width, so + * closing a panel forgets its drag width — reopening restores the contract + * default. Actions are the complete write set: drag writes clamp * into the panel's contract range and never cross the open/closed line; * open/close transitions write 0 / the default explicitly. * @returns the store handle (spec + type + identity + factory in one). */ export function createLayoutStore(): EngineStoreHandle { const handle = defineStore({ - init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }), - persist: 'dsh.layout.panels', + init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT }), actions: { setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) }, setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) }, diff --git a/packages/client/ui-layout/src/invariant.ts b/packages/client/ui-layout/src/invariant.ts index fa46392b5d..dd572e679d 100644 --- a/packages/client/ui-layout/src/invariant.ts +++ b/packages/client/ui-layout/src/invariant.ts @@ -15,8 +15,8 @@ export const name = 'client-ui-layout-invariant' export const inject = ['invariants'] /** - * No runtime invariant: shell viewing-state stores (zustand+persist) behind - * ctx.layout — it emits no cordis events; clamp/prune/concession-chain + * No runtime invariant: the shell viewing-state store behind ctx.layout emits + * no cordis events; clamp/prune/concession-chain * sequencing is asserted directly by this package's columns and service specs. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index 54c23688dc..95933b2783 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -24,7 +24,6 @@ import type { // Session selection controls for the SessionProvider and useSessions stubs. const selectedSession = { current: 's-test' as SessionId | undefined } const selectedSessionBlank = { current: false } -const sessionsPhase = { current: 'ready' as SessionListState['phase'] } const baselinesReady = { current: true } // Render-prop contract stub fed through the standard seat prop (the renderer @@ -56,7 +55,6 @@ function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapsho function mountFrame() { window.innerWidth = frameWidth // first-render viewport source before the observer fires const instance = createLayoutStore().create() - instance.actions.openDetails() // seed: sidebar at default 280, details open at default 360 const slotCalls: { key: string; props: unknown }[] = [] const renderSlot = ((key: string, owner: object) => { slotCalls.push({ key, props: owner }) @@ -74,7 +72,7 @@ function mountFrame() { ? {} : { [current]: { id: current, displayTitle: 'Test', running: false, blank: selectedSessionBlank.current, updatedAt: 1 } }, current, - phase: sessionsPhase.current, + phase: 'ready', } as SessionListState return sel(sessionState) }) as never @@ -116,9 +114,7 @@ beforeEach(() => { frameWidth = 1920 selectedSession.current = 's-test' as SessionId selectedSessionBlank.current = false - sessionsPhase.current = 'ready' baselinesReady.current = true - localStorage.clear() // the layout store persists; instances must not bleed across tests vi.useFakeTimers() vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number) @@ -176,7 +172,7 @@ describe('AppFrame', () => { expect(slotCalls.map(c => c.key)).toContain('details') }) - it('closes details when the ready current Session changes, including New Session, and keeps it closed on return', () => { + it('ignores unselected states and closes only when the Session id changes', () => { const { frame, instance, rerenderFrame } = mountFrame() expect(tracks(frame)).toEqual([280, 360]) @@ -189,31 +185,30 @@ describe('AppFrame', () => { selectedSessionBlank.current = true act(() => { rerenderFrame() }) expect(tracks(frame)).toEqual([280, 0]) + expect(instance.getSnapshot().details).toBe(360) - selectedSession.current = 's-test' as SessionId + selectedSession.current = 's-next' as SessionId selectedSessionBlank.current = false act(() => { rerenderFrame() }) - expect(tracks(frame)).toEqual([280, 0]) + expect(tracks(frame)).toEqual([280, 360]) - act(() => { instance.actions.openDetails() }) selectedSession.current = undefined act(() => { rerenderFrame() }) expect(tracks(frame)).toEqual([280, 0]) + selectedSession.current = 's-test' as SessionId + act(() => { rerenderFrame() }) + expect(tracks(frame)).toEqual([280, 0]) }) - it('preserves open details across active-session baseline restore but closes it for an initial New Session view', () => { - sessionsPhase.current = 'pending' - const active = mountFrame() - expect(tracks(active.frame)).toEqual([280, 360]) - sessionsPhase.current = 'ready' - act(() => { active.rerenderFrame() }) - expect(tracks(active.frame)).toEqual([280, 360]) - active.unmount() + it('keeps the default details width when the first Session materializes', () => { + selectedSession.current = undefined + const { frame, instance, rerenderFrame } = mountFrame() + expect(tracks(frame)).toEqual([280, 0]) + expect(instance.getSnapshot().details).toBe(360) - selectedSession.current = 's-blank' as SessionId - selectedSessionBlank.current = true - const blank = mountFrame() - expect(tracks(blank.frame)).toEqual([280, 0]) + selectedSession.current = 's-first' as SessionId + act(() => { rerenderFrame() }) + expect(tracks(frame)).toEqual([280, 360]) }) it('sidebar slot receives live concession output as owner props', () => { diff --git a/packages/client/ui-layout/tests/layout-store.spec.ts b/packages/client/ui-layout/tests/layout-store.spec.ts index e5938d0a1b..3ec3cb2c7e 100644 --- a/packages/client/ui-layout/tests/layout-store.spec.ts +++ b/packages/client/ui-layout/tests/layout-store.spec.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom /** * createLayoutStore unit account: init shape, the action write set (clamp - * inside actions), and the persist key round-trip over jsdom localStorage. - * Uses the test-sanctioned path: factory self-call + .create() gives the + * inside actions), and the absence of browser persistence. Uses the + * test-sanctioned path: factory self-call + .create() gives the * real engine instance (same create path as production). */ import { beforeEach, describe, expect, it } from 'vitest' @@ -17,9 +17,9 @@ const PERSIST_KEY = 'dsh.layout.panels' beforeEach(() => { localStorage.clear() }) describe('createLayoutStore', () => { - it('initializes with sidebar open at default and details closed', () => { + it('initializes both panels at their default widths', () => { const { store } = createLayoutStore().create() - expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 }) + expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT }) }) it('each create() is an independent instance (factory is not a singleton)', () => { @@ -52,6 +52,7 @@ describe('createLayoutStore', () => { it('openDetails is a no-op when already open; closeDetails zeroes', () => { const { store, actions } = createLayoutStore().create() + actions.closeDetails() actions.openDetails() expect(store.getSnapshot().details).toBe(DETAILS_DEFAULT) actions.setDetails(500) @@ -61,13 +62,16 @@ describe('createLayoutStore', () => { expect(store.getSnapshot().details).toBe(0) }) - it('persists under dsh.layout.panels and rehydrates on the next create', () => { + it('does not persist panel geometry', () => { const first = createLayoutStore().create() - first.actions.setSidebar(320) - first.actions.openDetails() - expect(JSON.parse(localStorage.getItem(PERSIST_KEY) ?? '{}')).toEqual({ sidebar: 320, details: DETAILS_DEFAULT }) + first.actions.setSidebar(400) + first.actions.closeDetails() + expect(localStorage.getItem(PERSIST_KEY)).toBeNull() const second = createLayoutStore().create() - expect(second.store.getSnapshot()).toEqual({ sidebar: 320, details: DETAILS_DEFAULT }) + expect(second.store.getSnapshot()).toEqual({ + sidebar: SIDEBAR_DEFAULT, + details: DETAILS_DEFAULT, + }) }) })