Major UI refresh: sidebar redesign, theme expansion, and chat performance optimizations (#706)
## Summary Complete sidebar redesign and comprehensive UI polish pass with performance optimizations, theme system refinements, and desktop integration improvements. ## Key Changes **Sidebar & Navigation Redesign** - Redesigned sessions sidebar layout with unified button primitives - Added activity sections with project grouping and improved session organization - Refined sidebar corners, spacing, and visual hierarchy - Removed NavRail component in favor of streamlined sidebar - Stabilized sessions bar toggle position in fullscreen mode **Performance Optimizations** - Reduced chat streaming CPU usage and storage churn - Optimized task tool polling and live timers with debouncing - Prevented chat state races and reduced background request load - Debounced draft writes and coalesced session reloads - Optimized message store updates and turn tracking **Theme & Visual System** - Added theme-aware window corners (desktop) and border radius tokens - Introduced glassmorphism effects on desktop sidebar - Added backdrop blur to UI elements **Chat Experience** - Added session-based permission auto-accept toggle in chat input - Polished permission shield UX with improved icon sizing and spacing - Fixed chat scroll-to-bottom behavior and timeline tracking - Enhanced tool output display with better path label detection - Removed duplicate draft context details in chat header - Added text selection menu to chat messages **Git Improvements** - Refreshed git history visual design with cleaner dividers - Added remote removal action in sync selector - Stabilized git polling to prevent excessive requests - Improved tool output rendering for git operations **Settings & Panels** - Fixed mobile scrolling on settings pages - Made outside-click settings close instantly - Reduced settings load churn and CPU spikes - Improved services dropdown layout and spacing - Softened panel resize handles **Desktop Integration** - Synced macOS window theme with app theme - Restored window dragging in sidebar header zones - Fixed system window corners on macOS - Improved header session metadata and action controls **Button & Component Standardization** - Unified button primitives across all components - Standardized destructive action patterns - Removed unused button variants (button-large, button-small) - Aligned context tab close hit areas --------- Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
359879153a
commit
321cc7252a
@@ -15,7 +15,7 @@ import { useChatScrollManager } from '@/hooks/useChatScrollManager';
|
||||
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||
import { TimelineDialog } from './TimelineDialog';
|
||||
import type { PermissionRequest } from '@/types/permission';
|
||||
@@ -30,6 +30,7 @@ const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
|
||||
const EMPTY_QUESTIONS: QuestionRequest[] = [];
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
const SESSION_RESELECTED_EVENT = 'openchamber:session-reselected';
|
||||
|
||||
type HydratingToolSkeletonRow = {
|
||||
id: string;
|
||||
@@ -193,7 +194,7 @@ export const ChatContainer: React.FC = () => {
|
||||
}, [parentSession, setCurrentSession]);
|
||||
|
||||
const returnToParentButton = parentSession ? (
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -204,7 +205,7 @@ export const ChatContainer: React.FC = () => {
|
||||
>
|
||||
<RiArrowLeftLine className="h-4 w-4" />
|
||||
Parent
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -255,6 +256,7 @@ export const ChatContainer: React.FC = () => {
|
||||
isPinned,
|
||||
isOverflowing,
|
||||
});
|
||||
const { resumeToBottomInstant } = timelineController;
|
||||
|
||||
React.useEffect(() => {
|
||||
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
||||
@@ -269,6 +271,26 @@ export const ChatContainer: React.FC = () => {
|
||||
resumeToBottom: timelineController.resumeToBottom,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || !currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSessionReselected = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<string>;
|
||||
if (customEvent.detail !== currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
resumeToBottomInstant();
|
||||
};
|
||||
|
||||
window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
|
||||
};
|
||||
}, [currentSessionId, resumeToBottomInstant]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
@@ -366,7 +388,7 @@ export const ChatContainer: React.FC = () => {
|
||||
>
|
||||
{!isDesktopExpandedInput ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<ChatEmptyState showDraftContext />
|
||||
<ChatEmptyState />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
@@ -374,7 +396,7 @@ export const ChatContainer: React.FC = () => {
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
|
||||
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
|
||||
)}
|
||||
>
|
||||
<ChatInput scrollToBottom={scrollToBottom} />
|
||||
@@ -444,7 +466,7 @@ export const ChatContainer: React.FC = () => {
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
|
||||
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
|
||||
)}
|
||||
>
|
||||
<ChatInput scrollToBottom={scrollToBottom} />
|
||||
@@ -507,7 +529,7 @@ export const ChatContainer: React.FC = () => {
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
|
||||
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
|
||||
)}
|
||||
>
|
||||
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import React from 'react';
|
||||
import { RiGitBranchLine } from '@remixicon/react';
|
||||
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { TextLoop } from '@/components/ui/TextLoop';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useGitStatus, useGitStore } from '@/stores/useGitStore';
|
||||
|
||||
const phrases = [
|
||||
"Fix the failing tests",
|
||||
@@ -27,51 +22,15 @@ const phrases = [
|
||||
"Add type definitions",
|
||||
];
|
||||
|
||||
interface ChatEmptyStateProps {
|
||||
showDraftContext?: boolean;
|
||||
}
|
||||
|
||||
const ChatEmptyState: React.FC<ChatEmptyStateProps> = ({
|
||||
showDraftContext = false,
|
||||
}) => {
|
||||
const ChatEmptyState: React.FC = () => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const { git } = useRuntimeAPIs();
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const { setActiveDirectory, fetchStatus } = useGitStore();
|
||||
const gitStatus = useGitStatus(effectiveDirectory ?? null);
|
||||
|
||||
// Use theme's muted foreground for secondary text
|
||||
const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)';
|
||||
const branchName = typeof gitStatus?.current === 'string' && gitStatus.current.trim().length > 0
|
||||
? gitStatus.current.trim()
|
||||
: null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showDraftContext || !effectiveDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveDirectory(effectiveDirectory);
|
||||
|
||||
const state = useGitStore.getState().directories.get(effectiveDirectory);
|
||||
if (!state?.status && state?.isGitRepo !== false) {
|
||||
void fetchStatus(effectiveDirectory, git, { silent: true });
|
||||
}
|
||||
}, [effectiveDirectory, fetchStatus, git, setActiveDirectory, showDraftContext]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-full w-full gap-6">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
{showDraftContext && (
|
||||
<div className="max-w-[calc(100%-2rem)] flex flex-col items-center gap-1">
|
||||
{branchName && (
|
||||
<div className="inline-flex items-center gap-1 text-body-md" style={{ color: textColor }}>
|
||||
<RiGitBranchLine className="h-4 w-4 shrink-0" />
|
||||
<span className="overflow-hidden whitespace-nowrap" title={branchName}>{branchName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<TextLoop
|
||||
className="text-body-md"
|
||||
interval={4}
|
||||
|
||||
@@ -7,13 +7,17 @@ import {
|
||||
RiCloseLine,
|
||||
RiCommandLine,
|
||||
RiExternalLinkLine,
|
||||
RiFolderLine,
|
||||
RiFullscreenLine,
|
||||
RiGitPullRequestLine,
|
||||
RiShieldCheckLine,
|
||||
RiShieldUserLine,
|
||||
RiGithubLine,
|
||||
RiSendPlane2Line,
|
||||
} from '@remixicon/react';
|
||||
import { BrowserVoiceButton } from '@/components/voice';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useSessionStore as useSessionManagementStore } from '@/stores/sessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
@@ -25,7 +29,7 @@ import { QueuedMessageChips } from './QueuedMessageChips';
|
||||
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
||||
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
||||
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
|
||||
import { cn, isMacOS } from '@/lib/utils';
|
||||
import { cn, formatDirectoryName, isMacOS } from '@/lib/utils';
|
||||
import { ModelControls } from './ModelControls';
|
||||
import { UnifiedControlsDrawer } from './UnifiedControlsDrawer';
|
||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||
@@ -49,15 +53,52 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
|
||||
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
|
||||
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||
import { useGitBranches, useGitStore } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
|
||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
|
||||
const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500;
|
||||
|
||||
const normalizePath = (value?: string | null): string | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const normalized = trimmed.replace(/\\/g, '/');
|
||||
if (normalized === '/') {
|
||||
return '/';
|
||||
}
|
||||
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
||||
};
|
||||
|
||||
const getProjectDisplayLabel = (project: { label?: string; path: string }): string => {
|
||||
const label = project.label?.trim();
|
||||
if (label) {
|
||||
return label;
|
||||
}
|
||||
return formatDirectoryName(project.path);
|
||||
};
|
||||
|
||||
const getProjectIconColor = (projectColor?: string | null): string | undefined => {
|
||||
if (!projectColor) {
|
||||
return undefined;
|
||||
}
|
||||
return PROJECT_COLOR_MAP[projectColor] ?? undefined;
|
||||
};
|
||||
|
||||
const appendWithLineBreaks = (base: string, next: string): string => {
|
||||
const separator = !base
|
||||
@@ -154,10 +195,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
|
||||
// Ref to track current message value without triggering re-renders in effects
|
||||
const messageRef = React.useRef(message);
|
||||
const draftPersistTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const skipNextDraftPersistRef = React.useRef(false);
|
||||
const lastPersistedDraftRef = React.useRef<Map<string, string>>(new Map());
|
||||
const currentSessionIdForDraftRef = React.useRef<string | null>(null);
|
||||
|
||||
const sendMessage = useSessionStore((state) => state.sendMessage);
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
|
||||
const newSessionDraft = useSessionStore((state) => state.newSessionDraft);
|
||||
const newSessionDraftOpen = Boolean(newSessionDraft?.open);
|
||||
const setNewSessionDraftTarget = useSessionStore((state) => state.setNewSessionDraftTarget);
|
||||
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
||||
const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation);
|
||||
const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort);
|
||||
const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
|
||||
@@ -169,18 +217,25 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText);
|
||||
const pendingInputText = useSessionStore((state) => state.pendingInputText);
|
||||
const consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts);
|
||||
const currentManagementSessionId = useSessionManagementStore((state) => state.currentSessionId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||
|
||||
const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
|
||||
const agents = getVisibleAgents();
|
||||
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
|
||||
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore();
|
||||
const { working } = useAssistantStatus();
|
||||
const { git: runtimeGit } = useRuntimeAPIs();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const chatSearchDirectory = useChatSearchDirectory();
|
||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||
const [textareaScrollTop, setTextareaScrollTop] = React.useState(0);
|
||||
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
|
||||
|
||||
const isDesktopExpanded = isExpandedInput && !isMobile;
|
||||
const chatInputRadius = 'var(--radius-lg)';
|
||||
|
||||
const sendableAttachedFiles = React.useMemo(
|
||||
() => attachedFiles.filter((file) => file.source !== 'server'),
|
||||
@@ -423,6 +478,29 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
messageRef.current = message;
|
||||
}, [message]);
|
||||
|
||||
React.useEffect(() => {
|
||||
currentSessionIdForDraftRef.current = currentSessionId;
|
||||
}, [currentSessionId]);
|
||||
|
||||
const persistDraftImmediately = React.useCallback((sessionId: string | null, draft: string) => {
|
||||
const key = getDraftKey(sessionId);
|
||||
const lastPersisted = lastPersistedDraftRef.current.get(key);
|
||||
if (lastPersisted === draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveStoredDraft(sessionId, draft);
|
||||
lastPersistedDraftRef.current.set(key, draft);
|
||||
}, []);
|
||||
|
||||
const clearPendingDraftPersist = React.useCallback(() => {
|
||||
if (!draftPersistTimerRef.current) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(draftPersistTimerRef.current);
|
||||
draftPersistTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Handle initial draft restoration and text selection
|
||||
const hasHandledInitialDraftRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
@@ -455,10 +533,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const oldSessionId = prevSessionIdRef.current;
|
||||
prevSessionIdRef.current = currentSessionId;
|
||||
setInputMode('normal');
|
||||
clearPendingDraftPersist();
|
||||
skipNextDraftPersistRef.current = true;
|
||||
|
||||
if (persistChatDraft) {
|
||||
// Save current draft for the session we're leaving
|
||||
saveStoredDraft(oldSessionId, messageRef.current);
|
||||
persistDraftImmediately(oldSessionId, messageRef.current);
|
||||
// Restore draft for the session we're entering
|
||||
const newDraft = getStoredDraft(currentSessionId);
|
||||
setMessage(newDraft);
|
||||
@@ -472,7 +552,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
setMessage('');
|
||||
}
|
||||
}
|
||||
}, [currentSessionId, persistChatDraft]);
|
||||
}, [clearPendingDraftPersist, currentSessionId, persistChatDraft, persistDraftImmediately]);
|
||||
|
||||
// Focus textarea when new session draft is opened
|
||||
const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen);
|
||||
@@ -494,16 +574,37 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
// Persist chat input draft to localStorage per session (only if setting enabled)
|
||||
React.useEffect(() => {
|
||||
if (!persistChatDraft) {
|
||||
// Clear stored draft for current session when setting is disabled
|
||||
try {
|
||||
localStorage.removeItem(getDraftKey(currentSessionId));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
clearPendingDraftPersist();
|
||||
persistDraftImmediately(currentSessionId, '');
|
||||
return;
|
||||
}
|
||||
saveStoredDraft(currentSessionId, message);
|
||||
}, [message, persistChatDraft, currentSessionId]);
|
||||
|
||||
if (skipNextDraftPersistRef.current) {
|
||||
skipNextDraftPersistRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
clearPendingDraftPersist();
|
||||
const draftSnapshot = message;
|
||||
const sessionSnapshot = currentSessionId;
|
||||
draftPersistTimerRef.current = setTimeout(() => {
|
||||
draftPersistTimerRef.current = null;
|
||||
persistDraftImmediately(sessionSnapshot, draftSnapshot);
|
||||
}, CHAT_DRAFT_PERSIST_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
clearPendingDraftPersist();
|
||||
};
|
||||
}, [clearPendingDraftPersist, currentSessionId, message, persistChatDraft, persistDraftImmediately]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
clearPendingDraftPersist();
|
||||
if (persistChatDraft) {
|
||||
persistDraftImmediately(currentSessionIdForDraftRef.current, messageRef.current);
|
||||
}
|
||||
};
|
||||
}, [clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]);
|
||||
|
||||
// Session activity for queue availability and controls
|
||||
const { phase: sessionPhase } = useCurrentSessionActivity();
|
||||
@@ -2164,6 +2265,243 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
|
||||
const footerGapClass = 'gap-x-1.5 gap-y-0';
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const showDraftTargetSelectors = newSessionDraftOpen && !isVSCode;
|
||||
|
||||
const selectedDraftProject = React.useMemo(() => {
|
||||
const explicit = newSessionDraft?.selectedProjectId
|
||||
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
|
||||
: null;
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const active = activeProjectId
|
||||
? projects.find((project) => project.id === activeProjectId) ?? null
|
||||
: null;
|
||||
if (active) {
|
||||
return active;
|
||||
}
|
||||
|
||||
return projects[0] ?? null;
|
||||
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
|
||||
|
||||
const selectedDraftProjectPath = React.useMemo(
|
||||
() => normalizePath(selectedDraftProject?.path ?? null),
|
||||
[selectedDraftProject?.path],
|
||||
);
|
||||
|
||||
const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath);
|
||||
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||
const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showDraftTargetSelectors || !selectedDraftProjectPath || !selectedDraftProject || !runtimeGit) {
|
||||
setIsDiscoveringDraftBranches(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedDraftProjectBranches?.all) {
|
||||
setIsDiscoveringDraftBranches(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsDiscoveringDraftBranches(true);
|
||||
|
||||
void fetchBranches(selectedDraftProjectPath, runtimeGit)
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsDiscoveringDraftBranches(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchBranches, runtimeGit, selectedDraftProject, selectedDraftProjectBranches?.all, selectedDraftProjectPath, showDraftTargetSelectors]);
|
||||
|
||||
const projectRootBranchOption = React.useMemo(() => {
|
||||
if (!selectedDraftProject) {
|
||||
return null;
|
||||
}
|
||||
const value = normalizePath(selectedDraftProject.path);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const projectRootBranch = selectedDraftProjectBranches?.current?.trim() ?? '';
|
||||
if (!projectRootBranch) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
value,
|
||||
label: projectRootBranch,
|
||||
};
|
||||
}, [selectedDraftProject, selectedDraftProjectBranches]);
|
||||
|
||||
const worktreeBranchOptions = React.useMemo(() => {
|
||||
if (!selectedDraftProject) {
|
||||
return [] as Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const options: Array<{ value: string; label: string }> = [];
|
||||
const rootValue = projectRootBranchOption?.value ?? null;
|
||||
|
||||
const worktrees = (() => {
|
||||
if (!selectedDraftProjectPath) {
|
||||
return [];
|
||||
}
|
||||
return availableWorktreesByProject.get(selectedDraftProjectPath)
|
||||
?? availableWorktreesByProject.get(selectedDraftProject.path)
|
||||
?? [];
|
||||
})();
|
||||
|
||||
worktrees
|
||||
.slice()
|
||||
.sort((a, b) => a.branch.localeCompare(b.branch))
|
||||
.forEach((worktree) => {
|
||||
const normalizedValue = normalizePath(worktree.path);
|
||||
if (!normalizedValue || normalizedValue === rootValue || seen.has(normalizedValue)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalizedValue);
|
||||
options.push({
|
||||
value: normalizedValue,
|
||||
label: worktree.branch?.trim() || formatDirectoryName(worktree.path),
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [availableWorktreesByProject, projectRootBranchOption?.value, selectedDraftProject, selectedDraftProjectPath]);
|
||||
|
||||
const selectedDraftDirectory = React.useMemo(
|
||||
() => normalizePath(newSessionDraft?.directoryOverride ?? null) ?? selectedDraftProjectPath,
|
||||
[newSessionDraft?.directoryOverride, selectedDraftProjectPath],
|
||||
);
|
||||
|
||||
const draftBranchItems = React.useMemo(() => {
|
||||
const baseItems: Array<{ value: string; label: string }> = [];
|
||||
if (projectRootBranchOption) {
|
||||
baseItems.push(projectRootBranchOption);
|
||||
}
|
||||
baseItems.push(...worktreeBranchOptions);
|
||||
|
||||
if (!selectedDraftDirectory) {
|
||||
return baseItems;
|
||||
}
|
||||
if (baseItems.some((option) => option.value === selectedDraftDirectory)) {
|
||||
return baseItems;
|
||||
}
|
||||
return [
|
||||
...baseItems,
|
||||
{ value: selectedDraftDirectory, label: formatDirectoryName(selectedDraftDirectory) },
|
||||
];
|
||||
}, [projectRootBranchOption, selectedDraftDirectory, worktreeBranchOptions]);
|
||||
|
||||
const selectedDraftBranchLabel = React.useMemo(() => {
|
||||
const selectedValue = selectedDraftDirectory ?? draftBranchItems[0]?.value ?? null;
|
||||
if (!selectedValue) {
|
||||
return null;
|
||||
}
|
||||
return draftBranchItems.find((item) => item.value === selectedValue)?.label ?? formatDirectoryName(selectedValue);
|
||||
}, [draftBranchItems, selectedDraftDirectory]);
|
||||
|
||||
const selectedDraftBranchIsKnown = React.useMemo(() => {
|
||||
if (!selectedDraftDirectory) {
|
||||
return true;
|
||||
}
|
||||
if (projectRootBranchOption?.value === selectedDraftDirectory) {
|
||||
return true;
|
||||
}
|
||||
return worktreeBranchOptions.some((option) => option.value === selectedDraftDirectory);
|
||||
}, [projectRootBranchOption?.value, selectedDraftDirectory, worktreeBranchOptions]);
|
||||
|
||||
const shouldShowDraftBranchSelector = React.useMemo(() => {
|
||||
if (isDiscoveringDraftBranches) {
|
||||
return false;
|
||||
}
|
||||
if (projectRootBranchOption) {
|
||||
return true;
|
||||
}
|
||||
return worktreeBranchOptions.length > 0;
|
||||
}, [isDiscoveringDraftBranches, projectRootBranchOption, worktreeBranchOptions.length]);
|
||||
|
||||
const handleDraftProjectChange = React.useCallback((projectId: string) => {
|
||||
const project = projects.find((entry) => entry.id === projectId);
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
if (activeProjectId !== projectId) {
|
||||
setActiveProjectIdOnly(projectId);
|
||||
}
|
||||
setNewSessionDraftTarget({
|
||||
projectId,
|
||||
directoryOverride: project.path,
|
||||
});
|
||||
}, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]);
|
||||
|
||||
const handleDraftDirectoryChange = React.useCallback((directory: string) => {
|
||||
if (!selectedDraftProject) {
|
||||
return;
|
||||
}
|
||||
setNewSessionDraftTarget({
|
||||
projectId: selectedDraftProject.id,
|
||||
directoryOverride: directory,
|
||||
});
|
||||
}, [selectedDraftProject, setNewSessionDraftTarget]);
|
||||
|
||||
const renderProjectLabelWithIcon = React.useCallback((project: {
|
||||
id: string;
|
||||
path: string;
|
||||
label?: string;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
|
||||
iconBackground?: string | null;
|
||||
}) => {
|
||||
const imageUrl = getProjectIconImageUrl(
|
||||
{ id: project.id, iconImage: project.iconImage ?? null },
|
||||
{
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
},
|
||||
);
|
||||
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const iconColor = getProjectIconColor(project.color);
|
||||
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
{imageUrl ? (
|
||||
<span
|
||||
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
|
||||
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
||||
>
|
||||
<img src={imageUrl} alt="" className="h-full w-full object-contain" draggable={false} />
|
||||
</span>
|
||||
) : ProjectIcon ? (
|
||||
<ProjectIcon className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
|
||||
) : (
|
||||
<RiFolderLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
|
||||
)}
|
||||
<span className="truncate">{getProjectDisplayLabel(project)}</span>
|
||||
</span>
|
||||
);
|
||||
}, [currentTheme.colors.surface.foreground, currentTheme.metadata.variant]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) {
|
||||
return;
|
||||
}
|
||||
const valid = draftBranchItems.some((option) => option.value === selectedDraftDirectory);
|
||||
if (valid) {
|
||||
return;
|
||||
}
|
||||
setNewSessionDraftTarget({
|
||||
projectId: selectedDraftProject.id,
|
||||
directoryOverride: selectedDraftProject.path,
|
||||
});
|
||||
}, [draftBranchItems, selectedDraftDirectory, selectedDraftProject, setNewSessionDraftTarget, showDraftTargetSelectors]);
|
||||
|
||||
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5');
|
||||
const buttonSizeClass = isMobile ? 'h-8 w-8' : (isVSCode ? 'h-5 w-5' : 'h-6 w-6');
|
||||
const sendIconSizeClass = isMobile ? 'h-4 w-4' : (isVSCode ? 'h-3.5 w-3.5' : 'h-4 w-4');
|
||||
@@ -2172,6 +2510,73 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
|
||||
const iconButtonBaseClass = 'flex cursor-pointer items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0 disabled:cursor-not-allowed';
|
||||
const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass);
|
||||
const permissionScopeSessionId = currentSessionId ?? currentManagementSessionId;
|
||||
const permissionAutoAcceptEnabled = usePermissionStore((state) => {
|
||||
if (!permissionScopeSessionId) {
|
||||
return false;
|
||||
}
|
||||
return state.isSessionAutoAccepting(permissionScopeSessionId);
|
||||
});
|
||||
|
||||
const handlePermissionAutoAcceptToggle = React.useCallback(() => {
|
||||
if (!permissionScopeSessionId) {
|
||||
toast.error('Open a session first');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEnabled = !permissionAutoAcceptEnabled;
|
||||
setSessionAutoAccept(permissionScopeSessionId, nextEnabled).catch(() => {
|
||||
toast.error('Failed to toggle permission auto-accept');
|
||||
});
|
||||
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]);
|
||||
|
||||
const permissionAutoAcceptAriaLabel = permissionAutoAcceptEnabled
|
||||
? 'Disable permission auto-accept'
|
||||
: 'Enable permission auto-accept';
|
||||
const permissionAutoAcceptTooltipLabel = permissionAutoAcceptEnabled
|
||||
? 'Permission auto-accept: on'
|
||||
: 'Permission auto-accept: off';
|
||||
|
||||
const permissionAutoAcceptButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePermissionAutoAcceptToggle}
|
||||
className={cn(
|
||||
footerIconButtonClass,
|
||||
'rounded-md hover:bg-transparent',
|
||||
!permissionScopeSessionId && 'opacity-30',
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
aria-pressed={permissionAutoAcceptEnabled}
|
||||
aria-label={permissionAutoAcceptAriaLabel}
|
||||
title={permissionAutoAcceptAriaLabel}
|
||||
>
|
||||
{permissionAutoAcceptEnabled ? (
|
||||
<RiShieldCheckLine className={cn(iconSizeClass)} style={{ color: 'var(--status-info)' }} />
|
||||
) : (
|
||||
<RiShieldUserLine className={cn(iconSizeClass)} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const permissionAutoAcceptButtonWithTooltip = (
|
||||
<Tooltip delayDuration={600}>
|
||||
<TooltipTrigger asChild>
|
||||
{permissionAutoAcceptButton}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
{permissionAutoAcceptTooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
// Send button - respects queue mode setting
|
||||
const sendButton = (
|
||||
@@ -2536,6 +2941,74 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
showAssistantStatus={false}
|
||||
showTodos
|
||||
/>
|
||||
{showDraftTargetSelectors && selectedDraftProject ? (
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
|
||||
<Select
|
||||
value={selectedDraftProject.id}
|
||||
onValueChange={handleDraftProjectChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[state=open]:bg-transparent"
|
||||
>
|
||||
<SelectValue>
|
||||
{renderProjectLabelWithIcon(selectedDraftProject)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent fitContent>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
|
||||
{renderProjectLabelWithIcon(project)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{shouldShowDraftBranchSelector ? (
|
||||
<Select
|
||||
value={selectedDraftDirectory ?? draftBranchItems[0]?.value ?? normalizePath(selectedDraftProject.path) ?? ''}
|
||||
onValueChange={handleDraftDirectoryChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[state=open]:bg-transparent"
|
||||
>
|
||||
<SelectValue>
|
||||
{selectedDraftBranchLabel ?? 'Branch'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent fitContent>
|
||||
{projectRootBranchOption ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Project root</SelectLabel>
|
||||
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate">
|
||||
{projectRootBranchOption.label}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
) : null}
|
||||
{worktreeBranchOptions.length > 0 ? (
|
||||
<>
|
||||
{projectRootBranchOption ? <SelectSeparator /> : null}
|
||||
<SelectGroup>
|
||||
<SelectLabel>Worktrees</SelectLabel>
|
||||
{worktreeBranchOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
) : null}
|
||||
{selectedDraftDirectory && !selectedDraftBranchIsKnown ? (
|
||||
<SelectItem value={selectedDraftDirectory} className="max-w-[24rem] truncate">
|
||||
{selectedDraftBranchLabel}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col relative overflow-visible",
|
||||
@@ -2548,7 +3021,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
isDragging && "ring-2 ring-primary ring-offset-2"
|
||||
)}
|
||||
style={{
|
||||
borderRadius: cornerRadius,
|
||||
borderRadius: chatInputRadius,
|
||||
backgroundColor: currentTheme?.colors?.surface?.subtle,
|
||||
}}
|
||||
ref={dropZoneRef}
|
||||
@@ -2714,8 +3187,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
flex: isDesktopExpanded ? '1 1 auto' : 'none',
|
||||
height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
|
||||
maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
||||
borderTopLeftRadius: cornerRadius,
|
||||
borderTopRightRadius: cornerRadius,
|
||||
borderTopLeftRadius: chatInputRadius,
|
||||
borderTopRightRadius: chatInputRadius,
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
@@ -2727,16 +3200,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
isMobile ? 'flex items-center gap-x-1.5' : cn('flex items-center justify-between', footerGapClass)
|
||||
)}
|
||||
style={{
|
||||
borderBottomLeftRadius: cornerRadius,
|
||||
borderBottomRightRadius: cornerRadius,
|
||||
borderBottomLeftRadius: chatInputRadius,
|
||||
borderBottomRightRadius: chatInputRadius,
|
||||
}}
|
||||
data-chat-input-footer="true"
|
||||
>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<div className="flex w-full items-center justify-between gap-x-1.5">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{attachmentsControls}
|
||||
{permissionAutoAcceptButton}
|
||||
</div>
|
||||
<div className="flex items-center min-w-0 gap-x-1 justify-end">
|
||||
<div className="flex items-center gap-x-1 min-w-0 max-w-[60vw] flex-shrink">
|
||||
@@ -2801,6 +3275,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{permissionAutoAcceptButtonWithTooltip}
|
||||
</div>
|
||||
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
||||
<ModelControls className={cn('flex-1 min-w-0 justify-end')} />
|
||||
|
||||
@@ -971,6 +971,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const assistantTopPaddingClass = !isUser && shouldShowHeader
|
||||
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
|
||||
: 'pt-0';
|
||||
const userMessageRadius = 'var(--radius-lg)';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -995,7 +996,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
>
|
||||
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
|
||||
<div className="max-w-[85%]">
|
||||
<div style={{ backgroundColor: 'var(--chat-user-message-bg)' }} className="rounded-[var(--radius-xl)] rounded-br-[var(--radius-sm)] px-5 py-3 shadow-none border border-primary/5">
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--chat-user-message-bg)',
|
||||
borderRadius: userMessageRadius,
|
||||
borderBottomRightRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
className="px-5 py-3 shadow-none border border-primary/5"
|
||||
>
|
||||
<MessageBody
|
||||
messageId={message.info.id}
|
||||
parts={displayParts}
|
||||
|
||||
@@ -971,12 +971,14 @@ const useFileReferenceInteractions = ({
|
||||
readFile,
|
||||
editor,
|
||||
preferRuntimeEditor,
|
||||
deferValidationUntilIdle = false,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
effectiveDirectory: string;
|
||||
readFile?: (path: string) => Promise<{ content: string; path: string }>;
|
||||
editor?: EditorAPI;
|
||||
preferRuntimeEditor?: boolean;
|
||||
deferValidationUntilIdle?: boolean;
|
||||
}) => {
|
||||
const validationCacheRef = React.useRef<Map<string, boolean>>(new Map());
|
||||
const inFlightValidationsRef = React.useRef<Map<string, Promise<boolean>>>(new Map());
|
||||
@@ -1048,6 +1050,9 @@ const useFileReferenceInteractions = ({
|
||||
};
|
||||
|
||||
const runValidationSweep = async (paths: string[], expectedPassID: number) => {
|
||||
if (deferValidationUntilIdle) {
|
||||
return;
|
||||
}
|
||||
if (isValidationSweepRunningRef.current || paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1208,9 +1213,13 @@ const useFileReferenceInteractions = ({
|
||||
annotationDebounceRef.current = window.setTimeout(() => {
|
||||
annotationDebounceRef.current = null;
|
||||
void annotateFileLinks();
|
||||
}, 120);
|
||||
}, deferValidationUntilIdle ? 280 : 120);
|
||||
});
|
||||
observer.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
...(deferValidationUntilIdle ? {} : { characterData: true }),
|
||||
});
|
||||
observer.observe(container, { childList: true, subtree: true, characterData: true });
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
@@ -1226,7 +1235,7 @@ const useFileReferenceInteractions = ({
|
||||
container.removeEventListener('click', handleClick);
|
||||
container.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, readFile]);
|
||||
}, [containerRef, deferValidationUntilIdle, editor, effectiveDirectory, preferRuntimeEditor, readFile]);
|
||||
};
|
||||
|
||||
const useMermaidInlineInteractions = ({
|
||||
@@ -1345,6 +1354,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
readFile: files.readFile,
|
||||
editor,
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
deferValidationUntilIdle: isStreaming,
|
||||
});
|
||||
|
||||
const shikiThemes = useMarkdownShikiThemes();
|
||||
@@ -1358,10 +1368,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
? 'streamdown-content streamdown-reasoning'
|
||||
: 'streamdown-content';
|
||||
|
||||
const streamdownAnimated = React.useMemo(
|
||||
() => ({ animation: 'blurIn' as const, duration: 150, easing: 'ease-out' }),
|
||||
[],
|
||||
);
|
||||
const streamdownAnimated = undefined;
|
||||
|
||||
const markdownContent = (
|
||||
<div className={cn('break-words w-full min-w-0', className)} ref={streamdownContainerRef}>
|
||||
|
||||
@@ -318,6 +318,33 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD
|
||||
};
|
||||
};
|
||||
|
||||
const normalizedMessageBySource = new WeakMap<ChatMessageEntry, ChatMessageEntry>();
|
||||
|
||||
const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => {
|
||||
const cached = normalizedMessageBySource.get(message);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const filteredParts = filterSyntheticParts(message.parts);
|
||||
const normalized = filteredParts === message.parts
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
parts: filteredParts,
|
||||
};
|
||||
|
||||
normalizedMessageBySource.set(message, normalized);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const isAssistantTextOnlyMessage = (message: ChatMessageEntry): boolean => {
|
||||
if (resolveMessageRole(message) !== 'assistant') {
|
||||
return false;
|
||||
}
|
||||
return message.parts.length > 0 && message.parts.every((part) => part?.type === 'text');
|
||||
};
|
||||
|
||||
interface MessageListProps {
|
||||
sessionKey: string;
|
||||
turnStart: number;
|
||||
@@ -813,6 +840,11 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
previousOrder: string[];
|
||||
animatedIds: Set<string>;
|
||||
}>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() });
|
||||
const baseDisplayCacheRef = React.useRef<{
|
||||
input: ChatMessageEntry[];
|
||||
output: ChatMessageEntry[];
|
||||
outputIndexById: Map<string, number>;
|
||||
} | null>(null);
|
||||
|
||||
const stableOnMessageContentChange = useStableEvent(onMessageContentChange);
|
||||
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
|
||||
@@ -843,8 +875,51 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
|
||||
|
||||
const baseDisplayMessages = React.useMemo(() => {
|
||||
const seenIdsFromTail = new Set<string>();
|
||||
const cached = baseDisplayCacheRef.current;
|
||||
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
const canUseTailFastPath = Boolean(lastMessage && isAssistantTextOnlyMessage(lastMessage));
|
||||
|
||||
if (cached && canUseTailFastPath && cached.input.length === messages.length && messages.length > 0) {
|
||||
let changedCount = 0;
|
||||
let changedIndex = -1;
|
||||
let idsStable = true;
|
||||
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
if (messages[index]?.info?.id !== cached.input[index]?.info?.id) {
|
||||
idsStable = false;
|
||||
break;
|
||||
}
|
||||
if (messages[index] !== cached.input[index]) {
|
||||
changedCount += 1;
|
||||
changedIndex = index;
|
||||
if (changedCount > 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (idsStable && changedCount === 1 && changedIndex === messages.length - 1) {
|
||||
const changedMessage = messages[changedIndex];
|
||||
const previousMessage = changedIndex > 0 ? messages[changedIndex - 1] : undefined;
|
||||
const bridgeSensitive = isUserSubtaskMessage(previousMessage) || isUserShellMarkerMessage(previousMessage);
|
||||
|
||||
if (changedMessage && isAssistantTextOnlyMessage(changedMessage) && !bridgeSensitive) {
|
||||
const outputIndex = cached.outputIndexById.get(changedMessage.info.id);
|
||||
if (outputIndex !== undefined) {
|
||||
const nextOutput = [...cached.output];
|
||||
nextOutput[outputIndex] = getNormalizedMessageForDisplay(changedMessage);
|
||||
baseDisplayCacheRef.current = {
|
||||
input: messages,
|
||||
output: nextOutput,
|
||||
outputIndexById: cached.outputIndexById,
|
||||
};
|
||||
return nextOutput;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seenIdsFromTail = new Set<string>();
|
||||
const dedupedMessages: ChatMessageEntry[] = [];
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
@@ -855,26 +930,13 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
}
|
||||
seenIdsFromTail.add(messageId);
|
||||
}
|
||||
dedupedMessages.push(message);
|
||||
dedupedMessages.push(getNormalizedMessageForDisplay(message));
|
||||
}
|
||||
dedupedMessages.reverse();
|
||||
|
||||
const normalizedMessages = dedupedMessages
|
||||
.map((message) => {
|
||||
const filteredParts = filterSyntheticParts(message.parts);
|
||||
const normalized = filteredParts === message.parts
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
parts: filteredParts,
|
||||
};
|
||||
return normalized;
|
||||
});
|
||||
|
||||
const output: ChatMessageEntry[] = [];
|
||||
|
||||
for (let index = 0; index < normalizedMessages.length; index += 1) {
|
||||
const current = normalizedMessages[index];
|
||||
for (let index = 0; index < dedupedMessages.length; index += 1) {
|
||||
const current = dedupedMessages[index];
|
||||
const previous = output.length > 0 ? output[output.length - 1] : undefined;
|
||||
|
||||
if (isUserSubtaskMessage(previous)) {
|
||||
@@ -896,6 +958,19 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
output.push(current);
|
||||
}
|
||||
|
||||
const outputIndexById = new Map<string, number>();
|
||||
output.forEach((message, index) => {
|
||||
const id = message.info?.id;
|
||||
if (typeof id === 'string' && id.length > 0) {
|
||||
outputIndexById.set(id, index);
|
||||
}
|
||||
});
|
||||
baseDisplayCacheRef.current = {
|
||||
input: messages,
|
||||
output,
|
||||
outputIndexById,
|
||||
};
|
||||
|
||||
return output;
|
||||
}, [messages]);
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ import { Input } from '@/components/ui/input';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { TextLoop } from '@/components/ui/TextLoop';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
||||
@@ -350,14 +349,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
? (sessionSavedAgentName || stickySessionAgentName || currentAgentName)
|
||||
: currentAgentName;
|
||||
|
||||
const sessionIdForEditMode = currentSessionId ?? '__global__';
|
||||
const sessionEditMode = useContextStore((state) => {
|
||||
if (!uiAgentName) {
|
||||
return undefined;
|
||||
}
|
||||
return state.getSessionAgentEditMode(sessionIdForEditMode, uiAgentName, 'ask');
|
||||
});
|
||||
const setSessionAgentEditMode = useContextStore((state) => state.setSessionAgentEditMode);
|
||||
const {
|
||||
toggleFavoriteModel,
|
||||
isFavoriteModel,
|
||||
@@ -506,21 +497,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return getCurrentAgent?.();
|
||||
}, [agents, getCurrentAgent, uiAgentName]);
|
||||
|
||||
const agentEditAction = React.useMemo<PermissionAction>(() => {
|
||||
if (!currentAgent) {
|
||||
return 'deny';
|
||||
}
|
||||
return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow';
|
||||
}, [currentAgent]);
|
||||
|
||||
const selectionContextReady = Boolean(uiAgentName);
|
||||
|
||||
const approveEditsAvailable = agentEditAction === 'ask';
|
||||
const approveEditsChecked = approveEditsAvailable
|
||||
? sessionEditMode === 'allow' || sessionEditMode === 'full'
|
||||
: agentEditAction === 'allow';
|
||||
const approveEditsDisabled = !selectionContextReady || !approveEditsAvailable;
|
||||
|
||||
const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default';
|
||||
const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8';
|
||||
const editToggleIconClass = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4';
|
||||
@@ -545,13 +521,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return <RiQuestionLine className={combinedClassName} style={iconStyle} />;
|
||||
}, [editToggleIconClass]);
|
||||
|
||||
const handleApproveEditsToggle = React.useCallback((checked: boolean) => {
|
||||
if (!selectionContextReady || !currentAgentName || !approveEditsAvailable) {
|
||||
return;
|
||||
}
|
||||
setSessionAgentEditMode(sessionIdForEditMode, currentAgentName, checked ? 'allow' : 'ask', 'ask');
|
||||
}, [approveEditsAvailable, currentAgentName, selectionContextReady, setSessionAgentEditMode, sessionIdForEditMode]);
|
||||
|
||||
const currentProvider = getCurrentProvider();
|
||||
const models = Array.isArray(currentProvider?.models) ? currentProvider.models : [];
|
||||
|
||||
@@ -1819,23 +1788,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
onClose={closeMobilePanel}
|
||||
title="Select agent"
|
||||
contentMaxHeightClassName="max-h-[min(52dvh,360px)]"
|
||||
footer={(
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
'typography-meta font-medium',
|
||||
approveEditsDisabled ? 'text-muted-foreground' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
Auto-approve edits
|
||||
</span>
|
||||
<Switch
|
||||
checked={approveEditsChecked}
|
||||
disabled={approveEditsDisabled}
|
||||
onCheckedChange={handleApproveEditsToggle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{selectableDesktopAgents.map((agent) => {
|
||||
@@ -2302,10 +2254,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
|
||||
{/* Favorites Section */}
|
||||
{filteredFavorites.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<DropdownMenuLabel
|
||||
style={{ backgroundColor: 'var(--surface-elevated)' }}
|
||||
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30"
|
||||
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30"
|
||||
>
|
||||
<RiStarFill className="h-4 w-4 text-primary" />
|
||||
Favorites
|
||||
@@ -2314,16 +2265,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const idx = currentFlatIndex++;
|
||||
return renderModelRow(model, providerID, modelID, 'fav', idx, modelSelectedIndex === idx);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recents Section */}
|
||||
{filteredRecents.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
{filteredFavorites.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel
|
||||
style={{ backgroundColor: 'var(--surface-elevated)' }}
|
||||
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30"
|
||||
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30"
|
||||
>
|
||||
<RiTimeLine className="h-4 w-4" />
|
||||
Recent
|
||||
@@ -2332,7 +2282,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const idx = currentFlatIndex++;
|
||||
return renderModelRow(model, providerID, modelID, 'recent', idx, modelSelectedIndex === idx);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator before providers */}
|
||||
@@ -2342,7 +2292,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
|
||||
{/* All Providers - Flat List */}
|
||||
{providerSections.map(({ provider, isExpanded, visibleModels }, index) => (
|
||||
<React.Fragment key={provider.id}>
|
||||
<div key={provider.id}>
|
||||
{index > 0 && <DropdownMenuSeparator />}
|
||||
<div
|
||||
role="button"
|
||||
@@ -2366,8 +2316,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex w-full items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30',
|
||||
'bg-[var(--surface-elevated)] text-left transition-colors',
|
||||
'typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex w-full items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30',
|
||||
'text-left transition-colors',
|
||||
forceExpandProviders ? 'cursor-default' : 'cursor-pointer'
|
||||
)}
|
||||
aria-expanded={isExpanded}
|
||||
@@ -2392,7 +2342,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const idx = currentFlatIndex++;
|
||||
return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, modelSelectedIndex === idx);
|
||||
})}
|
||||
</React.Fragment>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
@@ -2755,24 +2705,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex flex-col gap-1 px-1 py-0.5">
|
||||
<div className="rounded-xl bg-transparent">
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<span className={cn(
|
||||
'typography-meta font-medium',
|
||||
approveEditsDisabled ? 'text-muted-foreground' : 'text-foreground'
|
||||
)}>
|
||||
Auto-approve edits
|
||||
</span>
|
||||
<Switch
|
||||
checked={approveEditsChecked}
|
||||
disabled={approveEditsDisabled}
|
||||
onCheckedChange={handleApproveEditsToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{renderAgentTooltipContent()}
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface UseChatTimelineControllerResult {
|
||||
loadEarlier: () => Promise<void>;
|
||||
revealBufferedTurns: () => Promise<boolean>;
|
||||
resumeToBottom: () => void;
|
||||
resumeToBottomInstant: () => void;
|
||||
scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
||||
scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
||||
captureViewportAnchor: () => ViewportAnchor | null;
|
||||
@@ -394,6 +395,14 @@ export const useChatTimelineController = ({
|
||||
scrollToBottom({ force: true });
|
||||
}, [scrollToBottom]);
|
||||
|
||||
const resumeToBottomInstant = React.useCallback(() => {
|
||||
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
||||
setTurnStart(nextStart);
|
||||
setPendingRevealWork(false);
|
||||
setIsLoadingOlder(false);
|
||||
scrollToBottom({ instant: true, force: true });
|
||||
}, [scrollToBottom]);
|
||||
|
||||
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
||||
setActiveTurnId(turnId);
|
||||
}, []);
|
||||
@@ -411,6 +420,7 @@ export const useChatTimelineController = ({
|
||||
loadEarlier,
|
||||
revealBufferedTurns,
|
||||
resumeToBottom,
|
||||
resumeToBottomInstant,
|
||||
scrollToTurn,
|
||||
scrollToMessage,
|
||||
captureViewportAnchor,
|
||||
|
||||
@@ -18,11 +18,30 @@ export const collectVisibleSessionIdsForBlockingRequests = (
|
||||
return [];
|
||||
}
|
||||
|
||||
const childIds = sessions
|
||||
.filter((session) => session.parentID === currentSessionId)
|
||||
.map((session) => session.id);
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const session of sessions) {
|
||||
if (!session.parentID) {
|
||||
continue;
|
||||
}
|
||||
const existing = childrenByParent.get(session.parentID) ?? [];
|
||||
existing.push(session.id);
|
||||
childrenByParent.set(session.parentID, existing);
|
||||
}
|
||||
|
||||
return [currentSessionId, ...childIds];
|
||||
const scoped = [currentSessionId];
|
||||
const seen = new Set(scoped);
|
||||
for (const sessionId of scoped) {
|
||||
const children = childrenByParent.get(sessionId) ?? [];
|
||||
for (const childId of children) {
|
||||
if (seen.has(childId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(childId);
|
||||
scoped.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
return scoped;
|
||||
};
|
||||
|
||||
export const flattenBlockingRequests = <T extends { id: string }>(
|
||||
|
||||
@@ -80,6 +80,7 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
||||
let ro: ResizeObserver | undefined;
|
||||
let mo: MutationObserver | undefined;
|
||||
let frame: number | undefined;
|
||||
let roDebounce: ReturnType<typeof setTimeout> | undefined;
|
||||
let active: string | undefined;
|
||||
let dirty = true;
|
||||
|
||||
@@ -198,12 +199,17 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(roDebounce);
|
||||
roDebounce = undefined;
|
||||
ro?.disconnect();
|
||||
ro = undefined;
|
||||
if (CtorRO) {
|
||||
ro = new CtorRO(() => {
|
||||
dirty = true;
|
||||
schedule();
|
||||
clearTimeout(roDebounce);
|
||||
roDebounce = setTimeout(() => {
|
||||
dirty = true;
|
||||
schedule();
|
||||
}, 100);
|
||||
});
|
||||
ro.observe(container);
|
||||
for (const element of nodes.values()) {
|
||||
@@ -218,7 +224,15 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
||||
dirty = true;
|
||||
schedule();
|
||||
});
|
||||
mo.observe(container, { subtree: true, childList: true, characterData: true });
|
||||
const moConfig: MutationObserverInit = {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
};
|
||||
if (!CtorRO) {
|
||||
moConfig.characterData = true;
|
||||
moConfig.characterDataOldValue = false;
|
||||
}
|
||||
mo.observe(container, moConfig);
|
||||
}
|
||||
|
||||
dirty = true;
|
||||
@@ -292,6 +306,8 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
||||
caf(frame);
|
||||
}
|
||||
frame = undefined;
|
||||
clearTimeout(roDebounce);
|
||||
roDebounce = undefined;
|
||||
clear();
|
||||
io?.disconnect();
|
||||
ro?.disconnect();
|
||||
|
||||
@@ -81,69 +81,8 @@ interface ProjectActivityResult {
|
||||
|
||||
export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivityResult => {
|
||||
const activityParts: TurnActivityRecord[] = [];
|
||||
const hasTools = input.assistantMessages.some((message) =>
|
||||
message.parts.some((part) => part.type === 'tool')
|
||||
);
|
||||
const hasReasoning = input.assistantMessages.some((message) =>
|
||||
message.parts.some((part) => part.type === 'reasoning' && Boolean(getPartText(part)))
|
||||
);
|
||||
|
||||
input.assistantMessages.forEach((message) => {
|
||||
message.parts.forEach((part, partIndex) => {
|
||||
if (part.type === 'tool') {
|
||||
activityParts.push({
|
||||
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
|
||||
kind: 'tool',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === 'reasoning') {
|
||||
const text = getPartText(part);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
activityParts.push({
|
||||
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
|
||||
kind: 'reasoning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!input.showTextJustificationActivity || part.type !== 'text') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAssistantMessageCompleted(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finish = getMessageFinish(message);
|
||||
if (!finish) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (finish === 'stop') {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = getPartText(part);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityParts.push({
|
||||
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
|
||||
kind: 'justification',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const activitySegments: TurnActivityGroup[] = [];
|
||||
const activityByPart = new WeakMap<object, TurnActivityRecord>();
|
||||
activityParts.forEach((activity) => {
|
||||
activityByPart.set(activity.part as object, activity);
|
||||
});
|
||||
let hasTools = false;
|
||||
let hasReasoning = false;
|
||||
|
||||
const taskMessageById = new Map<string, string>();
|
||||
const taskOrder: string[] = [];
|
||||
@@ -151,32 +90,66 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
|
||||
let currentAfterToolPartId: string | null = null;
|
||||
|
||||
input.assistantMessages.forEach((message) => {
|
||||
const messageId = message.info.id;
|
||||
const messageCompleted = isAssistantMessageCompleted(message);
|
||||
const finish = getMessageFinish(message);
|
||||
|
||||
message.parts.forEach((part, partIndex) => {
|
||||
if (part.type === 'tool') {
|
||||
const toolName = (part as { tool?: unknown }).tool;
|
||||
if (isStandaloneTool(toolName)) {
|
||||
const toolPartId = part.id ?? `${messageId}-part-${partIndex}-${part.type}`;
|
||||
if (!taskMessageById.has(toolPartId)) {
|
||||
taskMessageById.set(toolPartId, messageId);
|
||||
taskOrder.push(toolPartId);
|
||||
}
|
||||
currentAfterToolPartId = toolPartId;
|
||||
return;
|
||||
}
|
||||
const isTool = part.type === 'tool';
|
||||
if (isTool) {
|
||||
hasTools = true;
|
||||
}
|
||||
|
||||
const activity = activityByPart.get(part as object);
|
||||
if (!activity) {
|
||||
const text = part.type === 'reasoning' || part.type === 'text'
|
||||
? getPartText(part)
|
||||
: undefined;
|
||||
|
||||
if (part.type === 'reasoning' && text) {
|
||||
hasReasoning = true;
|
||||
}
|
||||
|
||||
const toolName = isTool
|
||||
? (part as { tool?: unknown }).tool
|
||||
: undefined;
|
||||
const standaloneTool = isTool && isStandaloneTool(toolName);
|
||||
if (standaloneTool) {
|
||||
const toolPartId = part.id ?? `${message.info.id}-part-${partIndex}-${part.type}`;
|
||||
if (!taskMessageById.has(toolPartId)) {
|
||||
taskMessageById.set(toolPartId, message.info.id);
|
||||
taskOrder.push(toolPartId);
|
||||
}
|
||||
currentAfterToolPartId = toolPartId;
|
||||
}
|
||||
|
||||
let kind: TurnActivityRecord['kind'] | null = null;
|
||||
if (isTool) {
|
||||
kind = 'tool';
|
||||
} else if (part.type === 'reasoning') {
|
||||
if (text) {
|
||||
kind = 'reasoning';
|
||||
}
|
||||
} else if (
|
||||
input.showTextJustificationActivity
|
||||
&& part.type === 'text'
|
||||
&& messageCompleted
|
||||
&& typeof finish === 'string'
|
||||
&& finish !== 'stop'
|
||||
&& text
|
||||
) {
|
||||
kind = 'justification';
|
||||
}
|
||||
|
||||
if (!kind) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activity.kind === 'tool') {
|
||||
const toolName = (activity.part as { tool?: unknown }).tool;
|
||||
if (isStandaloneTool(toolName)) {
|
||||
return;
|
||||
}
|
||||
const activity: TurnActivityRecord = {
|
||||
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
|
||||
kind,
|
||||
};
|
||||
activityParts.push(activity);
|
||||
|
||||
if (kind === 'tool' && standaloneTool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = partsByAfterTool.get(currentAfterToolPartId) ?? [];
|
||||
@@ -185,6 +158,8 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
|
||||
});
|
||||
});
|
||||
|
||||
const activitySegments: TurnActivityGroup[] = [];
|
||||
|
||||
const pickStartAnchor = (segmentParts: TurnActivityRecord[]): string | undefined => {
|
||||
if (segmentParts.length === 0) {
|
||||
return undefined;
|
||||
|
||||
@@ -24,6 +24,12 @@ interface SelectionPayload {
|
||||
|
||||
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
|
||||
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
|
||||
const BLOCK_TAGS = new Set([
|
||||
'address', 'article', 'aside', 'blockquote', 'dd', 'div', 'dl', 'dt',
|
||||
'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
|
||||
'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre',
|
||||
'section', 'table', 'ul',
|
||||
]);
|
||||
|
||||
const normalizeLineBreaks = (value: string): string => value.replace(/\r\n?/g, '\n');
|
||||
|
||||
@@ -103,6 +109,11 @@ const renderBlockMarkdownNode = (node: Node): string => {
|
||||
return `\`\`\`${language}\n${code}\n\`\`\``;
|
||||
}
|
||||
|
||||
if (tag === 'code') {
|
||||
const code = normalizeLineBreaks(element.textContent || '').trim();
|
||||
return code ? `\`${code.replace(/`/g, '\\`')}\`` : '';
|
||||
}
|
||||
|
||||
if (tag === 'ul') return renderListMarkdown(element, false);
|
||||
if (tag === 'ol') return renderListMarkdown(element, true);
|
||||
|
||||
@@ -137,8 +148,34 @@ const renderBlockMarkdownNode = (node: Node): string => {
|
||||
return trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
|
||||
};
|
||||
|
||||
const isInlineSelectionFragment = (fragment: DocumentFragment): boolean => {
|
||||
return Array.from(fragment.childNodes).every((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return true;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const element = node as HTMLElement;
|
||||
return !BLOCK_TAGS.has(element.tagName.toLowerCase());
|
||||
});
|
||||
};
|
||||
|
||||
const rangeToMarkdown = (range: Range, plainText: string): string => {
|
||||
const fragment = range.cloneContents();
|
||||
|
||||
if (isInlineSelectionFragment(fragment)) {
|
||||
const inlineMarkdown = trimSelectionValue(
|
||||
Array.from(fragment.childNodes)
|
||||
.map((node) => renderInlineMarkdownNode(node))
|
||||
.join('')
|
||||
);
|
||||
if (inlineMarkdown) {
|
||||
return inlineMarkdown;
|
||||
}
|
||||
}
|
||||
|
||||
const markdown = Array.from(fragment.childNodes)
|
||||
.map((node) => renderBlockMarkdownNode(node))
|
||||
.filter((value) => value.length > 0)
|
||||
@@ -436,6 +473,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
)}
|
||||
style={{
|
||||
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
|
||||
backdropFilter: 'blur(28px)',
|
||||
WebkitBackdropFilter: 'blur(28px)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
@@ -507,6 +546,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
style={{
|
||||
backdropFilter: 'blur(28px)',
|
||||
WebkitBackdropFilter: 'blur(28px)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleAddToChat}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDurationTickerNow } from './useDurationTicker';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
||||
|
||||
@@ -65,19 +66,7 @@ const formatDuration = (start: number, end?: number, now: number = Date.now()):
|
||||
};
|
||||
|
||||
const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 100);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active]);
|
||||
const now = useDurationTickerNow(active, 250);
|
||||
|
||||
return <>{formatDuration(start, end, now)}</>;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { Text } from '@/components/ui/text';
|
||||
@@ -32,6 +33,7 @@ import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
|
||||
import { MinDurationShineText } from './MinDurationShineText';
|
||||
import { ToolRevealOnMount } from './ToolRevealOnMount';
|
||||
import { getToolIcon } from './toolPresentation';
|
||||
import { useDurationTickerNow } from './useDurationTicker';
|
||||
|
||||
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
|
||||
|
||||
@@ -144,6 +146,14 @@ const normalizeToolName = (toolName: string | undefined | null): string => {
|
||||
};
|
||||
|
||||
const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
|
||||
const TASK_TOOL_POLL_FAST_MS = 1200;
|
||||
const TASK_TOOL_POLL_IDLE_MS = 3200;
|
||||
const TASK_TOOL_POLL_HIDDEN_MS = 6000;
|
||||
const TASK_TOOL_INITIAL_FETCH_LIMIT = 500;
|
||||
const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160;
|
||||
const TASK_TOOL_IDLE_FETCH_LIMIT = 80;
|
||||
const TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS = 3;
|
||||
const TASK_TOOL_SETTLE_GRACE_MS = 2500;
|
||||
|
||||
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
|
||||
const duration = Math.min(Math.max(0, (end ?? now) - start), MAX_DURATION_MS);
|
||||
@@ -154,17 +164,7 @@ const formatDuration = (start: number, end?: number, now: number = Date.now()) =
|
||||
};
|
||||
|
||||
const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active]);
|
||||
const now = useDurationTickerNow(active, 250);
|
||||
|
||||
return <>{formatDuration(start, end, now)}</>;
|
||||
};
|
||||
@@ -642,6 +642,41 @@ const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[])
|
||||
return entries;
|
||||
};
|
||||
|
||||
const buildTaskSessionMessagesSignature = (messages: SessionMessageWithParts[]): string => {
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : '';
|
||||
const lastMessageUpdated =
|
||||
typeof lastMessage?.info?.time?.completed === 'number'
|
||||
? lastMessage.info.time.completed
|
||||
: typeof lastMessage?.info?.time?.created === 'number'
|
||||
? lastMessage.info.time.created
|
||||
: 0;
|
||||
const lastParts = Array.isArray(lastMessage?.parts) ? lastMessage.parts : [];
|
||||
const lastPart = lastParts[lastParts.length - 1] as Record<string, unknown> | undefined;
|
||||
const tailType = typeof lastPart?.type === 'string' ? lastPart.type : '';
|
||||
const tailId = typeof lastPart?.id === 'string' ? lastPart.id : '';
|
||||
const tailTextLength = (() => {
|
||||
const textCandidate = lastPart?.text;
|
||||
if (typeof textCandidate === 'string') {
|
||||
return textCandidate.length;
|
||||
}
|
||||
const stateCandidate = lastPart?.state;
|
||||
if (stateCandidate && typeof stateCandidate === 'object') {
|
||||
const stateStatus = (stateCandidate as Record<string, unknown>).status;
|
||||
if (typeof stateStatus === 'string') {
|
||||
return stateStatus.length;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
|
||||
return `${messages.length}:${lastMessageId}:${lastMessageUpdated}:${lastParts.length}:${tailType}:${tailId}:${tailTextLength}`;
|
||||
};
|
||||
|
||||
const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
|
||||
const title = entry.state?.title;
|
||||
if (typeof title === 'string' && title.trim().length > 0) {
|
||||
@@ -687,7 +722,16 @@ const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return trimmed.includes('/') || trimmed.includes('\\');
|
||||
if (trimmed.includes('/') || trimmed.includes('\\')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const baseName = trimmed.split(/[\\/]/).pop() || trimmed;
|
||||
if (baseName.startsWith('.') || baseName.includes('.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^[A-Za-z0-9_-]+$/.test(baseName);
|
||||
};
|
||||
|
||||
const stripTaskMetadataFromOutput = (output: string): string => {
|
||||
@@ -1692,6 +1736,69 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
return false;
|
||||
}, [childSessionMessages, isTaskTool, taskSessionId]);
|
||||
|
||||
const childSessionActivity = useSessionActivity(taskSessionId);
|
||||
const [taskChildSeenActive, setTaskChildSeenActive] = React.useState(false);
|
||||
const [taskChildPollingStopped, setTaskChildPollingStopped] = React.useState(false);
|
||||
|
||||
const taskPollNoChangeCountRef = React.useRef(0);
|
||||
const taskPollLastSignatureRef = React.useRef<string>('');
|
||||
|
||||
React.useEffect(() => {
|
||||
setTaskChildSeenActive(false);
|
||||
setTaskChildPollingStopped(false);
|
||||
taskPollNoChangeCountRef.current = 0;
|
||||
taskPollLastSignatureRef.current = '';
|
||||
}, [taskSessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTaskTool || !taskSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const childSessionIsActive =
|
||||
childSessionActivity.phase === 'busy'
|
||||
|| childSessionActivity.phase === 'retry'
|
||||
|| childSessionHasInFlightTools
|
||||
|| (!isFinalized && activeLatched);
|
||||
|
||||
if (childSessionIsActive) {
|
||||
if (!taskChildSeenActive) {
|
||||
setTaskChildSeenActive(true);
|
||||
}
|
||||
if (taskChildPollingStopped) {
|
||||
setTaskChildPollingStopped(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!taskChildSeenActive || taskChildPollingStopped || childSessionTaskSummaryEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
setTaskChildPollingStopped(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setTaskChildPollingStopped(true);
|
||||
}, TASK_TOOL_SETTLE_GRACE_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [
|
||||
childSessionActivity.phase,
|
||||
childSessionHasInFlightTools,
|
||||
childSessionTaskSummaryEntries.length,
|
||||
activeLatched,
|
||||
isFinalized,
|
||||
isTaskTool,
|
||||
taskChildPollingStopped,
|
||||
taskChildSeenActive,
|
||||
taskSessionId,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') {
|
||||
setLocalFinalizedAt(undefined);
|
||||
@@ -1730,7 +1837,10 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldPoll = isActive || childSessionHasInFlightTools || childSessionTaskSummaryEntries.length === 0;
|
||||
const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry';
|
||||
const shouldPoll =
|
||||
!taskChildPollingStopped
|
||||
&& (isActive || childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
|
||||
const shouldFetchSnapshot = childSessionTaskSummaryEntries.length === 0 || shouldPoll;
|
||||
if (!shouldFetchSnapshot) {
|
||||
return;
|
||||
@@ -1739,33 +1849,83 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
let cancelled = false;
|
||||
let pollTimer: number | undefined;
|
||||
|
||||
const fetchSessionMessages = async () => {
|
||||
const isVisible = () => {
|
||||
if (typeof document === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
return document.visibilityState === 'visible';
|
||||
};
|
||||
|
||||
const resolveFetchLimit = (isInitialFetch: boolean) => {
|
||||
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
|
||||
return TASK_TOOL_INITIAL_FETCH_LIMIT;
|
||||
}
|
||||
if (isActive || childSessionHasInFlightTools || childSessionActive) {
|
||||
return TASK_TOOL_ACTIVE_FETCH_LIMIT;
|
||||
}
|
||||
return TASK_TOOL_IDLE_FETCH_LIMIT;
|
||||
};
|
||||
|
||||
const resolvePollDelay = () => {
|
||||
if (!isVisible()) {
|
||||
return TASK_TOOL_POLL_HIDDEN_MS;
|
||||
}
|
||||
if (taskPollNoChangeCountRef.current >= TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS) {
|
||||
return TASK_TOOL_POLL_IDLE_MS;
|
||||
}
|
||||
return TASK_TOOL_POLL_FAST_MS;
|
||||
};
|
||||
|
||||
const scheduleNextPoll = () => {
|
||||
if (!shouldPoll || typeof window === 'undefined' || cancelled) {
|
||||
return;
|
||||
}
|
||||
pollTimer = window.setTimeout(() => {
|
||||
pollTimer = undefined;
|
||||
void fetchSessionMessages(false);
|
||||
}, resolvePollDelay());
|
||||
};
|
||||
|
||||
const fetchSessionMessages = async (isInitialFetch: boolean) => {
|
||||
try {
|
||||
const messages = await opencodeClient.getSessionMessages(taskSessionId, 500);
|
||||
const messages = await opencodeClient.getSessionMessages(taskSessionId, resolveFetchLimit(isInitialFetch));
|
||||
if (cancelled || !Array.isArray(messages) || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSignature = buildTaskSessionMessagesSignature(messages as SessionMessageWithParts[]);
|
||||
if (nextSignature === taskPollLastSignatureRef.current) {
|
||||
taskPollNoChangeCountRef.current += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
taskPollLastSignatureRef.current = nextSignature;
|
||||
taskPollNoChangeCountRef.current = 0;
|
||||
useSessionStore.getState().syncMessages(taskSessionId, messages);
|
||||
} catch {
|
||||
// Ignore transient subagent fetch errors.
|
||||
} finally {
|
||||
scheduleNextPoll();
|
||||
}
|
||||
};
|
||||
|
||||
void fetchSessionMessages();
|
||||
|
||||
if (shouldPoll && typeof window !== 'undefined') {
|
||||
pollTimer = window.setInterval(() => {
|
||||
void fetchSessionMessages();
|
||||
}, 1200);
|
||||
}
|
||||
void fetchSessionMessages(true);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (typeof pollTimer === 'number') {
|
||||
window.clearInterval(pollTimer);
|
||||
window.clearTimeout(pollTimer);
|
||||
}
|
||||
};
|
||||
}, [childSessionHasInFlightTools, childSessionTaskSummaryEntries.length, isActive, isTaskTool, taskSessionId]);
|
||||
}, [
|
||||
childSessionActivity.phase,
|
||||
childSessionHasInFlightTools,
|
||||
childSessionTaskSummaryEntries.length,
|
||||
isActive,
|
||||
isTaskTool,
|
||||
taskChildPollingStopped,
|
||||
taskSessionId,
|
||||
]);
|
||||
|
||||
|
||||
const taskSummaryLenRef = React.useRef<number>(taskSummaryEntries.length);
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
|
||||
return;
|
||||
}
|
||||
target.style.opacity = '';
|
||||
target.style.filter = '';
|
||||
// target.style.filter = '';
|
||||
target.style.transform = '';
|
||||
target.style.maskImage = '';
|
||||
target.style.webkitMaskImage = '';
|
||||
@@ -61,7 +61,7 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
|
||||
CSS.supports('-webkit-mask-image', 'linear-gradient(to right, black, transparent)'));
|
||||
|
||||
el.style.opacity = '0';
|
||||
el.style.filter = wipe ? 'blur(3px)' : 'blur(2px)';
|
||||
// el.style.filter = wipe ? 'blur(3px)' : 'blur(2px)';
|
||||
el.style.transform = wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)';
|
||||
|
||||
if (maskSupported) {
|
||||
@@ -84,16 +84,15 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
|
||||
|
||||
const keyframes: Keyframe[] = maskSupported
|
||||
? [
|
||||
{ opacity: 0, filter: 'blur(3px)', transform: 'translateX(-0.06em)', maskPosition: '100% 0%' },
|
||||
{ opacity: 1, filter: 'blur(0px)', transform: 'translateX(0)', maskPosition: '0% 0%' },
|
||||
{ opacity: 0, transform: 'translateX(-0.06em)', maskPosition: '100% 0%' },
|
||||
{ opacity: 1, transform: 'translateX(0)', maskPosition: '0% 0%' },
|
||||
]
|
||||
: [
|
||||
{
|
||||
opacity: 0,
|
||||
filter: wipe ? 'blur(3px)' : 'blur(2px)',
|
||||
transform: wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)',
|
||||
},
|
||||
{ opacity: 1, filter: 'blur(0px)', transform: wipe ? 'translateX(0)' : 'translateY(0)' },
|
||||
{ opacity: 1, transform: wipe ? 'translateX(0)' : 'translateY(0)' },
|
||||
];
|
||||
|
||||
animation = node.animate(keyframes, {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
|
||||
type Subscriber = (now: number) => void;
|
||||
|
||||
type TickerChannel = {
|
||||
subscribers: Set<Subscriber>;
|
||||
timerId: number | null;
|
||||
};
|
||||
|
||||
const tickerChannels = new Map<number, TickerChannel>();
|
||||
|
||||
const getTickerChannel = (intervalMs: number): TickerChannel => {
|
||||
const existing = tickerChannels.get(intervalMs);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created: TickerChannel = {
|
||||
subscribers: new Set<Subscriber>(),
|
||||
timerId: null,
|
||||
};
|
||||
tickerChannels.set(intervalMs, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const subscribeToTicker = (intervalMs: number, subscriber: Subscriber): (() => void) => {
|
||||
const channel = getTickerChannel(intervalMs);
|
||||
channel.subscribers.add(subscriber);
|
||||
subscriber(Date.now());
|
||||
|
||||
if (channel.timerId === null && typeof window !== 'undefined') {
|
||||
channel.timerId = window.setInterval(() => {
|
||||
const now = Date.now();
|
||||
channel.subscribers.forEach((listener) => {
|
||||
listener(now);
|
||||
});
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const tracked = tickerChannels.get(intervalMs);
|
||||
if (!tracked) {
|
||||
return;
|
||||
}
|
||||
|
||||
tracked.subscribers.delete(subscriber);
|
||||
if (tracked.subscribers.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tracked.timerId !== null && typeof window !== 'undefined') {
|
||||
window.clearInterval(tracked.timerId);
|
||||
}
|
||||
tickerChannels.delete(intervalMs);
|
||||
};
|
||||
};
|
||||
|
||||
export const useDurationTickerNow = (active: boolean, intervalMs: number = 250): number => {
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
return subscribeToTicker(intervalMs, setNow);
|
||||
}, [active, intervalMs]);
|
||||
|
||||
return now;
|
||||
};
|
||||
@@ -681,14 +681,14 @@ export function DesktopHostSwitcherDialog({
|
||||
const content = (
|
||||
<>
|
||||
{embedded ? (
|
||||
<div className="flex-shrink-0 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-2.5">
|
||||
<div className="flex-shrink-0 border-b border-[var(--interactive-border)] px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<span className="typography-ui-header font-semibold text-foreground">Current</span>
|
||||
<span className="max-w-[9rem] truncate typography-ui-label text-muted-foreground">{redactSensitiveUrl(current.label)}</span>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span className="typography-ui-header font-semibold text-foreground">Default</span>
|
||||
<span className="max-w-[9rem] truncate typography-ui-label text-muted-foreground">{redactSensitiveUrl(currentDefaultLabel)}</span>
|
||||
<div className="min-w-0 flex items-baseline gap-1.5 typography-ui-label">
|
||||
<span className="font-medium text-foreground">Current</span>
|
||||
<span className="max-w-[9rem] truncate text-muted-foreground">{redactSensitiveUrl(current.label)}</span>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span className="font-medium text-foreground">Default</span>
|
||||
<span className="max-w-[9rem] truncate text-muted-foreground">{redactSensitiveUrl(currentDefaultLabel)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -741,14 +741,12 @@ export function DesktopHostSwitcherDialog({
|
||||
)}
|
||||
|
||||
{tauriAvailable && (
|
||||
<div className="flex-shrink-0 rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-micro text-muted-foreground">Need SSH instances? Manage them in Settings.</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={openRemoteInstancesSettings}>
|
||||
<RiSettings3Line className="h-4 w-4" />
|
||||
Remote SSH
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center justify-between gap-2 px-2.5 py-1.5">
|
||||
<span className="typography-micro text-muted-foreground">Need SSH instances?<br />Manage them in Settings.</span>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={openRemoteInstancesSettings}>
|
||||
<RiSettings3Line className="h-4 w-4" />
|
||||
Remote SSH
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
if (!parentHeight || parentHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
const next = Math.round(parentHeight);
|
||||
const next = Math.max(0, Math.round(parentHeight));
|
||||
setFullscreenHeight((prev) => (prev === next ? prev : next));
|
||||
};
|
||||
|
||||
@@ -103,7 +103,7 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
}
|
||||
|
||||
const appliedHeight = isOpen
|
||||
? (isFullscreen ? Math.max(standardHeight, fullscreenHeight ?? standardHeight) : standardHeight)
|
||||
? (isFullscreen ? Math.max(0, fullscreenHeight ?? standardHeight) : standardHeight)
|
||||
: 0;
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent) => {
|
||||
@@ -146,8 +146,8 @@ export const BottomTerminalDock: React.FC<BottomTerminalDockProps> = ({ isOpen,
|
||||
{isOpen && !isFullscreen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-0 z-20 h-[4px] w-full cursor-row-resize hover:bg-primary/50 transition-colors',
|
||||
isResizing && 'bg-primary'
|
||||
'absolute left-0 top-0 z-20 h-[3px] w-full cursor-row-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
|
||||
isResizing && 'bg-[var(--interactive-border)]'
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
role="separator"
|
||||
|
||||
@@ -427,7 +427,7 @@ export const ContextPanel: React.FC = () => {
|
||||
const isFileTabActive = activeTab?.mode === 'file';
|
||||
|
||||
const header = (
|
||||
<header className="flex h-8 items-stretch border-b border-border/40">
|
||||
<header className="flex h-10 items-stretch border-b border-transparent">
|
||||
<SortableTabsStrip
|
||||
items={tabItems}
|
||||
activeId={activeTab?.id ?? null}
|
||||
@@ -450,8 +450,11 @@ export const ContextPanel: React.FC = () => {
|
||||
reorderContextPanelTabs(directoryKey, activeTabID, overTabID);
|
||||
}}
|
||||
layoutMode="scrollable"
|
||||
variant="active-pill"
|
||||
activePillLowercase={false}
|
||||
activePillInsetClassName="gap-0.5 pt-0.5 pb-1.5"
|
||||
/>
|
||||
<div className="flex items-center gap-1 px-1.5">
|
||||
<div className="flex items-end gap-1 px-1.5 pb-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -515,8 +518,8 @@ export const ContextPanel: React.FC = () => {
|
||||
{!isExpanded && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-0 z-20 h-full w-[4px] cursor-col-resize transition-colors hover:bg-primary/50',
|
||||
isResizing && 'bg-primary'
|
||||
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize transition-colors hover:bg-[var(--interactive-border)]/80',
|
||||
isResizing && 'bg-[var(--interactive-border)]'
|
||||
)}
|
||||
onPointerDown={handleResizeStart}
|
||||
onPointerMove={handleResizeMove}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,8 @@ import React, { useRef, useEffect } from 'react';
|
||||
import { motion, useMotionValue, animate } from 'motion/react';
|
||||
import { Header } from './Header';
|
||||
import { BottomTerminalDock } from './BottomTerminalDock';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { NavRail } from './NavRail';
|
||||
import { RightSidebar } from './RightSidebar';
|
||||
import { Sidebar, SIDEBAR_CONTENT_WIDTH } from './Sidebar';
|
||||
import { RightSidebar, RIGHT_SIDEBAR_CONTENT_WIDTH } from './RightSidebar';
|
||||
import { RightSidebarTabs } from './RightSidebarTabs';
|
||||
import { ContextPanel } from './ContextPanel';
|
||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
@@ -22,11 +21,16 @@ import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
|
||||
import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow } from '@/components/views';
|
||||
|
||||
// Mobile drawer width as screen percentage
|
||||
const MOBILE_DRAWER_WIDTH_PERCENT = 85;
|
||||
const DESKTOP_SIDEBAR_MIN_WIDTH = 250;
|
||||
const DESKTOP_SIDEBAR_MAX_WIDTH = 500;
|
||||
const DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH = 400;
|
||||
const DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH = 860;
|
||||
|
||||
const normalizeDirectoryKey = (value: string): string => {
|
||||
if (!value) return '';
|
||||
@@ -69,6 +73,10 @@ export const MainLayout: React.FC = () => {
|
||||
} = useUIStore();
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
||||
const sidebarWidth = useUIStore((state) => state.sidebarWidth);
|
||||
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
|
||||
const [desktopRightSidebarActionsHost, setDesktopRightSidebarActionsHost] = React.useState<HTMLDivElement | null>(null);
|
||||
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
||||
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
|
||||
const isContextPanelOpen = useUIStore((state) => {
|
||||
@@ -590,6 +598,14 @@ export const MainLayout: React.FC = () => {
|
||||
}, [activeMainTab]);
|
||||
|
||||
const isChatActive = activeMainTab === 'chat';
|
||||
const visibleSidebarWidth = React.useMemo(() => {
|
||||
const rawWidth = sidebarWidth || SIDEBAR_CONTENT_WIDTH;
|
||||
return Math.min(DESKTOP_SIDEBAR_MAX_WIDTH, Math.max(DESKTOP_SIDEBAR_MIN_WIDTH, rawWidth));
|
||||
}, [sidebarWidth]);
|
||||
const visibleRightSidebarWidth = React.useMemo(() => {
|
||||
const rawWidth = rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH;
|
||||
return Math.min(DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH, Math.max(DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH, rawWidth));
|
||||
}, [rightSidebarWidth]);
|
||||
|
||||
return (
|
||||
<DiffWorkerProvider>
|
||||
@@ -597,7 +613,7 @@ export const MainLayout: React.FC = () => {
|
||||
className={cn(
|
||||
'main-content-safe-area h-[100dvh]',
|
||||
isMobile ? 'flex flex-col' : 'flex',
|
||||
'bg-background'
|
||||
isDesktopShellRuntime ? 'bg-transparent' : 'bg-background'
|
||||
)}
|
||||
>
|
||||
<CommandPalette />
|
||||
@@ -654,7 +670,7 @@ export const MainLayout: React.FC = () => {
|
||||
opacity: mobileLeftDrawerOpen || isRightSidebarOpen ? 1 : 0,
|
||||
pointerEvents: mobileLeftDrawerOpen || isRightSidebarOpen ? 'auto' : 'none',
|
||||
}}
|
||||
className="fixed inset-0 z-40 bg-black/50 cursor-default"
|
||||
className="fixed left-0 right-0 bottom-0 top-[var(--oc-header-height,56px)] z-40 bg-black/50 cursor-default"
|
||||
onClick={() => {
|
||||
setMobileLeftDrawerOpen(false);
|
||||
setRightSidebarOpen(false);
|
||||
@@ -696,15 +712,15 @@ export const MainLayout: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'fixed left-0 top-0 z-50 h-full bg-transparent',
|
||||
'fixed left-0 top-[var(--oc-header-height,56px)] z-50 h-[calc(100%-var(--oc-header-height,56px))] bg-transparent',
|
||||
'cursor-grab active:cursor-grabbing'
|
||||
)}
|
||||
aria-hidden={!mobileLeftDrawerOpen}
|
||||
>
|
||||
<div className="h-full overflow-hidden flex bg-sidebar shadow-none drawer-safe-area">
|
||||
<div onPointerDownCapture={(e) => e.stopPropagation()}>
|
||||
<NavRail className="shrink-0" mobile />
|
||||
</div>
|
||||
<div
|
||||
className="h-full overflow-hidden flex bg-[var(--surface-background)] shadow-none drawer-safe-area"
|
||||
style={{ backgroundImage: 'linear-gradient(var(--surface-muted), var(--surface-muted))' }}
|
||||
>
|
||||
<div className="flex-1 min-w-0 overflow-hidden flex flex-col">
|
||||
<ErrorBoundary>
|
||||
<SessionSidebar mobileVariant />
|
||||
@@ -747,7 +763,7 @@ export const MainLayout: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'fixed right-0 top-0 z-50 h-full bg-transparent',
|
||||
'fixed right-0 top-[var(--oc-header-height,56px)] z-50 h-[calc(100%-var(--oc-header-height,56px))] bg-transparent',
|
||||
'cursor-grab active:cursor-grabbing'
|
||||
)}
|
||||
aria-hidden={!isRightSidebarOpen}
|
||||
@@ -765,7 +781,6 @@ export const MainLayout: React.FC = () => {
|
||||
'flex flex-1 overflow-hidden relative',
|
||||
(isSettingsDialogOpen || isMultiRunLauncherOpen) && 'hidden'
|
||||
)}
|
||||
style={{ paddingTop: 'var(--oc-header-height, 56px)' }}
|
||||
>
|
||||
<main className="w-full h-full overflow-hidden bg-background relative">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
@@ -781,7 +796,10 @@ export const MainLayout: React.FC = () => {
|
||||
|
||||
{/* Mobile multi-run launcher: full screen */}
|
||||
{isMultiRunLauncherOpen && (
|
||||
<div className="absolute inset-0 z-10 bg-background header-safe-area">
|
||||
<div
|
||||
className="absolute inset-0 z-10 bg-background"
|
||||
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<MultiRunLauncher
|
||||
initialPrompt={multiRunLauncherPrefillPrompt}
|
||||
@@ -794,49 +812,143 @@ export const MainLayout: React.FC = () => {
|
||||
|
||||
{/* Mobile settings: full screen */}
|
||||
{isSettingsDialogOpen && (
|
||||
<div className="absolute inset-0 z-10 bg-background header-safe-area">
|
||||
<div
|
||||
className="absolute inset-0 z-10 bg-background"
|
||||
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
||||
>
|
||||
<ErrorBoundary><SettingsView onClose={() => setSettingsDialogOpen(false)} /></ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</DrawerProvider>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop: Header always on top, then Sidebar + Content below */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden relative">
|
||||
{/* Normal view: Header above Sidebar + content (like SettingsView) */}
|
||||
<div className={cn('absolute inset-0 flex flex-col', isMultiRunLauncherOpen && 'invisible')}>
|
||||
<Header />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<NavRail />
|
||||
<div className="flex flex-1 min-w-0 overflow-hidden border-t border-l border-border/50 rounded-tl-xl">
|
||||
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
|
||||
<SessionSidebar hideProjectSelector />
|
||||
</Sidebar>
|
||||
<div className="flex flex-1 min-w-0 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden">
|
||||
<main className="flex-1 overflow-hidden bg-background relative">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||
{/* Desktop: Sidebar is a left column; header belongs to content column */}
|
||||
<div className="flex flex-1 overflow-hidden relative">
|
||||
<div className={cn(
|
||||
'absolute inset-0 flex overflow-hidden',
|
||||
isDesktopShellRuntime
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar',
|
||||
isMultiRunLauncherOpen && 'invisible'
|
||||
)}>
|
||||
{isSidebarOpen ? (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-0 z-0',
|
||||
isDesktopShellRuntime
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar'
|
||||
)}
|
||||
style={{
|
||||
left: `${visibleSidebarWidth}px`,
|
||||
width: 'var(--radius-md)',
|
||||
height: 'var(--radius-md)',
|
||||
WebkitMaskImage: 'radial-gradient(circle at 100% 100%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
maskImage: 'radial-gradient(circle at 100% 100%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-0 z-0',
|
||||
isDesktopShellRuntime
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar'
|
||||
)}
|
||||
style={{
|
||||
left: `${visibleSidebarWidth}px`,
|
||||
width: 'var(--radius-md)',
|
||||
height: 'var(--radius-md)',
|
||||
WebkitMaskImage: 'radial-gradient(circle at 100% 0%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
maskImage: 'radial-gradient(circle at 100% 0%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{isRightSidebarOpen ? (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-0 z-0',
|
||||
isDesktopShellRuntime
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar'
|
||||
)}
|
||||
style={{
|
||||
right: `${visibleRightSidebarWidth}px`,
|
||||
width: 'var(--radius-md)',
|
||||
height: 'var(--radius-md)',
|
||||
WebkitMaskImage: 'radial-gradient(circle at 0 100%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
maskImage: 'radial-gradient(circle at 0 100%, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-0 z-0',
|
||||
isDesktopShellRuntime
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar'
|
||||
)}
|
||||
style={{
|
||||
right: `${visibleRightSidebarWidth}px`,
|
||||
width: 'var(--radius-md)',
|
||||
height: 'var(--radius-md)',
|
||||
WebkitMaskImage: 'radial-gradient(circle at 0 0, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
maskImage: 'radial-gradient(circle at 0 0, transparent calc(var(--radius-md) - 1px), black var(--radius-md))',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Sidebar
|
||||
isOpen={isSidebarOpen}
|
||||
isMobile={isMobile}
|
||||
className="border-0"
|
||||
>
|
||||
<SessionSidebar />
|
||||
</Sidebar>
|
||||
<div className={cn(
|
||||
'relative flex flex-1 min-w-0 flex-col overflow-hidden',
|
||||
isDesktopShellRuntime
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar',
|
||||
isSidebarOpen && 'border-l border-border/50 rounded-tl-md rounded-bl-md',
|
||||
isRightSidebarOpen && 'border-r border-border/50 rounded-tr-md rounded-br-md'
|
||||
)}>
|
||||
<Header desktopRightSidebarActionsHost={desktopRightSidebarActionsHost} />
|
||||
<div className={cn(
|
||||
'flex flex-1 min-h-0 overflow-hidden',
|
||||
isSidebarOpen || isChatActive ? '' : 'border-l border-border/50',
|
||||
isRightSidebarOpen ? '' : 'border-r border-border/50'
|
||||
)}>
|
||||
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden">
|
||||
<main className="flex-1 overflow-hidden bg-background relative">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||
</div>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
||||
</div>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<ContextPanel />
|
||||
</div>
|
||||
<RightSidebar isOpen={isRightSidebarOpen}>
|
||||
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
|
||||
</RightSidebar>
|
||||
)}
|
||||
</main>
|
||||
<ContextPanel />
|
||||
</div>
|
||||
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
|
||||
<ErrorBoundary><TerminalView /></ErrorBoundary>
|
||||
</BottomTerminalDock>
|
||||
</div>
|
||||
</div>
|
||||
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
|
||||
<ErrorBoundary><TerminalView /></ErrorBoundary>
|
||||
</BottomTerminalDock>
|
||||
</div>
|
||||
<RightSidebar
|
||||
isOpen={isRightSidebarOpen}
|
||||
className="border-0"
|
||||
onTopActionsHostChange={setDesktopRightSidebarActionsHost}
|
||||
>
|
||||
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
|
||||
</RightSidebar>
|
||||
</div>
|
||||
|
||||
{/* Multi-Run Launcher: replaces tabs content only */}
|
||||
|
||||
@@ -1,919 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type Modifier,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
RiFolderAddLine,
|
||||
RiSettings3Line,
|
||||
RiQuestionLine,
|
||||
RiDownloadLine,
|
||||
RiInformationLine,
|
||||
RiPencilLine,
|
||||
RiCloseLine,
|
||||
RiMenuFoldLine,
|
||||
RiMenuUnfoldLine,
|
||||
} from '@remixicon/react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
|
||||
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { cn, formatDirectoryName, hasModifier } from '@/lib/utils';
|
||||
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, requestDirectoryAccess } from '@/lib/desktop';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) return '';
|
||||
const replaced = value.replace(/\\/g, '/');
|
||||
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
const NAV_RAIL_WIDTH = 56;
|
||||
const NAV_RAIL_EXPANDED_WIDTH = 200;
|
||||
const NAV_RAIL_TEXT_FADE_MS = 180;
|
||||
const PROJECT_TEXT_FADE_IN_DELAY_MS = 24;
|
||||
const ACTION_TEXT_FADE_IN_DELAY_MS = 60;
|
||||
|
||||
type NavRailActionButtonProps = {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
icon: React.ReactNode;
|
||||
tooltipLabel: string;
|
||||
shortcutHint?: string;
|
||||
showExpandedShortcutHint?: boolean;
|
||||
buttonClassName: string;
|
||||
showExpandedContent: boolean;
|
||||
actionTextVisible: boolean;
|
||||
};
|
||||
|
||||
const NavRailActionButton: React.FC<NavRailActionButtonProps> = ({
|
||||
onClick,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
icon,
|
||||
tooltipLabel,
|
||||
shortcutHint,
|
||||
showExpandedShortcutHint = true,
|
||||
buttonClassName,
|
||||
showExpandedContent,
|
||||
actionTextVisible,
|
||||
}) => {
|
||||
const pointerTriggeredRef = React.useRef(false);
|
||||
const pointerPressRef = React.useRef<{ active: boolean; pointerId: number | null }>({
|
||||
active: false,
|
||||
pointerId: null,
|
||||
});
|
||||
|
||||
const handlePointerDown = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (disabled || event.button !== 0) {
|
||||
pointerPressRef.current = { active: false, pointerId: null };
|
||||
return;
|
||||
}
|
||||
pointerPressRef.current = { active: true, pointerId: event.pointerId };
|
||||
}, [disabled]);
|
||||
|
||||
const clearPointerPress = React.useCallback(() => {
|
||||
pointerPressRef.current = { active: false, pointerId: null };
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (disabled) return;
|
||||
if (event.button !== 0) return;
|
||||
const pointerPress = pointerPressRef.current;
|
||||
if (!pointerPress.active || pointerPress.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
clearPointerPress();
|
||||
pointerTriggeredRef.current = true;
|
||||
onClick();
|
||||
}, [clearPointerPress, disabled, onClick]);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (disabled) return;
|
||||
if (pointerTriggeredRef.current) {
|
||||
pointerTriggeredRef.current = false;
|
||||
return;
|
||||
}
|
||||
onClick();
|
||||
}, [disabled, onClick]);
|
||||
|
||||
const btn = (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={clearPointerPress}
|
||||
onPointerLeave={clearPointerPress}
|
||||
onClick={handleClick}
|
||||
className={buttonClassName}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{showExpandedContent && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 left-[6px] right-[5px] rounded-lg bg-transparent transition-colors group-hover:bg-[var(--interactive-hover)]/50"
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 flex size-8 basis-8 shrink-0 grow-0 items-center justify-center">
|
||||
{icon}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden={!actionTextVisible}
|
||||
className={cn(
|
||||
'relative z-10 min-w-0 flex items-center justify-between gap-1 overflow-hidden transition-opacity duration-[180ms] ease-in-out',
|
||||
showExpandedContent ? 'flex-1' : 'w-0 flex-none',
|
||||
actionTextVisible ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left text-[13px]">{tooltipLabel}</span>
|
||||
{shortcutHint && showExpandedShortcutHint && (
|
||||
<span className="shrink-0 text-[10px] text-[var(--surface-mutedForeground)] opacity-70">
|
||||
{shortcutHint}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={400}>
|
||||
<TooltipTrigger asChild>{btn}</TooltipTrigger>
|
||||
{!showExpandedContent && (
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>{shortcutHint ? `${tooltipLabel} (${shortcutHint})` : tooltipLabel}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
/** Tinted background for project tiles — uses project color at low opacity, or neutral fallback */
|
||||
const TileBackground: React.FC<{ colorVar: string | null; children: React.ReactNode }> = ({
|
||||
colorVar,
|
||||
children,
|
||||
}) => (
|
||||
<span
|
||||
className="relative flex h-full w-full items-center justify-center rounded-lg overflow-hidden"
|
||||
style={{ backgroundColor: 'var(--surface-muted)' }}
|
||||
>
|
||||
{colorVar && (
|
||||
<span
|
||||
className="absolute inset-0 opacity-15"
|
||||
style={{ backgroundColor: colorVar }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10 flex items-center justify-center">
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
/** First-letter avatar fallback */
|
||||
const LetterAvatar: React.FC<{ label: string; color?: string | null }> = ({
|
||||
label,
|
||||
color,
|
||||
}) => {
|
||||
const letter = label.charAt(0).toUpperCase() || '?';
|
||||
const colorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null;
|
||||
return (
|
||||
<span
|
||||
className="flex h-4 w-4 items-center justify-center text-[15px] font-medium leading-none select-none"
|
||||
style={{ color: colorVar ?? 'var(--surface-foreground)', fontFamily: 'var(--font-mono, monospace)' }}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectStatusDots: React.FC<{
|
||||
color: string;
|
||||
variant?: 'streaming' | 'attention' | 'none';
|
||||
size?: 'sm' | 'md';
|
||||
}> = ({ color, variant = 'none', size = 'md' }) => (
|
||||
<span className="inline-flex items-center justify-center gap-px" aria-hidden="true">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<span key={index} className="inline-flex h-[3px] w-[3px] items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
size === 'sm' ? 'h-[2.5px] w-[2.5px]' : 'h-[3px] w-[3px]',
|
||||
'rounded-full',
|
||||
variant === 'streaming' && 'animate-grid-pulse',
|
||||
variant === 'attention' && 'animate-attention-diamond-pulse'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
animationDelay: variant === 'streaming'
|
||||
? `${index * 150}ms`
|
||||
: variant === 'attention'
|
||||
? (index === 1 ? '0ms' : '130ms')
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
|
||||
/** Single project tile in the nav rail — right-click for context menu (no visible 3-dot) */
|
||||
const ProjectTile: React.FC<{
|
||||
project: ProjectEntry;
|
||||
isActive: boolean;
|
||||
hasStreaming: boolean;
|
||||
hasUnread: boolean;
|
||||
label: string;
|
||||
expanded: boolean;
|
||||
projectTextVisible: boolean;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onClose: () => void;
|
||||
}> = ({ project, isActive, hasStreaming, hasUnread, label, expanded, projectTextVisible, onClick, onEdit, onClose }) => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [iconImageFailed, setIconImageFailed] = React.useState(false);
|
||||
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const projectIconImageUrl = !iconImageFailed
|
||||
? getProjectIconImageUrl(project, {
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
})
|
||||
: null;
|
||||
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
|
||||
const showStreamingDots = hasStreaming;
|
||||
const showAttentionDots = !hasStreaming && hasUnread;
|
||||
|
||||
React.useEffect(() => {
|
||||
setIconImageFailed(false);
|
||||
}, [project.id, project.iconImage?.updatedAt]);
|
||||
|
||||
const longPressHandlers = useLongPress({
|
||||
onLongPress: () => setMenuOpen(true),
|
||||
onTap: onClick,
|
||||
});
|
||||
|
||||
const iconElement = (
|
||||
<TileBackground colorVar={projectColorVar}>
|
||||
<span className="relative h-full w-full leading-none">
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
{projectIconImageUrl ? (
|
||||
<span
|
||||
className="inline-flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]"
|
||||
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
||||
>
|
||||
<img
|
||||
src={projectIconImageUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
draggable={false}
|
||||
onError={() => setIconImageFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
) : ProjectIcon ? (
|
||||
<ProjectIcon
|
||||
className="h-4 w-4 shrink-0"
|
||||
style={projectColorVar ? { color: projectColorVar } : { color: 'var(--surface-foreground)' }}
|
||||
/>
|
||||
) : (
|
||||
<LetterAvatar label={label} color={project.color} />
|
||||
)}
|
||||
</span>
|
||||
{showStreamingDots && (
|
||||
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
|
||||
<ProjectStatusDots color="var(--primary)" variant="streaming" />
|
||||
</span>
|
||||
)}
|
||||
{showAttentionDots && (
|
||||
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
|
||||
<ProjectStatusDots color="var(--status-info)" variant="attention" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TileBackground>
|
||||
);
|
||||
|
||||
const tileButton = (
|
||||
<button
|
||||
type="button"
|
||||
{...longPressHandlers}
|
||||
className={cn(
|
||||
'group relative flex cursor-pointer items-center rounded-lg overflow-hidden',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
|
||||
expanded ? 'h-9 w-full gap-2.5 pr-1.5 pl-[7px]' : 'h-9 w-9 justify-center',
|
||||
!expanded && (
|
||||
isActive
|
||||
? 'bg-transparent border border-[var(--surface-foreground)]'
|
||||
: 'bg-transparent border border-transparent hover:bg-[var(--interactive-hover)]/50 hover:border-[var(--interactive-border)]'
|
||||
),
|
||||
!expanded && menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]',
|
||||
)}
|
||||
>
|
||||
{expanded && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 left-[6px] right-[5px] rounded-lg border transition-colors',
|
||||
isActive
|
||||
? 'bg-[var(--interactive-selection)] border-[var(--interactive-border)]'
|
||||
: 'bg-transparent border-transparent group-hover:bg-[var(--interactive-hover)]/50 group-hover:border-[var(--interactive-border)]',
|
||||
menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="flex size-[34px] basis-[34px] shrink-0 grow-0 items-center justify-center">
|
||||
{iconElement}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden={!projectTextVisible}
|
||||
className={cn(
|
||||
'relative z-10 min-w-0 truncate text-left text-[13px] leading-tight transition-opacity duration-[180ms] ease-in-out',
|
||||
expanded ? 'flex-1' : 'w-0 flex-none',
|
||||
projectTextVisible ? 'opacity-100' : 'opacity-0',
|
||||
isActive && expanded ? 'font-medium text-[var(--interactive-selection-foreground)]' : 'text-[var(--surface-foreground)]',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{expanded ? (
|
||||
<div
|
||||
className="relative w-full"
|
||||
onContextMenu={(e) => {
|
||||
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
|
||||
e.preventDefault();
|
||||
setMenuOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tileButton}
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip delayDuration={400}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="relative"
|
||||
onContextMenu={(e) => {
|
||||
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
|
||||
e.preventDefault();
|
||||
setMenuOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tileButton}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<span className="sr-only">Project options</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="right" sideOffset={4} className="min-w-[160px]">
|
||||
<DropdownMenuItem onClick={onEdit} className="gap-2">
|
||||
<RiPencilLine className="h-4 w-4" />
|
||||
Edit project
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={onClose}
|
||||
className="text-destructive focus:text-destructive gap-2"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
Close project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** Constrain drag to Y axis only */
|
||||
const restrictToYAxis: Modifier = ({ transform }) => ({
|
||||
...transform,
|
||||
x: 0,
|
||||
});
|
||||
|
||||
/** Sortable wrapper for ProjectTile */
|
||||
const SortableProjectTile: React.FC<{
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ id, children }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}}
|
||||
className={cn(isDragging && 'opacity-30 z-50')}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface NavRailProps {
|
||||
className?: string;
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
||||
const projects = useProjectsStore((s) => s.projects);
|
||||
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
|
||||
const setActiveProjectIdOnly = useProjectsStore((s) => s.setActiveProjectIdOnly);
|
||||
const addProject = useProjectsStore((s) => s.addProject);
|
||||
const removeProject = useProjectsStore((s) => s.removeProject);
|
||||
const reorderProjects = useProjectsStore((s) => s.reorderProjects);
|
||||
const updateProjectMeta = useProjectsStore((s) => s.updateProjectMeta);
|
||||
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
|
||||
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
|
||||
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
||||
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
|
||||
const isOverlayBlockingNavRailActions = useUIStore((s) => (
|
||||
s.isSettingsDialogOpen
|
||||
|| s.isHelpDialogOpen
|
||||
|| s.isCommandPaletteOpen
|
||||
|| s.isSessionSwitcherOpen
|
||||
|| s.isAboutDialogOpen
|
||||
|| s.isOpenCodeStatusDialogOpen
|
||||
|| s.isSessionCreateDialogOpen
|
||||
|| s.isModelSelectorOpen
|
||||
|| s.isTimelineDialogOpen
|
||||
|| s.isMultiRunLauncherOpen
|
||||
|| s.isImagePreviewOpen
|
||||
));
|
||||
const isNavRailExpanded = useUIStore((s) => s.isNavRailExpanded);
|
||||
const toggleNavRail = useUIStore((s) => s.toggleNavRail);
|
||||
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
|
||||
const expanded = !mobile && isNavRailExpanded;
|
||||
const [showExpandedContent, setShowExpandedContent] = React.useState(expanded);
|
||||
const [projectTextVisible, setProjectTextVisible] = React.useState(expanded);
|
||||
const [actionTextVisible, setActionTextVisible] = React.useState(expanded);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (expanded) {
|
||||
setShowExpandedContent(true);
|
||||
setProjectTextVisible(false);
|
||||
setActionTextVisible(false);
|
||||
const projectTimer = window.setTimeout(() => {
|
||||
setProjectTextVisible(true);
|
||||
}, PROJECT_TEXT_FADE_IN_DELAY_MS);
|
||||
const actionTimer = window.setTimeout(() => {
|
||||
setActionTextVisible(true);
|
||||
}, ACTION_TEXT_FADE_IN_DELAY_MS);
|
||||
return () => {
|
||||
window.clearTimeout(projectTimer);
|
||||
window.clearTimeout(actionTimer);
|
||||
};
|
||||
}
|
||||
|
||||
setProjectTextVisible(false);
|
||||
setActionTextVisible(false);
|
||||
const timer = window.setTimeout(() => {
|
||||
setShowExpandedContent(false);
|
||||
}, NAV_RAIL_TEXT_FADE_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [expanded]);
|
||||
|
||||
const shortcutLabel = React.useCallback((actionId: string) => {
|
||||
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
|
||||
}, [shortcutOverrides]);
|
||||
|
||||
const sessionStatus = useSessionStore((s) => s.sessionStatus);
|
||||
const sessionAttentionStates = useSessionStore((s) => s.sessionAttentionStates);
|
||||
const sessionsByDirectory = useSessionStore((s) => s.sessionsByDirectory);
|
||||
const getSessionsByDirectory = useSessionStore((s) => s.getSessionsByDirectory);
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
const availableWorktreesByProject = useSessionStore((s) => s.availableWorktreesByProject);
|
||||
|
||||
const updateStore = useUpdateStore();
|
||||
const { available: updateAvailable, downloaded: updateDownloaded } = updateStore;
|
||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||
const navRailInteractionBlocked = isOverlayBlockingNavRailActions || updateDialogOpen;
|
||||
|
||||
const [editingProject, setEditingProject] = React.useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
iconBackground?: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
|
||||
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
||||
|
||||
const formatLabel = React.useCallback(
|
||||
(project: ProjectEntry): string => {
|
||||
return (
|
||||
project.label?.trim() ||
|
||||
formatDirectoryName(project.path, homeDirectory) ||
|
||||
project.path
|
||||
);
|
||||
},
|
||||
[homeDirectory],
|
||||
);
|
||||
|
||||
const projectIndicators = React.useMemo(() => {
|
||||
const result = new Map<string, { hasStreaming: boolean; hasUnread: boolean }>();
|
||||
for (const project of projects) {
|
||||
const projectRoot = normalize(project.path);
|
||||
if (!projectRoot) {
|
||||
result.set(project.id, { hasStreaming: false, hasUnread: false });
|
||||
continue;
|
||||
}
|
||||
|
||||
const dirs: string[] = [projectRoot];
|
||||
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
|
||||
for (const meta of worktrees) {
|
||||
const p =
|
||||
meta && typeof meta === 'object' && 'path' in meta
|
||||
? (meta as { path?: unknown }).path
|
||||
: null;
|
||||
if (typeof p === 'string' && p.trim()) {
|
||||
const normalized = normalize(p);
|
||||
if (normalized && normalized !== projectRoot) {
|
||||
dirs.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
let hasStreaming = false;
|
||||
let hasUnread = false;
|
||||
|
||||
for (const dir of dirs) {
|
||||
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
|
||||
for (const session of list) {
|
||||
if (!session?.id || seen.has(session.id)) continue;
|
||||
seen.add(session.id);
|
||||
|
||||
const statusType = sessionStatus?.get(session.id)?.type ?? 'idle';
|
||||
if (statusType === 'busy' || statusType === 'retry') {
|
||||
hasStreaming = true;
|
||||
}
|
||||
|
||||
const isCurrentVisible =
|
||||
session.id === currentSessionId && project.id === activeProjectId;
|
||||
if (
|
||||
!isCurrentVisible &&
|
||||
sessionAttentionStates.get(session.id)?.needsAttention === true
|
||||
) {
|
||||
hasUnread = true;
|
||||
}
|
||||
|
||||
if (hasStreaming && hasUnread) break;
|
||||
}
|
||||
if (hasStreaming && hasUnread) break;
|
||||
}
|
||||
|
||||
result.set(project.id, { hasStreaming, hasUnread });
|
||||
}
|
||||
return result;
|
||||
}, [
|
||||
activeProjectId,
|
||||
availableWorktreesByProject,
|
||||
currentSessionId,
|
||||
getSessionsByDirectory,
|
||||
projects,
|
||||
sessionAttentionStates,
|
||||
sessionStatus,
|
||||
sessionsByDirectory,
|
||||
]);
|
||||
|
||||
const handleAddProject = React.useCallback(() => {
|
||||
if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
|
||||
sessionEvents.requestDirectoryDialog();
|
||||
return;
|
||||
}
|
||||
requestDirectoryAccess('')
|
||||
.then((result) => {
|
||||
if (result.success && result.path) {
|
||||
const added = addProject(result.path, { id: result.projectId });
|
||||
if (!added) {
|
||||
toast.error('Failed to add project', {
|
||||
description: 'Please select a valid directory.',
|
||||
});
|
||||
}
|
||||
} else if (result.error && result.error !== 'Directory selection cancelled') {
|
||||
toast.error('Failed to select directory', { description: result.error });
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to select directory:', error);
|
||||
toast.error('Failed to select directory');
|
||||
});
|
||||
}, [addProject, tauriIpcAvailable]);
|
||||
|
||||
const handleEditProject = React.useCallback(
|
||||
(projectId: string) => {
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
if (!project) return;
|
||||
setEditingProject({
|
||||
id: project.id,
|
||||
name: formatLabel(project),
|
||||
path: project.path,
|
||||
icon: project.icon,
|
||||
color: project.color,
|
||||
iconBackground: project.iconBackground,
|
||||
});
|
||||
},
|
||||
[projects, formatLabel],
|
||||
);
|
||||
|
||||
const handleSaveProjectEdit = React.useCallback(
|
||||
(data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => {
|
||||
if (!editingProject) return;
|
||||
updateProjectMeta(editingProject.id, data);
|
||||
setEditingProject(null);
|
||||
},
|
||||
[editingProject, updateProjectMeta],
|
||||
);
|
||||
|
||||
const handleCloseProject = React.useCallback(
|
||||
(projectId: string) => {
|
||||
removeProject(projectId);
|
||||
},
|
||||
[removeProject],
|
||||
);
|
||||
|
||||
// Cmd/Ctrl+number to switch projects
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= projects.length) {
|
||||
e.preventDefault();
|
||||
const target = projects[num - 1];
|
||||
if (target && target.id !== activeProjectId) {
|
||||
setActiveProjectIdOnly(target.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [projects, activeProjectId, setActiveProjectIdOnly]);
|
||||
|
||||
// Drag-to-reorder
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
);
|
||||
|
||||
const projectIds = React.useMemo(() => projects.map((p) => p.id), [projects]);
|
||||
|
||||
const handleDragEnd = React.useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const fromIndex = projects.findIndex((p) => p.id === active.id);
|
||||
const toIndex = projects.findIndex((p) => p.id === over.id);
|
||||
if (fromIndex !== -1 && toIndex !== -1) {
|
||||
reorderProjects(fromIndex, toIndex);
|
||||
}
|
||||
},
|
||||
[projects, reorderProjects],
|
||||
);
|
||||
|
||||
const navRailActionButtonClass = cn(
|
||||
'group relative flex h-8 cursor-pointer items-center rounded-lg disabled:cursor-not-allowed',
|
||||
showExpandedContent ? 'w-full justify-start gap-2.5 pr-2 pl-2' : 'w-8 justify-center',
|
||||
showExpandedContent
|
||||
? 'text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)]'
|
||||
: 'text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)]/50 hover:text-[var(--surface-foreground)]',
|
||||
'transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
|
||||
);
|
||||
|
||||
const navRailActionIconClass = 'h-4.5 w-4.5 shrink-0';
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className={cn(
|
||||
'flex h-full shrink-0 flex-col bg-[var(--surface-background)] overflow-hidden',
|
||||
showExpandedContent ? 'items-stretch' : 'items-center',
|
||||
navRailInteractionBlocked && 'pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
style={{ width: expanded ? NAV_RAIL_EXPANDED_WIDTH : NAV_RAIL_WIDTH }}
|
||||
aria-label="Project navigation"
|
||||
>
|
||||
{/* Projects list */}
|
||||
<div className="flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden scrollbar-none">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={[restrictToYAxis]}
|
||||
>
|
||||
<SortableContext items={projectIds} strategy={verticalListSortingStrategy}>
|
||||
<div className={cn('flex flex-col gap-3 pt-1 pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
|
||||
{projects.map((project) => {
|
||||
const isActive = project.id === activeProjectId;
|
||||
const indicators = projectIndicators.get(project.id);
|
||||
return (
|
||||
<SortableProjectTile key={project.id} id={project.id}>
|
||||
<ProjectTile
|
||||
project={project}
|
||||
isActive={isActive}
|
||||
hasStreaming={indicators?.hasStreaming ?? false}
|
||||
hasUnread={indicators?.hasUnread ?? false}
|
||||
label={formatLabel(project)}
|
||||
expanded={showExpandedContent}
|
||||
projectTextVisible={projectTextVisible}
|
||||
onClick={() => {
|
||||
if (project.id !== activeProjectId) {
|
||||
setActiveProjectIdOnly(project.id);
|
||||
}
|
||||
}}
|
||||
onEdit={() => handleEditProject(project.id)}
|
||||
onClose={() => handleCloseProject(project.id)}
|
||||
/>
|
||||
</SortableProjectTile>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Add project button */}
|
||||
<div className={cn('flex flex-col pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
|
||||
<NavRailActionButton
|
||||
onClick={handleAddProject}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="Add project"
|
||||
icon={<RiFolderAddLine className={navRailActionIconClass} />}
|
||||
tooltipLabel="Add project"
|
||||
buttonClassName={navRailActionButtonClass}
|
||||
showExpandedContent={showExpandedContent}
|
||||
actionTextVisible={actionTextVisible}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom actions */}
|
||||
<div className={cn(
|
||||
'shrink-0 w-full pt-3 pb-4 flex flex-col gap-1',
|
||||
showExpandedContent ? 'items-stretch px-1' : 'items-center',
|
||||
)}>
|
||||
{(updateAvailable || updateDownloaded) && (
|
||||
<NavRailActionButton
|
||||
onClick={() => setUpdateDialogOpen(true)}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="Update available"
|
||||
icon={<RiDownloadLine className={navRailActionIconClass} />}
|
||||
tooltipLabel="Update available"
|
||||
buttonClassName={navRailActionButtonClass}
|
||||
showExpandedContent={showExpandedContent}
|
||||
actionTextVisible={actionTextVisible}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isDesktopApp && !(updateAvailable || updateDownloaded) && (
|
||||
<NavRailActionButton
|
||||
onClick={() => setAboutDialogOpen(true)}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="About"
|
||||
icon={<RiInformationLine className={navRailActionIconClass} />}
|
||||
tooltipLabel="About OpenChamber"
|
||||
buttonClassName={navRailActionButtonClass}
|
||||
showExpandedContent={showExpandedContent}
|
||||
actionTextVisible={actionTextVisible}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!mobile && (
|
||||
<NavRailActionButton
|
||||
onClick={toggleHelpDialog}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="Keyboard shortcuts"
|
||||
icon={<RiQuestionLine className={navRailActionIconClass} />}
|
||||
tooltipLabel="Shortcuts"
|
||||
shortcutHint={shortcutLabel('open_help')}
|
||||
showExpandedShortcutHint={false}
|
||||
buttonClassName={navRailActionButtonClass}
|
||||
showExpandedContent={showExpandedContent}
|
||||
actionTextVisible={actionTextVisible}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NavRailActionButton
|
||||
onClick={() => setSettingsDialogOpen(true)}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel="Settings"
|
||||
icon={<RiSettings3Line className={navRailActionIconClass} />}
|
||||
tooltipLabel="Settings"
|
||||
shortcutHint={shortcutLabel('open_settings')}
|
||||
showExpandedShortcutHint={false}
|
||||
buttonClassName={navRailActionButtonClass}
|
||||
showExpandedContent={showExpandedContent}
|
||||
actionTextVisible={actionTextVisible}
|
||||
/>
|
||||
|
||||
{/* Toggle expand/collapse (desktop only) */}
|
||||
{!mobile && (
|
||||
<NavRailActionButton
|
||||
onClick={toggleNavRail}
|
||||
disabled={navRailInteractionBlocked}
|
||||
ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
icon={expanded
|
||||
? <RiMenuFoldLine className={navRailActionIconClass} />
|
||||
: <RiMenuUnfoldLine className={navRailActionIconClass} />
|
||||
}
|
||||
tooltipLabel={expanded ? 'Collapse' : 'Expand'}
|
||||
shortcutHint={shortcutLabel('toggle_nav_rail')}
|
||||
showExpandedShortcutHint={false}
|
||||
buttonClassName={navRailActionButtonClass}
|
||||
showExpandedContent={showExpandedContent}
|
||||
actionTextVisible={actionTextVisible}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Dialogs */}
|
||||
{editingProject && (
|
||||
<ProjectEditDialog
|
||||
open={!!editingProject}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingProject(null);
|
||||
}}
|
||||
projectId={editingProject.id}
|
||||
projectName={editingProject.name}
|
||||
projectPath={editingProject.path}
|
||||
initialIcon={editingProject.icon}
|
||||
initialColor={editingProject.color}
|
||||
initialIconBackground={editingProject.iconBackground}
|
||||
onSave={handleSaveProjectEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UpdateDialog
|
||||
open={updateDialogOpen}
|
||||
onOpenChange={setUpdateDialogOpen}
|
||||
info={updateStore.info}
|
||||
downloading={updateStore.downloading}
|
||||
downloaded={updateStore.downloaded}
|
||||
progress={updateStore.progress}
|
||||
error={updateStore.error}
|
||||
onDownload={updateStore.downloadUpdate}
|
||||
onRestart={updateStore.restartToUpdate}
|
||||
runtimeType={updateStore.runtimeType}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { NAV_RAIL_WIDTH, NAV_RAIL_EXPANDED_WIDTH };
|
||||
@@ -153,6 +153,26 @@ const extractBestUrl = (value: string): string | null => {
|
||||
return normalized[0] ?? null;
|
||||
};
|
||||
|
||||
const formatActionButtonLabel = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return 'Action';
|
||||
}
|
||||
|
||||
const words = trimmed.split(/\s+/).filter(Boolean);
|
||||
if (words.length >= 2) {
|
||||
const first = words[0];
|
||||
const second = words[1].slice(0, 3);
|
||||
const shortTwoWord = `${first} ${second}`.trim();
|
||||
if (words.length > 2 || shortTwoWord.length < trimmed.length) {
|
||||
return `${shortTwoWord}...`;
|
||||
}
|
||||
return shortTwoWord;
|
||||
}
|
||||
|
||||
return trimmed.length > 12 ? `${trimmed.slice(0, 9).trimEnd()}...` : trimmed;
|
||||
};
|
||||
|
||||
export const ProjectActionsButton = ({
|
||||
projectRef,
|
||||
directory,
|
||||
@@ -651,15 +671,15 @@ export const ProjectActionsButton = ({
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex h-7 items-center gap-2 self-center rounded-md border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] pl-1.5 pr-2.5 typography-ui-label font-medium text-foreground hover:bg-interactive-hover transition-colors',
|
||||
'app-region-no-drag inline-flex h-7 shrink-0 items-center gap-2 self-center rounded-md border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] px-3 typography-ui-label font-medium text-foreground hover:bg-interactive-hover transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
className
|
||||
)}
|
||||
onClick={openProjectActionsSettings}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="header-open-label">Add action</span>
|
||||
<span className="header-open-label whitespace-nowrap">Add action</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -671,6 +691,7 @@ export const ProjectActionsButton = ({
|
||||
|
||||
const selectedIconKey = (resolvedSelected.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
|
||||
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
|
||||
const selectedButtonLabel = formatActionButtonLabel(resolvedSelected.name);
|
||||
const selectedRunKey = toProjectActionRunKey(normalizedDirectory, resolvedSelected.id);
|
||||
const selectedRunning = runningByKey[selectedRunKey];
|
||||
const isStoppingSelected = selectedRunning?.status === 'stopping';
|
||||
@@ -738,7 +759,7 @@ export const ProjectActionsButton = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex items-center self-center rounded-md border border-[var(--interactive-border)]',
|
||||
'app-region-no-drag inline-flex shrink-0 items-center self-center rounded-md border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] shadow-none overflow-hidden',
|
||||
compact ? 'h-9' : 'h-7',
|
||||
className
|
||||
@@ -750,8 +771,8 @@ export const ProjectActionsButton = ({
|
||||
disabled={isLoading || isStoppingSelected}
|
||||
className={cn(
|
||||
'inline-flex h-full items-center typography-ui-label font-medium text-foreground hover:bg-interactive-hover',
|
||||
compact ? 'w-9 justify-center px-0' : 'gap-2 pl-2 pr-3',
|
||||
'transition-colors disabled:cursor-not-allowed'
|
||||
compact ? 'w-9 justify-center px-0' : 'gap-2 px-3',
|
||||
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed'
|
||||
)}
|
||||
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
|
||||
>
|
||||
@@ -762,7 +783,7 @@ export const ProjectActionsButton = ({
|
||||
? <RiStopLine className="h-4 w-4 text-[var(--status-warning)]" />
|
||||
: <SelectedIcon className="h-4 w-4" />}
|
||||
</span>
|
||||
{!compact ? <span className="header-open-label">{resolvedSelected.name}</span> : null}
|
||||
{!compact ? <span className="header-open-label whitespace-nowrap">{selectedButtonLabel}</span> : null}
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
@@ -772,14 +793,14 @@ export const ProjectActionsButton = ({
|
||||
className={cn(
|
||||
compact ? 'inline-flex h-full w-8 items-center justify-center' : 'inline-flex h-full w-7 items-center justify-center',
|
||||
'border-l border-[var(--interactive-border)] text-muted-foreground',
|
||||
'hover:bg-interactive-hover hover:text-foreground transition-colors'
|
||||
'hover:bg-interactive-hover hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
||||
)}
|
||||
aria-label="Choose project action"
|
||||
>
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" alignOffset={8} className="w-52 max-h-[70vh] overflow-y-auto">
|
||||
<DropdownMenuContent align="center" className="w-52 max-h-[70vh] overflow-y-auto" style={{ translate: '-30px 0' }}>
|
||||
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground">Add new action</span>
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
|
||||
export const RIGHT_SIDEBAR_CONTENT_WIDTH = 420;
|
||||
const RIGHT_SIDEBAR_MIN_WIDTH = 400;
|
||||
const RIGHT_SIDEBAR_MAX_WIDTH = 860;
|
||||
|
||||
interface RightSidebarProps {
|
||||
isOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onTopActionsHostChange?: (element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children }) => {
|
||||
export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, className, onTopActionsHostChange }) => {
|
||||
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
|
||||
const setRightSidebarWidth = useUIStore((state) => state.setRightSidebarWidth);
|
||||
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const startXRef = React.useRef(0);
|
||||
const startWidthRef = React.useRef(rightSidebarWidth || 420);
|
||||
@@ -34,7 +39,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
}, []);
|
||||
|
||||
const appliedWidth = isOpen
|
||||
? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || 420))
|
||||
? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH))
|
||||
: 0;
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent) => {
|
||||
@@ -97,13 +102,47 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
}
|
||||
}, [isResizing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
onTopActionsHostChange?.(null);
|
||||
}
|
||||
}, [isOpen, onTopActionsHostChange]);
|
||||
|
||||
const handleDragStart = React.useCallback(async (event: React.MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.app-region-no-drag')) {
|
||||
return;
|
||||
}
|
||||
if (target.closest('button, a, input, select, textarea')) {
|
||||
return;
|
||||
}
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const appWindow = getCurrentWindow();
|
||||
await appWindow.startDragging();
|
||||
} catch (error) {
|
||||
console.error('Failed to start window dragging:', error);
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className={cn(
|
||||
'relative flex h-full overflow-hidden border-l border-border/40 bg-sidebar/50',
|
||||
'relative flex h-full overflow-hidden border-l border-border/40',
|
||||
isOpen
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar',
|
||||
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
|
||||
!isOpen && 'border-l-0'
|
||||
!isOpen && 'border-l-0',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
width: 'var(--oc-right-sidebar-width)',
|
||||
@@ -114,11 +153,23 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
}}
|
||||
aria-hidden={!isOpen || appliedWidth === 0}
|
||||
>
|
||||
{isOpen ? (
|
||||
<div
|
||||
onMouseDown={handleDragStart}
|
||||
className="app-region-drag absolute inset-x-0 top-0 z-20 flex h-[var(--oc-header-height,56px)] items-center justify-end px-3"
|
||||
aria-hidden
|
||||
>
|
||||
<div
|
||||
ref={onTopActionsHostChange}
|
||||
className="app-region-no-drag flex items-center gap-1"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-0 z-20 h-full w-[4px] cursor-col-resize hover:bg-primary/50 transition-colors',
|
||||
isResizing && 'bg-primary'
|
||||
'absolute left-0 top-0 z-20 h-full w-[3px] cursor-col-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
|
||||
isResizing && 'bg-[var(--interactive-border)]'
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
@@ -135,6 +186,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
isResizing && 'pointer-events-none',
|
||||
!isOpen && 'pointer-events-none select-none opacity-0'
|
||||
)}
|
||||
style={isOpen ? { paddingTop: 'var(--oc-header-height, 56px)' } : undefined}
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
{isOpen ? children : null}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
|
||||
export const SIDEBAR_CONTENT_WIDTH = 250;
|
||||
const SIDEBAR_MIN_WIDTH = 250;
|
||||
@@ -11,10 +12,12 @@ interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
isMobile: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children }) => {
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, className }) => {
|
||||
const { sidebarWidth, setSidebarWidth } = useUIStore();
|
||||
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const startXRef = React.useRef(0);
|
||||
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
|
||||
@@ -115,9 +118,12 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
ref={sidebarRef}
|
||||
className={cn(
|
||||
'relative flex h-full overflow-hidden border-r border-border/40',
|
||||
'bg-sidebar/50',
|
||||
isDesktopApp
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: 'bg-sidebar',
|
||||
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
|
||||
!isOpen && 'border-r-0'
|
||||
!isOpen && 'border-r-0',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
width: 'var(--oc-left-sidebar-width)',
|
||||
@@ -131,8 +137,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 top-0 z-20 h-full w-[4px] cursor-col-resize hover:bg-primary/50 transition-colors',
|
||||
isResizing && 'bg-primary'
|
||||
'absolute right-0 top-0 z-20 h-full w-[3px] cursor-col-resize hover:bg-[var(--interactive-border)]/80 transition-colors',
|
||||
isResizing && 'bg-[var(--interactive-border)]'
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
|
||||
@@ -94,12 +94,12 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<div className="sticky top-0 z-20 bg-[var(--surface-elevated)] border-b border-[var(--interactive-border)]">
|
||||
<div className="flex items-center justify-between gap-3 px-2 py-2.5">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<div className="border-b border-[var(--interactive-border)]">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 flex items-baseline gap-2">
|
||||
<div className="typography-ui-header font-semibold text-foreground">MCP Servers</div>
|
||||
{directory && (
|
||||
<div className="truncate typography-ui-label text-muted-foreground">
|
||||
<div className="truncate typography-micro text-muted-foreground">
|
||||
{directory.split('/').pop() || directory}
|
||||
</div>
|
||||
)}
|
||||
@@ -116,7 +116,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
<div className="max-h-64 overflow-y-auto py-2">
|
||||
{sortedNames.map((serverName) => {
|
||||
const serverStatus = status[serverName];
|
||||
const tone = statusTone(serverStatus);
|
||||
@@ -127,7 +127,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
return (
|
||||
<div
|
||||
key={serverName}
|
||||
className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-lg hover:bg-interactive-hover/50"
|
||||
className="flex items-center justify-between gap-2 px-4 py-1.5 rounded-lg hover:bg-interactive-hover/50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -174,7 +174,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
|
||||
})}
|
||||
|
||||
{sortedNames.length === 0 && (
|
||||
<div className="px-2 py-3 typography-ui-label text-muted-foreground text-center">
|
||||
<div className="px-4 py-5 typography-ui-label text-muted-foreground text-center">
|
||||
Configure MCP servers in Opencode config.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -685,7 +685,7 @@ export const AgentsPage: React.FC = () => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setMode('primary')}
|
||||
@@ -697,8 +697,8 @@ export const AgentsPage: React.FC = () => {
|
||||
)}
|
||||
>
|
||||
Primary
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setMode('subagent')}
|
||||
@@ -710,8 +710,8 @@ export const AgentsPage: React.FC = () => {
|
||||
)}
|
||||
>
|
||||
Subagent
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setMode('all')}
|
||||
@@ -723,7 +723,7 @@ export const AgentsPage: React.FC = () => {
|
||||
)}
|
||||
>
|
||||
All
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -790,7 +790,7 @@ export const AgentsPage: React.FC = () => {
|
||||
className="w-16"
|
||||
/>
|
||||
{temperature !== undefined && (
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setTemperature(undefined)}
|
||||
@@ -799,7 +799,7 @@ export const AgentsPage: React.FC = () => {
|
||||
title="Clear"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -834,7 +834,7 @@ export const AgentsPage: React.FC = () => {
|
||||
className="w-16"
|
||||
/>
|
||||
{topP !== undefined && (
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setTopP(undefined)}
|
||||
@@ -843,7 +843,7 @@ export const AgentsPage: React.FC = () => {
|
||||
title="Clear"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -876,14 +876,14 @@ export const AgentsPage: React.FC = () => {
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Tool Permissions
|
||||
</h3>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setShowPermissionEditor((prev) => !prev)}
|
||||
>
|
||||
{showPermissionEditor ? 'Hide Editor' : 'Advanced Editor'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!showPermissionEditor ? (
|
||||
@@ -961,13 +961,13 @@ export const AgentsPage: React.FC = () => {
|
||||
<span className="typography-micro text-muted-foreground">Pattern</span>
|
||||
<span className="typography-micro font-mono text-foreground bg-[var(--surface-muted)] px-1 rounded">*</span>
|
||||
{wildcardOverride && (
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => revertRule(permissionName, '*')}
|
||||
className="px-1.5 py-0 h-5"
|
||||
>
|
||||
<RiSubtractLine className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
@@ -1008,13 +1008,13 @@ export const AgentsPage: React.FC = () => {
|
||||
{isAdded && <span className="typography-micro text-[var(--status-success)]">New</span>}
|
||||
{isModified && <span className="typography-micro text-[var(--status-warning)]">Modified</span>}
|
||||
{(isAdded || isModified) && (
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => isAdded ? removeRule(rule.permission, rule.pattern) : revertRule(rule.permission, rule.pattern)}
|
||||
className="px-1.5 py-0 h-5"
|
||||
>
|
||||
<RiSubtractLine className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
@@ -1069,9 +1069,9 @@ export const AgentsPage: React.FC = () => {
|
||||
/>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('allow')}>Allow</ButtonSmall>
|
||||
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('ask')}>Ask</ButtonSmall>
|
||||
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('deny')}>Deny</ButtonSmall>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('allow')}>Allow</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('ask')}>Ask</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => applyPendingRule('deny')}>Deny</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1081,14 +1081,14 @@ export const AgentsPage: React.FC = () => {
|
||||
|
||||
{/* Save action */}
|
||||
<div className="px-2 py-1">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !isDirty}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { isMobileDeviceViaCSS } from '@/lib/device';
|
||||
@@ -324,13 +323,13 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {visibleAgents.length}</span>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -446,16 +445,17 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonLarge
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={closeConfirmActionDialog}
|
||||
disabled={isConfirmActionPending}
|
||||
>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge onClick={handleConfirmAction} disabled={isConfirmActionPending}>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirmAction} disabled={isConfirmActionPending}>
|
||||
{confirmActionType === 'delete' ? 'Delete' : 'Reset'}
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -481,15 +481,16 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<ButtonLarge
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setRenameDialogAgent(null)}
|
||||
>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge onClick={handleRenameAgent}>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRenameAgent}>
|
||||
Rename
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -563,12 +564,12 @@ const AgentListItem: React.FC<AgentListItemProps> = ({
|
||||
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
{onRename && (
|
||||
|
||||
@@ -630,8 +630,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
|
||||
{/* Favorites Section */}
|
||||
{filteredFavorites.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel style={{ backgroundColor: 'var(--surface-elevated)' }} className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30">
|
||||
<div>
|
||||
<DropdownMenuLabel className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30">
|
||||
<RiStarFill className="h-4 w-4 text-primary" />
|
||||
Favorites
|
||||
</DropdownMenuLabel>
|
||||
@@ -639,14 +639,14 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
const idx = currentFlatIndex++;
|
||||
return renderModelRow(model, providerID, modelID, 'fav', idx, selectedIndex === idx);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recents Section */}
|
||||
{filteredRecents.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
{filteredFavorites.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel style={{ backgroundColor: 'var(--surface-elevated)' }} className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30">
|
||||
<DropdownMenuLabel className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30">
|
||||
<RiTimeLine className="h-4 w-4" />
|
||||
Recent
|
||||
</DropdownMenuLabel>
|
||||
@@ -654,7 +654,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
const idx = currentFlatIndex++;
|
||||
return renderModelRow(model, providerID, modelID, 'recent', idx, selectedIndex === idx);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator before providers */}
|
||||
@@ -664,9 +664,9 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
|
||||
{/* All Providers - Flat List */}
|
||||
{filteredProviders.map((provider, index) => (
|
||||
<React.Fragment key={provider.id}>
|
||||
<div key={provider.id}>
|
||||
{index > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel style={{ backgroundColor: 'var(--surface-elevated)' }} className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30">
|
||||
<DropdownMenuLabel className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30">
|
||||
<ProviderLogo
|
||||
providerId={provider.id}
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
@@ -677,7 +677,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
const idx = currentFlatIndex++;
|
||||
return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, selectedIndex === idx);
|
||||
})}
|
||||
</React.Fragment>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
@@ -321,14 +321,14 @@ export const CommandsPage: React.FC = () => {
|
||||
|
||||
{/* Save action */}
|
||||
<div className="px-2 py-1">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !isDirty}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { isMobileDeviceViaCSS } from '@/lib/device';
|
||||
@@ -221,13 +220,13 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {commandOnlyItems.length}</span>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -310,16 +309,17 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonLarge
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={closeConfirmActionDialog}
|
||||
disabled={isConfirmActionPending}
|
||||
>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge onClick={handleConfirmAction} disabled={isConfirmActionPending}>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirmAction} disabled={isConfirmActionPending}>
|
||||
{confirmActionType === 'delete' ? 'Delete' : 'Reset'}
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -345,15 +345,16 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<ButtonLarge
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setRenameDialogCommand(null)}
|
||||
>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge onClick={handleRenameCommand}>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRenameCommand}>
|
||||
Rename
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -422,12 +423,12 @@ const CommandListItem: React.FC<CommandListItemProps> = ({
|
||||
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
{onRename && (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui';
|
||||
import {
|
||||
@@ -351,7 +350,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="typography-ui-label text-foreground">Auth Method</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAuthType('ssh')}
|
||||
@@ -362,8 +361,8 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
)}
|
||||
>
|
||||
<RiLock2Line className="w-3.5 h-3.5 mr-1" /> SSH
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
</Button>
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAuthType('token')}
|
||||
@@ -374,7 +373,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
)}
|
||||
>
|
||||
<RiKeyLine className="w-3.5 h-3.5 mr-1" /> Token
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -467,7 +466,7 @@ export const GitIdentityEditorDialog: React.FC<GitIdentityEditorDialogProps> = (
|
||||
<Button variant="ghost" onClick={() => setIsDeleteDialogOpen(false)} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void handleConfirmDelete()} disabled={isDeleting} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
|
||||
<Button size="sm" variant="destructive" onClick={() => void handleConfirmDelete()} disabled={isDeleting}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { toast } from '@/components/ui';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -124,9 +122,9 @@ export const GitPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Identities</h3>
|
||||
</div>
|
||||
<ButtonSmall variant="outline" onClick={() => openEditor('new')}>
|
||||
<Button size="sm" variant="outline" onClick={() => openEditor('new')}>
|
||||
<RiAddLine className="w-3.5 h-3.5 mr-1" /> New
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
|
||||
@@ -213,9 +211,9 @@ export const GitPage: React.FC = () => {
|
||||
<Button variant="ghost" onClick={() => setDeleteDialogProfile(null)} disabled={isDeletePending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonLarge onClick={() => void handleConfirmDelete()} disabled={isDeletePending} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
|
||||
<Button size="sm" variant="destructive" onClick={() => void handleConfirmDelete()} disabled={isDeletePending}>
|
||||
Delete
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -340,10 +338,10 @@ const DiscoveredRow: React.FC<DiscoveredRowProps> = ({ credential, onImport, has
|
||||
{isRepoSpecific ? credential.host : credential.username}
|
||||
</span>
|
||||
</div>
|
||||
<ButtonSmall variant="ghost" onClick={onImport} className="gap-1 shrink-0">
|
||||
<Button size="sm" variant="ghost" onClick={onImport} className="gap-1 shrink-0">
|
||||
<RiDownloadLine className="h-3 w-3" />
|
||||
Import
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -110,7 +108,7 @@ const CommandTextarea: React.FC<CommandTextareaProps> = ({ value, onChange }) =>
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal gap-1 text-muted-foreground"
|
||||
@@ -120,7 +118,7 @@ const CommandTextarea: React.FC<CommandTextareaProps> = ({ value, onChange }) =>
|
||||
>
|
||||
<RiClipboardLine className="h-3 w-3" />
|
||||
Paste command
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
@@ -252,7 +250,7 @@ const EnvEditor: React.FC<EnvEditorProps> = ({ value, onChange }) => {
|
||||
<span className="typography-micro text-muted-foreground w-32 shrink-0">Key</span>
|
||||
<span className="typography-micro text-muted-foreground">Value</span>
|
||||
</div>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal gap-1 text-muted-foreground"
|
||||
@@ -262,7 +260,7 @@ const EnvEditor: React.FC<EnvEditorProps> = ({ value, onChange }) => {
|
||||
>
|
||||
<RiClipboardLine className="h-3 w-3" />
|
||||
Paste .env
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
@@ -299,18 +297,18 @@ const EnvEditor: React.FC<EnvEditorProps> = ({ value, onChange }) => {
|
||||
</button>
|
||||
</div>
|
||||
{/* Remove */}
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 shrink-0 text-muted-foreground hover:text-[var(--status-error)]"
|
||||
onClick={() => removeRow(idx)}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal gap-1.5"
|
||||
@@ -319,7 +317,7 @@ const EnvEditor: React.FC<EnvEditorProps> = ({ value, onChange }) => {
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add variable
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
|
||||
{hasSensitiveValues && (
|
||||
<p className="typography-micro text-muted-foreground/60">
|
||||
@@ -535,7 +533,7 @@ export const McpPage: React.FC = () => {
|
||||
{isNewServer ? 'Configure a new MCP server' : `${mcpType === 'local' ? 'Local · stdio' : 'Remote · SSE'} transport`}
|
||||
</p>
|
||||
{!isNewServer && (
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant={isConnected ? 'outline' : 'default'}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
@@ -543,7 +541,7 @@ export const McpPage: React.FC = () => {
|
||||
disabled={isConnecting || !enabled}
|
||||
>
|
||||
{isConnecting ? 'Working...' : isConnected ? 'Disconnect' : 'Connect'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -618,7 +616,7 @@ export const McpPage: React.FC = () => {
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Transport Mode</span>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setMcpType('local')}
|
||||
@@ -630,8 +628,8 @@ export const McpPage: React.FC = () => {
|
||||
)}
|
||||
>
|
||||
Local · stdio
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setMcpType('remote')}
|
||||
@@ -643,7 +641,7 @@ export const McpPage: React.FC = () => {
|
||||
)}
|
||||
>
|
||||
Remote · SSE
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -693,23 +691,23 @@ export const McpPage: React.FC = () => {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || (!isDirty && !isNewServer)}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving...' : isNewServer ? 'Create' : 'Save Changes'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
{!isNewServer && (
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
Delete
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -736,9 +734,9 @@ export const McpPage: React.FC = () => {
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonLarge onClick={handleDelete} disabled={isDeleting}>
|
||||
<Button size="sm" onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -130,14 +129,14 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
Total {mcpServers.length}
|
||||
</span>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
onClick={handleCreateNew}
|
||||
title="Add MCP server"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,9 +197,9 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
|
||||
<DropdownMenu open={openMenuMcp === server.name} onOpenChange={(open) => setOpenMenuMcp(open ? server.name : null)}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ButtonSmall variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
|
||||
<Button size="xs" variant="ghost" className="flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
<DropdownMenuItem
|
||||
@@ -268,9 +267,9 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
|
||||
<DropdownMenu open={openMenuMcp === server.name} onOpenChange={(open) => setOpenMenuMcp(open ? server.name : null)}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ButtonSmall variant="ghost" className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
|
||||
<Button size="xs" variant="ghost" className="flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
<DropdownMenuItem
|
||||
@@ -308,16 +307,17 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonLarge
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge onClick={handleDelete} disabled={isDeleting}>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleDelete} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber';
|
||||
|
||||
@@ -155,26 +155,26 @@ export const AboutSettings: React.FC = () => {
|
||||
)}
|
||||
|
||||
{!updateStore.checking && updateStore.available && (
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="default"
|
||||
onClick={() => setUpdateDialogOpen(true)}
|
||||
>
|
||||
<RiDownloadLine className="h-4 w-4 mr-1" />
|
||||
Update to {updateStore.info?.version}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!updateStore.checking && !updateStore.available && !updateStore.error && (
|
||||
<span className="typography-meta text-muted-foreground">Up to date</span>
|
||||
)}
|
||||
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="outline"
|
||||
onClick={() => updateStore.checkForUpdates()}
|
||||
disabled={updateStore.checking}
|
||||
>
|
||||
Check for updates
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { toast } from '@/components/ui';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
@@ -304,18 +303,18 @@ export const GitHubSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonSmall variant="outline" onClick={disconnect} disabled={isBusy} className={cn("text-[var(--status-error)] hover:text-[var(--status-error)]", isMobile ? "w-full" : undefined)}>
|
||||
<Button size="sm" variant="outline" onClick={disconnect} disabled={isBusy} className={cn("text-[var(--status-error)] hover:text-[var(--status-error)]", isMobile ? "w-full" : undefined)}>
|
||||
Disconnect
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-4">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">Not Connected</span>
|
||||
</div>
|
||||
<ButtonSmall variant="default" onClick={startConnect} disabled={isBusy}>
|
||||
<Button size="sm" variant="default" onClick={startConnect} disabled={isBusy}>
|
||||
Connect GitHub
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -359,13 +358,13 @@ export const GitHubSettings: React.FC = () => {
|
||||
{isCurrent ? (
|
||||
<span className="typography-micro text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-1.5 py-0.5 rounded">Active</span>
|
||||
) : (
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => activateAccount(account.id)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
Switch to
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -378,14 +377,14 @@ export const GitHubSettings: React.FC = () => {
|
||||
|
||||
{connected && (
|
||||
<div className="mt-2 px-2 pb-2">
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="outline"
|
||||
onClick={startConnect}
|
||||
disabled={isBusy}
|
||||
className={cn(isMobile ? 'w-full' : undefined)}
|
||||
>
|
||||
Add Account
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -413,12 +412,12 @@ export const GitHubSettings: React.FC = () => {
|
||||
<span className="typography-micro text-muted-foreground animate-pulse">
|
||||
Waiting for approval… (auto-refresh)
|
||||
</span>
|
||||
<ButtonSmall variant="ghost" disabled={isBusy} onClick={() => {
|
||||
<Button size="sm" variant="ghost" disabled={isBusy} onClick={() => {
|
||||
stopPolling();
|
||||
setFlow(null);
|
||||
}}>
|
||||
Cancel
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { RiInformationLine } from '@remixicon/react';
|
||||
@@ -132,7 +132,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
<div className="mb-1 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Keyboard Shortcuts</h3>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -146,7 +146,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
Reset All
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
@@ -166,8 +166,8 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
This combo is already used by another shortcut. Overwrite and clear that other mapping?
|
||||
</span>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<ButtonSmall type="button" size="xs" className="!font-normal" onClick={confirmOverwrite}>Overwrite</ButtonSmall>
|
||||
<ButtonSmall type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => setPendingOverwrite(null)}>Cancel</ButtonSmall>
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={confirmOverwrite}>Overwrite</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => setPendingOverwrite(null)}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -233,7 +233,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
}}
|
||||
className="h-7 w-40 min-w-0 typography-ui-label text-center"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
@@ -249,10 +249,10 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
disabled={!hasDraft}
|
||||
>
|
||||
Save
|
||||
</ButtonSmall>
|
||||
<ButtonSmall type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
|
||||
</Button>
|
||||
<Button type="button" size="xs" className="!font-normal" variant="ghost" onClick={() => resetOne(action.id)}>
|
||||
Reset
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { GridLoader } from '@/components/ui/grid-loader';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -800,7 +800,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
step={50}
|
||||
className="w-20 tabular-nums"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setSummaryThreshold(DEFAULT_SUMMARY_THRESHOLD)}
|
||||
@@ -810,7 +810,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
@@ -827,7 +827,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
step={10}
|
||||
className="w-20 tabular-nums"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setSummaryLength(DEFAULT_SUMMARY_LENGTH)}
|
||||
@@ -837,7 +837,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -856,7 +856,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
step={10}
|
||||
className="w-20 tabular-nums"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setMaxLastMessageLength(DEFAULT_MAX_LAST_MESSAGE_LENGTH)}
|
||||
@@ -866,7 +866,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -99,7 +99,7 @@ const ShortcutsSectionContent: React.FC = () => {
|
||||
return <KeyboardShortcutsSettings />;
|
||||
};
|
||||
|
||||
// Visual section: Theme Mode, Font Size, Spacing, Corner Radius, Input Bar Offset (mobile), Nav Rail
|
||||
// Visual section: Theme Mode, Font Size, Spacing, Input Bar Offset (mobile), Nav Rail
|
||||
const VisualSectionContent: React.FC = () => {
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
return <OpenChamberVisualSettings visibleSettings={[
|
||||
@@ -108,9 +108,8 @@ const VisualSectionContent: React.FC = () => {
|
||||
'fontSize',
|
||||
'terminalFontSize',
|
||||
'spacing',
|
||||
'cornerRadius',
|
||||
'inputBarOffset',
|
||||
...(!isVSCode ? ['terminalQuickKeys' as const, 'navRail' as const] : []),
|
||||
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
|
||||
]} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ThemeMode } from '@/types/theme';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useMessageQueueStore } from '@/stores/messageQueueStore';
|
||||
import { cn, getModifierLabel } from '@/lib/utils';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { Radio } from '@/components/ui/radio';
|
||||
@@ -143,7 +143,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||
};
|
||||
|
||||
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck';
|
||||
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck';
|
||||
|
||||
interface OpenChamberVisualSettingsProps {
|
||||
/** Which settings to show. If undefined, shows all. */
|
||||
@@ -173,8 +173,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const setTerminalFontSize = useUIStore(state => state.setTerminalFontSize);
|
||||
const padding = useUIStore(state => state.padding);
|
||||
const setPadding = useUIStore(state => state.setPadding);
|
||||
const cornerRadius = useUIStore(state => state.cornerRadius);
|
||||
const setCornerRadius = useUIStore(state => state.setCornerRadius);
|
||||
const inputBarOffset = useUIStore(state => state.inputBarOffset);
|
||||
const setInputBarOffset = useUIStore(state => state.setInputBarOffset);
|
||||
const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference);
|
||||
@@ -195,10 +193,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const setShowExpandedBashTools = useUIStore(state => state.setShowExpandedBashTools);
|
||||
const showExpandedEditTools = useUIStore(state => state.showExpandedEditTools);
|
||||
const setShowExpandedEditTools = useUIStore(state => state.setShowExpandedEditTools);
|
||||
const isNavRailExpanded = useUIStore(state => state.isNavRailExpanded);
|
||||
const setNavRailExpanded = useUIStore(state => state.setNavRailExpanded);
|
||||
const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar);
|
||||
const setShowMobileSessionStatusBar = useUIStore(state => state.setShowMobileSessionStatusBar);
|
||||
const isSettingsDialogOpen = useUIStore(state => state.isSettingsDialogOpen);
|
||||
const {
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
@@ -214,7 +211,14 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const [themesReloading, setThemesReloading] = React.useState(false);
|
||||
const [chatRenderPreviewTick, setChatRenderPreviewTick] = React.useState(0);
|
||||
|
||||
const shouldAnimateChatPreview = isSettingsDialogOpen
|
||||
&& (visibleSettings ? visibleSettings.includes('chatRenderMode') : true);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldAnimateChatPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
setChatRenderPreviewTick((prev) => (prev + 1) % 24);
|
||||
}, 420);
|
||||
@@ -222,7 +226,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
}, [shouldAnimateChatPreview]);
|
||||
|
||||
const handleUserMessageRenderingModeChange = React.useCallback((mode: 'markdown' | 'plain') => {
|
||||
setUserMessageRenderingMode(mode);
|
||||
@@ -305,8 +309,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const hasAppearanceSettings = (shouldShow('theme') || shouldShow('pwaInstallName')) && !isVSCode;
|
||||
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset');
|
||||
const hasNavigationSettings = (!isMobile && shouldShow('navRail')) || (shouldShow('terminalQuickKeys') && !isMobile);
|
||||
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('inputBarOffset');
|
||||
const hasNavigationSettings = shouldShow('terminalQuickKeys') && !isMobile;
|
||||
const hasBehaviorSettings = shouldShow('mermaidRendering')
|
||||
|| shouldShow('userMessageRendering')
|
||||
|| shouldShow('chatRenderMode')
|
||||
@@ -402,7 +406,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<span className="typography-ui-header font-medium text-foreground">Color Mode</span>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{THEME_MODE_OPTIONS.map((option) => (
|
||||
<ButtonSmall
|
||||
<Button
|
||||
key={option.value}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -415,7 +419,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
onClick={() => setThemeMode(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -516,7 +520,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
maxLength={64}
|
||||
aria-label="PWA install app name"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
@@ -528,7 +532,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -558,7 +562,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
aria-label="Font size percentage"
|
||||
className="w-16"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setFontSize(100)}
|
||||
@@ -568,7 +572,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -587,7 +591,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
step={1}
|
||||
className="w-16"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setTerminalFontSize(13)}
|
||||
@@ -597,7 +601,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -616,7 +620,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
step={5}
|
||||
className="w-16"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setPadding(100)}
|
||||
@@ -626,36 +630,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShow('cornerRadius') && (
|
||||
<div className={cn("py-1", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
|
||||
<span className="typography-ui-label text-foreground">Corner Radius</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
|
||||
<NumberInput
|
||||
value={cornerRadius}
|
||||
onValueChange={setCornerRadius}
|
||||
min={0}
|
||||
max={32}
|
||||
step={1}
|
||||
className="w-16"
|
||||
/>
|
||||
<ButtonSmall
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setCornerRadius(12)}
|
||||
disabled={cornerRadius === 12}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Reset corner radius"
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -684,7 +659,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
step={5}
|
||||
className="w-16"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setInputBarOffset(0)}
|
||||
@@ -694,7 +669,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -710,38 +685,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
<div className="space-y-3">
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Navigation</h4>
|
||||
{shouldShow('navRail') && !isMobile && (
|
||||
<div
|
||||
className="group mt-1.5 flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setNavRailExpanded(!isNavRailExpanded)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setNavRailExpanded(!isNavRailExpanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isNavRailExpanded}
|
||||
onChange={setNavRailExpanded}
|
||||
ariaLabel="Expand project rail by default"
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">Expand project rail</span>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Show project names in the left rail when multiple projects are open. Auto-collapses with a single project.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShow('terminalQuickKeys') && !isMobile && (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { RiFolderLine, RiInformationLine } from '@remixicon/react';
|
||||
@@ -111,7 +111,7 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
disabled={isLoading || isSaving}
|
||||
className="h-7 min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -122,7 +122,7 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
title="Browse"
|
||||
>
|
||||
<RiFolderLine className="h-4 w-4" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,7 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start py-1.5">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={handleSaveAndReload}
|
||||
@@ -141,7 +141,7 @@ export const OpenCodeCliSettings: React.FC = () => {
|
||||
className="shrink-0 !font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save + Reload'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { RiInformationLine, RiRestartLine } from '@remixicon/react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||
@@ -90,7 +90,7 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
className="w-20 tabular-nums"
|
||||
/>
|
||||
<span className="typography-ui-label text-muted-foreground">days</span>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setAutoDeleteAfterDays(DEFAULT_RETENTION_DAYS)}
|
||||
@@ -100,7 +100,7 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
title="Reset"
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -111,7 +111,7 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
<p className="typography-meta text-foreground font-medium">Manual Cleanup</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:w-fit">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -120,7 +120,7 @@ export const SessionRetentionSettings: React.FC = () => {
|
||||
className="!font-normal"
|
||||
>
|
||||
{isRunning ? 'Cleaning up...' : 'Run cleanup now'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
RiRestartLine,
|
||||
} from '@remixicon/react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { GridLoader } from '@/components/ui/grid-loader';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -1121,7 +1121,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{TUNNEL_MODE_OPTIONS.map((option) => (
|
||||
<Tooltip key={option.value} delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className={cn(
|
||||
@@ -1136,7 +1136,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
{option.label}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{option.tooltip}
|
||||
@@ -1217,7 +1217,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
<div className="mb-1 flex items-center justify-between gap-3">
|
||||
<p className="typography-ui-label text-foreground">Saved managed remote tunnels</p>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
@@ -1226,7 +1226,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{managedRemoteTunnelPresets.length > 0 ? (
|
||||
@@ -1263,7 +1263,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
<span className="typography-ui-label min-w-0 flex-1 truncate text-foreground">{preset.name}</span>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-[var(--status-error)]"
|
||||
@@ -1274,7 +1274,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="pt-1.5">
|
||||
@@ -1305,7 +1305,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
disabled={state === 'starting' || state === 'stopping'}
|
||||
/>
|
||||
<div className="flex items-center justify-end">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
@@ -1320,7 +1320,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
Save token
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
@@ -1363,7 +1363,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
@@ -1373,8 +1373,8 @@ export const TunnelSettings: React.FC = () => {
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
Save
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
@@ -1387,7 +1387,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
disabled={isSavingMode || state === 'starting' || state === 'stopping'}
|
||||
>
|
||||
Cancel
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1442,7 +1442,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
className="h-7"
|
||||
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 w-7 p-0"
|
||||
@@ -1453,9 +1453,9 @@ export const TunnelSettings: React.FC = () => {
|
||||
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
|
||||
>
|
||||
<RiFolderLine className="size-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
{managedLocalConfigPath && (
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-7 w-7 p-0"
|
||||
@@ -1466,7 +1466,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
disabled={state === 'starting' || state === 'stopping' || isSavingMode}
|
||||
>
|
||||
<RiCloseLine className="size-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
@@ -1566,7 +1566,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStart}
|
||||
disabled={
|
||||
@@ -1580,7 +1580,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{state === 'starting'
|
||||
? <><RiLoader4Line className="size-3.5 animate-spin" /> Starting tunnel...</>
|
||||
: 'Start Tunnel'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1610,12 +1610,12 @@ export const TunnelSettings: React.FC = () => {
|
||||
<code className="typography-code flex-1 truncate rounded bg-muted/50 px-2 py-1 text-xs text-foreground">
|
||||
{tunnelInfo.connectUrl}
|
||||
</code>
|
||||
<ButtonSmall variant="ghost" onClick={handleCopyUrl} className="shrink-0 gap-1.5">
|
||||
<Button size="sm" variant="ghost" onClick={handleCopyUrl} className="shrink-0 gap-1.5">
|
||||
{copied
|
||||
? <RiCheckLine className="size-3.5 text-[var(--status-success)]" />
|
||||
: <RiFileCopyLine className="size-3.5" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="typography-meta mt-1 text-muted-foreground/70">
|
||||
Expires: {tunnelInfo.bootstrapExpiresAt ? remainingText : 'Never'}
|
||||
@@ -1634,7 +1634,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
|
||||
<div className="pt-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStart}
|
||||
disabled={state === 'stopping' || isSavingMode || (tunnelMode === 'managed-local' && isManagedLocalConfigPathInvalid)}
|
||||
@@ -1642,9 +1642,9 @@ export const TunnelSettings: React.FC = () => {
|
||||
>
|
||||
<RiRestartLine className="size-3.5" />
|
||||
New connect link
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleStop}
|
||||
disabled={state === 'stopping' || isSavingMode}
|
||||
@@ -1653,7 +1653,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{state === 'stopping'
|
||||
? <><RiLoader4Line className="size-3.5 animate-spin" /> Stopping...</>
|
||||
: 'Stop Tunnel'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1662,7 +1662,7 @@ export const TunnelSettings: React.FC = () => {
|
||||
{state === 'error' && errorMessage && (
|
||||
<section className="space-y-3 px-2 pb-2 pt-0">
|
||||
<p className="typography-meta text-[var(--status-error)]">{errorMessage}</p>
|
||||
<ButtonSmall variant="ghost" onClick={handleStart}>Retry</ButtonSmall>
|
||||
<Button size="sm" variant="ghost" onClick={handleStart}>Retry</Button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { RiPlayLine, RiStopLine, RiCloseLine, RiAppleLine, RiInformationLine } from '@remixicon/react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -363,7 +363,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setVoiceProvider('browser')}
|
||||
@@ -375,8 +375,8 @@ export const VoiceSettings: React.FC = () => {
|
||||
)}
|
||||
>
|
||||
Browser
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setVoiceProvider('openai')}
|
||||
@@ -388,9 +388,9 @@ export const VoiceSettings: React.FC = () => {
|
||||
)}
|
||||
>
|
||||
OpenAI
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
{isSayAvailable && (
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setVoiceProvider('say')}
|
||||
@@ -403,7 +403,7 @@ export const VoiceSettings: React.FC = () => {
|
||||
>
|
||||
<RiAppleLine className="w-3.5 h-3.5 mr-0.5" />
|
||||
Say
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -455,9 +455,9 @@ export const VoiceSettings: React.FC = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ButtonSmall variant="ghost" className="h-7 w-7 px-0" onClick={previewOpenAIVoice} title="Preview">
|
||||
<Button size="xs" variant="ghost" onClick={previewOpenAIVoice} title="Preview">
|
||||
{isOpenAIPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -473,9 +473,9 @@ export const VoiceSettings: React.FC = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ButtonSmall variant="ghost" className="h-7 w-7 px-0" onClick={previewVoice} title="Preview">
|
||||
<Button size="xs" variant="ghost" onClick={previewVoice} title="Preview">
|
||||
{isPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -492,9 +492,9 @@ export const VoiceSettings: React.FC = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ButtonSmall variant="ghost" className="h-7 w-7 px-0" onClick={previewBrowserVoice} title="Preview">
|
||||
<Button size="xs" variant="ghost" onClick={previewBrowserVoice} title="Preview">
|
||||
{isBrowserPreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { RiAddLine, RiCloseLine, RiDeleteBinLine, RiInformationLine } from '@remixicon/react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -306,7 +306,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@@ -315,7 +315,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add command
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
RiInformationLine,
|
||||
RiPlayLine,
|
||||
} from '@remixicon/react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -198,10 +198,10 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Actions</h3>
|
||||
<p className="typography-meta text-muted-foreground">Per-project commands shown in header next to project name.</p>
|
||||
</div>
|
||||
<ButtonSmall type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add action
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="pb-2 pt-0 space-y-2">
|
||||
@@ -248,7 +248,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@@ -256,7 +256,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
onClick={() => handleRemoveAction(action.id)}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="pt-1.5">
|
||||
@@ -418,7 +418,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
{validationError ? (
|
||||
<p className="typography-meta mb-2 text-[var(--status-warning)]">{validationError}</p>
|
||||
) : null}
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
@@ -426,7 +426,7 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
|
||||
disabled={!canSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Actions'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { toast } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -399,7 +399,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
placeholder="#000000"
|
||||
className="h-7 w-[8rem]"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
@@ -410,21 +410,21 @@ export const ProjectsPage: React.FC = () => {
|
||||
disabled={!iconBackground}
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{!hasCustomIcon && (
|
||||
<>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
size="xs"
|
||||
className="h-6 !font-normal"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploadingIcon}
|
||||
>
|
||||
{isUploadingIcon ? 'Uploading...' : 'Upload Icon'}
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
className="h-6 !font-normal"
|
||||
variant="outline"
|
||||
@@ -432,11 +432,11 @@ export const ProjectsPage: React.FC = () => {
|
||||
disabled={isDiscoveringIcon}
|
||||
>
|
||||
{isDiscoveringIcon ? 'Discovering...' : 'Discover Favicon'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{hasRemovableImageIcon && (
|
||||
<ButtonSmall
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
variant="outline"
|
||||
@@ -444,10 +444,10 @@ export const ProjectsPage: React.FC = () => {
|
||||
disabled={isRemovingCustomIcon}
|
||||
>
|
||||
{isRemovingCustomIcon ? 'Removing...' : 'Remove Project Icon'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
)}
|
||||
{pendingRemoveImageIcon && (
|
||||
<ButtonSmall
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
variant="outline"
|
||||
@@ -455,7 +455,7 @@ export const ProjectsPage: React.FC = () => {
|
||||
disabled={isRemovingCustomIcon}
|
||||
>
|
||||
Undo Remove
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -463,14 +463,14 @@ export const ProjectsPage: React.FC = () => {
|
||||
</section>
|
||||
|
||||
<div className="mt-0.5 px-2 py-1">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || name.trim().length === 0 || isUploadingIcon || isRemovingCustomIcon}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
Save Changes
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -653,14 +653,14 @@ export const ProvidersPage: React.FC = () => {
|
||||
placeholder="sk-..."
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={() => handleSaveApiKey(candidateProviderId)}
|
||||
disabled={authBusyKey === `api:${candidateProviderId}`}
|
||||
>
|
||||
{authBusyKey === `api:${candidateProviderId}` ? 'Saving...' : 'Save Key'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -693,7 +693,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
@@ -701,7 +701,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`}
|
||||
>
|
||||
Connect
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oauthDetails[codeKey]?.instructions && (
|
||||
@@ -713,7 +713,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
{oauthDetails[codeKey]?.userCode && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
|
||||
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code</ButtonSmall>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -721,8 +721,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open</ButtonSmall>
|
||||
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</ButtonSmall>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -740,14 +740,14 @@ export const ProvidersPage: React.FC = () => {
|
||||
placeholder="Paste authorization code"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthComplete(candidateProviderId, index)}
|
||||
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`}
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${candidateProviderId}:${index}` ? 'Saving...' : 'Complete'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -810,14 +810,14 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1 flex items-center justify-between gap-2">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">Authentication</h3>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => setShowAuthPanel((prev) => !prev)}
|
||||
>
|
||||
{showAuthPanel ? 'Hide' : 'Reconnect'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
@@ -856,14 +856,14 @@ export const ProvidersPage: React.FC = () => {
|
||||
placeholder="sk-..."
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
onClick={() => handleSaveApiKey(selectedProvider.id)}
|
||||
disabled={authBusyKey === `api:${selectedProvider.id}`}
|
||||
>
|
||||
{authBusyKey === `api:${selectedProvider.id}` ? 'Saving...' : 'Save Key'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -886,7 +886,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
@@ -894,7 +894,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`}
|
||||
>
|
||||
Connect
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oauthDetails[codeKey]?.instructions && (
|
||||
@@ -906,7 +906,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
{oauthDetails[codeKey]?.userCode && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
|
||||
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code</ButtonSmall>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>Copy Code</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -914,8 +914,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open</ButtonSmall>
|
||||
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</ButtonSmall>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => window.open(oauthDetails[codeKey]?.url, '_blank', 'noopener,noreferrer')}>Open</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>Copy</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -933,14 +933,14 @@ export const ProvidersPage: React.FC = () => {
|
||||
placeholder="Paste authorization code"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => handleOAuthComplete(selectedProvider.id, index)}
|
||||
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`}
|
||||
>
|
||||
{authBusyKey === `oauth-complete:${selectedProvider.id}:${index}` ? 'Saving...' : 'Complete'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -976,7 +976,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
@@ -984,7 +984,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
disabled={authBusyKey === `disconnect:${selectedProvider.id}`}
|
||||
>
|
||||
{authBusyKey === `disconnect:${selectedProvider.id}` ? 'Disconnecting...' : 'Disconnect'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -1001,7 +1001,7 @@ export const ProvidersPage: React.FC = () => {
|
||||
)}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
@@ -1013,15 +1013,15 @@ export const ProvidersPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
Hide all
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => showAllModels(selectedProvider.id)}
|
||||
>
|
||||
Show all
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { RiAddLine, RiStackLine } from '@remixicon/react';
|
||||
@@ -110,7 +110,7 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {providers.length}</span>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
onClick={() => {
|
||||
@@ -121,7 +121,7 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
|
||||
title="Connect provider"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -701,7 +701,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground">{candidate.source} config</div>
|
||||
</div>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -709,7 +709,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
|
||||
>
|
||||
Create
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -746,12 +746,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ButtonSmall type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
|
||||
Cancel
|
||||
</ButtonSmall>
|
||||
<ButtonSmall type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
|
||||
</Button>
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
|
||||
Create
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
@@ -782,7 +782,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
<section className="px-2 pb-2 pt-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant={canDisconnect ? 'outline' : 'default'}
|
||||
size="xs"
|
||||
@@ -792,8 +792,8 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
>
|
||||
{canDisconnect ? <RiStopLine className="h-3.5 w-3.5" /> : <RiPlug2Line className="h-3.5 w-3.5" />}
|
||||
{primaryButtonLabel}
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -803,8 +803,8 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
>
|
||||
<RiRefreshLine className={`h-3.5 w-3.5 ${isConnecting || (isReconnecting && !reconnectAppearsStuck) ? 'animate-spin' : ''}`} />
|
||||
{retryButtonLabel}
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -815,8 +815,8 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
>
|
||||
<RiTerminalWindowLine className="h-3.5 w-3.5" />
|
||||
Logs
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -838,7 +838,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
Remove
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
{status?.localUrl ? (
|
||||
<div className="flex flex-wrap items-center gap-2 typography-meta text-muted-foreground">
|
||||
@@ -1114,7 +1114,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}}
|
||||
emptyLabel="Auto"
|
||||
/>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -1131,7 +1131,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<RiShuffleLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1254,7 +1254,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={forward.enabled} onCheckedChange={(checked) => updateForward((item) => ({ ...item, enabled: checked }))} aria-label="Enable forward" />
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@@ -1267,7 +1267,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleContent className="pt-2">
|
||||
@@ -1416,7 +1416,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{canOpenLocalEndpoint ? (
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -1431,7 +1431,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
>
|
||||
<RiExternalLinkLine className="h-3.5 w-3.5" />
|
||||
Open local
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1440,7 +1440,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
);
|
||||
})}
|
||||
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -1459,7 +1459,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add forward
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1486,7 +1486,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground truncate">{candidate.sshCommand}</div>
|
||||
</div>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -1494,7 +1494,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
onClick={() => void handleImportCandidate(candidate.host, candidate.pattern)}
|
||||
>
|
||||
Import
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1504,12 +1504,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
|
||||
<div className="sticky bottom-0 z-10 -mx-3 sm:-mx-6 bg-[var(--surface-background)] border-t border-[var(--interactive-border)] px-3 sm:px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonSmall type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>
|
||||
<Button type="button" size="xs" className="!font-normal" onClick={() => void handleSave()} disabled={!hasChanges || isSaving}>
|
||||
Save changes
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
{status?.localUrl ? (
|
||||
<>
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -1524,8 +1524,8 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
>
|
||||
<RiFileCopyLine className="h-3.5 w-3.5" />
|
||||
Copy local URL
|
||||
</ButtonSmall>
|
||||
<ButtonSmall
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -1536,7 +1536,7 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
>
|
||||
<RiExternalLinkLine className="h-3.5 w-3.5" />
|
||||
Open
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{error ? <div className="ml-auto typography-meta text-[var(--status-error)]">{error}</div> : null}
|
||||
@@ -1552,14 +1552,14 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ButtonSmall type="button" variant="outline" size="xs" className="!font-normal" onClick={handleCopyAllLogs} disabled={logDialogLoading || !logLinesText.trim()}>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={handleCopyAllLogs} disabled={logDialogLoading || !logLinesText.trim()}>
|
||||
<RiFileCopyLine className="h-3.5 w-3.5" />
|
||||
Copy all
|
||||
</ButtonSmall>
|
||||
<ButtonSmall type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void handleClearLogs()} disabled={logDialogLoading}>
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={() => void handleClearLogs()} disabled={logDialogLoading}>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
Clear
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
{logDialogLoading ? (
|
||||
<div className="typography-meta text-muted-foreground">Loading logs...</div>
|
||||
@@ -1602,12 +1602,12 @@ export const RemoteInstancesPage: React.FC = () => {
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ButtonSmall type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
|
||||
<Button type="button" variant="outline" size="xs" className="!font-normal" onClick={closePatternDialog} disabled={patternCreating}>
|
||||
Cancel
|
||||
</ButtonSmall>
|
||||
<ButtonSmall type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
|
||||
</Button>
|
||||
<Button type="submit" size="xs" className="!font-normal" disabled={patternCreating}>
|
||||
Create
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
|
||||
import {
|
||||
SKILL_LOCATION_OPTIONS,
|
||||
@@ -459,9 +458,9 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
Supporting Files
|
||||
</h3>
|
||||
<ButtonSmall variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile}>
|
||||
<Button variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile}>
|
||||
<RiAddLine className="h-3.5 w-3.5" /> Add File
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0">
|
||||
@@ -491,7 +490,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
pending
|
||||
</span>
|
||||
)}
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 px-0 flex-shrink-0 text-muted-foreground hover:text-[var(--status-error)] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
@@ -500,7 +499,7 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3 w-3" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -511,14 +510,14 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
|
||||
{/* Save action */}
|
||||
<div className="px-2 py-1">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasSkillChanges}
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
>
|
||||
{isSaving ? 'Saving...' : isNewSkill ? 'Create Skill' : 'Save Changes'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -540,16 +539,17 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonLarge
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDeleteFilePath(null)}
|
||||
disabled={isDeletingFile}
|
||||
>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge onClick={handleConfirmDeleteFile} disabled={isDeletingFile} className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white border-0">
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleConfirmDeleteFile} disabled={isDeletingFile}>
|
||||
Delete
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -603,7 +603,8 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="mt-4">
|
||||
<ButtonLarge
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsFileDialogOpen(false);
|
||||
@@ -611,10 +612,10 @@ const SkillsInstalledPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveFile} disabled={isLoadingFile || !hasFileChanges}>
|
||||
{editingFilePath ? 'Save Changes' : 'Create File'}
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { isMobileDeviceViaCSS } from '@/lib/device';
|
||||
@@ -207,13 +206,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
<SettingsProjectSelector className="mb-3" />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {skills.length}</span>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 -my-1 text-muted-foreground"
|
||||
onClick={handleCreateNew}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -347,17 +346,18 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonLarge
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
|
||||
variant="ghost"
|
||||
onClick={() => setDeleteDialogSkill(null)}
|
||||
disabled={isDeletePending}
|
||||
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge onClick={handleConfirmDeleteSkill} disabled={isDeletePending}>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirmDeleteSkill} disabled={isDeletePending}>
|
||||
Delete
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -383,16 +383,17 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<ButtonLarge
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
|
||||
variant="ghost"
|
||||
onClick={() => setRenameDialogSkill(null)}
|
||||
className="text-foreground hover:bg-interactive-hover hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge onClick={handleRenameSkill}>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRenameSkill}>
|
||||
Rename
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -461,12 +462,12 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
||||
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 px-0 flex-shrink-0 -mr-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit min-w-20">
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
@@ -311,24 +311,26 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
variant="ghost"
|
||||
onClick={() => void handleScan()}
|
||||
disabled={isScanning || !source.trim()}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiGitRepositoryLine className="h-4 w-4" />
|
||||
{isScanning ? 'Scanning...' : 'Scan'}
|
||||
</ButtonLarge>
|
||||
<ButtonLarge
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={!scanOk || isDuplicate || !label.trim() || !source.trim()}
|
||||
>
|
||||
Add catalog
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -73,8 +72,8 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">{conflicts.length} conflict(s)</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => setAll('skip')}>Skip all</ButtonSmall>
|
||||
<ButtonSmall variant="outline" size="xs" className="!font-normal" onClick={() => setAll('overwrite')}>Overwrite all</ButtonSmall>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('skip')}>Skip all</Button>
|
||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => setAll('overwrite')}>Overwrite all</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,15 +112,16 @@ export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onConfirm(decisions)}
|
||||
disabled={!canConfirm}
|
||||
>
|
||||
Continue
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -487,15 +486,16 @@ export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ op
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<ButtonLarge variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isInstalling || selectedDirs.length === 0 || !source.trim() || (scope === 'project' && !directoryOverride)}
|
||||
onClick={() => void doInstall({})}
|
||||
>
|
||||
{isInstalling ? 'Installing…' : 'Install selected'}
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -231,13 +231,15 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<ButtonLarge
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isInstalling || !item.installable || (scope === 'project' && !directoryOverride)}
|
||||
onClick={() =>
|
||||
void doInstall({
|
||||
@@ -251,7 +253,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
}
|
||||
>
|
||||
{isInstalling ? 'Installing...' : 'Install'}
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -195,7 +194,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0"
|
||||
@@ -210,10 +209,10 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
title="Refresh"
|
||||
>
|
||||
<RiRefreshLine className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
|
||||
{isCustomSource && (
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
@@ -222,16 +221,16 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
title="Remove Catalog"
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ButtonSmall
|
||||
<Button
|
||||
size="xs"
|
||||
className="!font-normal gap-1"
|
||||
onClick={() => setAddCatalogOpen(true)}
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" /> Add Catalog
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="py-1.5">
|
||||
@@ -328,7 +327,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
@@ -339,7 +338,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
}}
|
||||
>
|
||||
Install
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -350,7 +349,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
|
||||
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
|
||||
<div className="flex justify-center mt-2 px-2">
|
||||
<ButtonSmall
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
@@ -358,7 +357,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? 'Loading...' : 'Load More Skills'}
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -381,16 +380,17 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<DialogDescription>Are you sure you want to remove this catalog?</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<ButtonLarge
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsRemoveCatalogDialogOpen(false)}
|
||||
disabled={isRemovingCatalog}
|
||||
>
|
||||
Cancel
|
||||
</ButtonLarge>
|
||||
<ButtonLarge className="bg-[var(--status-error)] hover:bg-[var(--status-error)]/90 text-white" onClick={() => void removeSelectedCatalog()} disabled={isRemovingCatalog}>
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => void removeSelectedCatalog()} disabled={isRemovingCatalog}>
|
||||
Remove Catalog
|
||||
</ButtonLarge>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
@@ -111,7 +111,7 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
<SelectItem value="300000">5m</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ButtonSmall
|
||||
<Button size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 px-0 text-muted-foreground"
|
||||
onClick={() => fetchAllQuotas()}
|
||||
@@ -120,7 +120,7 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RiRefreshLine className={cn('h-3.5 w-3.5', isLoading && 'animate-spin')} />
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
|
||||
@@ -148,8 +148,8 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
<div
|
||||
ref={droppableRef}
|
||||
className={cn(
|
||||
'group/folder flex items-center justify-between gap-1.5 py-1 min-w-0 rounded-sm',
|
||||
'hover:bg-interactive-hover/50 cursor-pointer',
|
||||
'group/folder relative flex items-center justify-between gap-1.5 py-1 min-w-0 rounded-md',
|
||||
'cursor-pointer',
|
||||
isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30',
|
||||
)}
|
||||
onClick={renaming ? undefined : onToggle}
|
||||
@@ -167,7 +167,12 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
}
|
||||
aria-label={isCollapsed ? `Expand folder ${folder.name}` : `Collapse folder ${folder.name}`}
|
||||
>
|
||||
<div className="min-w-0 flex items-center gap-1.5 pl-1.5 flex-1">
|
||||
<div className={cn(
|
||||
'min-w-0 flex items-center gap-1.5 pl-1.5 flex-1 transition-[padding]',
|
||||
archivedBucket
|
||||
? (mobileVariant ? 'pr-7' : 'group-hover/folder:pr-7 group-focus-within/folder:pr-7')
|
||||
: '',
|
||||
)}>
|
||||
<FolderIcon className={cn('h-3.5 w-3.5 flex-shrink-0', isDropTarget ? 'text-primary' : 'text-muted-foreground')} />
|
||||
|
||||
{renaming ? (
|
||||
@@ -243,15 +248,16 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{!renaming && !hideActions ? (
|
||||
{!renaming && (!hideActions || archivedBucket) ? (
|
||||
<div className="flex items-center gap-0.5 px-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-0.5 transition-opacity',
|
||||
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100',
|
||||
archivedBucket && 'absolute right-0.5 top-1/2 z-10 -translate-y-1/2 px-0',
|
||||
)}
|
||||
>
|
||||
{onNewSession ? (
|
||||
{!archivedBucket && onNewSession ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
@@ -266,7 +272,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
</button>
|
||||
) : null}
|
||||
{/* Only allow sub-folders at depth 0 (one level deep max) */}
|
||||
{onNewSubFolder && depth === 0 ? (
|
||||
{!archivedBucket && onNewSubFolder && depth === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
@@ -280,17 +286,19 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
<RiFolderAddLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleStartRename();
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`Rename folder ${folder.name}`}
|
||||
>
|
||||
<RiPencilAiLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{!archivedBucket ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleStartRename();
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`Rename folder ${folder.name}`}
|
||||
>
|
||||
<RiPencilAiLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
@@ -298,7 +306,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
onDelete();
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`Delete folder ${folder.name}`}
|
||||
aria-label={archivedBucket ? `Delete archived sessions in folder ${folder.name}` : `Delete folder ${folder.name}`}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { RiLayoutLeftLine } from '@remixicon/react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
@@ -9,10 +11,8 @@ import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import type { GitHubPullRequestStatus } from '@/lib/api/types';
|
||||
import { getSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
@@ -35,10 +35,14 @@ import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists';
|
||||
import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup';
|
||||
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
|
||||
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
||||
import { SessionGroupSection } from './sidebar/SessionGroupSection';
|
||||
import { SidebarHeader } from './sidebar/SidebarHeader';
|
||||
import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
|
||||
import { SidebarFooter } from './sidebar/SidebarFooter';
|
||||
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
|
||||
import { SessionNodeItem } from './sidebar/SessionNodeItem';
|
||||
import type { SortableDragHandleProps } from './sidebar/sortableItems';
|
||||
import {
|
||||
FolderDeleteConfirmDialog,
|
||||
SessionDeleteConfirmDialog,
|
||||
@@ -46,6 +50,13 @@ import {
|
||||
type DeleteSessionConfirmState,
|
||||
} from './sidebar/ConfirmDialogs';
|
||||
import { type SessionGroup, type SessionNode } from './sidebar/types';
|
||||
import {
|
||||
addActiveNowSession,
|
||||
deriveActiveNowSessions,
|
||||
persistActiveNowEntries,
|
||||
pruneActiveNowEntries,
|
||||
readActiveNowEntries,
|
||||
} from './sidebar/activitySections';
|
||||
import {
|
||||
compareSessionsByPinnedAndTime,
|
||||
formatProjectLabel,
|
||||
@@ -130,7 +141,6 @@ interface SessionSidebarProps {
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
allowReselect?: boolean;
|
||||
hideDirectoryControls?: boolean;
|
||||
hideProjectSelector?: boolean;
|
||||
showOnlyMainWorkspace?: boolean;
|
||||
}
|
||||
|
||||
@@ -139,7 +149,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
onSessionSelected,
|
||||
allowReselect = false,
|
||||
hideDirectoryControls = false,
|
||||
hideProjectSelector = true,
|
||||
showOnlyMainWorkspace = false,
|
||||
}) => {
|
||||
const [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false);
|
||||
@@ -148,13 +157,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const sessionSearchInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
|
||||
const [editProjectTitle, setEditProjectTitle] = React.useState('');
|
||||
const [editingProjectDialogId, setEditingProjectDialogId] = React.useState<string | null>(null);
|
||||
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
|
||||
const [directoryStatus, setDirectoryStatus] = React.useState<Map<string, 'unknown' | 'exists' | 'missing'>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const safeStorage = React.useMemo(() => getSafeStorage(), []);
|
||||
const [activeNowEntries, setActiveNowEntries] = React.useState(() => readActiveNowEntries(safeStorage));
|
||||
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
||||
@@ -162,7 +171,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
||||
const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false);
|
||||
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
|
||||
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
|
||||
const [openSidebarMenuKey, setOpenSidebarMenuKey] = React.useState<string | null>(null);
|
||||
const [renamingFolderId, setRenamingFolderId] = React.useState<string | null>(null);
|
||||
const [renameFolderDraft, setRenameFolderDraft] = React.useState('');
|
||||
const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState<DeleteSessionConfirmState>(null);
|
||||
@@ -228,8 +237,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const [isProjectRenameInline, setIsProjectRenameInline] = React.useState(false);
|
||||
const [projectRenameDraft, setProjectRenameDraft] = React.useState('');
|
||||
const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map());
|
||||
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
const ignoreIntersectionUntil = React.useRef<number>(0);
|
||||
@@ -243,17 +250,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const addProject = useProjectsStore((state) => state.addProject);
|
||||
const removeProject = useProjectsStore((state) => state.removeProject);
|
||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||
const renameProject = useProjectsStore((state) => state.renameProject);
|
||||
const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
|
||||
const reorderProjects = useProjectsStore((state) => state.reorderProjects);
|
||||
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const toggleHelpDialog = useUIStore((state) => state.toggleHelpDialog);
|
||||
const setAboutDialogOpen = useUIStore((state) => state.setAboutDialogOpen);
|
||||
const deviceInfo = useDeviceInfo();
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
|
||||
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
|
||||
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
|
||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
||||
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
||||
|
||||
const debouncedSessionSearchQuery = useDebouncedValue(sessionSearchQuery, 120);
|
||||
const normalizedSessionSearchQuery = React.useMemo(
|
||||
@@ -307,8 +318,89 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
|
||||
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
||||
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
||||
const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false);
|
||||
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
const isMacPlatform = React.useMemo(() => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
|
||||
}, []);
|
||||
const showDesktopSidebarChrome = !mobileVariant && !isVSCode;
|
||||
const desktopSidebarTopPaddingClass = isDesktopShellRuntime && isMacPlatform && !isDesktopWindowFullscreen ? 'pl-[5.5rem]' : 'pl-3';
|
||||
const desktopSidebarToggleButtonClass = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center rounded-md typography-ui-label font-medium text-foreground transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50';
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDesktopShellRuntime || !isMacPlatform) {
|
||||
setIsDesktopWindowFullscreen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let unlistenResize: (() => void) | null = null;
|
||||
|
||||
const syncFullscreenState = async () => {
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
const fullscreen = await currentWindow.isFullscreen();
|
||||
if (!disposed) {
|
||||
setIsDesktopWindowFullscreen(fullscreen);
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
setIsDesktopWindowFullscreen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const attach = async () => {
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const currentWindow = getCurrentWindow();
|
||||
unlistenResize = await currentWindow.onResized(() => {
|
||||
void syncFullscreenState();
|
||||
});
|
||||
} catch {
|
||||
// Ignore listener setup failures; fallback state remains false.
|
||||
}
|
||||
};
|
||||
|
||||
void syncFullscreenState();
|
||||
void attach();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (unlistenResize) {
|
||||
unlistenResize();
|
||||
}
|
||||
};
|
||||
}, [isDesktopShellRuntime, isMacPlatform]);
|
||||
|
||||
const handleDesktopSidebarDragStart = React.useCallback(async (event: React.MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.app-region-no-drag')) {
|
||||
return;
|
||||
}
|
||||
if (target.closest('button, a, input, select, textarea')) {
|
||||
return;
|
||||
}
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (!isDesktopShellRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const appWindow = getCurrentWindow();
|
||||
await appWindow.startDragging();
|
||||
} catch (error) {
|
||||
console.error('Failed to start window dragging:', error);
|
||||
}
|
||||
}, [isDesktopShellRuntime]);
|
||||
|
||||
const {
|
||||
buildGroupSearchText,
|
||||
@@ -359,11 +451,93 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
||||
}, [sessions, pinnedSessionIds]);
|
||||
|
||||
useSessionPrefetch({
|
||||
currentSessionId,
|
||||
sortedSessions,
|
||||
loadMessages,
|
||||
});
|
||||
const allKnownSessionsById = React.useMemo(() => {
|
||||
const next = new Map<string, Session>();
|
||||
[...sessions, ...archivedSessions].forEach((session) => {
|
||||
next.set(session.id, session);
|
||||
});
|
||||
return next;
|
||||
}, [sessions, archivedSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const pruned = pruneActiveNowEntries(activeNowEntries, allKnownSessionsById);
|
||||
if (pruned.length === activeNowEntries.length && pruned.every((entry, index) => entry.sessionId === activeNowEntries[index]?.sessionId)) {
|
||||
return;
|
||||
}
|
||||
setActiveNowEntries(pruned);
|
||||
persistActiveNowEntries(safeStorage, pruned);
|
||||
}, [activeNowEntries, allKnownSessionsById, safeStorage]);
|
||||
|
||||
const previousStreamingIdsRef = React.useRef<Set<string>>(new Set());
|
||||
React.useEffect(() => {
|
||||
const nextStreamingIds = new Set<string>();
|
||||
sessionStatus?.forEach((status, sessionId) => {
|
||||
if (status?.type === 'busy' || status?.type === 'retry') {
|
||||
nextStreamingIds.add(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
const previousStreamingIds = previousStreamingIdsRef.current;
|
||||
const startedStreamingIds = Array.from(nextStreamingIds).filter((sessionId) => !previousStreamingIds.has(sessionId));
|
||||
if (startedStreamingIds.length > 0) {
|
||||
setActiveNowEntries((prev) => {
|
||||
const next = startedStreamingIds.reduce((entries, sessionId) => addActiveNowSession(entries, sessionId), prev);
|
||||
if (next === prev) {
|
||||
return prev;
|
||||
}
|
||||
persistActiveNowEntries(safeStorage, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
previousStreamingIdsRef.current = nextStreamingIds;
|
||||
}, [sessionStatus, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const busyIds: string[] = [];
|
||||
sessionStatus?.forEach((status, sessionId) => {
|
||||
if (status?.type === 'busy' || status?.type === 'retry') {
|
||||
busyIds.push(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
if (busyIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveNowEntries((prev) => {
|
||||
const known = new Set(prev.map((entry) => entry.sessionId));
|
||||
let next = prev;
|
||||
let changed = false;
|
||||
|
||||
busyIds.forEach((sessionId) => {
|
||||
if (known.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = allKnownSessionsById.get(sessionId);
|
||||
if (!session || session.time?.archived) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSubtask = Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||
if (isSubtask) {
|
||||
return;
|
||||
}
|
||||
|
||||
next = addActiveNowSession(next, sessionId);
|
||||
known.add(sessionId);
|
||||
changed = true;
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
persistActiveNowEntries(safeStorage, next);
|
||||
return next;
|
||||
});
|
||||
}, [sessionStatus, allKnownSessionsById, safeStorage]);
|
||||
|
||||
const childrenMap = React.useMemo(() => {
|
||||
const map = new Map<string, Session[]>();
|
||||
@@ -394,17 +568,21 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleSaveProjectEdit = React.useCallback(() => {
|
||||
if (editingProjectId && editProjectTitle.trim()) {
|
||||
renameProject(editingProjectId, editProjectTitle.trim());
|
||||
setEditingProjectId(null);
|
||||
setEditProjectTitle('');
|
||||
}
|
||||
}, [editingProjectId, editProjectTitle, renameProject]);
|
||||
const editingProject = React.useMemo(
|
||||
() => projects.find((project) => project.id === editingProjectDialogId) ?? null,
|
||||
[projects, editingProjectDialogId],
|
||||
);
|
||||
|
||||
const handleCancelProjectEdit = React.useCallback(() => {
|
||||
setEditingProjectId(null);
|
||||
setEditProjectTitle('');
|
||||
const handleSaveProjectEdit = React.useCallback((data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => {
|
||||
if (!editingProjectDialogId) {
|
||||
return;
|
||||
}
|
||||
updateProjectMeta(editingProjectDialogId, data);
|
||||
setEditingProjectDialogId(null);
|
||||
}, [editingProjectDialogId, updateProjectMeta]);
|
||||
|
||||
const openNewWorktreeDialog = React.useCallback(() => {
|
||||
setNewWorktreeDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const deleteSession = useSessionStore((state) => state.deleteSession);
|
||||
@@ -569,6 +747,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
path: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
iconBackground?: string;
|
||||
}>;
|
||||
}, [projects]);
|
||||
|
||||
@@ -627,7 +809,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
searchMatchCount,
|
||||
} = useSessionSidebarSections({
|
||||
normalizedProjects,
|
||||
activeProjectId,
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
availableWorktreesByProject,
|
||||
@@ -671,7 +852,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const stableActiveProjectIsRepo = activeProjectForHeader && projectRepoStatus.has(activeProjectForHeader.id)
|
||||
? activeProjectIsRepo
|
||||
: lastRepoStatusRef.current;
|
||||
const reserveHeaderActionsSpace = Boolean(activeProjectForHeader);
|
||||
const reserveHeaderActionsSpace = true;
|
||||
const useMobileNotesPanel = mobileVariant || deviceInfo.isMobile;
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -687,7 +868,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
setActiveSessionByProject,
|
||||
currentSessionId,
|
||||
handleSessionSelect,
|
||||
isVSCode,
|
||||
newSessionDraftOpen,
|
||||
mobileVariant,
|
||||
openNewSessionDraft,
|
||||
@@ -698,30 +878,141 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
|
||||
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
|
||||
const hasInitializedArchivedCollapseRef = React.useRef(false);
|
||||
|
||||
const handleStartInlineProjectRename = React.useCallback(() => {
|
||||
if (!activeProjectForHeader) {
|
||||
React.useEffect(() => {
|
||||
if (hasInitializedArchivedCollapseRef.current || projectSections.length === 0) {
|
||||
return;
|
||||
}
|
||||
setProjectRenameDraft(formatProjectLabel(
|
||||
activeProjectForHeader.label?.trim()
|
||||
|| formatDirectoryName(activeProjectForHeader.normalizedPath, homeDirectory)
|
||||
|| activeProjectForHeader.normalizedPath,
|
||||
));
|
||||
setIsProjectRenameInline(true);
|
||||
}, [activeProjectForHeader, homeDirectory]);
|
||||
const archivedGroupKeys = projectSections.flatMap((section) =>
|
||||
section.groups
|
||||
.filter((group) => group.isArchivedBucket)
|
||||
.map((group) => `${section.project.id}:${group.id}`),
|
||||
);
|
||||
if (archivedGroupKeys.length > 0) {
|
||||
setCollapsedGroups((prev) => new Set([...prev, ...archivedGroupKeys]));
|
||||
}
|
||||
hasInitializedArchivedCollapseRef.current = true;
|
||||
}, [projectSections]);
|
||||
|
||||
const handleSaveInlineProjectRename = React.useCallback(() => {
|
||||
if (!activeProjectForHeader) {
|
||||
return;
|
||||
const sessionSidebarMetaById = React.useMemo(() => {
|
||||
const meta = new Map<string, {
|
||||
node: SessionNode;
|
||||
projectId: string | null;
|
||||
groupDirectory: string | null;
|
||||
secondaryMeta: {
|
||||
projectLabel?: string | null;
|
||||
branchLabel?: string | null;
|
||||
} | null;
|
||||
}>();
|
||||
|
||||
projectSections.forEach((section) => {
|
||||
const projectLabel = formatProjectLabel(
|
||||
section.project.label?.trim()
|
||||
|| formatDirectoryName(section.project.normalizedPath, homeDirectory)
|
||||
|| section.project.normalizedPath,
|
||||
);
|
||||
section.groups.forEach((group) => {
|
||||
const secondaryMeta = group.branch && group.branch !== projectLabel
|
||||
? { projectLabel, branchLabel: group.branch }
|
||||
: { projectLabel, branchLabel: null };
|
||||
|
||||
const visit = (nodes: SessionNode[]) => {
|
||||
nodes.forEach((node) => {
|
||||
meta.set(node.session.id, {
|
||||
node,
|
||||
projectId: section.project.id,
|
||||
groupDirectory: group.directory,
|
||||
secondaryMeta,
|
||||
});
|
||||
if (node.children.length > 0) {
|
||||
visit(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
visit(group.sessions);
|
||||
});
|
||||
});
|
||||
|
||||
return meta;
|
||||
}, [projectSections, homeDirectory]);
|
||||
|
||||
const activeNowSessions = React.useMemo(
|
||||
() => deriveActiveNowSessions(activeNowEntries, new Map(sessions.map((session) => [session.id, session]))),
|
||||
[activeNowEntries, sessions],
|
||||
);
|
||||
|
||||
useSessionPrefetch({
|
||||
currentSessionId,
|
||||
sortedSessions,
|
||||
recentSessionIds: activeNowSessions.map((session) => session.id),
|
||||
loadMessages,
|
||||
});
|
||||
|
||||
const activitySections = React.useMemo(() => {
|
||||
const toItem = (session: Session) => {
|
||||
const existing = sessionSidebarMetaById.get(session.id);
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
return {
|
||||
node: existing?.node ?? { session, children: [], worktree: null },
|
||||
projectId: existing?.projectId ?? null,
|
||||
groupDirectory: existing?.groupDirectory ?? sessionDirectory,
|
||||
secondaryMeta: existing?.secondaryMeta ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
return [
|
||||
{ key: 'active-now' as const, title: 'recent', items: activeNowSessions.map(toItem) },
|
||||
];
|
||||
}, [activeNowSessions, sessionSidebarMetaById]);
|
||||
|
||||
const activitySessionIds = React.useMemo(() => {
|
||||
const next = new Set<string>();
|
||||
activitySections.forEach((section) => {
|
||||
section.items.forEach((item) => {
|
||||
next.add(item.node.session.id);
|
||||
});
|
||||
});
|
||||
return next;
|
||||
}, [activitySections]);
|
||||
|
||||
const filteredProjectSections = React.useMemo(() => {
|
||||
if (hasSessionSearchQuery || activitySessionIds.size === 0) {
|
||||
return projectSections;
|
||||
}
|
||||
const trimmed = projectRenameDraft.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
|
||||
const filterNodes = (nodes: SessionNode[]): SessionNode[] => {
|
||||
return nodes.flatMap((node) => {
|
||||
if (activitySessionIds.has(node.session.id)) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
...node,
|
||||
children: filterNodes(node.children),
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
return projectSections.map((section) => ({
|
||||
...section,
|
||||
groups: section.groups.map((group) => ({
|
||||
...group,
|
||||
sessions: filterNodes(group.sessions),
|
||||
})),
|
||||
}));
|
||||
}, [hasSessionSearchQuery, activitySessionIds, projectSections]);
|
||||
|
||||
const filteredSectionsForRender = React.useMemo(() => {
|
||||
if (hasSessionSearchQuery || activitySessionIds.size === 0) {
|
||||
return sectionsForRender;
|
||||
}
|
||||
renameProject(activeProjectForHeader.id, trimmed);
|
||||
setIsProjectRenameInline(false);
|
||||
}, [activeProjectForHeader, projectRenameDraft, renameProject]);
|
||||
|
||||
const sectionsByProjectId = new Map(filteredProjectSections.map((section) => [section.project.id, section]));
|
||||
return sectionsForRender
|
||||
.map((section) => sectionsByProjectId.get(section.project.id) ?? section)
|
||||
.filter(Boolean);
|
||||
}, [hasSessionSearchQuery, activitySessionIds, filteredProjectSections, sectionsForRender]);
|
||||
|
||||
const desktopHeaderActionButtonClass =
|
||||
'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed';
|
||||
@@ -729,14 +1020,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed';
|
||||
const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass;
|
||||
const headerActionIconClass = 'h-4.5 w-4.5';
|
||||
const addProjectButtonClass = cn(
|
||||
'inline-flex cursor-pointer items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed',
|
||||
mobileVariant
|
||||
? 'h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50'
|
||||
: 'h-8 w-8 text-foreground hover:bg-interactive-hover',
|
||||
!isDesktopShellRuntime && 'bg-transparent hover:bg-sidebar/40',
|
||||
);
|
||||
|
||||
const stuckProjectHeaders = useStickyProjectHeaders({
|
||||
isDesktopShellRuntime,
|
||||
projectSections,
|
||||
@@ -750,6 +1033,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
groupDirectory?: string | null,
|
||||
projectId?: string | null,
|
||||
archivedBucket = false,
|
||||
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
|
||||
renderContext: 'project' | 'recent' = 'project',
|
||||
): React.ReactNode => (
|
||||
<SessionNodeItem
|
||||
node={node}
|
||||
@@ -782,8 +1067,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
copiedSessionId={copiedSessionId}
|
||||
handleCopyShareUrl={handleCopyShareUrl}
|
||||
handleUnshareSession={handleUnshareSession}
|
||||
openMenuSessionId={openMenuSessionId}
|
||||
setOpenMenuSessionId={setOpenMenuSessionId}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
renamingFolderId={renamingFolderId}
|
||||
getFoldersForScope={getFoldersForScope}
|
||||
getSessionFolderId={getSessionFolderId}
|
||||
@@ -794,6 +1079,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
handleDeleteSession={handleDeleteSession}
|
||||
mobileVariant={mobileVariant}
|
||||
renderSessionNode={renderSessionNode}
|
||||
secondaryMeta={secondaryMeta}
|
||||
renderContext={renderContext}
|
||||
/>
|
||||
),
|
||||
[
|
||||
@@ -822,8 +1109,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
copiedSessionId,
|
||||
handleCopyShareUrl,
|
||||
handleUnshareSession,
|
||||
openMenuSessionId,
|
||||
setOpenMenuSessionId,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
renamingFolderId,
|
||||
getFoldersForScope,
|
||||
getSessionFolderId,
|
||||
@@ -898,7 +1185,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}, [prStatusEntries]);
|
||||
|
||||
const renderGroupSessions = React.useCallback(
|
||||
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => (
|
||||
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null) => (
|
||||
<SessionGroupSection
|
||||
group={group}
|
||||
groupKey={groupKey}
|
||||
@@ -937,6 +1224,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
|
||||
onToggleCollapsedGroup={toggleCollapsedGroup}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
),
|
||||
[
|
||||
@@ -972,44 +1260,62 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectId);
|
||||
const topContent = !hasSessionSearchQuery ? (
|
||||
<SidebarActivitySections
|
||||
sections={activitySections}
|
||||
renderSessionNode={renderSessionNode}
|
||||
/>
|
||||
) : null;
|
||||
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
|
||||
const handleSidebarNewSession = React.useCallback(() => {
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) {
|
||||
setSessionSwitcherOpen(false);
|
||||
}
|
||||
openNewSessionDraft();
|
||||
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sessionSearchContainerRef}
|
||||
className={cn(
|
||||
'flex h-full flex-col text-foreground overflow-x-hidden',
|
||||
'relative flex h-full flex-col text-foreground overflow-x-hidden',
|
||||
mobileVariant ? '' : 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
{showDesktopSidebarChrome ? (
|
||||
<div
|
||||
onMouseDown={handleDesktopSidebarDragStart}
|
||||
className={cn(
|
||||
'app-region-drag flex h-[var(--oc-header-height,56px)] flex-shrink-0 items-center pr-3',
|
||||
desktopSidebarTopPaddingClass,
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSidebar}
|
||||
className={desktopSidebarToggleButtonClass}
|
||||
aria-label="Close sessions"
|
||||
>
|
||||
<RiLayoutLeftLine className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Close sessions</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SidebarHeader
|
||||
hideDirectoryControls={hideDirectoryControls}
|
||||
hideProjectSelector={hideProjectSelector}
|
||||
activeProjectForHeader={activeProjectForHeader}
|
||||
homeDirectory={homeDirectory}
|
||||
normalizedProjects={normalizedProjects}
|
||||
activeProjectId={activeProjectId}
|
||||
setActiveProjectIdOnly={setActiveProjectIdOnly}
|
||||
isProjectRenameInline={isProjectRenameInline}
|
||||
setIsProjectRenameInline={setIsProjectRenameInline}
|
||||
handleStartInlineProjectRename={handleStartInlineProjectRename}
|
||||
handleSaveInlineProjectRename={handleSaveInlineProjectRename}
|
||||
projectRenameDraft={projectRenameDraft}
|
||||
setProjectRenameDraft={setProjectRenameDraft}
|
||||
removeProject={removeProject}
|
||||
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
|
||||
addProjectButtonClass={addProjectButtonClass}
|
||||
handleNewSession={handleSidebarNewSession}
|
||||
headerActionIconClass={headerActionIconClass}
|
||||
reserveHeaderActionsSpace={reserveHeaderActionsSpace}
|
||||
stableActiveProjectIsRepo={stableActiveProjectIsRepo}
|
||||
useMobileNotesPanel={useMobileNotesPanel}
|
||||
projectNotesPanelOpen={projectNotesPanelOpen}
|
||||
setProjectNotesPanelOpen={setProjectNotesPanelOpen}
|
||||
activeProjectRefForHeader={activeProjectRefForHeader}
|
||||
openMultiRunLauncher={openMultiRunLauncher}
|
||||
headerActionButtonClass={headerActionButtonClass}
|
||||
setNewWorktreeDialogOpen={setNewWorktreeDialogOpen}
|
||||
setActiveMainTab={setActiveMainTab}
|
||||
isSessionSearchOpen={isSessionSearchOpen}
|
||||
setIsSessionSearchOpen={setIsSessionSearchOpen}
|
||||
sessionSearchInputRef={sessionSearchInputRef}
|
||||
@@ -1020,8 +1326,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
/>
|
||||
|
||||
<SidebarProjectsList
|
||||
sectionsForRender={sectionsForRender}
|
||||
projectSections={projectSections}
|
||||
topContent={topContent}
|
||||
sectionsForRender={filteredSectionsForRender}
|
||||
projectSections={filteredProjectSections}
|
||||
activeProjectId={activeProjectId}
|
||||
showOnlyMainWorkspace={showOnlyMainWorkspace}
|
||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||
@@ -1042,22 +1349,43 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
setActiveMainTab={setActiveMainTab}
|
||||
setSessionSwitcherOpen={setSessionSwitcherOpen}
|
||||
openNewSessionDraft={openNewSessionDraft}
|
||||
createWorktreeSession={createWorktreeSession}
|
||||
openNewWorktreeDialog={openNewWorktreeDialog}
|
||||
openMultiRunLauncher={openMultiRunLauncher}
|
||||
setEditingProjectId={setEditingProjectId}
|
||||
setEditProjectTitle={setEditProjectTitle}
|
||||
editingProjectId={editingProjectId}
|
||||
editProjectTitle={editProjectTitle}
|
||||
handleSaveProjectEdit={handleSaveProjectEdit}
|
||||
handleCancelProjectEdit={handleCancelProjectEdit}
|
||||
openProjectEditDialog={setEditingProjectDialogId}
|
||||
removeProject={removeProject}
|
||||
projectHeaderSentinelRefs={projectHeaderSentinelRefs}
|
||||
settingsAutoCreateWorktree={settingsAutoCreateWorktree}
|
||||
reorderProjects={reorderProjects}
|
||||
getOrderedGroups={getOrderedGroups}
|
||||
setGroupOrderByProject={setGroupOrderByProject}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
isInlineEditing={isInlineEditing}
|
||||
/>
|
||||
|
||||
<SidebarFooter
|
||||
onOpenSettings={() => setSettingsDialogOpen(true)}
|
||||
onOpenShortcuts={toggleHelpDialog}
|
||||
onOpenAbout={() => setAboutDialogOpen(true)}
|
||||
/>
|
||||
|
||||
{editingProject ? (
|
||||
<ProjectEditDialog
|
||||
open={Boolean(editingProject)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditingProjectDialogId(null);
|
||||
}
|
||||
}}
|
||||
projectId={editingProject.id}
|
||||
projectName={editingProject.label || formatDirectoryName(editingProject.path, homeDirectory)}
|
||||
projectPath={editingProject.path}
|
||||
initialIcon={editingProject.icon}
|
||||
initialColor={editingProject.color}
|
||||
initialIconBackground={editingProject.iconBackground}
|
||||
onSave={handleSaveProjectEdit}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<NewWorktreeDialog
|
||||
open={newWorktreeDialogOpen}
|
||||
onOpenChange={setNewWorktreeDialogOpen}
|
||||
|
||||
@@ -3,24 +3,29 @@
|
||||
## Refactor result
|
||||
|
||||
- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components.
|
||||
- Sidebar behavior stays intact: global+archived session grouping, folder operations, delete/archive semantics, project/worktree rendering, and search.
|
||||
- Recent migration gaps were fixed (persistence + repo-status hooks fully wired).
|
||||
- Sidebar is now a single multi-project tree: `recent` top section, then projects, then worktrees/archived groups, then sessions.
|
||||
- `NavRail` is no longer part of sidebar/navigation flow.
|
||||
- Project headers now own root sessions directly; there is no separate rendered `project root` subgroup.
|
||||
- Active/hover row styling is text-first; selected sessions use primary text instead of background fills.
|
||||
- Archived groups are collapsed by default and support bulk deletion at group/folder level.
|
||||
- Session rows support compact inline dates in minimal mode and simplified metadata in default mode.
|
||||
- New extractions in latest pass reduced local effect/callback bulk further:
|
||||
- project session list builders
|
||||
- folder cleanup sync
|
||||
- sticky project header observer
|
||||
- Baseline checks pass after refactor: `type-check`, `lint`, `build`.
|
||||
|
||||
## File summaries
|
||||
|
||||
### Components
|
||||
|
||||
- `SidebarHeader.tsx`: Top header UI (project selector/rename, search, add/open actions, notes/worktree entry points).
|
||||
- `SidebarProjectsList.tsx`: Main scrollable list renderer for project sections/groups, empty states, and project-level interactions.
|
||||
- `SessionGroupSection.tsx`: Renders a single group (root sessions + folders), collapse/expand, and group-level controls.
|
||||
- `SessionNodeItem.tsx`: Renders one session row/tree node with metadata, menu actions, inline rename, and nested children.
|
||||
- `SidebarHeader.tsx`: Top header UI for add-project, session search, and display mode.
|
||||
- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only.
|
||||
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
|
||||
- `SidebarProjectsList.tsx`: Main scrollable tree renderer for projects, root sessions, worktrees/groups, and empty/search states.
|
||||
- `SessionGroupSection.tsx`: Renders a single worktree/archived group, collapse/expand, folder subtree, and group-level controls.
|
||||
- `SessionNodeItem.tsx`: Renders one session row/tree node with inline metadata, menu actions, minimal/default variants, and nested children.
|
||||
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
|
||||
- `sortableItems.tsx`: DnD sortable wrappers for project and group ordering with drag handles/overlays.
|
||||
- `sortableItems.tsx`: DnD sortable wrappers for project and group ordering plus project-row action affordances.
|
||||
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
|
||||
|
||||
### Hooks
|
||||
@@ -32,7 +37,7 @@
|
||||
- `hooks/useSessionGrouping.ts`: Builds grouped session structures and search text/filter helpers.
|
||||
- `hooks/useSessionSidebarSections.ts`: Composes final per-project sections and group search metadata for rendering.
|
||||
- `hooks/useProjectSessionSelection.ts`: Resolves active/current project-session selection logic and session-directory context.
|
||||
- `hooks/useGroupOrdering.ts`: Applies persisted/custom group order with stable fallback ordering.
|
||||
- `hooks/useGroupOrdering.ts`: Applies persisted/custom group order with stable fallback ordering; archived groups are reorderable.
|
||||
- `hooks/useArchivedAutoFolders.ts`: Maintains archived auto-folder structure and assignment behavior.
|
||||
- `hooks/useSidebarPersistence.ts`: Persists sidebar UI state (expanded/collapsed/pinned/group order/active session) to storage + desktop settings.
|
||||
- `hooks/useProjectRepoStatus.ts`: Tracks per-project git-repo state and root branch metadata.
|
||||
@@ -43,4 +48,5 @@
|
||||
### Types and utilities
|
||||
|
||||
- `types.ts`: Shared sidebar types (`SessionNode`, `SessionGroup`, summary/search metadata).
|
||||
- `utils.tsx`: Shared sidebar utilities (path normalization, sorting, dedupe, archived scope keys, project relation checks, text highlight, labels).
|
||||
- `activitySections.ts`: Persisted top-section storage/helpers for the current `recent` session list.
|
||||
- `utils.tsx`: Shared sidebar utilities (path normalization, sorting, dedupe, archived scope keys, project relation checks, text highlight, labels, compact/default date formatting).
|
||||
|
||||
@@ -15,9 +15,11 @@ import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { SessionFolderItem } from '../SessionFolderItem';
|
||||
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
|
||||
import type { SortableDragHandleProps } from './sortableItems';
|
||||
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
|
||||
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
|
||||
type DeleteFolderConfirm = {
|
||||
scopeKey: string;
|
||||
@@ -45,7 +47,7 @@ type Props = {
|
||||
deleteFolder: (scopeKey: string, folderId: string) => void;
|
||||
showDeletionDialog: boolean;
|
||||
setDeleteFolderConfirm: React.Dispatch<React.SetStateAction<DeleteFolderConfirm>>;
|
||||
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean) => React.ReactNode;
|
||||
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null) => React.ReactNode;
|
||||
currentSessionDirectory: string | null;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
lastRepoStatus: boolean;
|
||||
@@ -87,6 +89,7 @@ type Props = {
|
||||
} | null;
|
||||
}>;
|
||||
onToggleCollapsedGroup: (groupKey: string) => void;
|
||||
dragHandleProps?: SortableDragHandleProps | null;
|
||||
};
|
||||
|
||||
export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
@@ -109,7 +112,6 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
showDeletionDialog,
|
||||
setDeleteFolderConfirm,
|
||||
renderSessionNode,
|
||||
currentSessionDirectory,
|
||||
projectRepoStatus,
|
||||
lastRepoStatus,
|
||||
toggleGroupSessionLimit,
|
||||
@@ -128,9 +130,12 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
pinnedSessionIds,
|
||||
prVisualStateByDirectoryBranch,
|
||||
onToggleCollapsedGroup,
|
||||
dragHandleProps,
|
||||
} = props;
|
||||
|
||||
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||
const isMinimalMode = displayMode === 'minimal';
|
||||
const isExpanded = expandedSessionGroups.has(groupKey);
|
||||
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
|
||||
const maxVisible = hideDirectoryControls ? 10 : 5;
|
||||
@@ -204,24 +209,17 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
};
|
||||
|
||||
const allGroupSessions = collectGroupSessions(sourceGroupNodes);
|
||||
const normalizedGroupDirectory = normalizePath(group.directory ?? null);
|
||||
const isGitProject = projectId && projectRepoStatus.has(projectId)
|
||||
? Boolean(projectRepoStatus.get(projectId))
|
||||
: lastRepoStatus;
|
||||
const isActiveGroup = Boolean(
|
||||
normalizedGroupDirectory
|
||||
&& currentSessionDirectory
|
||||
&& normalizedGroupDirectory === currentSessionDirectory,
|
||||
);
|
||||
const groupDirectoryKey = normalizePath(group.directory ?? null);
|
||||
const groupBranchKey = group.branch?.trim() ?? null;
|
||||
const prIndicator = groupDirectoryKey && groupBranchKey
|
||||
? (prVisualStateByDirectoryBranch.get(`${groupDirectoryKey}::${groupBranchKey}`) ?? null)
|
||||
: null;
|
||||
const showInlinePrTitle = Boolean(prIndicator && group.branch);
|
||||
const showBranchSubtitle = !group.isMain && (isBranchDifferentFromLabel(group.branch, group.label) || Boolean(prIndicator));
|
||||
const showBranchSubtitle = !prIndicator && !group.isMain && Boolean(group.branch);
|
||||
const prVisualState = prIndicator?.visualState ?? null;
|
||||
const branchIconColor = prVisualState ? `var(--pr-${prVisualState})` : undefined;
|
||||
const checksSummary = prIndicator && prIndicator.state === 'open' && prIndicator.checks
|
||||
? `${prIndicator.checks.success}/${prIndicator.checks.total} checks passed`
|
||||
: null;
|
||||
@@ -241,6 +239,33 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
: null;
|
||||
const baseBranchLabel = prIndicator?.base ?? null;
|
||||
const headBranchLabel = prIndicator?.head ?? null;
|
||||
const statusLine = (() => {
|
||||
if (!prIndicator) {
|
||||
return group.branch && isBranchDifferentFromLabel(group.branch, group.label)
|
||||
? { label: group.branch, color: null as string | null }
|
||||
: null;
|
||||
}
|
||||
switch (prIndicator.visualState) {
|
||||
case 'merged':
|
||||
return { label: 'Merged', color: 'var(--pr-merged)' };
|
||||
case 'open':
|
||||
return (prIndicator.canMerge === true || prIndicator.mergeableState === 'clean' || prIndicator.checks?.state === 'success')
|
||||
? { label: 'Ready to merge', color: 'var(--pr-open)' }
|
||||
: { label: 'PR open', color: 'var(--pr-open)' };
|
||||
case 'blocked':
|
||||
return {
|
||||
label: prIndicator.mergeableState === 'dirty' ? 'Merge conflicts' : 'Merge blocked',
|
||||
color: 'var(--pr-blocked)',
|
||||
};
|
||||
case 'draft':
|
||||
return { label: 'Draft PR', color: 'var(--pr-draft)' };
|
||||
case 'closed':
|
||||
return { label: 'Closed', color: 'var(--pr-closed)' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const branchIconColor = statusLine?.color ?? (prVisualState ? `var(--pr-${prVisualState})` : undefined);
|
||||
const handlePrLinkClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -263,6 +288,15 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
const subFolderItems = directSubFolders.length > 0
|
||||
? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}</>
|
||||
: undefined;
|
||||
const collectFolderSessions = (targetFolderId: string): Session[] => {
|
||||
const directNodes = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId)?.nodes ?? [];
|
||||
const childFolders = allFoldersForGroup.filter(({ folder: candidate }) => candidate.parentId === targetFolderId);
|
||||
return [
|
||||
...collectGroupSessions(directNodes),
|
||||
...childFolders.flatMap(({ folder: child }) => collectFolderSessions(child.id)),
|
||||
];
|
||||
};
|
||||
const folderSessionsForDelete = group.isArchivedBucket ? collectFolderSessions(folder.id) : [];
|
||||
|
||||
return (
|
||||
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
|
||||
@@ -277,6 +311,13 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name);
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (group.isArchivedBucket) {
|
||||
sessionEvents.requestDelete({
|
||||
sessions: folderSessionsForDelete,
|
||||
mode: 'session',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!folderScopeKey) return;
|
||||
if (!showDeletionDialog) {
|
||||
deleteFolder(folderScopeKey, folder.id);
|
||||
@@ -324,7 +365,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
if (!folderScopeKey) return;
|
||||
createFolderAndStartRename(folderScopeKey, folder.id);
|
||||
} : undefined}
|
||||
hideActions={group.isArchivedBucket === true}
|
||||
hideActions={false}
|
||||
archivedBucket={group.isArchivedBucket === true}
|
||||
/>
|
||||
)}
|
||||
@@ -333,6 +374,16 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
};
|
||||
|
||||
const renderFolderItems = () => rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0));
|
||||
const hasWorktreeDeleteAction = Boolean(!group.isMain && group.worktree);
|
||||
const groupHeaderRightPadding = mobileVariant
|
||||
? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7')
|
||||
: isMinimalMode
|
||||
? (hasWorktreeDeleteAction
|
||||
? 'pr-10 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
||||
: 'pr-10')
|
||||
: (hasWorktreeDeleteAction
|
||||
? 'pr-5 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
||||
: 'pr-5');
|
||||
|
||||
const body = (
|
||||
<SessionFolderDndScope
|
||||
@@ -371,13 +422,13 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
);
|
||||
|
||||
if (hideGroupLabel) {
|
||||
return <div className="oc-group"><div className="oc-group-body pb-3">{body}</div></div>;
|
||||
return <div className="oc-group"><div className="oc-group-body pb-3 pl-4">{body}</div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oc-group">
|
||||
<div
|
||||
className={cn('group/gh relative flex items-center justify-between gap-1 py-1 min-w-0 rounded-sm', 'hover:bg-interactive-hover/50 cursor-pointer')}
|
||||
className={cn('group/gh relative flex items-start justify-between gap-1 py-1 min-w-0 rounded-md', 'cursor-pointer')}
|
||||
onClick={() => onToggleCollapsedGroup(groupKey)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -388,78 +439,43 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
}
|
||||
}}
|
||||
aria-label={isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`}
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<div className={cn(
|
||||
'min-w-0 flex items-center gap-1.5 pl-1.5 transition-[padding]',
|
||||
mobileVariant
|
||||
? (!group.isMain && group.worktree ? 'pr-14' : 'pr-7')
|
||||
: (!group.isMain && group.worktree ? 'group-hover/gh:pr-14 group-focus-within/gh:pr-14' : 'group-hover/gh:pr-7 group-focus-within/gh:pr-7'),
|
||||
)}>
|
||||
{group.isArchivedBucket ? (
|
||||
<RiArchiveLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (!group.isMain || isGitProject) ? (
|
||||
showInlinePrTitle && prIndicator ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<RiGitBranchLine
|
||||
className="h-3.5 w-3.5 flex-shrink-0 translate-y-[0.5px] text-muted-foreground"
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
|
||||
<div className="space-y-1 text-xs">
|
||||
{(baseBranchLabel || headBranchLabel) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{baseBranchLabel && headBranchLabel ? (
|
||||
<>
|
||||
<span>{baseBranchLabel}</span>
|
||||
<RiArrowLeftLongLine className="mx-0.5 inline h-3 w-3 align-[-2px]" />
|
||||
<span>{headBranchLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
|
||||
{(mergeabilityLabel || checksSummary) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{mergeabilityLabel ?? ''}
|
||||
{mergeabilityLabel && checksSummary ? ' • ' : ''}
|
||||
{checksSummary ?? ''}
|
||||
{checksTail ? ` (${checksTail})` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<RiGitBranchLine
|
||||
className="h-3.5 w-3.5 flex-shrink-0 translate-y-[0.5px] text-muted-foreground"
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
<div className="min-w-0 flex flex-col justify-center">
|
||||
<p className={cn('text-[14px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
|
||||
<div
|
||||
ref={dragHandleProps?.setActivatorNodeRef}
|
||||
className={cn(
|
||||
'min-w-0 flex items-start gap-1 pl-0.5 transition-[padding] cursor-grab active:cursor-grabbing',
|
||||
groupHeaderRightPadding,
|
||||
)}
|
||||
{...(dragHandleProps?.listeners ?? {})}
|
||||
>
|
||||
<div className="min-w-0 flex flex-col justify-center gap-0.5">
|
||||
<p className="text-[14px] font-normal truncate text-foreground/92">
|
||||
{showInlinePrTitle && prIndicator ? (
|
||||
<>
|
||||
<span className="inline-flex min-w-0 max-w-full items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
<span className="inline-flex shrink-0 items-center gap-1 leading-none align-middle">
|
||||
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<RiGitBranchLine
|
||||
className="h-3.5 w-3.5 shrink-0 group-hover/gh:hidden"
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}
|
||||
/>
|
||||
<span className="hidden text-muted-foreground group-hover/gh:inline-flex h-3.5 w-3.5 items-center justify-center">
|
||||
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
</span>
|
||||
{prIndicator.url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-baseline leading-none underline hover:no-underline"
|
||||
className="inline-flex shrink-0 items-center leading-none"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={handlePrLinkClick}
|
||||
>
|
||||
#{prIndicator.number}
|
||||
</button>
|
||||
) : (
|
||||
<span className="leading-none">#{prIndicator.number}</span>
|
||||
<span className="inline-flex shrink-0 items-center leading-none">#{prIndicator.number}</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -490,42 +506,117 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span>{` ${group.branch}`}</span>
|
||||
</>
|
||||
<span className="ml-1 min-w-0 flex-1 truncate leading-none align-middle">{group.branch}</span>
|
||||
</span>
|
||||
) : group.isArchivedBucket ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<RiArchiveLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground group-hover/gh:hidden" />
|
||||
<span className="hidden text-muted-foreground group-hover/gh:inline-flex h-3.5 w-3.5 items-center justify-center">
|
||||
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
|
||||
</span>
|
||||
) : (!group.isMain || group.worktree) ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<RiGitBranchLine
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground group-hover/gh:hidden"
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}
|
||||
/>
|
||||
<span className="hidden text-muted-foreground group-hover/gh:inline-flex h-3.5 w-3.5 items-center justify-center">
|
||||
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
|
||||
</span>
|
||||
) : (
|
||||
renderHighlightedText(group.label, normalizedSessionSearchQuery)
|
||||
)}
|
||||
</p>
|
||||
{!showInlinePrTitle && showBranchSubtitle ? (
|
||||
<span className="text-[10px] sm:text-[11px] text-muted-foreground/80 truncate leading-tight">
|
||||
{prIndicator ? (
|
||||
<>
|
||||
{prIndicator.url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="underline hover:no-underline"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={handlePrLinkClick}
|
||||
>
|
||||
#{prIndicator.number}
|
||||
</button>
|
||||
) : (
|
||||
<span>#{prIndicator.number}</span>
|
||||
)}
|
||||
{group.branch ? <span>{` ${group.branch}`}</span> : null}
|
||||
</>
|
||||
) : (
|
||||
group.branch
|
||||
)}
|
||||
{showBranchSubtitle && statusLine ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 leading-tight">
|
||||
{group.isArchivedBucket ? (
|
||||
<RiArchiveLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (!group.isMain || isGitProject) ? (
|
||||
showInlinePrTitle && prIndicator ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
<RiGitBranchLine
|
||||
className="h-3.5 w-3.5 text-muted-foreground"
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
|
||||
<div className="space-y-1 text-xs">
|
||||
{(baseBranchLabel || headBranchLabel) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{baseBranchLabel && headBranchLabel ? (
|
||||
<>
|
||||
<span>{baseBranchLabel}</span>
|
||||
<RiArrowLeftLongLine className="mx-0.5 inline h-3 w-3 align-[-2px]" />
|
||||
<span>{headBranchLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
|
||||
{(mergeabilityLabel || checksSummary) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{mergeabilityLabel ?? ''}
|
||||
{mergeabilityLabel && checksSummary ? ' • ' : ''}
|
||||
{checksSummary ?? ''}
|
||||
{checksTail ? ` (${checksTail})` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<RiGitBranchLine
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
<span
|
||||
className={cn('min-w-0 truncate text-[11px] font-medium', !statusLine.color && 'text-muted-foreground/80')}
|
||||
style={statusLine.color ? { color: statusLine.color } : undefined}
|
||||
>
|
||||
{statusLine.label}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{group.isArchivedBucket && allGroupSessions.length > 0 ? (
|
||||
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
sessionEvents.requestDelete({
|
||||
sessions: allGroupSessions,
|
||||
mode: 'session',
|
||||
});
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`Delete archived sessions in ${group.label}`}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Delete archived sessions</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
{group.directory && !group.isMain && group.worktree ? (
|
||||
<div className={cn('absolute right-7 top-1/2 -translate-y-1/2 z-10 transition-opacity', mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
|
||||
<Tooltip>
|
||||
@@ -564,17 +655,17 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
openNewSessionDraft({ directoryOverride: group.directory });
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`New session in ${group.label}`}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New session</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
aria-label={`New draft session in ${group.label}`}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New draft session</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!isCollapsed ? <div className="oc-group-body pb-3">{body}</div> : null}
|
||||
{!isCollapsed ? <div className="oc-group-body pb-3 pl-4">{body}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,22 +22,21 @@ import {
|
||||
RiDeleteBinLine,
|
||||
RiErrorWarningLine,
|
||||
RiFileCopyLine,
|
||||
RiFileEditLine,
|
||||
RiFolderLine,
|
||||
RiLinkUnlinkM,
|
||||
RiMore2Line,
|
||||
RiPencilAiLine,
|
||||
RiPushpinLine,
|
||||
RiRobot2Line,
|
||||
RiShare2Line,
|
||||
RiShieldLine,
|
||||
RiUnpinLine,
|
||||
RiGitBranchLine,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { DraggableSessionRow } from './sessionFolderDnd';
|
||||
import type { SessionNode, SessionSummaryMeta } from './types';
|
||||
import { formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
|
||||
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
|
||||
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
|
||||
@@ -48,6 +47,11 @@ const getAttentionDiamondDelay = (index: number): string => {
|
||||
|
||||
type Folder = { id: string; name: string; sessionIds: string[] };
|
||||
|
||||
type SecondaryMeta = {
|
||||
projectLabel?: string | null;
|
||||
branchLabel?: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
node: SessionNode;
|
||||
depth?: number;
|
||||
@@ -79,8 +83,8 @@ type Props = {
|
||||
copiedSessionId: string | null;
|
||||
handleCopyShareUrl: (url: string, sessionId: string) => void;
|
||||
handleUnshareSession: (sessionId: string) => void;
|
||||
openMenuSessionId: string | null;
|
||||
setOpenMenuSessionId: (id: string | null) => void;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
renamingFolderId: string | null;
|
||||
getFoldersForScope: (scopeKey: string) => Folder[];
|
||||
getSessionFolderId: (scopeKey: string, sessionId: string) => string | null;
|
||||
@@ -90,7 +94,9 @@ type Props = {
|
||||
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string }) => void;
|
||||
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean }) => void;
|
||||
mobileVariant: boolean;
|
||||
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean) => React.ReactNode;
|
||||
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: SecondaryMeta | null, renderContext?: 'project' | 'recent') => React.ReactNode;
|
||||
secondaryMeta?: SecondaryMeta | null;
|
||||
renderContext?: 'project' | 'recent';
|
||||
};
|
||||
|
||||
export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
@@ -125,8 +131,8 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
copiedSessionId,
|
||||
handleCopyShareUrl,
|
||||
handleUnshareSession,
|
||||
openMenuSessionId,
|
||||
setOpenMenuSessionId,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
renamingFolderId,
|
||||
getFoldersForScope,
|
||||
getSessionFolderId,
|
||||
@@ -137,13 +143,19 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
handleDeleteSession,
|
||||
mobileVariant,
|
||||
renderSessionNode,
|
||||
secondaryMeta,
|
||||
renderContext = 'project',
|
||||
} = props;
|
||||
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
|
||||
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
|
||||
|
||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||
const isMinimalMode = displayMode === 'minimal';
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
const suppressNextSelectRef = React.useRef(false);
|
||||
|
||||
const session = node.session;
|
||||
const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`;
|
||||
const sessionDirectory =
|
||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(groupDirectory ?? null);
|
||||
@@ -160,12 +172,16 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
const needsAttention = rawNeedsAttention && (!isSubtaskSession || notifyOnSubtasks);
|
||||
const sessionSummary = session.summary as SessionSummaryMeta | undefined;
|
||||
const sessionDiffStats = resolveSessionDiffStats(sessionSummary);
|
||||
const sessionTimestamp = session.time?.updated || session.time?.created || Date.now();
|
||||
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
|
||||
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
|
||||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
||||
|
||||
if (editingId === session.id) {
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn('group relative flex items-center rounded-md px-1.5 py-1', 'bg-interactive-selection', depth > 0 && 'pl-[20px]')}
|
||||
className={cn('group relative flex items-center rounded-sm px-1.5 py-1', depth > 0 && 'pl-[20px]')}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0">
|
||||
<form
|
||||
@@ -197,17 +213,14 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
<button type="button" onClick={handleCancelEdit} className="shrink-0 text-muted-foreground hover:text-foreground"><RiCloseLine className="size-4" /></button>
|
||||
</form>
|
||||
{!isMinimalMode ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
|
||||
{hasChildren ? <span className="inline-flex items-center justify-center flex-shrink-0">{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}</span> : null}
|
||||
<span className="flex-shrink-0">{formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())}</span>
|
||||
{sessionDiffStats ? <span className="flex-shrink-0"><span className="text-status-success/80">+{sessionDiffStats.additions}</span><span className="text-muted-foreground/60">/</span><span className="text-status-error/80">-{sessionDiffStats.deletions}</span></span> : null}
|
||||
{session.share ? <RiShare2Line className="h-3 w-3 text-[color:var(--status-info)] flex-shrink-0" /> : null}
|
||||
{(sessionSummary?.files ?? 0) > 0 || hasChildren ? (
|
||||
<span className="flex items-center gap-2 flex-shrink-0">
|
||||
{(sessionSummary?.files ?? 0) > 0 ? <Tooltip><TooltipTrigger asChild><span className="inline-flex items-center gap-0.5"><RiFileEditLine className="h-3 w-3 text-muted-foreground/70" /><span>{sessionSummary!.files}</span></span></TooltipTrigger><TooltipContent side="bottom" sideOffset={4}><p>{sessionSummary!.files} changed {sessionSummary!.files === 1 ? 'file' : 'files'}</p></TooltipContent></Tooltip> : null}
|
||||
{hasChildren ? <Tooltip><TooltipTrigger asChild><span className="inline-flex items-center gap-0.5"><RiRobot2Line className="h-3 w-3 text-muted-foreground/70" /><span>{node.children.length}</span></span></TooltipTrigger><TooltipContent side="bottom" sideOffset={4}><p>{node.children.length} {node.children.length === 1 ? 'sub-session' : 'sub-sessions'}</p></TooltipContent></Tooltip> : null}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-3 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
|
||||
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
{hasChildren ? <span className="inline-flex items-center justify-center flex-shrink-0">{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}</span> : null}
|
||||
<span className="flex-shrink-0">{sessionUpdatedLabel}</span>
|
||||
{sessionDiffStats ? <span className="flex flex-shrink-0 items-center gap-0 text-[0.92em]"><span className="text-status-success/80">+{sessionDiffStats.additions}</span><span className="text-status-error/65">/-{sessionDiffStats.deletions}</span></span> : null}
|
||||
{hasSecondaryProjectLabel ? <span className="truncate">{secondaryMeta?.projectLabel}</span> : null}
|
||||
{hasSecondaryBranchLabel ? <span className="inline-flex min-w-0 items-center gap-0.5"><RiGitBranchLine className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" /><span className="truncate">{secondaryMeta?.branchLabel}</span></span> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -220,21 +233,191 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
const pendingPermissionCount = permissions.get(session.id)?.length ?? 0;
|
||||
const showUnreadStatus = !isStreaming && needsAttention && !isActive;
|
||||
const showStatusMarker = isStreaming || showUnreadStatus;
|
||||
const statusMarkerContent = isStreaming
|
||||
? <GridLoader size="xs" className="text-primary" />
|
||||
: (
|
||||
<span className="grid grid-cols-3 gap-[1px] text-[var(--status-info)]" aria-label="Unread updates" title="Unread updates">
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
ATTENTION_DIAMOND_INDICES.has(i) ? (
|
||||
<span key={i} className="h-[3px] w-[3px] rounded-full bg-current animate-attention-diamond-pulse" style={{ animationDelay: getAttentionDiamondDelay(i) }} />
|
||||
) : (
|
||||
<span key={i} className="h-[3px] w-[3px]" />
|
||||
)
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
const inlineStatusMarker = !isMinimalMode && showStatusMarker ? (
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
{statusMarkerContent}
|
||||
</span>
|
||||
) : null;
|
||||
const minimalLeadingStatusMarker = isMinimalMode && showStatusMarker ? (
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-[-10px] top-1/2 inline-flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center transition-opacity',
|
||||
hasChildren ? 'opacity-100 group-hover:opacity-0 group-focus-within:opacity-0' : '',
|
||||
)}
|
||||
>
|
||||
{statusMarkerContent}
|
||||
</span>
|
||||
) : null;
|
||||
const subsessionChevron = hasChildren ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleParent(session.id);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleParent(session.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'absolute left-[-10px] top-1/2 inline-flex h-3.5 w-3.5 -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 transition-opacity',
|
||||
isMinimalMode && showStatusMarker
|
||||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto'
|
||||
: '',
|
||||
)}
|
||||
aria-label={isExpanded ? 'Collapse subsessions' : 'Expand subsessions'}
|
||||
>
|
||||
{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const streamingIndicator = memoryState?.isZombie
|
||||
? <RiErrorWarningLine className="h-4 w-4 text-status-warning" />
|
||||
: null;
|
||||
|
||||
const handleMenuOpenChange = (open: boolean) => {
|
||||
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
|
||||
};
|
||||
|
||||
const handleMenuTriggerClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleRowSelect = () => {
|
||||
if (suppressNextSelectRef.current) {
|
||||
suppressNextSelectRef.current = false;
|
||||
return;
|
||||
}
|
||||
handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId);
|
||||
};
|
||||
|
||||
const handleRowMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (event.button === 2 || (event.button === 0 && event.ctrlKey)) {
|
||||
suppressNextSelectRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
const sessionMenuContent = (
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]" onCloseAutoFocus={(event) => { if (renamingFolderId) event.preventDefault(); }}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditingId(session.id);
|
||||
setEditTitle(sessionTitle);
|
||||
}}
|
||||
className="[&>svg]:mr-1"
|
||||
>
|
||||
<RiPencilAiLine className="mr-1 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
|
||||
{isPinnedSession ? <RiUnpinLine className="mr-1 h-4 w-4" /> : <RiPushpinLine className="mr-1 h-4 w-4" />}
|
||||
{isPinnedSession ? 'Unpin session' : 'Pin session'}
|
||||
</DropdownMenuItem>
|
||||
{!session.share ? (
|
||||
<DropdownMenuItem onClick={() => handleShareSession(session)} className="[&>svg]:mr-1">
|
||||
<RiShare2Line className="mr-1 h-4 w-4" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => { if (session.share?.url) handleCopyShareUrl(session.share.url, session.id); }} className="[&>svg]:mr-1">
|
||||
{copiedSessionId === session.id ? <><RiCheckLine className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }} />Copied</> : <><RiFileCopyLine className="mr-1 h-4 w-4" />Copy link</>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
|
||||
<RiLinkUnlinkM className="mr-1 h-4 w-4" />
|
||||
Unshare
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sessionDirectory && !archivedBucket ? (() => {
|
||||
const scopeFolders = getFoldersForScope(sessionDirectory);
|
||||
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="[&>svg]:mr-1"><RiFolderLine className="h-4 w-4" />Move to folder</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[180px]">
|
||||
{scopeFolders.length === 0 ? (
|
||||
<DropdownMenuItem disabled className="text-muted-foreground">No folders yet</DropdownMenuItem>
|
||||
) : (
|
||||
scopeFolders.map((folder) => (
|
||||
<DropdownMenuItem key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
{currentFolderId === folder.id ? <RiCheckLine className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
|
||||
<RiAddLine className="mr-1 h-4 w-4" />
|
||||
New folder...
|
||||
</DropdownMenuItem>
|
||||
{currentFolderId ? (
|
||||
<DropdownMenuItem onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
|
||||
<RiCloseLine className="mr-1 h-4 w-4" />
|
||||
Remove from folder
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
);
|
||||
})() : null}
|
||||
|
||||
{!isVSCode ? (
|
||||
<DropdownMenuItem
|
||||
disabled={!sessionDirectory}
|
||||
onClick={() => {
|
||||
if (!sessionDirectory) return;
|
||||
openContextPanelTab(sessionDirectory, {
|
||||
mode: 'chat',
|
||||
dedupeKey: `session:${session.id}`,
|
||||
label: sessionTitle,
|
||||
});
|
||||
}}
|
||||
className="[&>svg]:mr-1"
|
||||
>
|
||||
<RiChat4Line className="mr-1 h-4 w-4" />
|
||||
<span className="truncate">Open in Side Panel</span>
|
||||
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">beta</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
|
||||
<RiDeleteBinLine className="mr-1 h-4 w-4" />
|
||||
{archivedBucket ? 'Delete' : 'Archive'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={session.id}>
|
||||
<DraggableSessionRow sessionId={session.id} sessionDirectory={sessionDirectory ?? null} sessionTitle={sessionTitle}>
|
||||
<div
|
||||
className={cn('group relative flex items-center rounded-md px-1.5 py-1', isActive ? 'bg-interactive-selection' : 'hover:bg-interactive-hover', isMissingDirectory ? 'opacity-75' : '', depth > 0 && 'pl-[20px]')}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setOpenMenuSessionId(session.id);
|
||||
}}
|
||||
className={cn('group relative flex items-center rounded-sm px-1.5 py-1', isMissingDirectory ? 'opacity-75' : '', depth > 0 && 'pl-[20px]')}
|
||||
>
|
||||
{minimalLeadingStatusMarker}
|
||||
{subsessionChevron}
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
{isMinimalMode ? (
|
||||
<Tooltip>
|
||||
@@ -242,38 +425,52 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
disabled={isMissingDirectory}
|
||||
onClick={() => handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId)}
|
||||
onMouseDown={handleRowMouseDown}
|
||||
onClick={handleRowSelect}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSessionDoubleClick();
|
||||
}}
|
||||
className={cn('flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]', mobileVariant ? 'pr-7' : 'group-hover:pr-5 group-focus-within:pr-5')}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]',
|
||||
mobileVariant ? 'pr-7' : '',
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-2')}>
|
||||
{isMinimalMode && hasChildren ? (
|
||||
<span role="button" tabIndex={0} onClick={(event) => { event.stopPropagation(); toggleParent(session.id); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); toggleParent(session.id); } }} className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 flex-shrink-0 rounded-sm" aria-label={isExpanded ? 'Collapse subsessions' : 'Expand subsessions'}>
|
||||
{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
|
||||
</span>
|
||||
) : null}
|
||||
{showStatusMarker ? (
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
{isStreaming ? (
|
||||
<GridLoader size="xs" className="text-primary" />
|
||||
) : (
|
||||
<span className="grid grid-cols-3 gap-[1px] text-[var(--status-info)]" aria-label="Unread updates" title="Unread updates">
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
ATTENTION_DIAMOND_INDICES.has(i) ? (
|
||||
<span key={i} className="h-[3px] w-[3px] rounded-full bg-current animate-attention-diamond-pulse" style={{ animationDelay: getAttentionDiamondDelay(i) }} />
|
||||
) : (
|
||||
<span key={i} className="h-[3px] w-[3px]" />
|
||||
)
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
|
||||
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
|
||||
<div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||
{mobileVariant ? <span className="ml-2 flex-shrink-0 text-[0.72rem] text-muted-foreground/75">{sessionCompactUpdatedLabel}</span> : null}
|
||||
{!mobileVariant ? (
|
||||
<div className="relative ml-1 flex h-4 min-w-4 flex-shrink-0 items-center justify-end">
|
||||
<span className={cn(
|
||||
'whitespace-nowrap text-right text-[0.72rem] text-muted-foreground/75 transition-opacity duration-150',
|
||||
isMenuOpen
|
||||
? 'opacity-0'
|
||||
: 'group-hover:opacity-0 group-focus-within:opacity-0',
|
||||
)}>
|
||||
{sessionCompactUpdatedLabel}
|
||||
</span>
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'absolute inset-y-0 right-0 inline-flex h-4 w-4 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 transition-opacity',
|
||||
isMenuOpen
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto',
|
||||
)}
|
||||
aria-label="Session menu"
|
||||
onClick={handleMenuTriggerClick}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<RiMore2Line className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
{sessionMenuContent}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : null}
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title="Permission required" aria-label="Permission required">
|
||||
<RiShieldLine className="h-3 w-3" />
|
||||
@@ -283,40 +480,20 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8} className="max-w-xs">
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())}</span>
|
||||
{sessionDiffStats ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-status-success">+{sessionDiffStats.additions}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-status-error">-{sessionDiffStats.deletions}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<TooltipContent side="right" sideOffset={8} className="max-w-xs text-left">
|
||||
<div className="flex flex-col gap-1 text-left text-xs">
|
||||
<div className={cn('flex items-center gap-3 text-left text-muted-foreground', secondaryMeta?.projectLabel ? 'justify-between' : 'justify-start')}>
|
||||
{secondaryMeta?.projectLabel ? <div className="min-w-0 truncate">{secondaryMeta.projectLabel}</div> : null}
|
||||
<div className="flex-shrink-0">{sessionUpdatedLabel}</div>
|
||||
</div>
|
||||
{session.share ? (
|
||||
<div className="flex items-center gap-1 text-[color:var(--status-info)]">
|
||||
<RiShare2Line className="h-3 w-3" />
|
||||
<span>Shared session</span>
|
||||
</div>
|
||||
) : null}
|
||||
{(sessionSummary?.files ?? 0) > 0 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<RiFileEditLine className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{sessionSummary!.files} changed {sessionSummary!.files === 1 ? 'file' : 'files'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{hasChildren ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<RiRobot2Line className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{node.children.length} {node.children.length === 1 ? 'sub-session' : 'sub-sessions'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{isMissingDirectory ? (
|
||||
<div className="flex items-center gap-1 text-status-warning">
|
||||
<RiErrorWarningLine className="h-3 w-3" />
|
||||
<span>Directory missing</span>
|
||||
{secondaryMeta?.branchLabel || sessionDiffStats ? (
|
||||
<div className={cn('flex items-center gap-3 text-left text-muted-foreground', secondaryMeta?.branchLabel ? 'justify-between' : 'justify-start')}>
|
||||
{secondaryMeta?.branchLabel ? (
|
||||
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
<span className="inline-flex min-w-0 items-center gap-0.5"><RiGitBranchLine className="h-3 w-3 flex-shrink-0" /><span className="truncate">{secondaryMeta.branchLabel}</span></span>
|
||||
</div>
|
||||
) : null}
|
||||
{sessionDiffStats ? <span className="flex flex-shrink-0 items-center gap-0.5"><span className="text-status-success">+{sessionDiffStats.additions}</span><span className="text-status-error">-{sessionDiffStats.deletions}</span></span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -326,176 +503,85 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
disabled={isMissingDirectory}
|
||||
onClick={() => handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId)}
|
||||
onMouseDown={handleRowMouseDown}
|
||||
onClick={handleRowSelect}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSessionDoubleClick();
|
||||
}}
|
||||
className={cn('flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]', mobileVariant ? 'pr-7' : 'group-hover:pr-5 group-focus-within:pr-5')}
|
||||
>
|
||||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-2')}>
|
||||
{showStatusMarker ? (
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
{isStreaming ? (
|
||||
<GridLoader size="xs" className="text-primary" />
|
||||
) : (
|
||||
<span className="grid grid-cols-3 gap-[1px] text-[var(--status-info)]" aria-label="Unread updates" title="Unread updates">
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
ATTENTION_DIAMOND_INDICES.has(i) ? (
|
||||
<span key={i} className="h-[3px] w-[3px] rounded-full bg-current animate-attention-diamond-pulse" style={{ animationDelay: getAttentionDiamondDelay(i) }} />
|
||||
) : (
|
||||
<span key={i} className="h-[3px] w-[3px]" />
|
||||
)
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
|
||||
<div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title="Permission required" aria-label="Permission required">
|
||||
<RiShieldLine className="h-3 w-3" />
|
||||
<span className="leading-none">{pendingPermissionCount}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isMinimalMode ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
|
||||
{hasChildren ? (
|
||||
<span role="button" tabIndex={0} onClick={(event) => { event.stopPropagation(); toggleParent(session.id); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); toggleParent(session.id); } }} className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 flex-shrink-0 rounded-sm" aria-label={isExpanded ? 'Collapse subsessions' : 'Expand subsessions'}>
|
||||
{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex-shrink-0">{formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())}</span>
|
||||
{sessionDiffStats ? <span className="flex-shrink-0"><span className="text-status-success/80">+{sessionDiffStats.additions}</span><span className="text-muted-foreground/60">/</span><span className="text-status-error/80">-{sessionDiffStats.deletions}</span></span> : null}
|
||||
{session.share ? <RiShare2Line className="h-3 w-3 text-[color:var(--status-info)] flex-shrink-0" /> : null}
|
||||
{(sessionSummary?.files ?? 0) > 0 || hasChildren ? (
|
||||
<span className="flex items-center gap-2 flex-shrink-0">
|
||||
{(sessionSummary?.files ?? 0) > 0 ? <Tooltip><TooltipTrigger asChild><span className="inline-flex items-center gap-0.5"><RiFileEditLine className="h-3 w-3 text-muted-foreground/70" /><span>{sessionSummary!.files}</span></span></TooltipTrigger><TooltipContent side="bottom" sideOffset={4}><p>{sessionSummary!.files} changed {sessionSummary!.files === 1 ? 'file' : 'files'}</p></TooltipContent></Tooltip> : null}
|
||||
{hasChildren ? <Tooltip><TooltipTrigger asChild><span className="inline-flex items-center gap-0.5"><RiRobot2Line className="h-3 w-3 text-muted-foreground/70" /><span>{node.children.length}</span></span></TooltipTrigger><TooltipContent side="bottom" sideOffset={4}><p>{node.children.length} {node.children.length === 1 ? 'sub-session' : 'sub-sessions'}</p></TooltipContent></Tooltip> : null}
|
||||
</span>
|
||||
) : null}
|
||||
{isMissingDirectory ? <span className="inline-flex items-center gap-0.5 text-status-warning flex-shrink-0"><RiErrorWarningLine className="h-3 w-3" />Missing</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
|
||||
{inlineStatusMarker}
|
||||
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
|
||||
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title="Permission required" aria-label="Permission required">
|
||||
<RiShieldLine className="h-3 w-3" />
|
||||
<span className="leading-none">{pendingPermissionCount}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isMinimalMode ? (
|
||||
<div className="flex items-center justify-between gap-3 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
|
||||
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
<span className="flex-shrink-0">{sessionUpdatedLabel}</span>
|
||||
{sessionDiffStats ? <span className="flex flex-shrink-0 items-center gap-0 text-[0.92em]"><span className="text-status-success/80">+{sessionDiffStats.additions}</span><span className="text-muted-foreground/60">/</span><span className="text-status-error/65">-{sessionDiffStats.deletions}</span></span> : null}
|
||||
{hasSecondaryProjectLabel ? <span className="truncate">{secondaryMeta?.projectLabel}</span> : null}
|
||||
{hasSecondaryBranchLabel ? <span className="inline-flex min-w-0 items-center gap-0.5"><RiGitBranchLine className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" /><span className="truncate">{secondaryMeta?.branchLabel}</span></span> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{streamingIndicator && !mobileVariant ? (
|
||||
<div className={cn('absolute top-1/2 -translate-y-1/2 z-10', isMinimalMode ? 'right-7' : 'right-[30px]')}>
|
||||
<div className={cn('absolute top-1/2 -translate-y-1/2 z-10', isMinimalMode ? 'right-0' : 'right-[30px]')}>
|
||||
{streamingIndicator}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', mobileVariant ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100')}>
|
||||
<DropdownMenu open={openMenuSessionId === session.id} onOpenChange={(open) => setOpenMenuSessionId(open ? session.id : null)}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="inline-flex h-6 w-6 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" aria-label="Session menu" onClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]" onCloseAutoFocus={(event) => { if (renamingFolderId) event.preventDefault(); }}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditingId(session.id);
|
||||
setEditTitle(sessionTitle);
|
||||
}}
|
||||
className="[&>svg]:mr-1"
|
||||
>
|
||||
<RiPencilAiLine className="mr-1 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
|
||||
{isPinnedSession ? <RiUnpinLine className="mr-1 h-4 w-4" /> : <RiPushpinLine className="mr-1 h-4 w-4" />}
|
||||
{isPinnedSession ? 'Unpin session' : 'Pin session'}
|
||||
</DropdownMenuItem>
|
||||
{!session.share ? (
|
||||
<DropdownMenuItem onClick={() => handleShareSession(session)} className="[&>svg]:mr-1">
|
||||
<RiShare2Line className="mr-1 h-4 w-4" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => { if (session.share?.url) handleCopyShareUrl(session.share.url, session.id); }} className="[&>svg]:mr-1">
|
||||
{copiedSessionId === session.id ? <><RiCheckLine className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }} />Copied</> : <><RiFileCopyLine className="mr-1 h-4 w-4" />Copy link</>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
|
||||
<RiLinkUnlinkM className="mr-1 h-4 w-4" />
|
||||
Unshare
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sessionDirectory && !archivedBucket ? (() => {
|
||||
const scopeFolders = getFoldersForScope(sessionDirectory);
|
||||
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="[&>svg]:mr-1"><RiFolderLine className="h-4 w-4" />Move to folder</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[180px]">
|
||||
{scopeFolders.length === 0 ? (
|
||||
<DropdownMenuItem disabled className="text-muted-foreground">No folders yet</DropdownMenuItem>
|
||||
) : (
|
||||
scopeFolders.map((folder) => (
|
||||
<DropdownMenuItem key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
{currentFolderId === folder.id ? <RiCheckLine className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
|
||||
<RiAddLine className="mr-1 h-4 w-4" />
|
||||
New folder...
|
||||
</DropdownMenuItem>
|
||||
{currentFolderId ? (
|
||||
<DropdownMenuItem onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
|
||||
<RiCloseLine className="mr-1 h-4 w-4" />
|
||||
Remove from folder
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
);
|
||||
})() : null}
|
||||
|
||||
{!isVSCode ? (
|
||||
<DropdownMenuItem
|
||||
disabled={!sessionDirectory}
|
||||
onClick={() => {
|
||||
if (!sessionDirectory) return;
|
||||
openContextPanelTab(sessionDirectory, {
|
||||
mode: 'chat',
|
||||
dedupeKey: `session:${session.id}`,
|
||||
label: sessionTitle,
|
||||
});
|
||||
}}
|
||||
className="[&>svg]:mr-1"
|
||||
{!isMinimalMode || mobileVariant ? (
|
||||
<div className={cn(
|
||||
'absolute right-0 top-1/2 z-10 -translate-y-1/2',
|
||||
cn(
|
||||
'transition-opacity',
|
||||
isMenuOpen
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: mobileVariant
|
||||
? 'opacity-100 pointer-events-auto'
|
||||
: 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto',
|
||||
),
|
||||
)}>
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex 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 transition-opacity',
|
||||
isMinimalMode && !mobileVariant
|
||||
? (isMenuOpen
|
||||
? 'h-4 w-4 opacity-100 pointer-events-auto'
|
||||
: 'h-4 w-4 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto')
|
||||
: 'h-6 w-6 opacity-100',
|
||||
)}
|
||||
aria-label="Session menu"
|
||||
onClick={handleMenuTriggerClick}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<RiChat4Line className="mr-1 h-4 w-4" />
|
||||
<span className="truncate">Open in Side Panel</span>
|
||||
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">beta</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
|
||||
<RiDeleteBinLine className="mr-1 h-4 w-4" />
|
||||
{archivedBucket ? 'Delete' : 'Archive'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<RiMore2Line className={cn(isMinimalMode && !mobileVariant ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
{sessionMenuContent}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DraggableSessionRow>
|
||||
{hasChildren && isExpanded
|
||||
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket))
|
||||
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket, undefined, renderContext))
|
||||
: null}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SessionNode } from './types';
|
||||
|
||||
type ActivityItem = {
|
||||
node: SessionNode;
|
||||
projectId: string | null;
|
||||
groupDirectory: string | null;
|
||||
secondaryMeta: {
|
||||
projectLabel?: string | null;
|
||||
branchLabel?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type ActivitySection = {
|
||||
key: 'active-now';
|
||||
title: string;
|
||||
items: ActivityItem[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
sections: ActivitySection[];
|
||||
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean, secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null, renderContext?: 'project' | 'recent') => React.ReactNode;
|
||||
};
|
||||
|
||||
const MAX_VISIBLE_RECENT_SESSIONS = 7;
|
||||
|
||||
export function SidebarActivitySections({ sections, renderSessionNode }: Props): React.ReactNode {
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||
const [expandedSections, setExpandedSections] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSection = React.useCallback((key: string) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSectionLimit = React.useCallback((key: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const visibleSections = sections.filter((section) => section.items.length > 0);
|
||||
if (visibleSections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pb-2 pt-1">
|
||||
{visibleSections.map((section) => {
|
||||
const isCollapsed = collapsed.has(section.key);
|
||||
const isExpanded = expandedSections.has(section.key);
|
||||
const visibleItems = isExpanded ? section.items : section.items.slice(0, MAX_VISIBLE_RECENT_SESSIONS);
|
||||
const remainingCount = section.items.length - visibleItems.length;
|
||||
return (
|
||||
<div key={section.key} className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection(section.key)}
|
||||
className="group flex w-full items-center gap-1 rounded-md px-0.5 py-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<span className="inline-flex h-4 w-4 items-center justify-center text-muted-foreground">
|
||||
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<span className="text-[14px] font-normal text-foreground/95">{section.title}</span>
|
||||
</button>
|
||||
{!isCollapsed ? (
|
||||
<div className={cn('space-y-0.5 pl-7')}>
|
||||
{visibleItems.map((item) => renderSessionNode(item.node, 0, item.groupDirectory, item.projectId, false, item.secondaryMeta, 'recent'))}
|
||||
{remainingCount > 0 && !isExpanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSectionLimit(section.key)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
Show {remainingCount} more {remainingCount === 1 ? 'session' : 'sessions'}
|
||||
</button>
|
||||
) : null}
|
||||
{isExpanded && section.items.length > MAX_VISIBLE_RECENT_SESSIONS ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSectionLimit(section.key)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
Show fewer sessions
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { RiInformationLine, RiQuestionLine, RiSettings3Line } from '@remixicon/react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
type Props = {
|
||||
onOpenSettings: () => void;
|
||||
onOpenShortcuts: () => void;
|
||||
onOpenAbout: () => void;
|
||||
};
|
||||
|
||||
const footerButtonClassName = 'inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
|
||||
|
||||
export function SidebarFooter({ onOpenSettings, onOpenShortcuts, onOpenAbout }: Props): React.ReactNode {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-start gap-1 px-2.5 py-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" onClick={onOpenSettings} className={footerButtonClassName} aria-label="Settings">
|
||||
<RiSettings3Line className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>Settings</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" onClick={onOpenShortcuts} className={footerButtonClassName} aria-label="Shortcuts">
|
||||
<RiQuestionLine className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>Shortcuts</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" onClick={onOpenAbout} className={footerButtonClassName} aria-label="About OpenChamber">
|
||||
<RiInformationLine className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}><p>About OpenChamber</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,62 +7,22 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiChatNewLine,
|
||||
RiEqualizer2Line,
|
||||
RiNodeTree,
|
||||
RiPencilAiLine,
|
||||
RiFolderAddLine,
|
||||
RiSearchLine,
|
||||
RiStickyNoteLine,
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import { ProjectNotesTodoPanel } from '../ProjectNotesTodoPanel';
|
||||
import { formatDirectoryName } from '@/lib/utils';
|
||||
import { formatProjectLabel } from './utils';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
|
||||
type ProjectItem = {
|
||||
id: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
type ActiveProject = {
|
||||
id: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
} | null;
|
||||
|
||||
type Props = {
|
||||
hideDirectoryControls: boolean;
|
||||
hideProjectSelector: boolean;
|
||||
activeProjectForHeader: ActiveProject;
|
||||
homeDirectory: string | null;
|
||||
normalizedProjects: ProjectItem[];
|
||||
activeProjectId: string | null;
|
||||
setActiveProjectIdOnly: (projectId: string) => void;
|
||||
isProjectRenameInline: boolean;
|
||||
setIsProjectRenameInline: (value: boolean) => void;
|
||||
handleStartInlineProjectRename: () => void;
|
||||
handleSaveInlineProjectRename: () => void;
|
||||
projectRenameDraft: string;
|
||||
setProjectRenameDraft: (value: string) => void;
|
||||
removeProject: (projectId: string) => void;
|
||||
handleOpenDirectoryDialog: () => void;
|
||||
addProjectButtonClass: string;
|
||||
handleNewSession: () => void;
|
||||
headerActionIconClass: string;
|
||||
reserveHeaderActionsSpace: boolean;
|
||||
stableActiveProjectIsRepo: boolean;
|
||||
useMobileNotesPanel: boolean;
|
||||
projectNotesPanelOpen: boolean;
|
||||
setProjectNotesPanelOpen: (open: boolean) => void;
|
||||
activeProjectRefForHeader: ProjectRef | null;
|
||||
openMultiRunLauncher: () => void;
|
||||
headerActionButtonClass: string;
|
||||
setNewWorktreeDialogOpen: (open: boolean) => void;
|
||||
setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void;
|
||||
isSessionSearchOpen: boolean;
|
||||
setIsSessionSearchOpen: (open: boolean | ((prev: boolean) => boolean)) => void;
|
||||
sessionSearchInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
@@ -75,31 +35,11 @@ type Props = {
|
||||
export function SidebarHeader(props: Props): React.ReactNode {
|
||||
const {
|
||||
hideDirectoryControls,
|
||||
hideProjectSelector,
|
||||
activeProjectForHeader,
|
||||
homeDirectory,
|
||||
normalizedProjects,
|
||||
activeProjectId,
|
||||
setActiveProjectIdOnly,
|
||||
isProjectRenameInline,
|
||||
setIsProjectRenameInline,
|
||||
handleStartInlineProjectRename,
|
||||
handleSaveInlineProjectRename,
|
||||
projectRenameDraft,
|
||||
setProjectRenameDraft,
|
||||
removeProject,
|
||||
addProjectButtonClass,
|
||||
handleOpenDirectoryDialog,
|
||||
handleNewSession,
|
||||
headerActionIconClass,
|
||||
reserveHeaderActionsSpace,
|
||||
stableActiveProjectIsRepo,
|
||||
useMobileNotesPanel,
|
||||
projectNotesPanelOpen,
|
||||
setProjectNotesPanelOpen,
|
||||
activeProjectRefForHeader,
|
||||
openMultiRunLauncher,
|
||||
headerActionButtonClass,
|
||||
setNewWorktreeDialogOpen,
|
||||
setActiveMainTab,
|
||||
isSessionSearchOpen,
|
||||
setIsSessionSearchOpen,
|
||||
sessionSearchInputRef,
|
||||
@@ -117,321 +57,129 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`select-none pl-3.5 pr-2 flex-shrink-0 border-b border-border/60 ${hideProjectSelector ? 'py-1' : 'py-1.5'}`}>
|
||||
{!hideProjectSelector && (
|
||||
<div className="flex h-8 items-center justify-between gap-2">
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setIsProjectRenameInline(false);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 min-w-0 max-w-[calc(100%-2.5rem)] cursor-pointer items-center gap-1 rounded-md px-2 text-left text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
<span className="text-base font-semibold truncate">
|
||||
{activeProjectForHeader
|
||||
? formatProjectLabel(
|
||||
activeProjectForHeader.label?.trim()
|
||||
|| formatDirectoryName(activeProjectForHeader.normalizedPath, homeDirectory)
|
||||
|| activeProjectForHeader.normalizedPath,
|
||||
)
|
||||
: 'Projects'}
|
||||
</span>
|
||||
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[220px] max-w-[320px]">
|
||||
{normalizedProjects.map((project) => {
|
||||
const label = formatProjectLabel(
|
||||
project.label?.trim()
|
||||
|| formatDirectoryName(project.normalizedPath, homeDirectory)
|
||||
|| project.normalizedPath,
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() => setActiveProjectIdOnly(project.id)}
|
||||
className={`truncate ${project.id === activeProjectId ? 'text-primary' : ''}`}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<div className="my-1 h-px bg-border/70" />
|
||||
{!isProjectRenameInline ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
handleStartInlineProjectRename();
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiPencilAiLine className="h-4 w-4" />
|
||||
Rename project
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className="px-2 py-1.5">
|
||||
<form
|
||||
className="flex items-center gap-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSaveInlineProjectRename();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={projectRenameDraft}
|
||||
onChange={(event) => setProjectRenameDraft(event.target.value)}
|
||||
className="h-7 flex-1 rounded border border-border bg-transparent px-2 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
placeholder="Rename project"
|
||||
autoFocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
setIsProjectRenameInline(false);
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground">
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsProjectRenameInline(false)}
|
||||
className="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!activeProjectForHeader) return;
|
||||
removeProject(activeProjectForHeader.id);
|
||||
}}
|
||||
className="text-destructive focus:text-destructive gap-2"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
Close project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="select-none flex-shrink-0 px-2.5 py-1">
|
||||
{reserveHeaderActionsSpace ? (
|
||||
<div className="flex h-auto min-h-8 flex-col gap-1">
|
||||
<div className="flex h-8 items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={addProjectButtonClass}
|
||||
aria-label="Session display mode"
|
||||
onClick={handleOpenDirectoryDialog}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Add project"
|
||||
>
|
||||
<RiEqualizer2Line className={headerActionIconClass} />
|
||||
<RiFolderAddLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Display mode</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[160px]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('default')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Default</span>
|
||||
{displayMode === 'default' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('minimal')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Minimal</span>
|
||||
{displayMode === 'minimal' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Add project</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewSession}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="New session"
|
||||
>
|
||||
<RiChatNewLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New session</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{reserveHeaderActionsSpace ? (
|
||||
<div className="-ml-1 flex h-auto min-h-8 flex-col gap-1">
|
||||
{activeProjectForHeader ? (
|
||||
<>
|
||||
<div className="flex h-8 -translate-y-px items-center justify-between gap-1.5 rounded-md pl-0 pr-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{stableActiveProjectIsRepo ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!activeProjectForHeader) return;
|
||||
if (activeProjectForHeader.id !== activeProjectId) {
|
||||
setActiveProjectIdOnly(activeProjectForHeader.id);
|
||||
}
|
||||
setActiveMainTab('chat');
|
||||
setNewWorktreeDialogOpen(true);
|
||||
}}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="New worktree"
|
||||
>
|
||||
<RiNodeTree className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New worktree</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openMultiRunLauncher}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="New multi-run"
|
||||
>
|
||||
<ArrowsMerge className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New multi-run</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{useMobileNotesPanel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProjectNotesPanelOpen(true)}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Project notes and todos"
|
||||
>
|
||||
<RiStickyNoteLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Project notes</p></TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<DropdownMenu open={projectNotesPanelOpen} onOpenChange={setProjectNotesPanelOpen} modal={false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Project notes and todos"
|
||||
>
|
||||
<RiStickyNoteLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Project notes</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" className="w-[340px] p-0">
|
||||
<ProjectNotesTodoPanel
|
||||
projectRef={activeProjectRefForHeader}
|
||||
canCreateWorktree={stableActiveProjectIsRepo}
|
||||
onActionComplete={() => setProjectNotesPanelOpen(false)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSessionSearchOpen((prev) => !prev)}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Search sessions"
|
||||
aria-expanded={isSessionSearchOpen}
|
||||
>
|
||||
<RiSearchLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Search sessions</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSessionSearchOpen((prev) => !prev)}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Search sessions"
|
||||
aria-expanded={isSessionSearchOpen}
|
||||
>
|
||||
<RiSearchLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Search sessions</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Session display mode"
|
||||
>
|
||||
<RiEqualizer2Line className={headerActionIconClass} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Display mode</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[160px]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('default')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Default</span>
|
||||
{displayMode === 'default' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('minimal')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Minimal</span>
|
||||
{displayMode === 'minimal' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{isSessionSearchOpen ? (
|
||||
<div className="px-1 pb-1">
|
||||
<div className="mb-1 flex items-center justify-between px-0.5 typography-micro text-muted-foreground/80">
|
||||
{hasSessionSearchQuery ? (
|
||||
<span>{searchMatchCount} {searchMatchCount === 1 ? 'match' : 'matches'}</span>
|
||||
) : <span />}
|
||||
<span>Esc to clear</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<RiSearchLine className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
ref={sessionSearchInputRef}
|
||||
value={sessionSearchQuery}
|
||||
onChange={(event) => setSessionSearchQuery(event.target.value)}
|
||||
placeholder="Search sessions..."
|
||||
className="h-8 w-full rounded-md border border-border bg-transparent pl-8 pr-8 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
if (hasSessionSearchQuery) {
|
||||
setSessionSearchQuery('');
|
||||
} else {
|
||||
setIsSessionSearchOpen(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{sessionSearchQuery.length > 0 ? (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSessionSearchQuery('')}
|
||||
className="absolute right-1 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Clear search"
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Session display mode"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
<RiEqualizer2Line className={headerActionIconClass} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Display mode</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[160px]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('default')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Default</span>
|
||||
{displayMode === 'default' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('minimal')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Minimal</span>
|
||||
{displayMode === 'minimal' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSessionSearchOpen ? (
|
||||
<div className="pb-1">
|
||||
<div className="mb-1 flex items-center justify-between px-0.5 typography-micro text-muted-foreground/80">
|
||||
{hasSessionSearchQuery ? (
|
||||
<span>{searchMatchCount} {searchMatchCount === 1 ? 'match' : 'matches'}</span>
|
||||
) : <span />}
|
||||
<span>Esc to clear</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<RiSearchLine className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
ref={sessionSearchInputRef}
|
||||
value={sessionSearchQuery}
|
||||
onChange={(event) => setSessionSearchQuery(event.target.value)}
|
||||
placeholder="Search sessions..."
|
||||
className="h-8 w-full rounded-md border border-border bg-transparent pl-8 pr-8 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
if (hasSessionSearchQuery) {
|
||||
setSessionSearchQuery('');
|
||||
} else {
|
||||
setIsSessionSearchOpen(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{sessionSearchQuery.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSessionSearchQuery('')}
|
||||
className="absolute right-1 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSo
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
|
||||
import type { SessionGroup } from './types';
|
||||
import type { SortableDragHandleProps } from './sortableItems';
|
||||
import { SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||||
import { formatProjectLabel } from './utils';
|
||||
|
||||
@@ -20,11 +21,16 @@ type ProjectSection = {
|
||||
id: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
iconBackground?: string;
|
||||
};
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
topContent?: React.ReactNode;
|
||||
sectionsForRender: ProjectSection[];
|
||||
projectSections: ProjectSection[];
|
||||
activeProjectId: string | null;
|
||||
@@ -32,7 +38,7 @@ type Props = {
|
||||
hasSessionSearchQuery: boolean;
|
||||
emptyState: React.ReactNode;
|
||||
searchEmptyState: React.ReactNode;
|
||||
renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => React.ReactNode;
|
||||
renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null) => React.ReactNode;
|
||||
homeDirectory: string | null;
|
||||
collapsedProjects: Set<string>;
|
||||
hideDirectoryControls: boolean;
|
||||
@@ -47,38 +53,39 @@ type Props = {
|
||||
setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void;
|
||||
createWorktreeSession: () => void;
|
||||
openNewWorktreeDialog: () => void;
|
||||
openMultiRunLauncher: () => void;
|
||||
setEditingProjectId: (id: string | null) => void;
|
||||
setEditProjectTitle: (title: string) => void;
|
||||
editingProjectId: string | null;
|
||||
editProjectTitle: string;
|
||||
handleSaveProjectEdit: () => void;
|
||||
handleCancelProjectEdit: () => void;
|
||||
openProjectEditDialog: (id: string) => void;
|
||||
removeProject: (id: string) => void;
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
settingsAutoCreateWorktree: boolean;
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[];
|
||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
isInlineEditing: boolean;
|
||||
};
|
||||
|
||||
export function SidebarProjectsList(props: Props): React.ReactNode {
|
||||
const sensors = useSensors(
|
||||
const projectSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
const groupSensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
|
||||
if (props.projectSections.length === 0) {
|
||||
return <ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-1', props.mobileVariant ? '' : '')}>{props.emptyState}</ScrollableOverlay>;
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>{props.topContent}{props.emptyState}</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
if (props.sectionsForRender.length === 0) {
|
||||
return <ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-1', props.mobileVariant ? '' : '')}>{props.searchEmptyState}</ScrollableOverlay>;
|
||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>{props.searchEmptyState}</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-1', props.mobileVariant ? '' : '')}>
|
||||
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>
|
||||
{props.topContent}
|
||||
{props.showOnlyMainWorkspace ? (
|
||||
<div className="space-y-[0.6rem] py-1">
|
||||
{(() => {
|
||||
@@ -113,117 +120,128 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{props.sectionsForRender.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = formatProjectLabel(
|
||||
project.label?.trim()
|
||||
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|
||||
|| project.normalizedPath,
|
||||
);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||||
const isCollapsed = props.collapsedProjects.has(projectKey) && props.hideDirectoryControls;
|
||||
const isActiveProject = projectKey === props.activeProjectId;
|
||||
const isRepo = props.projectRepoStatus.get(projectKey);
|
||||
const isHovered = props.hoveredProjectId === projectKey;
|
||||
const orderedGroups = props.getOrderedGroups(projectKey, section.groups);
|
||||
const sortableEntries = orderedGroups.map((group) => ({
|
||||
sortableId: `${projectKey}:${group.id}`,
|
||||
groupId: group.id,
|
||||
}));
|
||||
const sortableGroupIds = sortableEntries.map((entry) => entry.sortableId);
|
||||
const sortableIdToGroupId = new Map(sortableEntries.map((entry) => [entry.sortableId, entry.groupId]));
|
||||
<DndContext
|
||||
sensors={projectSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
if (props.isInlineEditing) return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
||||
const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
props.reorderProjects(oldIndex, newIndex);
|
||||
}}
|
||||
>
|
||||
<SortableContext items={props.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{props.sectionsForRender.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = formatProjectLabel(
|
||||
project.label?.trim()
|
||||
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|
||||
|| project.normalizedPath,
|
||||
);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||||
const isCollapsed = props.collapsedProjects.has(projectKey);
|
||||
const isActiveProject = projectKey === props.activeProjectId;
|
||||
const isHovered = props.hoveredProjectId === projectKey;
|
||||
const isRepo = props.projectRepoStatus.get(projectKey);
|
||||
const orderedGroups = props.getOrderedGroups(projectKey, section.groups);
|
||||
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
|
||||
const nestedGroups = rootGroup
|
||||
? orderedGroups.filter((group) => group.id !== rootGroup.id)
|
||||
: orderedGroups;
|
||||
|
||||
return (
|
||||
<SortableProjectItem
|
||||
key={projectKey}
|
||||
id={projectKey}
|
||||
projectLabel={projectLabel}
|
||||
projectDescription={projectDescription}
|
||||
isCollapsed={isCollapsed}
|
||||
isActiveProject={isActiveProject}
|
||||
isRepo={Boolean(isRepo)}
|
||||
isHovered={isHovered}
|
||||
isDesktopShell={props.isDesktopShellRuntime}
|
||||
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
||||
hideDirectoryControls={props.hideDirectoryControls}
|
||||
mobileVariant={props.mobileVariant}
|
||||
onToggle={() => props.toggleProject(projectKey)}
|
||||
onHoverChange={(hovered) => props.setHoveredProjectId(hovered ? projectKey : null)}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||
props.openNewSessionDraft({ directoryOverride: project.normalizedPath });
|
||||
}}
|
||||
onNewWorktreeSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||
props.createWorktreeSession();
|
||||
}}
|
||||
onOpenMultiRunLauncher={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.openMultiRunLauncher();
|
||||
}}
|
||||
onRenameStart={() => {
|
||||
props.setEditingProjectId(projectKey);
|
||||
props.setEditProjectTitle(project.label?.trim() || formatDirectoryName(project.normalizedPath, props.homeDirectory) || project.normalizedPath);
|
||||
}}
|
||||
onRenameSave={props.handleSaveProjectEdit}
|
||||
onRenameCancel={props.handleCancelProjectEdit}
|
||||
onRenameValueChange={props.setEditProjectTitle}
|
||||
renameValue={props.editingProjectId === projectKey ? props.editProjectTitle : ''}
|
||||
isRenaming={props.editingProjectId === projectKey}
|
||||
onClose={() => props.removeProject(projectKey)}
|
||||
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
settingsAutoCreateWorktree={props.settingsAutoCreateWorktree}
|
||||
showCreateButtons={false}
|
||||
hideHeader
|
||||
>
|
||||
{!isCollapsed ? (
|
||||
<div className="space-y-[0.6rem] py-1">
|
||||
{section.groups.length > 0 ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const activeId = typeof active.id === 'string' ? sortableIdToGroupId.get(active.id) : null;
|
||||
const overId = typeof over.id === 'string' ? sortableIdToGroupId.get(over.id) : null;
|
||||
if (!activeId || !overId) return;
|
||||
const oldIndex = orderedGroups.findIndex((item) => item.id === activeId);
|
||||
const newIndex = orderedGroups.findIndex((item) => item.id === overId);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
const next = arrayMove(orderedGroups, oldIndex, newIndex).map((item) => item.id);
|
||||
props.setGroupOrderByProject((prev) => {
|
||||
const map = new Map(prev);
|
||||
map.set(projectKey, next);
|
||||
return map;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SortableContext items={sortableGroupIds} strategy={verticalListSortingStrategy}>
|
||||
{orderedGroups.map((group) => {
|
||||
const groupKey = `${projectKey}:${group.id}`;
|
||||
return (
|
||||
<SortableGroupItem key={groupKey} id={groupKey} disabled={props.isInlineEditing}>
|
||||
{props.renderGroupSessions(group, groupKey, projectKey)}
|
||||
</SortableGroupItem>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="py-1 text-left typography-micro text-muted-foreground">No sessions yet.</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</SortableProjectItem>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<SortableProjectItem
|
||||
key={projectKey}
|
||||
id={projectKey}
|
||||
projectLabel={projectLabel}
|
||||
projectDescription={projectDescription}
|
||||
projectIcon={project.icon}
|
||||
projectColor={project.color}
|
||||
projectIconImage={project.iconImage}
|
||||
projectIconBackground={project.iconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
isActiveProject={isActiveProject}
|
||||
isHovered={isHovered}
|
||||
isRepo={Boolean(isRepo)}
|
||||
isDesktopShell={props.isDesktopShellRuntime}
|
||||
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
||||
hideDirectoryControls={props.hideDirectoryControls}
|
||||
mobileVariant={props.mobileVariant}
|
||||
onToggle={() => props.toggleProject(projectKey)}
|
||||
onHoverChange={(hovered) => props.setHoveredProjectId(hovered ? projectKey : null)}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||
props.openNewSessionDraft({ directoryOverride: project.normalizedPath });
|
||||
}}
|
||||
onNewWorktreeSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||
props.openNewWorktreeDialog();
|
||||
}}
|
||||
onOpenMultiRunLauncher={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.openMultiRunLauncher();
|
||||
}}
|
||||
onRenameStart={() => props.openProjectEditDialog(projectKey)}
|
||||
onClose={() => props.removeProject(projectKey)}
|
||||
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
showCreateButtons
|
||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||
>
|
||||
{!isCollapsed ? (
|
||||
<div className="space-y-0 pt-0 pb-0.5 pl-3">
|
||||
{section.groups.length > 0 ? (
|
||||
<DndContext
|
||||
sensors={groupSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
if (props.isInlineEditing) return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = nestedGroups.findIndex((item) => item.id === active.id);
|
||||
const newIndex = nestedGroups.findIndex((item) => item.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
const nextNested = arrayMove(nestedGroups, oldIndex, newIndex).map((item) => item.id);
|
||||
const next = rootGroup ? [rootGroup.id, ...nextNested] : nextNested;
|
||||
props.setGroupOrderByProject((prev) => {
|
||||
const map = new Map(prev);
|
||||
map.set(projectKey, next);
|
||||
return map;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true) : null}
|
||||
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
|
||||
{nestedGroups.map((group) => {
|
||||
const groupKey = `${projectKey}:${group.id}`;
|
||||
return (
|
||||
<SortableGroupItem key={group.id} id={group.id} disabled={props.isInlineEditing}>
|
||||
{(dragHandleProps) => props.renderGroupSessions(group, groupKey, projectKey, false, dragHandleProps)}
|
||||
</SortableGroupItem>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="py-1 text-left typography-micro text-muted-foreground">No sessions yet.</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</SortableProjectItem>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
</>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
export const ACTIVE_NOW_STORAGE_KEY = 'oc.sessions.activeNow';
|
||||
export const ACTIVE_NOW_MAX_AGE_MS = 36 * 60 * 60 * 1000;
|
||||
|
||||
export type ActiveNowEntry = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
const isSubtaskSession = (session: Session): boolean => {
|
||||
return Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||
};
|
||||
|
||||
const isArchivedSession = (session: Session): boolean => {
|
||||
return Boolean(session.time?.archived);
|
||||
};
|
||||
|
||||
const getSessionUpdatedAt = (session: Session): number => {
|
||||
const updated = session.time?.updated;
|
||||
const created = session.time?.created;
|
||||
if (typeof updated === 'number' && Number.isFinite(updated)) {
|
||||
return updated;
|
||||
}
|
||||
if (typeof created === 'number' && Number.isFinite(created)) {
|
||||
return created;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const readActiveNowEntries = (storage: Storage): ActiveNowEntry[] => {
|
||||
try {
|
||||
const raw = storage.getItem(ACTIVE_NOW_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const next: ActiveNowEntry[] = [];
|
||||
parsed.forEach((item) => {
|
||||
const sessionId = typeof item === 'string'
|
||||
? item
|
||||
: (item && typeof item === 'object' && 'sessionId' in item && typeof item.sessionId === 'string' ? item.sessionId : null);
|
||||
if (!sessionId || seen.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
seen.add(sessionId);
|
||||
next.push({ sessionId });
|
||||
});
|
||||
return next;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const persistActiveNowEntries = (storage: Storage, entries: ActiveNowEntry[]): void => {
|
||||
try {
|
||||
storage.setItem(ACTIVE_NOW_STORAGE_KEY, JSON.stringify(entries));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
};
|
||||
|
||||
export const pruneActiveNowEntries = (
|
||||
entries: ActiveNowEntry[],
|
||||
sessionsById: Map<string, Session>,
|
||||
now = Date.now(),
|
||||
): ActiveNowEntry[] => {
|
||||
const minUpdatedAt = now - ACTIVE_NOW_MAX_AGE_MS;
|
||||
return entries.filter((entry) => {
|
||||
const session = sessionsById.get(entry.sessionId);
|
||||
if (!session) {
|
||||
return true;
|
||||
}
|
||||
if (isArchivedSession(session)) {
|
||||
return false;
|
||||
}
|
||||
return getSessionUpdatedAt(session) >= minUpdatedAt;
|
||||
});
|
||||
};
|
||||
|
||||
export const addActiveNowSession = (entries: ActiveNowEntry[], sessionId: string): ActiveNowEntry[] => {
|
||||
if (!sessionId || entries.some((entry) => entry.sessionId === sessionId)) {
|
||||
return entries;
|
||||
}
|
||||
return [{ sessionId }, ...entries];
|
||||
};
|
||||
|
||||
export const sortSessionsByUpdated = (sessions: Session[]): Session[] => {
|
||||
return [...sessions].sort((a, b) => getSessionUpdatedAt(b) - getSessionUpdatedAt(a));
|
||||
};
|
||||
|
||||
export const deriveActiveNowSessions = (
|
||||
entries: ActiveNowEntry[],
|
||||
sessionsById: Map<string, Session>,
|
||||
): Session[] => {
|
||||
const sessions = entries
|
||||
.map((entry) => sessionsById.get(entry.sessionId) ?? null)
|
||||
.filter((session): session is Session => Boolean(session))
|
||||
.filter((session) => !isArchivedSession(session))
|
||||
.filter((session) => !isSubtaskSession(session));
|
||||
return sortSessionsByUpdated(sessions);
|
||||
};
|
||||
|
||||
export const getSessionUpdatedAtMs = getSessionUpdatedAt;
|
||||
@@ -4,13 +4,11 @@ import type { SessionGroup } from '../types';
|
||||
export const useGroupOrdering = (groupOrderByProject: Map<string, string[]>) => {
|
||||
const getOrderedGroups = React.useCallback(
|
||||
(projectId: string, groups: SessionGroup[]) => {
|
||||
const archivedGroup = groups.find((group) => group.isArchivedBucket === true) ?? null;
|
||||
const reorderableGroups = archivedGroup ? groups.filter((group) => group !== archivedGroup) : groups;
|
||||
const preferredOrder = groupOrderByProject.get(projectId);
|
||||
if (!preferredOrder || preferredOrder.length === 0) {
|
||||
return archivedGroup ? [...reorderableGroups, archivedGroup] : reorderableGroups;
|
||||
return groups;
|
||||
}
|
||||
const groupById = new Map(reorderableGroups.map((group) => [group.id, group]));
|
||||
const groupById = new Map(groups.map((group) => [group.id, group]));
|
||||
const ordered: SessionGroup[] = [];
|
||||
preferredOrder.forEach((id) => {
|
||||
const group = groupById.get(id);
|
||||
@@ -19,12 +17,12 @@ export const useGroupOrdering = (groupOrderByProject: Map<string, string[]>) =>
|
||||
groupById.delete(id);
|
||||
}
|
||||
});
|
||||
reorderableGroups.forEach((group) => {
|
||||
groups.forEach((group) => {
|
||||
if (groupById.has(group.id)) {
|
||||
ordered.push(group);
|
||||
}
|
||||
});
|
||||
return archivedGroup ? [...ordered, archivedGroup] : ordered;
|
||||
return ordered;
|
||||
},
|
||||
[groupOrderByProject],
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ type Args = {
|
||||
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
||||
currentSessionId: string | null;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, isMissingDirectory: boolean, projectId?: string | null) => void;
|
||||
isVSCode: boolean;
|
||||
newSessionDraftOpen: boolean;
|
||||
mobileVariant: boolean;
|
||||
openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void;
|
||||
@@ -33,7 +32,6 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
|
||||
setActiveSessionByProject,
|
||||
currentSessionId,
|
||||
handleSessionSelect,
|
||||
isVSCode,
|
||||
newSessionDraftOpen,
|
||||
mobileVariant,
|
||||
openNewSessionDraft,
|
||||
@@ -84,18 +82,13 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
|
||||
}, [projectSections]);
|
||||
|
||||
const previousActiveProjectRef = React.useRef<string | null>(null);
|
||||
const lastSeenActiveProjectRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!activeProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSeenProjectId = lastSeenActiveProjectRef.current;
|
||||
const isProjectSwitch = Boolean(previousSeenProjectId && previousSeenProjectId !== activeProjectId);
|
||||
lastSeenActiveProjectRef.current = activeProjectId;
|
||||
|
||||
if (newSessionDraftOpen && (isVSCode || !isProjectSwitch)) {
|
||||
if (newSessionDraftOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,7 +139,6 @@ export const useProjectSessionSelection = (args: Args): { currentSessionDirector
|
||||
activeSessionByProject,
|
||||
currentSessionId,
|
||||
handleSessionSelect,
|
||||
isVSCode,
|
||||
newSessionDraftOpen,
|
||||
mobileVariant,
|
||||
openNewSessionDraft,
|
||||
|
||||
@@ -83,6 +83,11 @@ export const useSessionActions = (args: Args) => {
|
||||
|
||||
if (sessionId === args.currentSessionId) {
|
||||
if (args.allowReselect) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent<string>('openchamber:session-reselected', {
|
||||
detail: sessionId,
|
||||
}));
|
||||
}
|
||||
args.onSessionSelected?.(sessionId);
|
||||
}
|
||||
resetSessionSearch();
|
||||
|
||||
@@ -19,6 +19,8 @@ type Args = {
|
||||
isVSCode: boolean;
|
||||
};
|
||||
|
||||
const isArchivedSession = (session: Session): boolean => Boolean(session.time?.archived);
|
||||
|
||||
export const useSessionGrouping = (args: Args) => {
|
||||
const buildGroupSearchText = React.useCallback((group: SessionGroup): string => {
|
||||
return [group.label, group.branch ?? '', group.description ?? '', group.directory ?? ''].join(' ').toLowerCase();
|
||||
@@ -70,6 +72,10 @@ export const useSessionGrouping = (args: Args) => {
|
||||
sortedProjectSessions.forEach((session) => {
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentID) return;
|
||||
const parentSession = sessionMap.get(parentID);
|
||||
if (!parentSession || isArchivedSession(parentSession) !== isArchivedSession(session)) {
|
||||
return;
|
||||
}
|
||||
const collection = childrenMap.get(parentID) ?? [];
|
||||
collection.push(session);
|
||||
childrenMap.set(parentID, collection);
|
||||
@@ -105,7 +111,9 @@ export const useSessionGrouping = (args: Args) => {
|
||||
const roots = sortedProjectSessions.filter((session) => {
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentID) return true;
|
||||
return !sessionMap.has(parentID);
|
||||
const parentSession = sessionMap.get(parentID);
|
||||
if (!parentSession) return true;
|
||||
return isArchivedSession(parentSession) !== isArchivedSession(session);
|
||||
});
|
||||
|
||||
const groupedNodes = new Map<string, SessionNode[]>();
|
||||
|
||||
@@ -9,10 +9,11 @@ const SESSION_PREFETCH_PENDING_LIMIT = 6;
|
||||
type Args = {
|
||||
currentSessionId: string | null;
|
||||
sortedSessions: Session[];
|
||||
recentSessionIds?: string[];
|
||||
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useSessionPrefetch = ({ currentSessionId, sortedSessions, loadMessages }: Args): void => {
|
||||
export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], loadMessages }: Args): void => {
|
||||
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
|
||||
const sessionPrefetchQueueRef = React.useRef<string[]>([]);
|
||||
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
||||
@@ -100,6 +101,20 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, loadMessa
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
|
||||
}, [currentSessionId, scheduleSessionPrefetch, sortedSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId || recentSessionIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = recentSessionIds.indexOf(currentSessionId);
|
||||
if (currentIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]);
|
||||
scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]);
|
||||
}, [currentSessionId, recentSessionIds, scheduleSessionPrefetch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const prefetchTimers = sessionPrefetchTimersRef.current;
|
||||
return () => {
|
||||
|
||||
@@ -9,6 +9,10 @@ type ProjectItem = {
|
||||
path: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
iconBackground?: string;
|
||||
};
|
||||
|
||||
type ProjectSection = {
|
||||
@@ -18,7 +22,6 @@ type ProjectSection = {
|
||||
|
||||
type Args = {
|
||||
normalizedProjects: ProjectItem[];
|
||||
activeProjectId: string | null;
|
||||
getSessionsForProject: (project: { normalizedPath: string }) => Session[];
|
||||
getArchivedSessionsForProject: (project: { normalizedPath: string }) => Session[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
@@ -42,7 +45,6 @@ type Args = {
|
||||
export const useSessionSidebarSections = (args: Args) => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
activeProjectId,
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
availableWorktreesByProject,
|
||||
@@ -88,12 +90,8 @@ export const useSessionSidebarSections = (args: Args) => {
|
||||
]);
|
||||
|
||||
const visibleProjectSections = React.useMemo(() => {
|
||||
if (projectSections.length === 0) {
|
||||
return projectSections;
|
||||
}
|
||||
const active = projectSections.find((section) => section.project.id === activeProjectId);
|
||||
return active ? [active] : [projectSections[0]];
|
||||
}, [projectSections, activeProjectId]);
|
||||
return projectSections;
|
||||
}, [projectSections]);
|
||||
|
||||
const groupSearchDataByGroup = React.useMemo(() => {
|
||||
const result = new WeakMap<SessionGroup, GroupSearchData>();
|
||||
|
||||
@@ -10,23 +10,31 @@ import {
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import {
|
||||
RiAddLine,
|
||||
RiCheckLine,
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiCloseLine,
|
||||
RiGitBranchLine,
|
||||
RiFolderLine,
|
||||
RiMore2Line,
|
||||
RiNodeTree,
|
||||
RiPencilAiLine,
|
||||
} from '@remixicon/react';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
|
||||
export interface SortableProjectItemProps {
|
||||
id: string;
|
||||
projectLabel: string;
|
||||
projectDescription: string;
|
||||
projectIcon?: string;
|
||||
projectColor?: string;
|
||||
projectIconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||
projectIconBackground?: string;
|
||||
isCollapsed: boolean;
|
||||
isActiveProject: boolean;
|
||||
isRepo: boolean;
|
||||
isHovered: boolean;
|
||||
isRepo: boolean;
|
||||
isDesktopShell: boolean;
|
||||
isStuck: boolean;
|
||||
hideDirectoryControls: boolean;
|
||||
@@ -37,27 +45,32 @@ export interface SortableProjectItemProps {
|
||||
onNewWorktreeSession?: () => void;
|
||||
onOpenMultiRunLauncher: () => void;
|
||||
onRenameStart: () => void;
|
||||
onRenameSave: () => void;
|
||||
onRenameCancel: () => void;
|
||||
onRenameValueChange: (value: string) => void;
|
||||
renameValue: string;
|
||||
isRenaming: boolean;
|
||||
onClose: () => void;
|
||||
sentinelRef: (el: HTMLDivElement | null) => void;
|
||||
children?: React.ReactNode;
|
||||
settingsAutoCreateWorktree: boolean;
|
||||
showCreateButtons?: boolean;
|
||||
hideHeader?: boolean;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
}
|
||||
|
||||
export type SortableDragHandleProps = {
|
||||
listeners: ReturnType<typeof useSortable>['listeners'];
|
||||
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
|
||||
};
|
||||
|
||||
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
id,
|
||||
projectLabel,
|
||||
projectDescription,
|
||||
projectIcon,
|
||||
projectColor,
|
||||
projectIconImage,
|
||||
projectIconBackground,
|
||||
isCollapsed,
|
||||
isActiveProject,
|
||||
isRepo,
|
||||
isHovered,
|
||||
isRepo,
|
||||
isDesktopShell,
|
||||
isStuck,
|
||||
hideDirectoryControls,
|
||||
@@ -68,18 +81,15 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
onNewWorktreeSession,
|
||||
onOpenMultiRunLauncher,
|
||||
onRenameStart,
|
||||
onRenameSave,
|
||||
onRenameCancel,
|
||||
onRenameValueChange,
|
||||
renameValue,
|
||||
isRenaming,
|
||||
onClose,
|
||||
sentinelRef,
|
||||
children,
|
||||
settingsAutoCreateWorktree,
|
||||
showCreateButtons = true,
|
||||
hideHeader = false,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
}) => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -89,7 +99,45 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||
const [imageFailed, setImageFailed] = React.useState(false);
|
||||
const suppressNextToggleRef = React.useRef(false);
|
||||
const menuInstanceKey = `project:${id}`;
|
||||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
||||
|
||||
React.useEffect(() => {
|
||||
setImageFailed(false);
|
||||
}, [id, projectIconImage?.updatedAt]);
|
||||
|
||||
const ProjectIcon = projectIcon ? PROJECT_ICON_MAP[projectIcon] : null;
|
||||
const iconColor = projectColor ? (PROJECT_COLOR_MAP[projectColor] ?? null) : null;
|
||||
const imageUrl = !imageFailed
|
||||
? getProjectIconImageUrl({ id, iconImage: projectIconImage }, {
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
})
|
||||
: null;
|
||||
|
||||
const handleMenuOpenChange = React.useCallback((open: boolean) => {
|
||||
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
|
||||
}, [menuInstanceKey, setOpenSidebarMenuKey]);
|
||||
|
||||
const handleMenuTriggerClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleToggleMouseDown = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (event.button === 2 || (event.button === 0 && event.ctrlKey)) {
|
||||
suppressNextToggleRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleClick = React.useCallback(() => {
|
||||
if (suppressNextToggleRef.current) {
|
||||
suppressNextToggleRef.current = false;
|
||||
return;
|
||||
}
|
||||
onToggle();
|
||||
}, [onToggle]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -110,123 +158,115 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 pt-2 pb-1.5 w-full text-left cursor-pointer group/project border-b select-none',
|
||||
!isDesktopShell && 'bg-transparent',
|
||||
'w-full text-left group/project select-none',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isDesktopShell
|
||||
? (isStuck ? 'transparent' : 'transparent')
|
||||
: undefined,
|
||||
borderColor: isHovered
|
||||
? 'var(--color-border-hover)'
|
||||
: isCollapsed
|
||||
? 'color-mix(in srgb, var(--color-border) 35%, transparent)'
|
||||
: 'var(--color-border)',
|
||||
}}
|
||||
style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }}
|
||||
onMouseEnter={() => onHoverChange(true)}
|
||||
onMouseLeave={() => onHoverChange(false)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
if (!isRenaming) {
|
||||
setIsMenuOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative flex items-center gap-1 px-1" {...attributes}>
|
||||
{isRenaming ? (
|
||||
<form
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
data-keyboard-avoid="true"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onRenameSave();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={renameValue}
|
||||
onChange={(event) => onRenameValueChange(event.target.value)}
|
||||
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
placeholder="Rename project"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
onRenameCancel();
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RiCheckLine className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRenameCancel}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<Tooltip delayDuration={1500}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="relative flex items-center gap-1 px-0.5 py-0.5" {...attributes}>
|
||||
<Tooltip delayDuration={1500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
onMouseDown={handleToggleMouseDown}
|
||||
onClick={handleToggleClick}
|
||||
{...listeners}
|
||||
className="flex-1 min-w-0 flex items-center gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-sm cursor-grab active:cursor-grabbing"
|
||||
className={cn(
|
||||
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
|
||||
isRepo && !hideDirectoryControls
|
||||
? (mobileVariant ? 'pr-20' : isHovered ? 'pr-20' : 'pr-7')
|
||||
: (mobileVariant ? 'pr-14' : isHovered ? 'pr-14' : 'pr-7'),
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'typography-ui font-semibold truncate',
|
||||
isActiveProject ? 'text-primary' : 'text-foreground group-hover/project:text-foreground',
|
||||
)}>
|
||||
{projectLabel}
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
<span className={cn('hidden text-muted-foreground h-3.5 w-3.5 items-center justify-center', isHovered && 'inline-flex')}>
|
||||
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{projectDescription}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{imageUrl ? (
|
||||
<span
|
||||
className={cn('inline-flex h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[3px]', isHovered && 'hidden')}
|
||||
style={projectIconBackground ? { backgroundColor: projectIconBackground } : undefined}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
draggable={false}
|
||||
onError={() => setImageFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
) : ProjectIcon ? (
|
||||
<ProjectIcon className={cn('h-3.5 w-3.5', isHovered && 'hidden')} style={iconColor ? { color: iconColor } : undefined} />
|
||||
) : (
|
||||
<RiFolderLine className={cn('h-3.5 w-3.5 text-muted-foreground/80', isHovered && 'hidden')} style={iconColor ? { color: iconColor } : undefined} />
|
||||
)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-[14px] font-normal truncate lowercase',
|
||||
isActiveProject ? 'text-foreground' : 'text-foreground group-hover/project:text-foreground',
|
||||
)}>
|
||||
{projectLabel}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{projectDescription}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className={cn(
|
||||
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
|
||||
isRepo && !hideDirectoryControls ? 'right-7' : 'right-0.5',
|
||||
)}>
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNewWorktreeSession();
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground transition-opacity',
|
||||
mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
)}
|
||||
aria-label="New worktree"
|
||||
>
|
||||
<RiNodeTree className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
<p>New worktree...</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{!isRenaming ? (
|
||||
<DropdownMenu
|
||||
open={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
onOpenChange={handleMenuOpenChange}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
|
||||
mobileVariant ? 'opacity-70' : 'opacity-0 group-hover/project:opacity-100',
|
||||
)}
|
||||
aria-label="Project menu"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && settingsAutoCreateWorktree && onNewSession && (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
|
||||
isMenuOpen ? 'opacity-100 pointer-events-auto' : mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
)}
|
||||
aria-label="Project menu"
|
||||
onClick={handleMenuTriggerClick}
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
{showCreateButtons && !isRepo && !hideDirectoryControls && onNewSession && (
|
||||
<DropdownMenuItem onClick={onNewSession}>
|
||||
<RiAddLine className="mr-1.5 h-4 w-4" />
|
||||
New Session
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && !settingsAutoCreateWorktree && onNewWorktreeSession && (
|
||||
<DropdownMenuItem onClick={onNewWorktreeSession}>
|
||||
<RiGitBranchLine className="mr-1.5 h-4 w-4" />
|
||||
New Session in Worktree
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && (
|
||||
<DropdownMenuItem onClick={onOpenMultiRunLauncher}>
|
||||
<ArrowsMerge className="mr-1.5 h-4 w-4" />
|
||||
@@ -244,53 +284,35 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
<RiCloseLine className="mr-1.5 h-4 w-4" />
|
||||
Close Project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession && settingsAutoCreateWorktree && !isRenaming && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNewWorktreeSession();
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground hover:bg-interactive-hover/50 flex-shrink-0',
|
||||
mobileVariant ? 'opacity-70' : 'opacity-100',
|
||||
)}
|
||||
aria-label="New session in worktree"
|
||||
>
|
||||
<RiGitBranchLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
<p>New session in worktree</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showCreateButtons && (!settingsAutoCreateWorktree || !isRepo) && !isRenaming && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNewSession();
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 flex-shrink-0 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="New session"
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
<p>New session</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showCreateButtons && onNewSession ? (
|
||||
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNewSession();
|
||||
}}
|
||||
className={cn(
|
||||
'h-6 w-6 rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
mobileVariant ? 'inline-flex items-center justify-center' : isHovered ? 'inline-flex items-center justify-center' : 'hidden',
|
||||
)}
|
||||
aria-label={isRepo ? 'New draft session' : 'New session'}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
<p>{isRepo ? 'New draft session' : 'New session'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -304,17 +326,22 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
const SortableGroupItemBase: React.FC<{
|
||||
id: string;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode | ((dragHandleProps: SortableDragHandleProps) => React.ReactNode);
|
||||
}> = ({ id, disabled = false, children }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled });
|
||||
|
||||
const dragHandleProps = React.useMemo<SortableDragHandleProps>(() => ({
|
||||
listeners,
|
||||
setActivatorNodeRef,
|
||||
}), [listeners, setActivatorNodeRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -326,10 +353,8 @@ const SortableGroupItemBase: React.FC<{
|
||||
'space-y-0.5 rounded-md',
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{children}
|
||||
{typeof children === 'function' ? children(dragHandleProps) : children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,6 +45,34 @@ export const formatSessionDateLabel = (updatedMs: number): string => {
|
||||
return formatDateLabel(updatedMs);
|
||||
};
|
||||
|
||||
export const formatSessionCompactDateLabel = (updatedMs: number): string => {
|
||||
const diff = Math.max(0, Date.now() - updatedMs);
|
||||
|
||||
const minute = 60_000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
const week = 7 * day;
|
||||
const month = 30 * day;
|
||||
const year = 365 * day;
|
||||
|
||||
if (diff < hour) {
|
||||
return `${Math.max(1, Math.floor(diff / minute))}m`;
|
||||
}
|
||||
if (diff < day) {
|
||||
return `${Math.floor(diff / hour)}h`;
|
||||
}
|
||||
if (diff < week) {
|
||||
return `${Math.floor(diff / day)}d`;
|
||||
}
|
||||
if (diff < 5 * week) {
|
||||
return `${Math.floor(diff / week)}w`;
|
||||
}
|
||||
if (diff < year) {
|
||||
return `${Math.floor(diff / month)}mo`;
|
||||
}
|
||||
return `${Math.floor(diff / year)}y`;
|
||||
};
|
||||
|
||||
export const normalizePath = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
|
||||
@@ -5,11 +5,42 @@ import { Compartment, EditorState, RangeSetBuilder, StateField } from '@codemirr
|
||||
import { Decoration, type DecorationSet, EditorView, type KeyBinding, ViewPlugin, WidgetType, gutters, keymap, lineNumbers } from '@codemirror/view';
|
||||
import { defaultKeymap, indentWithTab, history, historyKeymap } from '@codemirror/commands';
|
||||
import { forceParsing, indentUnit } from '@codemirror/language';
|
||||
import { search, searchKeymap, openSearchPanel, closeSearchPanel } from '@codemirror/search';
|
||||
import { search, searchKeymap, openSearchPanel, closeSearchPanel, searchPanelOpen } from '@codemirror/search';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Patches `title` attributes onto CodeMirror search-panel controls for icon-only tooltips. */
|
||||
const buttonTooltips: Record<string, string> = {
|
||||
next: 'Next match',
|
||||
prev: 'Previous match',
|
||||
select: 'Select all matches',
|
||||
replace: 'Replace',
|
||||
replaceAll: 'Replace all',
|
||||
close: 'Close',
|
||||
};
|
||||
const checkboxTooltips: Record<string, string> = {
|
||||
case: 'Match case',
|
||||
re: 'Regular expression',
|
||||
word: 'Match whole word',
|
||||
};
|
||||
|
||||
function patchSearchTooltips(root: HTMLElement) {
|
||||
const panel = root.querySelector('.cm-search');
|
||||
if (!panel) return;
|
||||
for (const [name, title] of Object.entries(buttonTooltips)) {
|
||||
const btn = panel.querySelector(`button[name="${name}"]`) as HTMLElement | null;
|
||||
if (btn && !btn.title) btn.title = title;
|
||||
}
|
||||
for (const [name, title] of Object.entries(checkboxTooltips)) {
|
||||
const input = panel.querySelector(`input[name="${name}"]`) as HTMLElement | null;
|
||||
const label = input?.parentElement;
|
||||
if (label && !label.title) label.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type BlockWidgetDef = {
|
||||
afterLine: number;
|
||||
id: string;
|
||||
@@ -153,6 +184,7 @@ export function CodeMirrorEditor({
|
||||
blockWidgets,
|
||||
enableSearch,
|
||||
searchOpen,
|
||||
onSearchOpenChange,
|
||||
}: CodeMirrorEditorProps) {
|
||||
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = React.useRef<EditorView | null>(null);
|
||||
@@ -160,6 +192,7 @@ export function CodeMirrorEditor({
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const onViewReadyRef = React.useRef(onViewReady);
|
||||
const onViewDestroyRef = React.useRef(onViewDestroy);
|
||||
const onSearchOpenChangeRef = React.useRef(onSearchOpenChange);
|
||||
const blockWidgetsRef = React.useRef(blockWidgets);
|
||||
|
||||
// Scoped map for widget containers to avoid global collisions and memory leaks
|
||||
@@ -224,6 +257,10 @@ export function CodeMirrorEditor({
|
||||
onViewDestroyRef.current = onViewDestroy;
|
||||
}, [onViewReady, onViewDestroy]);
|
||||
|
||||
React.useEffect(() => {
|
||||
onSearchOpenChangeRef.current = onSearchOpenChange;
|
||||
}, [onSearchOpenChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
blockWidgetsRef.current = blockWidgets;
|
||||
syncPortalWidgets(blockWidgets);
|
||||
@@ -256,6 +293,12 @@ export function CodeMirrorEditor({
|
||||
if (update.viewportChanged || update.geometryChanged) {
|
||||
syncPortalWidgets(blockWidgetsRef.current);
|
||||
}
|
||||
// Detect search panel open/close and sync back to React state
|
||||
const wasOpen = searchPanelOpen(update.startState);
|
||||
const isOpen = searchPanelOpen(update.state);
|
||||
if (wasOpen !== isOpen) {
|
||||
onSearchOpenChangeRef.current?.(isOpen);
|
||||
}
|
||||
if (!update.docChanged) {
|
||||
return;
|
||||
}
|
||||
@@ -329,6 +372,10 @@ export function CodeMirrorEditor({
|
||||
}
|
||||
if (searchOpen) {
|
||||
openSearchPanelCompat(view);
|
||||
// Patch tooltips after panel DOM is mounted
|
||||
requestAnimationFrame(() => {
|
||||
patchSearchTooltips(view.dom);
|
||||
});
|
||||
} else {
|
||||
closeSearchPanelCompat(view);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
|
||||
const cachedSessionCount = messages.size;
|
||||
|
||||
return (
|
||||
<Card className="fixed bottom-4 right-4 w-96 p-4 shadow-none z-50 bg-background/95 backdrop-blur bottom-safe-area">
|
||||
<Card className="fixed bottom-4 right-4 w-96 p-4 shadow-none z-50 bg-background/95 bottom-safe-area">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<RiDatabase2Line className="h-4 w-4" />
|
||||
|
||||
@@ -34,6 +34,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||
const [horizontal, setHorizontal] = React.useState<ThumbMetrics>({ length: 0, offset: 0 });
|
||||
const hideTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const frameRef = React.useRef<number | null>(null);
|
||||
const metricsFrameRef = React.useRef<number | null>(null);
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const lastUserIntentAtRef = React.useRef(0);
|
||||
const dragStartRef = React.useRef<{
|
||||
@@ -76,6 +77,14 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||
}
|
||||
}, [containerRef, minThumbSize, disableHorizontal]);
|
||||
|
||||
const scheduleMetricsUpdate = React.useCallback(() => {
|
||||
if (metricsFrameRef.current !== null) return;
|
||||
metricsFrameRef.current = requestAnimationFrame(() => {
|
||||
metricsFrameRef.current = null;
|
||||
updateMetrics();
|
||||
});
|
||||
}, [updateMetrics]);
|
||||
|
||||
const scheduleHide = React.useCallback(() => {
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current);
|
||||
@@ -141,13 +150,15 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== "undefined"
|
||||
? new ResizeObserver(() => updateMetrics())
|
||||
? new ResizeObserver(() => {
|
||||
scheduleMetricsUpdate();
|
||||
})
|
||||
: null;
|
||||
resizeObserver?.observe(container);
|
||||
|
||||
const mutationObserver =
|
||||
observeMutations && typeof MutationObserver !== "undefined"
|
||||
? new MutationObserver(() => updateMetrics())
|
||||
? new MutationObserver(() => scheduleMetricsUpdate())
|
||||
: null;
|
||||
mutationObserver?.observe(container, { childList: true, subtree: true, characterData: true });
|
||||
|
||||
@@ -163,8 +174,9 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||
mutationObserver?.disconnect();
|
||||
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
if (metricsFrameRef.current) cancelAnimationFrame(metricsFrameRef.current);
|
||||
};
|
||||
}, [containerRef, handleScroll, markUserIntent, observeMutations, updateMetrics, userIntentOnly]);
|
||||
}, [containerRef, handleScroll, markUserIntent, observeMutations, scheduleMetricsUpdate, updateMetrics, userIntentOnly]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!suppressVisibility) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OverlayScrollbar } from "./OverlayScrollbar";
|
||||
import { ScrollShadow } from "./ScrollShadow";
|
||||
|
||||
type ScrollableOverlayProps = React.HTMLAttributes<HTMLElement> & {
|
||||
minThumbSize?: number;
|
||||
@@ -14,6 +15,8 @@ type ScrollableOverlayProps = React.HTMLAttributes<HTMLElement> & {
|
||||
keyboardAvoid?: boolean;
|
||||
/** Prevent scroll from propagating to parent when at boundaries */
|
||||
preventOverscroll?: boolean;
|
||||
useScrollShadow?: boolean;
|
||||
scrollShadowSize?: number;
|
||||
};
|
||||
|
||||
export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlayProps>(
|
||||
@@ -31,6 +34,8 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
||||
fillContainer = true,
|
||||
keyboardAvoid = false,
|
||||
preventOverscroll = false,
|
||||
useScrollShadow = false,
|
||||
scrollShadowSize,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const containerRef = React.useRef<HTMLElement | null>(null);
|
||||
@@ -46,20 +51,39 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
||||
)}
|
||||
data-keyboard-avoid={keyboardAvoid ? "true" : undefined}
|
||||
>
|
||||
<Component
|
||||
ref={containerRef as React.Ref<HTMLElement>}
|
||||
className={cn(
|
||||
"overlay-scrollbar-target overlay-scrollbar-container",
|
||||
preventOverscroll && "overscroll-none",
|
||||
fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto",
|
||||
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
{useScrollShadow ? (
|
||||
<ScrollShadow
|
||||
ref={containerRef as React.Ref<HTMLDivElement>}
|
||||
size={scrollShadowSize}
|
||||
className={cn(
|
||||
"overlay-scrollbar-target overlay-scrollbar-container",
|
||||
preventOverscroll && "overscroll-none",
|
||||
fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto",
|
||||
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
|
||||
className
|
||||
)}
|
||||
style={style as React.CSSProperties}
|
||||
observeMutations={observeMutations}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</ScrollShadow>
|
||||
) : (
|
||||
<Component
|
||||
ref={containerRef as React.Ref<HTMLElement>}
|
||||
className={cn(
|
||||
"overlay-scrollbar-target overlay-scrollbar-container",
|
||||
preventOverscroll && "overscroll-none",
|
||||
fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto",
|
||||
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
)}
|
||||
<OverlayScrollbar
|
||||
containerRef={containerRef}
|
||||
minThumbSize={minThumbSize}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Button, type buttonVariants } from "./button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
function ButtonLarge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size="sm"
|
||||
className={cn("h-7 px-2 py-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { ButtonLarge }
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Button, type buttonVariants } from "./button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
function ButtonSmall({
|
||||
className,
|
||||
variant,
|
||||
size = "sm",
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(size === "sm" && "h-7 px-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { ButtonSmall }
|
||||
@@ -6,16 +6,16 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg typography-ui-label font-medium transition-[background-color,border-color,color,box-shadow,opacity] duration-150 ease-in-out disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg typography-ui-label font-medium lowercase transition-[background-color,border-color,color,box-shadow,opacity] duration-150 ease-in-out disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-none hover:bg-primary/90",
|
||||
"border bg-[var(--primary-base)]/10 text-[var(--primary-base)] shadow-none hover:bg-[var(--primary-base)]/15 hover:text-[var(--primary-base)]",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-none hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
"bg-[var(--status-error)]/8 text-[var(--status-error)] shadow-none hover:bg-[var(--status-error)]/12 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-none hover:bg-interactive-hover hover:text-foreground",
|
||||
"border bg-[var(--surface-elevated)] shadow-none hover:bg-interactive-hover hover:text-foreground",
|
||||
secondary:
|
||||
"bg-interactive-hover text-foreground shadow-none hover:bg-interactive-active",
|
||||
ghost:
|
||||
@@ -25,7 +25,7 @@ const buttonVariants = cva(
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-lg gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
xs: "h-6 rounded-md gap-1 px-1.5 typography-micro has-[>svg]:px-1.5",
|
||||
xs: "h-6 rounded-lg gap-1 px-1.5 typography-micro has-[>svg]:px-1.5",
|
||||
lg: "h-10 rounded-lg px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
|
||||
@@ -43,6 +43,8 @@ function DropdownMenuContent({
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevated)',
|
||||
color: 'var(--surface-elevated-foreground)',
|
||||
backdropFilter: 'blur(28px)',
|
||||
WebkitBackdropFilter: 'blur(28px)',
|
||||
...style,
|
||||
}}
|
||||
className={cn(
|
||||
@@ -236,6 +238,8 @@ function DropdownMenuSubContent({
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevated)',
|
||||
color: 'var(--surface-elevated-foreground)',
|
||||
backdropFilter: 'blur(28px)',
|
||||
WebkitBackdropFilter: 'blur(28px)',
|
||||
}}
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-visible rounded-xl border-2 border-border/60 p-1 shadow-none",
|
||||
|
||||
@@ -67,6 +67,8 @@ function SelectContent({
|
||||
style={{
|
||||
backgroundColor: 'var(--surface-elevated)',
|
||||
color: 'var(--surface-elevated-foreground)',
|
||||
backdropFilter: 'blur(28px)',
|
||||
WebkitBackdropFilter: 'blur(28px)',
|
||||
}}
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden rounded-xl border-2 border-border/60 shadow-none transform-gpu will-change-transform",
|
||||
|
||||
@@ -14,13 +14,15 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
closeButton={false}
|
||||
toastOptions={{
|
||||
style: {
|
||||
borderRadius: "var(--radius-md)",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
backdropFilter: 'blur(28px)',
|
||||
WebkitBackdropFilter: 'blur(28px)',
|
||||
},
|
||||
classNames: {
|
||||
toast: "rounded-[var(--radius-md)]",
|
||||
actionButton: "rounded-[var(--radius-sm)]",
|
||||
cancelButton: "rounded-[var(--radius-sm)]",
|
||||
closeButton: "rounded-[var(--radius-sm)]",
|
||||
toast: "rounded-[var(--radius-lg)]",
|
||||
actionButton: "rounded-[var(--radius-md)]",
|
||||
cancelButton: "rounded-[var(--radius-md)]",
|
||||
closeButton: "rounded-[var(--radius-md)]",
|
||||
},
|
||||
}}
|
||||
style={
|
||||
|
||||
@@ -41,6 +41,7 @@ type SortableTabsStripProps = {
|
||||
activePillButtonClassName?: string;
|
||||
inactiveTabsIconOnly?: boolean;
|
||||
animateActivePill?: boolean;
|
||||
activePillLowercase?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -92,6 +93,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
activePillButtonClassName,
|
||||
inactiveTabsIconOnly = false,
|
||||
animateActivePill,
|
||||
activePillLowercase = true,
|
||||
className,
|
||||
}) => {
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
@@ -107,9 +109,10 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
const shouldAnimateActivePill = animateActivePill ?? isAnimatedVariant;
|
||||
const reorderEnabled = typeof onReorder === 'function';
|
||||
const Wrapper = reorderEnabled ? SortableTabWrapper : StaticTabWrapper;
|
||||
const tabRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const tabRefs = React.useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [pillRect, setPillRect] = React.useState<{ left: number; top: number; width: number; height: number } | null>(null);
|
||||
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
@@ -127,7 +130,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
&& Math.abs(a.height - b.height) < 0.5;
|
||||
}, []);
|
||||
|
||||
const setTabRef = React.useCallback((id: string, element: HTMLButtonElement | null) => {
|
||||
const setTabRef = React.useCallback((id: string, element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
tabRefs.current.set(id, element);
|
||||
return;
|
||||
@@ -148,14 +151,24 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const tabRect = activeTab.getBoundingClientRect();
|
||||
// Walk offsetParent chain to compute position relative to the scroll container.
|
||||
// Unlike getBoundingClientRect, offsetLeft/offsetTop are unaffected by CSS
|
||||
// transforms (e.g. dropdown entry scale animation), preventing pill mis-positioning
|
||||
// on first render.
|
||||
let left = 0;
|
||||
let top = 0;
|
||||
let el: HTMLElement | null = activeTab;
|
||||
while (el && el !== container) {
|
||||
left += el.offsetLeft;
|
||||
top += el.offsetTop;
|
||||
el = el.offsetParent as HTMLElement | null;
|
||||
}
|
||||
|
||||
const nextRect = {
|
||||
left: tabRect.left - containerRect.left + container.scrollLeft,
|
||||
top: tabRect.top - containerRect.top + container.scrollTop,
|
||||
width: tabRect.width,
|
||||
height: tabRect.height,
|
||||
left,
|
||||
top,
|
||||
width: activeTab.offsetWidth,
|
||||
height: activeTab.offsetHeight,
|
||||
};
|
||||
|
||||
setPillRect((prev) => (isSamePillRect(prev, nextRect) ? prev : nextRect));
|
||||
@@ -233,6 +246,8 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
updateActivePillRect();
|
||||
});
|
||||
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isScrollable || !activeId) {
|
||||
return;
|
||||
@@ -276,11 +291,25 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
|
||||
const list = (
|
||||
<div className={cn('relative flex h-full min-w-0 flex-1', className)}>
|
||||
{isScrollable && !usesActivePillIndicator && overflow.left ? (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-background to-transparent" />
|
||||
{isScrollable && overflow.left ? (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 left-0 z-20 bg-gradient-to-r to-transparent',
|
||||
usesActivePillIndicator
|
||||
? 'w-8 from-[var(--surface-background)]'
|
||||
: 'w-6 from-background'
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{isScrollable && !usesActivePillIndicator && overflow.right ? (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-background to-transparent" />
|
||||
{isScrollable && overflow.right ? (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-y-0 right-0 z-20 bg-gradient-to-l to-transparent',
|
||||
usesActivePillIndicator
|
||||
? 'w-8 from-[var(--surface-background)]'
|
||||
: 'w-6 from-background'
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
@@ -328,6 +357,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
return (
|
||||
<Wrapper key={item.id} id={item.id} className={wrapperClassName}>
|
||||
<div
|
||||
ref={(element) => setTabRef(item.id, element)}
|
||||
className={cn(
|
||||
'group flex h-full items-center',
|
||||
(isScrollable || useIntrinsicPillSizing)
|
||||
@@ -343,7 +373,6 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
)}
|
||||
>
|
||||
<button
|
||||
ref={(element) => setTabRef(item.id, element)}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
@@ -353,6 +382,8 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
usesActivePillIndicator
|
||||
? 'animated-tabs__button pill-tabs__button relative z-10 flex flex-1 items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 !min-h-0'
|
||||
: 'flex h-full min-w-0 items-center typography-micro',
|
||||
usesActivePillIndicator && closable && '!flex-none',
|
||||
usesActivePillIndicator && activePillLowercase ? 'lowercase' : null,
|
||||
usesActivePillIndicator && (showInactiveIconOnly ? 'gap-0' : 'gap-1.5'),
|
||||
usesActivePillIndicator
|
||||
? useIntrinsicPillSizing
|
||||
@@ -396,15 +427,25 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
{closable ? (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClose?.(item.id);
|
||||
}}
|
||||
className={cn(
|
||||
'mr-1 inline-flex aspect-square h-[65%] min-h-4 max-h-5 !min-h-0 !min-w-0 items-center justify-center rounded-sm transition-opacity',
|
||||
isActive
|
||||
? 'text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground'
|
||||
: 'text-muted-foreground opacity-0 hover:bg-interactive-hover/80 hover:text-foreground group-hover:opacity-100'
|
||||
'relative z-20 inline-flex !min-h-0 !min-w-0 items-center justify-center transition-opacity',
|
||||
usesActivePillIndicator
|
||||
? '-ml-2.5 mr-1 h-[88%] w-5 self-center !aspect-auto rounded-md'
|
||||
: 'aspect-square h-[65%] min-h-4 max-h-5 rounded-sm mr-1',
|
||||
usesActivePillIndicator
|
||||
? (isActive
|
||||
? 'text-muted-foreground hover:bg-transparent hover:text-foreground'
|
||||
: 'text-muted-foreground opacity-0 hover:bg-transparent hover:text-foreground group-hover:opacity-100')
|
||||
: (isActive
|
||||
? 'text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground'
|
||||
: 'text-muted-foreground opacity-0 hover:bg-interactive-hover/80 hover:text-foreground group-hover:opacity-100')
|
||||
)}
|
||||
aria-label={item.closeLabel ?? `Close ${item.label} tab`}
|
||||
title={item.closeLabel ?? `Close ${item.label} tab`}
|
||||
|
||||
@@ -71,10 +71,9 @@ const variants = [
|
||||
className={cn(
|
||||
"inline-block whitespace-pre align-baseline"
|
||||
)}
|
||||
initial={{ opacity: 0, filter: "blur(3px)" }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
filter: "blur(0px)",
|
||||
}}
|
||||
transition={{
|
||||
ease: "easeOut",
|
||||
|
||||
@@ -39,6 +39,7 @@ function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
@@ -50,10 +51,15 @@ function TooltipContent({
|
||||
"bg-muted text-muted-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-xl px-3 py-1.5 typography-meta text-balance overflow-hidden transform-gpu will-change-transform",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backdropFilter: 'blur(28px)',
|
||||
WebkitBackdropFilter: 'blur(28px)',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-muted fill-muted z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-xl" />
|
||||
<TooltipPrimitive.Arrow className="fill-muted z-50 size-2" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
|
||||
@@ -461,7 +461,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const [showMobilePageContent, setShowMobilePageContent] = React.useState(false);
|
||||
const [wrapLines, setWrapLines] = React.useState(isMobile);
|
||||
const [wrapLines, setWrapLines] = React.useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||
const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('edit');
|
||||
@@ -2114,6 +2114,202 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
);
|
||||
}, [currentTheme.metadata.variant, pierreTheme, wrapLines]);
|
||||
|
||||
const renderFloatingFileControls = ({ exitFullscreenOnly = false }: { exitFullscreenOnly?: boolean } = {}) => {
|
||||
if (!selectedFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex items-center gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]/95 p-1 shadow-sm backdrop-blur-sm">
|
||||
{canEdit && textViewMode === 'edit' && (
|
||||
isSaving ? (
|
||||
<span className="flex items-center gap-1 px-1 text-muted-foreground typography-meta">
|
||||
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
|
||||
Saving...
|
||||
</span>
|
||||
) : autoSaveStatus === 'saved' && !isDirty ? (
|
||||
<span className="flex items-center gap-1 px-1 text-[color:var(--status-success)] typography-meta">
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
</span>
|
||||
) : isDirty ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void saveDraft()}
|
||||
className="h-6 px-1 gap-1 text-muted-foreground opacity-80 hover:opacity-100"
|
||||
title={`Save now (${getModifierLabel()}+S) - auto-saves after 1.5s`}
|
||||
aria-label={`Save (${getModifierLabel()}+S)`}
|
||||
>
|
||||
<RiSave3Line className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground opacity-80 hover:opacity-100"
|
||||
title="Open in desktop app"
|
||||
aria-label="Open in desktop app"
|
||||
>
|
||||
<RiFileTransferLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 max-h-[70vh] overflow-y-auto">
|
||||
{openInApps.map((app) => (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => void handleOpenInApp(app)}
|
||||
>
|
||||
<OpenInAppListIcon label={app.label} iconDataUrl={app.iconDataUrl} />
|
||||
<span className="typography-ui-label text-foreground">{app.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{openInCacheStale ? (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => void loadOpenInApps(true)}
|
||||
>
|
||||
<RiRefreshLine className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground">Refresh Apps</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{!isSelectedImage && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setWrapLines(!wrapLines)}
|
||||
className={cn(
|
||||
'h-6 w-6 p-0 transition-opacity',
|
||||
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-65 hover:opacity-100'
|
||||
)}
|
||||
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
|
||||
>
|
||||
<RiTextWrap className="size-4" />
|
||||
</Button>
|
||||
{textViewMode === 'edit' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
||||
className={cn(
|
||||
'h-6 w-6 p-0 transition-opacity',
|
||||
isSearchOpen ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-65 hover:opacity-100'
|
||||
)}
|
||||
title="Find in file"
|
||||
>
|
||||
<RiSearchLine className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isMarkdown && (
|
||||
<PreviewToggleButton
|
||||
currentMode={getMdViewMode()}
|
||||
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canCopy && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const result = await copyTextToClipboard(fileContent);
|
||||
if (result.ok) {
|
||||
setCopiedContent(true);
|
||||
if (copiedContentTimeoutRef.current !== null) {
|
||||
window.clearTimeout(copiedContentTimeoutRef.current);
|
||||
}
|
||||
copiedContentTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedContent(false);
|
||||
}, 1200);
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
title="Copy file contents"
|
||||
aria-label="Copy file contents"
|
||||
>
|
||||
{copiedContent ? (
|
||||
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
||||
) : (
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canCopyPath && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const result = await copyTextToClipboard(displaySelectedPath);
|
||||
if (result.ok) {
|
||||
setCopiedPath(true);
|
||||
if (copiedPathTimeoutRef.current !== null) {
|
||||
window.clearTimeout(copiedPathTimeoutRef.current);
|
||||
}
|
||||
copiedPathTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedPath(false);
|
||||
}, 1200);
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
title={`Copy file path (${displaySelectedPath})`}
|
||||
aria-label={`Copy file path (${displaySelectedPath})`}
|
||||
>
|
||||
{copiedPath ? (
|
||||
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
||||
) : (
|
||||
<RiFileCopy2Line className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{exitFullscreenOnly ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="h-6 w-6 p-0"
|
||||
title="Exit fullscreen"
|
||||
aria-label="Exit fullscreen"
|
||||
>
|
||||
<RiFullscreenExitLine className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (!isMobile && mode === 'full' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="h-6 w-6 p-0"
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<RiFullscreenExitLine className="h-4 w-4" />
|
||||
) : (
|
||||
<RiFullscreenLine className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const fileViewer = (
|
||||
<div
|
||||
className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden"
|
||||
@@ -2144,7 +2340,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="flex flex-col border-b border-border/40 flex-shrink-0">
|
||||
<div className={cn('flex flex-col flex-shrink-0', showEditorTabsRow && 'border-b border-border/40')}>
|
||||
{/* Row 1: Tabs */}
|
||||
{showEditorTabsRow ? (
|
||||
<div className="flex min-w-0 items-center px-3 py-1.5">
|
||||
@@ -2288,199 +2484,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Row 2: Actions (right-aligned) */}
|
||||
{selectedFile && (
|
||||
<div className={cn('flex items-center justify-end gap-1 px-3 pb-1.5', !showEditorTabsRow && 'pt-1.5')}>
|
||||
{canEdit && textViewMode === 'edit' && (
|
||||
isSaving ? (
|
||||
<span className="flex items-center gap-1 text-muted-foreground typography-meta">
|
||||
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
|
||||
Saving…
|
||||
</span>
|
||||
) : autoSaveStatus === 'saved' && !isDirty ? (
|
||||
<span className="flex items-center gap-1 text-[color:var(--status-success)] typography-meta">
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
</span>
|
||||
) : isDirty ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void saveDraft()}
|
||||
className="h-5 px-1 gap-1 text-muted-foreground opacity-70 hover:opacity-100"
|
||||
title={`Save now (${getModifierLabel()}+S) — auto-saves after 1.5s`}
|
||||
aria-label={`Save (${getModifierLabel()}+S)`}
|
||||
>
|
||||
<RiSave3Line className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 text-muted-foreground opacity-70 hover:opacity-100"
|
||||
title="Open in desktop app"
|
||||
aria-label="Open in desktop app"
|
||||
>
|
||||
<RiFileTransferLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 max-h-[70vh] overflow-y-auto">
|
||||
{openInApps.map((app) => (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => void handleOpenInApp(app)}
|
||||
>
|
||||
<OpenInAppListIcon label={app.label} iconDataUrl={app.iconDataUrl} />
|
||||
<span className="typography-ui-label text-foreground">{app.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{openInCacheStale ? (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => void loadOpenInApps(true)}
|
||||
>
|
||||
<RiRefreshLine className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground">Refresh Apps</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{canEdit && !isSelectedImage && (
|
||||
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
||||
)}
|
||||
|
||||
{!isSelectedImage && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setWrapLines(!wrapLines)}
|
||||
className={cn(
|
||||
'h-5 w-5 p-0 transition-opacity',
|
||||
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
|
||||
)}
|
||||
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
|
||||
>
|
||||
<RiTextWrap className="size-4" />
|
||||
</Button>
|
||||
{textViewMode === 'edit' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
||||
className={cn(
|
||||
'h-5 w-5 p-0 transition-opacity',
|
||||
isSearchOpen ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
|
||||
)}
|
||||
title="Find in file"
|
||||
>
|
||||
<RiSearchLine className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canCopy || canCopyPath || isMarkdown) && (canEdit || !isSelectedImage) && (
|
||||
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
||||
)}
|
||||
|
||||
{isMarkdown && (
|
||||
<PreviewToggleButton
|
||||
currentMode={getMdViewMode()}
|
||||
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canCopy && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const result = await copyTextToClipboard(fileContent);
|
||||
if (result.ok) {
|
||||
setCopiedContent(true);
|
||||
if (copiedContentTimeoutRef.current !== null) {
|
||||
window.clearTimeout(copiedContentTimeoutRef.current);
|
||||
}
|
||||
copiedContentTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedContent(false);
|
||||
}, 1200);
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}}
|
||||
className="h-5 w-5 p-0"
|
||||
title="Copy file contents"
|
||||
aria-label="Copy file contents"
|
||||
>
|
||||
{copiedContent ? (
|
||||
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
||||
) : (
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canCopyPath && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const result = await copyTextToClipboard(displaySelectedPath);
|
||||
if (result.ok) {
|
||||
setCopiedPath(true);
|
||||
if (copiedPathTimeoutRef.current !== null) {
|
||||
window.clearTimeout(copiedPathTimeoutRef.current);
|
||||
}
|
||||
copiedPathTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedPath(false);
|
||||
}, 1200);
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}}
|
||||
className="h-5 w-5 p-0"
|
||||
title={`Copy file path (${displaySelectedPath})`}
|
||||
aria-label={`Copy file path (${displaySelectedPath})`}
|
||||
>
|
||||
{copiedPath ? (
|
||||
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
||||
) : (
|
||||
<RiFileCopy2Line className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isMobile && mode === 'full' && (
|
||||
<>
|
||||
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="h-5 w-5 p-0"
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<RiFullscreenExitLine className="h-4 w-4" />
|
||||
) : (
|
||||
<RiFullscreenLine className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 min-w-0 relative">
|
||||
{selectedFile && !isSearchOpen && (
|
||||
<div className="absolute right-3 top-3 z-30">
|
||||
{renderFloatingFileControls()}
|
||||
</div>
|
||||
)}
|
||||
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
|
||||
{!selectedFile ? (
|
||||
<div className="p-3 typography-ui text-muted-foreground">Pick a file from the tree.</div>
|
||||
@@ -2747,184 +2758,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
// Fullscreen file viewer overlay
|
||||
const fullscreenViewer = mode === 'full' && isFullscreen && selectedFile && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col bg-background">
|
||||
{/* Fullscreen header */}
|
||||
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-4 py-2 flex-shrink-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="typography-ui-label font-medium truncate">
|
||||
{selectedFile.name}
|
||||
</div>
|
||||
<div className="typography-meta text-muted-foreground truncate" title={displaySelectedPath}>
|
||||
{displaySelectedPath}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{canEdit && textViewMode === 'edit' && (
|
||||
isSaving ? (
|
||||
<span className="flex items-center gap-1 text-muted-foreground typography-meta">
|
||||
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
|
||||
Saving…
|
||||
</span>
|
||||
) : autoSaveStatus === 'saved' && !isDirty ? (
|
||||
<span className="flex items-center gap-1 text-[color:var(--status-success)] typography-meta">
|
||||
<RiCheckLine className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
</span>
|
||||
) : isDirty ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void saveDraft()}
|
||||
className="h-6 px-1 gap-1 text-muted-foreground opacity-70 hover:opacity-100"
|
||||
title={`Save now (${getModifierLabel()}+S) — auto-saves after 1.5s`}
|
||||
aria-label={`Save (${getModifierLabel()}+S)`}
|
||||
>
|
||||
<RiSave3Line className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground opacity-70 hover:opacity-100"
|
||||
title="Open in desktop app"
|
||||
aria-label="Open in desktop app"
|
||||
>
|
||||
<RiFileTransferLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 max-h-[70vh] overflow-y-auto">
|
||||
{openInApps.map((app) => (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => void handleOpenInApp(app)}
|
||||
>
|
||||
<OpenInAppListIcon label={app.label} iconDataUrl={app.iconDataUrl} />
|
||||
<span className="typography-ui-label text-foreground">{app.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{openInCacheStale ? (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => void loadOpenInApps(true)}
|
||||
>
|
||||
<RiRefreshLine className="h-4 w-4" />
|
||||
<span className="typography-ui-label text-foreground">Refresh Apps</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{canEdit && !isSelectedImage && (
|
||||
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
||||
)}
|
||||
|
||||
{!isSelectedImage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setWrapLines(!wrapLines)}
|
||||
className={cn(
|
||||
'h-6 w-6 p-0 transition-opacity',
|
||||
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
|
||||
)}
|
||||
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
|
||||
>
|
||||
<RiTextWrap className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(canCopy || canCopyPath || isMarkdown) && (canEdit || !isSelectedImage) && (
|
||||
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
||||
)}
|
||||
|
||||
{isMarkdown && (
|
||||
<PreviewToggleButton
|
||||
currentMode={getMdViewMode()}
|
||||
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canCopy && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const result = await copyTextToClipboard(fileContent);
|
||||
if (result.ok) {
|
||||
setCopiedContent(true);
|
||||
if (copiedContentTimeoutRef.current !== null) {
|
||||
window.clearTimeout(copiedContentTimeoutRef.current);
|
||||
}
|
||||
copiedContentTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedContent(false);
|
||||
}, 1200);
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
title="Copy file contents"
|
||||
aria-label="Copy file contents"
|
||||
>
|
||||
{copiedContent ? (
|
||||
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
||||
) : (
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canCopyPath && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const result = await copyTextToClipboard(displaySelectedPath);
|
||||
if (result.ok) {
|
||||
setCopiedPath(true);
|
||||
if (copiedPathTimeoutRef.current !== null) {
|
||||
window.clearTimeout(copiedPathTimeoutRef.current);
|
||||
}
|
||||
copiedPathTimeoutRef.current = window.setTimeout(() => {
|
||||
setCopiedPath(false);
|
||||
}, 1200);
|
||||
} else {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
title={`Copy file path (${displaySelectedPath})`}
|
||||
aria-label={`Copy file path (${displaySelectedPath})`}
|
||||
>
|
||||
{copiedPath ? (
|
||||
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
||||
) : (
|
||||
<RiFileCopy2Line className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="h-6 w-6 p-0"
|
||||
title="Exit fullscreen"
|
||||
aria-label="Exit fullscreen"
|
||||
>
|
||||
<RiFullscreenExitLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen content */}
|
||||
<div className="flex-1 min-h-0 min-w-0 relative">
|
||||
<div className="absolute right-4 top-4 z-30">
|
||||
{renderFloatingFileControls({ exitFullscreenOnly: true })}
|
||||
</div>
|
||||
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
|
||||
{fileLoading ? (
|
||||
suppressFileLoadingIndicator
|
||||
|
||||
@@ -433,6 +433,7 @@ export const GitView: React.FC = () => {
|
||||
return isActionTab(stored) ? stored : 'commit';
|
||||
});
|
||||
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
|
||||
const [removingRemoteName, setRemovingRemoteName] = React.useState<string | null>(null);
|
||||
const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
|
||||
const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]);
|
||||
const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false);
|
||||
@@ -585,14 +586,23 @@ export const GitView: React.FC = () => {
|
||||
git.getRemoteUrl(currentDirectory).then(setRemoteUrl).catch(() => setRemoteUrl(null));
|
||||
}, [currentDirectory, git]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const refreshRemotes = React.useCallback(async () => {
|
||||
if (!currentDirectory || !git?.getRemotes) {
|
||||
setRemotes([]);
|
||||
return;
|
||||
}
|
||||
git.getRemotes(currentDirectory).then(setRemotes).catch(() => setRemotes([]));
|
||||
try {
|
||||
const remoteList = await git.getRemotes(currentDirectory);
|
||||
setRemotes(remoteList);
|
||||
} catch {
|
||||
setRemotes([]);
|
||||
}
|
||||
}, [currentDirectory, git]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void refreshRemotes();
|
||||
}, [refreshRemotes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!settingsGitmojiEnabled) {
|
||||
setGitmojiEmojis([]);
|
||||
@@ -825,6 +835,35 @@ export const GitView: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRemote = React.useCallback(async (remote: GitRemote) => {
|
||||
if (!currentDirectory) return;
|
||||
|
||||
const remoteName = remote.name.trim();
|
||||
if (!remoteName) {
|
||||
toast.error('Remote name is required');
|
||||
return;
|
||||
}
|
||||
if (remoteName === 'origin') {
|
||||
toast.error('Cannot remove origin remote');
|
||||
return;
|
||||
}
|
||||
|
||||
setRemovingRemoteName(remoteName);
|
||||
try {
|
||||
await git.removeRemote(currentDirectory, { remote: remoteName });
|
||||
toast.success(`Removed ${remoteName} remote`);
|
||||
await Promise.all([
|
||||
refreshStatusAndBranches(false),
|
||||
refreshRemotes(),
|
||||
]);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : `Failed to remove ${remoteName}`;
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setRemovingRemoteName(null);
|
||||
}
|
||||
}, [currentDirectory, git, refreshRemotes, refreshStatusAndBranches]);
|
||||
|
||||
const handleCommit = async (options: { pushAfter?: boolean } = {}) => {
|
||||
if (!currentDirectory) return;
|
||||
if (!commitMessage.trim()) {
|
||||
@@ -1254,11 +1293,20 @@ export const GitView: React.FC = () => {
|
||||
}
|
||||
|
||||
const insertBeforeIndex = log.all.findIndex((entry) => !branchHashes.has(entry.hash));
|
||||
if (insertBeforeIndex <= 0) {
|
||||
if (insertBeforeIndex === 0) {
|
||||
setHistoryBranchDivider(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (insertBeforeIndex === -1) {
|
||||
setHistoryBranchDivider({
|
||||
insertBeforeIndex: log.all.length,
|
||||
branchName: currentBranch,
|
||||
direction: 'up',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setHistoryBranchDivider({
|
||||
insertBeforeIndex,
|
||||
branchName: currentBranch,
|
||||
@@ -1806,6 +1854,8 @@ export const GitView: React.FC = () => {
|
||||
onFetch={(remote) => handleSyncAction('fetch', remote)}
|
||||
onPull={(remote) => handleSyncAction('pull', remote)}
|
||||
onPush={() => handleSyncAction('push')}
|
||||
onRemoveRemote={handleRemoveRemote}
|
||||
removingRemoteName={removingRemoteName}
|
||||
onCheckoutBranch={handleCheckoutBranch}
|
||||
onCreateBranch={handleCreateBranch}
|
||||
onRenameBranch={handleRenameBranch}
|
||||
@@ -1861,7 +1911,6 @@ export const GitView: React.FC = () => {
|
||||
{(changeEntries?.length ?? 0) > 0 ? (
|
||||
<>
|
||||
<ChangesSection
|
||||
variant="plain"
|
||||
maxListHeightClassName="max-h-[40vh]"
|
||||
changeEntries={changeEntries}
|
||||
onVisiblePathsChange={setVisibleChangePaths}
|
||||
@@ -1887,7 +1936,6 @@ export const GitView: React.FC = () => {
|
||||
/>
|
||||
|
||||
<CommitSection
|
||||
variant="plain"
|
||||
selectedCount={selectedCount}
|
||||
commitMessage={commitMessage}
|
||||
onCommitMessageChange={setCommitMessage}
|
||||
@@ -1945,7 +1993,6 @@ export const GitView: React.FC = () => {
|
||||
<div className="space-y-4">
|
||||
{integrateCommitsProps ? (
|
||||
<IntegrateCommitsSection
|
||||
variant="plain"
|
||||
repoRoot={integrateCommitsProps.repoRoot}
|
||||
sourceBranch={integrateCommitsProps.sourceBranch}
|
||||
worktreeMetadata={integrateCommitsProps.worktreeMetadata}
|
||||
@@ -1974,7 +2021,6 @@ export const GitView: React.FC = () => {
|
||||
<div className="space-y-4">
|
||||
{pullRequestProps ? (
|
||||
<PullRequestSection
|
||||
variant="plain"
|
||||
directory={pullRequestProps.directory}
|
||||
branch={pullRequestProps.branch}
|
||||
baseBranch={baseBranch}
|
||||
|
||||
@@ -240,6 +240,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const isMobile = forceMobile ?? deviceInfo.isMobile;
|
||||
|
||||
const settingsPageRaw = useUIStore((state) => state.settingsPage);
|
||||
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const settingsSlug = resolveSettingsSlug(settingsPageRaw);
|
||||
|
||||
@@ -322,25 +323,27 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
|
||||
// Load stores when project changes or when a page becomes active.
|
||||
React.useEffect(() => {
|
||||
if (!isSettingsDialogOpen && !runtimeCtx.isVSCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingsSlug === 'agents') {
|
||||
setTimeout(() => void useAgentsStore.getState().loadAgents(), 0);
|
||||
void useAgentsStore.getState().loadAgents();
|
||||
return;
|
||||
}
|
||||
if (settingsSlug === 'commands') {
|
||||
setTimeout(() => void useCommandsStore.getState().loadCommands(), 0);
|
||||
void useCommandsStore.getState().loadCommands();
|
||||
return;
|
||||
}
|
||||
if (settingsSlug === 'mcp') {
|
||||
setTimeout(() => void useMcpConfigStore.getState().loadMcpConfigs(), 0);
|
||||
void useMcpConfigStore.getState().loadMcpConfigs();
|
||||
return;
|
||||
}
|
||||
if (settingsSlug === 'skills.installed' || settingsSlug === 'skills.catalog') {
|
||||
setTimeout(() => {
|
||||
void useSkillsStore.getState().loadSkills();
|
||||
void useSkillsCatalogStore.getState().loadCatalog();
|
||||
}, 0);
|
||||
void useSkillsStore.getState().loadSkills();
|
||||
void useSkillsCatalogStore.getState().loadCatalog();
|
||||
}
|
||||
}, [activeProjectId, settingsSlug]);
|
||||
}, [activeProjectId, isSettingsDialogOpen, runtimeCtx.isVSCode, settingsSlug]);
|
||||
|
||||
const openPage = React.useCallback((slug: SettingsPageSlug) => {
|
||||
setSettingsPage(slug);
|
||||
@@ -579,7 +582,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const renderMobileStage = () => {
|
||||
if (mobileStage === 'nav') {
|
||||
return (
|
||||
<div className={cn('flex-1 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
|
||||
<div className={cn('flex-1 min-h-0 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<ErrorBoundary>{renderSettingsNav(false)}</ErrorBoundary>
|
||||
</div>
|
||||
@@ -596,13 +599,13 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
// No sidebar available; fall back to direct content.
|
||||
const fallback = renderPageContent(settingsSlug);
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden bg-background" data-keyboard-avoid="true">
|
||||
<div className="flex-1 min-h-0 overflow-hidden bg-background" data-keyboard-avoid="true">
|
||||
<ErrorBoundary>{fallback}</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex-1 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
|
||||
<div className={cn('flex-1 min-h-0 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
|
||||
<ErrorBoundary>
|
||||
{renderPageSidebar(settingsSlug, { onItemSelect: () => setMobileStage('page-content') })}
|
||||
</ErrorBoundary>
|
||||
@@ -614,7 +617,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
const content = renderPageContent(settingsSlug);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden bg-background" data-keyboard-avoid="true">
|
||||
<div className="flex-1 min-h-0 overflow-hidden bg-background" data-keyboard-avoid="true">
|
||||
<ErrorBoundary>{content}</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
@@ -646,7 +649,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} data-settings-view="true" className={cn('relative flex h-full flex-col overflow-hidden bg-background')}>
|
||||
<div ref={containerRef} data-settings-view="true" className={cn('relative flex h-full min-h-0 flex-col overflow-hidden bg-background')}>
|
||||
{isMobile ? (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -724,7 +727,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{isMobile ? (
|
||||
renderMobileStage()
|
||||
) : (
|
||||
@@ -733,7 +736,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
className={cn(
|
||||
'relative flex h-full min-h-0 flex-col overflow-hidden border-r',
|
||||
isDesktopApp
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
? 'bg-[color:var(--sidebar-overlay-strong)] supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
||||
: runtimeCtx.isVSCode
|
||||
? 'bg-background'
|
||||
: 'bg-sidebar',
|
||||
|
||||
@@ -14,7 +14,6 @@ interface SettingsWindowProps {
|
||||
*/
|
||||
export const SettingsWindow: React.FC<SettingsWindowProps> = ({ open, onOpenChange }) => {
|
||||
const descriptionId = React.useId();
|
||||
const skipNextOverlayClickRef = React.useRef(false);
|
||||
|
||||
const hasOpenFloatingMenu = React.useCallback(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
@@ -31,15 +30,8 @@ export const SettingsWindow: React.FC<SettingsWindowProps> = ({ open, onOpenChan
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-md"
|
||||
onPointerDown={() => {
|
||||
skipNextOverlayClickRef.current = hasOpenFloatingMenu();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (skipNextOverlayClickRef.current) {
|
||||
skipNextOverlayClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (hasOpenFloatingMenu()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-10 w-10 flex-shrink-0" aria-label="Worktree actions">
|
||||
<Button variant="outline" size="icon" className="flex-shrink-0" aria-label="Worktree actions">
|
||||
<RiMore2Line className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -198,13 +198,13 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
{operationCompleted ? (
|
||||
mode === 'dialog' ? (
|
||||
<DialogFooter>
|
||||
<Button variant="default" size="sm" className="h-7 px-2 py-0" onClick={handleClose}>
|
||||
<Button variant="default" size="sm" onClick={handleClose}>
|
||||
{hasError ? 'Close' : 'Done'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="default" size="sm" className="h-7 px-2 py-0" onClick={handleClose}>
|
||||
<Button variant="default" size="sm" onClick={handleClose}>
|
||||
{hasError ? 'Close' : 'Done'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -284,7 +284,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
</p>
|
||||
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between h-10">
|
||||
<Button variant="outline" size="lg" className="w-full justify-between">
|
||||
<span className={cn('truncate', !selectedBranch && 'text-muted-foreground')}>
|
||||
{selectedBranch || 'Select a branch...'}
|
||||
</span>
|
||||
@@ -353,7 +353,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
|
||||
{mode === 'dialog' ? (
|
||||
<DialogFooter className="gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 py-0" onClick={handleCancel}>
|
||||
<Button variant="ghost" size="sm" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -361,7 +361,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
size="sm"
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedBranch}
|
||||
className="h-7 px-2 py-0 gap-1.5"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{operation === 'merge' ? (
|
||||
<>
|
||||
@@ -378,11 +378,11 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
</DialogFooter>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 py-0" onClick={handleCancel} disabled={isDisabled}>
|
||||
<Button variant="destructive" size="sm" onClick={handleCancel} disabled={isDisabled}>
|
||||
Reset
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="default" size="sm" className="h-7 px-2 py-0" onClick={handleConfirm} disabled={isDisabled || !selectedBranch}>
|
||||
<Button variant="default" size="sm" onClick={handleConfirm} disabled={isDisabled || !selectedBranch}>
|
||||
{operation === 'merge' ? 'Merge' : 'Rebase'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -416,7 +416,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 py-0 gap-1.5"
|
||||
className="gap-1.5"
|
||||
onClick={handleOpenDialog}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,6 @@ interface ChangesSectionProps {
|
||||
onViewDiff: (path: string) => void;
|
||||
onRevertFile: (path: string) => void;
|
||||
isRevertingAll?: boolean;
|
||||
variant?: 'framed' | 'plain';
|
||||
maxListHeightClassName?: string;
|
||||
onVisiblePathsChange?: (paths: string[]) => void;
|
||||
}
|
||||
@@ -48,7 +47,6 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
onViewDiff,
|
||||
onRevertFile,
|
||||
isRevertingAll = false,
|
||||
variant = 'framed',
|
||||
maxListHeightClassName,
|
||||
onVisiblePathsChange,
|
||||
}) => {
|
||||
@@ -92,19 +90,10 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
onVisiblePathsChange(virtualRows.map((row) => changeEntries[row.index]?.path).filter((value): value is string => Boolean(value)));
|
||||
}, [changeEntries, onVisiblePathsChange, shouldVirtualize, totalCount, virtualRows]);
|
||||
|
||||
const containerClassName =
|
||||
variant === 'framed'
|
||||
? 'flex flex-col rounded-xl border border-border/60 bg-background/70'
|
||||
: 'flex flex-col flex-1 min-h-0';
|
||||
const headerClassName =
|
||||
variant === 'framed'
|
||||
? 'flex items-center justify-between gap-2 px-3 py-2 border-b border-border/40'
|
||||
: 'flex items-center justify-between gap-2 px-0 py-3 border-b border-border/40';
|
||||
const scrollOuterClassName =
|
||||
variant === 'framed'
|
||||
? 'flex-1 min-h-0 max-h-[30vh]'
|
||||
: `flex-1 min-h-0 pr-0 ${maxListHeightClassName ?? ''}`.trim();
|
||||
const rowPaddingClassName = variant === 'plain' ? 'pl-0 pr-2' : 'px-3';
|
||||
const containerClassName = 'flex flex-col flex-1 min-h-0';
|
||||
const headerClassName = 'flex items-center justify-between gap-2 px-0 py-3 border-b border-border/40';
|
||||
const scrollOuterClassName = `flex-1 min-h-0 pr-0 ${maxListHeightClassName ?? ''}`.trim();
|
||||
const rowPaddingClassName = 'pl-0 pr-2';
|
||||
|
||||
const handleConfirmRevertAll = React.useCallback(async () => {
|
||||
if (!onRevertAll || isRevertingAll || changeEntries.length === 0) {
|
||||
@@ -144,12 +133,11 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn('flex items-center gap-2', variant === 'plain' && 'pr-1')}>
|
||||
<div className="flex items-center gap-2 pr-1">
|
||||
{totalCount > 0 && onRevertAll ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
variant="destructive"
|
||||
size="xs"
|
||||
onClick={() => setConfirmRevertAllOpen(true)}
|
||||
disabled={isRevertingAll}
|
||||
>
|
||||
@@ -237,10 +225,10 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmRevertAllOpen(false)} disabled={isRevertingAll}>
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmRevertAllOpen(false)} disabled={isRevertingAll}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => void handleConfirmRevertAll()} disabled={isRevertingAll}>
|
||||
<Button variant="destructive" size="sm" onClick={() => void handleConfirmRevertAll()} disabled={isRevertingAll}>
|
||||
{isRevertingAll ? 'Reverting...' : 'Revert all'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -5,12 +5,7 @@ import {
|
||||
RiLoader4Line,
|
||||
RiEmotionHappyLine,
|
||||
} from '@remixicon/react';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ButtonLarge } from '@/components/ui/button-large';
|
||||
import { CommitInput } from './CommitInput';
|
||||
import { AIHighlightsBox } from './AIHighlightsBox';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
@@ -33,7 +28,6 @@ interface CommitSectionProps {
|
||||
isBusy: boolean;
|
||||
gitmojiEnabled: boolean;
|
||||
onOpenGitmojiPicker: () => void;
|
||||
variant?: 'framed' | 'plain';
|
||||
}
|
||||
|
||||
export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
@@ -51,167 +45,148 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
isBusy,
|
||||
gitmojiEnabled,
|
||||
onOpenGitmojiPicker,
|
||||
variant = 'framed',
|
||||
}) => {
|
||||
const hasSelectedFiles = selectedCount > 0;
|
||||
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
|
||||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||
|
||||
const containerClassName =
|
||||
variant === 'framed'
|
||||
? 'rounded-xl border border-border/60 bg-background/70 overflow-hidden'
|
||||
: 'border-0 bg-transparent rounded-none';
|
||||
const headerClassName =
|
||||
variant === 'framed'
|
||||
? 'flex w-full items-center justify-between px-3 py-2'
|
||||
: 'flex w-full items-center justify-between px-0 py-3 border-b border-border/40';
|
||||
const contentClassName =
|
||||
variant === 'framed'
|
||||
? 'flex flex-col gap-3 p-3 pt-0'
|
||||
: 'flex flex-col gap-3 px-0 py-3';
|
||||
const containerClassName = 'border-0 bg-transparent rounded-none';
|
||||
const headerClassName = 'flex w-full items-center justify-between px-0 pt-2 pb-1';
|
||||
const contentClassName = 'flex flex-col gap-3 px-0 pt-1 pb-3';
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={variant === 'plain' ? true : hasSelectedFiles}
|
||||
className={containerClassName}
|
||||
data-keyboard-avoid="true"
|
||||
>
|
||||
<section className={containerClassName} data-keyboard-avoid="true">
|
||||
<div className={headerClassName}>
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Commit</h3>
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{hasSelectedFiles
|
||||
? `${selectedCount} file${selectedCount === 1 ? '' : 's'} selected`
|
||||
: 'No files selected'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className={contentClassName}>
|
||||
{!hasSelectedFiles ? (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Select files in Changes to enable commit.
|
||||
</p>
|
||||
) : null}
|
||||
<div className={contentClassName}>
|
||||
{!hasSelectedFiles ? (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Select files in Changes to enable commit.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<AIHighlightsBox
|
||||
highlights={generatedHighlights}
|
||||
onInsert={onInsertHighlights}
|
||||
onClear={onClearHighlights}
|
||||
/>
|
||||
<AIHighlightsBox
|
||||
highlights={generatedHighlights}
|
||||
onInsert={onInsertHighlights}
|
||||
onClear={onClearHighlights}
|
||||
/>
|
||||
|
||||
<CommitInput
|
||||
value={commitMessage}
|
||||
onChange={onCommitMessageChange}
|
||||
placeholder="Commit message"
|
||||
disabled={commitAction !== null}
|
||||
hasTouchInput={hasTouchInput}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
<CommitInput
|
||||
value={commitMessage}
|
||||
onChange={onCommitMessageChange}
|
||||
placeholder="Commit message"
|
||||
disabled={commitAction !== null}
|
||||
hasTouchInput={hasTouchInput}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{gitmojiEnabled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpenGitmojiPicker}
|
||||
className="w-fit"
|
||||
type="button"
|
||||
>
|
||||
<RiEmotionHappyLine className="size-4" />
|
||||
Add gitmoji
|
||||
</Button>
|
||||
)}
|
||||
{gitmojiEnabled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpenGitmojiPicker}
|
||||
className="w-fit"
|
||||
type="button"
|
||||
>
|
||||
<RiEmotionHappyLine className="size-4" />
|
||||
Add gitmoji
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="@container/commit-actions flex items-center gap-2 min-w-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onGenerateMessage}
|
||||
disabled={
|
||||
isGeneratingMessage ||
|
||||
commitAction !== null ||
|
||||
selectedCount === 0 ||
|
||||
isBusy
|
||||
}
|
||||
type="button"
|
||||
aria-label="Generate"
|
||||
className="commit-actions__btn"
|
||||
>
|
||||
{isGeneratingMessage ? (
|
||||
<div className="@container/commit-actions flex items-center gap-2 min-w-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onGenerateMessage}
|
||||
disabled={
|
||||
isGeneratingMessage ||
|
||||
commitAction !== null ||
|
||||
selectedCount === 0 ||
|
||||
isBusy
|
||||
}
|
||||
type="button"
|
||||
aria-label="Generate"
|
||||
className="commit-actions__btn"
|
||||
>
|
||||
{isGeneratingMessage ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiAiGenerate2 className="size-4 text-primary" />
|
||||
)}
|
||||
<span className="commit-actions__label">Generate</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCommit}
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="commit-actions__btn whitespace-nowrap"
|
||||
aria-label="Commit"
|
||||
>
|
||||
{commitAction === 'commit' ? (
|
||||
<>
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiAiGenerate2 className="size-4 text-primary" />
|
||||
)}
|
||||
<span className="commit-actions__label">Generate</span>
|
||||
</Button>
|
||||
<span className="commit-actions__label">Committing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiGitCommitLine className="size-4" />
|
||||
<span className="commit-actions__label">Commit</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<ButtonLarge
|
||||
variant="outline"
|
||||
onClick={onCommit}
|
||||
{isMobile ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onCommitAndPush()}
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Push"
|
||||
>
|
||||
{commitAction === 'commitAndPush' ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiArrowUpLine className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Push</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => onCommitAndPush()}
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="commit-actions__btn whitespace-nowrap"
|
||||
aria-label="Commit"
|
||||
className="commit-actions__btn"
|
||||
aria-label="Push"
|
||||
>
|
||||
{commitAction === 'commit' ? (
|
||||
{commitAction === 'commitAndPush' ? (
|
||||
<>
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
<span className="commit-actions__label">Committing...</span>
|
||||
<span className="commit-actions__label">Pushing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiGitCommitLine className="size-4" />
|
||||
<span className="commit-actions__label">Commit</span>
|
||||
<RiArrowUpLine className="size-3.5" />
|
||||
<span className="commit-actions__label">Push</span>
|
||||
</>
|
||||
)}
|
||||
</ButtonLarge>
|
||||
|
||||
{isMobile ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onCommitAndPush()}
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Push"
|
||||
>
|
||||
{commitAction === 'commitAndPush' ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiArrowUpLine className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Push</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<ButtonLarge
|
||||
variant="default"
|
||||
onClick={() => onCommitAndPush()}
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="commit-actions__btn"
|
||||
aria-label="Push"
|
||||
>
|
||||
{commitAction === 'commitAndPush' ? (
|
||||
<>
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
<span className="commit-actions__label">Pushing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiArrowUpLine className="size-4" />
|
||||
<span className="commit-actions__label">Push</span>
|
||||
</>
|
||||
)}
|
||||
</ButtonLarge>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -261,7 +261,7 @@ Important:
|
||||
<Button variant="ghost" size="sm" onClick={handleContinueLater} className="flex-1">
|
||||
Continue Later
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleAbort} className="flex-1 text-[var(--status-error)]">
|
||||
<Button variant="destructive" size="sm" onClick={handleAbort} className="flex-1">
|
||||
Abort {operationLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,8 @@ interface GitHeaderProps {
|
||||
onFetch: (remote: GitRemote) => void;
|
||||
onPull: (remote: GitRemote) => void;
|
||||
onPush: () => void;
|
||||
onRemoveRemote: (remote: GitRemote) => void;
|
||||
removingRemoteName: string | null;
|
||||
onCheckoutBranch: (branch: string) => void;
|
||||
onCreateBranch: (name: string, remote?: GitRemote) => Promise<void>;
|
||||
onRenameBranch?: (oldName: string, newName: string) => Promise<void>;
|
||||
@@ -196,6 +198,8 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
onFetch,
|
||||
onPull,
|
||||
onPush,
|
||||
onRemoveRemote,
|
||||
removingRemoteName,
|
||||
onCheckoutBranch,
|
||||
onCreateBranch,
|
||||
onRenameBranch,
|
||||
@@ -241,6 +245,8 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
onFetch={onFetch}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onRemoveRemote={onRemoveRemote}
|
||||
removingRemoteName={removingRemoteName}
|
||||
disabled={!status}
|
||||
iconOnly={true}
|
||||
tooltipDelayMs={useTwoRowHeader ? 300 : 1000}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user