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:
committed by
GitHub
parent
485efc7117
commit
85400459e9
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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)' }}
|
||||
|
||||
Reference in New Issue
Block a user