fix(ui): preserve upstream behavior after performance rebase

This commit is contained in:
c_w_xiaohei
2026-08-26 00:42:36 +08:00
parent 9b9d7069c7
commit 532bdab5e8
34 changed files with 752 additions and 361 deletions
@@ -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 }) => {
@@ -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';
@@ -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,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;
}
+93
View File
@@ -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 = {
+112 -48
View File
@@ -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) {
+1 -1
View File
@@ -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 } } }],
+2 -77
View File
@@ -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[] {