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 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> <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> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="block"> <span className="block">
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { isChatDirectoryForHome, isChatDirectoryPath } from '@/lib/chatDirectories'; import { getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath } from '@/lib/chatDirectories';
import { mergeSidebarSessionSources } from './sidebar/sidebarSessionSources'; import { mergeSidebarSessionSources } from './sidebar/sidebarSessionSources';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui'; import { toast } from '@/components/ui';
@@ -41,7 +41,7 @@ import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { SessionGroupSection } from './sidebar/SessionGroupSection'; import { SessionGroupSection } from './sidebar/SessionGroupSection';
import { SidebarHeader } from './sidebar/SidebarHeader'; import { SidebarHeader } from './sidebar/SidebarHeader';
import { SidebarNav } from './sidebar/SidebarNav'; import { SidebarNav } from './sidebar/SidebarNav';
import { SidebarActivitySections } from './sidebar/SidebarActivitySections'; import { SidebarActivitySections, type ActivityItem } from './sidebar/SidebarActivitySections';
import { SidebarFooter } from './sidebar/SidebarFooter'; import { SidebarFooter } from './sidebar/SidebarFooter';
import { SidebarProjectsList } from './sidebar/SidebarProjectsList'; import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
import { SessionNodeItem } from './sidebar/SessionNodeItem'; import { SessionNodeItem } from './sidebar/SessionNodeItem';
@@ -1730,6 +1730,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
normalizedSessionSearchQuery, normalizedSessionSearchQuery,
groupSearchDataByGroup, groupSearchDataByGroup,
visibleSessionCountByGroup, visibleSessionCountByGroup,
isSingleProjectMode,
sessionGroupingMode,
collapsedGroups, collapsedGroups,
hideDirectoryControls, hideDirectoryControls,
collapsedFolderIds, collapsedFolderIds,
@@ -1773,6 +1775,36 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
openNewSessionDraft(); openNewSessionDraft();
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]); }, [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( const topContent = React.useMemo(
() => (!isVSCode && !hasSessionSearchQuery && hasActivitySectionItems) ? ( () => (!isVSCode && !hasSessionSearchQuery && hasActivitySectionItems) ? (
<SidebarActivitySections <SidebarActivitySections
@@ -1785,9 +1817,10 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
isDesktopShellRuntime={isDesktopShellRuntime} isDesktopShellRuntime={isDesktopShellRuntime}
onNewChat={handleOpenNewSessionDraftFromHeader} onNewChat={handleOpenNewSessionDraftFromHeader}
alwaysShowActions={alwaysShowSidebarActions} alwaysShowActions={alwaysShowSidebarActions}
renderChatsSection={renderChatsSection}
/> />
) : null, ) : 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); 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. - 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. - 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. - 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. - 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. - 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; setActiveProjectIdOnly: (id: string) => void;
setActiveMainTab: (tab: MainTab) => void; setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => 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; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
renamingFolderId: string | null; renamingFolderId: string | null;
@@ -881,7 +881,12 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId); if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
setActiveMainTab('chat'); setActiveMainTab('chat');
if (mobileVariant) setSessionSwitcherOpen(false); 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} hideActions={false}
archivedBucket={group.isArchivedBucket === true} archivedBucket={group.isArchivedBucket === true}
@@ -43,6 +43,7 @@ import { getSessionGoal } from '@/lib/sessionGoalMetadata';
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation'; import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth'; import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories';
import { parseMultiRunSessionTitle } from '@/lib/multirun/title'; import { parseMultiRunSessionTitle } from '@/lib/multirun/title';
import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog'; import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog';
import { FusionIcon } from '@/components/icons/FusionIcon'; 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" /> <Icon name="download" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.exportMarkdown')} {t('sessions.sidebar.session.menu.exportMarkdown')}
</Item> </Item>
{!isSubtaskSession && !archivedBucket && !isVSCode ? ( {!isSubtaskSession && !archivedBucket && !isVSCode && !isChatDirectoryPath(sessionDirectory) ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="block"> <span className="block">
@@ -1015,6 +1016,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
.forEach((worktree) => pushScope(worktree.path)); .forEach((worktree) => pushScope(worktree.path));
} }
} }
pushScope(getChatsRootFromDirectory(sessionDirectory));
pushScope(sessionDirectory); pushScope(sessionDirectory);
const folderEntries = scopes.flatMap((scope) => const folderEntries = scopes.flatMap((scope) =>
getFoldersForScope(scope).map((folder) => ({ scope, folder }))); getFoldersForScope(scope).map((folder) => ({ scope, folder })));
@@ -12,7 +12,7 @@ import {
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils'; import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
type ActivityItem = { export type ActivityItem = {
node: SessionNode; node: SessionNode;
projectId: string | null; projectId: string | null;
groupDirectory: string | null; groupDirectory: string | null;
@@ -49,6 +49,7 @@ type Props = {
isDesktopShellRuntime: boolean; isDesktopShellRuntime: boolean;
onNewChat?: () => void; onNewChat?: () => void;
alwaysShowActions?: boolean; alwaysShowActions?: boolean;
renderChatsSection?: (items: ActivityItem[]) => React.ReactNode;
}; };
type RenderExtras = SessionNodeRenderExtras; type RenderExtras = SessionNodeRenderExtras;
@@ -151,7 +152,8 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
); );
const visibleItems = section.items.slice(0, visibleLimit); const visibleItems = section.items.slice(0, visibleLimit);
const remainingCount = section.items.length - visibleItems.length; const remainingCount = section.items.length - visibleItems.length;
const canShowFewer = !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0; const usesCustomRenderer = section.key === 'chats' && Boolean(props.renderChatsSection);
const canShowFewer = !usesCustomRenderer && !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
const getRenderExtras = buildRenderExtras(visibleItems.map((item) => item.node)); const getRenderExtras = buildRenderExtras(visibleItems.map((item) => item.node));
const renderItem = (item: ActivityItem) => renderSessionNode( const renderItem = (item: ActivityItem) => renderSessionNode(
item.node, item.node,
@@ -240,8 +242,10 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
</div> </div>
{!isCollapsed ? ( {!isCollapsed ? (
<div className={cn('space-y-0.5')}> <div className={cn('space-y-0.5')}>
{visibleItems.map(renderItem)} {section.key === 'chats' && props.renderChatsSection
{remainingCount > 0 ? ( ? props.renderChatsSection(section.items)
: visibleItems.map(renderItem)}
{!usesCustomRenderer && remainingCount > 0 ? (
<button <button
type="button" type="button"
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)} onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
@@ -29,6 +29,7 @@ export type SessionGroup = {
* instead of reading the single folderScopeKey. * instead of reading the single folderScopeKey.
*/ */
folderScopes?: SessionGroupFolderScope[]; folderScopes?: SessionGroupFolderScope[];
draftTarget?: 'chat' | 'project';
sessions: SessionNode[]; sessions: SessionNode[];
}; };
@@ -316,7 +316,9 @@ export const useKeyboardShortcuts = () => {
return; return;
} }
openNewSessionDraft(); openNewSessionDraft(currentSessionId && currentDirectory
? { directoryOverride: currentDirectory }
: undefined);
return; return;
} }
+7 -1
View File
@@ -193,7 +193,13 @@ export const useMenuActions = (
case 'new-session': case 'new-session':
setActiveMainTab('chat'); setActiveMainTab('chat');
setSessionSwitcherOpen(false); setSessionSwitcherOpen(false);
openNewSessionDraft(); {
const sessionState = useSessionUIStore.getState();
const directory = useDirectoryStore.getState().currentDirectory;
openNewSessionDraft(sessionState.currentSessionId && directory
? { directoryOverride: directory }
: undefined);
}
break; break;
case 'new-worktree-session': case 'new-worktree-session':
@@ -34,7 +34,10 @@ export const useMiniChatKeyboardShortcuts = () => {
if (eventMatchesShortcut(event, combo('new_chat'))) { if (eventMatchesShortcut(event, combo('new_chat'))) {
event.preventDefault(); event.preventDefault();
openNewSessionDraft(); const sessionState = useSessionUIStore.getState();
openNewSessionDraft(sessionState.currentSessionId && sessionState.currentSessionDirectory
? { directoryOverride: sessionState.currentSessionDirectory }
: undefined);
focusChatInput(); focusChatInput();
return; return;
} }
@@ -176,6 +176,11 @@ const createInstantWorktreeDraft = async (options?: {
initialPrompt?: string; initialPrompt?: string;
title?: string; title?: string;
}): Promise<string | null> => { }): Promise<string | null> => {
const currentDraft = useSessionUIStore.getState().newSessionDraft;
if (currentDraft.open && currentDraft.target === 'chat') {
return null;
}
if (isCreatingWorktreeSession) { if (isCreatingWorktreeSession) {
return null; return null;
} }
+3 -2
View File
@@ -30,7 +30,7 @@ import { useSkillsStore } from "@/stores/useSkillsStore"
import { getDeferredSafeStorage } from "@/stores/utils/safeStorage" import { getDeferredSafeStorage } from "@/stores/utils/safeStorage"
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
import { normalizePath } from "@/lib/pathNormalization" 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 { isVSCodeRuntime } from "@/lib/desktop"
import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { flattenAssistantTextParts } from "@/lib/messages/messageText"
import { composeForkSessionMessage } from "@/lib/messages/executionMeta" import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
@@ -1677,7 +1677,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
get().closeNewSessionDraft() get().closeNewSessionDraft()
if (targetFolderId) { if (targetFolderId) {
const scopeKey = dir || get().lastLoadedDirectory || session.directory const scopeDirectory = dir || get().lastLoadedDirectory || session.directory
const scopeKey = getChatsRootFromDirectory(scopeDirectory) ?? scopeDirectory
if (scopeKey) { if (scopeKey) {
useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id) useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id)
} }