fix(ui): preserve upstream behavior after performance rebase
This commit is contained in:
@@ -284,7 +284,7 @@ const initializePerformanceDom = async (): Promise<void> => {
|
|||||||
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
|
mock.module('@/hooks/useEffectiveDirectory', () => ({ useEffectiveDirectory: () => null }));
|
||||||
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
|
mock.module('@/hooks/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: false } }) }));
|
||||||
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
|
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: async () => ({ ok: false }) }));
|
||||||
mock.module('@/lib/url', () => ({ isExternalHttpUrl: () => false, openExternalUrl: async () => undefined, getExternalFaviconUrl: () => null, isLoopbackHttpUrl: () => false }));
|
mock.module('@/lib/url', () => ({ getUrlScheme: () => null, isAppLinkUrl: () => false, isExternalHttpUrl: () => false, openConfirmedAppLinkUrl: async () => false, openExternalUrl: async () => undefined, getExternalFaviconUrl: () => null, isLoopbackHttpUrl: () => false }));
|
||||||
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
|
mock.module('@/lib/desktop', () => ({ isDesktopLocalOriginActive: () => false, isDesktopShell: () => false, isVSCodeRuntime: () => false }));
|
||||||
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
|
mock.module('@/lib/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
|
||||||
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
|
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ let hookStates: Array<{ current: null } | undefined> = [];
|
|||||||
let activeFakeDocument: FakeDocument | null = null;
|
let activeFakeDocument: FakeDocument | null = null;
|
||||||
|
|
||||||
const makeFakeElement = (ownerDocument: { createElement: () => FakeElement }): FakeElement => {
|
const makeFakeElement = (ownerDocument: { createElement: () => FakeElement }): FakeElement => {
|
||||||
|
void ownerDocument;
|
||||||
let html = '';
|
let html = '';
|
||||||
const element: FakeElement = {
|
const element: FakeElement = {
|
||||||
childNodes: [],
|
childNodes: [],
|
||||||
@@ -184,6 +185,7 @@ const fakeReact = {
|
|||||||
return factory();
|
return factory();
|
||||||
},
|
},
|
||||||
useRef: <T>(current: T) => {
|
useRef: <T>(current: T) => {
|
||||||
|
void current;
|
||||||
const index = hookCursor;
|
const index = hookCursor;
|
||||||
hookCursor += 1;
|
hookCursor += 1;
|
||||||
if (!hookStates[index]) hookStates[index] = { current: null };
|
if (!hookStates[index]) hookStates[index] = { current: null };
|
||||||
|
|||||||
@@ -1080,8 +1080,9 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
|||||||
scope: `${runtimeKey}\0${settledPart.sessionID}`,
|
scope: `${runtimeKey}\0${settledPart.sessionID}`,
|
||||||
id: `${settledPart.messageID}\0${settledPart.id}\0${imageMode}`,
|
id: `${settledPart.messageID}\0${settledPart.id}\0${imageMode}`,
|
||||||
locale,
|
locale,
|
||||||
|
directory: effectiveDirectory,
|
||||||
};
|
};
|
||||||
}, [content.length, imageMode, isStreaming, locale, runtimeKey, settledPart]);
|
}, [content.length, effectiveDirectory, imageMode, isStreaming, locale, runtimeKey, settledPart]);
|
||||||
// Identity for the fade-in wrapper: a new part/message restarts the animation.
|
// Identity for the fade-in wrapper: a new part/message restarts the animation.
|
||||||
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { DetachedMarkdownDomCache, type DetachedMarkdownDom } from './detachedMa
|
|||||||
|
|
||||||
Object.assign(globalThis, { document: new Window().document });
|
Object.assign(globalThis, { document: new Window().document });
|
||||||
|
|
||||||
const keyFor = ({ scope, id, locale }: DetachedMarkdownDom) => ({ scope, id, locale });
|
const keyFor = ({ scope, id, locale, directory }: DetachedMarkdownDom) => ({ scope, id, locale, directory });
|
||||||
|
|
||||||
const createEntry = (
|
const createEntry = (
|
||||||
document: Document,
|
document: Document,
|
||||||
@@ -21,6 +21,7 @@ const createEntry = (
|
|||||||
scope: `runtime:${sessionId}`,
|
scope: `runtime:${sessionId}`,
|
||||||
id: `${messageId}:${partId}`,
|
id: `${messageId}:${partId}`,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
|
directory: '/repo-a',
|
||||||
fragment,
|
fragment,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -51,11 +52,13 @@ describe('DetachedMarkdownDomCache', () => {
|
|||||||
scope: 'runtime:session-a',
|
scope: 'runtime:session-a',
|
||||||
id: 'message-2:part',
|
id: 'message-2:part',
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
|
directory: '/repo-a',
|
||||||
})).toBeNull();
|
})).toBeNull();
|
||||||
expect(cache.take({
|
expect(cache.take({
|
||||||
scope: 'runtime:session-c',
|
scope: 'runtime:session-c',
|
||||||
id: 'message-5:part',
|
id: 'message-5:part',
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
|
directory: '/repo-a',
|
||||||
})).not.toBeNull();
|
})).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,6 +78,15 @@ describe('DetachedMarkdownDomCache', () => {
|
|||||||
expect(cache.take(keyFor(replacement))?.firstChild).toBe(replacementNode);
|
expect(cache.take(keyFor(replacement))?.firstChild).toBe(replacementNode);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not restore file-link DOM under another directory', () => {
|
||||||
|
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||||
|
const entry = createEntry(document, 'session', 'message', 'part');
|
||||||
|
|
||||||
|
cache.store(entry);
|
||||||
|
|
||||||
|
expect(cache.take({ ...keyFor(entry), directory: '/repo-b' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test('refreshes session LRU and clears all entries', () => {
|
test('refreshes session LRU and clears all entries', () => {
|
||||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||||
const sessionA = createEntry(document, 'session-a', 'message-a', 'part');
|
const sessionA = createEntry(document, 'session-a', 'message-a', 'part');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type DetachedMarkdownDomKey = {
|
|||||||
scope: string;
|
scope: string;
|
||||||
id: string;
|
id: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
directory: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DetachedMarkdownDom = DetachedMarkdownDomKey & {
|
export type DetachedMarkdownDom = DetachedMarkdownDomKey & {
|
||||||
@@ -79,7 +80,7 @@ export class DetachedMarkdownDomCache {
|
|||||||
// A fragment is a move-only resource; taking it removes cache ownership.
|
// A fragment is a move-only resource; taking it removes cache ownership.
|
||||||
session.delete(entryKey);
|
session.delete(entryKey);
|
||||||
if (session.size === 0) this.sessions.delete(sessionKey);
|
if (session.size === 0) this.sessions.delete(sessionKey);
|
||||||
if (entry.locale !== key.locale) return null;
|
if (entry.locale !== key.locale || entry.directory !== key.directory) return null;
|
||||||
return entry.fragment;
|
return entry.fragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
|||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { CollapsedActivityIndicator, type CollapsedActivityState } from './sidebar/sessions/collapsedActivityIndicator';
|
import { CollapsedActivityIndicator } from './sidebar/sessions/collapsedActivityIndicator';
|
||||||
|
import type { CollapsedActivityState } from './sidebar/sessions/collapsedActivityState';
|
||||||
|
|
||||||
interface SessionFolderItemProps<TSessionNode> {
|
interface SessionFolderItemProps<TSessionNode> {
|
||||||
folder: SessionFolder;
|
folder: SessionFolder;
|
||||||
@@ -56,9 +57,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
children,
|
children,
|
||||||
groupDirectory,
|
|
||||||
projectId,
|
|
||||||
mobileVariant = false,
|
mobileVariant = false,
|
||||||
alwaysShowActions = mobileVariant,
|
alwaysShowActions = mobileVariant,
|
||||||
isRenaming = false,
|
isRenaming = false,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { usePrefetchSessionMessages } from '@/sync/use-sync';
|
import { usePrefetchSessionMessages } from '@/sync/use-sync';
|
||||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||||
@@ -24,9 +25,18 @@ import type { useSessionProjectViewState } from '../projects/useSessionProjectVi
|
|||||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
import type { DeleteSessionConfirmState } from '../sessions/useSessionActions';
|
import type { DeleteSessionConfirmState } from '../sessions/useSessionActions';
|
||||||
import { useExpandedParents } from '../sessions/useExpandedParents';
|
import { useExpandedParents } from '../sessions/useExpandedParents';
|
||||||
|
import { SessionGroupSection } from '../projects/SessionGroupSection';
|
||||||
|
import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory } from '@/lib/chatDirectories';
|
||||||
|
import { isCapacitorApp } from '@/lib/platform';
|
||||||
|
|
||||||
const PR_NO_PR_RETRY_MS = 5 * 60_000;
|
const PR_NO_PR_RETRY_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
const isRootSession = (session: Session): boolean => {
|
||||||
|
// SAFETY: OpenCode attaches parentID to hierarchical session records,
|
||||||
|
// although the SDK's base Session type does not currently declare it.
|
||||||
|
return !(session as Session & { parentID?: string | null }).parentID;
|
||||||
|
};
|
||||||
|
|
||||||
type Project = {
|
type Project = {
|
||||||
id: string;
|
id: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -122,8 +132,13 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
|
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
|
||||||
|
const projectDisplayMode = useSessionDisplayStore((state) => state.projectDisplayMode);
|
||||||
|
const singleProjectId = useSessionDisplayStore((state) => state.singleProjectId);
|
||||||
|
const setSingleProjectId = useSessionDisplayStore((state) => state.setSingleProjectId);
|
||||||
|
const supportsSingleProjectMode = !topology.isVSCode && !isCapacitorApp();
|
||||||
|
const singleProjectMode = supportsSingleProjectMode && projectDisplayMode === 'single';
|
||||||
const recentSessions = useRecentSessionCollection({
|
const recentSessions = useRecentSessionCollection({
|
||||||
enabled: showRecentSection,
|
enabled: showRecentSection && !singleProjectMode,
|
||||||
isVSCode: topology.isVSCode,
|
isVSCode: topology.isVSCode,
|
||||||
pinnedSessionIds: collection.pinnedSessionIds,
|
pinnedSessionIds: collection.pinnedSessionIds,
|
||||||
sessionOrderRanks: collection.sessionOrderRanks,
|
sessionOrderRanks: collection.sessionOrderRanks,
|
||||||
@@ -269,6 +284,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
|||||||
editingId,
|
editingId,
|
||||||
editTitle,
|
editTitle,
|
||||||
copiedSessionId,
|
copiedSessionId,
|
||||||
|
sessionBatchSize: singleProjectMode && !view.useGroupedSections ? 20 : undefined,
|
||||||
setEditingId,
|
setEditingId,
|
||||||
setEditTitle,
|
setEditTitle,
|
||||||
toggleParent,
|
toggleParent,
|
||||||
@@ -309,6 +325,8 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
|||||||
view.hasSessionSearchQuery,
|
view.hasSessionSearchQuery,
|
||||||
view.mobileVariant,
|
view.mobileVariant,
|
||||||
view.normalizedSessionSearchQuery,
|
view.normalizedSessionSearchQuery,
|
||||||
|
view.useGroupedSections,
|
||||||
|
singleProjectMode,
|
||||||
]);
|
]);
|
||||||
const groupActions = React.useMemo(() => ({
|
const groupActions = React.useMemo(() => ({
|
||||||
showMoreGroupSessions,
|
showMoreGroupSessions,
|
||||||
@@ -327,8 +345,55 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
|||||||
scrollerActions.setActiveProjectIdOnly,
|
scrollerActions.setActiveProjectIdOnly,
|
||||||
scrollerActions.setSessionSwitcherOpen,
|
scrollerActions.setSessionSwitcherOpen,
|
||||||
]);
|
]);
|
||||||
|
const chatGroup = React.useMemo<SessionGroup | null>(() => {
|
||||||
|
if (topology.isVSCode) return null;
|
||||||
|
const chatsRoot = getChatsRootForHome(view.homeDirectory)
|
||||||
|
?? collection.chatSessions.map((session) => getChatsRootFromDirectory(session.directory)).find(Boolean)
|
||||||
|
?? null;
|
||||||
|
if (!chatsRoot) return null;
|
||||||
|
const folderScopes = Array.from(new Set([
|
||||||
|
chatsRoot,
|
||||||
|
...collection.chatSessions.map((session) => normalizePath(session.directory ?? null)).filter(Boolean),
|
||||||
|
])).filter((directory): directory is string => Boolean(directory))
|
||||||
|
.map((directory) => ({ scopeKey: directory, directory }));
|
||||||
|
return {
|
||||||
|
id: 'managed-chats',
|
||||||
|
label: '',
|
||||||
|
branch: null,
|
||||||
|
description: null,
|
||||||
|
isMain: true,
|
||||||
|
worktree: null,
|
||||||
|
directory: chatsRoot,
|
||||||
|
folderScopeKey: chatsRoot,
|
||||||
|
folderScopes,
|
||||||
|
draftTarget: 'chat',
|
||||||
|
sessions: collection.chatSessions
|
||||||
|
.filter((session) => !session.time?.archived && isRootSession(session))
|
||||||
|
.map((session) => ({ session, children: (collection.childrenMap.get(session.id) ?? []).filter((child) => !child.time?.archived).map((child) => ({ session: child, children: [], worktree: null })), worktree: null })),
|
||||||
|
};
|
||||||
|
}, [collection.chatSessions, collection.childrenMap, topology.isVSCode, view.homeDirectory]);
|
||||||
|
const renderChatsSection = React.useCallback(() => {
|
||||||
|
if (!chatGroup) return null;
|
||||||
|
return <SessionGroupSection
|
||||||
|
{...groupProps}
|
||||||
|
{...groupActions}
|
||||||
|
group={chatGroup}
|
||||||
|
groupKey="managed-chats"
|
||||||
|
projectId={null}
|
||||||
|
hideGroupLabel
|
||||||
|
sessionBatchSize={20}
|
||||||
|
scrollContainerRef={undefined}
|
||||||
|
openSidebarMenuKey={openSidebarMenuKey}
|
||||||
|
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||||
|
/>;
|
||||||
|
}, [chatGroup, groupActions, groupProps, openSidebarMenuKey]);
|
||||||
|
const handleOpenNewChat = React.useCallback(() => {
|
||||||
|
scrollerActions.setActiveMainTab('chat');
|
||||||
|
if (view.mobileVariant) scrollerActions.setSessionSwitcherOpen(false);
|
||||||
|
scrollerActions.openNewSessionDraft({ selectedProjectId: CHAT_DRAFT_PROJECT_ID, directoryOverride: null });
|
||||||
|
}, [scrollerActions, view.mobileVariant]);
|
||||||
const recentSection = React.useMemo(() => (
|
const recentSection = React.useMemo(() => (
|
||||||
!topology.isVSCode && showRecentSection ? <RecentSessionSection
|
!topology.isVSCode ? <RecentSessionSection
|
||||||
projects={topology.projects}
|
projects={topology.projects}
|
||||||
availableWorktreesByProject={topology.availableWorktreesByProject}
|
availableWorktreesByProject={topology.availableWorktreesByProject}
|
||||||
gitBranches={topology.gitBranches}
|
gitBranches={topology.gitBranches}
|
||||||
@@ -362,6 +427,10 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
|||||||
setDeleteSessionConfirm={setDeleteSessionConfirm}
|
setDeleteSessionConfirm={setDeleteSessionConfirm}
|
||||||
startFolderRename={startFolderRename}
|
startFolderRename={startFolderRename}
|
||||||
setCopiedSessionId={setCopiedSessionId}
|
setCopiedSessionId={setCopiedSessionId}
|
||||||
|
chatSessions={collection.chatSessions}
|
||||||
|
renderChatsSection={renderChatsSection}
|
||||||
|
onNewChat={handleOpenNewChat}
|
||||||
|
showRecentSection={showRecentSection && !singleProjectMode}
|
||||||
/> : null
|
/> : null
|
||||||
), [
|
), [
|
||||||
alwaysShowActions,
|
alwaysShowActions,
|
||||||
@@ -378,12 +447,16 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
|||||||
recentSessions,
|
recentSessions,
|
||||||
rowActions,
|
rowActions,
|
||||||
showRecentSection,
|
showRecentSection,
|
||||||
|
singleProjectMode,
|
||||||
|
handleOpenNewChat,
|
||||||
|
renderChatsSection,
|
||||||
startFolderRename,
|
startFolderRename,
|
||||||
toggleParent,
|
toggleParent,
|
||||||
topology.availableWorktreesByProject,
|
topology.availableWorktreesByProject,
|
||||||
topology.gitBranches,
|
topology.gitBranches,
|
||||||
topology.isVSCode,
|
topology.isVSCode,
|
||||||
topology.projects,
|
topology.projects,
|
||||||
|
collection.chatSessions,
|
||||||
view.hasSessionSearchQuery,
|
view.hasSessionSearchQuery,
|
||||||
view.homeDirectory,
|
view.homeDirectory,
|
||||||
view.isDesktopShellRuntime,
|
view.isDesktopShellRuntime,
|
||||||
@@ -396,6 +469,14 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
|||||||
sectionsForRender: orderedSectionsForRender,
|
sectionsForRender: orderedSectionsForRender,
|
||||||
projectSections,
|
projectSections,
|
||||||
activeProjectId: view.activeProjectId,
|
activeProjectId: view.activeProjectId,
|
||||||
|
singleProjectMode,
|
||||||
|
singleProjectId: singleProjectMode
|
||||||
|
? (projectSections.some((section) => section.project.id === singleProjectId)
|
||||||
|
? singleProjectId
|
||||||
|
: (projectSections.some((section) => section.project.id === view.activeProjectId)
|
||||||
|
? view.activeProjectId
|
||||||
|
: projectSections[0]?.project.id ?? null))
|
||||||
|
: null,
|
||||||
emptyState: view.emptyState,
|
emptyState: view.emptyState,
|
||||||
searchEmptyState: view.searchEmptyState,
|
searchEmptyState: view.searchEmptyState,
|
||||||
projectRepoStatus: topology.projectRepoStatus,
|
projectRepoStatus: topology.projectRepoStatus,
|
||||||
@@ -416,6 +497,8 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
|||||||
view.searchEmptyState,
|
view.searchEmptyState,
|
||||||
visibleSessionCountByGroup,
|
visibleSessionCountByGroup,
|
||||||
recentSection,
|
recentSection,
|
||||||
|
singleProjectId,
|
||||||
|
singleProjectMode,
|
||||||
]);
|
]);
|
||||||
const scrollerView = React.useMemo(() => ({
|
const scrollerView = React.useMemo(() => ({
|
||||||
homeDirectory: view.homeDirectory,
|
homeDirectory: view.homeDirectory,
|
||||||
@@ -456,6 +539,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
|||||||
reorderProjects: scrollerActions.reorderProjects,
|
reorderProjects: scrollerActions.reorderProjects,
|
||||||
setGroupOrderByProject,
|
setGroupOrderByProject,
|
||||||
renderProjectStatusIndicator: scrollerActions.renderProjectStatusIndicator,
|
renderProjectStatusIndicator: scrollerActions.renderProjectStatusIndicator,
|
||||||
|
setSingleProjectId,
|
||||||
}), [
|
}), [
|
||||||
groupActions,
|
groupActions,
|
||||||
scrollerActions.openNewSessionDraft,
|
scrollerActions.openNewSessionDraft,
|
||||||
@@ -470,6 +554,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
|||||||
setGroupOrderByProject,
|
setGroupOrderByProject,
|
||||||
toggleProject,
|
toggleProject,
|
||||||
scrollerActions.renderProjectStatusIndicator,
|
scrollerActions.renderProjectStatusIndicator,
|
||||||
|
setSingleProjectId,
|
||||||
]);
|
]);
|
||||||
return <>
|
return <>
|
||||||
<ProjectSessionSelectionEffect
|
<ProjectSessionSelectionEffect
|
||||||
|
|||||||
@@ -5,7 +5,14 @@ import React, { act } from 'react';
|
|||||||
import { createRoot, type Root } from 'react-dom/client';
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
import { deriveRecentSessions } from '../recent/activitySections';
|
import { deriveRecentSessions } from '../recent/activitySections';
|
||||||
import { applyGlobalSessionStatusEvent, useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
import { applyGlobalSessionStatusEvent, useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||||
import { getDescendantIds, projectSidebarActiveSessions, projectSidebarCollection, useRecentSessionCollection } from './sessionCollection';
|
import {
|
||||||
|
buildSidebarSessionProjection,
|
||||||
|
getDescendantIds,
|
||||||
|
partitionSidebarSessions,
|
||||||
|
projectSidebarActiveSessions,
|
||||||
|
projectSidebarCollection,
|
||||||
|
useRecentSessionCollection,
|
||||||
|
} from './sessionCollection';
|
||||||
|
|
||||||
const installMinimalDom = () => {
|
const installMinimalDom = () => {
|
||||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||||
@@ -157,6 +164,80 @@ describe('projectSidebarCollection', () => {
|
|||||||
expect(recentBefore).toEqual([]);
|
expect(recentBefore).toEqual([]);
|
||||||
expect(recentAfter.map((entry) => entry.id)).toEqual(['old-root']);
|
expect(recentAfter.map((entry) => entry.id)).toEqual(['old-root']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps managed Chats in a dedicated projection and out of project and Recent ownership', () => {
|
||||||
|
const managed = session('managed', '/home/.config/openchamber/chats/2026-08-24/session-managed');
|
||||||
|
const project = session('project', '/workspace/a');
|
||||||
|
const projects = projectSidebarCollection({
|
||||||
|
globalActiveSessions: [managed, project],
|
||||||
|
liveSessions: [],
|
||||||
|
knownDirectories: new Set(['/workspace/a']),
|
||||||
|
isVSCode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(projects.map((entry) => entry.id)).toEqual(['project']);
|
||||||
|
expect(partitionSidebarSessions([managed, project], false).chatSessions.map((entry) => entry.id)).toEqual(['managed']);
|
||||||
|
expect(deriveRecentSessions(projects, new Set(['managed', 'project']), 200_000_000)
|
||||||
|
.map((entry) => entry.id)).toEqual(['project']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps managed Chats out of the VS Code sidebar', () => {
|
||||||
|
const managed = session('managed', '/home/.config/openchamber/chats/2026-08-24/session-managed');
|
||||||
|
|
||||||
|
expect(partitionSidebarSessions([managed], true)).toEqual({ projectSessions: [], chatSessions: [] });
|
||||||
|
expect(projectSidebarCollection({
|
||||||
|
globalActiveSessions: [managed],
|
||||||
|
liveSessions: [],
|
||||||
|
knownDirectories: new Set(),
|
||||||
|
isVSCode: true,
|
||||||
|
})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('excludes a /btw fork before project ownership and restores it when the marker is removed', () => {
|
||||||
|
const fork = {
|
||||||
|
...session('fork', '/home/.config/openchamber/chats/2026-08-24/session-fork'),
|
||||||
|
metadata: { openchamber: { kind: 'btw', originalSessionID: 'parent' } },
|
||||||
|
};
|
||||||
|
const project = session('project', '/workspace/a');
|
||||||
|
const input = {
|
||||||
|
liveSessions: [],
|
||||||
|
knownDirectories: new Set(['/workspace/a']),
|
||||||
|
isVSCode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(projectSidebarCollection({ ...input, globalActiveSessions: [fork, project] }).map((entry) => entry.id)).toEqual(['project']);
|
||||||
|
expect(partitionSidebarSessions([fork], false).chatSessions).toEqual([]);
|
||||||
|
|
||||||
|
const promoted = {
|
||||||
|
...fork,
|
||||||
|
metadata: { openchamber: {} },
|
||||||
|
};
|
||||||
|
expect(partitionSidebarSessions([promoted], false).chatSessions.map((entry) => entry.id)).toEqual(['fork']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a ranked managed root and its active child in the Chats hierarchy', () => {
|
||||||
|
const managedRoot = { ...session('managed-root', '/home/.config/openchamber/chats/2026-08-24/session-root'), time: { created: 1, updated: 1 } };
|
||||||
|
const managedChild = {
|
||||||
|
...session('managed-child', '/home/.config/openchamber/chats/2026-08-24/session-root'),
|
||||||
|
parentID: 'managed-root',
|
||||||
|
time: { created: 2, updated: 2 },
|
||||||
|
};
|
||||||
|
const projectRoot = { ...session('project-root', '/workspace/a'), time: { created: 3, updated: 3 } };
|
||||||
|
|
||||||
|
const projection = buildSidebarSessionProjection({
|
||||||
|
globalActiveSessions: [projectRoot, managedRoot, managedChild],
|
||||||
|
liveSessions: [],
|
||||||
|
knownDirectories: new Set(['/workspace/a']),
|
||||||
|
isVSCode: false,
|
||||||
|
pinnedSessionIds: new Set(),
|
||||||
|
sessionOrderRanks: new Map([['managed-root', 10]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(projection.projectSessions.map((entry) => entry.id)).toEqual(['project-root']);
|
||||||
|
expect(projection.chatSessions.map((entry) => entry.id)).toEqual(['managed-root', 'managed-child']);
|
||||||
|
expect(projection.orderedSessions.map((entry) => entry.id)).toEqual(['managed-root', 'managed-child', 'project-root']);
|
||||||
|
expect(projection.childrenMap.get('managed-root')?.map((entry) => entry.id)).toEqual(['managed-child']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useRecentSessionCollection', () => {
|
describe('useRecentSessionCollection', () => {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
|||||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||||
import { deriveRecentSessions } from '../recent/activitySections';
|
import { deriveRecentSessions } from '../recent/activitySections';
|
||||||
import { normalizePath } from '../utils';
|
import { normalizePath } from '../utils';
|
||||||
|
import { isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||||
|
import { isBtwSession } from '@/lib/sessionBtwMetadata';
|
||||||
|
|
||||||
type ProjectSidebarActiveSessionsArgs = {
|
type ProjectSidebarActiveSessionsArgs = {
|
||||||
globalActiveSessions: Session[];
|
globalActiveSessions: Session[];
|
||||||
@@ -21,6 +23,31 @@ type ProjectSidebarActiveSessionsArgs = {
|
|||||||
isVSCode: boolean;
|
isVSCode: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SidebarSessionPartitions = {
|
||||||
|
projectSessions: Session[];
|
||||||
|
chatSessions: Session[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// This boundary owns session visibility before Recent or projects take
|
||||||
|
// ownership. Temporary /btw forks never leak into any sidebar projection.
|
||||||
|
export const partitionSidebarSessions = (
|
||||||
|
sessions: readonly Session[],
|
||||||
|
isVSCode: boolean,
|
||||||
|
): SidebarSessionPartitions => {
|
||||||
|
const projectSessions: Session[] = [];
|
||||||
|
const chatSessions: Session[] = [];
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (isBtwSession(session)) continue;
|
||||||
|
if (isChatDirectoryPath(session.directory)) {
|
||||||
|
if (isVSCode) continue;
|
||||||
|
chatSessions.push(session);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
projectSessions.push(session);
|
||||||
|
}
|
||||||
|
return { projectSessions, chatSessions };
|
||||||
|
};
|
||||||
|
|
||||||
const EMPTY_ACTIVE_SESSION_IDS: ReadonlySet<string> = new Set();
|
const EMPTY_ACTIVE_SESSION_IDS: ReadonlySet<string> = new Set();
|
||||||
|
|
||||||
const isKnownActiveSessionDirectory = (
|
const isKnownActiveSessionDirectory = (
|
||||||
@@ -51,13 +78,28 @@ export const projectSidebarActiveSessions = ({
|
|||||||
sessions.push(session);
|
sessions.push(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessions.filter((session) => isKnownActiveSessionDirectory(session, knownDirectories, isVSCode));
|
return partitionSidebarSessions(sessions, isVSCode).projectSessions
|
||||||
|
.filter((session) => isKnownActiveSessionDirectory(session, knownDirectories, isVSCode));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const projectSidebarCollection = (args: ProjectSidebarActiveSessionsArgs): Session[] => {
|
export const projectSidebarCollection = (args: ProjectSidebarActiveSessionsArgs): Session[] => {
|
||||||
return projectSidebarActiveSessions(args);
|
return projectSidebarActiveSessions(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mergeSidebarSessionSources = (
|
||||||
|
globalActiveSessions: readonly Session[],
|
||||||
|
liveSessions: readonly Session[],
|
||||||
|
): Session[] => {
|
||||||
|
const sessions = [...globalActiveSessions];
|
||||||
|
const knownIds = new Set(globalActiveSessions.map((session) => session.id));
|
||||||
|
for (const session of liveSessions) {
|
||||||
|
if (knownIds.has(session.id)) continue;
|
||||||
|
knownIds.add(session.id);
|
||||||
|
sessions.push(session);
|
||||||
|
}
|
||||||
|
return sessions;
|
||||||
|
};
|
||||||
|
|
||||||
// The collection owns hierarchy membership. Consumers receive this narrow
|
// The collection owns hierarchy membership. Consumers receive this narrow
|
||||||
// resolver instead of retaining the collection's mutable indexing detail.
|
// resolver instead of retaining the collection's mutable indexing detail.
|
||||||
export const getDescendantIds = (
|
export const getDescendantIds = (
|
||||||
@@ -78,6 +120,49 @@ export const getDescendantIds = (
|
|||||||
return descendants;
|
return descendants;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SidebarSessionProjectionArgs = ProjectSidebarActiveSessionsArgs & {
|
||||||
|
pinnedSessionIds: Set<string>;
|
||||||
|
sessionOrderRanks: ReadonlyMap<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildSidebarSessionProjection = ({
|
||||||
|
globalActiveSessions,
|
||||||
|
liveSessions,
|
||||||
|
knownDirectories,
|
||||||
|
isVSCode,
|
||||||
|
pinnedSessionIds,
|
||||||
|
sessionOrderRanks,
|
||||||
|
}: SidebarSessionProjectionArgs) => {
|
||||||
|
const visibleSessions = mergeSidebarSessionSources(globalActiveSessions, liveSessions);
|
||||||
|
const partition = partitionSidebarSessions(visibleSessions, isVSCode);
|
||||||
|
const projectSessions = partition.projectSessions
|
||||||
|
.filter((session) => isKnownActiveSessionDirectory(session, knownDirectories, isVSCode));
|
||||||
|
const orderedSessions = orderSessionsByLifecycleScopes(
|
||||||
|
[...projectSessions, ...partition.chatSessions],
|
||||||
|
pinnedSessionIds,
|
||||||
|
sessionOrderRanks,
|
||||||
|
);
|
||||||
|
const chatSessionIds = new Set(partition.chatSessions.map((session) => session.id));
|
||||||
|
const sessionById = new Map(orderedSessions.map((session) => [session.id, session]));
|
||||||
|
const childrenMap = new Map<string, Session[]>();
|
||||||
|
for (const session of sessionById.values()) {
|
||||||
|
// SAFETY: OpenCode's session records carry parentID for sub-session
|
||||||
|
// hierarchy; the SDK's base Session type does not currently expose it.
|
||||||
|
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||||
|
if (!parentID) continue;
|
||||||
|
const siblings = childrenMap.get(parentID) ?? [];
|
||||||
|
siblings.push(session);
|
||||||
|
childrenMap.set(parentID, siblings);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
chatSessions: orderedSessions.filter((session) => chatSessionIds.has(session.id)),
|
||||||
|
childrenMap,
|
||||||
|
orderedSessions,
|
||||||
|
projectSessions,
|
||||||
|
sessionById,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type UseSessionProjectCollectionArgs = {
|
type UseSessionProjectCollectionArgs = {
|
||||||
knownDirectories: Set<string>;
|
knownDirectories: Set<string>;
|
||||||
isVSCode: boolean;
|
isVSCode: boolean;
|
||||||
@@ -101,16 +186,15 @@ export const useSessionProjectCollection = ({
|
|||||||
(state) => isVisible ? state.rankById : EMPTY_SESSION_ORDER_RANKS,
|
(state) => isVisible ? state.rankById : EMPTY_SESSION_ORDER_RANKS,
|
||||||
[isVisible],
|
[isVisible],
|
||||||
));
|
));
|
||||||
const sessions = React.useMemo(() => projectSidebarCollection({
|
const projection = React.useMemo(() => buildSidebarSessionProjection({
|
||||||
globalActiveSessions,
|
globalActiveSessions,
|
||||||
liveSessions,
|
liveSessions,
|
||||||
knownDirectories,
|
knownDirectories,
|
||||||
isVSCode,
|
isVSCode,
|
||||||
}), [globalActiveSessions, isVSCode, knownDirectories, liveSessions]);
|
pinnedSessionIds,
|
||||||
const orderedSessions = React.useMemo(
|
sessionOrderRanks,
|
||||||
() => orderSessionsByLifecycleScopes(sessions, pinnedSessionIds, sessionOrderRanks),
|
}), [globalActiveSessions, isVSCode, knownDirectories, liveSessions, pinnedSessionIds, sessionOrderRanks]);
|
||||||
[pinnedSessionIds, sessionOrderRanks, sessions],
|
const { chatSessions, orderedSessions, projectSessions: sessions } = projection;
|
||||||
);
|
|
||||||
const sessionById = React.useMemo(() => new Map(
|
const sessionById = React.useMemo(() => new Map(
|
||||||
[...orderedSessions, ...archivedSessions].map((session) => [session.id, session]),
|
[...orderedSessions, ...archivedSessions].map((session) => [session.id, session]),
|
||||||
), [archivedSessions, orderedSessions]);
|
), [archivedSessions, orderedSessions]);
|
||||||
@@ -129,13 +213,14 @@ export const useSessionProjectCollection = ({
|
|||||||
}, [sessionById]);
|
}, [sessionById]);
|
||||||
const getDescendantIdsForAction = React.useCallback(
|
const getDescendantIdsForAction = React.useCallback(
|
||||||
(sessionId: string, options: { includeArchived: boolean }) => getDescendantIds(childrenMap, sessionId)
|
(sessionId: string, options: { includeArchived: boolean }) => getDescendantIds(childrenMap, sessionId)
|
||||||
.filter((id) => options.includeArchived || !Boolean(sessionById.get(id)?.time?.archived)),
|
.filter((id) => options.includeArchived || !sessionById.get(id)?.time?.archived),
|
||||||
[childrenMap, sessionById],
|
[childrenMap, sessionById],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
archivedSessions,
|
archivedSessions,
|
||||||
childrenMap,
|
childrenMap,
|
||||||
|
chatSessions,
|
||||||
getDescendantIds: getDescendantIdsForAction,
|
getDescendantIds: getDescendantIdsForAction,
|
||||||
globalActiveSessions,
|
globalActiveSessions,
|
||||||
hasAuthoritativeGlobalSessions,
|
hasAuthoritativeGlobalSessions,
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ mock.module('./useAuthoritativeSessionCleanup', () => ({
|
|||||||
const { useSessionListSync } = await import('./useSessionListSync');
|
const { useSessionListSync } = await import('./useSessionListSync');
|
||||||
|
|
||||||
const projects = [{ id: 'project', path: '/project' }];
|
const projects = [{ id: 'project', path: '/project' }];
|
||||||
const projectDirectories = new Set(['/project']);
|
|
||||||
const worktree: WorktreeMetadata = { path: '/worktree', projectDirectory: '/project', branch: 'feature', label: 'feature' };
|
const worktree: WorktreeMetadata = { path: '/worktree', projectDirectory: '/project', branch: 'feature', label: 'feature' };
|
||||||
|
|
||||||
const LifecycleProbe: React.FC<{ isVSCode: boolean }> = ({ isVSCode }) => {
|
const LifecycleProbe: React.FC<{ isVSCode: boolean }> = ({ isVSCode }) => {
|
||||||
|
|||||||
+3
-22
@@ -7,9 +7,6 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import type { SessionGroupSectionProps } from './SessionGroupSection';
|
import type { SessionGroupSectionProps } from './SessionGroupSection';
|
||||||
import { SessionProjectScroller } from './SessionProjectScroller';
|
|
||||||
import { RecentSessionSection } from '../recent/RecentSessionSection';
|
|
||||||
import { SidebarActivitySections } from '../recent/SidebarActivitySections';
|
|
||||||
import { installHookTestDom } from '../test-utils/testDom';
|
import { installHookTestDom } from '../test-utils/testDom';
|
||||||
|
|
||||||
type FolderCallbacks = {
|
type FolderCallbacks = {
|
||||||
@@ -27,25 +24,6 @@ type RowPropsCapture = Pick<SessionGroupSectionProps,
|
|||||||
| 'setCopiedSessionId'
|
| 'setCopiedSessionId'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type ExpectNever<T extends never> = T;
|
|
||||||
type RowDomainCallback =
|
|
||||||
| 'handleSaveEdit'
|
|
||||||
| 'handleCancelEdit'
|
|
||||||
| 'handleSessionSelect'
|
|
||||||
| 'handleSessionDoubleClick'
|
|
||||||
| 'handleShareSession'
|
|
||||||
| 'handleCopyShareUrl'
|
|
||||||
| 'handleCopySessionId'
|
|
||||||
| 'handleUnshareSession'
|
|
||||||
| 'handleDeleteSession'
|
|
||||||
| 'handleRestoreSession';
|
|
||||||
|
|
||||||
// Structural group contracts must not expose the row domain action surface.
|
|
||||||
type _SessionGroupSectionHasNoRowDomainCallbacks = ExpectNever<Extract<keyof SessionGroupSectionProps, RowDomainCallback>>;
|
|
||||||
type _SessionProjectScrollerHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof SessionProjectScroller>, RowDomainCallback>>;
|
|
||||||
type _RecentSessionSectionHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof RecentSessionSection>, RowDomainCallback>>;
|
|
||||||
type _SidebarActivitySectionsHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof SidebarActivitySections>, RowDomainCallback>>;
|
|
||||||
|
|
||||||
let folderCallbacks: FolderCallbacks | null = null;
|
let folderCallbacks: FolderCallbacks | null = null;
|
||||||
let rowPropsCapture: RowPropsCapture | null = null;
|
let rowPropsCapture: RowPropsCapture | null = null;
|
||||||
|
|
||||||
@@ -80,6 +58,9 @@ mock.module('@/sync/sync-context', () => ({
|
|||||||
|
|
||||||
mock.module('../sessions/collapsedActivityIndicator', () => ({
|
mock.module('../sessions/collapsedActivityIndicator', () => ({
|
||||||
CollapsedSessionActivityIndicator: () => null,
|
CollapsedSessionActivityIndicator: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../sessions/collapsedActivityState', () => ({
|
||||||
useCollapsedSessionActivityState: () => null,
|
useCollapsedSessionActivityState: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
selectFolderIdsForProjection,
|
selectFolderIdsForProjection,
|
||||||
selectFolderRootNodes,
|
selectFolderRootNodes,
|
||||||
} from '../sessions/sessionNodeItemUtils';
|
} from '../sessions/sessionNodeItemUtils';
|
||||||
import type { SessionNodeRenderExtras } from '../sessions/sessionNodeItemUtils';
|
|
||||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
|
|
||||||
type FolderScope = { scopeKey: string; directory: string | null };
|
type FolderScope = { scopeKey: string; directory: string | null };
|
||||||
@@ -40,7 +39,8 @@ import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrSt
|
|||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { useChildStoreManager } from '@/sync/sync-context';
|
import { useChildStoreManager } from '@/sync/sync-context';
|
||||||
import { canRequestNativeDirectoryAccess, requestDirectoryAccess } from '@/lib/desktop';
|
import { canRequestNativeDirectoryAccess, requestDirectoryAccess } from '@/lib/desktop';
|
||||||
import { CollapsedSessionActivityIndicator, useCollapsedSessionActivityState } from '../sessions/collapsedActivityIndicator';
|
import { CollapsedSessionActivityIndicator } from '../sessions/collapsedActivityIndicator';
|
||||||
|
import { useCollapsedSessionActivityState } from '../sessions/collapsedActivityState';
|
||||||
import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||||
import { FolderDeleteConfirmDialog } from '../shell/ConfirmDialogs';
|
import { FolderDeleteConfirmDialog } from '../shell/ConfirmDialogs';
|
||||||
|
|
||||||
@@ -445,18 +445,6 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
|
|||||||
return allFoldersForGroupBase.filter(({ folder }) => visibleFolderIds.has(folder.id));
|
return allFoldersForGroupBase.filter(({ folder }) => visibleFolderIds.has(folder.id));
|
||||||
}, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]);
|
}, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]);
|
||||||
|
|
||||||
const groupSessionIds = React.useMemo(() => {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
const visit = (nodes: SessionNode[]) => nodes.forEach((node) => {
|
|
||||||
ids.add(node.session.id);
|
|
||||||
visit(node.children);
|
|
||||||
});
|
|
||||||
visit(sourceGroupNodes);
|
|
||||||
return ids;
|
|
||||||
}, [sourceGroupNodes]);
|
|
||||||
const groupExpansionKeys = React.useMemo(() => new Set(
|
|
||||||
[...groupSessionIds].map((id) => `project:${group.isArchivedBucket ? 'archived' : 'active'}:${id}`),
|
|
||||||
), [group.isArchivedBucket, groupSessionIds]);
|
|
||||||
const effectiveEditingId = editingId;
|
const effectiveEditingId = editingId;
|
||||||
const effectiveOpenMenuKey = openSidebarMenuKey;
|
const effectiveOpenMenuKey = openSidebarMenuKey;
|
||||||
const effectiveExpandedParents = expandedParents;
|
const effectiveExpandedParents = expandedParents;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { buildGroupRenderDescriptors } from './sessionProjectRender';
|
import { buildGroupRenderDescriptors, selectRenderedProjectSections } from './sessionProjectRender';
|
||||||
import type { SessionGroup } from '../types';
|
import type { SessionGroup } from '../types';
|
||||||
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
|
|
||||||
const makeGroup = (id: string, overrides: Partial<SessionGroup> = {}): SessionGroup => ({
|
const makeGroup = (id: string, overrides: Partial<SessionGroup> = {}): SessionGroup => ({
|
||||||
id,
|
id,
|
||||||
@@ -68,3 +69,23 @@ describe('buildGroupRenderDescriptors', () => {
|
|||||||
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false }).map((descriptor) => descriptor.hideGroupLabel)).toEqual([false, false]);
|
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false }).map((descriptor) => descriptor.hideGroupLabel)).toEqual([false, false]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('single-project scroller projection', () => {
|
||||||
|
test('renders only the selected project from persisted display state', () => {
|
||||||
|
const previous = useSessionDisplayStore.getState();
|
||||||
|
const sections = [
|
||||||
|
{ project: { id: 'project-a', normalizedPath: '/workspace/a' }, groups: [] },
|
||||||
|
{ project: { id: 'project-b', normalizedPath: '/workspace/b' }, groups: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
useSessionDisplayStore.setState({ projectDisplayMode: 'single', singleProjectId: 'project-b' });
|
||||||
|
const state = useSessionDisplayStore.getState();
|
||||||
|
|
||||||
|
expect(selectRenderedProjectSections(sections, state.projectDisplayMode === 'single', state.singleProjectId)
|
||||||
|
.map((section) => section.project.id)).toEqual(['project-b']);
|
||||||
|
} finally {
|
||||||
|
useSessionDisplayStore.setState(previous, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
|||||||
import type { SessionGroup } from '../types';
|
import type { SessionGroup } from '../types';
|
||||||
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
|
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||||||
import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
|
import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
|
||||||
import { buildGroupRenderDescriptors, type ProjectSection } from './sessionProjectRender';
|
import { buildGroupRenderDescriptors, selectRenderedProjectSections, type ProjectSection } from './sessionProjectRender';
|
||||||
import { formatProjectLabel } from '../utils';
|
import { formatProjectLabel } from '../utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
|
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
|
||||||
@@ -78,6 +78,8 @@ type SessionProjectScrollerModel = {
|
|||||||
sectionsForRender: ProjectSection[];
|
sectionsForRender: ProjectSection[];
|
||||||
projectSections: ProjectSection[];
|
projectSections: ProjectSection[];
|
||||||
activeProjectId: string | null;
|
activeProjectId: string | null;
|
||||||
|
singleProjectMode: boolean;
|
||||||
|
singleProjectId: string | null;
|
||||||
emptyState: React.ReactNode;
|
emptyState: React.ReactNode;
|
||||||
searchEmptyState: React.ReactNode;
|
searchEmptyState: React.ReactNode;
|
||||||
projectRepoStatus: Map<string, boolean | null>;
|
projectRepoStatus: Map<string, boolean | null>;
|
||||||
@@ -114,6 +116,7 @@ type SessionProjectScrollerActions = {
|
|||||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||||
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||||
|
setSingleProjectId: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -139,7 +142,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { model, view, actions } = props;
|
const { model, view, actions } = props;
|
||||||
const isInlineEditing = model.state.editingId !== null;
|
const isInlineEditing = model.state.editingId !== null;
|
||||||
const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders;
|
const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders && !model.singleProjectMode;
|
||||||
const projectSensors = useSensors(
|
const projectSensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
@@ -180,7 +183,12 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
const hasProjectScroller = model.projectSections.length > 0 && model.sectionsForRender.length > 0;
|
const renderedSections = selectRenderedProjectSections(
|
||||||
|
model.sectionsForRender,
|
||||||
|
model.singleProjectMode,
|
||||||
|
model.singleProjectId,
|
||||||
|
);
|
||||||
|
const hasProjectScroller = model.projectSections.length > 0 && renderedSections.length > 0;
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
|
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
|
||||||
syncTopFade(scrollContainerRef.current);
|
syncTopFade(scrollContainerRef.current);
|
||||||
@@ -199,8 +207,17 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
|||||||
// ready in the same frame; the observer then corrects it. When shared sessions
|
// ready in the same frame; the observer then corrects it. When shared sessions
|
||||||
// lead the list, the Recent fallback below owns the top instead of a project.
|
// lead the list, the Recent fallback below owns the top instead of a project.
|
||||||
const leadingProject =
|
const leadingProject =
|
||||||
stuckProject ?? (model.hasSharedSessions ? null : model.sectionsForRender[0]?.project ?? null);
|
stuckProject ?? (model.hasSharedSessions ? null : renderedSections[0]?.project ?? null);
|
||||||
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null;
|
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null;
|
||||||
|
const projectPickerOptions = React.useMemo(() => model.projectSections.map((section) => ({
|
||||||
|
id: section.project.id,
|
||||||
|
projectLabel: getProjectLabel(section.project, view.homeDirectory),
|
||||||
|
projectDescription: formatPathForDisplay(section.project.normalizedPath, view.homeDirectory),
|
||||||
|
projectIcon: section.project.icon,
|
||||||
|
projectColor: section.project.color,
|
||||||
|
projectIconImage: section.project.iconImage,
|
||||||
|
projectIconBackground: section.project.iconBackground,
|
||||||
|
})), [model.projectSections, view.homeDirectory]);
|
||||||
|
|
||||||
if (model.projectSections.length === 0) {
|
if (model.projectSections.length === 0) {
|
||||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className="space-y-1 pb-1 pl-2.5 pr-2">{model.topContent}{model.emptyState}</ScrollableOverlay>;
|
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className="space-y-1 pb-1 pl-2.5 pr-2">{model.topContent}{model.emptyState}</ScrollableOverlay>;
|
||||||
@@ -237,7 +254,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
|||||||
{view.showOnlyMainWorkspace ? (
|
{view.showOnlyMainWorkspace ? (
|
||||||
<div className="space-y-[0.6rem] py-1">
|
<div className="space-y-[0.6rem] py-1">
|
||||||
{(() => {
|
{(() => {
|
||||||
const activeSection = model.sectionsForRender.find((section) => section.project.id === model.activeProjectId) ?? model.sectionsForRender[0];
|
const activeSection = renderedSections.find((section) => section.project.id === model.activeProjectId) ?? renderedSections[0];
|
||||||
if (!activeSection) {
|
if (!activeSection) {
|
||||||
return view.hasSessionSearchQuery ? model.searchEmptyState : model.emptyState;
|
return view.hasSessionSearchQuery ? model.searchEmptyState : model.emptyState;
|
||||||
}
|
}
|
||||||
@@ -270,20 +287,20 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
|||||||
actions.reorderProjects(oldIndex, newIndex);
|
actions.reorderProjects(oldIndex, newIndex);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SortableContext items={model.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={renderedSections.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||||
{model.sectionsForRender.map((section) => {
|
{renderedSections.map((section) => {
|
||||||
const project = section.project;
|
const project = section.project;
|
||||||
const projectKey = project.id;
|
const projectKey = project.id;
|
||||||
const projectLabel = getProjectLabel(project, view.homeDirectory);
|
const projectLabel = getProjectLabel(project, view.homeDirectory);
|
||||||
const projectDescription = formatPathForDisplay(project.normalizedPath, view.homeDirectory);
|
const projectDescription = formatPathForDisplay(project.normalizedPath, view.homeDirectory);
|
||||||
const isCollapsed = view.collapsedProjects.has(projectKey);
|
const isCollapsed = model.singleProjectMode ? false : view.collapsedProjects.has(projectKey);
|
||||||
const isRepo = model.projectRepoStatus.get(projectKey);
|
const isRepo = model.projectRepoStatus.get(projectKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableProjectItem
|
<SortableProjectItem
|
||||||
key={projectKey}
|
key={projectKey}
|
||||||
id={projectKey}
|
id={projectKey}
|
||||||
disabled={view.projectSortOrder !== 'manual'}
|
disabled={model.singleProjectMode || view.projectSortOrder !== 'manual'}
|
||||||
projectLabel={projectLabel}
|
projectLabel={projectLabel}
|
||||||
projectDescription={projectDescription}
|
projectDescription={projectDescription}
|
||||||
projectIcon={project.icon}
|
projectIcon={project.icon}
|
||||||
@@ -298,8 +315,10 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
|||||||
alwaysShowActions={view.alwaysShowActions}
|
alwaysShowActions={view.alwaysShowActions}
|
||||||
statusIndicator={isCollapsed ? actions.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
statusIndicator={isCollapsed ? actions.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
||||||
openSidebarMenuKey={model.state.openSidebarMenuKey}
|
openSidebarMenuKey={model.state.openSidebarMenuKey}
|
||||||
setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey}
|
setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey}
|
||||||
onToggle={() => actions.toggleProject(projectKey)}
|
projectPickerOptions={model.singleProjectMode ? projectPickerOptions : undefined}
|
||||||
|
onProjectSelect={model.singleProjectMode ? actions.setSingleProjectId : undefined}
|
||||||
|
onToggle={() => { if (!model.singleProjectMode) actions.toggleProject(projectKey); }}
|
||||||
onNewSession={() => {
|
onNewSession={() => {
|
||||||
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
|
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
|
||||||
if (view.mobileVariant) actions.setSessionSwitcherOpen(false);
|
if (view.mobileVariant) actions.setSessionSwitcherOpen(false);
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ export type ProjectSection = {
|
|||||||
groups: SessionGroup[];
|
groups: SessionGroup[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const selectRenderedProjectSections = (
|
||||||
|
sections: ProjectSection[],
|
||||||
|
singleProjectMode: boolean,
|
||||||
|
singleProjectId: string | null,
|
||||||
|
): ProjectSection[] => singleProjectMode
|
||||||
|
? sections.filter((section) => section.project.id === singleProjectId)
|
||||||
|
: sections;
|
||||||
|
|
||||||
type GroupRenderDescriptor = {
|
type GroupRenderDescriptor = {
|
||||||
group: SessionGroup;
|
group: SessionGroup;
|
||||||
groupKey: string;
|
groupKey: string;
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type ProjectHeaderIdentityProps = ProjectIdentityProps & {
|
|||||||
alwaysShowActions?: boolean;
|
alwaysShowActions?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ProjectPickerOption = ProjectIdentityProps & { projectDescription: string };
|
||||||
|
|
||||||
export const ProjectHeaderIdentity: React.FC<ProjectHeaderIdentityProps> = ({
|
export const ProjectHeaderIdentity: React.FC<ProjectHeaderIdentityProps> = ({
|
||||||
id,
|
id,
|
||||||
projectLabel,
|
projectLabel,
|
||||||
@@ -121,6 +123,8 @@ export interface SortableProjectItemProps extends ProjectIdentityProps {
|
|||||||
statusIndicator?: React.ReactNode;
|
statusIndicator?: React.ReactNode;
|
||||||
openSidebarMenuKey: string | null;
|
openSidebarMenuKey: string | null;
|
||||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||||
|
projectPickerOptions?: ProjectPickerOption[];
|
||||||
|
onProjectSelect?: (projectId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||||
@@ -150,6 +154,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
statusIndicator = null,
|
statusIndicator = null,
|
||||||
openSidebarMenuKey,
|
openSidebarMenuKey,
|
||||||
setOpenSidebarMenuKey,
|
setOpenSidebarMenuKey,
|
||||||
|
projectPickerOptions,
|
||||||
|
onProjectSelect,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||||
@@ -166,6 +172,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
const menuInstanceKey = `project:${id}`;
|
const menuInstanceKey = `project:${id}`;
|
||||||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
||||||
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
|
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
|
||||||
|
const isProjectPicker = Boolean(projectPickerOptions && onProjectSelect);
|
||||||
|
|
||||||
const handleMenuOpenChange = React.useCallback((open: boolean) => {
|
const handleMenuOpenChange = React.useCallback((open: boolean) => {
|
||||||
if (open) setIsContextMenuOpen(false);
|
if (open) setIsContextMenuOpen(false);
|
||||||
@@ -273,7 +280,28 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
className="relative flex items-center gap-1 py-1 pl-4 pr-3.5"
|
className="relative flex items-center gap-1 py-1 pl-4 pr-3.5"
|
||||||
{...attributes}
|
{...attributes}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
{isProjectPicker ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
|
aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })}
|
||||||
|
>
|
||||||
|
<ProjectHeaderIdentity id={id} projectLabel={projectLabel} projectIcon={projectIcon} projectColor={projectColor} projectIconImage={projectIconImage} projectIconBackground={projectIconBackground} />
|
||||||
|
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="max-h-[70vh] min-w-[220px] overflow-y-auto">
|
||||||
|
{projectPickerOptions?.map((option) => (
|
||||||
|
<DropdownMenuItem key={option.id} onClick={() => onProjectSelect?.(option.id)} className="flex items-center justify-between gap-3" title={option.projectDescription}>
|
||||||
|
<ProjectHeaderIdentity {...option} />
|
||||||
|
{option.id === id ? <Icon name="check" className="h-4 w-4 flex-shrink-0 text-primary" /> : null}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : <Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -305,7 +333,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
<TooltipContent side="right" sideOffset={8}>
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
{projectDescription}
|
{projectDescription}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>}
|
||||||
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
|
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ export const useSessionGrouping = (args: Args) => {
|
|||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
},
|
},
|
||||||
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
|
[args.homeDirectory, args.worktreeMetadata, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { formatDirectoryName } from '@/lib/utils';
|
|||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
import { SidebarActivitySections } from './SidebarActivitySections';
|
import { SidebarActivitySections } from './SidebarActivitySections';
|
||||||
import { deriveRecentActivitySections, type RecentSessionLocation } from './activitySections';
|
import { deriveRecentActivitySections, type RecentSessionLocation } from './activitySections';
|
||||||
|
import type { ActivityItem } from './SidebarActivitySections';
|
||||||
import type { SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
import type { SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||||
import type { SessionNode } from '../types';
|
import type { SessionNode } from '../types';
|
||||||
import { formatProjectLabel, normalizePath } from '../utils';
|
import { formatProjectLabel, normalizePath } from '../utils';
|
||||||
@@ -29,6 +30,10 @@ type Props = {
|
|||||||
openSidebarMenuKey: string | null;
|
openSidebarMenuKey: string | null;
|
||||||
mobileVariant: boolean;
|
mobileVariant: boolean;
|
||||||
alwaysShowActions: boolean;
|
alwaysShowActions: boolean;
|
||||||
|
chatSessions: Session[];
|
||||||
|
renderChatsSection: (items: ActivityItem[]) => React.ReactNode;
|
||||||
|
onNewChat: () => void;
|
||||||
|
showRecentSection: boolean;
|
||||||
} & Pick<SessionTreeItemProps,
|
} & Pick<SessionTreeItemProps,
|
||||||
| 'setEditingId'
|
| 'setEditingId'
|
||||||
| 'setEditTitle'
|
| 'setEditTitle'
|
||||||
@@ -59,6 +64,8 @@ export const RecentSessionSection: React.FC<Props> = (props) => {
|
|||||||
childrenMap,
|
childrenMap,
|
||||||
pinnedSessionIds,
|
pinnedSessionIds,
|
||||||
recentSessions,
|
recentSessions,
|
||||||
|
chatSessions,
|
||||||
|
showRecentSection,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const sessionLocationById = React.useMemo(() => {
|
const sessionLocationById = React.useMemo(() => {
|
||||||
@@ -104,15 +111,28 @@ export const RecentSessionSection: React.FC<Props> = (props) => {
|
|||||||
}),
|
}),
|
||||||
[childrenMap],
|
[childrenMap],
|
||||||
);
|
);
|
||||||
const sections = React.useMemo(() => deriveRecentActivitySections({
|
const recentSections = React.useMemo(() => deriveRecentActivitySections({
|
||||||
sessions: recentSessions,
|
sessions: recentSessions,
|
||||||
getSessionLocation,
|
getSessionLocation,
|
||||||
getSessionNode,
|
getSessionNode,
|
||||||
query: hasSessionSearchQuery ? normalizedSessionSearchQuery : '',
|
query: hasSessionSearchQuery ? normalizedSessionSearchQuery : '',
|
||||||
}), [getSessionLocation, getSessionNode, hasSessionSearchQuery, normalizedSessionSearchQuery, recentSessions]);
|
}), [getSessionLocation, getSessionNode, hasSessionSearchQuery, normalizedSessionSearchQuery, recentSessions]);
|
||||||
|
const sections = React.useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'chats' as const,
|
||||||
|
title: t('sessions.sidebar.activity.chatsTitle'),
|
||||||
|
items: chatSessions.map((session) => ({
|
||||||
|
node: getSessionNode(session),
|
||||||
|
projectId: null,
|
||||||
|
groupDirectory: session.directory ?? null,
|
||||||
|
secondaryMeta: null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
...(showRecentSection ? recentSections.map((section) => ({ ...section, title: t('sessions.sidebar.activity.recentTitle') })) : []),
|
||||||
|
], [chatSessions, getSessionNode, recentSections, showRecentSection, t]);
|
||||||
return (
|
return (
|
||||||
<SidebarActivitySections
|
<SidebarActivitySections
|
||||||
sections={sections.map((section) => ({ ...section, title: t('sessions.sidebar.activity.recentTitle') }))}
|
sections={sections}
|
||||||
variant="section"
|
variant="section"
|
||||||
isDesktopShellRuntime={isDesktopShellRuntime}
|
isDesktopShellRuntime={isDesktopShellRuntime}
|
||||||
pinnedSessionIds={pinnedSessionIds}
|
pinnedSessionIds={pinnedSessionIds}
|
||||||
@@ -126,6 +146,8 @@ export const RecentSessionSection: React.FC<Props> = (props) => {
|
|||||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||||
mobileVariant={props.mobileVariant}
|
mobileVariant={props.mobileVariant}
|
||||||
alwaysShowActions={props.alwaysShowActions}
|
alwaysShowActions={props.alwaysShowActions}
|
||||||
|
onNewChat={props.onNewChat}
|
||||||
|
renderChatsSection={props.renderChatsSection}
|
||||||
setEditingId={props.setEditingId}
|
setEditingId={props.setEditingId}
|
||||||
setEditTitle={props.setEditTitle}
|
setEditTitle={props.setEditTitle}
|
||||||
toggleParent={props.toggleParent}
|
toggleParent={props.toggleParent}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import type { SessionNodeRenderExtras } from '../sessions/sessionNodeItemUtils';
|
import type { SessionNodeRenderExtras } from '../sessions/sessionNodeItemUtils';
|
||||||
import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||||
|
|
||||||
type ActivityItem = {
|
export type ActivityItem = {
|
||||||
node: SessionNode;
|
node: SessionNode;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
groupDirectory: string | null;
|
groupDirectory: string | null;
|
||||||
@@ -23,7 +23,7 @@ type ActivityItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ActivitySection = {
|
type ActivitySection = {
|
||||||
key: 'active-now';
|
key: 'active-now' | 'chats';
|
||||||
title: string;
|
title: string;
|
||||||
items: ActivityItem[];
|
items: ActivityItem[];
|
||||||
};
|
};
|
||||||
@@ -46,6 +46,8 @@ type Props = {
|
|||||||
openSidebarMenuKey: string | null;
|
openSidebarMenuKey: string | null;
|
||||||
mobileVariant: boolean;
|
mobileVariant: boolean;
|
||||||
alwaysShowActions: boolean;
|
alwaysShowActions: boolean;
|
||||||
|
onNewChat?: () => void;
|
||||||
|
renderChatsSection?: (items: ActivityItem[]) => React.ReactNode;
|
||||||
} & Pick<SessionTreeItemProps,
|
} & Pick<SessionTreeItemProps,
|
||||||
| 'setEditingId'
|
| 'setEditingId'
|
||||||
| 'setEditTitle'
|
| 'setEditTitle'
|
||||||
@@ -142,7 +144,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
|||||||
});
|
});
|
||||||
}, [props.editingId, props.openSidebarMenuKey]);
|
}, [props.editingId, props.openSidebarMenuKey]);
|
||||||
|
|
||||||
const visibleSections = sections.filter((section) => section.items.length > 0);
|
const visibleSections = sections.filter((section) => section.items.length > 0 || section.key === 'chats');
|
||||||
if (visibleSections.length === 0) {
|
if (visibleSections.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -159,7 +161,8 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
|||||||
);
|
);
|
||||||
const visibleItems = section.items.slice(0, visibleLimit);
|
const visibleItems = section.items.slice(0, visibleLimit);
|
||||||
const remainingCount = section.items.length - visibleItems.length;
|
const remainingCount = section.items.length - visibleItems.length;
|
||||||
const canShowFewer = !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
|
const usesCustomRenderer = section.key === 'chats' && Boolean(props.renderChatsSection);
|
||||||
|
const canShowFewer = !usesCustomRenderer && !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
|
||||||
const getRenderExtras = buildRenderExtras(visibleItems.map((item) => item.node));
|
const getRenderExtras = buildRenderExtras(visibleItems.map((item) => item.node));
|
||||||
const renderItem = (item: ActivityItem) => (
|
const renderItem = (item: ActivityItem) => (
|
||||||
<SessionTreeItem
|
<SessionTreeItem
|
||||||
@@ -218,28 +221,39 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
|||||||
return (
|
return (
|
||||||
<div key={section.key} className="relative space-y-1">
|
<div key={section.key} className="relative space-y-1">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
'relative group/chats',
|
||||||
'-ml-2.5 -mr-2',
|
'-ml-2.5 -mr-2',
|
||||||
stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar',
|
stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar',
|
||||||
)} data-sidebar-sticky-header={stickyZoneHeaders ? 'true' : undefined}>
|
)} data-sidebar-sticky-header={stickyZoneHeaders ? 'true' : undefined}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleSection(section.key)}
|
onClick={() => toggleSection(section.key)}
|
||||||
className="group flex w-full items-center gap-1.5 py-1 pl-4 pr-3.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
className={cn('group flex w-full items-center gap-1.5 py-1 pl-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50', section.key === 'chats' ? 'pr-10' : 'pr-3.5')}
|
||||||
aria-expanded={!isCollapsed}
|
aria-expanded={!isCollapsed}
|
||||||
>
|
>
|
||||||
<span className="inline-flex h-3.5 w-3.5 items-center justify-center">
|
<span className="inline-flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<Icon name="history" className={cn('h-3.5 w-3.5 text-muted-foreground/80', 'group-hover:hidden')} />
|
<Icon name={section.key === 'chats' ? 'chat-4' : 'history'} className={cn('h-3.5 w-3.5 text-muted-foreground/80', 'group-hover:hidden')} />
|
||||||
<span className="hidden h-3.5 w-3.5 items-center justify-center text-muted-foreground group-hover:inline-flex">
|
<span className="hidden h-3.5 w-3.5 items-center justify-center text-muted-foreground group-hover:inline-flex">
|
||||||
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[14px] font-semibold lowercase text-foreground">{section.title}</span>
|
<span className="text-[14px] font-semibold lowercase text-foreground">{section.title}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{section.key === 'chats' && props.onNewChat ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => { event.stopPropagation(); props.onNewChat?.(); }}
|
||||||
|
className={cn('absolute right-0.5 top-1/2 z-10 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50', props.alwaysShowActions ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/chats:opacity-100 group-hover/chats:pointer-events-auto group-focus-within/chats:opacity-100 group-focus-within/chats:pointer-events-auto')}
|
||||||
|
aria-label={t('sessions.sidebar.header.actions.newSession')}
|
||||||
|
>
|
||||||
|
<Icon name="add" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!isCollapsed ? (
|
{!isCollapsed ? (
|
||||||
<div className={cn('space-y-0.5')}>
|
<div className={cn('space-y-0.5')}>
|
||||||
{visibleItems.map(renderItem)}
|
{usesCustomRenderer ? props.renderChatsSection?.(section.items) : visibleItems.map(renderItem)}
|
||||||
{remainingCount > 0 ? (
|
{!usesCustomRenderer && remainingCount > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { useSync } from '@/sync/use-sync';
|
|||||||
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
||||||
import { DraggableSessionRow } from '../folders/sessionFolderDnd';
|
import { DraggableSessionRow } from '../folders/sessionFolderDnd';
|
||||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
|
||||||
import type { SessionNode } from '../types';
|
import type { SessionNode } from '../types';
|
||||||
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils';
|
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
@@ -276,8 +275,6 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
|||||||
alwaysShowActions,
|
alwaysShowActions,
|
||||||
secondaryMeta,
|
secondaryMeta,
|
||||||
renderContext = 'project',
|
renderContext = 'project',
|
||||||
subtreeContainsEditing,
|
|
||||||
menuOpenSessionId,
|
|
||||||
children,
|
children,
|
||||||
} = props;
|
} = props;
|
||||||
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
|
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ describe('SessionTreeItem public behavior', () => {
|
|||||||
const sharedSession = session('same-session');
|
const sharedSession = session('same-session');
|
||||||
const rowNode = { session: sharedSession, children: [], worktree: null };
|
const rowNode = { session: sharedSession, children: [], worktree: null };
|
||||||
const noop = () => undefined;
|
const noop = () => undefined;
|
||||||
const noopWithValue = (_value: string | null) => undefined;
|
|
||||||
|
|
||||||
const Harness = () => {
|
const Harness = () => {
|
||||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { SessionNodeItem } from './SessionNodeItem';
|
import { SessionNodeItem } from './SessionNodeItem';
|
||||||
import type { SessionNodeItemProps } from './SessionNodeItem';
|
import type { SessionNodeItemProps } from './SessionNodeItem';
|
||||||
import type { SessionNode } from '../types';
|
import type { SessionNode } from '../types';
|
||||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||||
import { useSessionActions, type DeleteSessionConfirmState } from './useSessionActions';
|
import { useSessionActions, type DeleteSessionConfirmState } from './useSessionActions';
|
||||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||||
import { useNotificationStore } from '@/sync/notification-store';
|
import { useNotificationStore } from '@/sync/notification-store';
|
||||||
import { useCollapsedSessionActivityState } from './collapsedActivityIndicator';
|
import { useCollapsedSessionActivityState } from './collapsedActivityState';
|
||||||
import type { SessionNode } from '../types';
|
import type { SessionNode } from '../types';
|
||||||
import { installHookTestDom } from '../test-utils/testDom';
|
import { installHookTestDom } from '../test-utils/testDom';
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { getSessionNodesActivityState } from './collapsedActivityIndicator';
|
import { getSessionNodesActivityState } from './collapsedActivityState';
|
||||||
import type { SessionNode } from '../types';
|
import type { SessionNode } from '../types';
|
||||||
|
|
||||||
// SAFETY: the fixture supplies the minimal SDK identity fields used by the activity projection.
|
// SAFETY: the fixture supplies the minimal SDK identity fields used by the activity projection.
|
||||||
|
|||||||
@@ -1,62 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
|
||||||
import { useNotificationStore } from '@/sync/notification-store';
|
|
||||||
import type { SessionNode } from '../types';
|
import type { SessionNode } from '../types';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { useCollapsedSessionActivityState, type CollapsedActivityState } from './collapsedActivityState';
|
||||||
export type CollapsedActivityState = 'active' | 'unread' | null;
|
|
||||||
|
|
||||||
const mergeCollapsedActivityStates = (
|
|
||||||
current: CollapsedActivityState,
|
|
||||||
next: CollapsedActivityState,
|
|
||||||
): CollapsedActivityState => {
|
|
||||||
if (current === 'active' || next === 'active') return 'active';
|
|
||||||
if (current === 'unread' || next === 'unread') return 'unread';
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSessionNodeActivityState = (
|
|
||||||
node: SessionNode,
|
|
||||||
activeSessionIds: Set<string>,
|
|
||||||
unreadSessionIds: Set<string>,
|
|
||||||
includeUnreadSubtasks: boolean,
|
|
||||||
): CollapsedActivityState => {
|
|
||||||
if (activeSessionIds.has(node.session.id)) return 'active';
|
|
||||||
|
|
||||||
let state: CollapsedActivityState = null;
|
|
||||||
// SAFETY: SessionNode sessions are SDK Session records; parentID is the optional hierarchy field.
|
|
||||||
const isSubtask = Boolean((node.session as Session & { parentID?: string | null }).parentID);
|
|
||||||
if (unreadSessionIds.has(node.session.id) && (includeUnreadSubtasks || !isSubtask)) state = 'unread';
|
|
||||||
|
|
||||||
for (const child of node.children) {
|
|
||||||
state = mergeCollapsedActivityStates(
|
|
||||||
state,
|
|
||||||
getSessionNodeActivityState(child, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
|
||||||
);
|
|
||||||
if (state === 'active') return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return state;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSessionNodesActivityState = (
|
|
||||||
nodes: SessionNode[],
|
|
||||||
activeSessionIds: Set<string>,
|
|
||||||
unreadSessionIds: Set<string>,
|
|
||||||
includeUnreadSubtasks: boolean,
|
|
||||||
): CollapsedActivityState => {
|
|
||||||
let state: CollapsedActivityState = null;
|
|
||||||
for (const node of nodes) {
|
|
||||||
state = mergeCollapsedActivityStates(
|
|
||||||
state,
|
|
||||||
getSessionNodeActivityState(node, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
|
||||||
);
|
|
||||||
if (state === 'active') return state;
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CollapsedActivityIndicator({
|
export function CollapsedActivityIndicator({
|
||||||
state,
|
state,
|
||||||
@@ -85,48 +31,7 @@ export function CollapsedActivityIndicator({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionActivityProps = {
|
export const CollapsedSessionActivityIndicator: React.FC<{ nodes: SessionNode[]; includeUnreadSubtasks: boolean }> = ({ nodes, includeUnreadSubtasks }) => {
|
||||||
nodes: SessionNode[];
|
|
||||||
includeUnreadSubtasks: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const collectActivityIds = (nodes: SessionNode[], includeUnreadSubtasks: boolean) => {
|
|
||||||
const active = new Set<string>();
|
|
||||||
const unread = new Set<string>();
|
|
||||||
const visit = (node: SessionNode, isSubtask: boolean): void => {
|
|
||||||
active.add(node.session.id);
|
|
||||||
if (!isSubtask || includeUnreadSubtasks) unread.add(node.session.id);
|
|
||||||
node.children.forEach((child) => visit(child, true));
|
|
||||||
};
|
|
||||||
nodes.forEach((node) => visit(node, false));
|
|
||||||
return { active, unread };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCollapsedSessionActivityState = ({
|
|
||||||
nodes,
|
|
||||||
includeUnreadSubtasks,
|
|
||||||
enabled = true,
|
|
||||||
}: SessionActivityProps & { enabled?: boolean }): CollapsedActivityState => {
|
|
||||||
const ids = React.useMemo(() => collectActivityIds(nodes, includeUnreadSubtasks), [includeUnreadSubtasks, nodes]);
|
|
||||||
const active = useGlobalSessionStatusStore(React.useCallback((state): CollapsedActivityState => {
|
|
||||||
if (!enabled) return null;
|
|
||||||
for (const sessionId of ids.active) {
|
|
||||||
const status = state.statusById.get(sessionId)?.status.type;
|
|
||||||
if (status === 'busy' || status === 'retry') return 'active';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [enabled, ids.active]));
|
|
||||||
const unread = useNotificationStore(React.useCallback((state): CollapsedActivityState => {
|
|
||||||
if (!enabled) return null;
|
|
||||||
for (const sessionId of ids.unread) {
|
|
||||||
if ((state.index.session.unseenCount[sessionId] ?? 0) > 0) return 'unread';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [enabled, ids.unread]));
|
|
||||||
return active ?? unread;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CollapsedSessionActivityIndicator: React.FC<SessionActivityProps> = ({ nodes, includeUnreadSubtasks }) => {
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const resolved = useCollapsedSessionActivityState({ nodes, includeUnreadSubtasks });
|
const resolved = useCollapsedSessionActivityState({ nodes, includeUnreadSubtasks });
|
||||||
if (!resolved) return null;
|
if (!resolved) return null;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import React from 'react';
|
||||||
|
import type { SessionNode } from '../types';
|
||||||
|
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||||
|
import { useNotificationStore } from '@/sync/notification-store';
|
||||||
|
|
||||||
|
export type CollapsedActivityState = 'active' | 'unread' | null;
|
||||||
|
|
||||||
|
const mergeCollapsedActivityStates = (
|
||||||
|
current: CollapsedActivityState,
|
||||||
|
next: CollapsedActivityState,
|
||||||
|
): CollapsedActivityState => {
|
||||||
|
if (current === 'active' || next === 'active') return 'active';
|
||||||
|
if (current === 'unread' || next === 'unread') return 'unread';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionNodeActivityState = (
|
||||||
|
node: SessionNode,
|
||||||
|
activeSessionIds: Set<string>,
|
||||||
|
unreadSessionIds: Set<string>,
|
||||||
|
includeUnreadSubtasks: boolean,
|
||||||
|
): CollapsedActivityState => {
|
||||||
|
if (activeSessionIds.has(node.session.id)) return 'active';
|
||||||
|
|
||||||
|
let state: CollapsedActivityState = null;
|
||||||
|
// SAFETY: SessionNode sessions are SDK Session records; parentID is the optional hierarchy field.
|
||||||
|
const isSubtask = Boolean((node.session as Session & { parentID?: string | null }).parentID);
|
||||||
|
if (unreadSessionIds.has(node.session.id) && (includeUnreadSubtasks || !isSubtask)) state = 'unread';
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
state = mergeCollapsedActivityStates(
|
||||||
|
state,
|
||||||
|
getSessionNodeActivityState(child, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
||||||
|
);
|
||||||
|
if (state === 'active') return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSessionNodesActivityState = (
|
||||||
|
nodes: SessionNode[],
|
||||||
|
activeSessionIds: Set<string>,
|
||||||
|
unreadSessionIds: Set<string>,
|
||||||
|
includeUnreadSubtasks: boolean,
|
||||||
|
): CollapsedActivityState => {
|
||||||
|
let state: CollapsedActivityState = null;
|
||||||
|
for (const node of nodes) {
|
||||||
|
state = mergeCollapsedActivityStates(
|
||||||
|
state,
|
||||||
|
getSessionNodeActivityState(node, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
||||||
|
);
|
||||||
|
if (state === 'active') return state;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionActivityProps = {
|
||||||
|
nodes: SessionNode[];
|
||||||
|
includeUnreadSubtasks: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectActivityIds = (nodes: SessionNode[], includeUnreadSubtasks: boolean) => {
|
||||||
|
const active = new Set<string>();
|
||||||
|
const unread = new Set<string>();
|
||||||
|
const visit = (node: SessionNode, isSubtask: boolean): void => {
|
||||||
|
active.add(node.session.id);
|
||||||
|
if (!isSubtask || includeUnreadSubtasks) unread.add(node.session.id);
|
||||||
|
node.children.forEach((child) => visit(child, true));
|
||||||
|
};
|
||||||
|
nodes.forEach((node) => visit(node, false));
|
||||||
|
return { active, unread };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCollapsedSessionActivityState = ({
|
||||||
|
nodes,
|
||||||
|
includeUnreadSubtasks,
|
||||||
|
enabled = true,
|
||||||
|
}: SessionActivityProps & { enabled?: boolean }): CollapsedActivityState => {
|
||||||
|
const ids = React.useMemo(() => collectActivityIds(nodes, includeUnreadSubtasks), [includeUnreadSubtasks, nodes]);
|
||||||
|
const active = useGlobalSessionStatusStore(React.useCallback((state): CollapsedActivityState => {
|
||||||
|
if (!enabled) return null;
|
||||||
|
for (const sessionId of ids.active) {
|
||||||
|
const status = state.statusById.get(sessionId)?.status.type;
|
||||||
|
if (status === 'busy' || status === 'retry') return 'active';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [enabled, ids.active]));
|
||||||
|
const unread = useNotificationStore(React.useCallback((state): CollapsedActivityState => {
|
||||||
|
if (!enabled) return null;
|
||||||
|
for (const sessionId of ids.unread) {
|
||||||
|
if ((state.index.session.unseenCount[sessionId] ?? 0) > 0) return 'unread';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [enabled, ids.unread]));
|
||||||
|
return active ?? unread;
|
||||||
|
};
|
||||||
@@ -75,7 +75,6 @@ export const useSessionActions = (args: Args) => {
|
|||||||
setDeleteSessionConfirm,
|
setDeleteSessionConfirm,
|
||||||
setEditingId,
|
setEditingId,
|
||||||
setEditTitle,
|
setEditTitle,
|
||||||
copiedSessionId,
|
|
||||||
setCopiedSessionId,
|
setCopiedSessionId,
|
||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
@@ -155,7 +154,7 @@ export const useSessionActions = (args: Args) => {
|
|||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setCopiedSessionId]);
|
||||||
|
|
||||||
const handleShareSession = React.useCallback(async (session: Session) => {
|
const handleShareSession = React.useCallback(async (session: Session) => {
|
||||||
const result = await shareSession(session.id);
|
const result = await shareSession(session.id);
|
||||||
@@ -308,7 +307,7 @@ export const useSessionActions = (args: Args) => {
|
|||||||
handleDeleteSession,
|
handleDeleteSession,
|
||||||
handleRestoreSession,
|
handleRestoreSession,
|
||||||
confirmDeleteSession,
|
confirmDeleteSession,
|
||||||
}), [copiedSessionId, handleCancelEdit, handleCopySessionId, handleCopyShareUrl, handleDeleteSession,
|
}), [handleCancelEdit, handleCopySessionId, handleCopyShareUrl, handleDeleteSession,
|
||||||
handleRestoreSession, handleSaveEdit, handleSessionDoubleClick, handleSessionSelect, handleShareSession,
|
handleRestoreSession, handleSaveEdit, handleSessionDoubleClick, handleSessionSelect, handleShareSession,
|
||||||
handleUnshareSession, confirmDeleteSession]);
|
handleUnshareSession, confirmDeleteSession]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
|
||||||
|
|
||||||
import { mergeSidebarSessionSources } from './sidebarSessionSources';
|
|
||||||
|
|
||||||
const session = (id: string, title: string): Session => ({
|
|
||||||
id,
|
|
||||||
slug: id,
|
|
||||||
title,
|
|
||||||
directory: `/home/.config/openchamber/chats/2026-08-21/${id}`,
|
|
||||||
projectID: 'managed-chats',
|
|
||||||
version: '1',
|
|
||||||
time: { created: 1, updated: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sidebar session source merge', () => {
|
|
||||||
test('shows one row when the same cached global chat also exists live', () => {
|
|
||||||
const live = session('session-a', 'Live title');
|
|
||||||
const cached = session('session-a', 'Cached title');
|
|
||||||
|
|
||||||
expect(mergeSidebarSessionSources([cached], [live])).toEqual([cached]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prefers global authority over live fallback', () => {
|
|
||||||
const global = session('session-a', 'Global title');
|
|
||||||
expect(mergeSidebarSessionSources([global], [session('session-a', 'Live title')])).toEqual([global]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
|
||||||
|
|
||||||
export function mergeSidebarSessionSources(
|
|
||||||
globalSessions: readonly Session[],
|
|
||||||
liveSessions: readonly Session[],
|
|
||||||
): Session[] {
|
|
||||||
const merged = [...globalSessions];
|
|
||||||
const seenIds = new Set(merged.map((session) => session.id));
|
|
||||||
const appendMissing = (sessions: readonly Session[]) => {
|
|
||||||
sessions.forEach((session) => {
|
|
||||||
if (seenIds.has(session.id)) return;
|
|
||||||
seenIds.add(session.id);
|
|
||||||
merged.push(session);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
appendMissing(liveSessions);
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
@@ -548,6 +548,99 @@ describe('updateDesktopSettings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not broadcast a stale project selection over a newer pending update', async () => {
|
||||||
|
const firstSave = deferred<SettingsPayload>();
|
||||||
|
const savedChanges: Array<Partial<SettingsPayload>> = [];
|
||||||
|
registerSettingsSave(async (changes) => {
|
||||||
|
savedChanges.push(changes);
|
||||||
|
if (savedChanges.length === 1) return firstSave.promise;
|
||||||
|
return changes as SettingsPayload;
|
||||||
|
});
|
||||||
|
const syncedSettings: SettingsPayload[] = [];
|
||||||
|
const handleSettingsSynced = (event: Event) => {
|
||||||
|
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||||
|
};
|
||||||
|
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstUpdate = updateDesktopSettings({ activeProjectId: 'project-a' });
|
||||||
|
await delay(250);
|
||||||
|
const secondUpdate = updateDesktopSettings({ activeProjectId: 'project-b' });
|
||||||
|
|
||||||
|
firstSave.resolve({ activeProjectId: 'project-a' });
|
||||||
|
await firstUpdate;
|
||||||
|
|
||||||
|
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
|
||||||
|
|
||||||
|
await secondUpdate;
|
||||||
|
} finally {
|
||||||
|
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not broadcast a stale loaded project selection over a newer pending update', async () => {
|
||||||
|
const loadedSettings = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
|
||||||
|
registerSettingsApi(async (changes) => changes as SettingsPayload, () => loadedSettings.promise);
|
||||||
|
invalidateSettingsCache();
|
||||||
|
const syncedSettings: SettingsPayload[] = [];
|
||||||
|
const handleSettingsSynced = (event: Event) => {
|
||||||
|
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||||
|
};
|
||||||
|
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sync = syncDesktopSettings();
|
||||||
|
const update = updateDesktopSettings({ activeProjectId: 'project-b' });
|
||||||
|
|
||||||
|
loadedSettings.resolve({
|
||||||
|
settings: {
|
||||||
|
activeProjectId: 'project-a',
|
||||||
|
draftStartersCraftGoalAdded: true,
|
||||||
|
draftStartersScheduleTaskAdded: true,
|
||||||
|
},
|
||||||
|
source: 'web',
|
||||||
|
});
|
||||||
|
await sync;
|
||||||
|
|
||||||
|
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
|
||||||
|
|
||||||
|
await update;
|
||||||
|
} finally {
|
||||||
|
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not broadcast a stale load after a newer project update has saved', async () => {
|
||||||
|
const loadedSettings = deferred<{ settings: SettingsPayload; source: 'web' | 'vscode' }>();
|
||||||
|
registerSettingsApi(async (changes) => changes as SettingsPayload, () => loadedSettings.promise);
|
||||||
|
invalidateSettingsCache();
|
||||||
|
const syncedSettings: SettingsPayload[] = [];
|
||||||
|
const handleSettingsSynced = (event: Event) => {
|
||||||
|
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||||
|
};
|
||||||
|
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sync = syncDesktopSettings();
|
||||||
|
const update = updateDesktopSettings({ activeProjectId: 'project-b' });
|
||||||
|
await update;
|
||||||
|
|
||||||
|
loadedSettings.resolve({
|
||||||
|
settings: {
|
||||||
|
activeProjectId: 'project-a',
|
||||||
|
draftStartersCraftGoalAdded: true,
|
||||||
|
draftStartersScheduleTaskAdded: true,
|
||||||
|
},
|
||||||
|
source: 'web',
|
||||||
|
});
|
||||||
|
await sync;
|
||||||
|
|
||||||
|
expect(syncedSettings.at(-1)?.activeProjectId).toBe('project-b');
|
||||||
|
} finally {
|
||||||
|
getWindow().removeEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('applies model selector settings from server settings', async () => {
|
test('applies model selector settings from server settings', async () => {
|
||||||
getWindow();
|
getWindow();
|
||||||
const settings = {
|
const settings = {
|
||||||
|
|||||||
@@ -1669,6 +1669,8 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type SettingsRuntimeContext = { runtimeKey: string; generation: number };
|
type SettingsRuntimeContext = { runtimeKey: string; generation: number };
|
||||||
|
type SettingsMutation = { revision: number; changes: Partial<DesktopSettings> };
|
||||||
|
type SettingsOperation = SettingsRuntimeContext & { id: number; revision: number };
|
||||||
|
|
||||||
// Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup
|
// Short-lived cache + in-flight dedup for settings fetches to avoid repeated GET calls during startup
|
||||||
let _settingsRuntimeGeneration = 0;
|
let _settingsRuntimeGeneration = 0;
|
||||||
@@ -1679,9 +1681,53 @@ let _pendingSettingsContext: SettingsRuntimeContext | null = null;
|
|||||||
let _settingsFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
let _settingsFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let _settingsFlushWaiters: Array<() => void> = [];
|
let _settingsFlushWaiters: Array<() => void> = [];
|
||||||
let _settingsLifecycleInitialized = false;
|
let _settingsLifecycleInitialized = false;
|
||||||
|
let _settingsMutationRevision = 0;
|
||||||
|
let _settingsOperationId = 0;
|
||||||
|
let _pendingSettingsRevision = 0;
|
||||||
|
let _settingsMutations: SettingsMutation[] = [];
|
||||||
|
const _settingsOperations = new Map<number, SettingsOperation>();
|
||||||
const SETTINGS_CACHE_TTL = 2_000; // 2 seconds — covers the startup burst
|
const SETTINGS_CACHE_TTL = 2_000; // 2 seconds — covers the startup burst
|
||||||
const SETTINGS_DEBOUNCE_MS = 200;
|
const SETTINGS_DEBOUNCE_MS = 200;
|
||||||
|
|
||||||
|
const recordSettingsMutation = (changes: Partial<DesktopSettings>): number => {
|
||||||
|
_settingsMutationRevision += 1;
|
||||||
|
_settingsMutations.push({ revision: _settingsMutationRevision, changes });
|
||||||
|
return _settingsMutationRevision;
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginSettingsOperation = (
|
||||||
|
revision = _settingsMutationRevision,
|
||||||
|
context = captureSettingsRuntimeContext(),
|
||||||
|
): SettingsOperation => {
|
||||||
|
_settingsOperationId += 1;
|
||||||
|
const operation = { id: _settingsOperationId, revision, ...context };
|
||||||
|
_settingsOperations.set(operation.id, operation);
|
||||||
|
return operation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconcileSettingsOperation = (
|
||||||
|
settings: DesktopSettings,
|
||||||
|
operation: SettingsOperation,
|
||||||
|
): DesktopSettings => {
|
||||||
|
let reconciled = settings;
|
||||||
|
for (const mutation of _settingsMutations) {
|
||||||
|
if (mutation.revision <= operation.revision) continue;
|
||||||
|
reconciled = { ...reconciled, ...mutation.changes };
|
||||||
|
}
|
||||||
|
return reconciled;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishSettingsOperation = (operation: SettingsOperation): void => {
|
||||||
|
if (!isSettingsRuntimeContextCurrent(operation)) return;
|
||||||
|
_settingsOperations.delete(operation.id);
|
||||||
|
if (_settingsOperations.size === 0) {
|
||||||
|
_settingsMutations = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldestOperationRevision = Math.min(...[..._settingsOperations.values()].map(({ revision }) => revision));
|
||||||
|
_settingsMutations = _settingsMutations.filter((mutation) => mutation.revision > oldestOperationRevision);
|
||||||
|
};
|
||||||
|
|
||||||
const captureSettingsRuntimeContext = (): SettingsRuntimeContext => ({
|
const captureSettingsRuntimeContext = (): SettingsRuntimeContext => ({
|
||||||
runtimeKey: getRuntimeKey(),
|
runtimeKey: getRuntimeKey(),
|
||||||
generation: _settingsRuntimeGeneration,
|
generation: _settingsRuntimeGeneration,
|
||||||
@@ -1707,6 +1753,9 @@ const ensureSettingsRuntimeLifecycle = (): void => {
|
|||||||
subscribeRuntimeEndpointChanged((detail) => {
|
subscribeRuntimeEndpointChanged((detail) => {
|
||||||
if (detail.runtimeKey === detail.previousRuntimeKey) return;
|
if (detail.runtimeKey === detail.previousRuntimeKey) return;
|
||||||
_settingsRuntimeGeneration += 1;
|
_settingsRuntimeGeneration += 1;
|
||||||
|
_settingsMutations = [];
|
||||||
|
_settingsOperations.clear();
|
||||||
|
_pendingSettingsRevision = 0;
|
||||||
_settingsCache = null;
|
_settingsCache = null;
|
||||||
_settingsInflight = null;
|
_settingsInflight = null;
|
||||||
});
|
});
|
||||||
@@ -1780,6 +1829,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
ensureSettingsRuntimeLifecycle();
|
ensureSettingsRuntimeLifecycle();
|
||||||
const context = captureSettingsRuntimeContext();
|
const context = captureSettingsRuntimeContext();
|
||||||
|
const operation = beginSettingsOperation(_settingsMutationRevision, context);
|
||||||
|
|
||||||
const persistApis = [getPersistApi(), useSessionDisplayStore.persist];
|
const persistApis = [getPersistApi(), useSessionDisplayStore.persist];
|
||||||
|
|
||||||
@@ -1816,8 +1866,12 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
|||||||
// Each step is wrapped in try/catch so a failure in one side-effect (e.g.
|
// Each step is wrapped in try/catch so a failure in one side-effect (e.g.
|
||||||
// a TypeError from writing to a contextBridge-protected global) doesn't
|
// a TypeError from writing to a contextBridge-protected global) doesn't
|
||||||
// prevent server settings from reaching the Zustand store.
|
// prevent server settings from reaching the Zustand store.
|
||||||
const applySettings = async (settings: DesktopSettings) => {
|
const applySettings = async (loadedSettings: DesktopSettings) => {
|
||||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||||
|
let settings = reconcileSettingsOperation(loadedSettings, operation);
|
||||||
|
await waitForHydration();
|
||||||
|
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||||
|
settings = reconcileSettingsOperation(loadedSettings, operation);
|
||||||
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true
|
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true
|
||||||
|| settings.draftStartersScheduleTaskAdded !== true;
|
|| settings.draftStartersScheduleTaskAdded !== true;
|
||||||
// `autoSaveEnabled` is new to the settings backend. Until the server has a
|
// `autoSaveEnabled` is new to the settings backend. Until the server has a
|
||||||
@@ -1836,8 +1890,6 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('persistToLocalStorage failed:', error);
|
console.warn('persistToLocalStorage failed:', error);
|
||||||
}
|
}
|
||||||
await waitForHydration();
|
|
||||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
|
||||||
if (shouldSeedAutoSaveEnabled) {
|
if (shouldSeedAutoSaveEnabled) {
|
||||||
authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled;
|
authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled;
|
||||||
}
|
}
|
||||||
@@ -1900,6 +1952,8 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to synchronise settings:', error);
|
console.warn('Failed to synchronise settings:', error);
|
||||||
|
} finally {
|
||||||
|
finishSettingsOperation(operation);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1907,9 +1961,11 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
|||||||
async function _flushSettingsUpdate(): Promise<void> {
|
async function _flushSettingsUpdate(): Promise<void> {
|
||||||
const changes = _pendingSettingsChanges;
|
const changes = _pendingSettingsChanges;
|
||||||
const context = _pendingSettingsContext;
|
const context = _pendingSettingsContext;
|
||||||
|
const revision = _pendingSettingsRevision;
|
||||||
const waiters = _settingsFlushWaiters;
|
const waiters = _settingsFlushWaiters;
|
||||||
_pendingSettingsChanges = null;
|
_pendingSettingsChanges = null;
|
||||||
_pendingSettingsContext = null;
|
_pendingSettingsContext = null;
|
||||||
|
_pendingSettingsRevision = 0;
|
||||||
_settingsFlushTimer = null;
|
_settingsFlushTimer = null;
|
||||||
_settingsFlushWaiters = [];
|
_settingsFlushWaiters = [];
|
||||||
try {
|
try {
|
||||||
@@ -1918,59 +1974,66 @@ async function _flushSettingsUpdate(): Promise<void> {
|
|||||||
dispatchSettingsSaveState('saved');
|
dispatchSettingsSaveState('saved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const operation = beginSettingsOperation(revision, context);
|
||||||
|
|
||||||
const runtimeSettings = getRuntimeSettingsAPI();
|
try {
|
||||||
if (runtimeSettings) {
|
const runtimeSettings = getRuntimeSettingsAPI();
|
||||||
|
if (runtimeSettings) {
|
||||||
|
try {
|
||||||
|
const updated = await runtimeSettings.save(changes);
|
||||||
|
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||||
|
if (updated) {
|
||||||
|
const reconciled = reconcileSettingsOperation(updated, operation);
|
||||||
|
applyDesktopUiPreferences(reconciled);
|
||||||
|
dispatchSettingsSynced(reconciled);
|
||||||
|
_settingsCache = null;
|
||||||
|
}
|
||||||
|
dispatchSettingsSaveState(updated ? 'saved' : 'error');
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||||
|
console.warn('Failed to update settings via runtime settings API:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||||
try {
|
try {
|
||||||
const updated = await runtimeSettings.save(changes);
|
const response = await runtimeFetch('/api/config/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(changes),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
|
||||||
|
dispatchSettingsSaveState('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = sanitizeWebSettings(await response.json().catch(() => null));
|
||||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||||
if (updated) {
|
if (updated) {
|
||||||
applyDesktopUiPreferences(updated);
|
const reconciled = reconcileSettingsOperation(updated, operation);
|
||||||
dispatchSettingsSynced(updated);
|
applyDesktopUiPreferences(reconciled);
|
||||||
|
dispatchSettingsSynced(reconciled);
|
||||||
|
dispatchSettingsSaveState('saved');
|
||||||
|
// Invalidate GET cache so next read sees the fresh data
|
||||||
_settingsCache = null;
|
_settingsCache = null;
|
||||||
|
} else {
|
||||||
|
dispatchSettingsSaveState('error');
|
||||||
}
|
}
|
||||||
dispatchSettingsSaveState(updated ? 'saved' : 'error');
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
if (isSettingsRuntimeContextCurrent(context)) {
|
||||||
console.warn('Failed to update settings via runtime settings API:', error);
|
console.warn('Failed to update shared settings via API:', error);
|
||||||
}
|
dispatchSettingsSaveState('error');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
|
||||||
try {
|
|
||||||
const response = await runtimeFetch('/api/config/settings', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(changes),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
|
||||||
if (!response.ok) {
|
|
||||||
console.warn('Failed to update shared settings via API:', response.status, response.statusText);
|
|
||||||
dispatchSettingsSaveState('error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = sanitizeWebSettings(await response.json().catch(() => null));
|
|
||||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
|
||||||
if (updated) {
|
|
||||||
applyDesktopUiPreferences(updated);
|
|
||||||
dispatchSettingsSynced(updated);
|
|
||||||
dispatchSettingsSaveState('saved');
|
|
||||||
// Invalidate GET cache so next read sees the fresh data
|
|
||||||
_settingsCache = null;
|
|
||||||
} else {
|
|
||||||
dispatchSettingsSaveState('error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (isSettingsRuntimeContextCurrent(context)) {
|
|
||||||
console.warn('Failed to update shared settings via API:', error);
|
|
||||||
dispatchSettingsSaveState('error');
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
finishSettingsOperation(operation);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
waiters.forEach((resolve) => resolve());
|
waiters.forEach((resolve) => resolve());
|
||||||
@@ -1991,6 +2054,7 @@ export const updateDesktopSettings = async (changes: Partial<DesktopSettings>):
|
|||||||
|
|
||||||
_pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes };
|
_pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes };
|
||||||
_pendingSettingsContext = context;
|
_pendingSettingsContext = context;
|
||||||
|
_pendingSettingsRevision = recordSettingsMutation(changes);
|
||||||
dispatchSettingsSaveState('saving');
|
dispatchSettingsSaveState('saving');
|
||||||
|
|
||||||
if (_settingsFlushTimer) {
|
if (_settingsFlushTimer) {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ The composer compares normalized attachment MIME types with the selected model's
|
|||||||
|
|
||||||
### Layout-mounted session-list lifecycle
|
### Layout-mounted session-list lifecycle
|
||||||
|
|
||||||
`MainLayout` and `VSCodeLayout` each call `useSessionListSync({ isVSCode })` directly and unconditionally, outside Sidebar visibility, responsive, editor, settings, and compact-view branches. The hook selects the real topology inputs, publishes complete directory bootstrap demand through `ChildStoreManager`, refreshes the global list once per layout mount, refreshes topology additions (including all VS Code directories on its first mount), coalesces OpenChamber control events for 500ms, and supplies a memoized complete global active+archived input to authoritative cleanup. MainLayout includes available worktrees; VS Code intentionally excludes them. Sidebar-local `session-created` worktree discovery is separate and full-app-only.
|
`MainLayout` and `VSCodeLayout` each call `useSessionListSync({ isVSCode })` directly and unconditionally, outside Sidebar visibility, responsive, editor, settings, and compact-view branches. The hook selects the real topology inputs, publishes complete directory bootstrap demand through `ChildStoreManager`, refreshes topology additions (including all VS Code directories on its first mount), coalesces OpenChamber control events for 500ms, and supplies a memoized complete global active+archived input to authoritative cleanup. The root-level global poller owns the initial global refresh. MainLayout includes available worktrees; VS Code intentionally excludes them. Sidebar-local `session-created` worktree discovery is separate and full-app-only.
|
||||||
|
|
||||||
### Directory bootstrap scheduling
|
### Directory bootstrap scheduling
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ describe("upsertSessionRecord", () => {
|
|||||||
expect(result[1]).toBe(current[1])
|
expect(result[1]).toBe(current[1])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("replaces a same-ID record when an unlisted semantic field changes", () => {
|
||||||
|
const current = [session("a")]
|
||||||
|
// SAFETY: The runtime SDK payload may contain an additive field before the local Session type is updated.
|
||||||
|
const incoming = {
|
||||||
|
...session("a"),
|
||||||
|
// SDK records can gain fields independently of this synchronization boundary.
|
||||||
|
customField: "changed",
|
||||||
|
} as Session
|
||||||
|
|
||||||
|
expect(upsertSessionRecord(current, incoming)).not.toBe(current)
|
||||||
|
})
|
||||||
|
|
||||||
const changes: Array<[string, Partial<Session>, Partial<Session>]> = [
|
const changes: Array<[string, Partial<Session>, Partial<Session>]> = [
|
||||||
["scalars", { workspaceID: "one", path: "a", parentID: "p", cost: 1, agent: "a" }, { workspaceID: "two", path: "b", parentID: "q", cost: 2, agent: "b" }],
|
["scalars", { workspaceID: "one", path: "a", parentID: "p", cost: 1, agent: "a" }, { workspaceID: "two", path: "b", parentID: "q", cost: 2, agent: "b" }],
|
||||||
["tokens", { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } } }, { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 6 } } }],
|
["tokens", { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } } }, { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 6 } } }],
|
||||||
|
|||||||
@@ -1,83 +1,8 @@
|
|||||||
import type { PermissionRuleset, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
import { Binary } from "./binary"
|
import { Binary } from "./binary"
|
||||||
|
|
||||||
function areMetadataEqual(left: Session["metadata"], right: Session["metadata"]): boolean {
|
|
||||||
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalEqual<T>(
|
|
||||||
left: T | undefined,
|
|
||||||
right: T | undefined,
|
|
||||||
equal: (left: T, right: T) => boolean,
|
|
||||||
): boolean {
|
|
||||||
return left === right || (left !== undefined && right !== undefined && equal(left, right))
|
|
||||||
}
|
|
||||||
|
|
||||||
const diffsEqual = (left: SnapshotFileDiff[], right: SnapshotFileDiff[]) => (
|
|
||||||
left.length === right.length
|
|
||||||
&& left.every((item, index) => {
|
|
||||||
const candidate = right[index]
|
|
||||||
return item.file === candidate.file
|
|
||||||
&& item.patch === candidate.patch
|
|
||||||
&& item.additions === candidate.additions
|
|
||||||
&& item.deletions === candidate.deletions
|
|
||||||
&& item.status === candidate.status
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const permissionsEqual = (left: PermissionRuleset, right: PermissionRuleset) => (
|
|
||||||
left.length === right.length
|
|
||||||
&& left.every((item, index) => {
|
|
||||||
const candidate = right[index]
|
|
||||||
return item.permission === candidate.permission
|
|
||||||
&& item.pattern === candidate.pattern
|
|
||||||
&& item.action === candidate.action
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
function areSessionsEqual(left: Session, right: Session): boolean {
|
function areSessionsEqual(left: Session, right: Session): boolean {
|
||||||
return left.id === right.id
|
return JSON.stringify(left) === JSON.stringify(right)
|
||||||
&& left.slug === right.slug
|
|
||||||
&& left.projectID === right.projectID
|
|
||||||
&& left.workspaceID === right.workspaceID
|
|
||||||
&& left.directory === right.directory
|
|
||||||
&& left.path === right.path
|
|
||||||
&& left.parentID === right.parentID
|
|
||||||
&& left.cost === right.cost
|
|
||||||
&& left.title === right.title
|
|
||||||
&& left.agent === right.agent
|
|
||||||
&& left.version === right.version
|
|
||||||
&& areMetadataEqual(left.metadata, right.metadata)
|
|
||||||
&& optionalEqual(left.summary, right.summary, (a, b) => (
|
|
||||||
a.additions === b.additions
|
|
||||||
&& a.deletions === b.deletions
|
|
||||||
&& a.files === b.files
|
|
||||||
&& optionalEqual(a.diffs, b.diffs, diffsEqual)
|
|
||||||
))
|
|
||||||
&& optionalEqual(left.tokens, right.tokens, (a, b) => (
|
|
||||||
a.input === b.input
|
|
||||||
&& a.output === b.output
|
|
||||||
&& a.reasoning === b.reasoning
|
|
||||||
&& a.cache.read === b.cache.read
|
|
||||||
&& a.cache.write === b.cache.write
|
|
||||||
))
|
|
||||||
&& optionalEqual(left.share, right.share, (a, b) => a.url === b.url)
|
|
||||||
&& optionalEqual(left.model, right.model, (a, b) => (
|
|
||||||
a.id === b.id
|
|
||||||
&& a.providerID === b.providerID
|
|
||||||
&& a.variant === b.variant
|
|
||||||
))
|
|
||||||
&& left.time.created === right.time.created
|
|
||||||
&& left.time.updated === right.time.updated
|
|
||||||
&& left.time.compacting === right.time.compacting
|
|
||||||
&& left.time.archived === right.time.archived
|
|
||||||
&& optionalEqual(left.permission, right.permission, permissionsEqual)
|
|
||||||
&& optionalEqual(left.revert, right.revert, (a, b) => (
|
|
||||||
a.messageID === b.messageID
|
|
||||||
&& a.partID === b.partID
|
|
||||||
&& a.snapshot === b.snapshot
|
|
||||||
&& a.diff === b.diff
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertSessionRecord(current: Session[], incoming: Session): Session[] {
|
export function upsertSessionRecord(current: Session[], incoming: Session): Session[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user