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
@@ -21,6 +21,7 @@ import {
|
||||
useIsGitRepo,
|
||||
useGitLoadingStatus,
|
||||
} from '@/stores/useGitStore';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||
@@ -202,6 +203,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
setDiffLoadError(null);
|
||||
void git.getGitFileDiff(currentDirectory, { path: route.path, staged: route.staged || undefined })
|
||||
.then((response) => {
|
||||
@@ -210,7 +212,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
original: response.original ?? '',
|
||||
modified: response.modified ?? '',
|
||||
isBinary: response.isBinary,
|
||||
});
|
||||
}, runtimeKey);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { loadMobileConnections, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
|
||||
import { loadMobileConnections, migrateLegacyInlineTokenRecords, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -40,6 +40,18 @@ const testRelay: MobileRelayConfig = {
|
||||
};
|
||||
|
||||
describe('mobile connection storage', () => {
|
||||
test('removes inline tokens only after each secure migration succeeds', async () => {
|
||||
const result = await migrateLegacyInlineTokenRecords([
|
||||
{ id: 'ok', url: 'http://ok.example', clientToken: 'token-ok' },
|
||||
{ id: 'failed', url: 'http://failed.example', clientToken: 'token-failed' },
|
||||
], async (url) => url.includes('ok.example'));
|
||||
|
||||
expect(result.migrated).toBe(1);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.records[0]).toEqual({ id: 'ok', url: 'http://ok.example', hasToken: true });
|
||||
expect(result.records[1]).toEqual({ id: 'failed', url: 'http://failed.example', clientToken: 'token-failed' });
|
||||
});
|
||||
|
||||
test('entries persisted before candidates migrate to a single direct candidate', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
|
||||
@@ -691,6 +691,30 @@ const deleteSecureToken = async (key: string): Promise<void> => {
|
||||
|
||||
// One-time migration: a legacy localStorage record on native might still carry an
|
||||
// inline `clientToken`. Move it into the secure store and strip the metadata.
|
||||
export const migrateLegacyInlineTokenRecords = async (
|
||||
records: unknown[],
|
||||
migrateToken: (url: string, token: string) => Promise<boolean>,
|
||||
): Promise<{ records: unknown[]; migrated: number; failed: number }> => {
|
||||
let migrated = 0;
|
||||
let failed = 0;
|
||||
const next = await Promise.all(records.map(async (item) => {
|
||||
if (!item || typeof item !== 'object') return item;
|
||||
const record = item as Record<string, unknown>;
|
||||
const url = typeof record.url === 'string' ? record.url : null;
|
||||
const token = typeof record.clientToken === 'string' ? record.clientToken.trim() : '';
|
||||
if (!url || !token) return item;
|
||||
if (!await migrateToken(url, token)) {
|
||||
failed += 1;
|
||||
return item;
|
||||
}
|
||||
migrated += 1;
|
||||
const { clientToken: _removed, ...metadata } = record;
|
||||
void _removed;
|
||||
return { ...metadata, hasToken: true };
|
||||
}));
|
||||
return { records: next, migrated, failed };
|
||||
};
|
||||
|
||||
const migrateLegacyInlineTokens = async (): Promise<void> => {
|
||||
if (typeof window === 'undefined' || !isCapacitorApp()) return;
|
||||
let parsed: unknown;
|
||||
@@ -707,11 +731,20 @@ const migrateLegacyInlineTokens = async (): Promise<void> => {
|
||||
&& Boolean((item as { clientToken: string }).clientToken.trim()));
|
||||
if (legacy.length === 0) return;
|
||||
logStorage('secure:migrate-start', { count: legacy.length });
|
||||
for (const { url, clientToken } of legacy) {
|
||||
await writeSecureToken(getConnectionStorageKey(url), clientToken);
|
||||
const result = await migrateLegacyInlineTokenRecords(parsed, async (url, token) => {
|
||||
const key = getConnectionStorageKey(url);
|
||||
if (!await writeSecureToken(key, token)) return false;
|
||||
return await readSecureToken(key) === token;
|
||||
});
|
||||
if (result.migrated > 0) {
|
||||
try {
|
||||
window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(result.records));
|
||||
} catch (error) {
|
||||
console.warn('[mobile-storage] failed to finalize secure token migration', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
writeConnections(readConnections());
|
||||
logStorage('secure:migrate-done', { count: legacy.length });
|
||||
logStorage('secure:migrate-done', { migrated: result.migrated, failed: result.failed });
|
||||
};
|
||||
|
||||
export const loadMobileConnections = async (): Promise<MobileSavedConnection[]> => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useNotificationStore } from '@/sync/notification-store';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
/**
|
||||
* Builds the lightweight session overview the native iOS widgets render (home medium,
|
||||
@@ -26,6 +27,8 @@ export interface MobileWidgetSession {
|
||||
}
|
||||
|
||||
export interface MobileWidgetSnapshot {
|
||||
/** Runtime instance that owns all session IDs and paths in this snapshot. */
|
||||
runtimeKey: string;
|
||||
/** Count of sessions needing attention — same signal that drives the app-icon badge. */
|
||||
attentionCount: number;
|
||||
/** Most-recently-updated top-level sessions, newest first (capped for the medium widget). */
|
||||
@@ -97,7 +100,7 @@ export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
|
||||
.slice(0, RECENT_LIMIT)
|
||||
.map(({ id, title, unread, project }) => ({ id, title, unread, project }));
|
||||
|
||||
return { attentionCount, recentSessions };
|
||||
return { runtimeKey: getRuntimeKey(), attentionCount, recentSessions };
|
||||
};
|
||||
|
||||
const SNAPSHOT_GLOBAL_KEY = '__OPENCHAMBER_WIDGET_SNAPSHOT__';
|
||||
|
||||
@@ -7,9 +7,15 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { resetStreamingState } from '@/sync/streaming';
|
||||
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||
import { syncDesktopSettings } from '@/lib/persistence';
|
||||
|
||||
// Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK
|
||||
@@ -47,7 +53,13 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
||||
// Cross-project session list (mobile sessions sheet & co) belongs to the
|
||||
// previous instance — drop it so stale sessions can't linger after a switch.
|
||||
useGlobalSessionsStore.getState().resetForRuntimeSwitch();
|
||||
useGlobalSessionStatusStore.setState({ statusById: new Map() });
|
||||
usePermissionStore.getState().reset();
|
||||
useFileSearchStore.getState().resetForRuntimeSwitch();
|
||||
useGitStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useGitHubPrStatusStore.getState().resetForRuntimeSwitch();
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
resetStreamingState();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -1,161 +1,17 @@
|
||||
import React, { type JSX, type ReactNode } from 'react';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types';
|
||||
import {
|
||||
approxStringBytes,
|
||||
evictContentLru,
|
||||
setContentBytes,
|
||||
touchContent as touchContentLru,
|
||||
removeContentBytes,
|
||||
} from '@/sync/content-cache';
|
||||
|
||||
/** Wrap a FilesAPI with an in-memory LRU content cache. */
|
||||
function withContentCache(files: FilesAPI): FilesAPI {
|
||||
const cache = new Map<string, { content: string; path: string; size?: number; mtimeMs?: number }>();
|
||||
|
||||
const removeCacheEntry = (path: string) => {
|
||||
cache.delete(path);
|
||||
removeContentBytes(path);
|
||||
};
|
||||
|
||||
const removeCacheEntriesByPrefix = (path: string) => {
|
||||
const prefix = path.endsWith('/') ? path : `${path}/`;
|
||||
for (const key of cache.keys()) {
|
||||
if (key === path || key.startsWith(prefix)) {
|
||||
removeCacheEntry(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether cached metadata still matches the file on disk. */
|
||||
const statMatches = (
|
||||
cached: { size?: number; mtimeMs?: number },
|
||||
latest: { isFile: boolean; size: number; mtimeMs?: number },
|
||||
): boolean => {
|
||||
if (!latest.isFile) return false;
|
||||
// If mtimeMs is available on both sides, it is the strongest signal.
|
||||
if (cached.mtimeMs !== undefined && latest.mtimeMs !== undefined) {
|
||||
return cached.mtimeMs === latest.mtimeMs && cached.size === latest.size;
|
||||
}
|
||||
return cached.size === latest.size;
|
||||
};
|
||||
|
||||
const syncCacheEntry = (
|
||||
path: string,
|
||||
result: { content: string; path: string },
|
||||
stat?: { isFile: boolean; size: number; mtimeMs?: number } | null,
|
||||
): { content: string; path: string } => {
|
||||
const bytes = approxStringBytes(result.content);
|
||||
cache.set(path, {
|
||||
...result,
|
||||
size: stat?.isFile ? stat.size : undefined,
|
||||
mtimeMs: stat?.isFile ? stat.mtimeMs : undefined,
|
||||
});
|
||||
setContentBytes(path, bytes);
|
||||
|
||||
const keep = new Set<string>();
|
||||
evictContentLru(keep, (evictPath) => {
|
||||
cache.delete(evictPath);
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const readFreshFile = async (path: string, options?: Parameters<NonNullable<FilesAPI['readFile']>>[1]): Promise<{ content: string; path: string }> => {
|
||||
// stat → read → stat to avoid TOCTOU:
|
||||
// if the file changes between read and either stat, metadata won't match and we retry.
|
||||
const statBefore = await files.statFile?.(path, options).catch(() => null);
|
||||
|
||||
const result = await files.readFile!(path, options);
|
||||
|
||||
const statAfter = await files.statFile?.(path, options).catch(() => null);
|
||||
|
||||
// If both stats are available and agree, the read was atomic with respect to file changes.
|
||||
if (statBefore && statAfter && statBefore.isFile && statAfter.isFile) {
|
||||
if (statBefore.size === statAfter.size && statBefore.mtimeMs === statAfter.mtimeMs) {
|
||||
return syncCacheEntry(path, result, statAfter);
|
||||
}
|
||||
// File changed during read — discard and re-read once.
|
||||
const retryStatBefore = await files.statFile?.(path, options).catch(() => null);
|
||||
const retry = await files.readFile!(path, options);
|
||||
const retryStat = await files.statFile?.(path, options).catch(() => null);
|
||||
// Accept retry only if file was stable across the read.
|
||||
if (retryStatBefore && retryStat && retryStatBefore.isFile && retryStat.isFile
|
||||
&& retryStatBefore.size === retryStat.size && retryStatBefore.mtimeMs === retryStat.mtimeMs) {
|
||||
return syncCacheEntry(path, retry, retryStat);
|
||||
}
|
||||
// Best-effort: file was still changing, cache what we got. Next hit will re-validate.
|
||||
return syncCacheEntry(path, retry, retryStat);
|
||||
}
|
||||
|
||||
return syncCacheEntry(path, result, statAfter ?? statBefore);
|
||||
};
|
||||
|
||||
const cachedReadFile: FilesAPI['readFile'] = files.readFile
|
||||
? async (path: string, options) => {
|
||||
if (options?.allowOutsideWorkspace) {
|
||||
return readFreshFile(path, options);
|
||||
}
|
||||
const hit = cache.get(path);
|
||||
if (hit) {
|
||||
// Validate cached entry is still fresh
|
||||
if (files.statFile) {
|
||||
const latest = await files.statFile(path, options).catch(() => {
|
||||
removeCacheEntry(path);
|
||||
return null;
|
||||
});
|
||||
if (!latest || !statMatches(hit, latest)) {
|
||||
removeCacheEntry(path);
|
||||
return readFreshFile(path, options);
|
||||
}
|
||||
}
|
||||
touchContentLru(path);
|
||||
return { content: hit.content, path: hit.path };
|
||||
}
|
||||
|
||||
return readFreshFile(path, options);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Invalidate cache on writes, deletes, renames
|
||||
const cachedWriteFile: FilesAPI['writeFile'] = files.writeFile
|
||||
? async (path, content) => {
|
||||
removeCacheEntry(path);
|
||||
return files.writeFile!(path, content);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const cachedDelete: FilesAPI['delete'] = files.delete
|
||||
? async (path) => {
|
||||
removeCacheEntriesByPrefix(path);
|
||||
return files.delete!(path);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const cachedRename: FilesAPI['rename'] = files.rename
|
||||
? async (oldPath, newPath) => {
|
||||
removeCacheEntriesByPrefix(oldPath);
|
||||
removeCacheEntriesByPrefix(newPath);
|
||||
return files.rename!(oldPath, newPath);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...files,
|
||||
readFile: cachedReadFile,
|
||||
writeFile: cachedWriteFile,
|
||||
delete: cachedDelete,
|
||||
rename: cachedRename,
|
||||
};
|
||||
}
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { createContentCachedFiles } from '@/contexts/content-cache-owner';
|
||||
|
||||
export function RuntimeAPIProvider({ apis, children }: { apis: RuntimeAPIs; children: ReactNode }): JSX.Element {
|
||||
const cachedFiles = React.useMemo(() => createContentCachedFiles(apis.files), [apis.files]);
|
||||
React.useEffect(() => () => cachedFiles.dispose(), [cachedFiles]);
|
||||
const cachedApis = React.useMemo<RuntimeAPIs>(
|
||||
() => ({
|
||||
...apis,
|
||||
files: withContentCache(apis.files),
|
||||
files: cachedFiles.files,
|
||||
}),
|
||||
[apis],
|
||||
[apis, cachedFiles],
|
||||
);
|
||||
return <RuntimeAPIContext.Provider value={cachedApis}>{children}</RuntimeAPIContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
@@ -24,6 +25,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getInitialSystemPreference, readEmbeddedThemeSearchParams } from './theme-embedded-bootstrap';
|
||||
import { isValidTheme } from './theme-validation';
|
||||
import { getSyncedThemeFromPayload, getSyncedThemeVariant } from './theme-sync-payload';
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
|
||||
type ThemePreferences = {
|
||||
themeMode: ThemeMode;
|
||||
@@ -172,8 +174,8 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
return existing || null;
|
||||
});
|
||||
const isVSCode = useMemo(() => isVSCodeRuntime(), []);
|
||||
const isLocalDesktopOrigin = useMemo(() => isDesktopLocalOriginActive(), []);
|
||||
const isDesktopShell = useMemo(() => detectDesktopShell(), []);
|
||||
const customThemesRequestRef = useRef(0);
|
||||
const receivesParentThemeSync = useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
@@ -249,11 +251,13 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const request = ++customThemesRequestRef.current;
|
||||
setCustomThemesLoading(true);
|
||||
try {
|
||||
const res = await runtimeFetch('/api/config/themes', {
|
||||
method: 'GET',
|
||||
credentials: isLocalDesktopOrigin ? 'omit' : 'include',
|
||||
credentials: isDesktopLocalOriginActive() ? 'omit' : 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
@@ -269,20 +273,31 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
if (request !== customThemesRequestRef.current || runtimeKey !== getRuntimeKey()) return;
|
||||
const incoming = Array.isArray(payload?.themes) ? payload.themes : [];
|
||||
const normalized = incoming.filter(isValidTheme);
|
||||
setCustomThemes(normalized);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setCustomThemesLoading(false);
|
||||
if (request === customThemesRequestRef.current && runtimeKey === getRuntimeKey()) {
|
||||
setCustomThemesLoading(false);
|
||||
}
|
||||
}
|
||||
}, [isLocalDesktopOrigin, isVSCode]);
|
||||
}, [isVSCode]);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadCustomThemes();
|
||||
}, [reloadCustomThemes]);
|
||||
|
||||
useEffect(() => subscribeRuntimeEndpointChanged((detail) => {
|
||||
if (detail.runtimeKey === detail.previousRuntimeKey || isVSCode) return;
|
||||
customThemesRequestRef.current += 1;
|
||||
setCustomThemes([]);
|
||||
setCustomThemesLoading(false);
|
||||
void reloadCustomThemes();
|
||||
}), [isVSCode, reloadCustomThemes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVSCode) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FilesAPI } from "@/lib/api/types"
|
||||
import { createContentCachedFiles } from "./content-cache-owner"
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => { resolve = res })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe("content cache owner", () => {
|
||||
test("reuses only strongly validated content", async () => {
|
||||
let reads = 0
|
||||
const files = {
|
||||
readFile: async (path: string) => ({ path, content: `value-${++reads}` }),
|
||||
statFile: async () => ({ isFile: true, isDirectory: false, size: 7, mtimeMs: 1 }),
|
||||
} as unknown as FilesAPI
|
||||
const owner = createContentCachedFiles(files)
|
||||
|
||||
expect((await owner.files.readFile!("file.ts")).content).toBe("value-1")
|
||||
expect((await owner.files.readFile!("file.ts")).content).toBe("value-1")
|
||||
expect(reads).toBe(1)
|
||||
owner.dispose()
|
||||
})
|
||||
|
||||
test("does not retain size-only reads without mtime", async () => {
|
||||
let reads = 0
|
||||
const owner = createContentCachedFiles({
|
||||
readFile: async (path: string) => ({ path, content: `value-${++reads}` }),
|
||||
statFile: async () => ({ isFile: true, isDirectory: false, size: 7 }),
|
||||
} as unknown as FilesAPI)
|
||||
|
||||
await owner.files.readFile!("file.ts")
|
||||
await owner.files.readFile!("file.ts")
|
||||
expect(reads).toBe(2)
|
||||
owner.dispose()
|
||||
})
|
||||
|
||||
test("retries a read that overlaps a write", async () => {
|
||||
const firstRead = deferred<{ path: string; content: string }>()
|
||||
let content = "old"
|
||||
let reads = 0
|
||||
const owner = createContentCachedFiles({
|
||||
readFile: async (path: string) => {
|
||||
reads += 1
|
||||
return reads === 1 ? firstRead.promise : { path, content }
|
||||
},
|
||||
statFile: async () => ({ isFile: true, isDirectory: false, size: content.length, mtimeMs: content === "old" ? 1 : 2 }),
|
||||
writeFile: async (_path: string, next: string) => { content = next },
|
||||
} as unknown as FilesAPI)
|
||||
|
||||
const reading = owner.files.readFile!("file.ts")
|
||||
await owner.files.writeFile!("file.ts", "new")
|
||||
firstRead.resolve({ path: "file.ts", content: "old" })
|
||||
|
||||
expect((await reading).content).toBe("new")
|
||||
expect(reads).toBe(2)
|
||||
owner.dispose()
|
||||
})
|
||||
|
||||
test("separates identical paths by directory scope", async () => {
|
||||
let reads = 0
|
||||
const owner = createContentCachedFiles({
|
||||
readFile: async (path: string, options?: Parameters<NonNullable<FilesAPI['readFile']>>[1]) => ({ path, content: `${options?.directory}-${++reads}` }),
|
||||
statFile: async () => ({ isFile: true, isDirectory: false, size: 1, mtimeMs: 1 }),
|
||||
} as unknown as FilesAPI)
|
||||
|
||||
const first = await owner.files.readFile!("file.ts", { directory: "/a" })
|
||||
const second = await owner.files.readFile!("file.ts", { directory: "/b" })
|
||||
expect(first.content).toBe("/a-1")
|
||||
expect(second.content).toBe("/b-2")
|
||||
owner.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { FilesAPI } from '@/lib/api/types';
|
||||
import { subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
|
||||
|
||||
const MAX_ENTRIES = 40;
|
||||
const MAX_BYTES = 20 * 1024 * 1024;
|
||||
type Entry = { content: string; path: string; sourcePath: string; size: number; mtimeMs: number; bytes: number };
|
||||
|
||||
export function createContentCachedFiles(files: FilesAPI): { files: FilesAPI; dispose: () => void } {
|
||||
const cache = new Map<string, Entry>();
|
||||
let totalBytes = 0;
|
||||
let generation = 0;
|
||||
let active = true;
|
||||
let mutationBarrier = Promise.resolve();
|
||||
|
||||
const cacheKey = (path: string, options?: Parameters<NonNullable<FilesAPI['readFile']>>[1]) =>
|
||||
JSON.stringify([options?.directory ?? '', path]);
|
||||
const contentBytes = (content: string) => new TextEncoder().encode(content).byteLength;
|
||||
const removeEntry = (key: string) => {
|
||||
const entry = cache.get(key);
|
||||
if (entry) totalBytes = Math.max(0, totalBytes - entry.bytes);
|
||||
cache.delete(key);
|
||||
};
|
||||
const removePrefix = (path: string) => {
|
||||
const prefix = path.endsWith('/') ? path : `${path}/`;
|
||||
for (const [key, entry] of cache) {
|
||||
if (entry.sourcePath === path || entry.sourcePath.startsWith(prefix)) removeEntry(key);
|
||||
}
|
||||
};
|
||||
const metadataMatches = (cached: Entry, latest: { isFile: boolean; size: number; mtimeMs?: number }) => (
|
||||
latest.isFile
|
||||
&& latest.mtimeMs !== undefined
|
||||
&& cached.mtimeMs === latest.mtimeMs
|
||||
&& cached.size === latest.size
|
||||
);
|
||||
const cacheResult = (
|
||||
key: string,
|
||||
sourcePath: string,
|
||||
result: { content: string; path: string },
|
||||
stat: { isFile: boolean; size: number; mtimeMs?: number },
|
||||
) => {
|
||||
if (!active || !stat.isFile || stat.mtimeMs === undefined) return result;
|
||||
const bytes = contentBytes(result.content);
|
||||
if (bytes > MAX_BYTES) return result;
|
||||
removeEntry(key);
|
||||
cache.set(key, { ...result, sourcePath, size: stat.size, mtimeMs: stat.mtimeMs, bytes });
|
||||
totalBytes += bytes;
|
||||
while (cache.size > MAX_ENTRIES || totalBytes > MAX_BYTES) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (!oldest) break;
|
||||
removeEntry(oldest);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const readFresh = async (
|
||||
key: string,
|
||||
path: string,
|
||||
options: Parameters<NonNullable<FilesAPI['readFile']>>[1] | undefined,
|
||||
capturedGeneration: number,
|
||||
): Promise<{ content: string; path: string }> => {
|
||||
const before = await files.statFile?.(path, options).catch(() => null);
|
||||
const result = await files.readFile!(path, options);
|
||||
const after = await files.statFile?.(path, options).catch(() => null);
|
||||
if (!active) throw new Error('File read invalidated by runtime change');
|
||||
if (capturedGeneration !== generation) return cachedReadFile!(path, options);
|
||||
const stable = before && after && before.isFile && after.isFile
|
||||
&& before.mtimeMs !== undefined && after.mtimeMs !== undefined
|
||||
&& before.size === after.size && before.mtimeMs === after.mtimeMs;
|
||||
return stable ? cacheResult(key, path, result, after) : result;
|
||||
};
|
||||
|
||||
const cachedReadFile: FilesAPI['readFile'] = files.readFile
|
||||
? async (path, options) => {
|
||||
await mutationBarrier;
|
||||
if (!active) throw new Error('File cache owner disposed');
|
||||
const capturedGeneration = generation;
|
||||
if (options?.allowOutsideWorkspace) return files.readFile!(path, options);
|
||||
const key = cacheKey(path, options);
|
||||
const hit = cache.get(key);
|
||||
if (!hit) return readFresh(key, path, options, capturedGeneration);
|
||||
const latest = await files.statFile?.(path, options).catch(() => null);
|
||||
if (!active) throw new Error('File read invalidated by runtime change');
|
||||
if (capturedGeneration !== generation) return cachedReadFile!(path, options);
|
||||
if (!latest || !metadataMatches(hit, latest)) {
|
||||
removeEntry(key);
|
||||
return readFresh(key, path, options, capturedGeneration);
|
||||
}
|
||||
cache.delete(key);
|
||||
cache.set(key, hit);
|
||||
return { content: hit.content, path: hit.path };
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const mutate = async <T>(paths: string[], operation: () => Promise<T>): Promise<T> => {
|
||||
const previous = mutationBarrier;
|
||||
let release!: () => void;
|
||||
mutationBarrier = new Promise<void>((resolve) => { release = resolve; });
|
||||
await previous;
|
||||
generation += 1;
|
||||
paths.forEach(removePrefix);
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
paths.forEach(removePrefix);
|
||||
generation += 1;
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
const cachedFiles: FilesAPI = {
|
||||
...files,
|
||||
readFile: cachedReadFile,
|
||||
writeFile: files.writeFile ? (path, content) => mutate([path], () => files.writeFile!(path, content)) : undefined,
|
||||
delete: files.delete ? (path) => mutate([path], () => files.delete!(path)) : undefined,
|
||||
rename: files.rename ? (oldPath, newPath) => mutate([oldPath, newPath], () => files.rename!(oldPath, newPath)) : undefined,
|
||||
};
|
||||
const unsubscribeRuntime = subscribeRuntimeEndpointWillChange((detail) => {
|
||||
if (detail.runtimeKey === detail.previousRuntimeKey) return;
|
||||
active = false;
|
||||
generation += 1;
|
||||
cache.clear();
|
||||
totalBytes = 0;
|
||||
});
|
||||
return {
|
||||
files: cachedFiles,
|
||||
dispose: () => {
|
||||
active = false;
|
||||
generation += 1;
|
||||
cache.clear();
|
||||
totalBytes = 0;
|
||||
unsubscribeRuntime();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -69,6 +69,7 @@ export const usePwaManifestSync = () => {
|
||||
}, [currentSessionId, sessions]);
|
||||
|
||||
const signature = React.useMemo(() => JSON.stringify(recentShortcuts), [recentShortcuts]);
|
||||
const hasRecentShortcuts = recentShortcuts.length > 0;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || !isWebRuntime()) {
|
||||
@@ -76,7 +77,7 @@ export const usePwaManifestSync = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
if (recentShortcuts.length === 0) {
|
||||
if (!hasRecentShortcuts) {
|
||||
localStorage.removeItem(PWA_RECENT_SESSIONS_STORAGE_KEY);
|
||||
} else {
|
||||
localStorage.setItem(PWA_RECENT_SESSIONS_STORAGE_KEY, signature);
|
||||
@@ -87,5 +88,5 @@ export const usePwaManifestSync = () => {
|
||||
|
||||
const win = window as ManifestSyncWindow;
|
||||
win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
|
||||
}, [recentShortcuts, signature]);
|
||||
}, [hasRecentShortcuts, signature]);
|
||||
};
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
]);
|
||||
|
||||
expect(payload).not.toBeNull();
|
||||
await sendQueuedAutoSendPayload('session-original', payload!, {
|
||||
await sendQueuedAutoSendPayload('session-original', '/repo', payload!, {
|
||||
providerID: 'provider-1',
|
||||
modelID: 'model-1',
|
||||
agent: 'agent-1',
|
||||
@@ -185,7 +185,7 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
undefined,
|
||||
'variant-1',
|
||||
'normal',
|
||||
{ sessionId: 'session-original' },
|
||||
{ sessionId: 'session-original', directory: '/repo' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React from 'react';
|
||||
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { getMessageQueueKey, parseMessageQueueKey, useMessageQueueStore, type MessageQueueTarget, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||
import { getSyncSessionStatus } from '@/sync/sync-refs';
|
||||
import { getDirectoryState } from '@/sync/sync-refs';
|
||||
import { useDirectorySync } from '@/sync/sync-context';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
|
||||
type SessionStatusType = 'idle' | 'busy' | 'retry';
|
||||
|
||||
@@ -67,6 +69,7 @@ type ResolvedQueuedSendConfig = {
|
||||
|
||||
export const sendQueuedAutoSendPayload = (
|
||||
sessionId: string,
|
||||
directory: string,
|
||||
payload: QueuedAutoSendPayload,
|
||||
resolved: ResolvedQueuedSendConfig,
|
||||
) => {
|
||||
@@ -80,7 +83,7 @@ export const sendQueuedAutoSendPayload = (
|
||||
undefined,
|
||||
resolved.variant,
|
||||
'normal',
|
||||
{ sessionId },
|
||||
{ sessionId, directory },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -140,6 +143,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
|
||||
const autoReviewRuns = useAutoReviewStore((state) => state.runsByOriginalSessionID);
|
||||
const sessionStatusRecord = useDirectorySync((state) => state.session_status);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
|
||||
const inFlightSessionsRef = React.useRef<Set<string>>(new Set());
|
||||
const sendFailuresRef = React.useRef<Map<string, QueuedAutoSendFailure>>(new Map());
|
||||
@@ -151,11 +155,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
return;
|
||||
}
|
||||
|
||||
const dispatchSessionQueue = async (sessionId: string, queueSnapshot: QueuedMessage[]) => {
|
||||
const dispatchSessionQueue = async (target: MessageQueueTarget, queueSnapshot: QueuedMessage[]) => {
|
||||
const { sessionId } = target;
|
||||
const targetKey = getMessageQueueKey(target);
|
||||
if (queueSnapshot.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (inFlightSessionsRef.current.has(sessionId)) {
|
||||
if (inFlightSessionsRef.current.has(targetKey)) {
|
||||
return;
|
||||
}
|
||||
if (hasRecentAbort(sessionId)) {
|
||||
@@ -166,7 +172,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStatus = getSyncSessionStatus(sessionId)?.type ?? 'idle';
|
||||
const currentStatus = getDirectoryState(target.directory)?.session_status?.[sessionId]?.type ?? 'idle';
|
||||
if (currentStatus !== 'idle') {
|
||||
return;
|
||||
}
|
||||
@@ -176,9 +182,9 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
return;
|
||||
}
|
||||
|
||||
const failure = sendFailuresRef.current.get(sessionId);
|
||||
const failure = sendFailuresRef.current.get(targetKey);
|
||||
if (failure && failure.messageId !== payload.queuedMessageId) {
|
||||
sendFailuresRef.current.delete(sessionId);
|
||||
sendFailuresRef.current.delete(targetKey);
|
||||
} else if (isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) {
|
||||
return;
|
||||
}
|
||||
@@ -192,28 +198,28 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
return;
|
||||
}
|
||||
|
||||
inFlightSessionsRef.current.add(sessionId);
|
||||
inFlightSessionsRef.current.add(targetKey);
|
||||
|
||||
try {
|
||||
await sendQueuedAutoSendPayload(sessionId, payload, {
|
||||
await sendQueuedAutoSendPayload(sessionId, target.directory, payload, {
|
||||
providerID: resolved.providerID,
|
||||
modelID: resolved.modelID,
|
||||
agent: resolved.agent,
|
||||
variant: resolved.variant,
|
||||
});
|
||||
useMessageQueueStore.getState().removeFromQueue(sessionId, payload.queuedMessageId);
|
||||
sendFailuresRef.current.delete(sessionId);
|
||||
useMessageQueueStore.getState().removeFromQueue(target, payload.queuedMessageId);
|
||||
sendFailuresRef.current.delete(targetKey);
|
||||
} catch (error) {
|
||||
console.warn('[queue] queued auto-send failed:', error);
|
||||
const priorFailures = failure?.messageId === payload.queuedMessageId ? failure.failures : 0;
|
||||
const failures = priorFailures + 1;
|
||||
sendFailuresRef.current.set(sessionId, {
|
||||
sendFailuresRef.current.set(targetKey, {
|
||||
messageId: payload.queuedMessageId,
|
||||
failures,
|
||||
nextAttemptAt: Date.now() + getQueuedAutoSendRetryDelayMs(failures),
|
||||
});
|
||||
} finally {
|
||||
inFlightSessionsRef.current.delete(sessionId);
|
||||
inFlightSessionsRef.current.delete(targetKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -226,7 +232,10 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
}
|
||||
|
||||
const queueEntries = Object.entries(queuedMessages);
|
||||
queueEntries.forEach(([sessionId, queue]) => {
|
||||
queueEntries.forEach(([key, queue]) => {
|
||||
const target = parseMessageQueueKey(key);
|
||||
if (!target || target.runtimeKey !== getRuntimeKey() || target.directory !== currentDirectory) return;
|
||||
const { sessionId } = target;
|
||||
const currentStatusType = (statusRecord[sessionId]?.type ?? 'idle') as SessionStatusType;
|
||||
const previousStatusType = previousStatusRef.current.get(sessionId);
|
||||
const wasAutoReviewBlocked = autoReviewBlockedSessionsRef.current.has(sessionId);
|
||||
@@ -241,12 +250,12 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
shouldDispatchQueuedAutoSend(previousStatusType, currentStatusType, queue.length > 0)
|
||||
|| (wasAutoReviewBlocked && !isAutoReviewRunning && currentStatusType === 'idle')
|
||||
)) {
|
||||
void dispatchSessionQueue(sessionId, queue);
|
||||
void dispatchSessionQueue(target, queue);
|
||||
}
|
||||
|
||||
nextStatusMap.set(sessionId, currentStatusType);
|
||||
});
|
||||
|
||||
previousStatusRef.current = nextStatusMap;
|
||||
}, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns]);
|
||||
}, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory]);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const AUTO_DELETE_KEEP_RECENT = 5;
|
||||
const AUTO_DELETE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const EMPTY_SESSIONS: Session[] = [];
|
||||
|
||||
const getSessionLastActivity = (session: Session): number => {
|
||||
return session.time?.updated ?? session.time?.created ?? 0;
|
||||
@@ -71,14 +72,17 @@ export const useSessionAutoCleanup = (enabledOrOptions?: boolean | CleanupOption
|
||||
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const isLoading = useSessionUIStore((state) => state.isLoading);
|
||||
const globalSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
||||
|
||||
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
|
||||
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
|
||||
const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction);
|
||||
const autoDeleteLastRunAt = useUIStore((state) => state.autoDeleteLastRunAt);
|
||||
const setAutoDeleteLastRunAt = useUIStore((state) => state.setAutoDeleteLastRunAt);
|
||||
const needsGlobalSessions = enabled && (!autoRun || autoDeleteEnabled);
|
||||
const globalSessions = useGlobalSessionsStore(React.useCallback(
|
||||
(state) => needsGlobalSessions ? state.activeSessions : EMPTY_SESSIONS,
|
||||
[needsGlobalSessions],
|
||||
));
|
||||
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
||||
|
||||
const [isRunning, setIsRunning] = React.useState(false);
|
||||
const runningRef = React.useRef(false);
|
||||
|
||||
@@ -359,7 +359,7 @@ const buildSnapshot = (instanceName: string): TraySnapshot => {
|
||||
const resolveStatus = (id: string): TraySessionStatus => {
|
||||
const fromStores = live.statusById.get(id);
|
||||
if (fromStores && fromStores !== 'idle') return fromStores;
|
||||
return globalStatusById.get(id)?.status ?? fromStores ?? 'idle';
|
||||
return globalStatusById.get(id)?.status.type ?? fromStores ?? 'idle';
|
||||
};
|
||||
|
||||
const rollupStatus = (family: string[]): TraySessionStatus => {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
clearChatDraft,
|
||||
createChatDraftIdentity,
|
||||
getChatDraftIdentityKey,
|
||||
readChatDraft,
|
||||
subscribeChatDraftDeletion,
|
||||
writeChatDraft,
|
||||
} from './chatDraftPersistence';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
|
||||
const storage = getDeferredSafeStorage();
|
||||
|
||||
describe('chatDraftPersistence', () => {
|
||||
beforeEach(() => {
|
||||
storage.removeItem('openchamber.chatDrafts.v2');
|
||||
});
|
||||
|
||||
test('isolates drafts by runtime, directory, and session', () => {
|
||||
const first = createChatDraftIdentity('runtime-a', '/repo-a/', 'session-1')!;
|
||||
const second = createChatDraftIdentity('runtime-b', '/repo-a', 'session-1')!;
|
||||
const third = createChatDraftIdentity('runtime-a', '/repo-b', 'session-1')!;
|
||||
writeChatDraft(first, 'first', ['file.ts']);
|
||||
writeChatDraft(second, 'second', []);
|
||||
writeChatDraft(third, 'third', []);
|
||||
|
||||
expect(readChatDraft(first)).toEqual({ text: 'first', confirmedMentions: new Set(['file.ts']) });
|
||||
expect(readChatDraft(second).text).toBe('second');
|
||||
expect(readChatDraft(third).text).toBe('third');
|
||||
});
|
||||
|
||||
test('keeps new-session drafts separate from similarly named sessions', () => {
|
||||
const newSession = createChatDraftIdentity('runtime-a', '/repo', null)!;
|
||||
const namedSession = createChatDraftIdentity('runtime-a', '/repo', '__new__')!;
|
||||
|
||||
writeChatDraft(newSession, 'new session', []);
|
||||
writeChatDraft(namedSession, 'named session', []);
|
||||
|
||||
expect(readChatDraft(newSession).text).toBe('new session');
|
||||
expect(readChatDraft(namedSession).text).toBe('named session');
|
||||
});
|
||||
|
||||
test('clears only the matching identity and notifies active composers', () => {
|
||||
const deleted = createChatDraftIdentity('runtime-a', '/repo-a', 'session-1')!;
|
||||
const retained = createChatDraftIdentity('runtime-a', '/repo-b', 'session-1')!;
|
||||
const notifications: string[] = [];
|
||||
const unsubscribe = subscribeChatDraftDeletion((identity) => notifications.push(identity.directory));
|
||||
writeChatDraft(deleted, 'delete', []);
|
||||
writeChatDraft(retained, 'retain', []);
|
||||
|
||||
clearChatDraft(deleted, true);
|
||||
unsubscribe();
|
||||
|
||||
expect(readChatDraft(deleted).text).toBe('');
|
||||
expect(readChatDraft(retained).text).toBe('retain');
|
||||
expect(notifications).toEqual(['/repo-a']);
|
||||
});
|
||||
|
||||
test('bounds persisted drafts by recency', () => {
|
||||
for (let index = 0; index < 55; index += 1) {
|
||||
const identity = createChatDraftIdentity('runtime-a', '/repo', `session-${index}`)!;
|
||||
writeChatDraft(identity, `draft-${index}`, []);
|
||||
}
|
||||
|
||||
const envelope = JSON.parse(storage.getItem('openchamber.chatDrafts.v2') ?? '{}') as { drafts?: object };
|
||||
expect(Object.keys(envelope.drafts ?? {})).toHaveLength(50);
|
||||
});
|
||||
|
||||
test('reuses a parsed envelope while the stored value is unchanged', () => {
|
||||
const identity = createChatDraftIdentity('runtime-cache', '/repo', 'session-1')!;
|
||||
const key = getChatDraftIdentityKey(identity);
|
||||
storage.setItem('openchamber.chatDrafts.v2', JSON.stringify({
|
||||
version: 2,
|
||||
drafts: { [key]: { text: 'cached', confirmedMentions: [], touchedAt: 1 } },
|
||||
}));
|
||||
const originalParse = JSON.parse;
|
||||
let parseCalls = 0;
|
||||
JSON.parse = ((...args: Parameters<typeof JSON.parse>) => {
|
||||
parseCalls += 1;
|
||||
return originalParse(...args);
|
||||
}) as typeof JSON.parse;
|
||||
|
||||
try {
|
||||
expect(readChatDraft(identity).text).toBe('cached');
|
||||
expect(readChatDraft(identity).text).toBe('cached');
|
||||
expect(parseCalls).toBe(1);
|
||||
} finally {
|
||||
JSON.parse = originalParse;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { countSyncPersistenceSerialization } from '@/sync/performance-diagnostics';
|
||||
|
||||
export type ChatDraftIdentity = {
|
||||
runtimeKey: string;
|
||||
directory: string;
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
export type ChatDraftSnapshot = {
|
||||
text: string;
|
||||
confirmedMentions: Set<string>;
|
||||
};
|
||||
|
||||
type PersistedChatDraft = {
|
||||
text: string;
|
||||
confirmedMentions: string[];
|
||||
touchedAt: number;
|
||||
};
|
||||
|
||||
type PersistedChatDraftEnvelope = {
|
||||
version: 2;
|
||||
drafts: Record<string, PersistedChatDraft>;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'openchamber.chatDrafts.v2';
|
||||
const MAX_DRAFTS = 50;
|
||||
const storage = getDeferredSafeStorage();
|
||||
const deletionListeners = new Set<(identity: ChatDraftIdentity) => void>();
|
||||
let cachedRawEnvelope: string | null | undefined;
|
||||
let cachedEnvelope: PersistedChatDraftEnvelope | undefined;
|
||||
|
||||
export const createChatDraftIdentity = (
|
||||
runtimeKey: string,
|
||||
directory: string | null | undefined,
|
||||
sessionId: string | null,
|
||||
): ChatDraftIdentity | null => {
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
if (!runtimeKey || !normalizedDirectory) return null;
|
||||
return { runtimeKey, directory: normalizedDirectory, sessionId };
|
||||
};
|
||||
|
||||
export const getChatDraftIdentityKey = (identity: ChatDraftIdentity): string =>
|
||||
JSON.stringify([identity.runtimeKey, identity.directory, identity.sessionId]);
|
||||
|
||||
const readEnvelope = (): PersistedChatDraftEnvelope => {
|
||||
const raw = storage.getItem(STORAGE_KEY);
|
||||
if (raw === cachedRawEnvelope && cachedEnvelope) return cachedEnvelope;
|
||||
try {
|
||||
const parsed = JSON.parse(raw ?? '') as Partial<PersistedChatDraftEnvelope>;
|
||||
if (parsed.version !== 2 || !parsed.drafts || typeof parsed.drafts !== 'object' || Array.isArray(parsed.drafts)) {
|
||||
cachedRawEnvelope = raw;
|
||||
cachedEnvelope = { version: 2, drafts: {} };
|
||||
return cachedEnvelope;
|
||||
}
|
||||
const drafts: Record<string, PersistedChatDraft> = {};
|
||||
for (const [key, value] of Object.entries(parsed.drafts)) {
|
||||
if (!value || typeof value !== 'object') continue;
|
||||
const draft = value as Partial<PersistedChatDraft>;
|
||||
if (typeof draft.text !== 'string' || !Array.isArray(draft.confirmedMentions) || typeof draft.touchedAt !== 'number') continue;
|
||||
drafts[key] = {
|
||||
text: draft.text,
|
||||
confirmedMentions: draft.confirmedMentions.filter((mention): mention is string => typeof mention === 'string'),
|
||||
touchedAt: draft.touchedAt,
|
||||
};
|
||||
}
|
||||
cachedRawEnvelope = raw;
|
||||
cachedEnvelope = { version: 2, drafts };
|
||||
return cachedEnvelope;
|
||||
} catch {
|
||||
storage.removeItem(STORAGE_KEY);
|
||||
cachedRawEnvelope = null;
|
||||
cachedEnvelope = { version: 2, drafts: {} };
|
||||
return cachedEnvelope;
|
||||
}
|
||||
};
|
||||
|
||||
const writeEnvelope = (envelope: PersistedChatDraftEnvelope): void => {
|
||||
const serialized = JSON.stringify(envelope);
|
||||
cachedRawEnvelope = serialized;
|
||||
cachedEnvelope = envelope;
|
||||
countSyncPersistenceSerialization(serialized);
|
||||
storage.setItem(STORAGE_KEY, serialized);
|
||||
};
|
||||
|
||||
export const readChatDraft = (identity: ChatDraftIdentity | null): ChatDraftSnapshot => {
|
||||
if (!identity) return { text: '', confirmedMentions: new Set() };
|
||||
const persisted = readEnvelope().drafts[getChatDraftIdentityKey(identity)];
|
||||
return persisted
|
||||
? { text: persisted.text, confirmedMentions: new Set(persisted.confirmedMentions) }
|
||||
: { text: '', confirmedMentions: new Set() };
|
||||
};
|
||||
|
||||
export const writeChatDraft = (
|
||||
identity: ChatDraftIdentity | null,
|
||||
text: string,
|
||||
confirmedMentions: Iterable<string>,
|
||||
): void => {
|
||||
if (!identity) return;
|
||||
const envelope = readEnvelope();
|
||||
const key = getChatDraftIdentityKey(identity);
|
||||
const mentions = Array.from(new Set(confirmedMentions));
|
||||
if (!text && mentions.length === 0) {
|
||||
if (!(key in envelope.drafts)) return;
|
||||
delete envelope.drafts[key];
|
||||
} else {
|
||||
envelope.drafts[key] = { text, confirmedMentions: mentions, touchedAt: Date.now() };
|
||||
}
|
||||
|
||||
const retained = Object.entries(envelope.drafts)
|
||||
.sort((left, right) => right[1].touchedAt - left[1].touchedAt)
|
||||
.slice(0, MAX_DRAFTS);
|
||||
writeEnvelope({ version: 2, drafts: Object.fromEntries(retained) });
|
||||
};
|
||||
|
||||
export const clearChatDraft = (identity: ChatDraftIdentity, notify = false): void => {
|
||||
writeChatDraft(identity, '', []);
|
||||
if (notify) deletionListeners.forEach((listener) => listener(identity));
|
||||
};
|
||||
|
||||
export const subscribeChatDraftDeletion = (listener: (identity: ChatDraftIdentity) => void): (() => void) => {
|
||||
deletionListeners.add(listener);
|
||||
return () => deletionListeners.delete(listener);
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
import type {
|
||||
GitStatus,
|
||||
GitDiffResponse,
|
||||
@@ -37,6 +35,7 @@ import type {
|
||||
} from './api/types';
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
import { getRuntimeUrlResolver } from './runtime-url';
|
||||
import { getRuntimeKey } from './runtime-switch';
|
||||
|
||||
const API_BASE = '/api/git';
|
||||
const GIT_STATUS_CACHE_TTL_MS = 1200;
|
||||
@@ -48,24 +47,22 @@ const gitRepoCache = new Map<string, { value: boolean; expiresAt: number }>();
|
||||
const gitRepoInFlight = new Map<string, Promise<boolean>>();
|
||||
|
||||
const normalizeDirectoryKey = (directory: string): string => directory.trim();
|
||||
const getStatusCacheKey = (directory: string, mode?: 'light'): string =>
|
||||
mode === 'light' ? `${normalizeDirectoryKey(directory)}::light` : normalizeDirectoryKey(directory);
|
||||
const getDirectoryCacheKey = (runtimeKey: string, directory: string): string =>
|
||||
JSON.stringify([runtimeKey, normalizeDirectoryKey(directory)]);
|
||||
const getStatusCacheKey = (runtimeKey: string, directory: string, mode?: 'light'): string =>
|
||||
JSON.stringify([runtimeKey, normalizeDirectoryKey(directory), mode ?? 'full']);
|
||||
|
||||
const getStatusCacheVersion = (directory: string): number =>
|
||||
gitStatusCacheVersions.get(normalizeDirectoryKey(directory)) ?? 0;
|
||||
const getStatusCacheVersion = (runtimeKey: string, directory: string): number =>
|
||||
gitStatusCacheVersions.get(getDirectoryCacheKey(runtimeKey, directory)) ?? 0;
|
||||
|
||||
const invalidateGitStatusCache = (directory: string): void => {
|
||||
const key = normalizeDirectoryKey(directory);
|
||||
gitStatusCacheVersions.set(key, getStatusCacheVersion(directory) + 1);
|
||||
for (const cacheKey of Array.from(gitStatusCache.keys())) {
|
||||
if (cacheKey === key || cacheKey.startsWith(`${key}::`)) {
|
||||
gitStatusCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
for (const cacheKey of Array.from(gitStatusInFlight.keys())) {
|
||||
if (cacheKey === key || cacheKey.startsWith(`${key}::`)) {
|
||||
gitStatusInFlight.delete(cacheKey);
|
||||
}
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const key = getDirectoryCacheKey(runtimeKey, directory);
|
||||
gitStatusCacheVersions.set(key, getStatusCacheVersion(runtimeKey, directory) + 1);
|
||||
for (const mode of [undefined, 'light'] as const) {
|
||||
const statusKey = getStatusCacheKey(runtimeKey, directory, mode);
|
||||
gitStatusCache.delete(statusKey);
|
||||
gitStatusInFlight.delete(statusKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -81,7 +78,7 @@ function buildUrl(
|
||||
}
|
||||
|
||||
export async function checkIsGitRepository(directory: string): Promise<boolean> {
|
||||
const key = normalizeDirectoryKey(directory);
|
||||
const key = getDirectoryCacheKey(getRuntimeKey(), directory);
|
||||
const now = Date.now();
|
||||
const cached = gitRepoCache.get(key);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
@@ -119,7 +116,8 @@ export async function checkIsGitRepository(directory: string): Promise<boolean>
|
||||
|
||||
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
|
||||
const mode = options?.mode;
|
||||
const key = getStatusCacheKey(directory, mode);
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const key = getStatusCacheKey(runtimeKey, directory, mode);
|
||||
const now = Date.now();
|
||||
const cached = gitStatusCache.get(key);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
@@ -132,13 +130,13 @@ export async function getGitStatus(directory: string, options?: { mode?: 'light'
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const cacheVersion = getStatusCacheVersion(directory);
|
||||
const cacheVersion = getStatusCacheVersion(runtimeKey, directory);
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git status: ${response.statusText}`);
|
||||
}
|
||||
const payload = await response.json() as GitStatus;
|
||||
if (getStatusCacheVersion(directory) === cacheVersion) {
|
||||
if (getStatusCacheVersion(runtimeKey, directory) === cacheVersion) {
|
||||
gitStatusCache.set(key, {
|
||||
value: payload,
|
||||
expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS,
|
||||
|
||||
@@ -1860,6 +1860,12 @@ export const dict = {
|
||||
'chat.container.returnToParent.title': 'Return to parent session',
|
||||
'chat.container.returnToParent.label': 'Parent',
|
||||
'chat.container.readOnlySubagentPromptBanner': 'Subagent sessions cannot be prompted.',
|
||||
'chat.container.sessionLoadError.title': 'Session could not be loaded',
|
||||
'chat.container.sessionLoadError.description': 'Check the connection and try loading this session again.',
|
||||
'chat.container.sessionLoadError.retry': 'Try again',
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'Loading sessions…',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'Could not refresh sessions.',
|
||||
'sessions.sidebar.group.empty.retry': 'Try again',
|
||||
'chat.unifiedControls.title': 'Controls',
|
||||
'chat.unifiedControls.model.title': 'Model',
|
||||
'chat.unifiedControls.model.noRecent': 'No recent models',
|
||||
|
||||
@@ -1838,6 +1838,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.container.returnToParent.title": "Volver a la sesión principal",
|
||||
"chat.container.returnToParent.label": "Principal",
|
||||
"chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.",
|
||||
"chat.container.sessionLoadError.title": "No se pudo cargar la sesión",
|
||||
"chat.container.sessionLoadError.description": "Comprueba la conexión e intenta cargar esta sesión de nuevo.",
|
||||
"chat.container.sessionLoadError.retry": "Reintentar",
|
||||
"sessions.sidebar.group.empty.loadingSessions": "Cargando sesiones…",
|
||||
"sessions.sidebar.group.empty.loadFailed": "No se pudieron actualizar las sesiones.",
|
||||
"sessions.sidebar.group.empty.retry": "Reintentar",
|
||||
"chat.unifiedControls.title": "Controles",
|
||||
"chat.unifiedControls.model.title": "Modelo",
|
||||
"chat.unifiedControls.model.noRecent": "No hay modelos recientes",
|
||||
|
||||
@@ -1656,6 +1656,12 @@ export const dict = {
|
||||
'chat.container.returnToParent.title': 'Retour à la session parents',
|
||||
'chat.container.returnToParent.label': 'Mère',
|
||||
'chat.container.readOnlySubagentPromptBanner': 'Les sessions de sous-agent ne peuvent pas être invitées.',
|
||||
'chat.container.sessionLoadError.title': 'Impossible de charger la session',
|
||||
'chat.container.sessionLoadError.description': 'Vérifiez la connexion et essayez de charger à nouveau cette session.',
|
||||
'chat.container.sessionLoadError.retry': 'Réessayer',
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'Chargement des sessions…',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'Impossible d’actualiser les sessions.',
|
||||
'sessions.sidebar.group.empty.retry': 'Réessayer',
|
||||
'chat.unifiedControls.title': 'Contrôles',
|
||||
'chat.unifiedControls.model.title': 'Modèle',
|
||||
'chat.unifiedControls.model.noRecent': 'Aucun modèle récent',
|
||||
|
||||
@@ -1856,6 +1856,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.container.returnToParent.title': '親セッションに戻る',
|
||||
'chat.container.returnToParent.label': '親',
|
||||
'chat.container.readOnlySubagentPromptBanner': 'サブエージェントセッションはプロンプトを受け付けません。',
|
||||
'chat.container.sessionLoadError.title': 'セッションを読み込めませんでした',
|
||||
'chat.container.sessionLoadError.description': '接続を確認して、このセッションをもう一度読み込んでください。',
|
||||
'chat.container.sessionLoadError.retry': '再試行',
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'セッションを読み込んでいます…',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'セッションを更新できませんでした。',
|
||||
'sessions.sidebar.group.empty.retry': '再試行',
|
||||
'chat.unifiedControls.title': 'コントロール',
|
||||
'chat.unifiedControls.model.title': 'モデル',
|
||||
'chat.unifiedControls.model.noRecent': '最近のモデルはありません',
|
||||
|
||||
@@ -1862,6 +1862,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.container.returnToParent.title': '상위 세션으로 돌아가기',
|
||||
'chat.container.returnToParent.label': '상위',
|
||||
'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.',
|
||||
'chat.container.sessionLoadError.title': '세션을 불러올 수 없습니다',
|
||||
'chat.container.sessionLoadError.description': '연결을 확인한 후 이 세션을 다시 불러오세요.',
|
||||
'chat.container.sessionLoadError.retry': '다시 시도',
|
||||
'sessions.sidebar.group.empty.loadingSessions': '세션을 불러오는 중…',
|
||||
'sessions.sidebar.group.empty.loadFailed': '세션을 새로 고칠 수 없습니다.',
|
||||
'sessions.sidebar.group.empty.retry': '다시 시도',
|
||||
'chat.unifiedControls.title': '컨트롤',
|
||||
'chat.unifiedControls.model.title': '모델',
|
||||
'chat.unifiedControls.model.noRecent': '최근 모델 없음',
|
||||
|
||||
@@ -749,6 +749,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej',
|
||||
'chat.container.returnToParent.label': 'Nadrzędna',
|
||||
'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.',
|
||||
'chat.container.sessionLoadError.title': 'Nie udało się wczytać sesji',
|
||||
'chat.container.sessionLoadError.description': 'Sprawdź połączenie i spróbuj ponownie wczytać tę sesję.',
|
||||
'chat.container.sessionLoadError.retry': 'Spróbuj ponownie',
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'Wczytywanie sesji…',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'Nie udało się odświeżyć sesji.',
|
||||
'sessions.sidebar.group.empty.retry': 'Spróbuj ponownie',
|
||||
'chat.unifiedControls.title': 'Kontrolki',
|
||||
'chat.unifiedControls.model.title': 'Model',
|
||||
'chat.unifiedControls.model.noRecent': 'Brak ostatnich modeli',
|
||||
|
||||
@@ -1838,6 +1838,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.container.returnToParent.title": "Voltar para a sessão principal",
|
||||
"chat.container.returnToParent.label": "Principal",
|
||||
"chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.",
|
||||
"chat.container.sessionLoadError.title": "Não foi possível carregar a sessão",
|
||||
"chat.container.sessionLoadError.description": "Verifique a conexão e tente carregar esta sessão novamente.",
|
||||
"chat.container.sessionLoadError.retry": "Tentar novamente",
|
||||
"sessions.sidebar.group.empty.loadingSessions": "Carregando sessões…",
|
||||
"sessions.sidebar.group.empty.loadFailed": "Não foi possível atualizar as sessões.",
|
||||
"sessions.sidebar.group.empty.retry": "Tentar novamente",
|
||||
"chat.unifiedControls.title": "Controles",
|
||||
"chat.unifiedControls.model.title": "Modelo",
|
||||
"chat.unifiedControls.model.noRecent": "Não há modelos recentes",
|
||||
|
||||
@@ -1838,6 +1838,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.container.returnToParent.title": "Повернутися до батьківської сесії",
|
||||
"chat.container.returnToParent.label": "Батьківська",
|
||||
"chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.",
|
||||
"chat.container.sessionLoadError.title": "Не вдалося завантажити сесію",
|
||||
"chat.container.sessionLoadError.description": "Перевірте з’єднання та спробуйте завантажити цю сесію ще раз.",
|
||||
"chat.container.sessionLoadError.retry": "Спробувати знову",
|
||||
"sessions.sidebar.group.empty.loadingSessions": "Завантаження сесій…",
|
||||
"sessions.sidebar.group.empty.loadFailed": "Не вдалося оновити сесії.",
|
||||
"sessions.sidebar.group.empty.retry": "Спробувати знову",
|
||||
"chat.unifiedControls.title": "Елементи керування",
|
||||
"chat.unifiedControls.model.title": "Модель",
|
||||
"chat.unifiedControls.model.noRecent": "Немає останніх моделей",
|
||||
|
||||
@@ -1826,6 +1826,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.container.returnToParent.title': '返回父会话',
|
||||
'chat.container.returnToParent.label': '父级',
|
||||
'chat.container.readOnlySubagentPromptBanner': '无法向子智能体会话发送提示。',
|
||||
'chat.container.sessionLoadError.title': '无法加载会话',
|
||||
'chat.container.sessionLoadError.description': '请检查连接,然后重新加载此会话。',
|
||||
'chat.container.sessionLoadError.retry': '重试',
|
||||
'sessions.sidebar.group.empty.loadingSessions': '正在加载会话…',
|
||||
'sessions.sidebar.group.empty.loadFailed': '无法刷新会话。',
|
||||
'sessions.sidebar.group.empty.retry': '重试',
|
||||
'chat.unifiedControls.title': '控制',
|
||||
'chat.unifiedControls.model.title': '模型',
|
||||
'chat.unifiedControls.model.noRecent': '没有最近使用的模型',
|
||||
|
||||
@@ -1830,6 +1830,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.container.returnToParent.title': '返回父會話',
|
||||
'chat.container.returnToParent.label': '父級',
|
||||
'chat.container.readOnlySubagentPromptBanner': '無法向子 Agent 會話傳送提示。',
|
||||
'chat.container.sessionLoadError.title': '無法載入工作階段',
|
||||
'chat.container.sessionLoadError.description': '請檢查連線,然後重新載入此工作階段。',
|
||||
'chat.container.sessionLoadError.retry': '再試一次',
|
||||
'sessions.sidebar.group.empty.loadingSessions': '正在載入工作階段…',
|
||||
'sessions.sidebar.group.empty.loadFailed': '無法重新整理工作階段。',
|
||||
'sessions.sidebar.group.empty.retry': '再試一次',
|
||||
'chat.unifiedControls.title': '控制',
|
||||
'chat.unifiedControls.model.title': '模型',
|
||||
'chat.unifiedControls.model.noRecent': '沒有最近使用的模型',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
|
||||
|
||||
type ModelRef = { providerID: string; modelID: string };
|
||||
type ModelPrefsPayload = {
|
||||
@@ -75,9 +76,13 @@ export const startModelPrefsAutoSave = () => {
|
||||
let timer: number | null = null;
|
||||
let lastSent: ModelPrefsPayload | null = null;
|
||||
let didSkipInitial = false;
|
||||
let scheduledRuntimeKey: string | null = null;
|
||||
|
||||
const flush = () => {
|
||||
timer = null;
|
||||
const runtimeKey = scheduledRuntimeKey;
|
||||
scheduledRuntimeKey = null;
|
||||
if (!runtimeKey || runtimeKey !== getRuntimeKey()) return;
|
||||
const payload = snapshotModelPrefs();
|
||||
|
||||
if (lastSent && modelPrefsEqual(lastSent, payload)) {
|
||||
@@ -97,9 +102,17 @@ export const startModelPrefsAutoSave = () => {
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
scheduledRuntimeKey = getRuntimeKey();
|
||||
timer = window.setTimeout(flush, 1200);
|
||||
};
|
||||
|
||||
const unsubscribeRuntime = subscribeRuntimeEndpointWillChange(() => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
timer = null;
|
||||
scheduledRuntimeKey = null;
|
||||
lastSent = null;
|
||||
});
|
||||
|
||||
const unsubscribe = useUIStore.subscribe((state, prevState) => {
|
||||
const next = {
|
||||
favoriteModels: state.favoriteModels,
|
||||
@@ -125,6 +138,7 @@ export const startModelPrefsAutoSave = () => {
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribeRuntime();
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, mock, test } from "bun:test"
|
||||
|
||||
let runtimeKey = "runtime-a"
|
||||
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey }))
|
||||
|
||||
const { assertProviderCircuitClosed, recordProviderError, recordProviderSuccess } = await import("./provider-tracker")
|
||||
|
||||
test("isolates provider circuit state by runtime", () => {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) recordProviderError("provider", 503)
|
||||
expect(() => assertProviderCircuitClosed("provider")).toThrow()
|
||||
|
||||
runtimeKey = "runtime-b"
|
||||
assertProviderCircuitClosed("provider")
|
||||
|
||||
runtimeKey = "runtime-a"
|
||||
recordProviderSuccess("provider")
|
||||
assertProviderCircuitClosed("provider")
|
||||
})
|
||||
@@ -8,6 +8,8 @@
|
||||
* Inspired by HiveMind (arXiv:2604.17111) OS-inspired scheduling primitives.
|
||||
*/
|
||||
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch'
|
||||
|
||||
const DEFAULT_CIRCUIT_BREAK_THRESHOLD = 3
|
||||
const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000
|
||||
const DEFAULT_RETRY_BASE_DELAY_MS = 1000
|
||||
@@ -15,6 +17,7 @@ const DEFAULT_RETRY_MAX_DELAY_MS = 32_000
|
||||
const DEFAULT_RETRY_MAX_ATTEMPTS = 3
|
||||
const PROVIDER_EVICTION_TTL_MS = 60 * 60 * 1000
|
||||
const PROVIDER_EVICTION_INTERVAL_MS = 10 * 60 * 1000
|
||||
const PROVIDER_MAX_ENTRIES = 200
|
||||
|
||||
const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504])
|
||||
|
||||
@@ -27,15 +30,14 @@ type ProviderState = {
|
||||
}
|
||||
|
||||
const providers = new Map<string, ProviderState>()
|
||||
const providerKey = (providerID: string): string => JSON.stringify([getRuntimeKey(), providerID])
|
||||
|
||||
function evictStaleProviders(): void {
|
||||
const now = Date.now()
|
||||
for (const [providerID, state] of providers) {
|
||||
if (
|
||||
state.consecutiveErrors === 0 &&
|
||||
now - state.lastErrorAt > PROVIDER_EVICTION_TTL_MS
|
||||
) {
|
||||
providers.delete(providerID)
|
||||
for (const [key, state] of providers) {
|
||||
const lastActivityAt = Math.max(state.lastErrorAt, state.circuitOpenAt)
|
||||
if (now - lastActivityAt > PROVIDER_EVICTION_TTL_MS) {
|
||||
providers.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +48,8 @@ if (typeof setInterval !== 'undefined') {
|
||||
}
|
||||
|
||||
function getOrCreateProvider(providerID: string): ProviderState {
|
||||
let state = providers.get(providerID)
|
||||
const key = providerKey(providerID)
|
||||
let state = providers.get(key)
|
||||
if (!state) {
|
||||
state = {
|
||||
consecutiveErrors: 0,
|
||||
@@ -55,17 +58,19 @@ function getOrCreateProvider(providerID: string): ProviderState {
|
||||
circuitOpenAt: 0,
|
||||
circuitCooldownMs: DEFAULT_CIRCUIT_COOLDOWN_MS,
|
||||
}
|
||||
providers.set(providerID, state)
|
||||
providers.set(key, state)
|
||||
while (providers.size > PROVIDER_MAX_ENTRIES) {
|
||||
const oldest = providers.keys().next().value
|
||||
if (!oldest) break
|
||||
providers.delete(oldest)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export function recordProviderSuccess(providerID: string): void {
|
||||
if (!providerID) return
|
||||
const state = providers.get(providerID)
|
||||
if (!state) return
|
||||
state.consecutiveErrors = 0
|
||||
state.lastErrorAt = 0
|
||||
providers.delete(providerKey(providerID))
|
||||
}
|
||||
|
||||
export function recordProviderError(providerID: string, status?: number): void {
|
||||
@@ -91,7 +96,7 @@ function isCircuitBreakerStatus(status?: number): boolean {
|
||||
}
|
||||
|
||||
function isCircuitOpen(providerID: string): boolean {
|
||||
const state = providers.get(providerID)
|
||||
const state = providers.get(providerKey(providerID))
|
||||
if (!state?.circuitOpen) return false
|
||||
|
||||
const elapsed = Date.now() - state.circuitOpenAt
|
||||
|
||||
@@ -5,8 +5,10 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
|
||||
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import {
|
||||
applyPersistedHomeDirectoryToWindow,
|
||||
getRuntimeSettingsMirrorStorageKey,
|
||||
getSettingsSaveState,
|
||||
invalidateSettingsCache,
|
||||
subscribeToSettingsSaveState,
|
||||
@@ -315,6 +317,88 @@ describe('updateDesktopSettings', () => {
|
||||
expect(useUIStore.getState().terminalShell).toBe('bash');
|
||||
});
|
||||
|
||||
test('isolates local settings mirrors and removes values omitted by the next runtime', async () => {
|
||||
getWindow();
|
||||
localStorage.clear();
|
||||
switchRuntimeEndpoint({ apiBaseUrl: 'https://mirror-a.example', runtimeKey: 'mirror-a' });
|
||||
registerSettingsApi(async () => ({}), async () => ({
|
||||
settings: {
|
||||
themeId: 'theme-a',
|
||||
directoryShowHidden: true,
|
||||
sttModel: 'model-a',
|
||||
draftStartersCraftGoalAdded: true,
|
||||
},
|
||||
source: 'web',
|
||||
}));
|
||||
await syncDesktopSettings();
|
||||
|
||||
switchRuntimeEndpoint({ apiBaseUrl: 'https://mirror-b.example', runtimeKey: 'mirror-b' });
|
||||
registerSettingsApi(async () => ({}), async () => ({
|
||||
settings: { draftStartersCraftGoalAdded: true },
|
||||
source: 'web',
|
||||
}));
|
||||
await syncDesktopSettings();
|
||||
|
||||
expect(localStorage.getItem('selectedThemeId')).toBeNull();
|
||||
expect(localStorage.getItem('directoryTreeShowHidden')).toBeNull();
|
||||
expect(localStorage.getItem('sttModel')).toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem(getRuntimeSettingsMirrorStorageKey('mirror-a')) ?? '{}')).toEqual({
|
||||
themeId: 'theme-a',
|
||||
directoryShowHidden: true,
|
||||
sttModel: 'model-a',
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem(getRuntimeSettingsMirrorStorageKey('mirror-b')) ?? '{}')).toEqual({});
|
||||
});
|
||||
|
||||
test('resets in-memory preferences omitted by an authoritative runtime snapshot', async () => {
|
||||
getWindow();
|
||||
switchRuntimeEndpoint({ apiBaseUrl: 'https://preferences-a.example', runtimeKey: 'preferences-a' });
|
||||
registerSettingsApi(async () => ({}), async () => ({
|
||||
settings: {
|
||||
showReasoningTraces: false,
|
||||
terminalShell: 'fish',
|
||||
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }],
|
||||
followUpBehavior: 'steer',
|
||||
draftStarters: [{ type: 'command', name: 'runtime-a' }],
|
||||
draftStartersCraftGoalAdded: true,
|
||||
},
|
||||
source: 'web',
|
||||
}));
|
||||
await syncDesktopSettings();
|
||||
|
||||
expect(useUIStore.getState().showReasoningTraces).toBe(false);
|
||||
expect(useUIStore.getState().terminalShell).toBe('fish');
|
||||
expect(useUIStore.getState().favoriteModels).toHaveLength(1);
|
||||
expect(useUIStore.getState().globalDraftStarters).toEqual([{ type: 'command', name: 'runtime-a' }]);
|
||||
expect(useMessageQueueStore.getState().followUpBehavior).toBe('steer');
|
||||
|
||||
switchRuntimeEndpoint({ apiBaseUrl: 'https://preferences-b.example', runtimeKey: 'preferences-b' });
|
||||
registerSettingsApi(async () => ({}), async () => ({
|
||||
settings: { draftStartersCraftGoalAdded: true },
|
||||
source: 'web',
|
||||
}));
|
||||
await syncDesktopSettings();
|
||||
|
||||
expect(useUIStore.getState().showReasoningTraces).toBe(true);
|
||||
expect(useUIStore.getState().terminalShell).toBe('auto');
|
||||
expect(useUIStore.getState().favoriteModels).toEqual([]);
|
||||
expect(useUIStore.getState().globalDraftStarters).toBeNull();
|
||||
expect(useMessageQueueStore.getState().followUpBehavior).toBe('queue');
|
||||
});
|
||||
|
||||
test('treats settings save responses as partial patches', async () => {
|
||||
getWindow();
|
||||
localStorage.setItem('selectedThemeId', 'existing-theme');
|
||||
useUIStore.getState().setTerminalShell('fish');
|
||||
registerSettingsSave(async () => ({ showReasoningTraces: false }));
|
||||
|
||||
await updateDesktopSettings({ showReasoningTraces: false });
|
||||
|
||||
expect(useUIStore.getState().showReasoningTraces).toBe(false);
|
||||
expect(useUIStore.getState().terminalShell).toBe('fish');
|
||||
expect(localStorage.getItem('selectedThemeId')).toBe('existing-theme');
|
||||
});
|
||||
|
||||
test('applies model selector settings from server settings', async () => {
|
||||
getWindow();
|
||||
const settings = {
|
||||
|
||||
@@ -2,7 +2,13 @@ import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { createProjectIdFromPath } from '@/lib/projectId';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions';
|
||||
import { isFollowUpBehavior, normalizeFollowUpBehavior, useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore';
|
||||
import {
|
||||
DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||
isFollowUpBehavior,
|
||||
normalizeFollowUpBehavior,
|
||||
useMessageQueueStore,
|
||||
type FollowUpBehavior,
|
||||
} from '@/stores/messageQueueStore';
|
||||
import { setDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||
import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
|
||||
@@ -12,6 +18,8 @@ import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { isTerminalShell } from '@/lib/terminalShell';
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointChanged, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
|
||||
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/themes';
|
||||
import { DEFAULT_OPEN_IN_APP_ID } from '@/lib/openInApps';
|
||||
|
||||
export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -28,32 +36,80 @@ export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void
|
||||
}
|
||||
};
|
||||
|
||||
const SETTINGS_MIRROR_INDEX_KEY = 'openchamber.settingsMirror.v2.index';
|
||||
const SETTINGS_MIRROR_KEY_PREFIX = 'openchamber.settingsMirror.v2:';
|
||||
const MAX_SETTINGS_MIRROR_RUNTIMES = 5;
|
||||
|
||||
export const getRuntimeSettingsMirrorStorageKey = (runtimeKey: string): string =>
|
||||
`${SETTINGS_MIRROR_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`;
|
||||
|
||||
const setOrRemoveLocalStorage = (key: string, value: string | null): void => {
|
||||
if (value === null) {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
};
|
||||
|
||||
const persistRuntimeSettingsMirror = (settings: DesktopSettings, runtimeKey: string): void => {
|
||||
const mirror = {
|
||||
themeId: settings.themeId,
|
||||
themeVariant: settings.themeVariant,
|
||||
lightThemeId: settings.lightThemeId,
|
||||
darkThemeId: settings.darkThemeId,
|
||||
useSystemTheme: settings.useSystemTheme,
|
||||
lastDirectory: settings.lastDirectory,
|
||||
homeDirectory: settings.homeDirectory,
|
||||
projects: settings.projects,
|
||||
activeProjectId: settings.activeProjectId,
|
||||
pinnedDirectories: settings.pinnedDirectories,
|
||||
gitmojiEnabled: settings.gitmojiEnabled,
|
||||
directoryShowHidden: settings.directoryShowHidden,
|
||||
filesViewShowGitignored: settings.filesViewShowGitignored,
|
||||
openInAppId: settings.openInAppId,
|
||||
pwaAppName: settings.pwaAppName,
|
||||
mobileKeyboardMode: settings.mobileKeyboardMode,
|
||||
openCodeUpdateToastDismissedVersion: settings.openCodeUpdateToastDismissedVersion,
|
||||
dictationEnabled: settings.dictationEnabled,
|
||||
sttProvider: settings.sttProvider,
|
||||
sttServerUrl: settings.sttServerUrl,
|
||||
sttModel: settings.sttModel,
|
||||
sttLocalModel: settings.sttLocalModel,
|
||||
sttLanguage: settings.sttLanguage,
|
||||
};
|
||||
localStorage.setItem(getRuntimeSettingsMirrorStorageKey(runtimeKey), JSON.stringify(mirror));
|
||||
|
||||
let previous: string[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(SETTINGS_MIRROR_INDEX_KEY) ?? '[]') as unknown;
|
||||
if (Array.isArray(parsed)) previous = parsed.filter((entry): entry is string => typeof entry === 'string');
|
||||
} catch {
|
||||
previous = [];
|
||||
}
|
||||
const runtimes = [runtimeKey, ...previous.filter((entry) => entry !== runtimeKey)].slice(0, MAX_SETTINGS_MIRROR_RUNTIMES);
|
||||
for (const staleRuntime of previous) {
|
||||
if (!runtimes.includes(staleRuntime)) localStorage.removeItem(getRuntimeSettingsMirrorStorageKey(staleRuntime));
|
||||
}
|
||||
localStorage.setItem(SETTINGS_MIRROR_INDEX_KEY, JSON.stringify(runtimes));
|
||||
};
|
||||
|
||||
const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.themeId) {
|
||||
localStorage.setItem('selectedThemeId', settings.themeId);
|
||||
}
|
||||
if (settings.themeVariant) {
|
||||
localStorage.setItem('selectedThemeVariant', settings.themeVariant);
|
||||
}
|
||||
if (settings.lightThemeId) {
|
||||
localStorage.setItem('lightThemeId', settings.lightThemeId);
|
||||
}
|
||||
if (settings.darkThemeId) {
|
||||
localStorage.setItem('darkThemeId', settings.darkThemeId);
|
||||
}
|
||||
if (typeof settings.useSystemTheme === 'boolean') {
|
||||
localStorage.setItem('useSystemTheme', String(settings.useSystemTheme));
|
||||
}
|
||||
if (settings.lastDirectory) {
|
||||
localStorage.setItem('lastDirectory', settings.lastDirectory);
|
||||
}
|
||||
persistRuntimeSettingsMirror(settings, getRuntimeKey());
|
||||
setOrRemoveLocalStorage('selectedThemeId', settings.themeId || null);
|
||||
setOrRemoveLocalStorage('selectedThemeVariant', settings.themeVariant || null);
|
||||
setOrRemoveLocalStorage('lightThemeId', settings.lightThemeId || null);
|
||||
setOrRemoveLocalStorage('darkThemeId', settings.darkThemeId || null);
|
||||
setOrRemoveLocalStorage('useSystemTheme', typeof settings.useSystemTheme === 'boolean' ? String(settings.useSystemTheme) : null);
|
||||
setOrRemoveLocalStorage('lastDirectory', settings.lastDirectory || null);
|
||||
if (settings.homeDirectory) {
|
||||
localStorage.setItem('homeDirectory', settings.homeDirectory);
|
||||
applyPersistedHomeDirectoryToWindow(settings.homeDirectory);
|
||||
} else {
|
||||
localStorage.removeItem('homeDirectory');
|
||||
}
|
||||
if (Array.isArray(settings.projects) && settings.projects.length > 0) {
|
||||
localStorage.setItem('projects', JSON.stringify(settings.projects));
|
||||
@@ -81,6 +137,8 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
} else {
|
||||
localStorage.removeItem('oc.sessions.projectCollapse');
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem('oc.sessions.projectCollapse');
|
||||
}
|
||||
if (typeof settings.gitmojiEnabled === 'boolean') {
|
||||
localStorage.setItem('gitmojiEnabled', String(settings.gitmojiEnabled));
|
||||
@@ -89,13 +147,15 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
}
|
||||
if (typeof settings.directoryShowHidden === 'boolean') {
|
||||
localStorage.setItem('directoryTreeShowHidden', settings.directoryShowHidden ? 'true' : 'false');
|
||||
} else {
|
||||
localStorage.removeItem('directoryTreeShowHidden');
|
||||
}
|
||||
if (typeof settings.filesViewShowGitignored === 'boolean') {
|
||||
localStorage.setItem('filesViewShowGitignored', settings.filesViewShowGitignored ? 'true' : 'false');
|
||||
} else {
|
||||
localStorage.removeItem('filesViewShowGitignored');
|
||||
}
|
||||
if (typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0) {
|
||||
localStorage.setItem('openInAppId', settings.openInAppId);
|
||||
}
|
||||
setOrRemoveLocalStorage('openInAppId', typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0 ? settings.openInAppId : null);
|
||||
if (typeof settings.pwaAppName === 'string') {
|
||||
const normalized = settings.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64);
|
||||
if (normalized.length > 0) {
|
||||
@@ -103,10 +163,10 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
} else {
|
||||
localStorage.removeItem('openchamber.pwaName');
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem('openchamber.pwaName');
|
||||
}
|
||||
if (typeof settings.mobileKeyboardMode === 'string') {
|
||||
setStoredMobileKeyboardMode(settings.mobileKeyboardMode);
|
||||
}
|
||||
setStoredMobileKeyboardMode(settings.mobileKeyboardMode);
|
||||
if (typeof settings.openCodeUpdateToastDismissedVersion === 'string') {
|
||||
const version = settings.openCodeUpdateToastDismissedVersion.trim();
|
||||
if (version) {
|
||||
@@ -114,25 +174,23 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
} else {
|
||||
localStorage.removeItem('opencode-update-toast-dismissed-version');
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem('opencode-update-toast-dismissed-version');
|
||||
}
|
||||
if (typeof settings.dictationEnabled === 'boolean') {
|
||||
localStorage.setItem('dictationEnabled', String(settings.dictationEnabled));
|
||||
} else {
|
||||
localStorage.removeItem('dictationEnabled');
|
||||
}
|
||||
if (settings.sttProvider === 'local' || settings.sttProvider === 'openai-compatible') {
|
||||
localStorage.setItem('sttProvider', settings.sttProvider);
|
||||
} else {
|
||||
localStorage.removeItem('sttProvider');
|
||||
}
|
||||
if (typeof settings.sttServerUrl === 'string') {
|
||||
localStorage.setItem('sttServerUrl', settings.sttServerUrl);
|
||||
}
|
||||
if (typeof settings.sttModel === 'string') {
|
||||
localStorage.setItem('sttModel', settings.sttModel);
|
||||
}
|
||||
if (typeof settings.sttLocalModel === 'string') {
|
||||
localStorage.setItem('sttLocalModel', settings.sttLocalModel);
|
||||
}
|
||||
if (typeof settings.sttLanguage === 'string') {
|
||||
localStorage.setItem('sttLanguage', settings.sttLanguage);
|
||||
}
|
||||
setOrRemoveLocalStorage('sttServerUrl', typeof settings.sttServerUrl === 'string' ? settings.sttServerUrl : null);
|
||||
setOrRemoveLocalStorage('sttModel', typeof settings.sttModel === 'string' ? settings.sttModel : null);
|
||||
setOrRemoveLocalStorage('sttLocalModel', typeof settings.sttLocalModel === 'string' ? settings.sttLocalModel : null);
|
||||
setOrRemoveLocalStorage('sttLanguage', typeof settings.sttLanguage === 'string' ? settings.sttLanguage : null);
|
||||
};
|
||||
|
||||
const dispatchSettingsSynced = (settings: DesktopSettings): void => {
|
||||
@@ -457,6 +515,91 @@ const getPersistApi = (): PersistApi | undefined => {
|
||||
|
||||
const getRuntimeSettingsAPI = () => getRegisteredRuntimeAPIs()?.settings ?? null;
|
||||
|
||||
const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopSettings => {
|
||||
const defaults = useUIStore.getInitialState();
|
||||
|
||||
return {
|
||||
useSystemTheme: true,
|
||||
lightThemeId: DEFAULT_LIGHT_THEME_ID,
|
||||
darkThemeId: DEFAULT_DARK_THEME_ID,
|
||||
openInAppId: DEFAULT_OPEN_IN_APP_ID,
|
||||
showReasoningTraces: defaults.showReasoningTraces,
|
||||
sessionRecapEnabled: defaults.sessionRecapEnabled,
|
||||
sessionSuggestionEnabled: defaults.sessionSuggestionEnabled,
|
||||
sessionGoalEnabled: defaults.sessionGoalEnabled,
|
||||
sessionGoalDefaultBudgetEnabled: defaults.sessionGoalDefaultBudgetEnabled,
|
||||
sessionGoalDefaultBudget: defaults.sessionGoalDefaultBudget,
|
||||
collapsibleThinkingBlocks: defaults.collapsibleThinkingBlocks,
|
||||
autoDeleteEnabled: defaults.autoDeleteEnabled,
|
||||
autoDeleteAfterDays: defaults.autoDeleteAfterDays,
|
||||
sessionRetentionAction: defaults.sessionRetentionAction,
|
||||
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||
showDeletionDialog: defaults.showDeletionDialog,
|
||||
nativeNotificationsEnabled: defaults.nativeNotificationsEnabled,
|
||||
notificationMode: defaults.notificationMode,
|
||||
notifyOnSubtasks: defaults.notifyOnSubtasks,
|
||||
notifyOnCompletion: defaults.notifyOnCompletion,
|
||||
notifyOnError: defaults.notifyOnError,
|
||||
notifyOnQuestion: defaults.notifyOnQuestion,
|
||||
notificationTemplates: defaults.notificationTemplates,
|
||||
summarizeLastMessage: defaults.summarizeLastMessage,
|
||||
summaryThreshold: defaults.summaryThreshold,
|
||||
summaryLength: defaults.summaryLength,
|
||||
maxLastMessageLength: defaults.maxLastMessageLength,
|
||||
inputSpellcheckEnabled: defaults.inputSpellcheckEnabled,
|
||||
showOpenCodeUpdateNotifications: defaults.showOpenCodeUpdateNotifications,
|
||||
showToolFileIcons: defaults.showToolFileIcons,
|
||||
codeBlockLineWrap: defaults.codeBlockLineWrap,
|
||||
showTurnChangedFiles: defaults.showTurnChangedFiles,
|
||||
showExpandedBashTools: defaults.showExpandedBashTools,
|
||||
showExpandedEditTools: defaults.showExpandedEditTools,
|
||||
timeFormatPreference: defaults.timeFormatPreference,
|
||||
weekStartPreference: defaults.weekStartPreference,
|
||||
desktopWindowControlsPosition: defaults.desktopWindowControlsPosition,
|
||||
chatRenderMode: defaults.chatRenderMode,
|
||||
activityRenderMode: defaults.activityRenderMode,
|
||||
mermaidRenderingMode: defaults.mermaidRenderingMode,
|
||||
userMessageRenderingMode: defaults.userMessageRenderingMode,
|
||||
collapsibleUserMessages: defaults.collapsibleUserMessages,
|
||||
messageStreamTransport: 'auto',
|
||||
stickyUserHeader: defaults.stickyUserHeader,
|
||||
promptNavigatorEnabled: defaults.promptNavigatorEnabled,
|
||||
expandedEditorToolbar: defaults.expandedEditorToolbar,
|
||||
wideChatLayoutEnabled: defaults.wideChatLayoutEnabled,
|
||||
showSplitAssistantMessageActions: defaults.showSplitAssistantMessageActions,
|
||||
reportUsage: defaults.reportUsage,
|
||||
fontSize: defaults.fontSize,
|
||||
terminalFontSize: defaults.terminalFontSize,
|
||||
terminalShell: defaults.terminalShell,
|
||||
terminalLoginShells: defaults.terminalLoginShells,
|
||||
editorFontSize: defaults.editorFontSize,
|
||||
uiFont: defaults.uiFont,
|
||||
monoFont: defaults.monoFont,
|
||||
padding: defaults.padding,
|
||||
cornerRadius: defaults.cornerRadius,
|
||||
inputBarOffset: defaults.inputBarOffset,
|
||||
shortcutOverrides: defaults.shortcutOverrides,
|
||||
mobileKeyboardMode: 'resize-content',
|
||||
favoriteModels: defaults.favoriteModels,
|
||||
hiddenModels: defaults.hiddenModels,
|
||||
collapsedModelProviders: defaults.collapsedModelProviders,
|
||||
recentModels: defaults.recentModels,
|
||||
recentAgents: defaults.recentAgents,
|
||||
recentEfforts: defaults.recentEfforts,
|
||||
diffLayoutPreference: defaults.diffLayoutPreference,
|
||||
gitChangesViewMode: defaults.gitChangesViewMode,
|
||||
directoryShowHidden: true,
|
||||
filesViewShowGitignored: false,
|
||||
dictationEnabled: true,
|
||||
sttProvider: 'local',
|
||||
sttServerUrl: 'http://localhost:8001/v1',
|
||||
sttModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
|
||||
sttLocalModel: 'parakeet-tdt-0.6b-v2-int8',
|
||||
sttLanguage: '',
|
||||
...settings,
|
||||
};
|
||||
};
|
||||
|
||||
const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
const store = useUIStore.getState();
|
||||
const configStore = typeof window !== 'undefined'
|
||||
@@ -1215,6 +1358,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.promptNavigatorEnabled === 'boolean') {
|
||||
result.promptNavigatorEnabled = candidate.promptNavigatorEnabled;
|
||||
}
|
||||
if (typeof candidate.expandedEditorToolbar === 'boolean') {
|
||||
result.expandedEditorToolbar = candidate.expandedEditorToolbar;
|
||||
}
|
||||
if (typeof candidate.wideChatLayoutEnabled === 'boolean') {
|
||||
result.wideChatLayoutEnabled = candidate.wideChatLayoutEnabled;
|
||||
}
|
||||
@@ -1524,6 +1670,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
const applySettings = async (settings: DesktopSettings) => {
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true;
|
||||
const authoritativeSettings = materializeAuthoritativeUiSettings(settings);
|
||||
try {
|
||||
persistToLocalStorage(settings);
|
||||
} catch (error) {
|
||||
@@ -1531,20 +1678,23 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
}
|
||||
await waitForHydration();
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (settings.draftStarters === undefined) {
|
||||
useUIStore.setState({ globalDraftStarters: null });
|
||||
}
|
||||
try {
|
||||
applyDesktopUiPreferences(settings);
|
||||
applyDesktopUiPreferences(authoritativeSettings);
|
||||
} catch (error) {
|
||||
console.warn('applyDesktopUiPreferences failed:', error);
|
||||
}
|
||||
if (shouldPersistCraftGoalMigration) {
|
||||
await updateDesktopSettings({
|
||||
...(settings.draftStarters ? { draftStarters: settings.draftStarters } : {}),
|
||||
...(authoritativeSettings.draftStarters ? { draftStarters: authoritativeSettings.draftStarters } : {}),
|
||||
draftStartersCraftGoalAdded: true,
|
||||
});
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
}
|
||||
|
||||
dispatchSettingsSynced(settings);
|
||||
dispatchSettingsSynced(authoritativeSettings);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1579,7 +1729,6 @@ async function _flushSettingsUpdate(): Promise<void> {
|
||||
const updated = await runtimeSettings.save(changes);
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (updated) {
|
||||
persistToLocalStorage(updated);
|
||||
applyDesktopUiPreferences(updated);
|
||||
dispatchSettingsSynced(updated);
|
||||
_settingsCache = null;
|
||||
@@ -1613,7 +1762,6 @@ async function _flushSettingsUpdate(): Promise<void> {
|
||||
const updated = (await response.json().catch(() => null)) as DesktopSettings | null;
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (updated) {
|
||||
persistToLocalStorage(updated);
|
||||
applyDesktopUiPreferences(updated);
|
||||
dispatchSettingsSynced(updated);
|
||||
dispatchSettingsSaveState('saved');
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
clearRuntimeUrlAuthToken,
|
||||
getRuntimeBearerTokenSync,
|
||||
refreshRuntimeUrlAuthToken,
|
||||
refreshLocalRuntimeUrlAuthToken,
|
||||
getLocalRuntimeUrlAuthTokenSync,
|
||||
setRuntimeAuthCredentialProvider,
|
||||
setRuntimeBearerToken,
|
||||
setRuntimeExtraHeaders,
|
||||
@@ -145,4 +147,53 @@ describe('runtime auth headers', () => {
|
||||
clearRuntimeAuthCredentialProvider();
|
||||
}
|
||||
});
|
||||
|
||||
test('never reuses a local URL token for another origin', async () => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
let fetchCount = 0;
|
||||
try {
|
||||
clearRuntimeUrlAuthToken();
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
fetchCount += 1;
|
||||
const origin = new URL(String(input)).origin;
|
||||
return Response.json({ token: `${origin}-token`, expiresAt: Date.now() + 60_000 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const a = await refreshLocalRuntimeUrlAuthToken('http://127.0.0.1:3001');
|
||||
const b = await refreshLocalRuntimeUrlAuthToken('http://127.0.0.1:3002');
|
||||
|
||||
expect(a).toBe('http://127.0.0.1:3001-token');
|
||||
expect(b).toBe('http://127.0.0.1:3002-token');
|
||||
expect(getLocalRuntimeUrlAuthTokenSync('http://127.0.0.1:3001')).toBe('');
|
||||
expect(getLocalRuntimeUrlAuthTokenSync('http://127.0.0.1:3002')).toBe(b);
|
||||
expect(fetchCount).toBe(2);
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
clearRuntimeUrlAuthToken();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a local mint that completes after switching origins', async () => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
let resolveA!: (response: Response) => void;
|
||||
try {
|
||||
clearRuntimeUrlAuthToken();
|
||||
globalThis.fetch = ((input: RequestInfo | URL) => {
|
||||
const origin = new URL(String(input)).origin;
|
||||
if (origin.endsWith(':3001')) return new Promise<Response>((resolve) => { resolveA = resolve; });
|
||||
return Promise.resolve(Response.json({ token: 'token-b', expiresAt: Date.now() + 60_000 }));
|
||||
}) as typeof fetch;
|
||||
|
||||
const requestA = refreshLocalRuntimeUrlAuthToken('http://127.0.0.1:3001');
|
||||
const tokenB = await refreshLocalRuntimeUrlAuthToken('http://127.0.0.1:3002');
|
||||
resolveA(Response.json({ token: 'token-a', expiresAt: Date.now() + 60_000 }));
|
||||
|
||||
expect(tokenB).toBe('token-b');
|
||||
await expect(requestA).rejects.toThrow('stale');
|
||||
expect(getLocalRuntimeUrlAuthTokenSync('http://127.0.0.1:3002')).toBe('token-b');
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
clearRuntimeUrlAuthToken();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,10 @@ let runtimeUrlAuthTokenExpiresAt = 0;
|
||||
let runtimeUrlAuthRefreshPromise: Promise<string> | null = null;
|
||||
let localRuntimeUrlAuthToken = '';
|
||||
let localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
let localRuntimeUrlAuthOrigin = '';
|
||||
let localRuntimeUrlAuthRefreshPromise: Promise<string> | null = null;
|
||||
let localRuntimeUrlAuthRefreshOrigin = '';
|
||||
let localRuntimeUrlAuthGeneration = 0;
|
||||
let runtimeAuthGeneration = 0;
|
||||
|
||||
const URL_AUTH_REFRESH_SKEW_MS = 10_000;
|
||||
@@ -66,11 +69,27 @@ const buildAuthUrl = (apiBaseUrl: string | null | undefined, path: string): stri
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeOrigin = (value: string): string => {
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const clearLocalRuntimeUrlAuthToken = (): void => {
|
||||
localRuntimeUrlAuthToken = '';
|
||||
localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
localRuntimeUrlAuthOrigin = '';
|
||||
localRuntimeUrlAuthRefreshPromise = null;
|
||||
localRuntimeUrlAuthRefreshOrigin = '';
|
||||
localRuntimeUrlAuthGeneration += 1;
|
||||
};
|
||||
|
||||
export const clearRuntimeUrlAuthToken = (): void => {
|
||||
runtimeUrlAuthToken = '';
|
||||
runtimeUrlAuthTokenExpiresAt = 0;
|
||||
localRuntimeUrlAuthToken = '';
|
||||
localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
clearLocalRuntimeUrlAuthToken();
|
||||
};
|
||||
|
||||
const resetRuntimeAuthGeneration = (): void => {
|
||||
@@ -135,15 +154,20 @@ export const setRuntimeUrlAuthToken = (token: string | null | undefined, expires
|
||||
}
|
||||
};
|
||||
|
||||
export const setLocalRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => {
|
||||
export const setLocalRuntimeUrlAuthToken = (
|
||||
token: string | null | undefined,
|
||||
expiresAt: number | null | undefined,
|
||||
localOrigin?: string | null,
|
||||
): void => {
|
||||
const normalized = normalizeBearerToken(token);
|
||||
if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
|
||||
localRuntimeUrlAuthToken = '';
|
||||
localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
const origin = typeof localOrigin === 'string' ? normalizeOrigin(localOrigin) : '';
|
||||
if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || !origin) {
|
||||
clearLocalRuntimeUrlAuthToken();
|
||||
return;
|
||||
}
|
||||
localRuntimeUrlAuthToken = normalized;
|
||||
localRuntimeUrlAuthTokenExpiresAt = expiresAt;
|
||||
localRuntimeUrlAuthOrigin = origin;
|
||||
};
|
||||
|
||||
const readValidRuntimeUrlAuthTokenSync = (): string => {
|
||||
@@ -154,10 +178,13 @@ const readValidRuntimeUrlAuthTokenSync = (): string => {
|
||||
return runtimeUrlAuthToken;
|
||||
};
|
||||
|
||||
const readValidLocalRuntimeUrlAuthTokenSync = (): string => {
|
||||
const readValidLocalRuntimeUrlAuthTokenSync = (localOrigin: string): string => {
|
||||
const origin = normalizeOrigin(localOrigin);
|
||||
if (!origin || localRuntimeUrlAuthOrigin !== origin) return '';
|
||||
if (!localRuntimeUrlAuthToken || localRuntimeUrlAuthTokenExpiresAt <= Date.now() + URL_AUTH_REFRESH_SKEW_MS) {
|
||||
localRuntimeUrlAuthToken = '';
|
||||
localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
localRuntimeUrlAuthOrigin = '';
|
||||
return '';
|
||||
}
|
||||
return localRuntimeUrlAuthToken;
|
||||
@@ -172,7 +199,7 @@ export const getRuntimeUrlAuthTokenSync = (): string => {
|
||||
};
|
||||
|
||||
export const getLocalRuntimeUrlAuthTokenSync = (localOrigin?: string | null): string => {
|
||||
const token = readValidLocalRuntimeUrlAuthTokenSync();
|
||||
const token = localOrigin ? readValidLocalRuntimeUrlAuthTokenSync(localOrigin) : '';
|
||||
if (!token && localOrigin && typeof window !== 'undefined') {
|
||||
void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {});
|
||||
}
|
||||
@@ -242,15 +269,23 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise<string> =>
|
||||
};
|
||||
|
||||
const mintLocalRuntimeUrlAuthToken = (localOrigin: string): Promise<string> => {
|
||||
if (localRuntimeUrlAuthRefreshPromise) return localRuntimeUrlAuthRefreshPromise;
|
||||
const origin = normalizeOrigin(localOrigin);
|
||||
if (!origin) return Promise.reject(new Error('Local runtime URL auth origin was invalid'));
|
||||
if (localRuntimeUrlAuthRefreshPromise && localRuntimeUrlAuthRefreshOrigin === origin) {
|
||||
return localRuntimeUrlAuthRefreshPromise;
|
||||
}
|
||||
const generation = localRuntimeUrlAuthGeneration;
|
||||
const refreshPromise = (async () => {
|
||||
const response = await fetch(buildAuthUrl(localOrigin, '/auth/url-token'), {
|
||||
const response = await fetch(buildAuthUrl(origin, '/auth/url-token'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
localRuntimeUrlAuthToken = '';
|
||||
localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
if (generation === localRuntimeUrlAuthGeneration && origin === localRuntimeUrlAuthRefreshOrigin) {
|
||||
localRuntimeUrlAuthToken = '';
|
||||
localRuntimeUrlAuthTokenExpiresAt = 0;
|
||||
localRuntimeUrlAuthOrigin = '';
|
||||
}
|
||||
throw new Error(`Failed to mint local runtime URL auth token (${response.status})`);
|
||||
}
|
||||
const payload = await response.json().catch(() => null) as { token?: unknown; expiresAt?: unknown } | null;
|
||||
@@ -259,16 +294,22 @@ const mintLocalRuntimeUrlAuthToken = (localOrigin: string): Promise<string> => {
|
||||
if (!token || !Number.isFinite(expiresAt)) {
|
||||
throw new Error('Local runtime URL auth token response was invalid');
|
||||
}
|
||||
if (generation !== localRuntimeUrlAuthGeneration || origin !== localRuntimeUrlAuthRefreshOrigin) {
|
||||
throw new Error('Local runtime URL auth token response is stale');
|
||||
}
|
||||
localRuntimeUrlAuthToken = token;
|
||||
localRuntimeUrlAuthTokenExpiresAt = expiresAt;
|
||||
localRuntimeUrlAuthOrigin = origin;
|
||||
return token;
|
||||
})();
|
||||
const trackedPromise = refreshPromise.finally(() => {
|
||||
if (localRuntimeUrlAuthRefreshPromise === trackedPromise) {
|
||||
localRuntimeUrlAuthRefreshPromise = null;
|
||||
localRuntimeUrlAuthRefreshOrigin = '';
|
||||
}
|
||||
});
|
||||
localRuntimeUrlAuthRefreshPromise = trackedPromise;
|
||||
localRuntimeUrlAuthRefreshOrigin = origin;
|
||||
return localRuntimeUrlAuthRefreshPromise;
|
||||
};
|
||||
|
||||
@@ -281,9 +322,17 @@ export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Pr
|
||||
};
|
||||
|
||||
export const refreshLocalRuntimeUrlAuthToken = async (localOrigin: string): Promise<string> => {
|
||||
const existing = readValidLocalRuntimeUrlAuthTokenSync();
|
||||
const origin = normalizeOrigin(localOrigin);
|
||||
if (!origin) throw new Error('Local runtime URL auth origin was invalid');
|
||||
const existing = readValidLocalRuntimeUrlAuthTokenSync(origin);
|
||||
if (existing) return existing;
|
||||
return mintLocalRuntimeUrlAuthToken(localOrigin);
|
||||
if (
|
||||
(localRuntimeUrlAuthOrigin && localRuntimeUrlAuthOrigin !== origin)
|
||||
|| (localRuntimeUrlAuthRefreshOrigin && localRuntimeUrlAuthRefreshOrigin !== origin)
|
||||
) {
|
||||
clearLocalRuntimeUrlAuthToken();
|
||||
}
|
||||
return mintLocalRuntimeUrlAuthToken(origin);
|
||||
};
|
||||
|
||||
// ── Proactive URL auth token refresh ──────────────────────────────────────
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('createRuntimeUrlResolver', () => {
|
||||
test('adds local URL auth token to desktop realtime proxy URL', () => {
|
||||
setRuntimeExtraHeaders({ 'CF-Access-Client-Id': 'client-id' });
|
||||
setRuntimeUrlAuthToken('remote-url-token', Date.now() + 60_000);
|
||||
setLocalRuntimeUrlAuthToken('local-url-token', Date.now() + 60_000);
|
||||
setLocalRuntimeUrlAuthToken('local-url-token', Date.now() + 60_000, 'http://127.0.0.1:57123');
|
||||
try {
|
||||
withWindow({
|
||||
location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' },
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { themes } from '@/lib/theme/themes';
|
||||
import { getResolvedShikiTheme, getThemeContentSignature } from './appThemeRegistry';
|
||||
|
||||
describe('appThemeRegistry', () => {
|
||||
test('invalidates resolved themes when content changes under the same ID', () => {
|
||||
const original = themes[0];
|
||||
const changed = {
|
||||
...original,
|
||||
colors: {
|
||||
...original.colors,
|
||||
syntax: {
|
||||
...original.colors.syntax,
|
||||
base: {
|
||||
...original.colors.syntax.base,
|
||||
keyword: original.colors.syntax.base.string,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(getThemeContentSignature(changed)).not.toBe(getThemeContentSignature(original));
|
||||
expect(getResolvedShikiTheme(changed)).not.toBe(getResolvedShikiTheme(original));
|
||||
});
|
||||
|
||||
test('reuses resolved themes for identical content', () => {
|
||||
const original = themes[0];
|
||||
const clone = JSON.parse(JSON.stringify(original));
|
||||
|
||||
expect(getResolvedShikiTheme(clone)).toBe(getResolvedShikiTheme(original));
|
||||
});
|
||||
});
|
||||
@@ -35,8 +35,11 @@ function withStableStringId<T extends object>(value: T, id: string): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
const MAX_RESOLVED_THEME_CACHE_ENTRIES = 40;
|
||||
const resolvedThemeCache = new Map<string, ShikiThemeRegistrationResolvedLike>();
|
||||
const registeredPierreThemes = new Set<string>();
|
||||
const registeredPierreThemeSignatures = new Map<string, string>();
|
||||
|
||||
export const getThemeContentSignature = (theme: Theme): string => JSON.stringify(theme);
|
||||
|
||||
const toResolvedTheme = (raw: VSCodeTextMateTheme, id: string): ShikiThemeRegistrationResolvedLike => {
|
||||
const bgRaw = raw.colors?.['editor.background'];
|
||||
@@ -68,24 +71,33 @@ const buildTextMateTheme = (theme: Theme): VSCodeTextMateTheme => {
|
||||
};
|
||||
|
||||
export const getResolvedShikiTheme = (theme: Theme): ShikiThemeRegistrationResolvedLike => {
|
||||
const cached = resolvedThemeCache.get(theme.metadata.id);
|
||||
const signature = getThemeContentSignature(theme);
|
||||
const cached = resolvedThemeCache.get(signature);
|
||||
if (cached) {
|
||||
resolvedThemeCache.delete(signature);
|
||||
resolvedThemeCache.set(signature, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const raw = buildTextMateTheme(theme);
|
||||
const resolved = toResolvedTheme(raw, theme.metadata.id);
|
||||
resolvedThemeCache.set(theme.metadata.id, resolved);
|
||||
resolvedThemeCache.set(signature, resolved);
|
||||
while (resolvedThemeCache.size > MAX_RESOLVED_THEME_CACHE_ENTRIES) {
|
||||
const oldest = resolvedThemeCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
resolvedThemeCache.delete(oldest);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const ensurePierreThemeRegistered = (theme: Theme): void => {
|
||||
const id = theme.metadata.id;
|
||||
if (registeredPierreThemes.has(id)) {
|
||||
const signature = getThemeContentSignature(theme);
|
||||
if (registeredPierreThemeSignatures.get(id) === signature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = getResolvedShikiTheme(theme);
|
||||
registerCustomTheme(id, async () => resolved);
|
||||
registeredPierreThemes.add(id);
|
||||
registeredPierreThemeSignatures.set(id, signature);
|
||||
};
|
||||
|
||||
@@ -50,17 +50,42 @@ Examples:
|
||||
|
||||
These stores coordinate persistent project/session metadata across multiple views.
|
||||
|
||||
`useGlobalSessionsStore.ts` owns cold/global active and archived session coverage, including `sessionsByDirectory`. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages.
|
||||
|
||||
Global refresh rules:
|
||||
|
||||
- Per-directory refresh is bounded to two requests across callers and prioritizes the current directory.
|
||||
- Each directory is an independent completeness scope. A failed directory preserves its previous sessions while successful directories reconcile normally.
|
||||
- Fetch failure must remain distinguishable from a successful empty list; failed scopes cannot destructively clear cached sessions.
|
||||
- Runtime switch increments the load generation and clears the previous runtime's snapshot so stale in-flight work cannot commit.
|
||||
- Live session mutations update the cache directly after successful SDK actions; they preserve stable directory metadata when lighter event payloads omit it.
|
||||
- Full and per-directory loads capture a mutation revision. At commit time they overlay only per-session create/update/archive/delete/move mutations newer than that baseline, including no-op deletion tombstones, so an older response cannot undo newer local authority.
|
||||
|
||||
Permission auto-accept policy is authoritative in the active Web server or VS Code extension host. Owner snapshots carry a monotonic revision; the UI rejects lower revisions and any hydration or mutation completion captured before a runtime reset. Persisted UI policy is not live authority. The version-2 store retains an old unscoped policy only as a one-runtime legacy migration candidate, then removes it after successful migration.
|
||||
|
||||
Shared safe storage treats durable failures per key. A quota or access failure creates an ephemeral override or tombstone for that key without disabling reads and writes for unrelated keys; later writes retry the durable backend. Deferred adapters retain failed operations for a later flush, and malformed Zustand JSON is removed and treated as missing so hydration can recover.
|
||||
|
||||
Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors.
|
||||
|
||||
Session folders persist in runtime-specific v2 browser keys without silently evicting older runtime namespaces. Runtime switch, page hide, app freeze, and unload synchronously flush the pending browser snapshot before lifecycle suspension or namespace replacement. A runtime switch then cancels stale old-runtime disk work and starts generation-owned disk hydration. Missing or malformed server files are not authoritative empty snapshots; disk data may replace browser state only when it carries a real revision and no newer local folder mutation occurred. Server writes are serialized and reject non-newer revisions so delayed or duplicate requests cannot overwrite the current state. File-search cache and in-flight keys include runtime plus directory and are cleared on endpoint reset.
|
||||
|
||||
Persisted session todos use a bounded composite key of runtime, normalized directory, and session ID. Ambiguous legacy todo entries are discarded rather than claimed by whichever runtime starts first. Authoritative deletion uses an explicit runtime identity, and session-folder deletion scans every scope in the active runtime so archived assignments cannot survive after their session is gone.
|
||||
|
||||
Chat composer drafts, confirmed mentions, inline-comment drafts, and pinned sessions use the same runtime/directory/session ownership rule. Chat drafts use a bounded shared envelope and notify mounted composers when authoritative deletion clears their identity, preventing unmount autosave from resurrecting deleted text. Inline drafts enforce per-session, global-session, and serialized-byte bounds. Pins retain every valid composite key across runtimes without silent age/count eviction and are never pruned from the first startup list. Confirmed local deletion and routed deletion events clear immediately; after an authoritative baseline exists, a later complete omission also cleans persisted state. Ambiguous session-only legacy drafts and pins are not claimed.
|
||||
|
||||
Composer draft edits remain immediate in memory and use a trailing durable-write debounce. Pending text and confirmed mentions flush synchronously when the document becomes hidden, freezes, receives `pagehide`, switches identity, or unmounts; authoritative deletion cancels pending work before any lifecycle flush can run. The shared chat-draft envelope reuses its parsed snapshot until the storage value changes. Inline-comment draft byte accounting indexes serialized buckets and recalculates only the changed session bucket during normal edits; deferred storage still performs the final full-envelope serialization and lifecycle flush.
|
||||
|
||||
## Git / PR Stores
|
||||
|
||||
The Git and PR stores are the most important stores to understand before editing this directory.
|
||||
|
||||
### `useGitStore.ts`
|
||||
|
||||
`useGitStore` is a centralized per-directory Git cache.
|
||||
`useGitStore` is a centralized active-runtime, per-directory Git cache.
|
||||
|
||||
Core model:
|
||||
|
||||
- top-level keyed by `directory`
|
||||
- active runtime owns one `directories` map keyed by directory
|
||||
- each directory entry contains:
|
||||
- repo detection
|
||||
- status
|
||||
@@ -77,11 +102,15 @@ Important properties:
|
||||
- loading state is per-directory, not global
|
||||
- `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers
|
||||
- in-flight dedupe exists for status and `ensureAll()`
|
||||
- diff data is separately cached and capped with size + count limits
|
||||
- runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions
|
||||
- status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations
|
||||
- status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes
|
||||
- branch persistence is versioned, bounded, runtime-scoped, and claims the ambiguous legacy cache once
|
||||
- diff data has per-directory and aggregate count/UTF-8-byte limits; oversized single entries are rejected
|
||||
|
||||
### `useGitHubPrStatusStore.ts`
|
||||
|
||||
`useGitHubPrStatusStore` is a centralized PR cache keyed by `directory::branch`.
|
||||
`useGitHubPrStatusStore` is a centralized PR cache keyed by a collision-safe tuple of runtime, directory, branch, and requested remote.
|
||||
|
||||
Core model:
|
||||
|
||||
@@ -98,9 +127,11 @@ Important properties:
|
||||
|
||||
- `ensureEntry()` initializes a key lazily
|
||||
- `setParams()` attaches runtime context
|
||||
- parameter changes advance an entry revision; stale queued, successful, and failed requests cannot update a newer authority
|
||||
- `startWatching()` / `stopWatching()` are for true live PR consumers only
|
||||
- `refreshTargets()` supports one-shot multi-target bootstrap without turning on live watching
|
||||
- persisted cache is for page refresh continuity, not for broad background syncing
|
||||
- runtime reset disposes timers, watchers, API references, and request ownership while inert namespaced snapshots remain isolated
|
||||
- persisted cache is versioned, TTL-filtered, and bounded for page refresh continuity, not broad background syncing
|
||||
|
||||
## Ownership Rules
|
||||
|
||||
@@ -114,6 +145,8 @@ These rules are important. Breaking them tends to reintroduce idle CPU churn, st
|
||||
6. Header should not depend on PR store.
|
||||
7. Closed sidebar should not create live PR work.
|
||||
8. File tree Git status should update only when the file tree is visible.
|
||||
9. Global session refresh must remain bounded and failure-isolated per directory.
|
||||
10. Global session cache must not drive live activity indicators or message-loading state.
|
||||
|
||||
## Selector Rules
|
||||
|
||||
|
||||
@@ -95,4 +95,29 @@ describe('listGlobalSessionPages', () => {
|
||||
expect(calls[1]).toEqual({ directory: '/repo', archived: false, roots: false, limit: 2, cursor: 10 })
|
||||
expect(sessions.map((session) => session.id)).toEqual(['ses_root', 'ses_child_1', 'ses_child_2'])
|
||||
})
|
||||
|
||||
test('retries SDK error responses before treating the load as failed', async () => {
|
||||
let calls = 0
|
||||
const apiClient = {
|
||||
experimental: {
|
||||
session: {
|
||||
list: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) {
|
||||
return { error: { message: 'warming up' }, response: { status: 503 } }
|
||||
}
|
||||
return {
|
||||
data: [{ id: 'ses_1', time: { updated: 1 } }],
|
||||
response: { headers: new Headers() },
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
|
||||
const sessions = await listGlobalSessionPages(apiClient, { archived: false, pageSize: 500 })
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(sessions.map((session) => session.id)).toEqual(['ses_1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2";
|
||||
import { retry } from "@/sync/retry";
|
||||
import { stripSessionListDetails } from "@/sync/sanitize";
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch";
|
||||
import { startSessionLoadPerformanceEvent } from "@/sync/session-load-performance";
|
||||
|
||||
export type GlobalSessionRecord = Session & {
|
||||
project?: {
|
||||
@@ -86,21 +88,49 @@ export async function listGlobalSessionPages(
|
||||
const all: GlobalSessionRecord[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
let cursor: number | undefined;
|
||||
let operation: string;
|
||||
if (!options.directory) {
|
||||
operation = `global-sessions.${options.archived ? "archived" : "active"}`;
|
||||
} else if (options.roots === true) {
|
||||
operation = "bootstrap.sessions.roots";
|
||||
} else if (options.archived) {
|
||||
operation = "bootstrap.sessions.archived";
|
||||
} else {
|
||||
operation = "bootstrap.sessions.all";
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const response = await retry(
|
||||
() => apiClient.experimental.session.list({
|
||||
...(options.directory ? { directory: options.directory } : {}),
|
||||
archived: options.archived,
|
||||
...(options.roots !== undefined ? { roots: options.roots } : {}),
|
||||
limit: options.pageSize,
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
}),
|
||||
let attempts = 0;
|
||||
const finishPerformanceEvent = startSessionLoadPerformanceEvent({
|
||||
operation,
|
||||
runtimeKey: getRuntimeKey(),
|
||||
directory: options.directory,
|
||||
caller: cursor === undefined ? "initial-page" : "pagination",
|
||||
});
|
||||
const { response, payload } = await retry(
|
||||
async () => {
|
||||
attempts += 1;
|
||||
const response = await apiClient.experimental.session.list({
|
||||
...(options.directory ? { directory: options.directory } : {}),
|
||||
archived: options.archived,
|
||||
...(options.roots !== undefined ? { roots: options.roots } : {}),
|
||||
limit: options.pageSize,
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
});
|
||||
const payload = unwrapSessionList(response, "experimental.session.list")
|
||||
.map((session) => stripSessionListDetails(session) as GlobalSessionRecord);
|
||||
return { response, payload };
|
||||
},
|
||||
{ attempts: 3, delay: 500, retryIf: () => true },
|
||||
);
|
||||
).catch((error) => {
|
||||
finishPerformanceEvent("error", { retryCount: Math.max(0, attempts - 1) });
|
||||
throw error;
|
||||
});
|
||||
|
||||
const payload = unwrapSessionList(response, "experimental.session.list")
|
||||
.map((session) => stripSessionListDetails(session) as GlobalSessionRecord);
|
||||
finishPerformanceEvent("complete", {
|
||||
retryCount: Math.max(0, attempts - 1),
|
||||
recordCount: payload.length,
|
||||
});
|
||||
if (payload.length === 0) break;
|
||||
|
||||
let appended = 0;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import {
|
||||
createMessageQueueTarget,
|
||||
getMessageQueueKey,
|
||||
migrateMessageQueueState,
|
||||
parseMessageQueueKey,
|
||||
useMessageQueueStore,
|
||||
} from "./messageQueueStore"
|
||||
|
||||
beforeEach(() => {
|
||||
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {} })
|
||||
})
|
||||
|
||||
describe("message queue runtime ownership", () => {
|
||||
test("isolates colliding session IDs by runtime and directory", () => {
|
||||
const a = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
|
||||
const b = createMessageQueueTarget("session-1", "/repo", "runtime-b")!
|
||||
useMessageQueueStore.getState().addToQueue(a, { content: "from A" })
|
||||
useMessageQueueStore.getState().addToQueue(b, { content: "from B" })
|
||||
|
||||
expect(useMessageQueueStore.getState().getQueueForTarget(a)[0]?.content).toBe("from A")
|
||||
expect(useMessageQueueStore.getState().getQueueForTarget(b)[0]?.content).toBe("from B")
|
||||
})
|
||||
|
||||
test("round trips a composite queue key", () => {
|
||||
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
|
||||
expect(parseMessageQueueKey(getMessageQueueKey(target))).toEqual(target)
|
||||
})
|
||||
|
||||
test("quarantines legacy session-only queues instead of activating them", () => {
|
||||
const migrated = migrateMessageQueueState({
|
||||
queuedMessages: {
|
||||
"session-1": [{ id: "queued-1", content: "legacy", createdAt: 1 }],
|
||||
},
|
||||
}, 1)
|
||||
|
||||
expect(migrated.queuedMessages).toEqual({})
|
||||
expect(migrated.quarantinedLegacyMessages?.["session-1"]?.[0]?.content).toBe("legacy")
|
||||
})
|
||||
|
||||
test("bounds each queue to the newest 20 messages", () => {
|
||||
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
|
||||
for (let index = 0; index < 25; index += 1) {
|
||||
useMessageQueueStore.getState().addToQueue(target, { content: `message-${index}` })
|
||||
}
|
||||
|
||||
const queue = useMessageQueueStore.getState().getQueueForTarget(target)
|
||||
expect(queue).toHaveLength(20)
|
||||
expect(queue[0]?.content).toBe("message-5")
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,8 @@ import { devtools, persist } from 'zustand/middleware';
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
import type { AttachedFile } from './types/sessionTypes';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
|
||||
export type FollowUpBehavior = 'steer' | 'queue';
|
||||
|
||||
@@ -52,38 +54,82 @@ export interface QueuedMessage {
|
||||
};
|
||||
}
|
||||
|
||||
export type MessageQueueTarget = {
|
||||
runtimeKey: string;
|
||||
directory: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
const MAX_QUEUE_TARGETS = 50;
|
||||
const MAX_MESSAGES_PER_QUEUE = 20;
|
||||
|
||||
export const createMessageQueueTarget = (
|
||||
sessionId: string,
|
||||
directory: string | null | undefined,
|
||||
runtimeKey: string = getRuntimeKey(),
|
||||
): MessageQueueTarget | null => {
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
if (!runtimeKey || !normalizedDirectory || !sessionId) return null;
|
||||
return { runtimeKey, directory: normalizedDirectory, sessionId };
|
||||
};
|
||||
|
||||
export const getMessageQueueKey = (target: MessageQueueTarget): string =>
|
||||
`${target.runtimeKey}\n${target.directory}\n${target.sessionId}`;
|
||||
|
||||
export const parseMessageQueueKey = (key: string): MessageQueueTarget | null => {
|
||||
const [runtimeKey, directory, ...sessionParts] = key.split('\n');
|
||||
return createMessageQueueTarget(sessionParts.join('\n'), directory, runtimeKey);
|
||||
};
|
||||
|
||||
interface MessageQueueState {
|
||||
queuedMessages: Record<string, QueuedMessage[]>; // sessionId → queue
|
||||
queuedMessages: Record<string, QueuedMessage[]>; // runtime + directory + session → queue
|
||||
quarantinedLegacyMessages: Record<string, QueuedMessage[]>;
|
||||
followUpBehavior: FollowUpBehavior;
|
||||
}
|
||||
|
||||
interface MessageQueueActions {
|
||||
addToQueue: (sessionId: string, message: Omit<QueuedMessage, 'id' | 'createdAt'>) => void;
|
||||
removeFromQueue: (sessionId: string, messageId: string) => void;
|
||||
reorderQueue: (sessionId: string, fromId: string, toId: string) => void;
|
||||
popToInput: (sessionId: string, messageId: string) => QueuedMessage | null;
|
||||
clearQueue: (sessionId: string) => void;
|
||||
addToQueue: (target: MessageQueueTarget, message: Omit<QueuedMessage, 'id' | 'createdAt'>) => void;
|
||||
removeFromQueue: (target: MessageQueueTarget, messageId: string) => void;
|
||||
reorderQueue: (target: MessageQueueTarget, fromId: string, toId: string) => void;
|
||||
popToInput: (target: MessageQueueTarget, messageId: string) => QueuedMessage | null;
|
||||
clearQueue: (target: MessageQueueTarget) => void;
|
||||
clearAllQueues: () => void;
|
||||
setFollowUpBehavior: (behavior: FollowUpBehavior) => void;
|
||||
getQueueForSession: (sessionId: string) => QueuedMessage[];
|
||||
getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[];
|
||||
}
|
||||
|
||||
type MessageQueueStore = MessageQueueState & MessageQueueActions;
|
||||
|
||||
type PersistedMessageQueueState = {
|
||||
queuedMessages?: Record<string, QueuedMessage[]>;
|
||||
quarantinedLegacyMessages?: Record<string, QueuedMessage[]>;
|
||||
followUpBehavior?: FollowUpBehavior;
|
||||
queueModeEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const migrateMessageQueueState = (persistedState: unknown, version: number): Partial<MessageQueueStore> => {
|
||||
const state = (persistedState ?? {}) as PersistedMessageQueueState;
|
||||
const legacyQueues = version < 2 ? (state.queuedMessages ?? {}) : {};
|
||||
return {
|
||||
queuedMessages: version < 2 ? {} : (state.queuedMessages ?? {}),
|
||||
quarantinedLegacyMessages: {
|
||||
...(state.quarantinedLegacyMessages ?? {}),
|
||||
...legacyQueues,
|
||||
},
|
||||
followUpBehavior: normalizeFollowUpBehavior(state.followUpBehavior, state.queueModeEnabled ?? null),
|
||||
};
|
||||
};
|
||||
|
||||
export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
queuedMessages: {},
|
||||
quarantinedLegacyMessages: {},
|
||||
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
|
||||
|
||||
addToQueue: (sessionId, message) => {
|
||||
addToQueue: (target, message) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const queuedMessage: QueuedMessage = {
|
||||
id,
|
||||
@@ -94,23 +140,32 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[sessionId] ?? [];
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const queuedMessages = {
|
||||
...state.queuedMessages,
|
||||
[key]: [...currentQueue, queuedMessage].slice(-MAX_MESSAGES_PER_QUEUE),
|
||||
};
|
||||
const keys = Object.keys(queuedMessages);
|
||||
if (keys.length > MAX_QUEUE_TARGETS) {
|
||||
keys.sort((left, right) => (
|
||||
(queuedMessages[left]?.[0]?.createdAt ?? 0) - (queuedMessages[right]?.[0]?.createdAt ?? 0)
|
||||
));
|
||||
for (const staleKey of keys.slice(0, keys.length - MAX_QUEUE_TARGETS)) delete queuedMessages[staleKey];
|
||||
}
|
||||
return {
|
||||
queuedMessages: {
|
||||
...state.queuedMessages,
|
||||
[sessionId]: [...currentQueue, queuedMessage],
|
||||
},
|
||||
queuedMessages,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeFromQueue: (sessionId, messageId) => {
|
||||
removeFromQueue: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[sessionId] ?? [];
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const newQueue = currentQueue.filter((m) => m.id !== messageId);
|
||||
|
||||
if (newQueue.length === 0) {
|
||||
const { [sessionId]: _removed, ...rest } = state.queuedMessages;
|
||||
const { [key]: _removed, ...rest } = state.queuedMessages;
|
||||
void _removed;
|
||||
return { queuedMessages: rest };
|
||||
}
|
||||
@@ -118,16 +173,17 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
return {
|
||||
queuedMessages: {
|
||||
...state.queuedMessages,
|
||||
[sessionId]: newQueue,
|
||||
[key]: newQueue,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
reorderQueue: (sessionId, fromId, toId) => {
|
||||
reorderQueue: (target, fromId, toId) => {
|
||||
if (fromId === toId) return;
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const currentQueue = state.queuedMessages[sessionId];
|
||||
const currentQueue = state.queuedMessages[key];
|
||||
if (!currentQueue) return state;
|
||||
const fromIndex = currentQueue.findIndex((m) => m.id === fromId);
|
||||
const toIndex = currentQueue.findIndex((m) => m.id === toId);
|
||||
@@ -140,15 +196,16 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
return {
|
||||
queuedMessages: {
|
||||
...state.queuedMessages,
|
||||
[sessionId]: newQueue,
|
||||
[key]: newQueue,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
popToInput: (sessionId, messageId) => {
|
||||
popToInput: (target, messageId) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
const state = get();
|
||||
const currentQueue = state.queuedMessages[sessionId] ?? [];
|
||||
const currentQueue = state.queuedMessages[key] ?? [];
|
||||
const message = currentQueue.find((m) => m.id === messageId);
|
||||
|
||||
if (!message) {
|
||||
@@ -157,11 +214,11 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
|
||||
// Remove from queue
|
||||
set((prevState) => {
|
||||
const queue = prevState.queuedMessages[sessionId] ?? [];
|
||||
const queue = prevState.queuedMessages[key] ?? [];
|
||||
const newQueue = queue.filter((m) => m.id !== messageId);
|
||||
|
||||
if (newQueue.length === 0) {
|
||||
const { [sessionId]: _removed, ...rest } = prevState.queuedMessages;
|
||||
const { [key]: _removed, ...rest } = prevState.queuedMessages;
|
||||
void _removed;
|
||||
return { queuedMessages: rest };
|
||||
}
|
||||
@@ -169,7 +226,7 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
return {
|
||||
queuedMessages: {
|
||||
...prevState.queuedMessages,
|
||||
[sessionId]: newQueue,
|
||||
[key]: newQueue,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -177,9 +234,10 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
return message;
|
||||
},
|
||||
|
||||
clearQueue: (sessionId) => {
|
||||
clearQueue: (target) => {
|
||||
const key = getMessageQueueKey(target);
|
||||
set((state) => {
|
||||
const { [sessionId]: _removed, ...rest } = state.queuedMessages;
|
||||
const { [key]: _removed, ...rest } = state.queuedMessages;
|
||||
void _removed;
|
||||
return { queuedMessages: rest };
|
||||
});
|
||||
@@ -194,25 +252,20 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
|
||||
void updateDesktopSettings({ followUpBehavior: behavior });
|
||||
},
|
||||
|
||||
getQueueForSession: (sessionId) => {
|
||||
return get().queuedMessages[sessionId] ?? [];
|
||||
getQueueForTarget: (target) => {
|
||||
return get().queuedMessages[getMessageQueueKey(target)] ?? [];
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'message-queue-store',
|
||||
version: 1,
|
||||
version: 2,
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
partialize: (state) => ({
|
||||
queuedMessages: state.queuedMessages,
|
||||
quarantinedLegacyMessages: state.quarantinedLegacyMessages,
|
||||
followUpBehavior: state.followUpBehavior,
|
||||
}),
|
||||
migrate: (persistedState) => {
|
||||
const state = (persistedState ?? {}) as PersistedMessageQueueState;
|
||||
return {
|
||||
queuedMessages: state.queuedMessages ?? {},
|
||||
followUpBehavior: normalizeFollowUpBehavior(state.followUpBehavior, state.queueModeEnabled ?? null),
|
||||
};
|
||||
},
|
||||
migrate: migrateMessageQueueState,
|
||||
}
|
||||
),
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ const json = (value: unknown, status = 200) => new Response(JSON.stringify(value
|
||||
describe('permission store server policy', () => {
|
||||
beforeEach(() => {
|
||||
usePermissionStore.getState().reset();
|
||||
usePermissionStore.setState({ legacyCandidate: null, legacyRuntimeKey: null });
|
||||
fetchImpl = async () => json({ sessions: {} });
|
||||
});
|
||||
|
||||
@@ -51,7 +52,7 @@ describe('permission store server policy', () => {
|
||||
});
|
||||
|
||||
test('migrates a legacy local policy when the server has no policy yet', async () => {
|
||||
usePermissionStore.setState({ autoAccept: { root: true } });
|
||||
usePermissionStore.setState({ legacyCandidate: { root: true }, legacyRuntimeKey: null });
|
||||
const requests: string[] = [];
|
||||
fetchImpl = async (input) => {
|
||||
requests.push(input);
|
||||
@@ -62,5 +63,56 @@ describe('permission store server policy', () => {
|
||||
await usePermissionStore.getState().hydrate();
|
||||
expect(requests).toEqual(['/api/permission-auto-accept', '/api/permission-auto-accept/sessions/root']);
|
||||
expect(usePermissionStore.getState().autoAccept).toEqual({ root: true });
|
||||
expect(usePermissionStore.getState().legacyCandidate).toBe(null);
|
||||
});
|
||||
|
||||
test('rejects a hydration response from before reset', async () => {
|
||||
let resolveOld!: (response: Response) => void;
|
||||
const oldResponse = new Promise<Response>((resolve) => { resolveOld = resolve; });
|
||||
fetchImpl = async () => oldResponse;
|
||||
const oldHydration = usePermissionStore.getState().hydrate();
|
||||
|
||||
usePermissionStore.getState().reset();
|
||||
fetchImpl = async () => json({ sessions: { current: true }, revision: 2 });
|
||||
await usePermissionStore.getState().hydrate();
|
||||
resolveOld(json({ sessions: { stale: true }, revision: 1 }));
|
||||
await oldHydration;
|
||||
|
||||
expect(usePermissionStore.getState().autoAccept).toEqual({ current: true });
|
||||
});
|
||||
|
||||
test('rejects a mutation response from before reset', async () => {
|
||||
let resolveOld!: (response: Response) => void;
|
||||
fetchImpl = async () => new Promise<Response>((resolve) => { resolveOld = resolve; });
|
||||
const mutation = usePermissionStore.getState().setSessionAutoAccept('stale', true);
|
||||
|
||||
usePermissionStore.getState().reset();
|
||||
resolveOld(json({ sessions: { stale: true }, revision: 1 }));
|
||||
await mutation;
|
||||
|
||||
expect(usePermissionStore.getState().autoAccept).toEqual({});
|
||||
expect(usePermissionStore.getState().saving).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps the highest authoritative revision when mutations resolve out of order', async () => {
|
||||
const resolvers: Array<(response: Response) => void> = [];
|
||||
fetchImpl = async () => new Promise<Response>((resolve) => { resolvers.push(resolve); });
|
||||
const first = usePermissionStore.getState().setSessionAutoAccept('first', true);
|
||||
const second = usePermissionStore.getState().setSessionAutoAccept('second', true);
|
||||
|
||||
resolvers[1](json({ sessions: { first: true, second: true }, revision: 2 }));
|
||||
await second;
|
||||
resolvers[0](json({ sessions: { first: true }, revision: 1 }));
|
||||
await first;
|
||||
|
||||
expect(usePermissionStore.getState().autoAccept).toEqual({ first: true, second: true });
|
||||
expect(usePermissionStore.getState().saving).toBe(false);
|
||||
});
|
||||
|
||||
test('ignores an older broadcast revision', () => {
|
||||
usePermissionStore.getState().applySnapshot({ sessions: { current: true }, revision: 4 });
|
||||
usePermissionStore.getState().applySnapshot({ sessions: { stale: true }, revision: 3 });
|
||||
|
||||
expect(usePermissionStore.getState().autoAccept).toEqual({ current: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,17 +8,26 @@ import { isVSCodeRuntime } from "@/lib/desktop";
|
||||
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
|
||||
import { useSessionUIStore } from "@/sync/session-ui-store";
|
||||
import { opencodeClient } from "@/lib/opencode/client";
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch";
|
||||
|
||||
type PermissionPolicySnapshot = {
|
||||
sessions: PermissionAutoAcceptMap;
|
||||
revision?: number;
|
||||
};
|
||||
|
||||
const normalizeRevision = (value: unknown): number | undefined => (
|
||||
Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : undefined
|
||||
);
|
||||
|
||||
interface PermissionStore {
|
||||
autoAccept: PermissionAutoAcceptMap;
|
||||
loaded: boolean;
|
||||
saving: boolean;
|
||||
lastAppliedRevision: number;
|
||||
legacyCandidate: PermissionAutoAcceptMap | null;
|
||||
legacyRuntimeKey: string | null;
|
||||
hydrate: () => Promise<void>;
|
||||
applySnapshot: (snapshot: PermissionPolicySnapshot) => void;
|
||||
applySnapshot: (snapshot: PermissionPolicySnapshot, expectedRuntimeKey?: string) => void;
|
||||
reset: () => void;
|
||||
isSessionAutoAccepting: (sessionId: string) => boolean;
|
||||
setSessionAutoAccept: (sessionId: string, enabled: boolean) => Promise<void>;
|
||||
@@ -34,7 +43,7 @@ const readSnapshot = async (response: Response): Promise<PermissionPolicySnapsho
|
||||
for (const [sessionId, enabled] of Object.entries(payload.sessions)) {
|
||||
if (sessionId && typeof enabled === "boolean") sessions[sessionId] = enabled;
|
||||
}
|
||||
return { sessions };
|
||||
return { sessions, revision: normalizeRevision(payload.revision) };
|
||||
};
|
||||
|
||||
const requestSnapshot = async (path: string, init?: RequestInit) => readSnapshot(await runtimeFetch(path, init));
|
||||
@@ -45,15 +54,52 @@ const isAutoAccepting = (
|
||||
sessionId: string,
|
||||
) => autoRespondsPermission({ autoAccept, sessions: [], sessionById, sessionID: sessionId });
|
||||
|
||||
type PermissionOperation = { generation: number; runtimeKey: string; sequence: number };
|
||||
let generation = 0;
|
||||
let operationSequence = 0;
|
||||
let latestStartedSequence = 0;
|
||||
const pendingSavingOperations = new Set<number>();
|
||||
|
||||
const beginOperation = (): PermissionOperation => {
|
||||
const operation = { generation, runtimeKey: getRuntimeKey(), sequence: ++operationSequence };
|
||||
latestStartedSequence = operation.sequence;
|
||||
return operation;
|
||||
};
|
||||
|
||||
const isCurrentOperation = (operation: PermissionOperation) => (
|
||||
operation.generation === generation && operation.runtimeKey === getRuntimeKey()
|
||||
);
|
||||
|
||||
const normalizeSessions = (value: unknown): PermissionAutoAcceptMap => {
|
||||
const sessions: PermissionAutoAcceptMap = {};
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return sessions;
|
||||
for (const [sessionId, enabled] of Object.entries(value)) {
|
||||
if (sessionId && typeof enabled === "boolean") sessions[sessionId] = enabled;
|
||||
}
|
||||
return sessions;
|
||||
};
|
||||
|
||||
export const usePermissionStore = create<PermissionStore>()(persist((set, get) => ({
|
||||
autoAccept: {},
|
||||
loaded: false,
|
||||
saving: false,
|
||||
lastAppliedRevision: -1,
|
||||
legacyCandidate: null,
|
||||
legacyRuntimeKey: null,
|
||||
|
||||
hydrate: async () => {
|
||||
const operation = beginOperation();
|
||||
const legacyCandidate = get().legacyCandidate;
|
||||
let legacyRuntimeKey = get().legacyRuntimeKey;
|
||||
if (legacyCandidate && !legacyRuntimeKey) {
|
||||
legacyRuntimeKey = operation.runtimeKey;
|
||||
set({ legacyRuntimeKey });
|
||||
}
|
||||
let snapshot = await requestSnapshot("/api/permission-auto-accept");
|
||||
const legacyEntries = Object.entries(get().autoAccept)
|
||||
.filter(([sessionId, enabled]) => !sessionId.includes("/") && typeof enabled === "boolean");
|
||||
if (!isCurrentOperation(operation)) return;
|
||||
const legacyEntries = legacyRuntimeKey === operation.runtimeKey
|
||||
? Object.entries(legacyCandidate ?? {})
|
||||
: [];
|
||||
if (Object.keys(snapshot.sessions).length === 0 && legacyEntries.length > 0) {
|
||||
for (const [sessionId, enabled] of legacyEntries) {
|
||||
if (!sessionId || typeof enabled !== "boolean") continue;
|
||||
@@ -65,19 +111,37 @@ export const usePermissionStore = create<PermissionStore>()(persist((set, get) =
|
||||
body: JSON.stringify({ enabled }),
|
||||
},
|
||||
);
|
||||
if (!isCurrentOperation(operation)) return;
|
||||
}
|
||||
}
|
||||
set({ autoAccept: snapshot.sessions, loaded: true });
|
||||
if (!isCurrentOperation(operation)) return;
|
||||
if (snapshot.revision === undefined && operation.sequence !== latestStartedSequence) return;
|
||||
get().applySnapshot(snapshot, operation.runtimeKey);
|
||||
if (legacyRuntimeKey === operation.runtimeKey) {
|
||||
set({ legacyCandidate: null, legacyRuntimeKey: null });
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => set({ autoAccept: {}, loaded: false, saving: false }),
|
||||
reset: () => {
|
||||
generation += 1;
|
||||
latestStartedSequence = 0;
|
||||
pendingSavingOperations.clear();
|
||||
set({ autoAccept: {}, loaded: false, saving: false, lastAppliedRevision: -1 });
|
||||
},
|
||||
|
||||
applySnapshot: (snapshot) => {
|
||||
const sessions: PermissionAutoAcceptMap = {};
|
||||
for (const [sessionId, enabled] of Object.entries(snapshot.sessions ?? {})) {
|
||||
if (sessionId && typeof enabled === "boolean") sessions[sessionId] = enabled;
|
||||
}
|
||||
set({ autoAccept: sessions, loaded: true });
|
||||
applySnapshot: (snapshot, expectedRuntimeKey) => {
|
||||
if (expectedRuntimeKey && expectedRuntimeKey !== getRuntimeKey()) return;
|
||||
const sessions = normalizeSessions(snapshot.sessions);
|
||||
const revision = normalizeRevision(snapshot.revision);
|
||||
set((state) => {
|
||||
if (revision === undefined && state.lastAppliedRevision >= 0) return state;
|
||||
if (revision !== undefined && revision < state.lastAppliedRevision) return state;
|
||||
return {
|
||||
autoAccept: sessions,
|
||||
loaded: true,
|
||||
...(revision !== undefined ? { lastAppliedRevision: revision } : {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
isSessionAutoAccepting: (sessionId) => {
|
||||
@@ -89,6 +153,8 @@ export const usePermissionStore = create<PermissionStore>()(persist((set, get) =
|
||||
|
||||
setSessionAutoAccept: async (sessionId, enabled) => {
|
||||
if (!sessionId) return;
|
||||
const operation = beginOperation();
|
||||
pendingSavingOperations.add(operation.sequence);
|
||||
set({ saving: true });
|
||||
try {
|
||||
const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId)
|
||||
@@ -102,18 +168,40 @@ export const usePermissionStore = create<PermissionStore>()(persist((set, get) =
|
||||
body: JSON.stringify({ enabled, directory }),
|
||||
},
|
||||
);
|
||||
set({ autoAccept: snapshot.sessions, loaded: true });
|
||||
if (isVSCodeRuntime() && enabled) {
|
||||
if (!isCurrentOperation(operation)) return;
|
||||
if (snapshot.revision === undefined && operation.sequence !== latestStartedSequence) return;
|
||||
get().applySnapshot(snapshot, operation.runtimeKey);
|
||||
if (isCurrentOperation(operation) && isVSCodeRuntime() && enabled) {
|
||||
const { reconcileVSCodePendingPermissions } = await import("@/sync/vscode-permission-auto-accept");
|
||||
void reconcileVSCodePendingPermissions(directory).catch(() => undefined);
|
||||
if (isCurrentOperation(operation)) {
|
||||
void reconcileVSCodePendingPermissions(directory).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
set({ saving: false });
|
||||
if (isCurrentOperation(operation)) {
|
||||
pendingSavingOperations.delete(operation.sequence);
|
||||
set({ saving: pendingSavingOperations.size > 0 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
}), {
|
||||
name: "permission-store",
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
partialize: (state) => ({ autoAccept: state.autoAccept }),
|
||||
version: 2,
|
||||
migrate: (persisted, version) => {
|
||||
const state = persisted && typeof persisted === "object" ? persisted as Record<string, unknown> : {};
|
||||
if (version < 2) {
|
||||
const legacyCandidate = normalizeSessions(state.autoAccept);
|
||||
return {
|
||||
legacyCandidate: Object.keys(legacyCandidate).length > 0 ? legacyCandidate : null,
|
||||
legacyRuntimeKey: null,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
legacyCandidate: state.legacyCandidate,
|
||||
legacyRuntimeKey: state.legacyRuntimeKey,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -21,6 +21,7 @@ import { runtimeFetch } from "@/lib/runtime-fetch";
|
||||
import { markStartupTrace, measureStartupTrace } from "@/lib/startupTrace";
|
||||
import { normalizePath } from "@/lib/pathNormalization";
|
||||
import { getSyncConfig, subscribeToSyncConfigChanges } from "@/sync/sync-refs";
|
||||
import { getRuntimeKey } from "@/lib/runtime-switch";
|
||||
|
||||
const MODELS_DEV_API_URL = "https://models.dev/api.json";
|
||||
const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
|
||||
@@ -717,18 +718,57 @@ const resolveInitialDirectoryKey = (): string => {
|
||||
// We cache resolved mappings to localStorage so subsequent launches resolve the
|
||||
// project synchronously at init time. worktree→project is effectively immutable,
|
||||
// so a cached entry is safe to trust.
|
||||
const WORKTREE_PROJECT_MAP_KEY = 'oc.worktreeProjectMap';
|
||||
let _worktreeProjectMap: Record<string, string> | null = null;
|
||||
const WORKTREE_PROJECT_MAP_KEY = 'oc.worktreeProjectMap.v2';
|
||||
const LEGACY_WORKTREE_PROJECT_MAP_KEY = 'oc.worktreeProjectMap';
|
||||
const MAX_WORKTREE_PROJECT_RUNTIME_MAPS = 8;
|
||||
type WorktreeProjectMapEnvelope = {
|
||||
version: 2;
|
||||
legacyClaimed: boolean;
|
||||
runtimes: Record<string, { updatedAt: number; entries: Record<string, string> }>;
|
||||
};
|
||||
const _worktreeProjectMaps = new Map<string, Record<string, string>>();
|
||||
const readWorktreeProjectEnvelope = (): WorktreeProjectMapEnvelope => {
|
||||
try {
|
||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(WORKTREE_PROJECT_MAP_KEY) : null;
|
||||
if (!raw) return { version: 2, legacyClaimed: false, runtimes: {} };
|
||||
const parsed = JSON.parse(raw) as Partial<WorktreeProjectMapEnvelope>;
|
||||
if (parsed.version !== 2 || !parsed.runtimes || typeof parsed.runtimes !== 'object') {
|
||||
return { version: 2, legacyClaimed: false, runtimes: {} };
|
||||
}
|
||||
return { version: 2, legacyClaimed: parsed.legacyClaimed === true, runtimes: parsed.runtimes };
|
||||
} catch {
|
||||
return { version: 2, legacyClaimed: false, runtimes: {} };
|
||||
}
|
||||
};
|
||||
const writeWorktreeProjectEnvelope = (envelope: WorktreeProjectMapEnvelope): void => {
|
||||
const runtimes = Object.fromEntries(
|
||||
Object.entries(envelope.runtimes)
|
||||
.sort(([, left], [, right]) => right.updatedAt - left.updatedAt)
|
||||
.slice(0, MAX_WORKTREE_PROJECT_RUNTIME_MAPS),
|
||||
);
|
||||
localStorage.setItem(WORKTREE_PROJECT_MAP_KEY, JSON.stringify({ ...envelope, runtimes }));
|
||||
};
|
||||
const getWorktreeProjectMap = (): Record<string, string> => {
|
||||
if (_worktreeProjectMap === null) {
|
||||
const runtimeKey = getRuntimeKey() || 'default';
|
||||
const existing = _worktreeProjectMaps.get(runtimeKey);
|
||||
if (existing) return existing;
|
||||
const envelope = readWorktreeProjectEnvelope();
|
||||
let map = envelope.runtimes[runtimeKey]?.entries ?? null;
|
||||
if (!map && !envelope.legacyClaimed) {
|
||||
try {
|
||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(WORKTREE_PROJECT_MAP_KEY) : null;
|
||||
_worktreeProjectMap = raw ? (JSON.parse(raw) as Record<string, string>) : {};
|
||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(LEGACY_WORKTREE_PROJECT_MAP_KEY) : null;
|
||||
map = raw ? (JSON.parse(raw) as Record<string, string>) : {};
|
||||
envelope.legacyClaimed = true;
|
||||
envelope.runtimes[runtimeKey] = { updatedAt: Date.now(), entries: map };
|
||||
writeWorktreeProjectEnvelope(envelope);
|
||||
localStorage.removeItem(LEGACY_WORKTREE_PROJECT_MAP_KEY);
|
||||
} catch {
|
||||
_worktreeProjectMap = {};
|
||||
map = {};
|
||||
}
|
||||
}
|
||||
return _worktreeProjectMap;
|
||||
const result = map ?? {};
|
||||
_worktreeProjectMaps.set(runtimeKey, result);
|
||||
return result;
|
||||
};
|
||||
const rememberWorktreeProject = (worktree: string, project: string): void => {
|
||||
if (!worktree || !project || worktree === project) return;
|
||||
@@ -736,7 +776,12 @@ const rememberWorktreeProject = (worktree: string, project: string): void => {
|
||||
if (map[worktree] === project) return;
|
||||
map[worktree] = project;
|
||||
try {
|
||||
localStorage.setItem(WORKTREE_PROJECT_MAP_KEY, JSON.stringify(map));
|
||||
const runtimeKey = getRuntimeKey() || 'default';
|
||||
const envelope = readWorktreeProjectEnvelope();
|
||||
envelope.legacyClaimed = true;
|
||||
envelope.runtimes[runtimeKey] = { updatedAt: Date.now(), entries: map };
|
||||
writeWorktreeProjectEnvelope(envelope);
|
||||
localStorage.removeItem(LEGACY_WORKTREE_PROJECT_MAP_KEY);
|
||||
} catch {
|
||||
// localStorage quota exceeded — ignore; live resolution still works.
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ type Deferred<T> = {
|
||||
};
|
||||
|
||||
const searchRequests: Array<Deferred<Array<{ path: string }>>> = [];
|
||||
let runtimeKey = 'runtime-a';
|
||||
|
||||
const createDeferred = <T>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void;
|
||||
@@ -29,12 +30,14 @@ mock.module('@/lib/opencode/client', () => ({
|
||||
searchFiles: searchFilesMock,
|
||||
},
|
||||
}));
|
||||
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => runtimeKey }));
|
||||
|
||||
const { useFileSearchStore } = await import('./useFileSearchStore');
|
||||
|
||||
describe('useFileSearchStore', () => {
|
||||
beforeEach(() => {
|
||||
searchRequests.length = 0;
|
||||
runtimeKey = 'runtime-a';
|
||||
useFileSearchStore.setState({
|
||||
cache: {},
|
||||
cacheKeys: [],
|
||||
@@ -101,4 +104,20 @@ describe('useFileSearchStore', () => {
|
||||
searchRequests[1].resolve([{ path: 'second.ts' }]);
|
||||
expect(await secondPromise).toEqual([{ path: 'second.ts' }]);
|
||||
});
|
||||
|
||||
test('isolates cache and in-flight ownership by runtime', async () => {
|
||||
const firstPromise = useFileSearchStore.getState().searchFiles('/project', 'foo');
|
||||
runtimeKey = 'runtime-b';
|
||||
const secondPromise = useFileSearchStore.getState().searchFiles('/project', 'foo');
|
||||
expect(searchRequests).toHaveLength(2);
|
||||
|
||||
searchRequests[1].resolve([{ path: 'runtime-b.ts' }]);
|
||||
expect(await secondPromise).toEqual([{ path: 'runtime-b.ts' }]);
|
||||
searchRequests[0].resolve([{ path: 'runtime-a.ts' }]);
|
||||
await firstPromise;
|
||||
|
||||
runtimeKey = 'runtime-b';
|
||||
expect(await useFileSearchStore.getState().searchFiles('/project', 'foo')).toEqual([{ path: 'runtime-b.ts' }]);
|
||||
expect(searchRequests).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { opencodeClient, type ProjectFileSearchHit } from '@/lib/opencode/client';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
const MAX_CACHE_ENTRIES = 40;
|
||||
@@ -22,9 +23,11 @@ interface FileSearchStoreState {
|
||||
options?: { includeHidden?: boolean; respectGitignore?: boolean; type?: 'file' | 'directory' }
|
||||
) => Promise<ProjectFileSearchHit[]>;
|
||||
invalidateDirectory: (directory?: string | null) => void;
|
||||
resetForRuntimeSwitch: () => void;
|
||||
}
|
||||
|
||||
const buildCacheKey = (
|
||||
runtimeKey: string,
|
||||
directory: string,
|
||||
query: string,
|
||||
limit: number,
|
||||
@@ -34,13 +37,13 @@ const buildCacheKey = (
|
||||
) => {
|
||||
const normalizedDirectory = directory.trim();
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return JSON.stringify([normalizedDirectory, normalizedQuery, limit, includeHidden, respectGitignore, type]);
|
||||
return JSON.stringify([runtimeKey, normalizedDirectory, normalizedQuery, limit, includeHidden, respectGitignore, type]);
|
||||
};
|
||||
|
||||
const cacheKeyMatchesDirectory = (cacheKey: string, directory: string) => {
|
||||
try {
|
||||
const value: unknown = JSON.parse(cacheKey);
|
||||
return Array.isArray(value) && value[0] === directory;
|
||||
return Array.isArray(value) && value[1] === directory;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -58,11 +61,12 @@ export const useFileSearchStore = create<FileSearchStoreState>()(
|
||||
}
|
||||
|
||||
const normalizedDirectory = directory.trim();
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
|
||||
const includeHidden = Boolean(options?.includeHidden);
|
||||
const respectGitignore = options?.respectGitignore ?? true;
|
||||
const type = options?.type === 'directory' ? 'directory' : 'file';
|
||||
const key = buildCacheKey(normalizedDirectory, normalizedQuery, limit, includeHidden, respectGitignore, type);
|
||||
const key = buildCacheKey(runtimeKey, normalizedDirectory, normalizedQuery, limit, includeHidden, respectGitignore, type);
|
||||
const now = Date.now();
|
||||
const cached = get().cache[key];
|
||||
|
||||
@@ -159,6 +163,9 @@ export const useFileSearchStore = create<FileSearchStoreState>()(
|
||||
};
|
||||
});
|
||||
},
|
||||
resetForRuntimeSwitch() {
|
||||
set({ cache: {}, cacheKeys: [], inFlight: {} });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'file-search-store',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user