fix(chats): align folders and session actions

Render managed Chats through the shared folder-aware session group and keep folder ownership at the Chats root, including new drafts and older per-directory scopes.

Hide worktree actions for Chats, make worktree shortcuts inert on Chat drafts, and let new-session shortcuts inherit an active session directory while explicit Chats controls still create managed Chat drafts.
This commit is contained in:
Bohdan Triapitsyn
2026-08-21 16:29:26 +03:00
parent b879cf323f
commit b1030d3cb7
12 changed files with 82 additions and 16 deletions
+1 -1
View File
@@ -2062,7 +2062,7 @@ export const Header: React.FC<HeaderProps> = ({
<DropdownMenuItem onClick={() => void shareCurrentSession()}><Icon name="share-2" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.share')}</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => void exportCurrentSession()}><Icon name="download" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.exportMarkdown')}</DropdownMenuItem>
{!isVSCode && currentSession && !currentSession.parentId ? (
{!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block">
@@ -1,5 +1,5 @@
import React from 'react';
import { isChatDirectoryForHome, isChatDirectoryPath } from '@/lib/chatDirectories';
import { getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath } from '@/lib/chatDirectories';
import { mergeSidebarSessionSources } from './sidebar/sidebarSessionSources';
import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui';
@@ -41,7 +41,7 @@ import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { SessionGroupSection } from './sidebar/SessionGroupSection';
import { SidebarHeader } from './sidebar/SidebarHeader';
import { SidebarNav } from './sidebar/SidebarNav';
import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
import { SidebarActivitySections, type ActivityItem } from './sidebar/SidebarActivitySections';
import { SidebarFooter } from './sidebar/SidebarFooter';
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
import { SessionNodeItem } from './sidebar/SessionNodeItem';
@@ -1730,6 +1730,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
normalizedSessionSearchQuery,
groupSearchDataByGroup,
visibleSessionCountByGroup,
isSingleProjectMode,
sessionGroupingMode,
collapsedGroups,
hideDirectoryControls,
collapsedFolderIds,
@@ -1773,6 +1775,36 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
openNewSessionDraft();
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
const renderChatsSection = React.useCallback((items: ActivityItem[]) => {
const chatsRoot = getChatsRootForHome(homeDirectory)
?? items.map((item) => getChatsRootFromDirectory(item.node.session.directory)).find(Boolean)
?? null;
if (!chatsRoot) return items.map((item) => renderSessionNode(item.node, 0, item.groupDirectory));
const folderDirectories = [
chatsRoot,
...items.map((item) => normalizePath(item.node.session.directory ?? null)).filter((directory): directory is string => Boolean(directory)),
];
const folderScopes = Array.from(new Set(folderDirectories)).map((directory) => ({
scopeKey: directory,
directory,
}));
const group: SessionGroup = {
id: 'managed-chats',
label: '',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: chatsRoot,
folderScopeKey: chatsRoot,
folderScopes,
draftTarget: 'chat',
sessions: items.map((item) => item.node),
};
return renderGroupSessions(group, 'managed-chats', null, true);
}, [homeDirectory, renderGroupSessions, renderSessionNode]);
const topContent = React.useMemo(
() => (!isVSCode && !hasSessionSearchQuery && hasActivitySectionItems) ? (
<SidebarActivitySections
@@ -1785,9 +1817,10 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
isDesktopShellRuntime={isDesktopShellRuntime}
onNewChat={handleOpenNewSessionDraftFromHeader}
alwaysShowActions={alwaysShowSidebarActions}
renderChatsSection={renderChatsSection}
/>
) : null,
[activitySections, alwaysShowSidebarActions, editingId, handleOpenNewSessionDraftFromHeader, hasActivitySectionItems, hasSessionSearchQuery, isDesktopShellRuntime, isVSCode, openSidebarMenuKey, recentExpandedParents, renderSessionNode],
[activitySections, alwaysShowSidebarActions, editingId, handleOpenNewSessionDraftFromHeader, hasActivitySectionItems, hasSessionSearchQuery, isDesktopShellRuntime, isVSCode, openSidebarMenuKey, recentExpandedParents, renderChatsSection, renderSessionNode],
);
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
@@ -14,6 +14,10 @@
- Scheduled tasks (`ScheduledTasksDialog`, now a full-page surface on web/desktop) and per-project worktree management (`WorktreesView`, opened from the project menu) render as overlays inside `<main>` in `MainLayout`; the sidebar no longer mounts them.
- Group-level PR-status polling/indicators and worktree-group drag-to-reorder were removed together with the worktree grouping level; `oc.sessions.groupOrder` is no longer read or written. Worktree PR/branch context lives in the Worktrees surface.
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
- Managed Chats never offer the worktree-move action in either the sidebar row menu or the active-session header menu because their directories are not project repositories.
- Managed Chats use the shared Chats root as their folder scope. Their activity section renders the normal folder tree, and sessions created from a Chats folder are assigned back to that root-scoped folder after their date/session directory materializes. Per-session folder scopes created by older builds remain visible for compatibility.
- The New session keyboard command inherits the active materialized session directory. Explicit sidebar entry points, including the top New session row and the Chats `+`, open a fresh managed Chat draft instead.
- The new-worktree keyboard command is a silent no-op while a managed Chat draft is open. It must not retarget that draft to the active project or show a Git/worktree error because Chats never participate in worktrees.
- Directory loading is demand-driven: the sidebar publishes one complete priority plan for all known project/worktree directories, while the sync layer owns bounded execution.
- When multiple configured projects are checkouts of the same Git repository, exactly one project owns the shared worktree topology: the configured canonical primary root when present, otherwise the first configured source for that repository. Any worktree path that is also a configured project is omitted from subordinate worktree groups, so every directory has one sidebar location while remaining part of bootstrap demand.
@@ -85,7 +85,7 @@ type Props = {
setActiveProjectIdOnly: (id: string) => void;
setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void;
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null; targetFolderId?: string }) => void;
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null; targetFolderId?: string; target?: 'chat' | 'project' }) => void;
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
renamingFolderId: string | null;
@@ -881,7 +881,12 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
setActiveMainTab('chat');
if (mobileVariant) setSessionSwitcherOpen(false);
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: scopeDirectory ?? group.directory, targetFolderId: folder.id });
openNewSessionDraft({
selectedProjectId: projectId,
directoryOverride: scopeDirectory ?? group.directory,
targetFolderId: folder.id,
target: group.draftTarget,
});
}}
hideActions={false}
archivedBucket={group.isArchivedBucket === true}
@@ -43,6 +43,7 @@ import { getSessionGoal } from '@/lib/sessionGoalMetadata';
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories';
import { parseMultiRunSessionTitle } from '@/lib/multirun/title';
import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog';
import { FusionIcon } from '@/components/icons/FusionIcon';
@@ -957,7 +958,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<Icon name="download" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.exportMarkdown')}
</Item>
{!isSubtaskSession && !archivedBucket && !isVSCode ? (
{!isSubtaskSession && !archivedBucket && !isVSCode && !isChatDirectoryPath(sessionDirectory) ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block">
@@ -1015,6 +1016,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
.forEach((worktree) => pushScope(worktree.path));
}
}
pushScope(getChatsRootFromDirectory(sessionDirectory));
pushScope(sessionDirectory);
const folderEntries = scopes.flatMap((scope) =>
getFoldersForScope(scope).map((folder) => ({ scope, folder })));
@@ -12,7 +12,7 @@ import {
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
type ActivityItem = {
export type ActivityItem = {
node: SessionNode;
projectId: string | null;
groupDirectory: string | null;
@@ -49,6 +49,7 @@ type Props = {
isDesktopShellRuntime: boolean;
onNewChat?: () => void;
alwaysShowActions?: boolean;
renderChatsSection?: (items: ActivityItem[]) => React.ReactNode;
};
type RenderExtras = SessionNodeRenderExtras;
@@ -151,7 +152,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) => renderSessionNode(
item.node,
@@ -240,8 +242,10 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
</div>
{!isCollapsed ? (
<div className={cn('space-y-0.5')}>
{visibleItems.map(renderItem)}
{remainingCount > 0 ? (
{section.key === 'chats' && props.renderChatsSection
? props.renderChatsSection(section.items)
: visibleItems.map(renderItem)}
{!usesCustomRenderer && remainingCount > 0 ? (
<button
type="button"
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
@@ -29,6 +29,7 @@ export type SessionGroup = {
* instead of reading the single folderScopeKey.
*/
folderScopes?: SessionGroupFolderScope[];
draftTarget?: 'chat' | 'project';
sessions: SessionNode[];
};
@@ -316,7 +316,9 @@ export const useKeyboardShortcuts = () => {
return;
}
openNewSessionDraft();
openNewSessionDraft(currentSessionId && currentDirectory
? { directoryOverride: currentDirectory }
: undefined);
return;
}
+7 -1
View File
@@ -193,7 +193,13 @@ export const useMenuActions = (
case 'new-session':
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
openNewSessionDraft();
{
const sessionState = useSessionUIStore.getState();
const directory = useDirectoryStore.getState().currentDirectory;
openNewSessionDraft(sessionState.currentSessionId && directory
? { directoryOverride: directory }
: undefined);
}
break;
case 'new-worktree-session':
@@ -34,7 +34,10 @@ export const useMiniChatKeyboardShortcuts = () => {
if (eventMatchesShortcut(event, combo('new_chat'))) {
event.preventDefault();
openNewSessionDraft();
const sessionState = useSessionUIStore.getState();
openNewSessionDraft(sessionState.currentSessionId && sessionState.currentSessionDirectory
? { directoryOverride: sessionState.currentSessionDirectory }
: undefined);
focusChatInput();
return;
}
@@ -176,6 +176,11 @@ const createInstantWorktreeDraft = async (options?: {
initialPrompt?: string;
title?: string;
}): Promise<string | null> => {
const currentDraft = useSessionUIStore.getState().newSessionDraft;
if (currentDraft.open && currentDraft.target === 'chat') {
return null;
}
if (isCreatingWorktreeSession) {
return null;
}
+3 -2
View File
@@ -30,7 +30,7 @@ import { useSkillsStore } from "@/stores/useSkillsStore"
import { getDeferredSafeStorage } from "@/stores/utils/safeStorage"
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
import { normalizePath } from "@/lib/pathNormalization"
import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, warmChatsRootDirectory } from "@/lib/chatDirectories"
import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, warmChatsRootDirectory } from "@/lib/chatDirectories"
import { isVSCodeRuntime } from "@/lib/desktop"
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
@@ -1677,7 +1677,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
get().closeNewSessionDraft()
if (targetFolderId) {
const scopeKey = dir || get().lastLoadedDirectory || session.directory
const scopeDirectory = dir || get().lastLoadedDirectory || session.directory
const scopeKey = getChatsRootFromDirectory(scopeDirectory) ?? scopeDirectory
if (scopeKey) {
useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id)
}