diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx
index cd202f93..fdbb7f76 100644
--- a/packages/ui/src/components/chat/ChatContainer.tsx
+++ b/packages/ui/src/components/chat/ChatContainer.tsx
@@ -39,6 +39,7 @@ import {
useSessionStatus,
} from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
+import { getSessionMaterializationStatus } from '@/sync/materialization';
import { usePlanDetection } from '@/hooks/usePlanDetection';
import { getAllSyncSessions } from '@/sync/sync-refs';
import { useI18n } from '@/lib/i18n';
@@ -329,8 +330,8 @@ export const ChatContainer: React.FC = () => {
// Sync actions
const sync = useSync();
- const loadMessages = React.useCallback(
- (sessionId: string) => sync.syncSession(sessionId),
+ const ensureSessionRenderable = React.useCallback(
+ (sessionId: string) => sync.ensureSessionRenderable(sessionId),
[sync],
);
const loadMoreMessages = React.useCallback(
@@ -363,9 +364,9 @@ export const ChatContainer: React.FC = () => {
),
);
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '');
- const hasLoadedSessionMessages = useDirectorySync(
+ const hasRenderableSessionSnapshot = useDirectorySync(
React.useCallback(
- (state) => (currentSessionId ? state.message[currentSessionId] !== undefined : false),
+ (state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
[currentSessionId],
),
);
@@ -425,10 +426,6 @@ export const ChatContainer: React.FC = () => {
return false;
}
- if (streamingMessageId || activeStreamingPhase) {
- return true;
- }
-
const statusType = sessionStatusForCurrent.type ?? 'idle';
if (statusType === 'busy' || statusType === 'retry') {
return true;
@@ -440,7 +437,7 @@ export const ChatContainer: React.FC = () => {
&& lastMessage.role === 'assistant'
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number',
);
- }, [activeStreamingPhase, currentSessionId, sessionMessages, sessionPermissions.length, sessionQuestions.length, sessionStatusForCurrent.type, streamingMessageId]);
+ }, [currentSessionId, sessionMessages, sessionPermissions.length, sessionQuestions.length, sessionStatusForCurrent.type]);
const activeRetryStatus = React.useMemo(() => {
if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') {
return null;
@@ -751,7 +748,7 @@ export const ChatContainer: React.FC = () => {
const isSessionHydrating =
Boolean(currentSessionId)
- && !hasLoadedSessionMessages;
+ && !hasRenderableSessionSnapshot;
React.useEffect(() => {
if (!currentSessionId) {
@@ -783,10 +780,10 @@ export const ChatContainer: React.FC = () => {
React.useEffect(() => {
if (!currentSessionId) return;
- if (hasLoadedSessionMessages) return;
+ if (hasRenderableSessionSnapshot) return;
const load = async () => {
- await loadMessages(currentSessionId).finally(() => {
+ await ensureSessionRenderable(currentSessionId).finally(() => {
const statusType = sessionStatusForCurrent.type ?? 'idle';
const isActivePhase = statusType === 'busy' || statusType === 'retry';
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
@@ -805,7 +802,7 @@ export const ChatContainer: React.FC = () => {
};
void load();
- }, [currentSessionId, hasLoadedSessionMessages, isPinned, loadMessages, resumeToLatestInstant, sessionStatusForCurrent.type]);
+ }, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, isPinned, resumeToLatestInstant, sessionStatusForCurrent.type]);
if (!currentSessionId && !draftOpen) {
return (
@@ -841,7 +838,7 @@ export const ChatContainer: React.FC = () => {
return null;
}
- if (isSessionHydrating && sessionMessages.length === 0 && !streamingMessageId) {
+ if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
return (
{returnToParentButton}
@@ -897,7 +894,7 @@ export const ChatContainer: React.FC = () => {
);
}
- if (sessionMessages.length === 0 && !streamingMessageId) {
+ if (sessionMessages.length === 0 && !sessionIsWorking) {
return (
{returnToParentButton}
diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx
index 8073d138..c6357440 100644
--- a/packages/ui/src/components/chat/ModelControls.tsx
+++ b/packages/ui/src/components/chat/ModelControls.tsx
@@ -63,6 +63,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useDirectorySync, useSessionMessages } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
+import { getSessionMaterializationStatus } from '@/sync/materialization';
import { useUIStore } from '@/stores/useUIStore';
import { useModelLists } from '@/hooks/useModelLists';
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
@@ -768,9 +769,9 @@ export const ModelControls: React.FC = ({
const latestLoadedUserChoiceRestoreRef = React.useRef(null);
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
- const hasCurrentSessionMessagesEntry = useDirectorySync(
+ const hasRenderableCurrentSessionSnapshot = useDirectorySync(
React.useCallback(
- (state) => (currentSessionId ? state.message[currentSessionId] !== undefined : false),
+ (state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
[currentSessionId],
),
currentSessionDirectory ?? undefined,
@@ -942,7 +943,7 @@ export const ModelControls: React.FC = ({
return;
}
- if (!contextHydrated || providers.length === 0 || !hasCurrentSessionMessagesEntry || !latestLoadedUserChoice?.providerID || !latestLoadedUserChoice.modelID) {
+ if (!contextHydrated || providers.length === 0 || !hasRenderableCurrentSessionSnapshot || !latestLoadedUserChoice?.providerID || !latestLoadedUserChoice.modelID) {
return;
}
@@ -990,7 +991,7 @@ export const ModelControls: React.FC = ({
currentAgentName,
contextHydrated,
providers,
- hasCurrentSessionMessagesEntry,
+ hasRenderableCurrentSessionSnapshot,
latestLoadedUserChoice,
setAgent,
tryApplyModelSelection,
@@ -1113,9 +1114,9 @@ export const ModelControls: React.FC = ({
return;
}
- if (!hasCurrentSessionMessagesEntry) {
+ if (!hasRenderableCurrentSessionSnapshot) {
if (!sync.isLoading(currentSessionId)) {
- void sync.syncSession(currentSessionId);
+ void sync.ensureSessionRenderable(currentSessionId);
}
return;
}
@@ -1127,7 +1128,7 @@ export const ModelControls: React.FC = ({
applyFallbackAgent();
}, [
currentSessionId,
- hasCurrentSessionMessagesEntry,
+ hasRenderableCurrentSessionSnapshot,
latestLoadedUserChoice,
agents,
primaryAgents,
diff --git a/packages/ui/src/components/chat/lib/turns/windowTurns.test.ts b/packages/ui/src/components/chat/lib/turns/windowTurns.test.ts
new file mode 100644
index 00000000..bb819449
--- /dev/null
+++ b/packages/ui/src/components/chat/lib/turns/windowTurns.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, test } from 'bun:test';
+import type { Message, Part } from '@opencode-ai/sdk/v2';
+import { buildTurnWindowModel, updateTurnWindowModelIncremental } from './windowTurns';
+import type { ChatMessageEntry } from './types';
+
+function message({ id, role, parentID }: { id: string; role: 'user' | 'assistant' | 'system'; parentID?: string }): ChatMessageEntry {
+ return {
+ info: {
+ id,
+ role,
+ ...(parentID ? { parentID } : {}),
+ time: { created: 1 },
+ } as Message,
+ parts: [] as Part[],
+ };
+}
+
+describe('windowTurns', () => {
+ test('does not map assistant messages without a parent to the current turn', () => {
+ const user = message({ id: 'u1', role: 'user' });
+ const assistant = message({ id: 'a1', role: 'assistant' });
+
+ const model = buildTurnWindowModel([user, assistant]);
+
+ expect(model.messageToTurnId.get('u1')).toBe('u1');
+ expect(model.messageToTurnId.has('a1')).toBe(false);
+ });
+
+ test('incremental update does not map assistant messages without a parent to the current turn', () => {
+ const user = message({ id: 'u1', role: 'user' });
+ const assistant = message({ id: 'a1', role: 'assistant' });
+ const base = buildTurnWindowModel([user]);
+
+ const next = updateTurnWindowModelIncremental(base, [user], [user, assistant]);
+
+ expect(next?.messageToTurnId.get('u1')).toBe('u1');
+ expect(next?.messageToTurnId.has('a1')).toBe(false);
+ });
+
+ test('maps assistant messages to their parent user turn', () => {
+ const user = message({ id: 'u1', role: 'user' });
+ const assistant = message({ id: 'a1', role: 'assistant', parentID: 'u1' });
+
+ const model = buildTurnWindowModel([user, assistant]);
+
+ expect(model.messageToTurnId.get('a1')).toBe('u1');
+ });
+});
diff --git a/packages/ui/src/components/chat/lib/turns/windowTurns.ts b/packages/ui/src/components/chat/lib/turns/windowTurns.ts
index c6c24912..b141b4ec 100644
--- a/packages/ui/src/components/chat/lib/turns/windowTurns.ts
+++ b/packages/ui/src/components/chat/lib/turns/windowTurns.ts
@@ -119,9 +119,10 @@ export const updateTurnWindowModelIncremental = (
}
const parentId = resolveParentMessageId(nextMessage);
- const targetTurnIndex = parentId
- ? nextModel.turnIndexById.get(parentId)
- : nextModel.turnIds.length - 1;
+ if (!parentId) {
+ return nextModel;
+ }
+ const targetTurnIndex = nextModel.turnIndexById.get(parentId);
if (typeof targetTurnIndex !== 'number' || targetTurnIndex < 0) {
return null;
}
@@ -173,8 +174,13 @@ export const buildTurnWindowModel = (messages: ChatMessageEntry[]): TurnWindowMo
}
const parentId = resolveParentMessageId(message);
- const parentTurnIndex = parentId ? userMessageToTurnIndex.get(parentId) : undefined;
- const targetTurnIndex = typeof parentTurnIndex === 'number' ? parentTurnIndex : currentTurnIndex;
+ if (!parentId) {
+ return;
+ }
+ const targetTurnIndex = userMessageToTurnIndex.get(parentId);
+ if (typeof targetTurnIndex !== 'number') {
+ return;
+ }
if (targetTurnIndex < 0) {
return;
}
diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx
index 20312015..a5c22701 100644
--- a/packages/ui/src/components/session/SessionSidebar.tsx
+++ b/packages/ui/src/components/session/SessionSidebar.tsx
@@ -1087,7 +1087,7 @@ export const SessionSidebar: React.FC = ({
currentSessionId,
sortedSessions,
recentSessionIds: recentSessionIdsList,
- loadMessages: sync.syncSession,
+ ensureSessionRenderable: sync.ensureSessionRenderable,
});
const sectionsForSidebarRender = React.useMemo(() => {
diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx
index e9695cd8..bca7c47f 100644
--- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx
+++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx
@@ -347,7 +347,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
let skipped = 0;
for (const child of children) {
try {
- await sync.syncSession(child.session.id);
+ await sync.ensureSessionRenderable(child.session.id);
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
const childAgent = (child.session as Session & { agent?: string }).agent;
@@ -379,7 +379,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
return;
}
- await sync.syncSession(session.id);
+ await sync.ensureSessionRenderable(session.id);
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
if (records.length === 0) {
diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts
index 211ebd07..26734f36 100644
--- a/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts
+++ b/packages/ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts
@@ -1,7 +1,7 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useSessionUIStore } from '@/sync/session-ui-store';
-import { getSyncMessages } from '@/sync/sync-refs';
+import { getSyncSessionMaterializationStatus } from '@/sync/sync-refs';
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
const SESSION_PREFETCH_SETTLE_MS = 600;
@@ -12,10 +12,10 @@ type Args = {
currentSessionId: string | null;
sortedSessions: Session[];
recentSessionIds?: string[];
- loadMessages: (sessionId: string) => Promise;
+ ensureSessionRenderable: (sessionId: string) => Promise;
};
-export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], loadMessages }: Args): void => {
+export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], ensureSessionRenderable }: Args): void => {
const sessionPrefetchTimersRef = React.useRef