Merge branch 'master' into fix/web-turn-error-surface

This commit is contained in:
Ziya
2026-07-31 07:07:05 -04:00
committed by GitHub
8 changed files with 259 additions and 11 deletions

View File

@@ -22,6 +22,7 @@ export function ConversationRoot({
const session = useSession(s => s)
const inputState = useInput(s => s)
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank)
const workspaces = useWorkspaces(s => s)
const [pickerOpen, setPickerOpen] = useState(false)
@@ -65,8 +66,16 @@ export function ConversationRoot({
// While a session is still replaying (loading + blank) the hero/docked
// choice is unknowable — render the composer hidden instead of flashing
// the centered hero and snapping to the docked bar (or vice versa).
// Exemption: a session the list summary already proves blank can only
// land on the hero, so hiding would blank the column for the whole
// history round-trip (the startup auto-selection flash) for nothing.
// The exemption is deliberately open-state-wide, not loading-only: a
// summary-blank session is the hero before its open starts (`cold`) and
// after one fails (`error`) for the same reason — there is no history.
const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading'
const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open')
&& summaryBlank !== true
const hero = sessionId === undefined
|| (composerPhase === 'blank' && (openState === 'open' || summaryBlank === true))
const zone: InputZone | undefined =
session === undefined || inputState === undefined ? undefined : { session, input: inputState }

View File

@@ -80,16 +80,25 @@ 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,
options: {
/** When true, mimic overlay:true chain siblings (hidden fallback + takeover). */
overlayTakeover?: boolean
/** The session list summary's `blank` flag — independent of the snapshot's. */
summaryBlank?: boolean
/** Drop the session's summary row entirely (a session the list has not caught up with). */
omitSummaryRow?: boolean
} = {},
) {
const root = sid('root')
const rootRow = { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 }
const childRow = {
id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one',
running: false, waitingApproval: false, blank: options.summaryBlank ?? false, updatedAt: 2,
}
const listed = options.omitSummaryRow !== true
const sessions = createSnapshotStore<SessionListState>({
ids: [root, SID],
byId: {
[root]: { id: root, displayTitle: 'Root', running: false, waitingApproval: false, blank: false, updatedAt: 1 },
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, waitingApproval: false, blank: false, updatedAt: 2 },
},
ids: listed ? [root, SID] : [root],
byId: { [root]: rootRow, ...listed && { [SID]: childRow } },
current: SID,
phase: 'ready',
})
@@ -167,7 +176,7 @@ function mount(
return <div data-testid={`view-${opts?.only ?? key}`} />
}) as ConversationRootProps['renderSlot']
const renderSlotChain = ((_key, _owner, opts) => (
overlayTakeover
options.overlayTakeover === true
? (
<>
<div data-chain-overlay-fallback="conversation.composer" style={{ display: 'none' }}>
@@ -229,7 +238,7 @@ describe('ConversationRoot resident composer', () => {
})
it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {
const b = mount(conversationSnapshot(), undefined, undefined, true)
const b = mount(conversationSnapshot(), undefined, undefined, { overlayTakeover: 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"]')
@@ -270,6 +279,39 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByText('Selected Folder')).toBeTruthy()
})
it('settling phase: a summary that does not prove the session blank hides the composer while it opens', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('settling')
expect(b.view.queryByText('开始构建吧')).toBeNull()
})
it('settling phase: a session the list has no row for settles conservatively', () => {
const b = mount(
conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }),
undefined,
undefined,
{ omitSummaryRow: true },
)
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('settling')
})
it('startup auto-selection: a summary-proven blank session opens straight into the hero', () => {
const b = mount(
conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }),
undefined,
undefined,
{ summaryBlank: true },
)
// The summary already proves the outcome, so the settling hide would only
// blank the column for the history round-trip.
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('hero')
expect(b.view.getByText('开始构建吧')).toBeTruthy()
expect(b.view.getByRole('textbox')).toBeTruthy()
})
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')