feat(web): add plan mode controls

This commit is contained in:
fz
2026-07-24 12:55:23 +08:00
parent bc63b5fe00
commit 9a69feeec0
41 changed files with 1047 additions and 27 deletions

View File

@@ -82,6 +82,7 @@ describe('apply wiring', () => {
// Declaring is claiming: the chat entry's registration put the hole on
// the ledger with the contract's kind/scope.
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
expect(b.slots.spec('conversation.composer.controls')).toEqual({ kind: 'list', scope: 'session' })
})
it('occupies the three slots + the ring; session entries share one store handle, empty declares none', async () => {
@@ -122,6 +123,7 @@ describe('apply wiring', () => {
expect(b.slots.entries('conversation.view')).toHaveLength(0)
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
expect(b.slots.spec('conversation.composer.controls')).toBeUndefined()
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.slots.entries('conversation.empty')).toHaveLength(0)
expect(b.ctx.get('conversation')).toBeUndefined()

View File

@@ -128,4 +128,11 @@ describe('error strip and variants', () => {
expect(view.getByTestId('acc')).toBeTruthy()
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
})
it('renders additive controls left of the primary action', () => {
const { view, button } = setup({ controls: <span data-testid="mode-control">计划</span> })
const control = view.getByTestId('mode-control')
expect(control).toBeTruthy()
expect(control.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
})
})

View File

@@ -58,7 +58,9 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
describe('ConversationRoot branches', () => {
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
const stubRenderSlot = ((key: string) => key === 'conversation.composer.controls'
? null
: <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>

View File

@@ -119,9 +119,9 @@ describe('ConversationRoot', () => {
// The renderSlot share as the outlet would bake it: renders a marker for
// the ring key carrying the active-id filter (a Mock cannot satisfy the
// generic method type directly — cast once at the prop seam).
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => (
<div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />
))
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => key === 'conversation.composer.controls'
? <span data-testid="composer-controls" />
: <div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />)
const ui = render(
<ConversationRoot
sessionId={sid('s1')}
@@ -173,7 +173,9 @@ describe('ConversationRoot', () => {
const { renderSlot } = bench([tab('chat', 'Chat')])
// No owner share: views take everything from the standard kit (contract).
expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' })
expect(renderSlot).toHaveBeenCalledWith('conversation.composer.controls', {})
expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view')
expect(screen.getByTestId('composer-controls')).toBeTruthy()
})
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
@@ -195,6 +197,7 @@ describe('ConversationRoot', () => {
bench([tab('chat', 'Chat')], undefined, { pending: [wait] }, renderSlotChain)
expect(screen.getByText('question takeover')).toBeTruthy()
expect(screen.queryByPlaceholderText(/输入消息/)).toBeNull()
expect(screen.queryByTestId('composer-controls')).toBeNull()
// The owner dispatches the raw pending list (chain currency); routing
// lives in entry selectors, not here.
expect(renderSlotChain).toHaveBeenCalledWith(