Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # apps/web/tests/code-mode-fixture.snapshot.ts # docs/architecture.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/module-graph.md # packages/README.i18n.yaml # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/test-runtime/tests/runtime.spec.tsx # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.zh.md # packages/compact/compact-basic/src/summarizer.ts # packages/host/apiproxy/src/api/sessions.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.i18n.yaml # pnpm-lock.yaml
This commit is contained in:
@@ -17,7 +17,9 @@ import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
|
||||
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
|
||||
import type { ComposerBarOwnerProps } from '../src/client/contract/slots.ts'
|
||||
import type {
|
||||
ComposerBarOwnerProps,
|
||||
} from '../src/client/contract/slots.ts'
|
||||
|
||||
/** Machine-backed wiring over a sink spy. */
|
||||
function fakeWiring() {
|
||||
@@ -59,6 +61,8 @@ function mount(
|
||||
snapshot: ConversationSnapshot,
|
||||
workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }],
|
||||
retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}),
|
||||
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
|
||||
overlayTakeover = false,
|
||||
) {
|
||||
const root = sid('root')
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
@@ -99,10 +103,18 @@ function mount(
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot as never}
|
||||
views={{ list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 }}
|
||||
views={{
|
||||
list: () => [
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'trajectory', label: 'Trajectory' },
|
||||
],
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
releaseSessionImages={vi.fn()}
|
||||
bindDraftMirror={write => wiring.bindMirror(write)}
|
||||
open={open}
|
||||
{...owner}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -128,6 +140,7 @@ function mount(
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
stop={stop}
|
||||
command={() => Promise.resolve(true)}
|
||||
translateHint={(key: string) => key}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
/>
|
||||
@@ -135,7 +148,18 @@ function mount(
|
||||
}
|
||||
return <div data-testid={`view-${opts?.only ?? key}`} />
|
||||
}) as ConversationRootProps['renderSlot']
|
||||
const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
|
||||
const renderSlotChain = ((_key, _owner, opts) => (
|
||||
overlayTakeover
|
||||
? (
|
||||
<>
|
||||
<div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
|
||||
{opts?.fallback ?? null}
|
||||
</div>
|
||||
<div data-testid="composer-takeover">TAKEOVER</div>
|
||||
</>
|
||||
)
|
||||
: (opts?.fallback ?? null)
|
||||
)) as ConversationRootProps['renderSlotChain']
|
||||
const props: ConversationRootProps = {
|
||||
sessionId: SID,
|
||||
SessionProvider: ({ children }) => children(SID),
|
||||
@@ -170,6 +194,30 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.open).toHaveBeenCalledWith(sid('root'))
|
||||
})
|
||||
|
||||
it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => {
|
||||
const b = mount(conversationSnapshot())
|
||||
const host = b.view.container.querySelector('[data-conversation-scroll]')
|
||||
const seat = b.view.container.querySelector('[data-composer-seat]')
|
||||
const header = b.view.container.querySelector('header')
|
||||
const textarea = b.view.container.querySelector('textarea')
|
||||
expect(host).not.toBeNull()
|
||||
expect(seat).not.toBeNull()
|
||||
expect(header).not.toBeNull()
|
||||
// Header is column chrome above the scrollport; the seat sticks inside it.
|
||||
expect(host?.contains(header)).toBe(false)
|
||||
expect(host?.contains(seat)).toBe(true)
|
||||
expect(seat?.contains(textarea)).toBe(true)
|
||||
})
|
||||
|
||||
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
|
||||
const b = mount(conversationSnapshot(), undefined, undefined, true)
|
||||
const seat = b.view.container.querySelector('[data-composer-seat]')
|
||||
const takeover = b.view.getByTestId('composer-takeover')
|
||||
const fallback = b.view.container.querySelector('[data-chain-overlay-fallback="conversation.composer"]')
|
||||
expect(seat?.contains(takeover)).toBe(true)
|
||||
expect(seat?.contains(fallback)).toBe(true)
|
||||
})
|
||||
|
||||
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
|
||||
const b = mount(
|
||||
conversationSnapshot({ composerPhase: 'blank', blank: true }),
|
||||
@@ -178,13 +226,19 @@ describe('ConversationRoot resident composer', () => {
|
||||
{ ...workspace('second'), title: 'Selected Folder' },
|
||||
],
|
||||
)
|
||||
// Hero chrome present, view ring absent.
|
||||
// Hero chrome present, view ring absent; scroll host already wraps the
|
||||
// resident composer so the blank → active flip does not remount it.
|
||||
const host = b.view.container.querySelector('[data-conversation-scroll]')
|
||||
const header = b.view.container.querySelector('header')
|
||||
expect(host).not.toBeNull()
|
||||
expect(header?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(b.view.getByText("Let's start building")).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-less
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-hidden
|
||||
// for blank sessions): hero typing reaches the chat store.
|
||||
const box = b.view.getByRole('textbox')
|
||||
expect(host?.contains(box)).toBe(true)
|
||||
fireEvent.change(box, { target: { value: 'draft in hero' } })
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
|
||||
// Picker: open through the chip; a pick switches to the other
|
||||
@@ -197,20 +251,31 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.view.getByText('Selected Folder')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('textarea DOM identity survives the hero → active flip', () => {
|
||||
it('same textarea DOM node survives the hero → active flip into the sticky scrollport', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const before = b.view.getByRole('textbox')
|
||||
fireEvent.change(before, { target: { value: 'kept across flip' } })
|
||||
// First message landed: content exists, phase leaves blank.
|
||||
// First message landed: content exists, phase leaves blank. Composer
|
||||
// already sat in the Session scrollport during hero, so the textarea
|
||||
// node and InputHub draft both survive.
|
||||
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
|
||||
b.rerender()
|
||||
const after = b.view.getByRole('textbox')
|
||||
const after = b.view.getByRole('textbox') as HTMLTextAreaElement
|
||||
expect(after).toBe(before)
|
||||
expect((after as HTMLTextAreaElement).value).toBe('kept across flip')
|
||||
expect(after.value).toBe('kept across flip')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
|
||||
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
|
||||
expect(b.view.queryByText("Let's start building")).toBeNull()
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps pending takeover interaction accessible outside the Chat view', () => {
|
||||
const b = mount(conversationSnapshot({ pending: [{} as never] }))
|
||||
act(() => { b.chat.actions.setView('trajectory') })
|
||||
expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
|
||||
expect(b.view.getByRole('textbox')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rolls the pending workspace label back when switching fails', async () => {
|
||||
const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
|
||||
const b = mount(
|
||||
|
||||
Reference in New Issue
Block a user