perf: overhaul session loading, caching, and runtime isolation (#2360)

Improve OpenChamber responsiveness under large session workloads while fixing
cache, synchronization, and persistence correctness across runtimes, projects,
directories, and worktrees.

- prioritize selected and visible sessions during bootstrap and defer
  non-critical enrichment work
- reduce redundant message loading, event processing, store publication, and
  hidden sidebar work
- prevent stale session and message requests from overwriting newer
  authoritative state
- preserve existing data when authoritative fetches fail instead of treating
  failures as successful empty responses
- scope session materialization, messages, drafts, queues, todos, pins,
  permissions, folders, tabs, Git state, and pull request data by runtime and
  directory identity
- harden runtime switching, reconnect, cleanup, mutation reconciliation, and
  persisted-state ordering
- preserve live subagent Task linkage when metadata arrives after an older
  message request or while streaming parts are suspended
- coalesce overlapping tail refreshes without losing newer refresh demand
- improve cold-session loading by moving deferrable work out of the critical
  bootstrap path
- isolate URL authentication, mobile credentials, native secrets, and other
  runtime-owned state across endpoint changes
- bound long-lived caches and remove avoidable allocations from event and
  rendering hot paths
- limit virtualization to archive collections where it improves rendering
  without disrupting active sidebar layout
- stabilize session folders, pin ordering, expanded state, and persisted
  sidebar behavior
- open skill files through the same secure editor and outside-workspace grant
  flow used by file navigation, including worktree sessions
- expand regression coverage for stale completions, runtime collisions,
  reconnect behavior, persistence races, authoritative empty results, and
  subagent refresh ordering
- document the updated synchronization, cache ownership, performance, and
  runtime-isolation invariants
This commit is contained in:
Bohdan Triapitsyn
2026-07-21 20:52:20 +03:00
committed by GitHub
parent 485efc7117
commit 85400459e9
197 changed files with 10835 additions and 3400 deletions
@@ -36,16 +36,15 @@ import { useStreamingStore } from '@/sync/streaming';
import {
useSessionMessageCount,
useSessionMessageRecords,
useSessionMessageLoadState,
useSyncDirectory,
useDirectorySync,
useSessionRenderable,
useSessionStatus,
useScopedBlockingPermissions,
useScopedBlockingQuestions,
useParentSession,
} from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache';
import { getSessionMaterializationStatus } from '@/sync/materialization';
import { usePlanDetection } from '@/hooks/usePlanDetection';
import { useI18n } from '@/lib/i18n';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
@@ -215,6 +214,11 @@ const ChatViewport = React.memo(({
// Shell-mode prompts show their extracted command; cache by message id so
// the parts array reference is stable while the command is unchanged.
const shellPreviewCache = React.useRef(new Map<string, { command: string; parts: Part[] }>());
const shellPreviewSessionRef = React.useRef(currentSessionId);
if (shellPreviewSessionRef.current !== currentSessionId) {
shellPreviewSessionRef.current = currentSessionId;
shellPreviewCache.current.clear();
}
const promptPreviewsByTurnId = React.useMemo(() => {
const next = new Map<string, Part[]>();
for (let index = 0; index < renderedMessages.length; index += 1) {
@@ -339,6 +343,7 @@ const ChatViewport = React.memo(({
</div>
)}
<MessageList
key={currentSessionId}
ref={messageListRef}
sessionKey={currentSessionId}
disableStaging={pendingRevealWork}
@@ -487,12 +492,42 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea
);
};
const DraftWelcome: React.FC = () => {
const { t } = useI18n();
const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null);
const projectLabel = useProjectsStore(React.useCallback((state) => {
const projectId = selectedProjectId ?? state.activeProjectId;
const project = (projectId
? state.projects.find((candidate) => candidate.id === projectId)
: null) ?? state.projects[0] ?? null;
return project ? getProjectDisplayLabel(project) : null;
}, [selectedProjectId]));
return (
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
{renderDraftTitle(
projectLabel
? t('chat.emptyState.draftTitleWithProject', { project: projectLabel })
: t('chat.emptyState.draftTitle'),
projectLabel,
)}
</h1>
<DraftPresetChips
onSubmit={(text) => useInputStore.getState().requestPresetSubmit(text)}
className="oc-draft-starters mt-8 max-w-md"
/>
</div>
);
};
type ChatContainerProps = {
active?: boolean;
autoOpenDraft?: boolean;
readOnly?: boolean;
};
export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = true, readOnly = false }) => {
export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, autoOpenDraft = true, readOnly = false }) => {
const { t } = useI18n();
// Session UI state
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
@@ -500,16 +535,14 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const projects = useProjectsStore((s) => s.projects);
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
// Sync actions
const sync = useSync();
const syncDirectory = useSyncDirectory();
const effectiveSessionDirectory = currentSessionDirectory ?? syncDirectory;
const ensureSessionRenderable = React.useCallback(
(sessionId: string) => sync.ensureSessionRenderable(sessionId),
[sync],
(sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory),
[effectiveSessionDirectory, sync],
);
const loadMoreMessages = React.useCallback(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -542,31 +575,17 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
),
);
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '', effectiveSessionDirectory);
const hasRenderableSessionSnapshot = useDirectorySync(
React.useCallback(
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
[currentSessionId],
),
effectiveSessionDirectory,
);
const hasRenderableSessionSnapshot = useSessionRenderable(currentSessionId ?? '', effectiveSessionDirectory);
// Messages from sync system
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, {
enabled: active,
suspendPartUpdates: Boolean(streamingMessageId),
suspendPartUpdatesForMessageId: streamingMessageId,
});
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
const sessionPrefetchInfo = React.useSyncExternalStore(
React.useCallback(
(notify) => currentSessionId
? subscribeSessionPrefetch(effectiveSessionDirectory, currentSessionId, notify)
: () => undefined,
[currentSessionId, effectiveSessionDirectory],
),
React.useCallback(
() => currentSessionId ? getSessionPrefetch(effectiveSessionDirectory, currentSessionId) : undefined,
[currentSessionId, effectiveSessionDirectory],
),
React.useCallback(() => undefined, []),
const sessionMessageLoadState = useSessionMessageLoadState(
currentSessionId ?? '',
effectiveSessionDirectory,
);
// Plan detection - watches messages for plan creation and signals store
@@ -643,20 +662,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
// History metadata — use sync's hasMore/isLoading
const historyMeta = React.useMemo(() => {
if (!currentSessionId) return null;
// Sync's meta is authoritative once a fetch has confirmed the history
// is fully loaded — a stale prefetch-cache entry (cursor recorded at
// the initial page) must not keep the "load older" affordance alive
// after the user has already reached the top.
const syncComplete = sync.isComplete(currentSessionId);
const prefetchHasMore = !syncComplete
&& Boolean(sessionPrefetchInfo?.cursor)
&& sessionPrefetchInfo?.complete !== true;
return {
limit: sessionMessages.length,
complete: syncComplete || !(sync.hasMore(currentSessionId) || prefetchHasMore),
loading: sync.isLoading(currentSessionId),
complete: sessionMessageLoadState.complete || !sessionMessageLoadState.cursor,
loading: sessionMessageLoadState.status === 'loading',
};
}, [currentSessionId, sessionMessages.length, sessionPrefetchInfo, sync]);
}, [currentSessionId, sessionMessageLoadState.complete, sessionMessageLoadState.cursor, sessionMessageLoadState.status, sessionMessages.length]);
const { isMobile } = useDeviceInfo();
const isVSCode = isVSCodeRuntime();
@@ -668,17 +679,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
const isDesktopExpandedInput = isExpandedInput;
const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat';
const messageListRef = React.useRef<MessageListHandle | null>(null);
const draftProjectLabel = React.useMemo(() => {
const selectedProject = newSessionDraft?.selectedProjectId
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
: null;
const activeProject = activeProjectId
? projects.find((project) => project.id === activeProjectId) ?? null
: null;
const project = selectedProject ?? activeProject ?? projects[0] ?? null;
return project ? getProjectDisplayLabel(project) : null;
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
// In the embedded session-chat iframe, hide "Return to parent" when
@@ -942,9 +942,13 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
const isSessionHydrating =
Boolean(currentSessionId)
&& !hasRenderableSessionSnapshot;
const retrySessionLoad = React.useCallback(() => {
if (!active || !currentSessionId) return;
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
}, [active, currentSessionId, effectiveSessionDirectory, sync]);
React.useEffect(() => {
if (!currentSessionId) return;
if (!active || !currentSessionId) return;
if (lastScrolledSessionRef.current === currentSessionId) return;
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
@@ -963,14 +967,13 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
} else {
window.requestAnimationFrame(run);
}
}, [currentSessionId, releaseAutoFollow, restoreSnapshot]);
}, [active, currentSessionId, releaseAutoFollow, restoreSnapshot]);
React.useEffect(() => {
if (!currentSessionId) return;
if (!active || !currentSessionId) return;
if (hasRenderableSessionSnapshot) return;
if (effectiveSessionDirectory !== syncDirectory) return;
void ensureSessionRenderable(currentSessionId);
}, [currentSessionId, effectiveSessionDirectory, ensureSessionRenderable, hasRenderableSessionSnapshot, syncDirectory]);
}, [active, currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot]);
if (!currentSessionId && !draftOpen) {
// With auto-open, the draft welcome opens on the next tick (effect below),
@@ -993,22 +996,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
// the fullscreen composer's position:fixed visual-viewport pinning in
// mobile browsers (see ChatInput's composerFormRef effect).
<div className="relative flex h-full flex-col bg-background">
{useCompactDraftLayout && !isDesktopExpandedInput ? (
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
{renderDraftTitle(
draftProjectLabel
? t('chat.emptyState.draftTitleWithProject', { project: draftProjectLabel })
: t('chat.emptyState.draftTitle'),
draftProjectLabel,
)}
</h1>
<DraftPresetChips
onSubmit={(text) => useInputStore.getState().requestPresetSubmit(text)}
className="oc-draft-starters mt-8 max-w-md"
/>
</div>
) : null}
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
<div
className={cn(
'relative z-10 flex min-h-0',
@@ -1030,6 +1018,28 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
}
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
if (sessionMessageLoadState.status === 'error') {
return (
<div className="relative flex h-full flex-col bg-background">
{returnToParentButton}
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
<div className="max-w-sm text-center">
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
<Icon name="error-warning" className="size-4" />
</div>
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
</div>
</div>
<div className="relative z-10 bg-background">
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
}
return (
<div className="relative flex flex-col h-full bg-background">
{returnToParentButton}
@@ -1124,7 +1134,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
<div className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<ChatViewport
key={currentSessionId}
currentSessionId={currentSessionId}
isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile}
+147 -144
View File
@@ -6,7 +6,7 @@ import { ComposerDictation } from '@/components/dictation/ComposerDictation';
// sessionStore removed — currentSessionId comes from useSessionUIStore
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
@@ -14,12 +14,21 @@ import { useInputStore } from '@/sync/input-store';
import type { AttachedFile } from '@/stores/types/sessionTypes';
import * as sessionActions from '@/sync/session-actions';
import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context';
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
import { useSnippetsStore } from '@/stores/useSnippetsStore';
import { appendInlineComments } from '@/lib/messages/inlineComments';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { startReviewFlow } from '@/lib/reviewFlow';
import { getRuntimeKey } from '@/lib/runtime-switch';
import {
createChatDraftIdentity,
getChatDraftIdentityKey,
readChatDraft,
subscribeChatDraftDeletion,
writeChatDraft,
type ChatDraftIdentity,
type ChatDraftSnapshot,
} from '@/lib/chatDraftPersistence';
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import ToolOutputDialog from './message/ToolOutputDialog';
@@ -111,6 +120,9 @@ const FILE_MENTION_TOKEN = /^@[^\s]+$/;
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
const INLINE_SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g;
const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500;
const getChatDraftSnapshotSignature = (text: string, confirmedMentions: Iterable<string>): string => (
`${text}\u0000${[...confirmedMentions].sort().join('\u0000')}`
);
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
const VS_CODE_DROP_DATA_TYPES = [
'CodeFiles',
@@ -918,81 +930,35 @@ type AutocompleteOverlayPosition = {
maxHeight: number;
};
// Per-session draft key — preserves in-progress messages across project switches
const getDraftKey = (sessionId: string | null): string =>
`openchamber_chat_input_draft_${sessionId ?? 'new'}`;
// Helper to safely read from localStorage for a given session
const getStoredDraft = (sessionId: string | null): string => {
try {
return localStorage.getItem(getDraftKey(sessionId)) ?? '';
} catch {
return '';
}
};
// Helper to safely write/clear a per-session draft
const saveStoredDraft = (sessionId: string | null, draft: string): void => {
try {
if (draft) {
localStorage.setItem(getDraftKey(sessionId), draft);
} else {
localStorage.removeItem(getDraftKey(sessionId));
}
} catch {
// Ignore localStorage errors
}
};
// Per-session confirmed mentions key — tracks which @mentions are confirmed (blue) vs plain text
const getConfirmedMentionsKey = (sessionId: string | null): string =>
`openchamber_chat_confirmed_mentions_${sessionId ?? 'new'}`;
const saveConfirmedMentions = (sessionId: string | null, mentions: Set<string>): void => {
try {
if (mentions.size > 0) {
localStorage.setItem(getConfirmedMentionsKey(sessionId), JSON.stringify([...mentions]));
} else {
localStorage.removeItem(getConfirmedMentionsKey(sessionId));
}
} catch {
// Ignore localStorage errors
}
};
const loadConfirmedMentions = (sessionId: string | null): Set<string> => {
try {
const raw = localStorage.getItem(getConfirmedMentionsKey(sessionId));
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return new Set(parsed.filter((v): v is string => typeof v === 'string'));
}
}
} catch {
// Ignore localStorage errors
}
return new Set();
const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
const sessionState = useSessionUIStore.getState();
const newSessionDirectory = sessionState.newSessionDraft?.open
? sessionState.newSessionDraft.bootstrapPendingDirectory ?? sessionState.newSessionDraft.directoryOverride
: null;
const directory = sessionId
? sessionState.getDirectoryForSession(sessionId) ?? sessionState.currentSessionDirectory
: newSessionDirectory ?? useDirectoryStore.getState().currentDirectory;
return createChatDraftIdentity(getRuntimeKey(), directory, sessionId);
};
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
const { t } = useI18n();
// Track if we restored a draft on mount (for text selection)
const initialDraftRef = React.useRef<string | null>(null);
// Track initial session ID (captured at mount time for draft restoration)
const initialSessionIdRef = React.useRef<string | null>(null);
const initialDraftIdentityRef = React.useRef<ChatDraftIdentity | null>(null);
const initialDraftSnapshotRef = React.useRef<ChatDraftSnapshot>({ text: '', confirmedMentions: new Set() });
const [message, setMessage] = React.useState(() => {
// Read per-session draft at mount time using the current session from the store
const sessionId = useSessionUIStore.getState().currentSessionId;
initialSessionIdRef.current = sessionId;
const draft = getStoredDraft(sessionId);
if (draft) {
initialDraftRef.current = draft;
const identity = resolveChatDraftIdentity(sessionId);
const snapshot = readChatDraft(identity);
initialDraftIdentityRef.current = identity;
initialDraftSnapshotRef.current = snapshot;
if (snapshot.text) {
initialDraftRef.current = snapshot.text;
}
return draft;
return snapshot.text;
});
// Restore confirmed mentions from localStorage on mount
const confirmedMentionsRef = React.useRef<Set<string>>(loadConfirmedMentions(initialSessionIdRef.current));
const confirmedMentionsRef = React.useRef<Set<string>>(initialDraftSnapshotRef.current.confirmedMentions);
// Helper: check if a mention path looks like a file/folder (has path separators, extension, or was explicitly confirmed)
const isConfirmedFilePath = (text: string): boolean =>
text.includes('/') || text.includes('\\') || text.includes('.') || confirmedMentionsRef.current.has(text);
@@ -1070,7 +1036,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const draftPersistTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const skipNextDraftPersistRef = React.useRef(false);
const lastPersistedDraftRef = React.useRef<Map<string, string>>(new Map());
const currentSessionIdForDraftRef = React.useRef<string | null>(null);
const currentChatDraftIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraftIdentityRef.current);
const pendingPastedAttachmentFilenamesRef = React.useRef<Set<string>>(new Set());
// TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.)
@@ -1084,6 +1050,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const currentSessionDirectoryForSync = useSessionUIStore(
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]),
);
const activeRuntimeKey = getRuntimeKey();
const chatDraftIdentity = React.useMemo(
() => createChatDraftIdentity(
activeRuntimeKey,
currentSessionDirectoryForSync ?? currentDirectory,
currentSessionId,
),
[activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId],
);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const newSessionDraftOpen = Boolean(newSessionDraft?.open);
const draftPermissionAutoAcceptEnabled = useSessionUIStore((s) => (
@@ -1487,14 +1462,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} | null>(null);
// Message queue
const messageQueueTarget = currentSessionId
? createMessageQueueTarget(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory)
: null;
const messageQueueKey = messageQueueTarget ? getMessageQueueKey(messageQueueTarget) : null;
const followUpBehavior = useMessageQueueStore((state) => state.followUpBehavior);
const queuedMessages = useMessageQueueStore(
React.useCallback(
(state) => {
if (!currentSessionId) return EMPTY_QUEUE;
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
if (!messageQueueKey) return EMPTY_QUEUE;
return state.queuedMessages[messageQueueKey] ?? EMPTY_QUEUE;
},
[currentSessionId]
[messageQueueKey]
)
);
const addToQueue = useMessageQueueStore((state) => state.addToQueue);
@@ -1502,21 +1481,27 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
// Inline comment drafts
const inlineDraftSessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
const inlineDraftDirectory = currentSessionDirectoryForSync ?? currentDirectory;
const inlineDraftTarget = React.useMemo<InlineCommentDraftTarget | null>(
() => inlineDraftSessionKey && inlineDraftDirectory
? { directory: inlineDraftDirectory, sessionKey: inlineDraftSessionKey }
: null,
[inlineDraftDirectory, inlineDraftSessionKey],
);
const inlineDraftKey = inlineDraftTarget
? getInlineCommentDraftKey(activeRuntimeKey, inlineDraftTarget.directory, inlineDraftTarget.sessionKey)
: null;
const draftCount = useInlineCommentDraftStore(
React.useCallback(
(state) => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
if (!sessionKey) return 0;
return (state.drafts[sessionKey] ?? []).length;
},
[currentSessionId, newSessionDraftOpen]
(state) => inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []).length : 0,
[inlineDraftKey]
)
);
const draftSourceKey = useInlineCommentDraftStore(
React.useCallback(
(state) => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
const drafts = sessionKey ? (state.drafts[sessionKey] ?? []) : [];
const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : [];
let previewConsole = 0;
let previewAnnotation = 0;
let review = 0;
@@ -1529,7 +1514,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
return `${previewConsole}:${previewAnnotation}:${review}:${terminal}`;
},
[currentSessionId, newSessionDraftOpen]
[inlineDraftKey]
)
);
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
@@ -1537,29 +1522,27 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const hasDrafts = draftCount > 0;
const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0);
const terminalContextDrafts = terminalContextCount > 0
? (useInlineCommentDraftStore.getState().drafts[currentSessionId ?? (newSessionDraftOpen ? 'draft' : '')] ?? []).filter((draft) => draft.source === 'terminal')
? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal')
: [];
const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
if (!sessionKey) return;
const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? [];
if (!inlineDraftTarget) return;
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
for (const draft of drafts) {
if (draft.source === source) {
removeInlineCommentDraft(sessionKey, draft.id);
removeInlineCommentDraft(inlineDraftTarget, draft.id);
}
}
}, [currentSessionId, newSessionDraftOpen, removeInlineCommentDraft]);
}, [inlineDraftTarget, removeInlineCommentDraft]);
// Review comments are the inline-comment drafts that aren't preview sources.
const removeReviewDrafts = React.useCallback(() => {
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
if (!sessionKey) return;
const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? [];
if (!inlineDraftTarget) return;
const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget);
for (const draft of drafts) {
if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal') {
removeInlineCommentDraft(sessionKey, draft.id);
removeInlineCommentDraft(inlineDraftTarget, draft.id);
}
}
}, [currentSessionId, newSessionDraftOpen, removeInlineCommentDraft]);
}, [inlineDraftTarget, removeInlineCommentDraft]);
// User message history for up/down arrow navigation.
// Keep this on a narrow hook instead of full session message records.
@@ -1571,17 +1554,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}, [message]);
React.useEffect(() => {
currentSessionIdForDraftRef.current = currentSessionId;
}, [currentSessionId]);
currentChatDraftIdentityRef.current = chatDraftIdentity;
}, [chatDraftIdentity]);
const persistDraftImmediately = React.useCallback((sessionId: string | null, draft: string) => {
const key = getDraftKey(sessionId);
const lastPersisted = lastPersistedDraftRef.current.get(key);
if (lastPersisted === draft) {
return;
}
saveStoredDraft(sessionId, draft);
const persistDraftImmediately = React.useCallback((identity: ChatDraftIdentity | null, draft: string) => {
if (!identity) return;
const key = getChatDraftIdentityKey(identity);
// Only persist confirmed mentions that are actually present in the draft text
const activeMentions = new Set<string>();
for (const mention of confirmedMentionsRef.current) {
@@ -1590,8 +1568,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
}
confirmedMentionsRef.current = activeMentions;
saveConfirmedMentions(sessionId, activeMentions);
lastPersistedDraftRef.current.set(key, draft);
const signature = getChatDraftSnapshotSignature(draft, activeMentions);
const lastPersisted = lastPersistedDraftRef.current.get(key);
if (lastPersisted === signature) {
return;
}
writeChatDraft(identity, draft, activeMentions);
lastPersistedDraftRef.current.set(key, signature);
}, []);
const clearPendingDraftPersist = React.useCallback(() => {
@@ -1614,11 +1597,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!persistChatDraft) {
// Setting disabled - clear the restored draft
setMessage('');
try {
localStorage.removeItem(getDraftKey(initialSessionIdRef.current));
} catch {
// Ignore
}
writeChatDraft(initialDraftIdentityRef.current, '', []);
} else {
// Setting enabled - select all text
requestAnimationFrame(() => {
@@ -1627,24 +1606,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
}, [persistChatDraft]);
// Handle session switching: save draft for old session, restore draft for new session
const prevSessionIdRef = React.useRef(currentSessionId);
// Handle identity switching: save the old draft and restore the new runtime/directory/session draft.
const prevChatDraftIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraftIdentityRef.current);
React.useEffect(() => {
if (prevSessionIdRef.current !== currentSessionId) {
const oldSessionId = prevSessionIdRef.current;
prevSessionIdRef.current = currentSessionId;
const previousIdentity = prevChatDraftIdentityRef.current;
const previousKey = previousIdentity ? getChatDraftIdentityKey(previousIdentity) : null;
const currentKey = chatDraftIdentity ? getChatDraftIdentityKey(chatDraftIdentity) : null;
if (previousKey !== currentKey) {
prevChatDraftIdentityRef.current = chatDraftIdentity;
setInputMode('normal');
clearPendingDraftPersist();
skipNextDraftPersistRef.current = true;
if (persistChatDraft) {
// Save current draft for the session we're leaving
persistDraftImmediately(oldSessionId, messageRef.current);
// Restore draft for the session we're entering
const newDraft = getStoredDraft(currentSessionId);
setMessage(newDraft);
confirmedMentionsRef.current = loadConfirmedMentions(currentSessionId);
if (newDraft) {
persistDraftImmediately(previousIdentity, messageRef.current);
const nextSnapshot = readChatDraft(chatDraftIdentity);
setMessage(nextSnapshot.text);
confirmedMentionsRef.current = nextSnapshot.confirmedMentions;
if (nextSnapshot.text) {
requestAnimationFrame(() => {
textareaRef.current?.select();
});
@@ -1655,7 +1634,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
confirmedMentionsRef.current = new Set();
}
}
}, [clearPendingDraftPersist, currentSessionId, persistChatDraft, persistDraftImmediately]);
}, [chatDraftIdentity, clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]);
React.useEffect(() => subscribeChatDraftDeletion((deletedIdentity) => {
const deletedKey = getChatDraftIdentityKey(deletedIdentity);
lastPersistedDraftRef.current.set(deletedKey, getChatDraftSnapshotSignature('', []));
const currentIdentity = currentChatDraftIdentityRef.current;
if (!currentIdentity || getChatDraftIdentityKey(currentIdentity) !== deletedKey) return;
clearPendingDraftPersist();
skipNextDraftPersistRef.current = true;
messageRef.current = '';
confirmedMentionsRef.current = new Set();
setMessage('');
}), [clearPendingDraftPersist]);
// Focus textarea when new session draft is opened
const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen);
@@ -1678,7 +1669,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
React.useEffect(() => {
if (!persistChatDraft) {
clearPendingDraftPersist();
persistDraftImmediately(currentSessionId, '');
persistDraftImmediately(chatDraftIdentity, '');
return;
}
@@ -1689,24 +1680,37 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
clearPendingDraftPersist();
const draftSnapshot = message;
const sessionSnapshot = currentSessionId;
const identitySnapshot = chatDraftIdentity;
draftPersistTimerRef.current = setTimeout(() => {
draftPersistTimerRef.current = null;
persistDraftImmediately(sessionSnapshot, draftSnapshot);
persistDraftImmediately(identitySnapshot, draftSnapshot);
}, CHAT_DRAFT_PERSIST_DEBOUNCE_MS);
return () => {
clearPendingDraftPersist();
};
}, [clearPendingDraftPersist, currentSessionId, message, persistChatDraft, persistDraftImmediately]);
}, [chatDraftIdentity, clearPendingDraftPersist, message, persistChatDraft, persistDraftImmediately]);
React.useEffect(() => {
return () => {
const flushCurrentDraft = () => {
clearPendingDraftPersist();
if (persistChatDraft) {
persistDraftImmediately(currentSessionIdForDraftRef.current, messageRef.current);
persistDraftImmediately(currentChatDraftIdentityRef.current, messageRef.current);
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') flushCurrentDraft();
};
document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('freeze', flushCurrentDraft);
window.addEventListener('pagehide', flushCurrentDraft);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('freeze', flushCurrentDraft);
window.removeEventListener('pagehide', flushCurrentDraft);
flushCurrentDraft();
};
}, [clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]);
// Session activity for queue availability and controls
@@ -1782,9 +1786,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Add message to queue instead of sending
const handleQueueMessage = React.useCallback(() => {
const inputSnapshot = getCurrentInputSnapshot();
if (!inputSnapshot.hasContent || !currentSessionId) return;
if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return;
const drafts = consumeDrafts(currentSessionId);
const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : [];
let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, '');
if (drafts.length > 0) {
@@ -1792,7 +1796,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
const attachmentsToQueue = sanitizeAttachmentsForSend(sendableAttachedFiles);
addToQueue(currentSessionId, {
addToQueue(messageQueueTarget, {
content: messageToQueue,
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
sendConfig: currentProviderId && currentModelId ? {
@@ -1815,7 +1819,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!isMobile) {
textareaRef.current?.focus();
}
}, [getCurrentInputSnapshot, currentSessionId, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
const handleQueuedMessageEdit = React.useCallback((content: string) => {
setMessage(content);
@@ -1974,10 +1978,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
}
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
const consumedDraftTarget = inlineDraftTarget;
let drafts: InlineCommentDraft[] = [];
if (!queuedOnly && sessionKey) {
drafts = consumeDrafts(sessionKey);
if (!queuedOnly && consumedDraftTarget) {
drafts = consumeDrafts(consumedDraftTarget);
}
if (drafts.length > 0) {
@@ -2032,17 +2036,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
if (!primaryText && primaryAttachments.length === 0 && additionalParts.length === 0) return;
// Clear queue and input
if (currentSessionId && queuedMessageId) {
removeFromQueue(currentSessionId, queuedMessageId);
} else if (currentSessionId && hasQueuedMessages) {
clearQueue(currentSessionId);
if (messageQueueTarget && queuedMessageId) {
removeFromQueue(messageQueueTarget, queuedMessageId);
} else if (messageQueueTarget && hasQueuedMessages) {
clearQueue(messageQueueTarget);
}
if (!queuedOnly) {
setMessage('');
confirmedMentionsRef.current.clear();
// Clear per-session draft on submit
saveStoredDraft(currentSessionId, '');
saveConfirmedMentions(currentSessionId, confirmedMentionsRef.current);
persistDraftImmediately(chatDraftIdentity, '');
// Reset message history navigation state
setHistoryIndex(-1);
setDraftMessage('');
@@ -2333,8 +2336,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
sendMessageOptions,
);
const restoreConsumedDrafts = () => {
if (sessionKey && drafts.length > 0) {
useInlineCommentDraftStore.getState().restoreDrafts(sessionKey, drafts);
if (consumedDraftTarget && drafts.length > 0) {
useInlineCommentDraftStore.getState().restoreDrafts(consumedDraftTarget, drafts);
}
};
@@ -2369,7 +2372,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const currentInput = textareaRef.current?.value ?? messageRef.current;
if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) {
setMessage(inputSnapshot.message);
saveStoredDraft(null, inputSnapshot.message);
writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current);
}
const isSoftNetworkError =
@@ -4667,7 +4670,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
<span className="truncate text-xs font-medium text-[var(--surface-mutedForeground)]">
{t('chat.chatInput.terminalContext', { terminal: draft.fileLabel, start: draft.startLine, end: draft.endLine })}
</span>
<button type="button" className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]" onClick={() => removeInlineCommentDraft(draft.sessionKey, draft.id)} aria-label={t('chat.chatInput.terminalContextRemove')} title={t('chat.chatInput.terminalContextRemove')}>
<button type="button" className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]" onClick={() => inlineDraftTarget && removeInlineCommentDraft(inlineDraftTarget, draft.id)} aria-label={t('chat.chatInput.terminalContextRemove')} title={t('chat.chatInput.terminalContextRemove')}>
<Icon name="close" className="h-3 w-3" />
</button>
</div>
@@ -163,12 +163,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const { currentTheme } = useThemeSystem();
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession);
const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection);
const revertToMessage = useSessionUIStore((s) => s.revertToMessage);
const forkFromMessage = useSessionUIStore((s) => s.forkFromMessage);
streamPerfCount('ui.chat_message.render');
if (isInActiveTurn) {
@@ -186,12 +182,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}))
);
React.useEffect(() => {
if (currentSessionId) {
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
}
}, [currentSessionId]);
const [copiedCode, setCopiedCode] = React.useState<string | null>(null);
const [copiedMessage, setCopiedMessage] = React.useState(false);
const [expandedTools, setExpandedTools] = React.useState<Set<string>>(() => readExpandedToolsCache(message.info.id));
@@ -580,8 +570,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const shouldAnimateMessage = React.useMemo(() => {
if (isUser) return false;
const freshnessDetector = MessageFreshnessDetector.getInstance();
return freshnessDetector.shouldAnimateMessage(message.info, currentSessionId || message.info.sessionID);
}, [message.info, currentSessionId, isUser]);
return freshnessDetector.shouldAnimateMessage(message.info, message.info.sessionID);
}, [message.info, isUser]);
const [hasStartedStreamingHeader, setHasStartedStreamingHeader] = React.useState(false);
@@ -794,14 +784,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const handleRevert = React.useCallback(() => {
if (!sessionId || !message.info.id) return;
revertToMessage(sessionId, message.info.id);
}, [sessionId, message.info.id, revertToMessage]);
useSessionUIStore.getState().revertToMessage(sessionId, message.info.id);
}, [sessionId, message.info.id]);
// NEW: Fork handler
const handleFork = React.useCallback(() => {
if (!sessionId || !message.info.id) return;
forkFromMessage(sessionId, message.info.id);
}, [sessionId, message.info.id, forkFromMessage]);
useSessionUIStore.getState().forkFromMessage(sessionId, message.info.id);
}, [sessionId, message.info.id]);
const handleToggleTool = React.useCallback((toolId: string) => {
const isDefaultOpen = defaultOpenToolIds.has(toolId);
@@ -40,6 +40,7 @@ import {
parseFileReference,
type ParsedFileReference,
} from './fileReferenceParser';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem();
@@ -754,69 +755,6 @@ const useMermaidInlineInteractions = ({
// Rendering core: marked -> math -> shiki -> sanitize -> decorate -> morphdom
// ---------------------------------------------------------------------------
// Single tuning knob: the streaming reveal cadence. Lower = smoother but more
// CPU (more re-parse steps/sec); higher = cheaper but chunkier. Step sizes are
// auto-scaled from this so reveal throughput (chars/sec) stays constant no
// matter the cadence — text always keeps up with the incoming stream.
const TEXT_PACE_MS = 64;
const PACE_BASELINE_MS = 24;
const PACE_RATIO = TEXT_PACE_MS / PACE_BASELINE_MS;
const TEXT_SNAP = /[\s.,!?;:)\]]/;
const paceStep = (remaining: number): number => {
const base = remaining <= 12 ? 2 : remaining <= 48 ? 4 : remaining <= 96 ? 8 : Math.min(24, Math.ceil(remaining / 8));
return Math.max(1, Math.round(base * PACE_RATIO));
};
const nextRevealIndex = (text: string, start: number): number => {
const end = Math.min(text.length, start + paceStep(text.length - start));
for (let i = end; i < Math.min(text.length, end + 8); i += 1) {
if (TEXT_SNAP.test(text[i] ?? '')) return i + 1;
}
return end;
};
// Granular streaming reveal. Cheap because each step only re-runs the
// marked->morphdom pipeline (patching changed DOM nodes), with no React tree
// reconciliation of the markdown body.
const usePacedText = (content: string, streaming: boolean): string => {
const [shown, setShown] = React.useState<number>(() => (streaming ? 0 : content.length));
const shownRef = React.useRef(shown);
shownRef.current = shown;
React.useEffect(() => {
if (!streaming || typeof window === 'undefined') {
setShown(content.length);
return;
}
if (shownRef.current > content.length) {
setShown(content.length);
}
let timer: number | null = null;
const tick = () => {
const current = Math.min(shownRef.current, content.length);
if (current >= content.length) {
timer = null;
return;
}
setShown(nextRevealIndex(content, current));
timer = window.setTimeout(tick, TEXT_PACE_MS);
};
if (shownRef.current < content.length) {
timer = window.setTimeout(tick, TEXT_PACE_MS);
}
return () => {
if (timer !== null) window.clearTimeout(timer);
};
}, [content, streaming]);
if (!streaming) return content;
return content.slice(0, Math.min(shown, content.length));
};
// Mermaid layout is expensive; `decorate` would otherwise re-render every
// diagram on every paced-stream step (~40/sec). Memoize by theme+mode+source
// so a stable diagram is laid out once and served from cache thereafter.
@@ -1094,6 +1032,9 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
onShowPopup,
enableFileReferences = true,
}) => {
streamPerfCount('ui.markdown_renderer.render');
if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming');
streamPerfObserve('ui.markdown_renderer.content_len', content.length);
const currentTheme = useCurrentMermaidTheme();
const { editor, runtime } = useRuntimeAPIs();
const containerRef = React.useRef<HTMLDivElement>(null);
@@ -1106,7 +1047,6 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
}, [effectiveDirectory, openContextPreview]);
const live = isStreaming && !disableStreamAnimation;
const pacedText = usePacedText(content, live);
useMermaidInlineInteractions({
containerRef,
@@ -1127,7 +1067,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
useMorphdomMarkdown({ containerRef, text: pacedText, streaming: live, cacheKey, syntaxVars, ctx });
useMorphdomMarkdown({ containerRef, text: content, streaming: live, cacheKey, syntaxVars, ctx });
const markdownContent = (
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
@@ -16,7 +16,7 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { isHiddenUserMessage } from './message/hiddenUserMessage';
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
import { streamPerfCount, streamPerfMark, streamPerfMeasure } from '@/stores/utils/streamDebug';
import type { StreamPhase } from './message/types';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useSessionParts } from '@/sync/sync-context';
@@ -885,6 +885,7 @@ const MessageListEntry = React.memo(({
activeStreamingPhase,
reviewTransferDirection,
}: MessageListEntryProps) => {
streamPerfCount('ui.message_list_entry.render');
if (entry.kind === 'ungrouped') {
return (
<UngroupedMessageRow
@@ -1264,6 +1265,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
scrollRef,
directory,
}, ref) => {
streamPerfMark('react.message_list_render');
streamPerfCount('ui.message_list.render');
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
@@ -0,0 +1,18 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
const source = readFileSync(new URL('./MobileSessionStatusBar.tsx', import.meta.url), 'utf8');
describe('MobileSessionStatusBar hidden work', () => {
test('does not mount session grouping and project derivation while the panel is closed', () => {
const wrapperStart = source.indexOf('export const MobileSessionStatusBar');
const openPanelStart = source.indexOf('const MobileSessionStatusOpenPanel');
const closedGuard = source.indexOf('if (!isMobile || !open) return null;', wrapperStart);
const openPanelMount = source.indexOf('<MobileSessionStatusOpenPanel', wrapperStart);
expect(openPanelStart).toBeGreaterThan(-1);
expect(closedGuard).toBeGreaterThan(wrapperStart);
expect(openPanelMount).toBeGreaterThan(closedGuard);
expect(source.indexOf('useSessionGrouping(', openPanelStart)).toBeLessThan(wrapperStart);
});
});
@@ -21,9 +21,7 @@ interface MobileSessionStatusBarProps {
interface SessionWithStatus extends Session {
_statusType?: 'busy' | 'retry' | 'idle';
_hasRunningChildren?: boolean;
_runningChildrenCount?: number;
_childIndicators?: Array<{ session: Session; isRunning: boolean }>;
}
// Cross-project session source. Mirrors the dedicated MobileSessionsSheet:
@@ -84,12 +82,14 @@ function useSessionGrouping(
const map = new Map<string, Session[]>();
const allIds = new Set(sessions.map((s) => s.id));
sessions.forEach((session) => {
for (const session of sessions) {
const parentID = (session as { parentID?: string }).parentID;
if (parentID && allIds.has(parentID)) {
map.set(parentID, [...(map.get(parentID) || []), session]);
const children = map.get(parentID);
if (children) children.push(session);
else map.set(parentID, [session]);
}
});
}
return map;
}, [sessions]);
@@ -99,24 +99,6 @@ function useSessionGrouping(
return 'idle';
}, [sessionStatus]);
const hasRunningChildren = React.useCallback((sessionId: string): boolean => {
const children = parentChildMap.get(sessionId) || [];
return children.some((child) => getStatusType(child.id) !== 'idle');
}, [parentChildMap, getStatusType]);
const getRunningChildrenCount = React.useCallback((sessionId: string): number => {
const children = parentChildMap.get(sessionId) || [];
return children.filter((child) => getStatusType(child.id) !== 'idle').length;
}, [parentChildMap, getStatusType]);
const getChildIndicators = React.useCallback((sessionId: string): Array<{ session: Session; isRunning: boolean }> => {
const children = parentChildMap.get(sessionId) || [];
return children
.filter((child) => getStatusType(child.id) !== 'idle')
.map((child) => ({ session: child, isRunning: true }))
.slice(0, 3);
}, [parentChildMap, getStatusType]);
const processedSessions = React.useMemo(() => {
const sessionIds = new Set(sessions.map((s) => s.id));
const topLevel = sessions.filter((session) => {
@@ -129,20 +111,18 @@ function useSessionGrouping(
topLevel.forEach((session) => {
const statusType = getStatusType(session.id);
const hasRunning = hasRunningChildren(session.id);
const runningChildrenCount = (parentChildMap.get(session.id) ?? [])
.filter((child) => getStatusType(child.id) !== 'idle')
.length;
const attention = (unseenCounts[session.id] ?? 0) > 0;
const enriched: SessionWithStatus = {
...session,
_statusType: statusType,
_hasRunningChildren: hasRunning,
_runningChildrenCount: getRunningChildrenCount(session.id),
_childIndicators: getChildIndicators(session.id),
_runningChildrenCount: runningChildrenCount,
};
if (statusType !== 'idle' || hasRunning) {
running.push(enriched);
} else if (attention) {
if (statusType !== 'idle' || runningChildrenCount > 0 || attention) {
running.push(enriched);
} else {
viewed.push(enriched);
@@ -159,7 +139,7 @@ function useSessionGrouping(
viewed.sort(sortByUpdated);
return [...running, ...viewed];
}, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, unseenCounts]);
}, [sessions, getStatusType, parentChildMap, unseenCounts]);
const totalRunning = processedSessions.reduce((sum, s) => {
const selfRunning = s._statusType !== 'idle' ? 1 : 0;
@@ -188,7 +168,6 @@ function useSessionHelpers() {
// Per-project status indicators (running / unread) for the filter chips.
function useProjectStatus(
sessions: Session[],
sessionStatus: Record<string, { type: string }> | undefined,
currentSessionId: string | null
) {
@@ -450,12 +429,11 @@ export const MobileSessionPanelTrigger: React.FC<MobileSessionPanelTriggerProps>
);
};
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
const MobileSessionStatusOpenPanel: React.FC<MobileSessionStatusBarProps> = ({
onSessionSwitch,
}) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const isMobile = useUIStore((state) => state.isMobile);
const sessions = useAllProjectSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionStatus = useAllSessionStatuses();
@@ -469,7 +447,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
const { sessions: sortedSessions, totalRunning, totalUnread } = useSessionGrouping(sessions, sessionStatus);
const { getSessionTitle, needsAttention } = useSessionHelpers();
const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId);
const getProjectStatus = useProjectStatus(sessionStatus, currentSessionId);
const resolveProjectRoots = useProjectRootsResolver();
// Project filter, persisted in the UI store so the choice survives closing and
@@ -605,10 +583,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
</div>
), [t, totalRunning, totalUnread, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]);
if (!isMobile) {
return null;
}
return (
<MobileOverlayPanel
open={open}
@@ -639,3 +613,11 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
</MobileOverlayPanel>
);
};
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = (props) => {
const isMobile = useUIStore((state) => state.isMobile);
const open = useUIStore((state) => state.mobileSessionPanelOpen);
if (!isMobile || !open) return null;
return <MobileSessionStatusOpenPanel {...props} />;
};
@@ -29,9 +29,8 @@ import { useContextStore } from '@/stores/contextStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useDirectorySync, useSessionMessages } from '@/sync/sync-context';
import { useSessionMessages, useSessionRenderable } 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';
@@ -646,11 +645,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
const hasRenderableCurrentSessionSnapshot = useDirectorySync(
React.useCallback(
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
[currentSessionId],
),
const hasRenderableCurrentSessionSnapshot = useSessionRenderable(
currentSessionId ?? '',
currentSessionDirectory ?? undefined,
);
const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined);
@@ -14,7 +14,7 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type MessageQueueTarget, type QueuedMessage } from '@/stores/messageQueueStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { useI18n } from '@/lib/i18n';
@@ -24,12 +24,12 @@ import { cn } from '@/lib/utils';
interface QueuedMessageChipProps {
message: QueuedMessage;
sessionId: string;
target: MessageQueueTarget;
onEdit: (message: QueuedMessage) => void;
onSend: (message: QueuedMessage) => void;
}
const QueuedMessageChip = memo(({ message, sessionId, onEdit, onSend }: QueuedMessageChipProps) => {
const QueuedMessageChip = memo(({ message, target, onEdit, onSend }: QueuedMessageChipProps) => {
const { t } = useI18n();
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: message.id });
@@ -89,7 +89,7 @@ const QueuedMessageChip = memo(({ message, sessionId, onEdit, onSend }: QueuedMe
</Button>
<button
type="button"
onClick={() => removeFromQueue(sessionId, message.id)}
onClick={() => removeFromQueue(target, message.id)}
className="flex items-center justify-center h-6 w-6 flex-shrink-0 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
aria-label={t('chat.queuedMessage.removeAria')}
>
@@ -111,13 +111,16 @@ const EMPTY_QUEUE: QueuedMessage[] = [];
export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: QueuedMessageChipsProps) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const target = currentSessionId ? createMessageQueueTarget(currentSessionId, currentSessionDirectory) : null;
const queueKey = target ? getMessageQueueKey(target) : null;
const queuedMessages = useMessageQueueStore(
React.useCallback(
(state) => {
if (!currentSessionId) return EMPTY_QUEUE;
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
if (!queueKey) return EMPTY_QUEUE;
return state.queuedMessages[queueKey] ?? EMPTY_QUEUE;
},
[currentSessionId]
[queueKey]
)
);
const popToInput = useMessageQueueStore((state) => state.popToInput);
@@ -132,14 +135,14 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !currentSessionId) return;
reorderQueue(currentSessionId, String(active.id), String(over.id));
}, [currentSessionId, reorderQueue]);
if (!over || active.id === over.id || !target) return;
reorderQueue(target, String(active.id), String(over.id));
}, [target, reorderQueue]);
const handleEdit = React.useCallback((message: QueuedMessage) => {
if (!currentSessionId) return;
if (!target) return;
const popped = popToInput(currentSessionId, message.id);
const popped = popToInput(target, message.id);
if (popped) {
if (popped.attachments && popped.attachments.length > 0) {
const currentAttachments = useInputStore.getState().attachedFiles;
@@ -147,13 +150,13 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
}
onEditMessage(popped.content, popped.attachments);
}
}, [currentSessionId, popToInput, onEditMessage]);
}, [target, popToInput, onEditMessage]);
const handleSend = React.useCallback((message: QueuedMessage) => {
onSendMessage(message.id);
}, [onSendMessage]);
if (queuedMessages.length === 0 || !currentSessionId) {
if (queuedMessages.length === 0 || !target) {
return null;
}
@@ -180,7 +183,7 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued
<QueuedMessageChip
key={message.id}
message={message}
sessionId={currentSessionId}
target={target}
onEdit={handleEdit}
onSend={handleSend}
/>
+10 -2
View File
@@ -159,6 +159,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const liveTodos = useDirectorySync(
React.useCallback(
(state) => {
@@ -170,8 +176,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
);
const persistedSessionTodos = useTodosPersistStore(
React.useCallback(
(state) => (showTodos && currentSessionId ? state.sessions[currentSessionId]?.todos : undefined),
[currentSessionId, showTodos],
(state) => (showTodos && currentSessionId && currentSessionDirectory
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
: undefined),
[currentSessionDirectory, currentSessionId, showTodos],
),
);
const todos: TodoItem[] = React.useMemo(() => {
@@ -53,13 +53,14 @@ export const useTurnRecords = (
const projection = React.useMemo(() => {
const sessionKey = options.sessionKey ?? '';
const mergeKey = options.planModeEnabled ? 'merge:plan' : 'merge';
const cached = getCachedProjection(
const cacheKey = buildProjectionCacheKey(
sessionKey,
messages,
options.showTextJustificationActivity,
options.showTurnChangedFiles,
mergeKey,
);
const cached = getCachedProjection(cacheKey);
if (cached) {
previousProjectionRef.current = cached;
return cached;
@@ -74,13 +75,6 @@ export const useTurnRecords = (
});
previousProjectionRef.current = nextProjection;
const cacheKey = buildProjectionCacheKey(
sessionKey,
messages,
options.showTextJustificationActivity,
options.showTurnChangedFiles,
mergeKey,
);
setCachedProjection(cacheKey, nextProjection);
return nextProjection;
@@ -56,14 +56,7 @@ export const buildProjectionCacheKey = (
].join('|');
};
export const getCachedProjection = (
sessionKey: string,
messages: ChatMessageEntry[],
showTextJustificationActivity: boolean,
showTurnChangedFiles: boolean,
mergeHiddenUserTurnsKey: string,
): TurnProjectionResult | undefined => {
const key = buildProjectionCacheKey(sessionKey, messages, showTextJustificationActivity, showTurnChangedFiles, mergeHiddenUserTurnsKey);
export const getCachedProjection = (key: string): TurnProjectionResult | undefined => {
const cached = projectionCache.get(key);
if (cached) {
// LRU re-order: move hit to the end (most recent) so it survives
@@ -623,7 +623,7 @@ const StaticToolRowInner: React.FC<{
return entries;
}, [activities, currentDirectory, isReadGroup]);
const handleReadFileClick = React.useCallback((filePath: string, offset?: number) => {
const handleFileClick = React.useCallback((filePath: string, offset?: number) => {
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
if (!absolutePath) {
return;
@@ -656,14 +656,6 @@ const StaticToolRowInner: React.FC<{
uiStore.openContextFile(contextDirectory, absolutePath);
}, [currentDirectory, runtime]);
const handleSkillClick = React.useCallback((skillPath: string) => {
if (!skillPath) {
return;
}
const uiStore = useUIStore.getState();
uiStore.openContextFile(currentDirectory || getDirectoryForFilePath('', skillPath), skillPath);
}, [currentDirectory]);
const normalizedToolName = toolName.toLowerCase();
const isSearchGroup = normalizedToolName === 'grep'
|| normalizedToolName === 'search'
@@ -699,7 +691,7 @@ const StaticToolRowInner: React.FC<{
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleReadFileClick(entry.path, entry.offset);
handleFileClick(entry.path, entry.offset);
}}
className={cn('inline-flex !min-h-0 items-center justify-start gap-1 min-w-0 flex-1 text-left hover:opacity-90', TOOL_ROW_DESCRIPTION_CLASS)}
style={{ color: 'var(--tools-description)' }}
@@ -751,7 +743,7 @@ const StaticToolRowInner: React.FC<{
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleSkillClick(entry.path);
handleFileClick(entry.path);
}}
className={cn('!min-h-0 min-w-0 flex-1 truncate whitespace-nowrap text-left hover:opacity-90', TOOL_ROW_DESCRIPTION_CLASS)}
style={{ color: 'var(--tools-description)' }}