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
18 changes: 10 additions & 8 deletions cometline/src/lib/components/ChatView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,8 @@
// loads would otherwise dock the composer and skip FirstTurnFlight.
return chatStore.items.length > 0;
}
// Store is still bound to a previous session (mid-switch). Before our
// first sync, assume visible when we do not yet know the target is empty
// so we don't flash EmptyChatState while switching to a full transcript.
// Mid-switch: no user/assistant yet stays hero (fork status notes ignored).
if (!chatStore.hasCachedConversationTurns(sessionId)) return false;
if (!snapshotSynced) return true;
return snapshotItems.length > 0;
});
Expand Down Expand Up @@ -203,7 +202,9 @@
// destination avatar/thinking indicator appear before the overlay arrives.
// Soft swaps (/change fork, sidebar click) keep ChatView mounted — this must
// be remount-equivalent so composer phase + flight flags are not stuck until Cmd+R.
$effect(() => {
// Use $effect.pre so stale awaiting/firstTurn flags clear BEFORE syncComposerPhase
// can dock on the previous session's mid-switch visibility.
$effect.pre(() => {
void sessionId;
untrack(() => {
flightAbortController?.abort();
Expand All @@ -212,12 +213,13 @@
userBubbleFlight?.dismissParticle();
firstTurnActive = false;
firstTurnHandoffPending = false;
const cachedCount = chatStore.getCachedItemCount(sessionId);
const hasTurns = chatStore.hasCachedConversationTurns(sessionId);
awaitingFirstAssistant = chatStore.isAwaitingFirstAssistant(sessionId);
// Empty session: explicitly false. Do NOT use `!awaitingFirstAssistant`
// No user/assistant yet: explicitly false. Do NOT use `!awaitingFirstAssistant`
// (true when idle) which wrongly marks flight done after soft swaps.
firstTurnFlightDone = cachedCount > 0;
if (cachedCount === 0 && !awaitingFirstAssistant) {
// Fork system notes are status-only and must not mark flight done.
firstTurnFlightDone = hasTurns;
if (!hasTurns && !awaitingFirstAssistant) {
snapshotItems = [];
snapshotSynced = true;
shellStore.centerComposer();
Expand Down
63 changes: 61 additions & 2 deletions cometline/src/lib/conversation/conversation-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import {
import { chatStore } from '$lib/stores/chat.svelte';
import { sessionStore } from '$lib/stores/session.svelte';
import { shellStore } from '$lib/stores/shell.svelte';
import { getSession } from '$lib/client/cometmind';
import { getSession, getSessionMessages } from '$lib/client/cometmind';

type FlightPayload = Parameters<ConversationFlightAdapter['onUserMessageFlight']>[0];
type FlightContext = Parameters<ConversationFlightAdapter['onUserMessageFlight']>[1];

vi.mock('$lib/client/cometmind', () => ({
getSession: vi.fn().mockResolvedValue({ id: 'sess-1', title: 'Updated' })
getSession: vi.fn().mockResolvedValue({ id: 'sess-1', title: 'Updated' }),
getSessionMessages: vi.fn().mockResolvedValue({ session_id: 'sess-1', items: [] }),
listChildSessions: vi.fn().mockResolvedValue({ sessions: [] })
}));

describe('createConversationController', () => {
Expand All @@ -25,6 +27,7 @@ describe('createConversationController', () => {
sessionStore.setSessions([]);
resetConversationTurnQueuesForTests();
shellStore.centerComposer();
vi.mocked(getSessionMessages).mockResolvedValue({ session_id: 'sess-1', items: [] });
vi.mocked(getSession).mockResolvedValue({
id: 'sess-1',
workspace_id: 'ws-1',
Expand Down Expand Up @@ -639,6 +642,62 @@ describe('createConversationController', () => {
centerSpy.mockRestore();
});

it('syncComposerPhase keeps empty session centered even when hasVisibleConversation is true', () => {
chatStore.bindSession('sess-1');
const { controller } = createDeps({ hasVisibleConversation: true });
controller.bindSession();
shellStore.centerComposer();

controller.syncComposerPhase({
hasVisibleConversation: true,
firstTurnActive: false,
awaitingFirstAssistant: false
});

expect(shellStore.composerPhase).toBe('centered');
expect(chatStore.getCachedItemCount('sess-1')).toBe(0);
});

it('treats fork status-only transcript as firstTurn and keeps composer centered', async () => {
vi.mocked(getSessionMessages).mockResolvedValue({
session_id: 'sess-1',
items: [
{
type: 'system',
text: 'Forked from a session in /old. File tools now operate under /new.'
}
]
});
chatStore.bindSession('sess-1');
await chatStore.loadTranscript('sess-1');
expect(chatStore.getCachedItemCount('sess-1')).toBe(1);
expect(chatStore.hasCachedConversationTurns('sess-1')).toBe(false);

const onUserMessageFlight = vi.fn().mockImplementation((_, ctx: FlightContext) => {
ctx.stageUser('hello', undefined);
});
const { controller, send } = createDeps({
hasVisibleConversation: true,
flight: { onUserMessageFlight }
});
controller.bindSession();
expect(shellStore.composerPhase).toBe('centered');

controller.syncComposerPhase({
hasVisibleConversation: true,
firstTurnActive: false,
awaitingFirstAssistant: false
});
expect(shellStore.composerPhase).toBe('centered');

await controller.enqueue('hello');
expect(onUserMessageFlight).toHaveBeenCalledWith(
'hello',
expect.objectContaining({ firstTurn: true, sessionId: 'sess-1' })
);
expect(send).toHaveBeenCalledWith('sess-1', { text: 'hello' }, { skipUser: true });
});

it('runs FirstTurnFlight when hasVisibleConversation is true only due to loading and cache is empty', async () => {
chatStore.bindSession('sess-1');
const onUserMessageFlight = vi.fn().mockImplementation((_, ctx: FlightContext) => {
Expand Down
18 changes: 14 additions & 4 deletions cometline/src/lib/conversation/conversation-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ async function runTurn(
const userDisplay = payload.displayText ?? payload.text;
const usesFlight = Boolean(deps.flight?.onUserMessageFlight);
const isViewing = deps.getSessionId() === turnSessionId;
// Content emptiness — not hasVisibleConversation(). Loading makes the latter
// true on an empty fork/soft swap and would skip FirstTurnFlight.
const firstTurn = chatStore.getCachedItemCount(turnSessionId) === 0;
// No user/assistant yet — not hasVisibleConversation() / raw item count.
// Fork AppendSystemMessage becomes a status row and must not skip FirstTurnFlight.
const firstTurn = !chatStore.hasCachedConversationTurns(turnSessionId);
const flightPayload = payload.images?.length ? payload : userDisplay;
const contexts = messageContextRefsFromWebContexts(payload.webContexts);
let stagedUserId: string | undefined;
Expand Down Expand Up @@ -211,7 +211,7 @@ export function createConversationController(
// content/in-flight dock. Do not dock solely because isLoading — that
// leaves the composer stuck docked after /change → empty fork.
if (
chatStore.getCachedItemCount(sessionId) > 0 ||
chatStore.hasCachedConversationTurns(sessionId) ||
chatStore.hasInFlightTurn(sessionId)
) {
shellStore.dockComposer();
Expand Down Expand Up @@ -267,6 +267,16 @@ export function createConversationController(
if (chatStore.sessionID !== deps.getSessionId()) return;
if (firstTurnActive) return;

const sessionId = deps.getSessionId();
const empty = !chatStore.hasCachedConversationTurns(sessionId);
// Soft /change into an empty fork (status-only system note counts as empty):
// mid-switch visibility flags must not dock after we just centered —
// emptiness wins until real user/assistant content or first-turn prepare.
if (empty && !awaitingFirstAssistant) {
shellStore.centerComposer();
return;
}

if (hasVisibleConversation) {
shellStore.dockComposer();
} else if (!awaitingFirstAssistant) {
Expand Down
9 changes: 9 additions & 0 deletions cometline/src/lib/stores/chat.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ function createChatStore() {
return cachedItemCount(targetSessionID);
}

/** User/assistant turns only — fork system notes map to `status` and must not
* count as a real conversation for first-turn / composer dock. */
function hasCachedConversationTurns(targetSessionID: string) {
return getCachedItems(targetSessionID).some(
(item) => item.type === 'user' || item.type === 'assistant'
);
}

function getCachedItems(targetSessionID: string) {
return sessionCache.get(targetSessionID) ?? [];
}
Expand Down Expand Up @@ -927,6 +935,7 @@ function createChatStore() {
hasInFlightTurn,
isAwaitingFirstAssistant,
getCachedItemCount,
hasCachedConversationTurns,
clear,
resetTranscript,
detachActiveSession,
Expand Down
Loading