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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user