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)' }}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import React from 'react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentSource } from '@/stores/useInlineCommentDraftStore';
|
||||
import {
|
||||
EMPTY_INLINE_COMMENT_DRAFTS,
|
||||
getInlineCommentDraftKey,
|
||||
useInlineCommentDraftStore,
|
||||
type InlineCommentDraft,
|
||||
type InlineCommentSource,
|
||||
} from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
type LineRangeBase = {
|
||||
start: number;
|
||||
@@ -52,25 +60,42 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
|
||||
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const sessionDirectory = useSessionUIStore(
|
||||
React.useCallback(
|
||||
(state) => currentSessionId ? state.getDirectoryForSession(currentSessionId) : null,
|
||||
[currentSessionId],
|
||||
),
|
||||
);
|
||||
|
||||
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
|
||||
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
|
||||
|
||||
const [selection, setSelection] = React.useState<TRange | null>(null);
|
||||
const [commentText, setCommentText] = React.useState('');
|
||||
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
|
||||
|
||||
const sessionKey = React.useMemo(() => {
|
||||
return currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
}, [currentSessionId, newSessionDraftOpen]);
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
const draftDirectory = sessionDirectory ?? effectiveDirectory;
|
||||
const target = React.useMemo(() => {
|
||||
if (!sessionKey || !draftDirectory) return null;
|
||||
return { directory: draftDirectory, sessionKey };
|
||||
}, [draftDirectory, sessionKey]);
|
||||
const targetKey = target
|
||||
? getInlineCommentDraftKey(getRuntimeKey(), target.directory, target.sessionKey)
|
||||
: null;
|
||||
const sessionDrafts = useInlineCommentDraftStore(
|
||||
React.useCallback(
|
||||
(state) => targetKey ? state.drafts[targetKey] ?? EMPTY_INLINE_COMMENT_DRAFTS : EMPTY_INLINE_COMMENT_DRAFTS,
|
||||
[targetKey],
|
||||
),
|
||||
);
|
||||
|
||||
const drafts = React.useMemo(() => {
|
||||
if (!sessionKey || !fileLabel) return [];
|
||||
const sessionDrafts = allDrafts[sessionKey] ?? [];
|
||||
if (!target || !fileLabel) return [];
|
||||
return sessionDrafts.filter((draft) => draft.source === source && draft.fileLabel === fileLabel);
|
||||
}, [allDrafts, fileLabel, sessionKey, source]);
|
||||
}, [fileLabel, sessionDrafts, source, target]);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setSelection(null);
|
||||
@@ -90,18 +115,19 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
|
||||
}, [fromDraftRange]);
|
||||
|
||||
const deleteDraft = React.useCallback((draft: InlineCommentDraft) => {
|
||||
removeDraft(draft.sessionKey, draft.id);
|
||||
if (!target) return;
|
||||
removeDraft(target, draft.id);
|
||||
if (editingDraftId === draft.id) {
|
||||
reset();
|
||||
}
|
||||
}, [editingDraftId, removeDraft, reset]);
|
||||
}, [editingDraftId, removeDraft, reset, target]);
|
||||
|
||||
const saveComment = React.useCallback((textToSave: string, rangeOverride?: TRange) => {
|
||||
const targetRange = rangeOverride ?? selection;
|
||||
const trimmedText = textToSave.trim();
|
||||
if (!targetRange || !trimmedText || !fileLabel) return;
|
||||
|
||||
if (!sessionKey) {
|
||||
if (!target) {
|
||||
toast.error(t('inlineComment.toast.selectSessionToSave'));
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +137,7 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
|
||||
const code = getCodeForRange(normalizedRange);
|
||||
|
||||
if (editingDraftId) {
|
||||
updateDraft(sessionKey, editingDraftId, {
|
||||
updateDraft(target, editingDraftId, {
|
||||
fileLabel,
|
||||
startLine: normalizedStoreRange.startLine,
|
||||
endLine: normalizedStoreRange.endLine,
|
||||
@@ -121,8 +147,7 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
|
||||
text: trimmedText,
|
||||
});
|
||||
} else {
|
||||
addDraft({
|
||||
sessionKey,
|
||||
addDraft(target, {
|
||||
source,
|
||||
fileLabel,
|
||||
startLine: normalizedStoreRange.startLine,
|
||||
@@ -135,7 +160,7 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
|
||||
}
|
||||
|
||||
reset();
|
||||
}, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, sessionKey, source, t, toStoreRange, updateDraft]);
|
||||
}, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, source, t, target, toStoreRange, updateDraft]);
|
||||
|
||||
return {
|
||||
sessionKey,
|
||||
|
||||
@@ -542,6 +542,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
const [hoverTarget, setHoverTarget] = React.useState<PreviewElementMetadata | null>(null);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
|
||||
|
||||
@@ -688,7 +689,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
|
||||
const attachPreviewAnnotation = React.useCallback((target: PreviewElementMetadata) => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
if (!sessionKey) {
|
||||
if (!sessionKey || !effectiveDirectory) {
|
||||
toast.error(t('contextPanel.preview.inspect.attachNoSession'));
|
||||
return;
|
||||
}
|
||||
@@ -712,8 +713,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
attachedScreenshot = false;
|
||||
}
|
||||
|
||||
addInlineCommentDraft({
|
||||
sessionKey,
|
||||
addInlineCommentDraft({ directory: effectiveDirectory, sessionKey }, {
|
||||
source: 'preview-annotation',
|
||||
fileLabel: pageUrl || 'preview',
|
||||
startLine: 1,
|
||||
@@ -731,7 +731,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
});
|
||||
toast.success(t('contextPanel.preview.inspect.attached'));
|
||||
})();
|
||||
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, effectiveSrc, newSessionDraftOpen, rawUrl, t]);
|
||||
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, effectiveDirectory, effectiveSrc, newSessionDraftOpen, rawUrl, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setBridgeReady(false);
|
||||
@@ -921,7 +921,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
|
||||
const attachConsoleEvents = React.useCallback(() => {
|
||||
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||
if (!sessionKey) {
|
||||
if (!sessionKey || !effectiveDirectory) {
|
||||
toast.error(t('contextPanel.preview.console.attachNoSession'));
|
||||
return;
|
||||
}
|
||||
@@ -937,8 +937,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
return `[${timestamp}] [${event.level}] ${event.message}${details}`;
|
||||
}).join('\n');
|
||||
|
||||
addInlineCommentDraft({
|
||||
sessionKey,
|
||||
addInlineCommentDraft({ directory: effectiveDirectory, sessionKey }, {
|
||||
source: 'preview-console',
|
||||
fileLabel: rawUrl || effectiveSrc || 'preview',
|
||||
startLine: 1,
|
||||
@@ -948,7 +947,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||
text: t('contextPanel.preview.console.attachAnnotation'),
|
||||
});
|
||||
toast.success(t('contextPanel.preview.console.attached'));
|
||||
}, [addInlineCommentDraft, consoleEvents, currentSessionId, effectiveSrc, newSessionDraftOpen, rawUrl, t]);
|
||||
}, [addInlineCommentDraft, consoleEvents, currentSessionId, effectiveDirectory, effectiveSrc, newSessionDraftOpen, rawUrl, t]);
|
||||
|
||||
// Out-of-band upstream probe: iframes don't expose HTTP status to the parent,
|
||||
// so when the proxy returns a 502 (upstream dev server is offline) the iframe
|
||||
@@ -1590,8 +1589,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
||||
await addAttachedFile(file);
|
||||
}
|
||||
|
||||
addInlineCommentDraft({
|
||||
sessionKey,
|
||||
addInlineCommentDraft({ directory, sessionKey }, {
|
||||
source: 'preview-annotation',
|
||||
fileLabel: currentUrl || 'browser',
|
||||
startLine: 1,
|
||||
@@ -1610,7 +1608,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
|
||||
text: '',
|
||||
});
|
||||
toast.success(t('contextPanel.preview.inspect.attached'));
|
||||
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, newSessionDraftOpen, t]);
|
||||
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, directory, newSessionDraftOpen, t]);
|
||||
|
||||
const cancelInspect = React.useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
@@ -1998,8 +1996,7 @@ const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dir
|
||||
await addAttachedFile(file);
|
||||
}
|
||||
|
||||
addInlineCommentDraft({
|
||||
sessionKey,
|
||||
addInlineCommentDraft({ directory, sessionKey }, {
|
||||
source: 'preview-annotation',
|
||||
fileLabel: currentUrl || 'browser',
|
||||
startLine: 1,
|
||||
@@ -2018,7 +2015,7 @@ const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dir
|
||||
toast.success(t('contextPanel.preview.inspect.attached'));
|
||||
})
|
||||
.catch(() => setIsInspecting(false));
|
||||
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, isInspecting, newSessionDraftOpen, t]);
|
||||
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, directory, isInspecting, newSessionDraftOpen, t]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col bg-background">
|
||||
|
||||
@@ -21,12 +21,12 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
|
||||
import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract';
|
||||
import { useAllLiveSessions, useSession, useSessionMessagesResolved } from '@/sync/sync-context';
|
||||
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||
import { useSessionMessagesResolved } from '@/sync/sync-context';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { useGitBranchLabel } from '@/stores/useGitStore';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
@@ -74,7 +74,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import type { Session } from '@opencode-ai/sdk/v2/client';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
|
||||
const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors';
|
||||
@@ -703,12 +703,20 @@ interface HeaderProps {
|
||||
rightDrawerOpen?: boolean;
|
||||
}
|
||||
|
||||
type HeaderSessionSnapshot = {
|
||||
title: string | null;
|
||||
directory: string | null;
|
||||
created: number | null;
|
||||
slug: string | null;
|
||||
};
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
onToggleLeftDrawer,
|
||||
onToggleRightDrawer,
|
||||
leftDrawerOpen,
|
||||
rightDrawerOpen,
|
||||
}) => {
|
||||
streamPerfCount('ui.header.render');
|
||||
const { t } = useI18n();
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
|
||||
@@ -720,7 +728,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const openContextBrowser = useUIStore((state) => state.openContextBrowser);
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
|
||||
const contextPanelByDirectory = useUIStore((state) => state.contextPanelByDirectory);
|
||||
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
@@ -734,15 +741,28 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
|
||||
const currentSyncedSession = useSession(currentSessionId ?? null);
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const liveSessions = useAllLiveSessions();
|
||||
const activeProject = useProjectsStore((state) => {
|
||||
const currentGlobalSession = useGlobalSessionsStore(useShallow(React.useCallback(
|
||||
(state): HeaderSessionSnapshot | null => {
|
||||
if (!currentSessionId) return null;
|
||||
const session = state.activeSessions.find((candidate) => candidate.id === currentSessionId);
|
||||
if (!session) return null;
|
||||
const record = session as typeof session & { directory?: string | null; slug?: string | null };
|
||||
return {
|
||||
title: session.title ?? null,
|
||||
directory: record.directory ?? null,
|
||||
created: session.time?.created ?? null,
|
||||
slug: record.slug ?? null,
|
||||
};
|
||||
},
|
||||
[currentSessionId],
|
||||
)));
|
||||
const activeProject = useProjectsStore(useShallow((state) => {
|
||||
if (!state.activeProjectId) {
|
||||
return null;
|
||||
}
|
||||
return state.projects.find((project) => project.id === state.activeProjectId) ?? null;
|
||||
});
|
||||
const project = state.projects.find((candidate) => candidate.id === state.activeProjectId);
|
||||
return project ? { id: project.id, path: project.path, label: project.label } : null;
|
||||
}));
|
||||
const activeProjectLabel = React.useMemo(() => {
|
||||
if (!activeProject) {
|
||||
return null;
|
||||
@@ -1130,18 +1150,13 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
});
|
||||
}, [fetchAllQuotas, isUsageRefreshSpinning]);
|
||||
|
||||
const currentSessionLive = React.useMemo(() => {
|
||||
if (!currentSessionId) return null;
|
||||
return liveSessions.find((s) => s.id === currentSessionId)
|
||||
?? globalActiveSessions.find((s) => s.id === currentSessionId)
|
||||
?? currentSyncedSession
|
||||
?? getAllSyncSessions().find((s) => s.id === currentSessionId)
|
||||
?? null;
|
||||
}, [currentSessionId, currentSyncedSession, globalActiveSessions, liveSessions]);
|
||||
const currentSessionSnapshot = currentSessionId
|
||||
? currentGlobalSession ?? null
|
||||
: null;
|
||||
|
||||
const lastResolvedSessionRef = React.useRef<{
|
||||
sessionId: string;
|
||||
session: Session;
|
||||
session: HeaderSessionSnapshot;
|
||||
expiresAt: number;
|
||||
} | null>(null);
|
||||
const [sessionFallbackVersion, setSessionFallbackVersion] = React.useState(0);
|
||||
@@ -1155,10 +1170,10 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSessionLive) {
|
||||
if (currentSessionSnapshot) {
|
||||
lastResolvedSessionRef.current = {
|
||||
sessionId: currentSessionId,
|
||||
session: currentSessionLive,
|
||||
session: currentSessionSnapshot,
|
||||
expiresAt: Date.now() + 2000,
|
||||
};
|
||||
return;
|
||||
@@ -1186,12 +1201,12 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [currentSessionId, currentSessionLive]);
|
||||
}, [currentSessionId, currentSessionSnapshot]);
|
||||
|
||||
void sessionFallbackVersion;
|
||||
const currentSession = (() => {
|
||||
if (currentSessionLive) {
|
||||
return currentSessionLive;
|
||||
if (currentSessionSnapshot) {
|
||||
return currentSessionSnapshot;
|
||||
}
|
||||
|
||||
if (!currentSessionId) {
|
||||
@@ -1256,6 +1271,10 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const openDirectory = React.useMemo(() => {
|
||||
return worktreeDirectory || sessionDirectory || draftDirectory;
|
||||
}, [draftDirectory, sessionDirectory, worktreeDirectory]);
|
||||
const activeContextMode = useUIStore(React.useCallback((state) => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
return directory ? getActiveContextMode(state.contextPanelByDirectory[directory]) : null;
|
||||
}, [openDirectory]));
|
||||
|
||||
const catalogWorktreeBranch = useSessionUIStore((state) => {
|
||||
const candidateDirectory = normalize(worktreeDirectory || sessionDirectory || '');
|
||||
@@ -1336,7 +1355,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
if (!currentSessionId) return;
|
||||
|
||||
const sessionKey = `${currentSessionId || 'none'}:${sessionDirectory || 'none'}:${currentSession?.time?.created || 0}:${currentSession?.slug || 'none'}`;
|
||||
const sessionKey = `${currentSessionId || 'none'}:${sessionDirectory || 'none'}:${currentSession?.created || 0}:${currentSession?.slug || 'none'}`;
|
||||
if (lastPlanSessionKeyRef.current !== sessionKey) {
|
||||
lastPlanSessionKeyRef.current = sessionKey;
|
||||
}
|
||||
@@ -1349,7 +1368,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
planModeEnabled,
|
||||
planTabAvailable,
|
||||
currentSession?.slug,
|
||||
currentSession?.time?.created,
|
||||
currentSession?.created,
|
||||
currentSessionId,
|
||||
sessionDirectory,
|
||||
]);
|
||||
@@ -1449,23 +1468,16 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
|
||||
if (getActiveContextMode(panelState) === 'context') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
|
||||
openContextOverview(directory);
|
||||
}, [closeContextPanel, contextPanelByDirectory, openContextOverview, openDirectory]);
|
||||
}, [closeContextPanel, openContextOverview, openDirectory]);
|
||||
|
||||
const isContextPanelActive = React.useMemo(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
if (!directory) {
|
||||
return false;
|
||||
}
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
return getActiveContextMode(panelState) === 'context';
|
||||
}, [contextPanelByDirectory, openDirectory]);
|
||||
const isContextPanelActive = activeContextMode === 'context';
|
||||
|
||||
const handleOpenContextPlan = React.useCallback(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
@@ -1473,14 +1485,14 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
|
||||
if (getActiveContextMode(panelState) === 'plan') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
|
||||
openContextPlan(directory);
|
||||
}, [closeContextPanel, contextPanelByDirectory, openContextPlan, openDirectory]);
|
||||
}, [closeContextPanel, openContextPlan, openDirectory]);
|
||||
|
||||
const handleOpenContextChanges = React.useCallback(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
@@ -1488,14 +1500,14 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
|
||||
if (getActiveContextMode(panelState) === 'diff') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
|
||||
openContextPanelTab(directory, { mode: 'diff', stagedDiff: false });
|
||||
}, [closeContextPanel, contextPanelByDirectory, openContextPanelTab, openDirectory]);
|
||||
}, [closeContextPanel, openContextPanelTab, openDirectory]);
|
||||
|
||||
const handleOpenContextBrowser = React.useCallback(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
@@ -1503,41 +1515,18 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
const panelState = useUIStore.getState().contextPanelByDirectory[directory];
|
||||
if (getActiveContextMode(panelState) === 'browser') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
|
||||
openContextBrowser(directory);
|
||||
}, [closeContextPanel, contextPanelByDirectory, openContextBrowser, openDirectory]);
|
||||
}, [closeContextPanel, openContextBrowser, openDirectory]);
|
||||
|
||||
const isContextPlanActive = React.useMemo(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
if (!directory) {
|
||||
return false;
|
||||
}
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
return getActiveContextMode(panelState) === 'plan';
|
||||
}, [contextPanelByDirectory, openDirectory]);
|
||||
|
||||
const isContextChangesActive = React.useMemo(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
if (!directory) {
|
||||
return false;
|
||||
}
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
return getActiveContextMode(panelState) === 'diff';
|
||||
}, [contextPanelByDirectory, openDirectory]);
|
||||
|
||||
const isContextBrowserActive = React.useMemo(() => {
|
||||
const directory = normalize(openDirectory || '');
|
||||
if (!directory) {
|
||||
return false;
|
||||
}
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
return getActiveContextMode(panelState) === 'browser';
|
||||
}, [contextPanelByDirectory, openDirectory]);
|
||||
const isContextPlanActive = activeContextMode === 'plan';
|
||||
const isContextChangesActive = activeContextMode === 'diff';
|
||||
const isContextBrowserActive = activeContextMode === 'browser';
|
||||
|
||||
const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS;
|
||||
const mobileHeaderIconButtonClass = MOBILE_HEADER_ICON_BUTTON_CLASS;
|
||||
|
||||
@@ -72,7 +72,6 @@ export const MainLayout: React.FC = () => {
|
||||
setMobileLeftDrawerOpen(open);
|
||||
useUIStore.getState().setSessionSwitcherOpen(open);
|
||||
}, []);
|
||||
const mobileRightDrawerOpenRef = React.useRef(false);
|
||||
const initialDrawerWidthRef = React.useRef(typeof window === 'undefined' ? 0 : window.innerWidth);
|
||||
|
||||
// Left drawer motion value
|
||||
@@ -114,7 +113,6 @@ export const MainLayout: React.FC = () => {
|
||||
setMobileRightDrawerVisible(false);
|
||||
return;
|
||||
}
|
||||
mobileRightDrawerOpenRef.current = mobileRightSidebarOpen;
|
||||
if (mobileRightSidebarOpen) {
|
||||
setMobileRightDrawerVisible(true);
|
||||
}
|
||||
@@ -441,7 +439,7 @@ export const MainLayout: React.FC = () => {
|
||||
>
|
||||
<main className="w-full h-full overflow-hidden bg-background relative" data-page-scroll-lock="true">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen} /></ErrorBoundary>
|
||||
</div>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
@@ -480,7 +478,7 @@ export const MainLayout: React.FC = () => {
|
||||
aria-hidden={!mobileLeftDrawerOpen}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<SessionSidebar mobileVariant />
|
||||
<SessionSidebar mobileVariant isVisible={mobileLeftDrawerVisible} />
|
||||
</ErrorBoundary>
|
||||
</motion.div>
|
||||
{mobileRightDrawerVisible && (
|
||||
@@ -520,7 +518,7 @@ export const MainLayout: React.FC = () => {
|
||||
className="border-border/50"
|
||||
topBar={<SidebarTopBar />}
|
||||
>
|
||||
<SessionSidebar />
|
||||
<SessionSidebar isVisible={isSidebarOpen} />
|
||||
</Sidebar>
|
||||
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden bg-background" data-page-scroll-lock="true">
|
||||
<Header />
|
||||
@@ -530,7 +528,7 @@ export const MainLayout: React.FC = () => {
|
||||
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true">
|
||||
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen} /></ErrorBoundary>
|
||||
</div>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import {
|
||||
@@ -121,20 +122,23 @@ type FileTreeCache = {
|
||||
};
|
||||
const FILE_TREE_CACHE_MAX_ROOTS = 8;
|
||||
const fileTreeCacheByRoot = new Map<string, FileTreeCache>();
|
||||
const fileTreeCacheKey = (root: string): string => JSON.stringify([getRuntimeKey(), root]);
|
||||
|
||||
const touchCache = (root: string): FileTreeCache | null => {
|
||||
const entry = fileTreeCacheByRoot.get(root);
|
||||
const key = fileTreeCacheKey(root);
|
||||
const entry = fileTreeCacheByRoot.get(key);
|
||||
if (!entry) return null;
|
||||
entry.touchedAt = Date.now();
|
||||
// Touch on read promotes the key to the end of the Map's iteration order,
|
||||
// so the oldest (front) entry is the next eviction candidate.
|
||||
fileTreeCacheByRoot.delete(root);
|
||||
fileTreeCacheByRoot.set(root, entry);
|
||||
fileTreeCacheByRoot.delete(key);
|
||||
fileTreeCacheByRoot.set(key, entry);
|
||||
return entry;
|
||||
};
|
||||
|
||||
const getOrCreateCache = (root: string): FileTreeCache => {
|
||||
const existing = fileTreeCacheByRoot.get(root);
|
||||
const key = fileTreeCacheKey(root);
|
||||
const existing = fileTreeCacheByRoot.get(key);
|
||||
if (existing) {
|
||||
existing.touchedAt = Date.now();
|
||||
return existing;
|
||||
@@ -151,12 +155,12 @@ const getOrCreateCache = (root: string): FileTreeCache => {
|
||||
loadedDirs: new Set(),
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
fileTreeCacheByRoot.set(root, created);
|
||||
fileTreeCacheByRoot.set(key, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const dropCacheForRoot = (root: string): void => {
|
||||
fileTreeCacheByRoot.delete(root);
|
||||
fileTreeCacheByRoot.delete(fileTreeCacheKey(root));
|
||||
};
|
||||
|
||||
const getFileIcon = (filePath: string, extension?: string): React.ReactNode => {
|
||||
|
||||
@@ -251,6 +251,10 @@ export const VSCodeLayout: React.FC = () => {
|
||||
setCurrentView('sessions');
|
||||
}, []);
|
||||
|
||||
const handleSessionSelected = React.useCallback(() => {
|
||||
setCurrentView('chat');
|
||||
}, []);
|
||||
|
||||
const isSessionInActiveWorkspace = React.useCallback((session: Session): boolean => {
|
||||
if (!activeWorkspacePath) {
|
||||
return false;
|
||||
@@ -590,7 +594,7 @@ export const VSCodeLayout: React.FC = () => {
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ErrorBoundary>
|
||||
<ChatView />
|
||||
<ChatView active={currentView === 'chat'} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
@@ -609,7 +613,7 @@ export const VSCodeLayout: React.FC = () => {
|
||||
<SessionSidebar
|
||||
mobileVariant
|
||||
allowReselect
|
||||
onSessionSelected={() => setCurrentView('chat')}
|
||||
onSessionSelected={handleSessionSelected}
|
||||
hideDirectoryControls
|
||||
/>
|
||||
</div>
|
||||
@@ -628,7 +632,7 @@ export const VSCodeLayout: React.FC = () => {
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ErrorBoundary>
|
||||
<ChatView />
|
||||
<ChatView active={currentView === 'chat'} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,10 @@ const mainLayoutSource = readFileSync(
|
||||
join(__dirname, '..', 'MainLayout.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
const sessionSidebarSource = readFileSync(
|
||||
join(__dirname, '..', '..', 'session', 'SessionSidebar.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)', () => {
|
||||
test('mobile SessionSidebar is not conditionally mounted on mobileLeftDrawerVisible', () => {
|
||||
@@ -20,10 +24,11 @@ describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)'
|
||||
expect(/\{\s*mobileLeftDrawerVisible\s*&&\s*\(/.test(precedingWindow)).toBe(false);
|
||||
|
||||
expect(precedingWindow.includes('pointer-events-none')).toBe(true);
|
||||
expect(mainLayoutSource.slice(mobileSidebarIndex, mobileSidebarIndex + 120)).toContain('isVisible={mobileLeftDrawerVisible}');
|
||||
});
|
||||
|
||||
test('desktop SessionSidebar is rendered inside Sidebar without drawer-visibility gating', () => {
|
||||
const desktopSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar />');
|
||||
const desktopSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar isVisible={isSidebarOpen} />');
|
||||
expect(desktopSidebarIndex).toBeGreaterThan(-1);
|
||||
|
||||
const windowStart = Math.max(0, desktopSidebarIndex - 300);
|
||||
@@ -32,4 +37,12 @@ describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)'
|
||||
expect(precedingWindow).toContain('<Sidebar');
|
||||
expect(/mobileLeftDrawerVisible\s*&&/.test(precedingWindow)).toBe(false);
|
||||
});
|
||||
|
||||
test('hidden sidebars disable render-only subscriptions and effects', () => {
|
||||
expect(sessionSidebarSource).toContain('useGitAllBranches(isVisible)');
|
||||
expect(sessionSidebarSource).toContain('useGitRepoStatusMap(isVisible ? normalizedProjectPaths : EMPTY_STRING_ARRAY)');
|
||||
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isSessionSearchOpen');
|
||||
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isDesktopShellRuntime');
|
||||
expect(sessionSidebarSource).toContain('if (!isVisible) return EMPTY_STRING_ARRAY;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,11 @@ import { isDesktopShell } from '@/lib/desktop';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { formatDirectoryName, cn } from '@/lib/utils';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useAllLiveSessions } from '@/sync/sync-context';
|
||||
import { useChildStoreManager } from '@/sync/sync-context';
|
||||
import { getAllSyncSessionMap } from '@/sync/sync-refs';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
|
||||
import { SessionPrefetchEffect } from './sidebar/hooks/useSessionPrefetch';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
@@ -22,7 +23,7 @@ import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { useArchivedAutoFolders } from './sidebar/hooks/useArchivedAutoFolders';
|
||||
import { useSessionSidebarSections } from './sidebar/hooks/useSessionSidebarSections';
|
||||
import { useProjectSessionSelection } from './sidebar/hooks/useProjectSessionSelection';
|
||||
import { ProjectSessionSelectionEffect } from './sidebar/hooks/useProjectSessionSelection';
|
||||
import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering';
|
||||
import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping';
|
||||
import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects';
|
||||
@@ -30,7 +31,7 @@ import { useSessionActions } from './sidebar/hooks/useSessionActions';
|
||||
import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence';
|
||||
import { useProjectRepoStatus } from './sidebar/hooks/useProjectRepoStatus';
|
||||
import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists';
|
||||
import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup';
|
||||
import { useAuthoritativeSessionCleanup } from './sidebar/hooks/useAuthoritativeSessionCleanup';
|
||||
import { createSessionOwnershipIndex } from './sidebar/sessionOwnership';
|
||||
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
@@ -69,30 +70,32 @@ import {
|
||||
compareSessionsByPinnedAndTime,
|
||||
formatProjectLabel,
|
||||
normalizePath,
|
||||
selectExpandedParentKeysForContext,
|
||||
toggleExpandedParentKey,
|
||||
} from './sidebar/utils';
|
||||
import {
|
||||
mergeLiveSessionWithGlobalSession,
|
||||
refreshGlobalSessions,
|
||||
refreshGlobalSessionsForDirectories,
|
||||
getSessionStructuralSignature,
|
||||
resolveGlobalSessionDirectory,
|
||||
useGlobalSessionsStore,
|
||||
} from '@/stores/useGlobalSessionsStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
import { buildSessionBootstrapDemands } from './sidebar/sessionBootstrapDemands';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { streamPerfCount, streamPerfMark } from '@/stores/utils/streamDebug';
|
||||
|
||||
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
||||
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
||||
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
|
||||
const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
|
||||
// v2 key holds composite "${renderContext}:${active|archived}:${sessionId}"
|
||||
// v3 holds composite "${renderContext}:${active|archived}:${sessionId}"
|
||||
// entries so the same session in different render contexts (e.g. "Recent"
|
||||
// and a project's root) has independent expand state. v1 held bare session
|
||||
// ids; useSidebarPersistence migrates v1 data on first read by fanning each
|
||||
// id into all four context combinations.
|
||||
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents.v2';
|
||||
const LEGACY_SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
|
||||
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
|
||||
// and a project's root) has independent expand state. Older expansion state
|
||||
// mixed contexts and is intentionally not migrated.
|
||||
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents.v3';
|
||||
|
||||
type PrVisualState = 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
|
||||
|
||||
@@ -157,6 +160,7 @@ const isKnownActiveSessionDirectory = (
|
||||
const SIDEBAR_PR_NO_PR_RETRY_MS = 5 * 60_000;
|
||||
|
||||
const EMPTY_SUBTREE_SET: Set<string> = new Set();
|
||||
const EMPTY_STRING_ARRAY: string[] = [];
|
||||
|
||||
const useStableRenderCallback = <Args extends unknown[], Return>(handler: (...args: Args) => Return): ((...args: Args) => Return) => {
|
||||
const handlerRef = React.useRef(handler);
|
||||
@@ -165,6 +169,7 @@ const useStableRenderCallback = <Args extends unknown[], Return>(handler: (...ar
|
||||
};
|
||||
|
||||
interface SessionSidebarProps {
|
||||
isVisible?: boolean;
|
||||
mobileVariant?: boolean;
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
allowReselect?: boolean;
|
||||
@@ -172,13 +177,65 @@ interface SessionSidebarProps {
|
||||
showOnlyMainWorkspace?: boolean;
|
||||
}
|
||||
|
||||
export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const SidebarBootstrapDemandEffect: React.FC<{
|
||||
owner: string;
|
||||
childStores: ReturnType<typeof useChildStoreManager>;
|
||||
projectSections: Parameters<typeof buildSessionBootstrapDemands>[0]['projectSections'];
|
||||
activeProjectId: string | null;
|
||||
collapsedProjects: ReadonlySet<string>;
|
||||
collapsedGroups: ReadonlySet<string>;
|
||||
currentDirectory: string | null;
|
||||
}> = ({
|
||||
owner,
|
||||
childStores,
|
||||
projectSections,
|
||||
activeProjectId,
|
||||
collapsedProjects,
|
||||
collapsedGroups,
|
||||
currentDirectory,
|
||||
}) => {
|
||||
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
|
||||
|
||||
React.useEffect(() => {
|
||||
childStores.setBootstrapDemand(owner, buildSessionBootstrapDemands({
|
||||
projectSections,
|
||||
activeProjectId,
|
||||
collapsedProjects,
|
||||
collapsedGroups,
|
||||
currentDirectory,
|
||||
currentSessionDirectory,
|
||||
}));
|
||||
}, [
|
||||
activeProjectId,
|
||||
childStores,
|
||||
collapsedGroups,
|
||||
collapsedProjects,
|
||||
currentDirectory,
|
||||
currentSessionDirectory,
|
||||
owner,
|
||||
projectSections,
|
||||
]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => childStores.clearBootstrapDemand(owner),
|
||||
[childStores, owner],
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
isVisible = true,
|
||||
mobileVariant = false,
|
||||
onSessionSelected,
|
||||
allowReselect = false,
|
||||
hideDirectoryControls = false,
|
||||
showOnlyMainWorkspace = false,
|
||||
}) => {
|
||||
streamPerfMark('react.session_sidebar_render');
|
||||
streamPerfCount('ui.session_sidebar.render');
|
||||
streamPerfCount(`ui.session_sidebar.render.${mobileVariant ? 'mobile' : 'desktop'}`);
|
||||
streamPerfCount(`ui.session_sidebar.render.${isVisible ? 'visible' : 'hidden'}`);
|
||||
const { t } = useI18n();
|
||||
const [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false);
|
||||
const [sessionSearchQuery, setSessionSearchQuery] = React.useState('');
|
||||
@@ -204,7 +261,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<DeleteFolderConfirmState>(null);
|
||||
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
|
||||
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
||||
const setPinnedSessionIds = useSessionPinnedStore((state) => state.setIds);
|
||||
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
|
||||
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
|
||||
try {
|
||||
@@ -236,7 +292,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return new Map();
|
||||
}
|
||||
});
|
||||
const [activeSessionByProject, setActiveSessionByProject] = React.useState<Map<string, string>>(() => {
|
||||
const initialActiveSessionByProject = React.useMemo<Map<string, string>>(() => {
|
||||
try {
|
||||
const raw = getDeferredSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
@@ -253,7 +309,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
const persistActiveSessionByProject = React.useCallback((value: Map<string, string>) => {
|
||||
try {
|
||||
safeStorage.setItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY, JSON.stringify(Object.fromEntries(value.entries())));
|
||||
} catch { /* ignored */ }
|
||||
}, [safeStorage]);
|
||||
|
||||
const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map());
|
||||
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
@@ -261,7 +322,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const setDirectory = useDirectoryStore((state) => state.setDirectory);
|
||||
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
@@ -302,26 +362,51 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder);
|
||||
const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders);
|
||||
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
|
||||
const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions);
|
||||
const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId);
|
||||
|
||||
useSessionSearchEffects({
|
||||
enabled: isVisible,
|
||||
isSessionSearchOpen,
|
||||
setIsSessionSearchOpen,
|
||||
sessionSearchInputRef,
|
||||
sessionSearchContainerRef,
|
||||
});
|
||||
|
||||
const gitBranches = useGitAllBranches();
|
||||
const gitBranches = useGitAllBranches(isVisible);
|
||||
|
||||
const sync = useSync();
|
||||
const liveSessions = useAllLiveSessions();
|
||||
const childStores = useChildStoreManager();
|
||||
const bootstrapDemandOwner = `session-sidebar:${React.useId()}`;
|
||||
const liveSessionIndex = getAllSyncSessionMap();
|
||||
const liveSessions = React.useMemo(() => Array.from(liveSessionIndex.values()), [liveSessionIndex]);
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const activeSessionStructure = useGlobalSessionsStore(useShallow(
|
||||
(state) => state.activeSessions.map(getSessionStructuralSignature).sort(),
|
||||
));
|
||||
const archivedSessionStructure = useGlobalSessionsStore(useShallow(
|
||||
(state) => state.archivedSessions.map(getSessionStructuralSignature).sort(),
|
||||
));
|
||||
const globalSessionSnapshot = useGlobalSessionsStore.getState();
|
||||
const globalActiveSessions = globalSessionSnapshot.activeSessions;
|
||||
const archivedSessions = globalSessionSnapshot.archivedSessions;
|
||||
const liveFallbackCacheRef = React.useRef<{ signature: string; sessions: Session[] }>({
|
||||
signature: '',
|
||||
sessions: [],
|
||||
});
|
||||
const globalActiveSessionIds = React.useMemo(
|
||||
() => new Set(globalActiveSessions.map((session) => session.id)),
|
||||
[globalActiveSessions],
|
||||
);
|
||||
const liveFallbackSessions = (() => {
|
||||
const candidates = liveSessions.filter((session) => !globalActiveSessionIds.has(session.id));
|
||||
const signature = candidates.map(getSessionStructuralSignature).sort().join('\n');
|
||||
if (liveFallbackCacheRef.current.signature === signature) {
|
||||
return liveFallbackCacheRef.current.sessions;
|
||||
}
|
||||
liveFallbackCacheRef.current = { signature, sessions: candidates };
|
||||
return candidates;
|
||||
})();
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
|
||||
const shareSession = useSessionUIStore((state) => state.shareSession);
|
||||
@@ -359,14 +444,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
);
|
||||
|
||||
const sessions = React.useMemo(() => {
|
||||
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
|
||||
const merged = globalActiveSessions.map((session) => {
|
||||
const liveSession = liveById.get(session.id);
|
||||
return liveSession ? mergeLiveSessionWithGlobalSession(liveSession, session) : session;
|
||||
});
|
||||
const merged = [...globalActiveSessions];
|
||||
const seenIds = new Set(merged.map((session) => session.id));
|
||||
|
||||
liveSessions.forEach((session) => {
|
||||
liveFallbackSessions.forEach((session) => {
|
||||
if (seenIds.has(session.id)) {
|
||||
return;
|
||||
}
|
||||
@@ -377,43 +458,24 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
allowUnknownDirectory: !isVSCode,
|
||||
allowEmptyDirectorySet: !isVSCode,
|
||||
}));
|
||||
}, [globalActiveSessions, isVSCode, knownSessionDirectories, liveSessions]);
|
||||
}, [globalActiveSessions, isVSCode, knownSessionDirectories, liveFallbackSessions]);
|
||||
|
||||
const persistenceSessions = React.useMemo(
|
||||
() => [...globalActiveSessions, ...archivedSessions],
|
||||
[archivedSessions, globalActiveSessions],
|
||||
);
|
||||
|
||||
const syncSessionStructureSignature = React.useMemo(
|
||||
() => liveSessions
|
||||
.map((session) => {
|
||||
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
|
||||
return `${session.id}:${session.title ?? ''}:${session.time?.archived ? 1 : 0}:${directory}`;
|
||||
})
|
||||
.join('|'),
|
||||
[liveSessions],
|
||||
);
|
||||
|
||||
const syncSessionsSnapshotRef = React.useRef<Session[]>(liveSessions);
|
||||
React.useEffect(() => {
|
||||
syncSessionsSnapshotRef.current = liveSessions;
|
||||
}, [syncSessionStructureSignature, liveSessions]);
|
||||
|
||||
// Batched live-session index. Building this here turns the per-row
|
||||
// `useSession(session.id)` reads in SessionNodeItem (each of which
|
||||
// iterates all child-stores via `findLiveSession`) into a single
|
||||
// Map lookup. With M visible rows, that changes an O(M × child-stores)
|
||||
// work to O(child-stores) once per Sidebar render.
|
||||
const liveSessionById = React.useMemo(
|
||||
() => new Map(liveSessions.map((session) => [session.id, session] as const)),
|
||||
[liveSessions],
|
||||
);
|
||||
}, [liveSessions]);
|
||||
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const projectWorktreeDiscoveryKey = React.useMemo(
|
||||
() => projects
|
||||
() => `${runtimeKey}|${projects
|
||||
.map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`)
|
||||
.join('|'),
|
||||
[projects],
|
||||
.join('|')}`,
|
||||
[projects, runtimeKey],
|
||||
);
|
||||
const [resolvedWorktreeTopologyKey, setResolvedWorktreeTopologyKey] = React.useState<string | null>(
|
||||
isVSCode ? projectWorktreeDiscoveryKey : null,
|
||||
@@ -434,6 +496,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
let cancelled = false;
|
||||
|
||||
const discoverWorktrees = async () => {
|
||||
const discoveryRuntimeKey = runtimeKey;
|
||||
const projectEntries = useProjectsStore.getState().projects;
|
||||
if (projectEntries.length === 0 || isVSCode) {
|
||||
if (!cancelled) {
|
||||
@@ -488,7 +551,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
await Promise.all(workers);
|
||||
|
||||
if (cancelled) return;
|
||||
if (cancelled || getRuntimeKey() !== discoveryRuntimeKey) return;
|
||||
|
||||
const activeProjectPaths = new Set(projectEntries.map((project) => normalizePath(project.path)).filter(Boolean));
|
||||
for (const projectPath of worktreesByProject.keys()) {
|
||||
@@ -514,7 +577,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVSCode, projectWorktreeDiscoveryKey]);
|
||||
}, [isVSCode, projectWorktreeDiscoveryKey, runtimeKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -556,22 +619,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
|
||||
const { scheduleCollapsedProjectsPersist } = useSidebarPersistence({
|
||||
isVSCode,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
safeStorage,
|
||||
keys: {
|
||||
sessionExpanded: SESSION_EXPANDED_STORAGE_KEY,
|
||||
sessionExpandedLegacy: LEGACY_SESSION_EXPANDED_STORAGE_KEY,
|
||||
projectCollapse: PROJECT_COLLAPSE_STORAGE_KEY,
|
||||
sessionPinned: SESSION_PINNED_STORAGE_KEY,
|
||||
groupOrder: GROUP_ORDER_STORAGE_KEY,
|
||||
projectActiveSession: PROJECT_ACTIVE_SESSION_STORAGE_KEY,
|
||||
groupCollapse: GROUP_COLLAPSE_STORAGE_KEY,
|
||||
},
|
||||
sessions: persistenceSessions,
|
||||
pinnedSessionIds,
|
||||
setPinnedSessionIds,
|
||||
groupOrderByProject,
|
||||
activeSessionByProject,
|
||||
collapsedGroups,
|
||||
setExpandedParents,
|
||||
setCollapsedProjects,
|
||||
@@ -619,12 +674,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return map;
|
||||
}, [sortedSessions, pinnedSessionIds]);
|
||||
|
||||
const emptyState = (
|
||||
const emptyState = React.useMemo(() => (
|
||||
<div className="py-6 text-center text-muted-foreground">
|
||||
<p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noSessions.title')}</p>
|
||||
<p className="typography-meta mt-1">{t('sessions.sidebar.empty.noSessions.description')}</p>
|
||||
</div>
|
||||
);
|
||||
), [t]);
|
||||
|
||||
const editingProject = React.useMemo(
|
||||
() => projects.find((project) => project.id === editingProjectDialogId) ?? null,
|
||||
@@ -703,9 +758,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
handleDeleteSession,
|
||||
confirmDeleteSession,
|
||||
} = useSessionActions({
|
||||
activeProjectId,
|
||||
currentDirectory,
|
||||
currentSessionId,
|
||||
mobileVariant,
|
||||
allowReselect,
|
||||
onSessionSelected,
|
||||
@@ -713,8 +765,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
sessionSearchQuery,
|
||||
setSessionSearchQuery,
|
||||
setIsSessionSearchOpen,
|
||||
setActiveProjectIdOnly,
|
||||
setDirectory,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
setCurrentSession,
|
||||
@@ -746,40 +796,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
sessionEvents.requestDirectoryDialog();
|
||||
}, []);
|
||||
|
||||
// Auto-expand parent session when navigating to a subagent (child) session.
|
||||
// We don't know which render context the user will look at the parent in
|
||||
// (Recent, project root, archived bucket, ...), so fan out across all
|
||||
// four combinations to ensure it's expanded wherever it appears.
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId) return;
|
||||
const current = sessions.find((s) => s.id === currentSessionId);
|
||||
const parentID = (current as Session & { parentID?: string | null })?.parentID;
|
||||
if (!parentID) return;
|
||||
const keysToAdd = [
|
||||
`project:active:${parentID}`,
|
||||
`project:archived:${parentID}`,
|
||||
`recent:active:${parentID}`,
|
||||
`recent:archived:${parentID}`,
|
||||
];
|
||||
setExpandedParents((prev) => {
|
||||
if (keysToAdd.every((k) => prev.has(k))) return prev;
|
||||
const next = new Set(prev);
|
||||
keysToAdd.forEach((k) => next.add(k));
|
||||
try {
|
||||
safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next)));
|
||||
} catch { /* ignored */ }
|
||||
return next;
|
||||
});
|
||||
}, [currentSessionId, sessions, safeStorage]);
|
||||
|
||||
const toggleParent = React.useCallback((expansionKey: string) => {
|
||||
setExpandedParents((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(expansionKey)) {
|
||||
next.delete(expansionKey);
|
||||
} else {
|
||||
next.add(expansionKey);
|
||||
}
|
||||
setExpandedParents((previous) => {
|
||||
const next = toggleExpandedParentKey(previous, expansionKey);
|
||||
try {
|
||||
safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next)));
|
||||
} catch { /* ignored */ }
|
||||
@@ -964,12 +983,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const { github } = useRuntimeAPIs();
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const gitRepoStatus = useGitRepoStatusMap(normalizedProjectPaths);
|
||||
const gitRepoStatus = useGitRepoStatusMap(isVisible ? normalizedProjectPaths : EMPTY_STRING_ARRAY);
|
||||
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
|
||||
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
|
||||
const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
|
||||
|
||||
useProjectRepoStatus({
|
||||
enabled: isVisible,
|
||||
normalizedProjects,
|
||||
gitRepoStatus,
|
||||
setProjectRepoStatus,
|
||||
@@ -981,15 +1001,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
() => createSessionOwnershipIndex(sessions, normalizedProjects, availableWorktreesByProject, isVSCode, archivedSessions),
|
||||
[archivedSessions, availableWorktreesByProject, isVSCode, normalizedProjects, sessions],
|
||||
);
|
||||
useSessionFolderCleanup({
|
||||
isSessionsLoading,
|
||||
useAuthoritativeSessionCleanup({
|
||||
enabled: isVisible,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
normalizedProjects,
|
||||
ownership: sessionOwnership,
|
||||
availableWorktreesByProject,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
cleanupSessions,
|
||||
sessions: persistenceSessions,
|
||||
});
|
||||
|
||||
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({
|
||||
@@ -997,6 +1012,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
|
||||
useArchivedAutoFolders({
|
||||
enabled: isVisible,
|
||||
normalizedProjects,
|
||||
ownership: sessionOwnership,
|
||||
isSessionsLoading,
|
||||
@@ -1006,7 +1022,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
cleanupSessions,
|
||||
});
|
||||
|
||||
// Keep last-known repo status to avoid UI jiggling during project switch
|
||||
@@ -1019,6 +1034,89 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions);
|
||||
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
|
||||
const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder);
|
||||
const projectExpandedParentsRef = React.useRef<Set<string>>(new Set());
|
||||
const recentExpandedParentsRef = React.useRef<Set<string>>(new Set());
|
||||
const projectExpandedParents = selectExpandedParentKeysForContext(
|
||||
projectExpandedParentsRef.current,
|
||||
expandedParents,
|
||||
'project',
|
||||
);
|
||||
const recentExpandedParents = selectExpandedParentKeysForContext(
|
||||
recentExpandedParentsRef.current,
|
||||
expandedParents,
|
||||
'recent',
|
||||
);
|
||||
projectExpandedParentsRef.current = projectExpandedParents;
|
||||
recentExpandedParentsRef.current = recentExpandedParents;
|
||||
|
||||
const sidebarRenderSources = {
|
||||
isVisible,
|
||||
mobileVariant,
|
||||
onSessionSelected,
|
||||
allowReselect,
|
||||
hideDirectoryControls,
|
||||
showOnlyMainWorkspace,
|
||||
t,
|
||||
isTablet,
|
||||
liveSessions,
|
||||
activeSessionStructure,
|
||||
archivedSessionStructure,
|
||||
globalActiveSessions,
|
||||
archivedSessions,
|
||||
projects,
|
||||
activeProjectId,
|
||||
manualProjectOrder,
|
||||
currentDirectory,
|
||||
worktreeMetadata,
|
||||
availableWorktreesByProject,
|
||||
pinnedSessionIds,
|
||||
foldersMap,
|
||||
collapsedFolderIds,
|
||||
gitBranches,
|
||||
gitRepoStatus,
|
||||
githubAuthStatus,
|
||||
githubAuthChecked,
|
||||
updateStore,
|
||||
showRecentSection,
|
||||
showArchivedSessions,
|
||||
projectSortOrder,
|
||||
projectRepoStatus,
|
||||
projectRootBranches,
|
||||
resolvedWorktreeTopologyKey,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
isSessionSearchOpen,
|
||||
sessionSearchQuery,
|
||||
editingId,
|
||||
editTitle,
|
||||
editingProjectDialogId,
|
||||
expandedParents,
|
||||
collapsedProjects,
|
||||
visibleSessionCountByGroup,
|
||||
updateDialogOpen,
|
||||
openSidebarMenuKey,
|
||||
renamingFolderId,
|
||||
renameFolderDraft,
|
||||
deleteSessionConfirm,
|
||||
deleteFolderConfirm,
|
||||
bulkDeleteConfirm,
|
||||
collapsedGroups,
|
||||
groupOrderByProject,
|
||||
};
|
||||
const previousSidebarRenderSourcesRef = React.useRef<typeof sidebarRenderSources | null>(null);
|
||||
const previousSidebarRenderSources = previousSidebarRenderSourcesRef.current;
|
||||
if (previousSidebarRenderSources) {
|
||||
let attributed = false;
|
||||
for (const source of Object.keys(sidebarRenderSources) as Array<keyof typeof sidebarRenderSources>) {
|
||||
if (!Object.is(previousSidebarRenderSources[source], sidebarRenderSources[source])) {
|
||||
streamPerfCount(`ui.session_sidebar.source.${source}`);
|
||||
attributed = true;
|
||||
}
|
||||
}
|
||||
if (!attributed) {
|
||||
streamPerfCount('ui.session_sidebar.source.parent_or_context');
|
||||
}
|
||||
}
|
||||
previousSidebarRenderSourcesRef.current = sidebarRenderSources;
|
||||
|
||||
const sortedProjects = React.useMemo(() => {
|
||||
const list = [...normalizedProjects];
|
||||
@@ -1079,26 +1177,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
foldersMap,
|
||||
});
|
||||
|
||||
const searchEmptyState = (
|
||||
const searchEmptyState = React.useMemo(() => (
|
||||
<div className="py-6 text-center text-muted-foreground">
|
||||
<p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noMatches.title')}</p>
|
||||
<p className="typography-meta mt-1">{t('sessions.sidebar.empty.noMatches.description')}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
useProjectSessionSelection({
|
||||
projectSections,
|
||||
activeProjectId,
|
||||
activeSessionByProject,
|
||||
setActiveSessionByProject,
|
||||
currentSessionId,
|
||||
handleSessionSelect,
|
||||
newSessionDraftOpen,
|
||||
mobileVariant,
|
||||
openNewSessionDraft,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
});
|
||||
), [t]);
|
||||
|
||||
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
|
||||
const hasInitializedArchivedCollapseRef = React.useRef(false);
|
||||
@@ -1231,19 +1315,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
);
|
||||
|
||||
|
||||
const recentSessionIds = React.useMemo(() => {
|
||||
return new Set(activeNowSessions.map((session) => session.id));
|
||||
}, [activeNowSessions]);
|
||||
|
||||
const recentSessionIdsList = React.useMemo(() => [...recentSessionIds], [recentSessionIds]);
|
||||
|
||||
useSessionPrefetch({
|
||||
currentSessionId,
|
||||
sortedSessions,
|
||||
recentSessionIds: recentSessionIdsList,
|
||||
ensureSessionRenderable: sync.ensureSessionRenderable,
|
||||
});
|
||||
|
||||
const sectionsForSidebarRender = React.useMemo(() => {
|
||||
return showArchivedSessions
|
||||
? sectionsForRender
|
||||
@@ -1254,6 +1325,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}, [sectionsForRender, showArchivedSessions]);
|
||||
|
||||
const prLookupKeys = React.useMemo(() => {
|
||||
if (!isVisible) return EMPTY_STRING_ARRAY;
|
||||
const keys = new Set<string>();
|
||||
sectionsForSidebarRender.forEach((section) => {
|
||||
section.groups.forEach((group) => {
|
||||
@@ -1266,16 +1338,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
});
|
||||
return [...keys];
|
||||
}, [gitBranches, sectionsForSidebarRender]);
|
||||
}, [gitBranches, isVisible, sectionsForSidebarRender]);
|
||||
|
||||
const prVisualSummaryMap = usePrVisualSummaryByKeys(prLookupKeys);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!githubAuthChecked || !githubAuthStatus?.connected || !github) {
|
||||
if (!isVisible || !githubAuthChecked || !githubAuthStatus?.connected || !github) {
|
||||
return;
|
||||
}
|
||||
|
||||
const missingTargets: Array<{ directory: string; branch: string; remoteName?: string | null }> = [];
|
||||
const targetsByKey = new Map<string, { directory: string; branch: string }>();
|
||||
const now = Date.now();
|
||||
|
||||
sectionsForSidebarRender.forEach((section) => {
|
||||
@@ -1307,29 +1379,23 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
if (shouldRetryNoPr) {
|
||||
retriedNoPrStatusKeysRef.current.add(retryKey);
|
||||
}
|
||||
missingTargets.push({ directory, branch });
|
||||
if (!targetsByKey.has(key)) {
|
||||
targetsByKey.set(key, { directory, branch });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (missingTargets.length === 0) {
|
||||
if (targetsByKey.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueTargets = new Map<string, { directory: string; branch: string; remoteName?: string | null }>();
|
||||
missingTargets.forEach((target) => {
|
||||
const key = getGitHubPrStatusKey(target.directory, target.branch, target.remoteName ?? null);
|
||||
if (!uniqueTargets.has(key)) {
|
||||
uniqueTargets.set(key, target);
|
||||
}
|
||||
});
|
||||
|
||||
uniqueTargets.forEach((target, key) => {
|
||||
targetsByKey.forEach((target, key) => {
|
||||
ensurePrStatusEntry(key);
|
||||
setPrStatusParams(key, {
|
||||
directory: target.directory,
|
||||
branch: target.branch,
|
||||
remoteName: target.remoteName ?? null,
|
||||
remoteName: null,
|
||||
canShow: true,
|
||||
github,
|
||||
githubAuthChecked,
|
||||
@@ -1337,7 +1403,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
});
|
||||
|
||||
void refreshPrStatusTargets([...uniqueTargets.values()], {
|
||||
void refreshPrStatusTargets([...targetsByKey.values()], {
|
||||
silent: true,
|
||||
markInitialResolved: true,
|
||||
});
|
||||
@@ -1347,6 +1413,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
github,
|
||||
githubAuthChecked,
|
||||
githubAuthStatus?.connected,
|
||||
isVisible,
|
||||
gitBranches,
|
||||
refreshPrStatusTargets,
|
||||
sectionsForSidebarRender,
|
||||
@@ -1360,6 +1427,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass;
|
||||
const headerActionIconClass = 'h-4.5 w-4.5';
|
||||
const stuckProjectHeaders = useStickyProjectHeaders({
|
||||
enabled: isVisible,
|
||||
isDesktopShellRuntime,
|
||||
projectSections,
|
||||
projectHeaderSentinelRefs,
|
||||
@@ -1382,9 +1450,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
groupDirectory={groupDirectory}
|
||||
projectId={projectId}
|
||||
archivedBucket={archivedBucket}
|
||||
currentSessionId={currentSessionId}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={expandedParents}
|
||||
expandedParents={renderContext === 'recent' ? recentExpandedParents : projectExpandedParents}
|
||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||
notifyOnSubtasks={notifyOnSubtasks}
|
||||
@@ -1417,12 +1484,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
renderSessionNode={renderSessionNode}
|
||||
secondaryMeta={secondaryMeta}
|
||||
renderContext={renderContext}
|
||||
subtreeContainsActive={renderExtras?.subtreeContainsActive ?? EMPTY_SUBTREE_SET}
|
||||
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_SET}
|
||||
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
|
||||
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
|
||||
childRenderExtrasFor={renderExtras?.childRenderExtrasFor}
|
||||
liveSessionById={liveSessionById}
|
||||
/>
|
||||
),
|
||||
);
|
||||
@@ -1505,13 +1570,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
setRenameFolderDraft={setRenameFolderDraft}
|
||||
setRenamingFolderId={setRenamingFolderId}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
expandedParents={expandedParents}
|
||||
expandedParents={projectExpandedParents}
|
||||
sessionOrderIndex={sessionOrderIndex}
|
||||
currentSessionId={currentSessionId}
|
||||
editingId={editingId}
|
||||
editTitle={editTitle}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
liveSessionById={liveSessionById}
|
||||
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
|
||||
onToggleCollapsedGroup={toggleCollapsedGroup}
|
||||
dragHandleProps={dragHandleProps}
|
||||
@@ -1546,28 +1609,29 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
renamingFolderId,
|
||||
renameFolderDraft,
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
projectExpandedParents,
|
||||
sessionOrderIndex,
|
||||
currentSessionId,
|
||||
editingId,
|
||||
editTitle,
|
||||
openSidebarMenuKey,
|
||||
liveSessionById,
|
||||
prVisualStateByDirectoryBranch,
|
||||
toggleCollapsedGroup,
|
||||
],
|
||||
);
|
||||
|
||||
const topContent = (!isVSCode && showRecentSection && !hasSessionSearchQuery) ? (
|
||||
<SidebarActivitySections
|
||||
sections={activitySections}
|
||||
renderSessionNode={renderSessionNode}
|
||||
currentSessionId={currentSessionId}
|
||||
editingId={editingId}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
variant="section"
|
||||
/>
|
||||
) : null;
|
||||
const topContent = React.useMemo(
|
||||
() => (!isVSCode && showRecentSection && !hasSessionSearchQuery) ? (
|
||||
<SidebarActivitySections
|
||||
sections={activitySections}
|
||||
renderSessionNode={renderSessionNode}
|
||||
editingId={editingId}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
expansionState={recentExpandedParents}
|
||||
variant="section"
|
||||
/>
|
||||
) : null,
|
||||
[activitySections, editingId, hasSessionSearchQuery, isVSCode, openSidebarMenuKey, recentExpandedParents, renderSessionNode, showRecentSection],
|
||||
);
|
||||
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
|
||||
|
||||
const {
|
||||
@@ -1620,6 +1684,32 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
mobileVariant ? '' : 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
<SidebarBootstrapDemandEffect
|
||||
owner={bootstrapDemandOwner}
|
||||
childStores={childStores}
|
||||
projectSections={projectSections}
|
||||
activeProjectId={activeProjectId}
|
||||
collapsedProjects={collapsedProjects}
|
||||
collapsedGroups={collapsedGroups}
|
||||
currentDirectory={currentDirectory}
|
||||
/>
|
||||
<ProjectSessionSelectionEffect
|
||||
projectSections={projectSections}
|
||||
activeProjectId={activeProjectId}
|
||||
initialActiveSessionByProject={initialActiveSessionByProject}
|
||||
persistActiveSessionByProject={persistActiveSessionByProject}
|
||||
handleSessionSelect={stableHandleSessionSelect}
|
||||
mobileVariant={mobileVariant}
|
||||
openNewSessionDraft={openNewSessionDraft}
|
||||
setActiveMainTab={setActiveMainTab}
|
||||
setSessionSwitcherOpen={setSessionSwitcherOpen}
|
||||
/>
|
||||
<SessionPrefetchEffect
|
||||
enabled={isVisible}
|
||||
sortedSessions={sortedSessions}
|
||||
recentSessions={activeNowSessions}
|
||||
prefetchSession={sync.prefetchSession}
|
||||
/>
|
||||
<SidebarHeader
|
||||
hideDirectoryControls={hideDirectoryControls}
|
||||
showRecentControls={!isVSCode}
|
||||
@@ -1643,7 +1733,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
onToggleSelectionMode={handleToggleSelectionMode}
|
||||
/>
|
||||
|
||||
<SidebarProjectsList
|
||||
{isVisible ? <SidebarProjectsList
|
||||
topContent={topContent}
|
||||
hasSharedSessions={hasActivitySectionItems}
|
||||
sectionsForRender={sectionsForSidebarRender}
|
||||
@@ -1678,7 +1768,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
isInlineEditing={isInlineEditing}
|
||||
/>
|
||||
/> : null}
|
||||
|
||||
{selectionModeEnabled && hasSelection ? (
|
||||
<BulkActionBar
|
||||
@@ -1770,3 +1860,5 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SessionSidebar = React.memo(SessionSidebarComponent);
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
- Archived groups are collapsed by default and support bulk deletion at group/folder level.
|
||||
- Session rows support compact inline dates in minimal mode and simplified metadata in default mode.
|
||||
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
|
||||
- Directory loading is demand-driven: the sidebar publishes one complete priority plan for all known project/worktree directories, while the sync layer owns bounded execution.
|
||||
- New extractions in latest pass reduced local effect/callback bulk further:
|
||||
- project session list builders
|
||||
- folder cleanup sync
|
||||
- authoritative deletion cleanup
|
||||
- sticky project header observer
|
||||
|
||||
## VS Code grouping
|
||||
@@ -29,8 +30,8 @@
|
||||
- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only.
|
||||
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
|
||||
- `SidebarProjectsList.tsx`: Main scrollable tree renderer for projects, root sessions, worktrees/groups, and empty/search states.
|
||||
- `SessionGroupSection.tsx`: Renders a single worktree/archived group, collapse/expand, folder subtree, and group-level controls.
|
||||
- `SessionNodeItem.tsx`: Renders one session row/tree node with inline metadata, menu actions, minimal/default variants, and nested children.
|
||||
- `SessionGroupSection.tsx`: Renders a single worktree/archived group, collapse/expand, folder subtree, group-level controls, and explicit loading/error/retry state for empty groups.
|
||||
- `SessionNodeItem.tsx`: Renders one session row/tree node with inline metadata, menu actions, minimal/default variants, and nested children. Rows do not initiate directory bootstrap on mount.
|
||||
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
|
||||
- `sortableItems.tsx`: DnD sortable wrappers for project and group ordering plus project-row action affordances.
|
||||
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
|
||||
@@ -40,7 +41,7 @@
|
||||
|
||||
- `hooks/useSessionActions.ts`: Centralizes session row actions (select/open, rename, share/unshare, archive/delete, confirmations).
|
||||
- `hooks/useSessionSearchEffects.ts`: Handles search open/close UX and input focus behavior.
|
||||
- `hooks/useSessionPrefetch.ts`: Prefetches messages for nearby/active sessions to improve perceived load speed.
|
||||
- `hooks/useSessionPrefetch.ts`: Publishes directory-aware nearby/active session prefetch demand to the shared message loader. Recent may prefetch across projects without substituting the current directory.
|
||||
- `hooks/useSessionGrouping.ts`: Builds grouped session structures and search text/filter helpers.
|
||||
- `hooks/useSessionSidebarSections.ts`: Composes final per-project sections and group search metadata for rendering.
|
||||
- `hooks/useProjectSessionSelection.ts`: Resolves active/current project-session selection logic and session-directory context.
|
||||
@@ -49,11 +50,29 @@
|
||||
- `hooks/useSidebarPersistence.ts`: Persists sidebar UI state (expanded/collapsed/pinned/group order/active session) to storage + desktop settings.
|
||||
- `hooks/useProjectRepoStatus.ts`: Tracks per-project git-repo state and root branch metadata.
|
||||
- `hooks/useProjectSessionLists.ts`: Reads live and archived project buckets from the shared ownership index.
|
||||
- `hooks/useSessionFolderCleanup.ts`: Cleans stale folder session IDs by reconciling known sessions/archived scopes.
|
||||
- `hooks/useAuthoritativeSessionCleanup.ts`: Establishes the first complete active+archived list as a non-destructive baseline, then cleans persisted state only for sessions omitted by a later authoritative snapshot.
|
||||
- `hooks/useStickyProjectHeaders.ts`: Tracks which project headers are sticky/stuck via `IntersectionObserver`.
|
||||
|
||||
### Types and utilities
|
||||
|
||||
- `types.ts`: Shared sidebar types (`SessionNode`, `SessionGroup`, summary/search metadata).
|
||||
- `activitySections.ts`: Persisted top-section storage/helpers for the current `recent` session list.
|
||||
- `sessionBootstrapDemands.ts`: Builds the deduplicated directory demand plan. Selected directories rank above active projects, expanded groups, visible collapsed groups, and background/collapsed projects.
|
||||
- `utils.tsx`: Shared sidebar utilities (path normalization, sorting, dedupe, archived scope keys, project relation checks, text highlight, labels, compact/default date formatting).
|
||||
|
||||
## Loading rules
|
||||
|
||||
- Always publish every known project root and worktree directory. Collapse/visibility changes priority only; they do not opt a directory out of authoritative refresh.
|
||||
- Current directory and selected-session directory are `selected` demand and therefore run first.
|
||||
- Expanded projects/worktrees outrank merely visible and background groups.
|
||||
- The sync scheduler deduplicates, promotes, retries, and limits work. Sidebar components must not reproduce that lifecycle with mount effects.
|
||||
- Hide speculative work when the sidebar/chat surface is hidden: message prefetch, Git/PR enrichment and subscriptions, search listeners, sticky-header observation, and archived-folder derivation stop. The session row tree unmounts so row-owned status, permission, unseen, and viewport subscriptions do no background work. The outer sidebar remains mounted, preserving UI state and authoritative directory refresh for an immediate reopen; deferred derived work reruns from current state when visibility returns.
|
||||
- The sidebar does not subscribe its whole tree to the cross-directory live-session aggregate. Global create/structural/lifecycle snapshots drive rendered session metadata; the cached sync index only fills sessions not yet present globally and provides refresh fallback data. Row activity continues to come from the session-keyed live status index.
|
||||
- Session selection does not invalidate the sidebar orchestration component. Each mounted row selects only whether its own session ID is active, while parent expansion, project selection memory, and neighbor prefetch run in small effect-only subscribers.
|
||||
- Parent expansion is exclusively manual. Selecting or navigating to a subsession never expands its parent automatically. Project/worktree and `recent` trees use independent persisted context keys and receive separate stable projections, so expansion changes in one context neither invalidate nor change the other. The persisted storage key remains `v3`; older state mixed contexts and is not migrated into this contract.
|
||||
- Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics.
|
||||
- Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling.
|
||||
- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes are read from the authoritative snapshot on the next sidebar render rather than triggering a full tree rebuild themselves.
|
||||
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
|
||||
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
|
||||
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
|
||||
|
||||
@@ -5,11 +5,6 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
// Archived buckets routinely grow into the hundreds/thousands; virtualize
|
||||
// when we cross this row count so the DOM stays bounded.
|
||||
const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
|
||||
// Active/worktree groups can also grow large (a single worktree with 80+
|
||||
// sessions), and unlike the archive they're interactive from the start.
|
||||
// Virtualize eagerly for non-archived groups to keep the rendered row
|
||||
// count bounded. With overscan ~8 the visible behavior is identical.
|
||||
const ACTIVE_VIRTUALIZE_THRESHOLD = 30;
|
||||
// Compact rows in the archived bucket without nested subagents render
|
||||
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
|
||||
const ARCHIVED_ROW_ESTIMATE_PX = 28;
|
||||
@@ -26,8 +21,10 @@ import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePa
|
||||
import {
|
||||
collectSubtreeContainingId,
|
||||
computeNodeStructureKey,
|
||||
nodeHasPinnedMembershipChange,
|
||||
nodeContainsSessionId,
|
||||
resolveMenuOpenSessionId,
|
||||
selectFolderRootNodes,
|
||||
} from './sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
@@ -36,6 +33,7 @@ import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useChildStoreManager } from '@/sync/sync-context';
|
||||
|
||||
type DeleteFolderConfirm = {
|
||||
scopeKey: string;
|
||||
@@ -92,11 +90,9 @@ type Props = {
|
||||
pinnedSessionIds: Set<string>;
|
||||
expandedParents: Set<string>;
|
||||
sessionOrderIndex: Map<string, number>;
|
||||
currentSessionId: string | null;
|
||||
editingId: string | null;
|
||||
editTitle: string;
|
||||
openSidebarMenuKey: string | null;
|
||||
liveSessionById: Map<string, Session>;
|
||||
prVisualStateByDirectoryBranch: Map<string, {
|
||||
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
|
||||
number: number;
|
||||
@@ -142,12 +138,14 @@ const groupHasPinnedMembershipChange = (
|
||||
prevPinnedSessionIds: Set<string>,
|
||||
nextPinnedSessionIds: Set<string>,
|
||||
): boolean => {
|
||||
const visit = (node: SessionNode): boolean => {
|
||||
const sessionId = node.session.id;
|
||||
if (prevPinnedSessionIds.has(sessionId) !== nextPinnedSessionIds.has(sessionId)) return true;
|
||||
return node.children.some(visit);
|
||||
};
|
||||
return group.sessions.some(visit);
|
||||
return group.sessions.some((node) => nodeHasPinnedMembershipChange(
|
||||
node,
|
||||
node,
|
||||
prevPinnedSessionIds,
|
||||
nextPinnedSessionIds,
|
||||
group.directory,
|
||||
group.directory,
|
||||
));
|
||||
};
|
||||
|
||||
const groupHasSessionOrderChange = (
|
||||
@@ -177,21 +175,6 @@ const groupHasExpansionMembershipChange = (
|
||||
return group.sessions.some(visit);
|
||||
};
|
||||
|
||||
const groupHasResolvedSessionChange = (
|
||||
group: SessionGroup,
|
||||
prevLiveSessionById: Map<string, Session>,
|
||||
nextLiveSessionById: Map<string, Session>,
|
||||
): boolean => {
|
||||
const visit = (node: SessionNode): boolean => {
|
||||
const sessionId = node.session.id;
|
||||
if ((prevLiveSessionById.get(sessionId) ?? node.session) !== (nextLiveSessionById.get(sessionId) ?? node.session)) {
|
||||
return true;
|
||||
}
|
||||
return node.children.some(visit);
|
||||
};
|
||||
return group.sessions.some(visit);
|
||||
};
|
||||
|
||||
const getProjectRepoStatusValue = (props: Props): boolean | null | undefined => {
|
||||
if (!props.projectId) return undefined;
|
||||
return props.projectRepoStatus.has(props.projectId)
|
||||
@@ -236,11 +219,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.currentSessionId !== next.currentSessionId
|
||||
&& (groupContainsSessionId(prev.group, prev.currentSessionId) || groupContainsSessionId(next.group, next.currentSessionId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.editingId !== next.editingId
|
||||
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
|
||||
return false;
|
||||
@@ -257,11 +235,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
if (prevMenuSessionId || nextMenuSessionId) return false;
|
||||
}
|
||||
|
||||
if (prev.liveSessionById !== next.liveSessionById
|
||||
&& groupHasResolvedSessionChange(next.group, prev.liveSessionById, next.liveSessionById)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Per-row / per-state props. The PR-visual-state map flips frequently
|
||||
// during bootstrap but a single group's value is usually stable, so we
|
||||
// compare only the value this group actually consumes instead of the
|
||||
@@ -352,7 +325,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
sessionOrderIndex,
|
||||
currentSessionId,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
prVisualStateByDirectoryBranch,
|
||||
@@ -379,6 +351,19 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
// VS Code always uses the expanded layout (see SessionNodeItem).
|
||||
const isMinimalMode = displayMode === 'minimal' && !isVSCodeRuntime();
|
||||
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
|
||||
const childStores = useChildStoreManager();
|
||||
const bootstrapDirectory = normalizePath(group.directory ?? null);
|
||||
const bootstrapState = React.useSyncExternalStore(
|
||||
React.useCallback(
|
||||
(notify) => bootstrapDirectory ? childStores.subscribeBootstrap(notify) : () => undefined,
|
||||
[bootstrapDirectory, childStores],
|
||||
),
|
||||
React.useCallback(
|
||||
() => bootstrapDirectory ? childStores.getBootstrapState(bootstrapDirectory) : undefined,
|
||||
[bootstrapDirectory, childStores],
|
||||
),
|
||||
React.useCallback(() => undefined, []),
|
||||
);
|
||||
const maxVisible = hideDirectoryControls ? 10 : 5;
|
||||
const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible);
|
||||
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
|
||||
@@ -409,10 +394,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}, [sourceGroupNodes]);
|
||||
|
||||
const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map((folder) => {
|
||||
const nodes = folder.sessionIds
|
||||
.map((sid) => nodeBySessionId.get(sid))
|
||||
.filter((n): n is SessionNode => Boolean(n))
|
||||
.sort(compareSessionNodes);
|
||||
const nodes = selectFolderRootNodes(folder.sessionIds, nodeBySessionId).sort(compareSessionNodes);
|
||||
return { folder, nodes };
|
||||
}), [scopeFolders, nodeBySessionId, compareSessionNodes]);
|
||||
|
||||
@@ -472,21 +454,12 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
|
||||
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
|
||||
|
||||
// Precompute per-row "subtree contains active session" and "subtree contains
|
||||
// editing session" lookups once per render. The previous design walked the
|
||||
// Precompute the per-row "subtree contains editing session" lookup once per
|
||||
// render. The previous design walked the
|
||||
// node tree inside SessionNodeItem.areEqual for every row, which is O(M^2)
|
||||
// across the whole sidebar. These sets let areEqual answer with a single
|
||||
// Set.has lookup, so the cost is O(M) once per SessionGroupSection render.
|
||||
const renderContextForGroup = 'project' as const;
|
||||
const subtreeContainsActive = React.useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
collectSubtreeContainingId(sourceGroupNodes, currentSessionId, set);
|
||||
allFoldersForGroup.forEach(({ nodes }) => {
|
||||
collectSubtreeContainingId(nodes, currentSessionId, set);
|
||||
});
|
||||
return set;
|
||||
}, [sourceGroupNodes, allFoldersForGroup, currentSessionId]);
|
||||
|
||||
const subtreeContainsEditing = React.useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
collectSubtreeContainingId(sourceGroupNodes, editingId, set);
|
||||
@@ -539,11 +512,10 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}, [nodeStructureKeyBySourceNode, nodeStructureKeyByFolderNode]);
|
||||
|
||||
const childRenderExtrasFor = React.useCallback((child: SessionNode) => ({
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(child),
|
||||
}), [subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]);
|
||||
}), [subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]);
|
||||
|
||||
const totalSessions = ungroupedSessions.length;
|
||||
const visibleSessions = group.isArchivedBucket
|
||||
@@ -554,21 +526,14 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
const remainingCount = totalSessions - visibleSessions.length;
|
||||
const canShowLess = !group.isArchivedBucket && !hasSessionSearchQuery && totalSessions > maxVisible && remainingCount === 0;
|
||||
|
||||
// Virtualize large groups. Archived buckets grow into the hundreds or
|
||||
// thousands of rows; active/worktree groups can also hit 80+ sessions
|
||||
// when a single worktree accumulates over time. Both paths share the
|
||||
// same virtua Virtualizer; the threshold just controls when we mount
|
||||
// it. The visible behavior is identical because virtua uses overscan
|
||||
// (8) for the buffer zone. All hooks below MUST stay above the
|
||||
// search-empty early-return so they fire in the same order every
|
||||
// render — rules-of-hooks.
|
||||
const shouldVirtualizeArchived = group.isArchivedBucket === true
|
||||
// Virtualize archived buckets, which can grow into the thousands. Active
|
||||
// groups retain normal flow because their incremental Show more control and
|
||||
// the shared ancestor scroller cannot expose an unmounted virtual tail.
|
||||
// Hooks below MUST stay above the search-empty early-return so they fire in
|
||||
// the same order every render — rules-of-hooks.
|
||||
const shouldVirtualize = group.isArchivedBucket === true
|
||||
&& !hasSessionSearchQuery
|
||||
&& visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD;
|
||||
const shouldVirtualizeActive = group.isArchivedBucket !== true
|
||||
&& !hasSessionSearchQuery
|
||||
&& visibleSessions.length >= ACTIVE_VIRTUALIZE_THRESHOLD;
|
||||
const shouldVirtualize = shouldVirtualizeArchived || shouldVirtualizeActive;
|
||||
|
||||
// Check if any parent node is expanded - expanded parents render their
|
||||
// children inline, making them much taller than the fixed estimate.
|
||||
@@ -864,7 +829,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
renderSessionNode={renderSessionNode}
|
||||
getRenderExtras={resolveNodeStructureKey
|
||||
? (node) => ({
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
@@ -941,7 +905,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
// meanwhile keeps the container's height real so the scroller
|
||||
// never collapses/clamps during the flip.
|
||||
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
@@ -980,7 +943,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}}
|
||||
>
|
||||
{renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
@@ -994,7 +956,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
</div>
|
||||
) : (
|
||||
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
@@ -1005,6 +966,31 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
<div className="py-1 text-left typography-micro text-muted-foreground">
|
||||
{group.isArchivedBucket
|
||||
? t('sessions.sidebar.group.empty.noArchivedSessions')
|
||||
: bootstrapState === 'queued' || bootstrapState === 'running'
|
||||
? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Icon name="loader-4" className="size-3 animate-spin" />
|
||||
{t('sessions.sidebar.group.empty.loadingSessions')}
|
||||
</span>
|
||||
)
|
||||
: bootstrapState === 'failed' && bootstrapDirectory
|
||||
? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{t('sessions.sidebar.group.empty.loadFailed')}
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground hover:underline"
|
||||
onClick={() => childStores.requestBootstrap({
|
||||
directory: bootstrapDirectory,
|
||||
priority: isCollapsed ? 'visible' : 'expanded',
|
||||
reason: group.isMain ? 'project-expanded' : 'worktree-expanded',
|
||||
force: true,
|
||||
})}
|
||||
>
|
||||
{t('sessions.sidebar.group.empty.retry')}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
: t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { isSessionPinned, type SessionPinnedTarget } from '@/stores/useSessionPinnedStore';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
||||
import type { ChildSessionExport } from '@/lib/exportSession';
|
||||
@@ -25,7 +26,7 @@ import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSession
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
||||
import { DraggableSessionRow } from './sessionFolderDnd';
|
||||
import { nodeContainsSessionId } from './sessionNodeItemUtils';
|
||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils';
|
||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from './types';
|
||||
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
@@ -43,6 +44,8 @@ import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog
|
||||
import { FusionIcon } from '@/components/icons/FusionIcon';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type Folder = { id: string; name: string; sessionIds: string[] };
|
||||
|
||||
@@ -57,7 +60,6 @@ type Props = {
|
||||
groupDirectory?: string | null;
|
||||
projectId?: string | null;
|
||||
archivedBucket?: boolean;
|
||||
currentSessionId: string | null;
|
||||
pinnedSessionIds: Set<string>;
|
||||
expandedParents: Set<string>;
|
||||
hasSessionSearchQuery: boolean;
|
||||
@@ -70,9 +72,9 @@ type Props = {
|
||||
handleSaveEdit: (titleOverride?: string) => void;
|
||||
handleCancelEdit: () => void;
|
||||
toggleParent: (expansionKey: string) => void;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
||||
handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void;
|
||||
togglePinnedSession: (sessionId: string) => void;
|
||||
togglePinnedSession: (target: SessionPinnedTarget) => void;
|
||||
handleShareSession: (session: Session) => void;
|
||||
copiedSessionId: string | null;
|
||||
handleCopyShareUrl: (url: string, sessionId: string) => void;
|
||||
@@ -101,16 +103,9 @@ type Props = {
|
||||
) => React.ReactNode;
|
||||
secondaryMeta?: SecondaryMeta | null;
|
||||
renderContext?: 'project' | 'recent';
|
||||
/**
|
||||
* Precomputed set of session IDs whose subtree contains the current
|
||||
* active session. Computed once per SessionGroupSection render (when
|
||||
* currentSessionId changes) instead of being recomputed in every row's
|
||||
* React.memo comparator.
|
||||
*/
|
||||
subtreeContainsActive: Set<string>;
|
||||
/**
|
||||
* Precomputed set of session IDs whose subtree contains the session
|
||||
* currently being edited. Same rationale as subtreeContainsActive.
|
||||
* currently being edited. Precomputed once per group render.
|
||||
*/
|
||||
subtreeContainsEditing: Set<string>;
|
||||
/**
|
||||
@@ -132,15 +127,52 @@ type Props = {
|
||||
* to fetch the right key for each child it produces.
|
||||
*/
|
||||
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
|
||||
/**
|
||||
* Batched index of live session objects keyed by id. The previous
|
||||
* implementation called `useSession(session.id)` per row, which used
|
||||
* `findLiveSession` to iterate every child-store on every SSE event.
|
||||
* With M visible rows that's M×child-stores per event; the batched
|
||||
* map turns it into a single Map.get per row. The parent falls back
|
||||
* to `useSession` only when this map returns undefined.
|
||||
*/
|
||||
liveSessionById: Map<string, Session>;
|
||||
};
|
||||
|
||||
const cancelScrollAnchorByContainer = new WeakMap<HTMLElement, () => void>();
|
||||
|
||||
const holdSessionRowPosition = (target: HTMLElement): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const row = target.closest<HTMLElement>('[data-session-row]');
|
||||
const container = row?.closest<HTMLElement>('.overlay-scrollbar-container');
|
||||
if (!row || !container) return;
|
||||
|
||||
cancelScrollAnchorByContainer.get(container)?.();
|
||||
|
||||
const initialTop = row.getBoundingClientRect().top;
|
||||
let remainingFrames = 3;
|
||||
let cancelled = false;
|
||||
let frameId: number | null = null;
|
||||
const cancel = () => {
|
||||
cancelled = true;
|
||||
if (frameId !== null) window.cancelAnimationFrame(frameId);
|
||||
frameId = null;
|
||||
cancelScrollAnchorByContainer.delete(container);
|
||||
container.removeEventListener('wheel', cancel);
|
||||
container.removeEventListener('touchstart', cancel);
|
||||
};
|
||||
const restore = () => {
|
||||
if (cancelled || !row.isConnected || !container.isConnected) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
const delta = row.getBoundingClientRect().top - initialTop;
|
||||
if (Math.abs(delta) > 0.5) {
|
||||
container.scrollTop += delta;
|
||||
streamPerfCount('ui.sidebar.selection_scroll_anchor_adjustment');
|
||||
}
|
||||
remainingFrames -= 1;
|
||||
if (remainingFrames <= 0) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
frameId = window.requestAnimationFrame(restore);
|
||||
};
|
||||
|
||||
container.addEventListener('wheel', cancel, { passive: true });
|
||||
container.addEventListener('touchstart', cancel, { passive: true });
|
||||
cancelScrollAnchorByContainer.set(container, cancel);
|
||||
frameId = window.requestAnimationFrame(restore);
|
||||
};
|
||||
|
||||
type QuickSessionActionProps = {
|
||||
@@ -206,6 +238,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
|
||||
});
|
||||
|
||||
function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
streamPerfCount('ui.sidebar_session_node.render');
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
node,
|
||||
@@ -213,7 +246,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
groupDirectory,
|
||||
projectId,
|
||||
archivedBucket = false,
|
||||
currentSessionId,
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
hasSessionSearchQuery,
|
||||
@@ -248,11 +280,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
renderSessionNode,
|
||||
secondaryMeta,
|
||||
renderContext = 'project',
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
childRenderExtrasFor,
|
||||
liveSessionById,
|
||||
} = props;
|
||||
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
|
||||
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
|
||||
@@ -307,25 +337,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const session = node.session;
|
||||
// Batched live-session lookup. `liveSessionById` is built once per
|
||||
// Sidebar render from the same `useAllLiveSessions` selector that
|
||||
// `useSession` would have iterated per child-store, so a Map.get
|
||||
// here is equivalent in observed state but O(1) per row instead of
|
||||
// O(child-stores). Falls back to the row session when the live map
|
||||
// hasn't seen this id yet (sub-render latency between when a session
|
||||
// is created and when the SSE-driven aggregate picks it up).
|
||||
const resolvedSession = liveSessionById.get(session.id) ?? session;
|
||||
const resolvedSession = session;
|
||||
const isActive = useSessionUIStore((state) => state.currentSessionId === session.id);
|
||||
|
||||
const sessionDirectory =
|
||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(groupDirectory ?? null);
|
||||
// Archived rows are historical and never need live state, yet they point at
|
||||
// dozens of (often deleted) worktrees — bootstrapping each from the sidebar
|
||||
// triggers a pointless session-list fetch + 6×2s empty-retry storm on startup.
|
||||
// Skip bootstrap for archived rows; the store ref is only read on-demand via
|
||||
// getState() in the export handlers (never subscribed). Active rows keep
|
||||
// bootstrapping so live cross-directory session/status still aggregates.
|
||||
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: !archivedBucket });
|
||||
// Directory bootstrap is scheduled once at sidebar level. A row only needs
|
||||
// the lightweight store reference for scoped state and export actions.
|
||||
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: false });
|
||||
const sync = useSync();
|
||||
|
||||
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
|
||||
@@ -368,7 +388,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
);
|
||||
const sessionStatus = useGlobalSessionStatus(session.id);
|
||||
const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id);
|
||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false });
|
||||
const sessionGoal = getSessionGoal(resolvedSession);
|
||||
const sessionGoalGlyph = sessionGoal ? (
|
||||
<span
|
||||
@@ -379,10 +399,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<Icon name="target" className="h-3 w-3" style={{ color: sessionGoalStatusColor[sessionGoal.status] }} />
|
||||
</span>
|
||||
) : null;
|
||||
const isActive = currentSessionId === session.id;
|
||||
const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled');
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isPinnedSession = pinnedSessionIds.has(session.id);
|
||||
const isPinnedSession = isSessionPinned(pinnedSessionIds, sessionDirectory, session.id);
|
||||
// Per-render-context expansion key: the same session can appear in both
|
||||
// the project's root and the "Recent" list, and expanding one should not
|
||||
// expand the other. Matches the format of menuInstanceKey.
|
||||
@@ -409,7 +428,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
let skipped = 0;
|
||||
for (const child of children) {
|
||||
try {
|
||||
await sync.ensureSessionRenderable(child.session.id);
|
||||
await sync.ensureSessionRenderable(child.session.id, false, sessionDirectory ?? undefined);
|
||||
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
|
||||
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
|
||||
const childAgent = (child.session as Session & { agent?: string }).agent;
|
||||
@@ -426,7 +445,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
}
|
||||
}
|
||||
return { children: results, skipped };
|
||||
}, [collectNodeDescendantIds, directoryStore, sync, t]);
|
||||
}, [collectNodeDescendantIds, directoryStore, sessionDirectory, sync, t]);
|
||||
|
||||
const showSkippedSubtasksWarning = React.useCallback((count: number) => {
|
||||
if (count <= 0) return;
|
||||
@@ -441,7 +460,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
return;
|
||||
}
|
||||
|
||||
await sync.ensureSessionRenderable(session.id);
|
||||
await sync.ensureSessionRenderable(session.id, false, sessionDirectory);
|
||||
|
||||
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
|
||||
if (records.length === 0) {
|
||||
@@ -775,7 +794,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node));
|
||||
return;
|
||||
}
|
||||
handleSessionSelect(session.id, sessionDirectory, projectId);
|
||||
if (event?.currentTarget) holdSessionRowPosition(event.currentTarget);
|
||||
handleSessionSelect(session.id, sessionDirectory);
|
||||
};
|
||||
|
||||
// The selection/active highlight covers the WHOLE row box (gutter, edge
|
||||
@@ -832,7 +852,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<Icon name="pencil-ai" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.rename')}
|
||||
</Item>
|
||||
<Item onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
|
||||
<Item onClick={() => sessionDirectory && togglePinnedSession({ directory: sessionDirectory, sessionId: session.id })} className="[&>svg]:mr-1">
|
||||
{isPinnedSession ? <Icon name="unpin" className="mr-1 h-4 w-4" /> : <Icon name="pushpin" className="mr-1 h-4 w-4" />}
|
||||
{isPinnedSession ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')}
|
||||
</Item>
|
||||
@@ -1267,7 +1287,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor
|
||||
? childRenderExtrasFor(child)
|
||||
: {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: '',
|
||||
@@ -1387,26 +1406,6 @@ const hasSetMembershipChangeInNode = (
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasResolvedSessionChangeInNode = (
|
||||
prevNode: SessionNode,
|
||||
nextNode: SessionNode,
|
||||
prevLiveSessionById: Map<string, Session>,
|
||||
nextLiveSessionById: Map<string, Session>,
|
||||
): boolean => {
|
||||
if (prevNode.session.id !== nextNode.session.id) return true;
|
||||
const sessionId = prevNode.session.id;
|
||||
if ((prevLiveSessionById.get(sessionId) ?? prevNode.session) !== (nextLiveSessionById.get(sessionId) ?? nextNode.session)) {
|
||||
return true;
|
||||
}
|
||||
if (prevNode.children.length !== nextNode.children.length) return true;
|
||||
for (let i = 0; i < prevNode.children.length; i += 1) {
|
||||
if (hasResolvedSessionChangeInNode(prevNode.children[i], nextNode.children[i], prevLiveSessionById, nextLiveSessionById)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
|
||||
if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false;
|
||||
const prevBucketTag = prev.archivedBucket ? 'archived' : 'active';
|
||||
@@ -1428,6 +1427,7 @@ const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
|
||||
|
||||
const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
if (prev.node.session.id !== next.node.session.id) return false;
|
||||
if (prev.node.session !== next.node.session) return false;
|
||||
if (prev.depth !== next.depth) return false;
|
||||
if (prev.groupDirectory !== next.groupDirectory) return false;
|
||||
if (prev.projectId !== next.projectId) return false;
|
||||
@@ -1442,13 +1442,15 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false;
|
||||
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false;
|
||||
|
||||
if (prev.liveSessionById !== next.liveSessionById
|
||||
&& hasResolvedSessionChangeInNode(prev.node, next.node, prev.liveSessionById, next.liveSessionById)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.pinnedSessionIds !== next.pinnedSessionIds
|
||||
&& hasSetMembershipChangeInNode(prev.node, next.node, prev.pinnedSessionIds, next.pinnedSessionIds, (node) => node.session.id)) {
|
||||
&& nodeHasPinnedMembershipChange(
|
||||
prev.node,
|
||||
next.node,
|
||||
prev.pinnedSessionIds,
|
||||
next.pinnedSessionIds,
|
||||
prev.groupDirectory,
|
||||
next.groupDirectory,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1456,14 +1458,6 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.currentSessionId !== next.currentSessionId
|
||||
&& (
|
||||
subtreeContainsSession(prev, prev.currentSessionId, prev.subtreeContainsActive)
|
||||
|| subtreeContainsSession(next, next.currentSessionId, next.subtreeContainsActive)
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.editingId !== next.editingId
|
||||
&& (
|
||||
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
|
||||
|
||||
@@ -38,9 +38,9 @@ type Props = {
|
||||
renderContext?: 'project' | 'recent',
|
||||
renderExtras?: SessionNodeRenderExtras,
|
||||
) => React.ReactNode;
|
||||
currentSessionId: string | null;
|
||||
editingId: string | null;
|
||||
openSidebarMenuKey: string | null;
|
||||
expansionState?: ReadonlySet<string>;
|
||||
variant?: 'section' | 'flat';
|
||||
initialVisibleCount?: number;
|
||||
batchSize?: number;
|
||||
@@ -50,16 +50,16 @@ type RenderExtras = SessionNodeRenderExtras;
|
||||
|
||||
const MAX_VISIBLE_RECENT_SESSIONS = 7;
|
||||
|
||||
export function SidebarActivitySections({
|
||||
sections,
|
||||
renderSessionNode,
|
||||
currentSessionId,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
variant = 'section',
|
||||
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
}: Props): React.ReactNode {
|
||||
export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
const {
|
||||
sections,
|
||||
renderSessionNode,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
variant = 'section',
|
||||
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
||||
@@ -101,8 +101,6 @@ export function SidebarActivitySections({
|
||||
}, [batchSize]);
|
||||
|
||||
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
|
||||
const subtreeContainsActive = new Set<string>();
|
||||
collectSubtreeContainingId(nodes, currentSessionId, subtreeContainsActive);
|
||||
const subtreeContainsEditing = new Set<string>();
|
||||
collectSubtreeContainingId(nodes, editingId, subtreeContainsEditing);
|
||||
const menuOpenSessionId = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, 'recent', false);
|
||||
@@ -114,7 +112,6 @@ export function SidebarActivitySections({
|
||||
nodes.forEach(visit);
|
||||
|
||||
const childRenderExtrasFor = (child: SessionNode): RenderExtras => ({
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: nodeStructureKeyByNode.get(child) ?? '',
|
||||
@@ -122,13 +119,12 @@ export function SidebarActivitySections({
|
||||
});
|
||||
|
||||
return (node: SessionNode): RenderExtras => ({
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
|
||||
childRenderExtrasFor,
|
||||
});
|
||||
}, [currentSessionId, editingId, openSidebarMenuKey]);
|
||||
}, [editingId, openSidebarMenuKey]);
|
||||
|
||||
const visibleSections = sections.filter((section) => section.items.length > 0);
|
||||
if (visibleSections.length === 0) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { formatProjectLabel } from './utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
|
||||
type ProjectSection = {
|
||||
project: {
|
||||
@@ -78,7 +79,8 @@ type Props = {
|
||||
isInlineEditing: boolean;
|
||||
};
|
||||
|
||||
export function SidebarProjectsList(props: Props): React.ReactNode {
|
||||
function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
streamPerfCount('ui.sidebar_projects_list.render');
|
||||
const { t } = useI18n();
|
||||
const projectSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
@@ -311,3 +313,5 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
export const SidebarProjectsList = React.memo(SidebarProjectsListComponent);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
|
||||
type AuthoritativeSessionIdentity = {
|
||||
directory: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export const buildAuthoritativeSessionIdentityMap = (
|
||||
sessions: Session[],
|
||||
): Map<string, AuthoritativeSessionIdentity> => {
|
||||
const identities = new Map<string, AuthoritativeSessionIdentity>();
|
||||
for (const session of sessions) {
|
||||
const directory = resolveGlobalSessionDirectory(session);
|
||||
if (!directory) continue;
|
||||
identities.set(session.id, { directory, sessionId: session.id });
|
||||
}
|
||||
return identities;
|
||||
};
|
||||
|
||||
export const findRemovedAuthoritativeSessions = (
|
||||
previous: ReadonlyMap<string, AuthoritativeSessionIdentity> | null,
|
||||
current: ReadonlyMap<string, AuthoritativeSessionIdentity>,
|
||||
): AuthoritativeSessionIdentity[] => {
|
||||
if (!previous) return [];
|
||||
const removed: AuthoritativeSessionIdentity[] = [];
|
||||
previous.forEach((identity, key) => {
|
||||
if (!current.has(key)) removed.push(identity);
|
||||
});
|
||||
return removed;
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
export const prunePinnedSessionIds = (
|
||||
sessions: Array<Pick<Session, 'id'>>,
|
||||
pinnedSessionIds: Set<string>,
|
||||
): Set<string> => {
|
||||
const existingSessionIds = new Set(sessions.map((session) => session.id));
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
|
||||
pinnedSessionIds.forEach((id) => {
|
||||
if (existingSessionIds.has(id)) {
|
||||
next.add(id);
|
||||
return;
|
||||
}
|
||||
changed = true;
|
||||
});
|
||||
|
||||
return changed ? next : pinnedSessionIds;
|
||||
};
|
||||
@@ -17,6 +17,7 @@ type FolderEntry = {
|
||||
};
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
normalizedProjects: ProjectForArchivedFolders[];
|
||||
ownership: SessionOwnershipIndex;
|
||||
isSessionsLoading: boolean;
|
||||
@@ -26,12 +27,12 @@ type Args = {
|
||||
foldersMap: Record<string, FolderEntry[]>;
|
||||
createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry;
|
||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
||||
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>) => void;
|
||||
};
|
||||
|
||||
export const useArchivedAutoFolders = (args: Args): void => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
enabled = true,
|
||||
ownership,
|
||||
isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
@@ -40,11 +41,10 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
cleanupSessions,
|
||||
} = args;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
|
||||
if (!enabled || isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,8 +54,6 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
||||
}
|
||||
const scopeKey = getArchivedScopeKey(project.normalizedPath);
|
||||
const projectArchivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? [];
|
||||
const sessionIds = new Set(projectArchivedSessions.map((session) => session.id));
|
||||
|
||||
const existingFolders = foldersMap[scopeKey] ?? [];
|
||||
const folderByName = new Map(existingFolders.map((folder) => [folder.name.toLowerCase(), folder]));
|
||||
|
||||
@@ -72,11 +70,10 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
||||
addSessionToFolder(scopeKey, folder.id, session.id);
|
||||
}
|
||||
});
|
||||
|
||||
cleanupSessions(scopeKey, sessionIds);
|
||||
});
|
||||
}, [
|
||||
normalizedProjects,
|
||||
enabled,
|
||||
ownership,
|
||||
isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
@@ -85,6 +82,5 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
cleanupSessions,
|
||||
]);
|
||||
};
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import {
|
||||
buildAuthoritativeSessionIdentityMap,
|
||||
findRemovedAuthoritativeSessions,
|
||||
} from '../authoritativeSessionCleanup';
|
||||
|
||||
const session = (id: string, directory = '/repo'): Session => ({ id, directory }) as Session;
|
||||
|
||||
describe('authoritative session cleanup', () => {
|
||||
test('does not infer deletion from the first authoritative startup snapshot', () => {
|
||||
const current = buildAuthoritativeSessionIdentityMap([]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(null, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('finds sessions omitted after an established authoritative baseline', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([
|
||||
session('deleted'),
|
||||
session('retained'),
|
||||
]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([session('retained')]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([
|
||||
{ directory: '/repo', sessionId: 'deleted' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('treats archive membership as retained authority', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([session('archived')]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([
|
||||
{ ...session('archived'), time: { archived: 10 } } as Session,
|
||||
]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not treat a directory move as session deletion', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-a')]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-b')]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { cleanupPersistedSessionState } from '@/sync/session-deletion-cleanup';
|
||||
import {
|
||||
buildAuthoritativeSessionIdentityMap,
|
||||
findRemovedAuthoritativeSessions,
|
||||
} from '../authoritativeSessionCleanup';
|
||||
|
||||
export const useAuthoritativeSessionCleanup = (args: {
|
||||
enabled?: boolean;
|
||||
hasAuthoritativeGlobalSessions: boolean;
|
||||
sessions: Session[];
|
||||
}): void => {
|
||||
const { enabled = true, hasAuthoritativeGlobalSessions, sessions } = args;
|
||||
const baselineRef = React.useRef<{
|
||||
runtimeKey: string;
|
||||
identities: ReturnType<typeof buildAuthoritativeSessionIdentityMap>;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !hasAuthoritativeGlobalSessions) return;
|
||||
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const current = buildAuthoritativeSessionIdentityMap(sessions);
|
||||
const previous = baselineRef.current?.runtimeKey === runtimeKey
|
||||
? baselineRef.current.identities
|
||||
: null;
|
||||
|
||||
for (const identity of findRemovedAuthoritativeSessions(previous, current)) {
|
||||
cleanupPersistedSessionState({ runtimeKey, ...identity });
|
||||
}
|
||||
baselineRef.current = { runtimeKey, identities: current };
|
||||
}, [enabled, hasAuthoritativeGlobalSessions, sessions]);
|
||||
};
|
||||
@@ -5,8 +5,10 @@ import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
|
||||
type Project = { id: string; path: string; normalizedPath: string };
|
||||
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
normalizedProjects: Project[];
|
||||
gitRepoStatus: Map<string, { isGitRepo: boolean | null; branch: string | null }>;
|
||||
setProjectRepoStatus: React.Dispatch<React.SetStateAction<Map<string, boolean | null>>>;
|
||||
@@ -16,6 +18,7 @@ type Args = {
|
||||
export const useProjectRepoStatus = (args: Args): void => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
enabled = true,
|
||||
gitRepoStatus,
|
||||
setProjectRepoStatus,
|
||||
setProjectRootBranches,
|
||||
@@ -26,7 +29,7 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
|
||||
// Derive repo status from centralized Git store
|
||||
React.useEffect(() => {
|
||||
if (!git || normalizedProjects.length === 0) {
|
||||
if (!enabled || !git || normalizedProjects.length === 0) {
|
||||
setProjectRepoStatus(new Map());
|
||||
return;
|
||||
}
|
||||
@@ -35,16 +38,17 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
normalizedProjects.forEach((project) => {
|
||||
void ensureStatus(project.normalizedPath, git);
|
||||
});
|
||||
}, [normalizedProjects, git, ensureStatus, setProjectRepoStatus]);
|
||||
}, [enabled, normalizedProjects, git, ensureStatus, setProjectRepoStatus]);
|
||||
|
||||
// Read isGitRepo from the store-populated state
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const next = new Map<string, boolean | null>();
|
||||
normalizedProjects.forEach((project) => {
|
||||
next.set(project.id, gitRepoStatus.get(project.normalizedPath)?.isGitRepo ?? null);
|
||||
});
|
||||
setProjectRepoStatus(next);
|
||||
}, [normalizedProjects, gitRepoStatus, setProjectRepoStatus]);
|
||||
}, [enabled, normalizedProjects, gitRepoStatus, setProjectRepoStatus]);
|
||||
|
||||
const projectGitBranchesKey = React.useMemo(() => {
|
||||
return normalizedProjects
|
||||
@@ -69,9 +73,9 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
// background updates and only re-resolve on cold start or actual
|
||||
// branch changes (those still invalidate via the input-key check).
|
||||
const rootBranchCacheRef = React.useRef<Map<string, { branch: string; at: number }>>(new Map());
|
||||
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
|
||||
// Debounce so the initial burst of per-project `ensureStatus` updates
|
||||
@@ -164,9 +168,5 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// ROOT_BRANCH_TTL_MS is a module-level constant; intentionally not
|
||||
// in the deps array since it never changes during the component
|
||||
// lifetime.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
|
||||
}, [enabled, normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SessionGroup, SessionNode } from '../types';
|
||||
import { normalizePath } from '../utils';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type ProjectSection = {
|
||||
project: { id: string; normalizedPath: string };
|
||||
@@ -16,7 +17,7 @@ type Args = {
|
||||
activeSessionByProject: Map<string, string>;
|
||||
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
||||
currentSessionId: string | null;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
||||
newSessionDraftOpen: boolean;
|
||||
mobileVariant: boolean;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
@@ -148,7 +149,7 @@ export const useProjectSessionSelection = (args: Args): void => {
|
||||
return;
|
||||
}
|
||||
const targetDirectory = projectMap.get(targetSessionId)?.directory ?? null;
|
||||
handleSessionSelect(targetSessionId, targetDirectory, activeProjectId);
|
||||
handleSessionSelect(targetSessionId, targetDirectory);
|
||||
}, [
|
||||
activeProjectId,
|
||||
activeSessionByProject,
|
||||
@@ -183,3 +184,34 @@ export const useProjectSessionSelection = (args: Args): void => {
|
||||
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
|
||||
|
||||
};
|
||||
|
||||
type ProjectSessionSelectionEffectProps = Omit<
|
||||
Args,
|
||||
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen'
|
||||
> & {
|
||||
initialActiveSessionByProject: Map<string, string>;
|
||||
persistActiveSessionByProject: (value: Map<string, string>) => void;
|
||||
};
|
||||
|
||||
export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffectProps> = ({
|
||||
initialActiveSessionByProject,
|
||||
persistActiveSessionByProject,
|
||||
...args
|
||||
}) => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const [activeSessionByProject, setActiveSessionByProject] = React.useState(
|
||||
() => new Map(initialActiveSessionByProject),
|
||||
);
|
||||
useProjectSessionSelection({
|
||||
...args,
|
||||
activeSessionByProject,
|
||||
setActiveSessionByProject,
|
||||
currentSessionId,
|
||||
newSessionDraftOpen,
|
||||
});
|
||||
React.useEffect(() => {
|
||||
persistActiveSessionByProject(activeSessionByProject);
|
||||
}, [activeSessionByProject, persistActiveSessionByProject]);
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@ import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { streamPerfMark } from '@/stores/utils/streamDebug';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type DeleteSessionConfirmSetter = React.Dispatch<React.SetStateAction<{
|
||||
session: Session;
|
||||
@@ -20,9 +22,6 @@ type DeleteSessionSource = {
|
||||
};
|
||||
|
||||
type Args = {
|
||||
activeProjectId: string | null;
|
||||
currentDirectory: string | null;
|
||||
currentSessionId: string | null;
|
||||
mobileVariant: boolean;
|
||||
allowReselect: boolean;
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
@@ -30,8 +29,6 @@ type Args = {
|
||||
sessionSearchQuery: string;
|
||||
setSessionSearchQuery: (value: string) => void;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setDirectory: (directory: string, options?: { showOverlay?: boolean }) => void;
|
||||
setActiveMainTab: (tab: MainTab) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
setCurrentSession: (sessionId: string | null, directoryHint?: string | null) => void;
|
||||
@@ -66,7 +63,8 @@ export const useSessionActions = (args: Args) => {
|
||||
}, []);
|
||||
|
||||
const handleSessionSelect = React.useCallback(
|
||||
(sessionId: string, sessionDirectory?: string | null, projectId?: string | null) => {
|
||||
(sessionId: string, sessionDirectory?: string | null) => {
|
||||
streamPerfMark('navigation.session_select');
|
||||
const resetSessionSearch = () => {
|
||||
if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) {
|
||||
return;
|
||||
@@ -75,26 +73,19 @@ export const useSessionActions = (args: Args) => {
|
||||
args.setIsSessionSearchOpen(false);
|
||||
};
|
||||
|
||||
if (projectId && projectId !== args.activeProjectId) {
|
||||
args.setActiveProjectIdOnly(projectId);
|
||||
}
|
||||
|
||||
if (sessionDirectory && sessionDirectory !== args.currentDirectory) {
|
||||
args.setDirectory(sessionDirectory, { showOverlay: false });
|
||||
}
|
||||
|
||||
if (args.mobileVariant) {
|
||||
args.setActiveMainTab('chat');
|
||||
args.setSessionSwitcherOpen(false);
|
||||
}
|
||||
|
||||
if (sessionId === args.currentSessionId) {
|
||||
if (sessionId === useSessionUIStore.getState().currentSessionId) {
|
||||
if (args.allowReselect) {
|
||||
args.onSessionSelected?.(sessionId);
|
||||
}
|
||||
resetSessionSearch();
|
||||
return;
|
||||
}
|
||||
streamPerfMark('navigation.session_state_set');
|
||||
args.setCurrentSession(sessionId, sessionDirectory ?? null);
|
||||
args.onSessionSelected?.(sessionId);
|
||||
resetSessionSearch();
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import React from 'react';
|
||||
import { getArchivedScopeKey, normalizePath } from '../utils';
|
||||
import type { SessionOwnershipIndex } from '../sessionOwnership';
|
||||
|
||||
type WorktreeMeta = { path: string };
|
||||
|
||||
type NormalizedProject = {
|
||||
id: string;
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
isSessionsLoading: boolean;
|
||||
hasAuthoritativeGlobalSessions: boolean;
|
||||
isWorktreeTopologyLoading: boolean;
|
||||
normalizedProjects: NormalizedProject[];
|
||||
ownership: SessionOwnershipIndex;
|
||||
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
|
||||
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
|
||||
cleanupSessions: (scopeKey: string, validSessionIds: Set<string>) => void;
|
||||
};
|
||||
|
||||
export const useSessionFolderCleanup = (args: Args): void => {
|
||||
const {
|
||||
isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
normalizedProjects,
|
||||
ownership,
|
||||
availableWorktreesByProject,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
cleanupSessions,
|
||||
} = args;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ownership.bySessionId.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idsByScope = new Map<string, Set<string>>();
|
||||
ownership.sessionsByScope.forEach((sessionIds, scopeDirectory) => {
|
||||
idsByScope.set(scopeDirectory, new Set(sessionIds));
|
||||
});
|
||||
|
||||
normalizedProjects.forEach((project) => {
|
||||
if (unresolvedWorktreeProjectPaths.has(project.normalizedPath)) {
|
||||
return;
|
||||
}
|
||||
const scopeKey = getArchivedScopeKey(project.normalizedPath);
|
||||
const archivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? [];
|
||||
idsByScope.set(scopeKey, new Set(archivedSessions.map((session) => session.id)));
|
||||
if (!idsByScope.has(project.normalizedPath)) {
|
||||
idsByScope.set(project.normalizedPath, new Set());
|
||||
}
|
||||
for (const worktree of availableWorktreesByProject.get(project.normalizedPath) ?? []) {
|
||||
const worktreePath = normalizePath(worktree.path);
|
||||
if (worktreePath && !idsByScope.has(worktreePath)) {
|
||||
idsByScope.set(worktreePath, new Set());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
idsByScope.forEach((sessionIds, scopeKey) => {
|
||||
cleanupSessions(scopeKey, sessionIds);
|
||||
});
|
||||
}, [
|
||||
availableWorktreesByProject,
|
||||
cleanupSessions,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
isSessionsLoading,
|
||||
normalizedProjects,
|
||||
ownership,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
]);
|
||||
};
|
||||
@@ -10,120 +10,152 @@ const SESSION_PREFETCH_CONCURRENCY = 1;
|
||||
const SESSION_PREFETCH_PENDING_LIMIT = 6;
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
currentSessionId: string | null;
|
||||
sortedSessions: Session[];
|
||||
recentSessionIds?: string[];
|
||||
ensureSessionRenderable: (sessionId: string) => Promise<unknown>;
|
||||
recentSessions?: Session[];
|
||||
prefetchSession: (sessionId: string, directory: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], ensureSessionRenderable }: Args): void => {
|
||||
type PrefetchRequest = {
|
||||
sessionId: string;
|
||||
directory: string;
|
||||
generation: number;
|
||||
};
|
||||
|
||||
const sessionDirectory = (session: Session | null | undefined): string | null => {
|
||||
const directory = (session as (Session & { directory?: string | null }) | null | undefined)?.directory;
|
||||
return typeof directory === 'string' && directory.trim() ? directory : null;
|
||||
};
|
||||
|
||||
export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
|
||||
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
|
||||
const sessionPrefetchQueueRef = React.useRef<string[]>([]);
|
||||
const sessionPrefetchQueueRef = React.useRef<PrefetchRequest[]>([]);
|
||||
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
||||
const generationRef = React.useRef(0);
|
||||
const prefetchDisabled = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
|
||||
const requestKey = React.useCallback((request: Pick<PrefetchRequest, 'directory' | 'sessionId'>) => (
|
||||
`${request.directory}\n${request.sessionId}`
|
||||
), []);
|
||||
|
||||
const clearPendingPrefetches = React.useCallback(() => {
|
||||
generationRef.current += 1;
|
||||
sessionPrefetchQueueRef.current = [];
|
||||
sessionPrefetchTimersRef.current.forEach((timer) => window.clearTimeout(timer));
|
||||
sessionPrefetchTimersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const pumpSessionPrefetchQueue = React.useCallback(() => {
|
||||
if (prefetchDisabled || typeof window === 'undefined') {
|
||||
if (!enabled || prefetchDisabled || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
while (sessionPrefetchInFlightRef.current.size < SESSION_PREFETCH_CONCURRENCY && sessionPrefetchQueueRef.current.length > 0) {
|
||||
const nextSessionId = sessionPrefetchQueueRef.current.shift();
|
||||
if (!nextSessionId) {
|
||||
const request = sessionPrefetchQueueRef.current.shift();
|
||||
if (!request) {
|
||||
break;
|
||||
}
|
||||
if (request.generation !== generationRef.current) continue;
|
||||
|
||||
const state = useSessionUIStore.getState();
|
||||
if (state.currentSessionId === nextSessionId) {
|
||||
if (state.currentSessionId === request.sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the session is already renderable in the sync child store.
|
||||
if (getSyncSessionMaterializationStatus(nextSessionId).renderable) {
|
||||
if (getSyncSessionMaterializationStatus(request.sessionId, request.directory).renderable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sessionPrefetchInFlightRef.current.add(nextSessionId);
|
||||
void ensureSessionRenderable(nextSessionId)
|
||||
const key = requestKey(request);
|
||||
sessionPrefetchInFlightRef.current.add(key);
|
||||
void prefetchSession(request.sessionId, request.directory)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
sessionPrefetchInFlightRef.current.delete(nextSessionId);
|
||||
sessionPrefetchInFlightRef.current.delete(key);
|
||||
pumpSessionPrefetchQueue();
|
||||
});
|
||||
}
|
||||
}, [ensureSessionRenderable, prefetchDisabled]);
|
||||
}, [enabled, prefetchDisabled, prefetchSession, requestKey]);
|
||||
|
||||
const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => {
|
||||
if (prefetchDisabled || !sessionId || sessionId === currentSessionId || typeof window === 'undefined') {
|
||||
const scheduleSessionPrefetch = React.useCallback((session: Session | null | undefined) => {
|
||||
const sessionId = session?.id;
|
||||
const directory = sessionDirectory(session);
|
||||
if (!enabled || prefetchDisabled || !sessionId || !directory || sessionId === currentSessionId || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const request = { sessionId, directory, generation: generationRef.current };
|
||||
const key = requestKey(request);
|
||||
|
||||
// Already renderable in sync
|
||||
if (getSyncSessionMaterializationStatus(sessionId).renderable) {
|
||||
if (getSyncSessionMaterializationStatus(sessionId, directory).renderable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchInFlightRef.current.has(sessionId)) {
|
||||
if (sessionPrefetchInFlightRef.current.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchQueueRef.current.includes(sessionId)) {
|
||||
if (sessionPrefetchQueueRef.current.some((candidate) => requestKey(candidate) === key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchQueueRef.current.length >= SESSION_PREFETCH_PENDING_LIMIT) {
|
||||
sessionPrefetchQueueRef.current.shift();
|
||||
}
|
||||
|
||||
const existingTimer = sessionPrefetchTimersRef.current.get(sessionId);
|
||||
const existingTimer = sessionPrefetchTimersRef.current.get(key);
|
||||
if (existingTimer !== undefined) {
|
||||
window.clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
sessionPrefetchTimersRef.current.delete(sessionId);
|
||||
sessionPrefetchQueueRef.current.push(sessionId);
|
||||
sessionPrefetchTimersRef.current.delete(key);
|
||||
if (request.generation !== generationRef.current) return;
|
||||
const queue = sessionPrefetchQueueRef.current;
|
||||
if (queue.length >= SESSION_PREFETCH_PENDING_LIMIT) {
|
||||
queue.shift();
|
||||
}
|
||||
queue.push(request);
|
||||
pumpSessionPrefetchQueue();
|
||||
}, SESSION_PREFETCH_HOVER_DELAY_MS);
|
||||
sessionPrefetchTimersRef.current.set(sessionId, timer);
|
||||
}, [currentSessionId, prefetchDisabled, pumpSessionPrefetchQueue]);
|
||||
sessionPrefetchTimersRef.current.set(key, timer);
|
||||
}, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue, requestKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
clearPendingPrefetches();
|
||||
}, [clearPendingPrefetches, currentSessionId, enabled, prefetchDisabled]);
|
||||
|
||||
// Wait for the active session to finish loading before prefetching neighbors.
|
||||
// On rapid session switches the timer resets, so only the final session triggers prefetch.
|
||||
React.useEffect(() => {
|
||||
if (prefetchDisabled || !currentSessionId || sortedSessions.length === 0) {
|
||||
if (!enabled || prefetchDisabled || !currentSessionId || sortedSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
|
||||
if (currentIndex < 0) return;
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id);
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]);
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]);
|
||||
}, SESSION_PREFETCH_SETTLE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [currentSessionId, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
|
||||
}, [currentSessionId, enabled, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prefetchDisabled || !currentSessionId || recentSessionIds.length === 0) {
|
||||
if (!enabled || prefetchDisabled || !currentSessionId || recentSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
const currentIndex = recentSessionIds.indexOf(currentSessionId);
|
||||
const currentIndex = recentSessions.findIndex((session) => session.id === currentSessionId);
|
||||
if (currentIndex < 0) return;
|
||||
scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]);
|
||||
scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]);
|
||||
scheduleSessionPrefetch(recentSessions[currentIndex - 1]);
|
||||
scheduleSessionPrefetch(recentSessions[currentIndex + 1]);
|
||||
}, SESSION_PREFETCH_SETTLE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [currentSessionId, prefetchDisabled, recentSessionIds, scheduleSessionPrefetch]);
|
||||
}, [currentSessionId, enabled, prefetchDisabled, recentSessions, scheduleSessionPrefetch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const prefetchTimers = sessionPrefetchTimersRef.current;
|
||||
return () => {
|
||||
prefetchTimers.forEach((timer) => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
prefetchTimers.clear();
|
||||
sessionPrefetchQueueRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
React.useEffect(() => clearPendingPrefetches, [clearPendingPrefetches]);
|
||||
};
|
||||
|
||||
export const SessionPrefetchEffect: React.FC<Omit<Args, 'currentSessionId'>> = (args) => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
useSessionPrefetch({ ...args, currentSessionId });
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
isSessionSearchOpen: boolean;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
sessionSearchInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
@@ -8,13 +9,14 @@ type Args = {
|
||||
};
|
||||
|
||||
export const useSessionSearchEffects = ({
|
||||
enabled = true,
|
||||
isSessionSearchOpen,
|
||||
setIsSessionSearchOpen,
|
||||
sessionSearchInputRef,
|
||||
sessionSearchContainerRef,
|
||||
}: Args): void => {
|
||||
React.useEffect(() => {
|
||||
if (!isSessionSearchOpen || typeof window === 'undefined') {
|
||||
if (!enabled || !isSessionSearchOpen || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
@@ -22,10 +24,10 @@ export const useSessionSearchEffects = ({
|
||||
sessionSearchInputRef.current?.select();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [isSessionSearchOpen, sessionSearchInputRef]);
|
||||
}, [enabled, isSessionSearchOpen, sessionSearchInputRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isSessionSearchOpen || typeof document === 'undefined') {
|
||||
if (!enabled || !isSessionSearchOpen || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
@@ -38,5 +40,5 @@ export const useSessionSearchEffects = ({
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchContainerRef]);
|
||||
}, [enabled, isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchContainerRef]);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SessionGroup, SessionNode, GroupSearchData } from '../types';
|
||||
import { dedupeSessionsById, normalizePath } from '../utils';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { SessionFoldersMap } from '@/stores/useSessionFoldersStore';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
|
||||
type ProjectItem = {
|
||||
id: string;
|
||||
@@ -21,6 +22,19 @@ type ProjectSection = {
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
type ProjectSectionCacheEntry = {
|
||||
project: ProjectItem;
|
||||
activeSessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
availableWorktrees: WorktreeMetadata[];
|
||||
rootBranch: string | null;
|
||||
isRepo: boolean;
|
||||
buildGroupedSessions: Args['buildGroupedSessions'];
|
||||
section: ProjectSection;
|
||||
};
|
||||
|
||||
const EMPTY_WORKTREES: WorktreeMetadata[] = [];
|
||||
|
||||
type Args = {
|
||||
normalizedProjects: ProjectItem[];
|
||||
getSessionsForProject: (projectId: string) => Session[];
|
||||
@@ -59,26 +73,67 @@ export const useSessionSidebarSections = (args: Args) => {
|
||||
buildGroupSearchText,
|
||||
foldersMap,
|
||||
} = args;
|
||||
const projectSectionCacheRef = React.useRef<Map<string, ProjectSectionCacheEntry>>(new Map());
|
||||
|
||||
const projectSections = React.useMemo<ProjectSection[]>(() => {
|
||||
return normalizedProjects.map((project) => {
|
||||
const projectSessions = dedupeSessionsById([
|
||||
...getSessionsForProject(project.id),
|
||||
...getArchivedSessionsForProject(project.id),
|
||||
]);
|
||||
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
|
||||
const previousCache = projectSectionCacheRef.current;
|
||||
const nextCache = new Map<string, ProjectSectionCacheEntry>();
|
||||
let reusedSections = 0;
|
||||
let rebuiltSections = 0;
|
||||
const sameSessions = (left: Session[], right: Session[]): boolean => (
|
||||
left.length === right.length && left.every((session, index) => session === right[index])
|
||||
);
|
||||
|
||||
const sections = normalizedProjects.map((project) => {
|
||||
const activeSessions = getSessionsForProject(project.id);
|
||||
const archivedSessions = getArchivedSessionsForProject(project.id);
|
||||
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? EMPTY_WORKTREES;
|
||||
const isRepo = projectRepoStatus.has(project.id)
|
||||
? Boolean(projectRepoStatus.get(project.id))
|
||||
: lastRepoStatus;
|
||||
const rootBranch = projectRootBranches.get(project.id) ?? null;
|
||||
const cached = previousCache.get(project.id);
|
||||
if (
|
||||
cached
|
||||
&& cached.project === project
|
||||
&& sameSessions(cached.activeSessions, activeSessions)
|
||||
&& sameSessions(cached.archivedSessions, archivedSessions)
|
||||
&& cached.availableWorktrees === worktreesForProject
|
||||
&& cached.rootBranch === rootBranch
|
||||
&& cached.isRepo === isRepo
|
||||
&& cached.buildGroupedSessions === buildGroupedSessions
|
||||
) {
|
||||
reusedSections += 1;
|
||||
nextCache.set(project.id, cached);
|
||||
return cached.section;
|
||||
}
|
||||
|
||||
rebuiltSections += 1;
|
||||
const projectSessions = dedupeSessionsById([...activeSessions, ...archivedSessions]);
|
||||
const groups = buildGroupedSessions(
|
||||
projectSessions,
|
||||
project.normalizedPath,
|
||||
worktreesForProject,
|
||||
projectRootBranches.get(project.id) ?? null,
|
||||
rootBranch,
|
||||
isRepo,
|
||||
);
|
||||
return { project, groups };
|
||||
const section = { project, groups };
|
||||
nextCache.set(project.id, {
|
||||
project,
|
||||
activeSessions,
|
||||
archivedSessions,
|
||||
availableWorktrees: worktreesForProject,
|
||||
rootBranch,
|
||||
isRepo,
|
||||
buildGroupedSessions,
|
||||
section,
|
||||
});
|
||||
return section;
|
||||
});
|
||||
projectSectionCacheRef.current = nextCache;
|
||||
if (reusedSections > 0) streamPerfCount('ui.sidebar.project_section.reused', reusedSections);
|
||||
if (rebuiltSections > 0) streamPerfCount('ui.sidebar.project_section.rebuilt', rebuiltSections);
|
||||
return sections;
|
||||
}, [
|
||||
normalizedProjects,
|
||||
getSessionsForProject,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { prunePinnedSessionIds } from './pinnedSessionCleanup';
|
||||
|
||||
const makeSession = (id: string): Pick<Session, 'id'> => ({ id });
|
||||
|
||||
describe('prunePinnedSessionIds', () => {
|
||||
test('keeps pinned ids that still exist in the authoritative session list', () => {
|
||||
const sessions = [makeSession('visible-session'), makeSession('hidden-session')];
|
||||
const pinnedSessionIds = new Set(['hidden-session', 'missing-session']);
|
||||
|
||||
const next = prunePinnedSessionIds(sessions, pinnedSessionIds);
|
||||
|
||||
expect([...next]).toEqual(['hidden-session']);
|
||||
expect(next).not.toBe(pinnedSessionIds);
|
||||
});
|
||||
|
||||
test('returns the original set when nothing needs pruning', () => {
|
||||
const sessions = [makeSession('visible-session'), makeSession('hidden-session')];
|
||||
const pinnedSessionIds = new Set(['visible-session', 'hidden-session']);
|
||||
|
||||
const next = prunePinnedSessionIds(sessions, pinnedSessionIds);
|
||||
|
||||
expect(next).toBe(pinnedSessionIds);
|
||||
});
|
||||
});
|
||||
@@ -1,46 +1,24 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { prunePinnedSessionIds } from './pinnedSessionCleanup';
|
||||
|
||||
type SafeStorageLike = {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem?: (key: string) => void;
|
||||
};
|
||||
|
||||
type Keys = {
|
||||
sessionExpanded: string;
|
||||
// v1 key, still on disk for users upgrading from pre-per-context expansion.
|
||||
// When present, its bare-session-id entries are fanned out to all four
|
||||
// (project|recent) × (active|archived) context combinations and rewritten
|
||||
// under `sessionExpanded`. After migration the v1 key is removed.
|
||||
sessionExpandedLegacy: string;
|
||||
projectCollapse: string;
|
||||
sessionPinned: string;
|
||||
groupOrder: string;
|
||||
projectActiveSession: string;
|
||||
groupCollapse: string;
|
||||
};
|
||||
|
||||
const LEGACY_EXPANSION_CONTEXT_PREFIXES = [
|
||||
'project:active:',
|
||||
'project:archived:',
|
||||
'recent:active:',
|
||||
'recent:archived:',
|
||||
];
|
||||
|
||||
type Args = {
|
||||
isVSCode: boolean;
|
||||
hasAuthoritativeGlobalSessions: boolean;
|
||||
safeStorage: SafeStorageLike;
|
||||
keys: Keys;
|
||||
sessions: Session[];
|
||||
pinnedSessionIds: Set<string>;
|
||||
setPinnedSessionIds: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
groupOrderByProject: Map<string, string[]>;
|
||||
activeSessionByProject: Map<string, string>;
|
||||
collapsedGroups: Set<string>;
|
||||
setExpandedParents: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
setCollapsedProjects: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
@@ -49,13 +27,9 @@ type Args = {
|
||||
export const useSidebarPersistence = (args: Args) => {
|
||||
const {
|
||||
isVSCode,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
safeStorage,
|
||||
keys,
|
||||
sessions,
|
||||
setPinnedSessionIds,
|
||||
groupOrderByProject,
|
||||
activeSessionByProject,
|
||||
collapsedGroups,
|
||||
setExpandedParents,
|
||||
setCollapsedProjects,
|
||||
@@ -115,28 +89,6 @@ export const useSidebarPersistence = (args: Args) => {
|
||||
if (Array.isArray(parsed)) {
|
||||
setExpandedParents(new Set(parsed.filter((item) => typeof item === 'string')));
|
||||
}
|
||||
} else {
|
||||
// No v2 data — migrate from v1 (bare session ids) if present.
|
||||
const legacyRaw = safeStorage.getItem(keys.sessionExpandedLegacy);
|
||||
if (legacyRaw) {
|
||||
try {
|
||||
const parsedLegacy = JSON.parse(legacyRaw);
|
||||
if (Array.isArray(parsedLegacy)) {
|
||||
const migrated = new Set<string>();
|
||||
parsedLegacy.forEach((item) => {
|
||||
if (typeof item !== 'string' || item.length === 0) return;
|
||||
LEGACY_EXPANSION_CONTEXT_PREFIXES.forEach((prefix) => migrated.add(`${prefix}${item}`));
|
||||
});
|
||||
if (migrated.size > 0) {
|
||||
setExpandedParents(migrated);
|
||||
try { safeStorage.setItem(keys.sessionExpanded, JSON.stringify(Array.from(migrated))); } catch { /* ignored */ }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// legacy data was malformed; ignore and let it expire
|
||||
}
|
||||
try { safeStorage.removeItem?.(keys.sessionExpandedLegacy); } catch { /* ignored */ }
|
||||
}
|
||||
}
|
||||
const storedProjects = safeStorage.getItem(keys.projectCollapse);
|
||||
if (storedProjects) {
|
||||
@@ -148,17 +100,7 @@ export const useSidebarPersistence = (args: Args) => {
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [keys.projectCollapse, keys.sessionExpanded, keys.sessionExpandedLegacy, safeStorage, setCollapsedProjects, setExpandedParents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasAuthoritativeGlobalSessions) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPinnedSessionIds((prev) => {
|
||||
return prunePinnedSessionIds(sessions, prev);
|
||||
});
|
||||
}, [hasAuthoritativeGlobalSessions, sessions, setPinnedSessionIds]);
|
||||
}, [keys.projectCollapse, keys.sessionExpanded, safeStorage, setCollapsedProjects, setExpandedParents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
@@ -169,15 +111,6 @@ export const useSidebarPersistence = (args: Args) => {
|
||||
}
|
||||
}, [groupOrderByProject, keys.groupOrder, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const serialized = Object.fromEntries(activeSessionByProject.entries());
|
||||
safeStorage.setItem(keys.projectActiveSession, JSON.stringify(serialized));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [activeSessionByProject, keys.projectActiveSession, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
safeStorage.setItem(keys.groupCollapse, JSON.stringify(Array.from(collapsedGroups)));
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
isDesktopShellRuntime: boolean;
|
||||
projectSections: unknown[];
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
};
|
||||
|
||||
export const useStickyProjectHeaders = (args: Args): Set<string> => {
|
||||
const { isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args;
|
||||
const { enabled = true, isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args;
|
||||
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDesktopShellRuntime) {
|
||||
if (!enabled || !isDesktopShellRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,12 +25,16 @@ export const useStickyProjectHeaders = (args: Args): Set<string> => {
|
||||
}
|
||||
|
||||
setStuckProjectHeaders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!entry.isIntersecting) {
|
||||
if (prev.has(projectId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(projectId);
|
||||
} else {
|
||||
next.delete(projectId);
|
||||
return next;
|
||||
}
|
||||
|
||||
if (!prev.has(projectId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(projectId);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
@@ -44,7 +49,7 @@ export const useStickyProjectHeaders = (args: Args): Set<string> => {
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]);
|
||||
}, [enabled, isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]);
|
||||
|
||||
return stuckProjectHeaders;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildSessionBootstrapDemands } from "./sessionBootstrapDemands"
|
||||
|
||||
const sections = [{
|
||||
project: { id: "project-a", normalizedPath: "/repo" },
|
||||
groups: [
|
||||
{ id: "root", directory: "/repo", isMain: true },
|
||||
{ id: "worktree:/repo/wt-a", directory: "/repo/wt-a", isMain: false },
|
||||
{ id: "worktree:/repo/wt-b", directory: "/repo/wt-b", isMain: false },
|
||||
],
|
||||
}]
|
||||
|
||||
describe("buildSessionBootstrapDemands", () => {
|
||||
test("keeps collapsed worktrees eligible at background priority", () => {
|
||||
const demands = buildSessionBootstrapDemands({
|
||||
projectSections: sections,
|
||||
activeProjectId: null,
|
||||
collapsedProjects: new Set(["project-a"]),
|
||||
collapsedGroups: new Set(),
|
||||
currentDirectory: null,
|
||||
currentSessionDirectory: null,
|
||||
})
|
||||
|
||||
expect(demands.map(({ directory, priority }) => [directory, priority])).toEqual([
|
||||
["/repo", "background"],
|
||||
["/repo/wt-a", "background"],
|
||||
["/repo/wt-b", "background"],
|
||||
])
|
||||
})
|
||||
|
||||
test("promotes expansion and selected session without duplicate directories", () => {
|
||||
const demands = buildSessionBootstrapDemands({
|
||||
projectSections: sections,
|
||||
activeProjectId: "project-a",
|
||||
collapsedProjects: new Set(),
|
||||
collapsedGroups: new Set(["project-a:worktree:/repo/wt-b"]),
|
||||
currentDirectory: "/repo",
|
||||
currentSessionDirectory: "/repo/wt-b",
|
||||
})
|
||||
const byDirectory = new Map(demands.map((demand) => [demand.directory, demand]))
|
||||
|
||||
expect(demands.length).toBe(3)
|
||||
expect(byDirectory.get("/repo")?.priority).toBe("selected")
|
||||
expect(byDirectory.get("/repo/wt-a")?.priority).toBe("expanded")
|
||||
expect(byDirectory.get("/repo/wt-b")?.priority).toBe("selected")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { DirectoryBootstrapDemand, DirectoryBootstrapPriority } from "@/sync/child-store"
|
||||
import { normalizePath } from "./utils"
|
||||
|
||||
type BootstrapProjectSection = {
|
||||
project: { id: string; normalizedPath: string }
|
||||
groups: Array<{
|
||||
id: string
|
||||
directory: string | null
|
||||
isArchivedBucket?: boolean
|
||||
isMain: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
const PRIORITY_RANK: Record<DirectoryBootstrapPriority, number> = {
|
||||
selected: 0,
|
||||
"active-project": 1,
|
||||
expanded: 2,
|
||||
visible: 3,
|
||||
background: 4,
|
||||
}
|
||||
|
||||
export function buildSessionBootstrapDemands(input: {
|
||||
projectSections: BootstrapProjectSection[]
|
||||
activeProjectId: string | null
|
||||
collapsedProjects: ReadonlySet<string>
|
||||
collapsedGroups: ReadonlySet<string>
|
||||
currentDirectory: string | null
|
||||
currentSessionDirectory: string | null
|
||||
}): DirectoryBootstrapDemand[] {
|
||||
const byDirectory = new Map<string, DirectoryBootstrapDemand>()
|
||||
const add = (
|
||||
directory: string | null | undefined,
|
||||
priority: DirectoryBootstrapPriority,
|
||||
reason: DirectoryBootstrapDemand["reason"],
|
||||
) => {
|
||||
const normalizedDirectory = normalizePath(directory ?? null)
|
||||
if (!normalizedDirectory) return
|
||||
const existing = byDirectory.get(normalizedDirectory)
|
||||
if (existing && PRIORITY_RANK[existing.priority] <= PRIORITY_RANK[priority]) return
|
||||
byDirectory.set(normalizedDirectory, { directory: normalizedDirectory, priority, reason })
|
||||
}
|
||||
|
||||
for (const section of input.projectSections) {
|
||||
const projectExpanded = !input.collapsedProjects.has(section.project.id)
|
||||
let projectPriority: DirectoryBootstrapPriority = "background"
|
||||
if (section.project.id === input.activeProjectId) {
|
||||
projectPriority = "active-project"
|
||||
} else if (projectExpanded) {
|
||||
projectPriority = "expanded"
|
||||
}
|
||||
add(
|
||||
section.project.normalizedPath,
|
||||
projectPriority,
|
||||
projectExpanded ? "project-expanded" : "known-project",
|
||||
)
|
||||
|
||||
for (const group of section.groups) {
|
||||
if (!group.directory || group.isArchivedBucket || group.isMain) continue
|
||||
const groupExpanded = projectExpanded && !input.collapsedGroups.has(`${section.project.id}:${group.id}`)
|
||||
let groupPriority: DirectoryBootstrapPriority = "background"
|
||||
if (groupExpanded) {
|
||||
groupPriority = "expanded"
|
||||
} else if (projectExpanded) {
|
||||
groupPriority = "visible"
|
||||
}
|
||||
add(
|
||||
group.directory,
|
||||
groupPriority,
|
||||
groupExpanded ? "worktree-expanded" : "known-worktree",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
add(input.currentDirectory, "selected", "current-directory")
|
||||
add(input.currentSessionDirectory, "selected", "selected-session")
|
||||
return [...byDirectory.values()]
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
||||
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from './types';
|
||||
|
||||
const session = (id: string, title: string): Session => ({
|
||||
id,
|
||||
title,
|
||||
time: { created: 1, updated: 1 },
|
||||
} as Session);
|
||||
|
||||
const rootWithChild = (childSession: Session): SessionNode => ({
|
||||
session: session('root', 'Root'),
|
||||
children: [{ session: childSession, children: [], worktree: null }],
|
||||
worktree: null,
|
||||
});
|
||||
|
||||
describe('computeNodeStructureKey', () => {
|
||||
test('stays stable across grouping rebuilds that reuse session objects', () => {
|
||||
const child = session('child', 'Child');
|
||||
|
||||
expect(computeNodeStructureKey(rootWithChild(child))).toBe(computeNodeStructureKey(rootWithChild(child)));
|
||||
});
|
||||
|
||||
test('changes when a descendant session object changes', () => {
|
||||
const previous = session('child', 'Before');
|
||||
const next = { ...previous, title: 'After' };
|
||||
|
||||
expect(computeNodeStructureKey(rootWithChild(previous))).not.toBe(computeNodeStructureKey(rootWithChild(next)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodeHasPinnedMembershipChange', () => {
|
||||
test('detects composite pin changes using the group directory fallback', () => {
|
||||
const node: SessionNode = {
|
||||
session: session('root', 'Root'),
|
||||
children: [],
|
||||
worktree: null,
|
||||
};
|
||||
const pinnedKey = getPinnedSessionKey(getRuntimeKey(), '/repo', 'root');
|
||||
|
||||
expect(pinnedKey).not.toBeNull();
|
||||
expect(nodeHasPinnedMembershipChange(
|
||||
node,
|
||||
node,
|
||||
new Set(),
|
||||
new Set([pinnedKey!]),
|
||||
'/repo',
|
||||
'/repo',
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
test('ignores pin changes for the same session id in another directory', () => {
|
||||
const node: SessionNode = {
|
||||
session: session('root', 'Root'),
|
||||
children: [],
|
||||
worktree: null,
|
||||
};
|
||||
const pinnedKey = getPinnedSessionKey(getRuntimeKey(), '/other-repo', 'root');
|
||||
|
||||
expect(pinnedKey).not.toBeNull();
|
||||
expect(nodeHasPinnedMembershipChange(
|
||||
node,
|
||||
node,
|
||||
new Set(),
|
||||
new Set([pinnedKey!]),
|
||||
'/repo',
|
||||
'/repo',
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectFolderRootNodes', () => {
|
||||
test('does not render assigned descendants again beside their assigned parent tree', () => {
|
||||
const grandchild: SessionNode = {
|
||||
session: { ...session('grandchild', 'Grandchild'), parentID: 'child' } as Session,
|
||||
children: [],
|
||||
worktree: null,
|
||||
};
|
||||
const child: SessionNode = {
|
||||
session: { ...session('child', 'Child'), parentID: 'root' } as Session,
|
||||
children: [grandchild],
|
||||
worktree: null,
|
||||
};
|
||||
const root: SessionNode = {
|
||||
session: session('root', 'Root'),
|
||||
children: [child],
|
||||
worktree: null,
|
||||
};
|
||||
const nodes = new Map([
|
||||
['root', root],
|
||||
['child', child],
|
||||
['grandchild', grandchild],
|
||||
]);
|
||||
|
||||
expect(selectFolderRootNodes(['root', 'child', 'grandchild'], nodes)).toEqual([root]);
|
||||
});
|
||||
|
||||
test('keeps a child as a folder root when none of its ancestors are assigned', () => {
|
||||
const child: SessionNode = {
|
||||
session: { ...session('child', 'Child'), parentID: 'root' } as Session,
|
||||
children: [],
|
||||
worktree: null,
|
||||
};
|
||||
const root: SessionNode = {
|
||||
session: session('root', 'Root'),
|
||||
children: [child],
|
||||
worktree: null,
|
||||
};
|
||||
|
||||
expect(selectFolderRootNodes(['child'], new Map([['root', root], ['child', child]]))).toEqual([child]);
|
||||
});
|
||||
|
||||
test('keeps a child when an assigned ancestor is not available in the group', () => {
|
||||
const child: SessionNode = {
|
||||
session: { ...session('child', 'Child'), parentID: 'missing-root' } as Session,
|
||||
children: [],
|
||||
worktree: null,
|
||||
};
|
||||
|
||||
expect(selectFolderRootNodes(['missing-root', 'child'], new Map([['child', child]]))).toEqual([child]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
||||
import type { SessionNode } from './types';
|
||||
|
||||
/**
|
||||
@@ -10,7 +12,6 @@ import type { SessionNode } from './types';
|
||||
* each child's extras object.
|
||||
*/
|
||||
export type SessionNodeChildRenderExtras = {
|
||||
subtreeContainsActive: Set<string>;
|
||||
subtreeContainsEditing: Set<string>;
|
||||
menuOpenSessionId: string | null;
|
||||
nodeStructureKey: string;
|
||||
@@ -24,7 +25,7 @@ export type SessionNodeRenderExtras<TNode = SessionNode> = SessionNodeChildRende
|
||||
* Walk `nodes` and add `node.session.id` to `result` for every node
|
||||
* whose subtree contains `targetId`. This is used to precompute, once
|
||||
* per SessionGroupSection render, which rows need to update when
|
||||
* `currentSessionId` or `editingId` changes. With M visible rows, this
|
||||
* `editingId` changes. With M visible rows, this
|
||||
* turns an O(M × subtree-depth) walk inside `SessionNodeItem.areEqual`
|
||||
* into a single O(M) `Set.has` per row.
|
||||
*/
|
||||
@@ -69,12 +70,45 @@ export const nodeContainsSessionId = (node: SessionNode, sessionId: string | nul
|
||||
return false;
|
||||
};
|
||||
|
||||
export const selectFolderRootNodes = (
|
||||
sessionIds: string[],
|
||||
nodeBySessionId: ReadonlyMap<string, SessionNode>,
|
||||
): SessionNode[] => {
|
||||
const assignedSessionIds = new Set(sessionIds);
|
||||
|
||||
return sessionIds
|
||||
.map((sessionId) => nodeBySessionId.get(sessionId))
|
||||
.filter((node): node is SessionNode => {
|
||||
if (!node) return false;
|
||||
|
||||
const visited = new Set<string>();
|
||||
let parentID = (node.session as SessionNode['session'] & { parentID?: string | null }).parentID ?? null;
|
||||
while (parentID && !visited.has(parentID)) {
|
||||
if (assignedSessionIds.has(parentID) && nodeBySessionId.has(parentID)) return false;
|
||||
visited.add(parentID);
|
||||
const parentNode = nodeBySessionId.get(parentID);
|
||||
parentID = (parentNode?.session as (SessionNode['session'] & { parentID?: string | null }) | undefined)?.parentID ?? null;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const sessionObjectVersions = new WeakMap<object, number>();
|
||||
let nextSessionObjectVersion = 1;
|
||||
|
||||
const getSessionObjectVersion = (session: object): number => {
|
||||
const existing = sessionObjectVersions.get(session);
|
||||
if (existing !== undefined) return existing;
|
||||
const version = nextSessionObjectVersion;
|
||||
nextSessionObjectVersion += 1;
|
||||
sessionObjectVersions.set(session, version);
|
||||
return version;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a structural key for `node` that encodes the IDs of all
|
||||
* descendants. Used by `SessionNodeItem.areEqual` so a reference-only
|
||||
* rebuild of the tree (which happens on every `buildGroupedSessions`
|
||||
* pass) can be detected with a single string compare instead of a
|
||||
* recursive walk per row.
|
||||
* Build a key encoding descendant IDs and session object versions. This lets
|
||||
* row memoization detect one changed descendant without recursively comparing
|
||||
* every subtree after a reference-only grouping rebuild.
|
||||
*/
|
||||
export const computeNodeStructureKey = (node: SessionNode): string => {
|
||||
if (node.children.length === 0) {
|
||||
@@ -82,15 +116,49 @@ export const computeNodeStructureKey = (node: SessionNode): string => {
|
||||
}
|
||||
|
||||
const childKeys = node.children.map((child) => {
|
||||
const childVersion = getSessionObjectVersion(child.session);
|
||||
if (child.children.length === 0) {
|
||||
return child.session.id;
|
||||
return `${child.session.id}@${childVersion}`;
|
||||
}
|
||||
return `${child.session.id}:${computeNodeStructureKey(child)}`;
|
||||
return `${child.session.id}@${childVersion}:${computeNodeStructureKey(child)}`;
|
||||
});
|
||||
|
||||
return childKeys.join('|');
|
||||
};
|
||||
|
||||
export const nodeHasPinnedMembershipChange = (
|
||||
prevNode: SessionNode,
|
||||
nextNode: SessionNode,
|
||||
prevPinnedSessionIds: Set<string>,
|
||||
nextPinnedSessionIds: Set<string>,
|
||||
prevGroupDirectory?: string | null,
|
||||
nextGroupDirectory?: string | null,
|
||||
): boolean => {
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const visit = (previous: SessionNode, current: SessionNode): boolean => {
|
||||
if (previous.session.id !== current.session.id || previous.children.length !== current.children.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prevDirectory = (previous.session as SessionNode['session'] & { directory?: string | null }).directory
|
||||
?? prevGroupDirectory;
|
||||
const nextDirectory = (current.session as SessionNode['session'] & { directory?: string | null }).directory
|
||||
?? nextGroupDirectory;
|
||||
const prevKey = getPinnedSessionKey(runtimeKey, prevDirectory ?? '', previous.session.id);
|
||||
const nextKey = getPinnedSessionKey(runtimeKey, nextDirectory ?? '', current.session.id);
|
||||
if (
|
||||
(prevKey ? prevPinnedSessionIds.has(prevKey) : false)
|
||||
!== (nextKey ? nextPinnedSessionIds.has(nextKey) : false)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return previous.children.some((child, index) => visit(child, current.children[index]));
|
||||
};
|
||||
|
||||
return visit(prevNode, nextNode);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the session id whose sidebar menu is open, or null if no
|
||||
* menu is open. Only one row can have its menu open at a time.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { isPathWithinProject } from './utils';
|
||||
import {
|
||||
isPathWithinProject,
|
||||
selectExpandedParentKeysForContext,
|
||||
toggleExpandedParentKey,
|
||||
} from './utils';
|
||||
|
||||
describe('isPathWithinProject', () => {
|
||||
test('matches child directories for root projects', () => {
|
||||
@@ -26,3 +30,48 @@ describe('isPathWithinProject', () => {
|
||||
expect(isPathWithinProject('/workspace/app/sub/dir', '/workspace/app')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectExpandedParentKeysForContext', () => {
|
||||
test('keeps project and recent expansion state isolated', () => {
|
||||
const expanded = new Set([
|
||||
'project:active:parent-a',
|
||||
'project:archived:parent-b',
|
||||
'recent:active:parent-a',
|
||||
]);
|
||||
|
||||
expect(selectExpandedParentKeysForContext(new Set(), expanded, 'project')).toEqual(new Set([
|
||||
'project:active:parent-a',
|
||||
'project:archived:parent-b',
|
||||
]));
|
||||
expect(selectExpandedParentKeysForContext(new Set(), expanded, 'recent')).toEqual(new Set([
|
||||
'recent:active:parent-a',
|
||||
]));
|
||||
});
|
||||
|
||||
test('preserves a context projection when only another context changes', () => {
|
||||
const recent = new Set(['recent:active:parent-a']);
|
||||
const expanded = new Set(['recent:active:parent-a', 'project:active:parent-a']);
|
||||
|
||||
expect(selectExpandedParentKeysForContext(recent, expanded, 'recent')).toBe(recent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parent expansion state', () => {
|
||||
const recentKey = 'recent:active:parent-a';
|
||||
const projectKey = 'project:active:parent-a';
|
||||
|
||||
test('manually expands and collapses a parent', () => {
|
||||
const expanded = toggleExpandedParentKey(new Set(), recentKey);
|
||||
expect(expanded).toEqual(new Set([recentKey]));
|
||||
expect(toggleExpandedParentKey(expanded, recentKey)).toEqual(new Set());
|
||||
});
|
||||
|
||||
test('does not change the other render context', () => {
|
||||
const recentExpanded = new Set([recentKey]);
|
||||
const bothExpanded = toggleExpandedParentKey(recentExpanded, projectKey);
|
||||
const projectCollapsed = toggleExpandedParentKey(bothExpanded, projectKey);
|
||||
|
||||
expect(selectExpandedParentKeysForContext(new Set(), bothExpanded, 'recent')).toEqual(new Set([recentKey]));
|
||||
expect(selectExpandedParentKeysForContext(new Set(), projectCollapsed, 'recent')).toEqual(new Set([recentKey]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { isSessionPinned } from '@/stores/useSessionPinnedStore';
|
||||
import { getCurrentIntlLocale } from '@/lib/i18n';
|
||||
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
|
||||
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
export { normalizePath };
|
||||
|
||||
export const selectExpandedParentKeysForContext = (
|
||||
previous: Set<string>,
|
||||
expanded: ReadonlySet<string>,
|
||||
context: 'project' | 'recent',
|
||||
): Set<string> => {
|
||||
const prefix = `${context}:`;
|
||||
const next = new Set([...expanded].filter((key) => key.startsWith(prefix)));
|
||||
if (previous.size === next.size && [...next].every((key) => previous.has(key))) {
|
||||
return previous;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
export const toggleExpandedParentKey = (
|
||||
expanded: Set<string>,
|
||||
key: string,
|
||||
): Set<string> => {
|
||||
const next = new Set(expanded);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
};
|
||||
|
||||
const t = (key: Parameters<typeof formatMessage>[1], params?: Parameters<typeof formatMessage>[2]) =>
|
||||
formatMessage(useI18nStore.getState().dictionary, key, params);
|
||||
|
||||
@@ -132,8 +157,8 @@ export const compareSessionsByPinnedAndTime = (
|
||||
b: Session,
|
||||
pinnedSessionIds: Set<string>,
|
||||
): number => {
|
||||
const aPinned = pinnedSessionIds.has(a.id);
|
||||
const bPinned = pinnedSessionIds.has(b.id);
|
||||
const aPinned = isSessionPinned(pinnedSessionIds, resolveGlobalSessionDirectory(a), a.id);
|
||||
const bPinned = isSessionPinned(pinnedSessionIds, resolveGlobalSessionDirectory(b), b.id);
|
||||
if (aPinned !== bPinned) {
|
||||
return aPinned ? -1 : 1;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ type CommandEntry = {
|
||||
};
|
||||
|
||||
type FileHit = { path: string; name: string; relativePath: string };
|
||||
const EMPTY_SESSIONS: Session[] = [];
|
||||
|
||||
const normalizePath = (value: string): string => {
|
||||
if (!value) return '';
|
||||
@@ -85,7 +86,10 @@ export const CommandPalette: React.FC = () => {
|
||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
||||
|
||||
const activeSessions = useGlobalSessionsStore((s) => s.activeSessions);
|
||||
const activeSessions = useGlobalSessionsStore(React.useCallback(
|
||||
(state) => isCommandPaletteOpen ? state.activeSessions : EMPTY_SESSIONS,
|
||||
[isCommandPaletteOpen],
|
||||
));
|
||||
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||
const activeProject = useProjectsStore((s) => s.getActiveProject());
|
||||
const projects = useProjectsStore((s) => s.projects);
|
||||
|
||||
@@ -4,15 +4,16 @@ import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type ChatViewProps = {
|
||||
active?: boolean;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export const ChatView: React.FC<ChatViewProps> = ({ readOnly = false }) => {
|
||||
export const ChatView: React.FC<ChatViewProps> = ({ active = true, readOnly = false }) => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
|
||||
return (
|
||||
<ChatErrorBoundary sessionId={currentSessionId || undefined}>
|
||||
<ChatContainer readOnly={readOnly} />
|
||||
<ChatContainer active={active} readOnly={readOnly} />
|
||||
</ChatErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GitStatus } from '@/lib/api/types';
|
||||
import {
|
||||
@@ -667,6 +668,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
setIsLoading(true);
|
||||
|
||||
let cancelled = false;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const contextLines = loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES;
|
||||
const fetchPromise = isImageFile(file.path)
|
||||
? git.getGitFileDiff(directory, { path: file.path, staged })
|
||||
@@ -697,7 +699,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
if (staged) {
|
||||
setStagedDiffData(nextDiff);
|
||||
} else {
|
||||
setDiff(directory, file.path, nextDiff);
|
||||
setDiff(directory, file.path, nextDiff, runtimeKey);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
@@ -1494,6 +1496,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
}
|
||||
|
||||
setOpeningEditorFilePath(filePath);
|
||||
const runtimeKey = getRuntimeKey();
|
||||
try {
|
||||
let targetLine: number | null = null;
|
||||
|
||||
@@ -1525,7 +1528,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
isBinary: response.isBinary,
|
||||
};
|
||||
if (!activeDiffStaged) {
|
||||
setDiff(effectiveDirectory, filePath, diffForNavigation);
|
||||
setDiff(effectiveDirectory, filePath, diffForNavigation, runtimeKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -567,9 +567,9 @@ const FileRow: React.FC<FileRowProps> = ({
|
||||
onOpenChange={(open) => setContextMenuPath(open ? node.path : null)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={handleMenuButtonClick}
|
||||
>
|
||||
@@ -810,7 +810,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const openPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.openPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
|
||||
const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null));
|
||||
const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
|
||||
const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath);
|
||||
const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath);
|
||||
const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix);
|
||||
const removeExpandedPathsByPrefix = useFilesViewTabsStore((state) => state.removeExpandedPathsByPrefix);
|
||||
@@ -922,6 +921,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const diagramEditorRef = React.useRef<React.ComponentRef<typeof DiagramEditor>>(null);
|
||||
const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
|
||||
const activeFileLoadIdRef = React.useRef(0);
|
||||
const loadingFilePathRef = React.useRef<string | null>(null);
|
||||
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
|
||||
const [diagramSaved, setDiagramSaved] = React.useState(false);
|
||||
const [autoSaveEnabled, setAutoSaveEnabled] = React.useState(getInitialAutoSaveEnabled);
|
||||
@@ -1952,7 +1952,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
if (root) {
|
||||
setSelectedPath(root, node.path);
|
||||
addOpenPath(root, node.path);
|
||||
void ensurePathVisible(node.path, false);
|
||||
}
|
||||
|
||||
@@ -1966,7 +1965,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
if (isMobile) {
|
||||
setShowMobilePageContent(true);
|
||||
}
|
||||
}, [addOpenPath, ensurePathVisible, isDirty, isMobile, root, setSelectedPath]);
|
||||
}, [ensurePathVisible, isDirty, isMobile, root, setSelectedPath]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedFile?.path) {
|
||||
@@ -1979,16 +1978,23 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
React.useEffect(() => {
|
||||
if (!selectedFile) {
|
||||
activeFileLoadIdRef.current += 1;
|
||||
loadingFilePathRef.current = null;
|
||||
setFileLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadedFilePath === selectedFile.path) {
|
||||
if (loadedFilePath === selectedFile.path || loadingFilePathRef.current === selectedFile.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Selection changes are guarded; this effect is also what restores persisted tabs on mount.
|
||||
void loadSelectedFile(selectedFile);
|
||||
const loadingPath = selectedFile.path;
|
||||
loadingFilePathRef.current = loadingPath;
|
||||
void loadSelectedFile(selectedFile).finally(() => {
|
||||
if (loadingFilePathRef.current === loadingPath) {
|
||||
loadingFilePathRef.current = null;
|
||||
}
|
||||
});
|
||||
}, [loadSelectedFile, loadedFilePath, selectedFile]);
|
||||
|
||||
// Sync isDirty to a ref so the polling interval can read the latest value
|
||||
@@ -2192,7 +2198,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const getFileStatus = React.useCallback((path: string): FileStatus | null => {
|
||||
// Check open status
|
||||
if (openPaths.includes(path)) return 'open';
|
||||
|
||||
|
||||
// Check git status
|
||||
if (gitStatus?.files) {
|
||||
const relative = path.startsWith(root + '/') ? path.slice(root.length + 1) : path;
|
||||
@@ -2210,7 +2216,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
if (!gitStatus?.files) return null;
|
||||
const relativeDir = dirPath.startsWith(root + '/') ? dirPath.slice(root.length + 1) : dirPath;
|
||||
const prefix = relativeDir ? `${relativeDir}/` : '';
|
||||
|
||||
|
||||
let modified = 0, added = 0;
|
||||
for (const f of gitStatus.files) {
|
||||
if (f.path.startsWith(prefix)) {
|
||||
|
||||
@@ -615,9 +615,8 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
const handleAttachSelection = React.useCallback(() => {
|
||||
const selection = terminalControllerRef.current?.getSelection();
|
||||
const sessionKey = currentSessionId ?? (newSessionDraft?.open ? 'draft' : null);
|
||||
if (!selection || !sessionKey || !activeTab) return;
|
||||
addContextDraft({
|
||||
sessionKey,
|
||||
if (!selection || !sessionKey || !activeTab || !effectiveDirectory) return;
|
||||
addContextDraft({ directory: effectiveDirectory, sessionKey }, {
|
||||
source: 'terminal',
|
||||
fileLabel: activeTab.label,
|
||||
startLine: selection.startLine,
|
||||
@@ -626,7 +625,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
|
||||
language: activeTab.terminalSessionId ?? activeTab.id,
|
||||
text: '',
|
||||
});
|
||||
}, [activeTab, addContextDraft, currentSessionId, newSessionDraft?.open]);
|
||||
}, [activeTab, addContextDraft, currentSessionId, effectiveDirectory, newSessionDraft?.open]);
|
||||
|
||||
const handleSelectTab = React.useCallback(
|
||||
(tabId: string) => {
|
||||
|
||||
@@ -382,8 +382,8 @@ export const PullRequestSection: React.FC<{
|
||||
const canShow = Boolean(directory && branch && baseBranch && (branch !== baseBranch || isFork));
|
||||
|
||||
const prStatusKey = React.useMemo(
|
||||
() => getGitHubPrStatusKey(directory, branch),
|
||||
[directory, branch],
|
||||
() => getGitHubPrStatusKey(directory, branch, selectedRemote?.name ?? null),
|
||||
[directory, branch, selectedRemote?.name],
|
||||
);
|
||||
const statusEntry = useGitHubPrStatusStore((state) => state.entries[prStatusKey]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user