Skip to content
Open
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
7 changes: 7 additions & 0 deletions apps/cli/src/lib/loro/doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2579,6 +2579,13 @@ export class SessionDocument implements LoroDocument<SessionDocMeta, SessionMeta
this.logger.debug(`[${this.sessionId}] setStatus: upsertDocMeta complete`);
}

/**
* Full materialization of the transcript: every history entry, every item,
* through the full-schema Mirror. Reserved for consumers that genuinely
* need the whole list — import hashing and the dispatch/fork scans. Do not
* add renderer-style readers on top of it; the client reads history
* through the windowed `ConversationView` for exactly that reason.
*/
async getHistory(): Promise<SessionHistoryInput[]> {
if (!this.mirror) {
return [];
Expand Down
9 changes: 9 additions & 0 deletions packages/components/src/atoms/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import type {
} from '@lody/shared';
import type { LocalProjectGitStateRpcResponse } from '@lody/loro-streams-rpc';
import type { WorkspaceWriter } from '../providers/workspace-writer';
import type { ConversationView } from '../lib/conversation-view';
import type { CodeCollabFileIndexCache } from '@/lib/code-collab-file-index-cache';
import { readStoredAuthToken } from '@/lib/auth-bootstrap';
import type { RoomSyncState } from '@/lib/room-sync-state';
Expand All @@ -92,6 +93,14 @@ export type SessionDocStore = {
acquireSync: () => () => void;
getSyncState: () => RoomSyncState;
subscribeSyncState: (listener: (state: RoomSyncState) => void) => () => void;
/**
* Windowed history reader, present when the store was built with the
* control-plane Mirror (`isConversationViewEnabled()`); `null` on the
* full-Mirror rollback path. When present, `getState().history` is a lazy
* bridge over `conversationView.readAll()` and history writes must go
* through the history writer (`WorkspaceWriter` does), never `setState`.
*/
readonly conversationView: ConversationView | null;
getState: () => SessionDocState;
setState: (updater: SessionDocUpdater) => void;
subscribe: (listener: (state: SessionDocState) => void) => () => void;
Expand Down
28 changes: 26 additions & 2 deletions packages/components/src/components/ai-gui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
} from './view';
import { buildChatStreamItems, type BuildChatStreamItemsCache } from './build-chat-stream-items';
import { useStableCallback } from '@/hooks/use-stable-callback';
import { useConversationViewSelector } from '@/hooks/use-conversation-view-selector';
import type { ConversationView } from '@/lib/conversation-view';
import { useCloudQuery } from '@lody/platform/react';
import type { SessionNavigationTarget } from '@/lib/session-navigation';
import type {
Expand All @@ -38,6 +40,14 @@ import type {
} from '@/components/sessions/session-fork-destination-menu';

const emptyHistory = [] as SessionDoc['history'];

const findLastUserTurnId = (view: ConversationView): string | null => {
for (let index = view.turnCount - 1; index >= 0; index -= 1) {
const row = view.index(index);
if (row?.role === 'user') return row.id ?? null;
}
return null;
};
const CHAT_STREAM_ITEMS_CACHE_LIMIT = 20;
const chatStreamItemsCacheBySessionId = new Map<SessionId, BuildChatStreamItemsCache>();

Expand Down Expand Up @@ -75,6 +85,12 @@ export interface SessionChatStreamProps {
sessionId: SessionId;
workspaceId?: WorkspaceId | null;
sessionDoc: SessionDoc;
/**
* Windowed history reader for `sessionDoc`'s session. Present on the
* ConversationView path; `null` (or omitted) on the full-Mirror rollback
* path, where `sessionDoc.history` is the only source.
*/
conversationView?: ConversationView | null;
sessionCreatedAt?: string;
dividerLabel?: string;
className?: string;
Expand Down Expand Up @@ -159,6 +175,7 @@ const SessionChatStreamImpl = forwardRef<SessionChatStreamHandle, SessionChatStr
sessionId,
workspaceId,
sessionDoc,
conversationView = null,
sessionCreatedAt: _sessionCreatedAt,
dividerLabel: _dividerLabel,
className,
Expand Down Expand Up @@ -225,12 +242,19 @@ const SessionChatStreamImpl = forwardRef<SessionChatStreamHandle, SessionChatStr
const hasFilePathClick = onFilePathClick !== undefined;
const hasNavigateSession = onNavigateSession !== undefined;
const hasForkLastAssistant = onForkLastAssistant !== undefined;
const lastUserMessageId = useMemo(() => {
const fallbackLastUserMessageId = useMemo(() => {
if (conversationView) return null;
for (let index = sessionHistory.length - 1; index >= 0; index -= 1) {
if (sessionHistory[index]?.role === 'user') return sessionHistory[index]?.id ?? null;
}
return null;
}, [sessionHistory]);
}, [conversationView, sessionHistory]);
// Index rows carry `role` and `id`, so this never hydrates a turn.
const lastUserMessageId = useConversationViewSelector(
conversationView,
findLastUserTurnId,
fallbackLastUserMessageId
);

const renderMessageRow = useCallback(
({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { Loader2, Send, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai';
import {
resolveActiveAssistantTurnId,
type SessionMeta,
type VisualAnnotationReferencePayload,
} from '@lody/shared';
Expand Down Expand Up @@ -55,6 +54,7 @@ import {
} from '@/components/chat/visual-annotation-reference-state';
import { usePreviewVisualCommentDoc } from '@/hooks/use-preview-visual-comment-doc';
import { useSessionDoc } from '@/hooks/use-session-doc';
import { useActiveAssistantTurnId } from '@/hooks/use-session-turn-selectors';
import { useStableCallback } from '@/hooks/use-stable-callback';
import { observeResizeOnAnimationFrame } from '@/lib/resize-observer';
import {
Expand Down Expand Up @@ -349,8 +349,8 @@ export function ManagedPreviewSurface({
)
.map((comment) => comment.id);
}, [comments, visualAnnotationReferenceKeys]);
const commentTurnId =
resolveActiveAssistantTurnId(sessionDoc.doc.history) ?? session.latestUserMsgId ?? session.id;
const activeAssistantTurnId = useActiveAssistantTurnId(sessionDoc);
const commentTurnId = activeAssistantTurnId ?? session.latestUserMsgId ?? session.id;

const trackedAnchors = useMemo<TrackedVisualAnnotationAnchor[]>(() => {
const next = comments.map((comment) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ import {
resolveSessionConversationConfig,
resolveSessionConversationSourceFence,
resolveVisibleSessionGoal,
resolveActiveAssistantTurnId,
resolveBaseBranchPreference,
resolveProjectGitHubRepo,
} from '@lody/shared';
Expand Down Expand Up @@ -187,6 +186,7 @@ import { Input } from '@/ui/input';
import { Separator } from '@/ui/separator';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/ui/tooltip';
import { useSessionDoc } from '@/hooks/use-session-doc';
import { useActiveAssistantTurnId } from '@/hooks/use-session-turn-selectors';
import { useSessionActions } from '@/hooks/use-session-actions';
import { useWorkspaceMembers, type WorkspaceMember } from '@/hooks/use-workspace-members';
import { UserAvatar } from '@/components/user-avatar';
Expand Down Expand Up @@ -2052,6 +2052,7 @@ export const SessionChatInterface = memo(
const [pendingRemoteHtmlFileName, setPendingRemoteHtmlFileName] = useState<string | null>(null);
const {
doc: sessionDoc,
conversationView,
addHistory: addSessionHistory,
pushMessageQueue,
removeMessageQueueItem,
Expand Down Expand Up @@ -2753,9 +2754,10 @@ export const SessionChatInterface = memo(
return resolveActivityFromHistory(sessionHistory);
}, [liveSessionStatus, sessionHistory]);

const activeAssistantTurnId = useMemo(() => {
return resolveActiveAssistantTurnId(sessionHistory);
}, [sessionHistory]);
const activeAssistantTurnId = useActiveAssistantTurnId({
doc: sessionDoc,
conversationView,
});
const messageQueue = useMemo(
() => (sessionDoc?.mq ?? []) as MessageQueueItem[],
[sessionDoc?.mq]
Expand Down Expand Up @@ -4246,13 +4248,16 @@ export const SessionChatInterface = memo(

const handleScrollToMessage = useCallback(
(historyId: string) => {
const history = (sessionDoc?.history as SessionHistory[] | undefined) ?? [];
const index = history.findIndex((h) => h.id === historyId);
const index = conversationView
? conversationView.indexOf(historyId)
: ((sessionDoc?.history as SessionHistory[] | undefined) ?? []).findIndex(
(h) => h.id === historyId
);
if (index >= 0) {
chatStreamRef.current?.scrollToIndex(index);
}
},
[sessionDoc?.history]
[conversationView, sessionDoc?.history]
);

const createPrPrompt = t('sessions.prompts.createPr', CREATE_PR_PROMPT);
Expand Down Expand Up @@ -5836,6 +5841,7 @@ export const SessionChatInterface = memo(
sessionId={session?.id}
workspaceId={workspaceId}
sessionDoc={sessionDoc}
conversationView={conversationView}
sessionCreatedAt={session?.createdAt}
dividerLabel={sessionDividerLabel}
className="h-full"
Expand Down
14 changes: 7 additions & 7 deletions packages/components/src/components/sessions/session-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ import {
} from '@/lib/session-file-provider-open-result';
import { canOpenHistoricalSessionDiffs } from '@/lib/session-file-provider';
import { useSessionDoc, useSessionDocSyncState } from '@/hooks/use-session-doc';
import { useSessionSystemNotice } from '@/hooks/use-session-turn-selectors';
import { useDelayedFlag } from '@/hooks/use-delayed-flag';
import { isSyncingRoomSyncState } from '@/lib/room-sync-state';
import {
Expand Down Expand Up @@ -462,7 +463,11 @@ function PendingWorktreeForkObserver({
onCompleted: () => void;
onFailed: (message: string) => void;
}) {
const { doc, ready } = useSessionDoc(targetSessionId, { syncEnabled: true });
const sessionDoc = useSessionDoc(targetSessionId, { syncEnabled: true });
const { doc, ready } = sessionDoc;
// The origin notice is the system turn the fork appends last, so this reads
// system turns only and never hydrates the copied transcript.
const completed = useSessionSystemNotice(sessionDoc, 'session_fork_origin');
const terminalRef = useRef(false);
useEffect(() => {
if (!ready || terminalRef.current) return;
Expand All @@ -472,16 +477,11 @@ function PendingWorktreeForkObserver({
onFailed(operation.data.error?.message ?? 'Unable to create the fork worktree');
return;
}
const completed = doc.history.some((entry) =>
(entry.items ?? []).some(
(item) => item.type === 'system_notice' && item.name === 'session_fork_origin'
)
);
if (!operation.success && completed) {
terminalRef.current = true;
onCompleted();
}
}, [doc.forkOperation, doc.history, onCompleted, onFailed, ready]);
}, [completed, doc.forkOperation, onCompleted, onFailed, ready]);
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { normalizeFileDiff, type FileDiff, type SessionId } from '@lody/shared';
import { useAtomValue } from 'jotai';
import { useEffect, useRef, useState } from 'react';
import { activeWorkspaceRuntimeAtom } from '@/atoms/runtime';
import { readDiffInputsFromView } from '@/lib/conversation-view';
import type {
SessionFileChangedFilesResult,
SessionFileChangeEntry,
Expand Down Expand Up @@ -401,7 +402,13 @@ export function useSessionDiffSummary(
}
releaseSync = store.acquireSync();

const initialHistory = store.getState().history;
// On the view path every turn's `fileDiff` comes from its own small
// container (index rows carry id/role), so the summary never hydrates
// message items; the rollback path reads the Mirror's history array.
const view = store.conversationView;
const readDiffInputs = (): SessionHistoryInput =>
view ? readDiffInputsFromView(view) : store.getState().history;
const initialHistory = readDiffInputs();
historyRef.current = initialHistory;
diffInputsFingerprintRef.current = computeSessionDiffInputsFingerprint(initialHistory);
setDiffInputsVersion((prev) => prev + 1);
Expand All @@ -427,18 +434,19 @@ export function useSessionDiffSummary(
// ignore
});

unsubscribe = store.subscribe((nextState) => {
const nextFingerprint = computeSessionDiffInputsFingerprint(nextState.history);
const handleHistoryChange = () => {
const nextHistory = readDiffInputs();
const nextFingerprint = computeSessionDiffInputsFingerprint(nextHistory);
if (nextFingerprint === diffInputsFingerprintRef.current) {
return;
}
diffInputsFingerprintRef.current = nextFingerprint;
historyRef.current = nextState.history;
historyRef.current = nextHistory;
setDiffInputsVersion((prev) => prev + 1);
if (!shouldUpdateFallbackSummary()) {
return;
}
const nextSummary = buildSessionDiffSummary(nextState.history);
const nextSummary = buildSessionDiffSummary(nextHistory);
setState((prev) => {
if (areSessionDiffSummariesEqual(prev.summary, nextSummary)) {
if (prev.source === 'fallback') {
Expand All @@ -456,7 +464,10 @@ export function useSessionDiffSummary(
unavailableMessage: undefined,
};
});
});
};
unsubscribe = view
? view.subscribe(handleHistoryChange)
: store.subscribe(handleHistoryChange);
} catch (error) {
console.error('Failed to load session diff summary', { sessionId, error });
}
Expand Down
9 changes: 8 additions & 1 deletion packages/components/src/hooks/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,14 @@ this file; edit `AGENTS.md` only.
queue minutes of React work behind a long active turn and retain every obsolete
history tree. Control state (`session`, message queue, fork, preview, external
cursor) stays synchronous; do not delay it behind transcript rendering or
restore a direct `setState` for history-only mirror events.
restore a direct `setState` for history-only mirror events. It also returns
`conversationView` (the store's windowed history reader, `null` on the
full-Mirror rollback path). Prefer it over `doc.history`: on the view path
that property is a lazy bridge whose first read hydrates the whole
transcript. `use-conversation-view-selector.ts` derives values from the
view per version and takes the doc-state fallback from `useViewFallback`,
which only evaluates while there is no view; `use-session-turn-selectors.ts`
hosts the shared ones (active assistant turn, system notices).
- Code Collab file-index hooks borrow owner-session resources from the
workspace-owned Effect `ScopedCache`; do not open, scan, subscribe, or join
the same Flock once per React mount. The resource subscribes before its cold
Expand Down
74 changes: 74 additions & 0 deletions packages/components/src/hooks/use-conversation-view-selector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';
import type { ConversationView } from '@/lib/conversation-view';

const noopSubscribe = () => () => {};

/**
* Derive a value from a `ConversationView` and re-render when the view
* changes. `select` runs at most once per view version (and per `revision`,
* for callers that hydrate turns themselves, which does not bump the view);
* its result is reused until then, which is what `useSyncExternalStore`
* needs from a snapshot. Pass `isEqual` for derived objects so an unchanged
* answer keeps its identity across versions.
*
* With no view (flag off, or the store not loaded yet) the hook returns
* `fallback`, which the caller derives from the doc state instead.
*/
export function useConversationViewSelector<T>(
view: ConversationView | null | undefined,
select: (view: ConversationView) => T,
fallback: T,
options: { isEqual?: (previous: T, next: T) => boolean; revision?: number } = {}
): T {
const { isEqual = Object.is, revision = 0 } = options;
const selectRef = useRef(select);
selectRef.current = select;
const isEqualRef = useRef(isEqual);
isEqualRef.current = isEqual;
const cacheRef = useRef<{
view: ConversationView;
version: number;
revision: number;
value: T;
} | null>(null);

const subscribe = useCallback(
(onStoreChange: () => void) => (view ? view.subscribe(() => onStoreChange()) : noopSubscribe()),
[view]
);
const getSnapshot = useCallback((): T => {
if (!view) return fallback;
const cached = cacheRef.current;
if (
cached &&
cached.view === view &&
cached.version === view.version &&
cached.revision === revision
) {
return cached.value;
}
const next = selectRef.current(view);
const value =
cached && cached.view === view && isEqualRef.current(cached.value, next)
? cached.value
: next;
cacheRef.current = { view, version: view.version, revision, value };
return value;
}, [fallback, revision, view]);

return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

/**
* Evaluate `computeFallback` only while there is no view, so a bridged
* `doc.history` getter is never touched on the view path. `deps` are the
* doc-state inputs the fallback reads.
*/
export function useViewFallback<T>(
view: ConversationView | null | undefined,
computeFallback: () => T,
deps: readonly unknown[]
): T | undefined {
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => (view ? undefined : computeFallback()), [view, ...deps]);
}
Loading
Loading