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/useRuntimeAPIs', () => ({ useRuntimeAPIs: () => ({ editor: undefined, runtime: { isVSCode: 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/runtimeSurface', () => ({ isMobileSurfaceRuntime: () => false }));
|
||||
mock.module('@/lib/outsideFileGrants', () => ({ ensureOutsideFileGrantForDesktop: async () => undefined }));
|
||||
|
||||
@@ -48,6 +48,7 @@ let hookStates: Array<{ current: null } | undefined> = [];
|
||||
let activeFakeDocument: FakeDocument | null = null;
|
||||
|
||||
const makeFakeElement = (ownerDocument: { createElement: () => FakeElement }): FakeElement => {
|
||||
void ownerDocument;
|
||||
let html = '';
|
||||
const element: FakeElement = {
|
||||
childNodes: [],
|
||||
@@ -184,6 +185,7 @@ const fakeReact = {
|
||||
return factory();
|
||||
},
|
||||
useRef: <T>(current: T) => {
|
||||
void current;
|
||||
const index = hookCursor;
|
||||
hookCursor += 1;
|
||||
if (!hookStates[index]) hookStates[index] = { current: null };
|
||||
|
||||
@@ -1080,8 +1080,9 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
scope: `${runtimeKey}\0${settledPart.sessionID}`,
|
||||
id: `${settledPart.messageID}\0${settledPart.id}\0${imageMode}`,
|
||||
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.
|
||||
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 });
|
||||
|
||||
const keyFor = ({ scope, id, locale }: DetachedMarkdownDom) => ({ scope, id, locale });
|
||||
const keyFor = ({ scope, id, locale, directory }: DetachedMarkdownDom) => ({ scope, id, locale, directory });
|
||||
|
||||
const createEntry = (
|
||||
document: Document,
|
||||
@@ -21,6 +21,7 @@ const createEntry = (
|
||||
scope: `runtime:${sessionId}`,
|
||||
id: `${messageId}:${partId}`,
|
||||
locale: 'en',
|
||||
directory: '/repo-a',
|
||||
fragment,
|
||||
};
|
||||
};
|
||||
@@ -51,11 +52,13 @@ describe('DetachedMarkdownDomCache', () => {
|
||||
scope: 'runtime:session-a',
|
||||
id: 'message-2:part',
|
||||
locale: 'en',
|
||||
directory: '/repo-a',
|
||||
})).toBeNull();
|
||||
expect(cache.take({
|
||||
scope: 'runtime:session-c',
|
||||
id: 'message-5:part',
|
||||
locale: 'en',
|
||||
directory: '/repo-a',
|
||||
})).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -75,6 +78,15 @@ describe('DetachedMarkdownDomCache', () => {
|
||||
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', () => {
|
||||
const cache = new DetachedMarkdownDomCache({ maxSessions: 2, maxEntriesPerSession: 2 });
|
||||
const sessionA = createEntry(document, 'session-a', 'message-a', 'part');
|
||||
|
||||
@@ -2,6 +2,7 @@ export type DetachedMarkdownDomKey = {
|
||||
scope: string;
|
||||
id: string;
|
||||
locale: string;
|
||||
directory: string;
|
||||
};
|
||||
|
||||
export type DetachedMarkdownDom = DetachedMarkdownDomKey & {
|
||||
@@ -79,7 +80,7 @@ export class DetachedMarkdownDomCache {
|
||||
// A fragment is a move-only resource; taking it removes cache ownership.
|
||||
session.delete(entryKey);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
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> {
|
||||
folder: SessionFolder;
|
||||
@@ -56,9 +57,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
onToggle,
|
||||
onRename,
|
||||
onDelete,
|
||||
children,
|
||||
groupDirectory,
|
||||
projectId,
|
||||
children,
|
||||
mobileVariant = false,
|
||||
alwaysShowActions = mobileVariant,
|
||||
isRenaming = false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { usePrefetchSessionMessages } from '@/sync/use-sync';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
@@ -24,9 +25,18 @@ import type { useSessionProjectViewState } from '../projects/useSessionProjectVi
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import type { DeleteSessionConfirmState } from '../sessions/useSessionActions';
|
||||
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 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 = {
|
||||
id: string;
|
||||
path: string;
|
||||
@@ -122,8 +132,13 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
});
|
||||
}, []);
|
||||
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({
|
||||
enabled: showRecentSection,
|
||||
enabled: showRecentSection && !singleProjectMode,
|
||||
isVSCode: topology.isVSCode,
|
||||
pinnedSessionIds: collection.pinnedSessionIds,
|
||||
sessionOrderRanks: collection.sessionOrderRanks,
|
||||
@@ -269,6 +284,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
editingId,
|
||||
editTitle,
|
||||
copiedSessionId,
|
||||
sessionBatchSize: singleProjectMode && !view.useGroupedSections ? 20 : undefined,
|
||||
setEditingId,
|
||||
setEditTitle,
|
||||
toggleParent,
|
||||
@@ -309,6 +325,8 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
view.hasSessionSearchQuery,
|
||||
view.mobileVariant,
|
||||
view.normalizedSessionSearchQuery,
|
||||
view.useGroupedSections,
|
||||
singleProjectMode,
|
||||
]);
|
||||
const groupActions = React.useMemo(() => ({
|
||||
showMoreGroupSessions,
|
||||
@@ -327,8 +345,55 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
scrollerActions.setActiveProjectIdOnly,
|
||||
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(() => (
|
||||
!topology.isVSCode && showRecentSection ? <RecentSessionSection
|
||||
!topology.isVSCode ? <RecentSessionSection
|
||||
projects={topology.projects}
|
||||
availableWorktreesByProject={topology.availableWorktreesByProject}
|
||||
gitBranches={topology.gitBranches}
|
||||
@@ -362,6 +427,10 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
setDeleteSessionConfirm={setDeleteSessionConfirm}
|
||||
startFolderRename={startFolderRename}
|
||||
setCopiedSessionId={setCopiedSessionId}
|
||||
chatSessions={collection.chatSessions}
|
||||
renderChatsSection={renderChatsSection}
|
||||
onNewChat={handleOpenNewChat}
|
||||
showRecentSection={showRecentSection && !singleProjectMode}
|
||||
/> : null
|
||||
), [
|
||||
alwaysShowActions,
|
||||
@@ -378,12 +447,16 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
recentSessions,
|
||||
rowActions,
|
||||
showRecentSection,
|
||||
singleProjectMode,
|
||||
handleOpenNewChat,
|
||||
renderChatsSection,
|
||||
startFolderRename,
|
||||
toggleParent,
|
||||
topology.availableWorktreesByProject,
|
||||
topology.gitBranches,
|
||||
topology.isVSCode,
|
||||
topology.projects,
|
||||
collection.chatSessions,
|
||||
view.hasSessionSearchQuery,
|
||||
view.homeDirectory,
|
||||
view.isDesktopShellRuntime,
|
||||
@@ -396,6 +469,14 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
sectionsForRender: orderedSectionsForRender,
|
||||
projectSections,
|
||||
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,
|
||||
searchEmptyState: view.searchEmptyState,
|
||||
projectRepoStatus: topology.projectRepoStatus,
|
||||
@@ -416,6 +497,8 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
view.searchEmptyState,
|
||||
visibleSessionCountByGroup,
|
||||
recentSection,
|
||||
singleProjectId,
|
||||
singleProjectMode,
|
||||
]);
|
||||
const scrollerView = React.useMemo(() => ({
|
||||
homeDirectory: view.homeDirectory,
|
||||
@@ -456,6 +539,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
reorderProjects: scrollerActions.reorderProjects,
|
||||
setGroupOrderByProject,
|
||||
renderProjectStatusIndicator: scrollerActions.renderProjectStatusIndicator,
|
||||
setSingleProjectId,
|
||||
}), [
|
||||
groupActions,
|
||||
scrollerActions.openNewSessionDraft,
|
||||
@@ -470,6 +554,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
setGroupOrderByProject,
|
||||
toggleProject,
|
||||
scrollerActions.renderProjectStatusIndicator,
|
||||
setSingleProjectId,
|
||||
]);
|
||||
return <>
|
||||
<ProjectSessionSelectionEffect
|
||||
|
||||
@@ -5,7 +5,14 @@ import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { deriveRecentSessions } from '../recent/activitySections';
|
||||
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 descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
@@ -157,6 +164,80 @@ describe('projectSidebarCollection', () => {
|
||||
expect(recentBefore).toEqual([]);
|
||||
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', () => {
|
||||
|
||||
@@ -13,6 +13,8 @@ import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { deriveRecentSessions } from '../recent/activitySections';
|
||||
import { normalizePath } from '../utils';
|
||||
import { isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||
import { isBtwSession } from '@/lib/sessionBtwMetadata';
|
||||
|
||||
type ProjectSidebarActiveSessionsArgs = {
|
||||
globalActiveSessions: Session[];
|
||||
@@ -21,6 +23,31 @@ type ProjectSidebarActiveSessionsArgs = {
|
||||
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 isKnownActiveSessionDirectory = (
|
||||
@@ -51,13 +78,28 @@ export const projectSidebarActiveSessions = ({
|
||||
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[] => {
|
||||
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
|
||||
// resolver instead of retaining the collection's mutable indexing detail.
|
||||
export const getDescendantIds = (
|
||||
@@ -78,6 +120,49 @@ export const getDescendantIds = (
|
||||
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 = {
|
||||
knownDirectories: Set<string>;
|
||||
isVSCode: boolean;
|
||||
@@ -101,16 +186,15 @@ export const useSessionProjectCollection = ({
|
||||
(state) => isVisible ? state.rankById : EMPTY_SESSION_ORDER_RANKS,
|
||||
[isVisible],
|
||||
));
|
||||
const sessions = React.useMemo(() => projectSidebarCollection({
|
||||
const projection = React.useMemo(() => buildSidebarSessionProjection({
|
||||
globalActiveSessions,
|
||||
liveSessions,
|
||||
knownDirectories,
|
||||
isVSCode,
|
||||
}), [globalActiveSessions, isVSCode, knownDirectories, liveSessions]);
|
||||
const orderedSessions = React.useMemo(
|
||||
() => orderSessionsByLifecycleScopes(sessions, pinnedSessionIds, sessionOrderRanks),
|
||||
[pinnedSessionIds, sessionOrderRanks, sessions],
|
||||
);
|
||||
pinnedSessionIds,
|
||||
sessionOrderRanks,
|
||||
}), [globalActiveSessions, isVSCode, knownDirectories, liveSessions, pinnedSessionIds, sessionOrderRanks]);
|
||||
const { chatSessions, orderedSessions, projectSessions: sessions } = projection;
|
||||
const sessionById = React.useMemo(() => new Map(
|
||||
[...orderedSessions, ...archivedSessions].map((session) => [session.id, session]),
|
||||
), [archivedSessions, orderedSessions]);
|
||||
@@ -129,13 +213,14 @@ export const useSessionProjectCollection = ({
|
||||
}, [sessionById]);
|
||||
const getDescendantIdsForAction = React.useCallback(
|
||||
(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],
|
||||
);
|
||||
|
||||
return {
|
||||
archivedSessions,
|
||||
childrenMap,
|
||||
chatSessions,
|
||||
getDescendantIds: getDescendantIdsForAction,
|
||||
globalActiveSessions,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
|
||||
@@ -74,7 +74,6 @@ mock.module('./useAuthoritativeSessionCleanup', () => ({
|
||||
const { useSessionListSync } = await import('./useSessionListSync');
|
||||
|
||||
const projects = [{ id: 'project', path: '/project' }];
|
||||
const projectDirectories = new Set(['/project']);
|
||||
const worktree: WorktreeMetadata = { path: '/worktree', projectDirectory: '/project', branch: 'feature', label: 'feature' };
|
||||
|
||||
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 { Session } from '@opencode-ai/sdk/v2';
|
||||
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';
|
||||
|
||||
type FolderCallbacks = {
|
||||
@@ -27,25 +24,6 @@ type RowPropsCapture = Pick<SessionGroupSectionProps,
|
||||
| '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 rowPropsCapture: RowPropsCapture | null = null;
|
||||
|
||||
@@ -80,6 +58,9 @@ mock.module('@/sync/sync-context', () => ({
|
||||
|
||||
mock.module('../sessions/collapsedActivityIndicator', () => ({
|
||||
CollapsedSessionActivityIndicator: () => null,
|
||||
}));
|
||||
|
||||
mock.module('../sessions/collapsedActivityState', () => ({
|
||||
useCollapsedSessionActivityState: () => null,
|
||||
}));
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
selectFolderIdsForProjection,
|
||||
selectFolderRootNodes,
|
||||
} from '../sessions/sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from '../sessions/sessionNodeItemUtils';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
|
||||
type FolderScope = { scopeKey: string; directory: string | null };
|
||||
@@ -40,7 +39,8 @@ import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrSt
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useChildStoreManager } from '@/sync/sync-context';
|
||||
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 { FolderDeleteConfirmDialog } from '../shell/ConfirmDialogs';
|
||||
|
||||
@@ -445,18 +445,6 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
|
||||
return allFoldersForGroupBase.filter(({ folder }) => visibleFolderIds.has(folder.id));
|
||||
}, [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 effectiveOpenMenuKey = openSidebarMenuKey;
|
||||
const effectiveExpandedParents = expandedParents;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildGroupRenderDescriptors } from './sessionProjectRender';
|
||||
import { buildGroupRenderDescriptors, selectRenderedProjectSections } from './sessionProjectRender';
|
||||
import type { SessionGroup } from '../types';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
|
||||
const makeGroup = (id: string, overrides: Partial<SessionGroup> = {}): SessionGroup => ({
|
||||
id,
|
||||
@@ -68,3 +69,23 @@ describe('buildGroupRenderDescriptors', () => {
|
||||
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 { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||||
import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
|
||||
import { buildGroupRenderDescriptors, type ProjectSection } from './sessionProjectRender';
|
||||
import { buildGroupRenderDescriptors, selectRenderedProjectSections, type ProjectSection } from './sessionProjectRender';
|
||||
import { formatProjectLabel } from '../utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
|
||||
@@ -78,6 +78,8 @@ type SessionProjectScrollerModel = {
|
||||
sectionsForRender: ProjectSection[];
|
||||
projectSections: ProjectSection[];
|
||||
activeProjectId: string | null;
|
||||
singleProjectMode: boolean;
|
||||
singleProjectId: string | null;
|
||||
emptyState: React.ReactNode;
|
||||
searchEmptyState: React.ReactNode;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
@@ -114,6 +116,7 @@ type SessionProjectScrollerActions = {
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||
setSingleProjectId: (id: string) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -139,7 +142,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
const { t } = useI18n();
|
||||
const { model, view, actions } = props;
|
||||
const isInlineEditing = model.state.editingId !== null;
|
||||
const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders;
|
||||
const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders && !model.singleProjectMode;
|
||||
const projectSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
@@ -180,7 +183,12 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
event.preventDefault();
|
||||
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(() => {
|
||||
if (enableStickyFade && hasProjectScroller && 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
|
||||
// lead the list, the Recent fallback below owns the top instead of a project.
|
||||
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 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) {
|
||||
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 ? (
|
||||
<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) {
|
||||
return view.hasSessionSearchQuery ? model.searchEmptyState : model.emptyState;
|
||||
}
|
||||
@@ -270,20 +287,20 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
actions.reorderProjects(oldIndex, newIndex);
|
||||
}}
|
||||
>
|
||||
<SortableContext items={model.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{model.sectionsForRender.map((section) => {
|
||||
<SortableContext items={renderedSections.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{renderedSections.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = getProjectLabel(project, 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);
|
||||
|
||||
return (
|
||||
<SortableProjectItem
|
||||
key={projectKey}
|
||||
id={projectKey}
|
||||
disabled={view.projectSortOrder !== 'manual'}
|
||||
disabled={model.singleProjectMode || view.projectSortOrder !== 'manual'}
|
||||
projectLabel={projectLabel}
|
||||
projectDescription={projectDescription}
|
||||
projectIcon={project.icon}
|
||||
@@ -298,8 +315,10 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||
alwaysShowActions={view.alwaysShowActions}
|
||||
statusIndicator={isCollapsed ? actions.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
||||
openSidebarMenuKey={model.state.openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey}
|
||||
onToggle={() => actions.toggleProject(projectKey)}
|
||||
setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey}
|
||||
projectPickerOptions={model.singleProjectMode ? projectPickerOptions : undefined}
|
||||
onProjectSelect={model.singleProjectMode ? actions.setSingleProjectId : undefined}
|
||||
onToggle={() => { if (!model.singleProjectMode) actions.toggleProject(projectKey); }}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
|
||||
if (view.mobileVariant) actions.setSessionSwitcherOpen(false);
|
||||
|
||||
@@ -13,6 +13,14 @@ export type ProjectSection = {
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
export const selectRenderedProjectSections = (
|
||||
sections: ProjectSection[],
|
||||
singleProjectMode: boolean,
|
||||
singleProjectId: string | null,
|
||||
): ProjectSection[] => singleProjectMode
|
||||
? sections.filter((section) => section.project.id === singleProjectId)
|
||||
: sections;
|
||||
|
||||
type GroupRenderDescriptor = {
|
||||
group: SessionGroup;
|
||||
groupKey: string;
|
||||
|
||||
@@ -35,6 +35,8 @@ type ProjectHeaderIdentityProps = ProjectIdentityProps & {
|
||||
alwaysShowActions?: boolean;
|
||||
};
|
||||
|
||||
type ProjectPickerOption = ProjectIdentityProps & { projectDescription: string };
|
||||
|
||||
export const ProjectHeaderIdentity: React.FC<ProjectHeaderIdentityProps> = ({
|
||||
id,
|
||||
projectLabel,
|
||||
@@ -121,6 +123,8 @@ export interface SortableProjectItemProps extends ProjectIdentityProps {
|
||||
statusIndicator?: React.ReactNode;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
projectPickerOptions?: ProjectPickerOption[];
|
||||
onProjectSelect?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
@@ -150,6 +154,8 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
statusIndicator = null,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
projectPickerOptions,
|
||||
onProjectSelect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
@@ -166,6 +172,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
const menuInstanceKey = `project:${id}`;
|
||||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
|
||||
const isProjectPicker = Boolean(projectPickerOptions && onProjectSelect);
|
||||
|
||||
const handleMenuOpenChange = React.useCallback((open: boolean) => {
|
||||
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"
|
||||
{...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>
|
||||
<button
|
||||
type="button"
|
||||
@@ -305,7 +333,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{projectDescription}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Tooltip>}
|
||||
|
||||
<div className={cn(
|
||||
'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;
|
||||
},
|
||||
[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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { formatDirectoryName } from '@/lib/utils';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { SidebarActivitySections } from './SidebarActivitySections';
|
||||
import { deriveRecentActivitySections, type RecentSessionLocation } from './activitySections';
|
||||
import type { ActivityItem } from './SidebarActivitySections';
|
||||
import type { SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||
import type { SessionNode } from '../types';
|
||||
import { formatProjectLabel, normalizePath } from '../utils';
|
||||
@@ -29,6 +30,10 @@ type Props = {
|
||||
openSidebarMenuKey: string | null;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
chatSessions: Session[];
|
||||
renderChatsSection: (items: ActivityItem[]) => React.ReactNode;
|
||||
onNewChat: () => void;
|
||||
showRecentSection: boolean;
|
||||
} & Pick<SessionTreeItemProps,
|
||||
| 'setEditingId'
|
||||
| 'setEditTitle'
|
||||
@@ -59,6 +64,8 @@ export const RecentSessionSection: React.FC<Props> = (props) => {
|
||||
childrenMap,
|
||||
pinnedSessionIds,
|
||||
recentSessions,
|
||||
chatSessions,
|
||||
showRecentSection,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const sessionLocationById = React.useMemo(() => {
|
||||
@@ -104,15 +111,28 @@ export const RecentSessionSection: React.FC<Props> = (props) => {
|
||||
}),
|
||||
[childrenMap],
|
||||
);
|
||||
const sections = React.useMemo(() => deriveRecentActivitySections({
|
||||
const recentSections = React.useMemo(() => deriveRecentActivitySections({
|
||||
sessions: recentSessions,
|
||||
getSessionLocation,
|
||||
getSessionNode,
|
||||
query: hasSessionSearchQuery ? normalizedSessionSearchQuery : '',
|
||||
}), [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 (
|
||||
<SidebarActivitySections
|
||||
sections={sections.map((section) => ({ ...section, title: t('sessions.sidebar.activity.recentTitle') }))}
|
||||
sections={sections}
|
||||
variant="section"
|
||||
isDesktopShellRuntime={isDesktopShellRuntime}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
@@ -126,6 +146,8 @@ export const RecentSessionSection: React.FC<Props> = (props) => {
|
||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||
mobileVariant={props.mobileVariant}
|
||||
alwaysShowActions={props.alwaysShowActions}
|
||||
onNewChat={props.onNewChat}
|
||||
renderChatsSection={props.renderChatsSection}
|
||||
setEditingId={props.setEditingId}
|
||||
setEditTitle={props.setEditTitle}
|
||||
toggleParent={props.toggleParent}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import type { SessionNodeRenderExtras } from '../sessions/sessionNodeItemUtils';
|
||||
import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||
|
||||
type ActivityItem = {
|
||||
export type ActivityItem = {
|
||||
node: SessionNode;
|
||||
projectId: string | null;
|
||||
groupDirectory: string | null;
|
||||
@@ -23,7 +23,7 @@ type ActivityItem = {
|
||||
};
|
||||
|
||||
type ActivitySection = {
|
||||
key: 'active-now';
|
||||
key: 'active-now' | 'chats';
|
||||
title: string;
|
||||
items: ActivityItem[];
|
||||
};
|
||||
@@ -46,6 +46,8 @@ type Props = {
|
||||
openSidebarMenuKey: string | null;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
onNewChat?: () => void;
|
||||
renderChatsSection?: (items: ActivityItem[]) => React.ReactNode;
|
||||
} & Pick<SessionTreeItemProps,
|
||||
| 'setEditingId'
|
||||
| 'setEditTitle'
|
||||
@@ -142,7 +144,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
});
|
||||
}, [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) {
|
||||
return null;
|
||||
}
|
||||
@@ -159,7 +161,8 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
);
|
||||
const visibleItems = section.items.slice(0, visibleLimit);
|
||||
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 renderItem = (item: ActivityItem) => (
|
||||
<SessionTreeItem
|
||||
@@ -218,28 +221,39 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
return (
|
||||
<div key={section.key} className="relative space-y-1">
|
||||
<div className={cn(
|
||||
'relative group/chats',
|
||||
'-ml-2.5 -mr-2',
|
||||
stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar',
|
||||
)} data-sidebar-sticky-header={stickyZoneHeaders ? 'true' : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
>
|
||||
<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">
|
||||
{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 className="text-[14px] font-semibold lowercase text-foreground">{section.title}</span>
|
||||
</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>
|
||||
{!isCollapsed ? (
|
||||
<div className={cn('space-y-0.5')}>
|
||||
{visibleItems.map(renderItem)}
|
||||
{remainingCount > 0 ? (
|
||||
{usesCustomRenderer ? props.renderChatsSection?.(section.items) : visibleItems.map(renderItem)}
|
||||
{!usesCustomRenderer && remainingCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
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 { DraggableSessionRow } from '../folders/sessionFolderDnd';
|
||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from '../types';
|
||||
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -276,8 +275,6 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
||||
alwaysShowActions,
|
||||
secondaryMeta,
|
||||
renderContext = 'project',
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
children,
|
||||
} = props;
|
||||
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
|
||||
|
||||
@@ -56,7 +56,6 @@ describe('SessionTreeItem public behavior', () => {
|
||||
const sharedSession = session('same-session');
|
||||
const rowNode = { session: sharedSession, children: [], worktree: null };
|
||||
const noop = () => undefined;
|
||||
const noopWithValue = (_value: string | null) => undefined;
|
||||
|
||||
const Harness = () => {
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { SessionNodeItem } from './SessionNodeItem';
|
||||
import type { SessionNodeItemProps } from './SessionNodeItem';
|
||||
import type { SessionNode } from '../types';
|
||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import { useSessionActions, type DeleteSessionConfirmState } from './useSessionActions';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
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 { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||
import { useNotificationStore } from '@/sync/notification-store';
|
||||
import { useCollapsedSessionActivityState } from './collapsedActivityIndicator';
|
||||
import { useCollapsedSessionActivityState } from './collapsedActivityState';
|
||||
import type { SessionNode } from '../types';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { getSessionNodesActivityState } from './collapsedActivityIndicator';
|
||||
import { getSessionNodesActivityState } from './collapsedActivityState';
|
||||
import type { SessionNode } from '../types';
|
||||
|
||||
// SAFETY: the fixture supplies the minimal SDK identity fields used by the activity projection.
|
||||
|
||||
@@ -1,62 +1,8 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||
import { useNotificationStore } from '@/sync/notification-store';
|
||||
import type { SessionNode } from '../types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
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;
|
||||
};
|
||||
import { useCollapsedSessionActivityState, type CollapsedActivityState } from './collapsedActivityState';
|
||||
|
||||
export function CollapsedActivityIndicator({
|
||||
state,
|
||||
@@ -85,48 +31,7 @@ export function CollapsedActivityIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const CollapsedSessionActivityIndicator: React.FC<SessionActivityProps> = ({ nodes, includeUnreadSubtasks }) => {
|
||||
export const CollapsedSessionActivityIndicator: React.FC<{ nodes: SessionNode[]; includeUnreadSubtasks: boolean }> = ({ nodes, includeUnreadSubtasks }) => {
|
||||
const { t } = useI18n();
|
||||
const resolved = useCollapsedSessionActivityState({ nodes, includeUnreadSubtasks });
|
||||
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,
|
||||
setEditingId,
|
||||
setEditTitle,
|
||||
copiedSessionId,
|
||||
setCopiedSessionId,
|
||||
} = args;
|
||||
|
||||
@@ -155,7 +154,7 @@ export const useSessionActions = (args: Args) => {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
}, [setCopiedSessionId]);
|
||||
|
||||
const handleShareSession = React.useCallback(async (session: Session) => {
|
||||
const result = await shareSession(session.id);
|
||||
@@ -308,7 +307,7 @@ export const useSessionActions = (args: Args) => {
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
confirmDeleteSession,
|
||||
}), [copiedSessionId, handleCancelEdit, handleCopySessionId, handleCopyShareUrl, handleDeleteSession,
|
||||
}), [handleCancelEdit, handleCopySessionId, handleCopyShareUrl, handleDeleteSession,
|
||||
handleRestoreSession, handleSaveEdit, handleSessionDoubleClick, handleSessionSelect, handleShareSession,
|
||||
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 () => {
|
||||
getWindow();
|
||||
const settings = {
|
||||
|
||||
@@ -1669,6 +1669,8 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
};
|
||||
|
||||
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
|
||||
let _settingsRuntimeGeneration = 0;
|
||||
@@ -1679,9 +1681,53 @@ let _pendingSettingsContext: SettingsRuntimeContext | null = null;
|
||||
let _settingsFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _settingsFlushWaiters: Array<() => void> = [];
|
||||
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_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 => ({
|
||||
runtimeKey: getRuntimeKey(),
|
||||
generation: _settingsRuntimeGeneration,
|
||||
@@ -1707,6 +1753,9 @@ const ensureSettingsRuntimeLifecycle = (): void => {
|
||||
subscribeRuntimeEndpointChanged((detail) => {
|
||||
if (detail.runtimeKey === detail.previousRuntimeKey) return;
|
||||
_settingsRuntimeGeneration += 1;
|
||||
_settingsMutations = [];
|
||||
_settingsOperations.clear();
|
||||
_pendingSettingsRevision = 0;
|
||||
_settingsCache = null;
|
||||
_settingsInflight = null;
|
||||
});
|
||||
@@ -1780,6 +1829,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
}
|
||||
ensureSettingsRuntimeLifecycle();
|
||||
const context = captureSettingsRuntimeContext();
|
||||
const operation = beginSettingsOperation(_settingsMutationRevision, context);
|
||||
|
||||
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.
|
||||
// a TypeError from writing to a contextBridge-protected global) doesn't
|
||||
// prevent server settings from reaching the Zustand store.
|
||||
const applySettings = async (settings: DesktopSettings) => {
|
||||
const applySettings = async (loadedSettings: DesktopSettings) => {
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
let settings = reconcileSettingsOperation(loadedSettings, operation);
|
||||
await waitForHydration();
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
settings = reconcileSettingsOperation(loadedSettings, operation);
|
||||
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true
|
||||
|| settings.draftStartersScheduleTaskAdded !== true;
|
||||
// `autoSaveEnabled` is new to the settings backend. Until the server has a
|
||||
@@ -1836,8 +1890,6 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
} catch (error) {
|
||||
console.warn('persistToLocalStorage failed:', error);
|
||||
}
|
||||
await waitForHydration();
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
if (shouldSeedAutoSaveEnabled) {
|
||||
authoritativeSettings.autoSaveEnabled = useUIStore.getState().autoSaveEnabled;
|
||||
}
|
||||
@@ -1900,6 +1952,8 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
}
|
||||
} catch (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> {
|
||||
const changes = _pendingSettingsChanges;
|
||||
const context = _pendingSettingsContext;
|
||||
const revision = _pendingSettingsRevision;
|
||||
const waiters = _settingsFlushWaiters;
|
||||
_pendingSettingsChanges = null;
|
||||
_pendingSettingsContext = null;
|
||||
_pendingSettingsRevision = 0;
|
||||
_settingsFlushTimer = null;
|
||||
_settingsFlushWaiters = [];
|
||||
try {
|
||||
@@ -1918,59 +1974,66 @@ async function _flushSettingsUpdate(): Promise<void> {
|
||||
dispatchSettingsSaveState('saved');
|
||||
return;
|
||||
}
|
||||
const operation = beginSettingsOperation(revision, context);
|
||||
|
||||
const runtimeSettings = getRuntimeSettingsAPI();
|
||||
if (runtimeSettings) {
|
||||
try {
|
||||
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 {
|
||||
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 (updated) {
|
||||
applyDesktopUiPreferences(updated);
|
||||
dispatchSettingsSynced(updated);
|
||||
const reconciled = reconcileSettingsOperation(updated, operation);
|
||||
applyDesktopUiPreferences(reconciled);
|
||||
dispatchSettingsSynced(reconciled);
|
||||
dispatchSettingsSaveState('saved');
|
||||
// Invalidate GET cache so next read sees the fresh data
|
||||
_settingsCache = null;
|
||||
} else {
|
||||
dispatchSettingsSaveState('error');
|
||||
}
|
||||
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 {
|
||||
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');
|
||||
if (isSettingsRuntimeContextCurrent(context)) {
|
||||
console.warn('Failed to update shared settings via API:', error);
|
||||
dispatchSettingsSaveState('error');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
finishSettingsOperation(operation);
|
||||
}
|
||||
} finally {
|
||||
waiters.forEach((resolve) => resolve());
|
||||
@@ -1991,6 +2054,7 @@ export const updateDesktopSettings = async (changes: Partial<DesktopSettings>):
|
||||
|
||||
_pendingSettingsChanges = { ...(_pendingSettingsChanges ?? {}), ...changes };
|
||||
_pendingSettingsContext = context;
|
||||
_pendingSettingsRevision = recordSettingsMutation(changes);
|
||||
dispatchSettingsSaveState('saving');
|
||||
|
||||
if (_settingsFlushTimer) {
|
||||
|
||||
@@ -65,7 +65,7 @@ The composer compares normalized attachment MIME types with the selected model's
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -27,6 +27,18 @@ describe("upsertSessionRecord", () => {
|
||||
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>]> = [
|
||||
["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 } } }],
|
||||
|
||||
@@ -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"
|
||||
|
||||
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 {
|
||||
return left.id === right.id
|
||||
&& 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
|
||||
))
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
}
|
||||
|
||||
export function upsertSessionRecord(current: Session[], incoming: Session): Session[] {
|
||||
|
||||
Reference in New Issue
Block a user