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:
creatixchu
2026-07-30 10:36:41 +08:00
935 changed files with 41976 additions and 6862 deletions

View File

@@ -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(