Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 36 additions & 33 deletions apps/conciv/src/pane/chat-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export function ChatPane(props: {sessionId: string}): JSX.Element {
const isStreaming = () => chat.status() === 'streaming'
const working = () => isThinking() || isStreaming()
const disconnected = () => chat.connectionStatus() !== 'connected'
const hydrated = createMemo<boolean>((prev) => prev || !disconnected(), false)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified in the dep source rather than assumed: in @tanstack/ai-client chat-client.ts, processIncomingChunk sets connectionStatus to 'connected' on the first chunk and then synchronously calls processor.processChunk(chunk) — a synchronous void method whose handleMessagesSnapshotEvent applies the snapshot and emits the messages change in the same block; the inter-chunk macrotask yield (setTimeout 0) happens only after both. Our server unconditionally emits MESSAGES_SNAPSHOT as the first chunk on every subscribe (core/src/chat/subscribe.ts), including an empty one for new sessions. So no paint can occur between 'connected' and 'snapshot applied' — connected is evidence of transcript hydration here, not just transport liveness. The PR description was stale (it described a scrapped earlier design) and has been updated with this evidence.


const panelFocus = usePanelComposerFocus()
const [inputHandle, setInputHandle] = createSignal<ComposerInputHandle>()
Expand Down Expand Up @@ -328,40 +329,42 @@ export function ChatPane(props: {sessionId: string}): JSX.Element {
<Thread>
<Thread.Viewport>
<Suspense fallback={<ConversationSkeleton />}>
<Thread.Welcome>
<Show when={!disconnected()} fallback={<ConversationSkeleton />}>
<EmptyStateSlot
onStarter={(starter) => void chat.sendMessage(starter)}
instances={instances}
/>
<Show when={hydrated()} fallback={<ConversationSkeleton />}>
<Thread.Welcome>
<Show when={!disconnected()} fallback={<ConversationSkeleton />}>
<EmptyStateSlot
onStarter={(starter) => void chat.sendMessage(starter)}
instances={instances}
/>
</Show>
</Thread.Welcome>
<Thread.Messages
tools={tools()}
attachmentCards={attachments().cards}
components={{ToolFallback: ToolFallbackCard}}
turnPrefix={renderTurnPrefix}
pageSession={PAGE_SESSION}
/>
<For each={dividersAt(chat.messages().length)}>{renderDivider}</For>
<Show when={compacting()}>
<Divider kind="compact" pending />
</Show>
<Show when={isThinking()}>
<ThinkingBubble />
</Show>
<Show when={nowTitleText()}>
{(title) => <NowLine title={title()} onStop={() => chat.stop()} />}
</Show>
<Show when={visibleError()}>
{(error) => (
<div class={ERROR} role="alert">
<span class="flex-1">{error().message}</span>
<button type="button" class={RETRY} onClick={() => void chat.reload()}>
Retry
</button>
</div>
)}
</Show>
</Thread.Welcome>
<Thread.Messages
tools={tools()}
attachmentCards={attachments().cards}
components={{ToolFallback: ToolFallbackCard}}
turnPrefix={renderTurnPrefix}
pageSession={PAGE_SESSION}
/>
<For each={dividersAt(chat.messages().length)}>{renderDivider}</For>
<Show when={compacting()}>
<Divider kind="compact" pending />
</Show>
<Show when={isThinking()}>
<ThinkingBubble />
</Show>
<Show when={nowTitleText()}>
{(title) => <NowLine title={title()} onStop={() => chat.stop()} />}
</Show>
<Show when={visibleError()}>
{(error) => (
<div class={ERROR} role="alert">
<span class="flex-1">{error().message}</span>
<button type="button" class={RETRY} onClick={() => void chat.reload()}>
Retry
</button>
</div>
)}
</Show>
</Suspense>
</Thread.Viewport>
Expand Down
21 changes: 21 additions & 0 deletions apps/conciv/test/chat-pane.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,24 @@ test('Escape outside the composer does not stop the run', async () => {

await expect.element(page.getByRole('button', {name: 'Stop generating'})).toBeVisible()
})

test('a new-session divider does not flash before the transcript snapshot hydrates', async () => {
mountChatPane({
holdSnapshot: true,
markers: [{id: 'marker-1', sessionId: PANE_SESSION, afterTurn: 0, kind: 'new'}],
snapshotFor: () => [
{id: 'u1', role: 'user', parts: [{type: 'text', content: 'restart with a clean slate'}]},
{id: 'a1', role: 'assistant', parts: [{type: 'text', content: 'starting a fresh session'}]},
],
})

await expect.element(page.getByRole('status', {name: 'Loading conversation'})).toBeVisible()
await core?.idle()
await expect.element(page.getByRole('separator', {name: 'New session'})).not.toBeInTheDocument()

core?.releaseSnapshot()

await expect.element(page.getByText('starting a fresh session')).toBeVisible()
await expect.element(page.getByRole('separator', {name: 'New session'})).toBeVisible()
await expect.element(page.getByRole('status', {name: 'Loading conversation'})).not.toBeInTheDocument()
})
11 changes: 9 additions & 2 deletions apps/conciv/test/helpers/fake-core.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import {browserRpcConnection, closeBrowserRpcConnection, type DraftRow, type SessionMeta} from '@conciv/contract'
import {
browserRpcConnection,
closeBrowserRpcConnection,
type DraftRow,
type MarkerRow,
type SessionMeta,
} from '@conciv/contract'
import '../../src/lib/api-base.js'

export const CORE_BASE = 'http://conciv.test'
Expand All @@ -24,6 +30,7 @@ export type FakeCoreConfig = {
rejectSend?: boolean
snapshotFor?: (subscribeIndex: number) => unknown[]
holdSnapshot?: boolean
markers?: MarkerRow[]
holdRun?: boolean
launchOk?: boolean
launchRejects?: boolean
Expand Down Expand Up @@ -139,7 +146,7 @@ export function installFakeCore(config: FakeCoreConfig = {}): FakeCore {
'/rpc/sessions/compact': () => reply({ok: true}),
'/rpc/drafts/get': () => reply(config.draft ?? null),
'/rpc/drafts/set': () => reply({ok: true}),
'/rpc/markers/list': () => reply([]),
'/rpc/markers/list': () => reply(config.markers ?? []),
'/rpc/captures/list': () => reply({captures: [], cssBundles: {}}),
'/rpc/meta/models': () =>
reply({
Expand Down
Loading