feat(ui): polish chat and git workflows with mobile UX and reliability fixes (#569)
* feat: add chat option for user message rendering mode * feat: add chat option to toggle sticky user header * feat(ui): overhaul context panel with reusable tabs and embedded session chat Enable parallel context workflows with persistent tabbed views and isolated session chat while reducing resize and background runtime overhead. * feat: polish context panel and git sidebar tabs Refined context panel tab behavior and visuals for smoother switching and resizing Reused the new tabs component in right sidebar and git sidebar with fit layout Improved git section spacing, selection controls, and bulk revert confirmation flow * feat: open diff files in editor at changed lines Add edit actions in diff views to open files at the first changed line Support per-file open-in-editor from All Files headers and icon-only action in single-file view Improve file jump UX with load-aware navigation and reduced visual blink during line targeting * fix: stabilize pill tabs and prevent git commit pathspec failures Unified sortable tab variants to match animated styling behavior with responsive spacing and cleaner sidebar chrome Fixed active tab pill measurement so size/position recalculates correctly when dropdowns reopen Commit API now filters stale file paths before staging to avoid pathspec errors on deleted files * fix: align user message action row spacing and hover behavior * fix: persist user message view preferences in settings Save plain-text and sticky-header toggles to settings.json when changed Restore both chat display preferences from settings.json on startup Validate and accept both preference fields in the settings API * fix: improve git and sidebar tab layout on mobile * fix: refine mobile user message action row spacing Show mobile user-message actions in a consistent external row for sticky and non-sticky modes Tune button row height and vertical position to match both mobile variants Reduce sticky-header gradient tail and tighten assistant gap after user messages * fix: improve chat action hover zones and mobile top shadow logic Expand desktop trigger area so user action buttons reveal across the full row Add sticky-header phantom hover row so inline actions appear from the whole button lane Hide chat top scroll shadow on mobile only when sticky user headers are enabled * fix: remove commit message input scrollbar flicker Added optional scrollbar class support to shared textarea wrapper. Disabled overlay scrollbar for Git commit message input. Kept auto-resize behavior while preventing one-line empty-state micro-scroll. * feat: make model provider groups collapsible in selector Add collapsible provider headers in the chat model dropdown Persist expanded/collapsed provider state across sessions Refine provider header UX with inline chevrons and no hover highlight * feat: arrange chat settings into a compact two-column layout Places User Message Rendering next to Mermaid Rendering. Places Diff Layout next to Diff View Mode. Reduces right-column spacing to better match other settings sections. * fix: show worktree branch edit controls in draft sessions Detect worktree mode from current directory when session metadata is not yet bound Enable immediate branch rename UI in Git sidebar without session switching * feat: add beta badge to side panel menu action
This commit is contained in:
committed by
GitHub
parent
73e533a315
commit
b4cd16f55b
+179
-11
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
||||
import { AgentManagerView } from '@/components/views/agent-manager';
|
||||
import { ChatView } from '@/components/views';
|
||||
import { FireworksProvider } from '@/contexts/FireworksContext';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
|
||||
@@ -54,17 +55,60 @@ type AppProps = {
|
||||
apis: RuntimeAPIs;
|
||||
};
|
||||
|
||||
type EmbeddedSessionChatConfig = {
|
||||
sessionId: string;
|
||||
directory: string | null;
|
||||
};
|
||||
|
||||
type EmbeddedVisibilityPayload = {
|
||||
visible?: unknown;
|
||||
};
|
||||
|
||||
const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('ocPanel') !== 'session-chat') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionIdRaw = params.get('sessionId');
|
||||
const sessionId = typeof sessionIdRaw === 'string' ? sessionIdRaw.trim() : '';
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directoryRaw = params.get('directory');
|
||||
const directory = typeof directoryRaw === 'string' && directoryRaw.trim().length > 0
|
||||
? directoryRaw.trim()
|
||||
: null;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
directory,
|
||||
};
|
||||
};
|
||||
|
||||
function App({ apis }: AppProps) {
|
||||
const { initializeApp, isInitialized, isConnected } = useConfigStore();
|
||||
const { error, clearError, loadSessions } = useSessionStore();
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const setDirectory = useDirectoryStore((state) => state.setDirectory);
|
||||
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
|
||||
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
|
||||
const { uiFont, monoFont } = useFontPreferences();
|
||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
||||
const [showCliOnboarding, setShowCliOnboarding] = React.useState(false);
|
||||
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
|
||||
const appReadyDispatchedRef = React.useRef(false);
|
||||
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
|
||||
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsVSCodeRuntime(apis.runtime.isVSCode);
|
||||
@@ -76,8 +120,12 @@ function App({ apis }: AppProps) {
|
||||
}, [apis]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (embeddedSessionChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
}, [apis.github, refreshGitHubAuthStatus]);
|
||||
}, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
@@ -166,6 +214,95 @@ function App({ apis }: AppProps) {
|
||||
syncDirectoryAndSessions();
|
||||
}, [currentDirectory, isSwitchingDirectory, loadSessions, isConnected, isVSCodeRuntime]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!embeddedSessionChat || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyVisibility = (payload?: EmbeddedVisibilityPayload) => {
|
||||
const nextVisible = payload?.visible === true;
|
||||
setIsEmbeddedVisible(nextVisible);
|
||||
};
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data as { type?: unknown; payload?: EmbeddedVisibilityPayload };
|
||||
if (data?.type !== 'openchamber:embedded-visibility') {
|
||||
return;
|
||||
}
|
||||
|
||||
applyVisibility(data.payload);
|
||||
};
|
||||
|
||||
const scopedWindow = window as unknown as {
|
||||
__openchamberSetEmbeddedVisibility?: (payload?: EmbeddedVisibilityPayload) => void;
|
||||
};
|
||||
|
||||
scopedWindow.__openchamberSetEmbeddedVisibility = applyVisibility;
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
if (scopedWindow.__openchamberSetEmbeddedVisibility === applyVisibility) {
|
||||
delete scopedWindow.__openchamberSetEmbeddedVisibility;
|
||||
}
|
||||
};
|
||||
}, [embeddedSessionChat]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!embeddedSessionChat?.directory || isVSCodeRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentDirectory === embeddedSessionChat.directory) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDirectory(embeddedSessionChat.directory, { showOverlay: false });
|
||||
}, [currentDirectory, embeddedSessionChat, isVSCodeRuntime, setDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!embeddedSessionChat || isVSCodeRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSessionId === embeddedSessionChat.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void setCurrentSession(embeddedSessionChat.sessionId);
|
||||
}, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!embeddedSessionChat || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.storageArea !== window.localStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'ui-store') {
|
||||
return;
|
||||
}
|
||||
|
||||
void useUIStore.persist.rehydrate();
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
};
|
||||
}, [embeddedSessionChat]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!isInitialized || isSwitchingDirectory) return;
|
||||
@@ -175,13 +312,13 @@ function App({ apis }: AppProps) {
|
||||
window.dispatchEvent(new Event('openchamber:app-ready'));
|
||||
}, [isInitialized, isSwitchingDirectory]);
|
||||
|
||||
useEventStream();
|
||||
useEventStream({ enabled: embeddedBackgroundWorkEnabled });
|
||||
|
||||
// Server-authoritative session status polling
|
||||
// Replaces SSE-dependent status updates with reliable HTTP polling
|
||||
useServerSessionStatus();
|
||||
useServerSessionStatus({ enabled: embeddedBackgroundWorkEnabled });
|
||||
|
||||
usePushVisibilityBeacon();
|
||||
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
|
||||
|
||||
useWindowTitle();
|
||||
|
||||
@@ -197,6 +334,10 @@ function App({ apis }: AppProps) {
|
||||
|
||||
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
||||
React.useEffect(() => {
|
||||
if (embeddedSessionChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTauriShell()) {
|
||||
return;
|
||||
}
|
||||
@@ -206,15 +347,19 @@ function App({ apis }: AppProps) {
|
||||
}
|
||||
|
||||
void tauri.core.invoke('desktop_set_auto_worktree_menu', { enabled: settingsAutoCreateWorktree });
|
||||
}, [settingsAutoCreateWorktree]);
|
||||
}, [embeddedSessionChat, settingsAutoCreateWorktree]);
|
||||
|
||||
|
||||
|
||||
useSessionStatusBootstrap();
|
||||
useSessionAutoCleanup();
|
||||
useQueuedMessageAutoSend();
|
||||
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
|
||||
useSessionAutoCleanup({ enabled: embeddedBackgroundWorkEnabled });
|
||||
useQueuedMessageAutoSend({ enabled: embeddedBackgroundWorkEnabled });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (embeddedSessionChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (hasModifier(e) && e.shiftKey && e.key === 'D') {
|
||||
e.preventDefault();
|
||||
@@ -224,16 +369,24 @@ function App({ apis }: AppProps) {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
}, [embeddedSessionChat]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (embeddedSessionChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
||||
setTimeout(() => clearError(), 5000);
|
||||
}
|
||||
}, [error, clearError]);
|
||||
}, [clearError, embeddedSessionChat, error]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (embeddedSessionChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDesktopShell() || !isDesktopLocalOriginActive()) {
|
||||
return;
|
||||
}
|
||||
@@ -269,7 +422,7 @@ function App({ apis }: AppProps) {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}, [embeddedSessionChat]);
|
||||
|
||||
const handleCliAvailable = React.useCallback(() => {
|
||||
setShowCliOnboarding(false);
|
||||
@@ -286,6 +439,21 @@ function App({ apis }: AppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (embeddedSessionChat) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<RuntimeAPIProvider apis={apis}>
|
||||
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<ChatView />
|
||||
<Toaster />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</RuntimeAPIProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// VS Code runtime - simplified layout without git/terminal views
|
||||
if (isVSCodeRuntime) {
|
||||
// Check if this is the Agent Manager panel
|
||||
|
||||
@@ -106,6 +106,7 @@ export const ChatContainer: React.FC = () => {
|
||||
isTimelineDialogOpen,
|
||||
setTimelineDialogOpen,
|
||||
isExpandedInput,
|
||||
stickyUserHeader,
|
||||
} = useUIStore();
|
||||
|
||||
const sessionMessages = useSessionStore(
|
||||
@@ -593,14 +594,14 @@ export const ChatContainer: React.FC = () => {
|
||||
aria-hidden={isDesktopExpandedInput}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<ScrollShadow
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||
ref={scrollRef}
|
||||
observeMutations={false}
|
||||
hideTopShadow={isMobile}
|
||||
data-scroll-shadow="true"
|
||||
data-scrollbar="chat"
|
||||
>
|
||||
<ScrollShadow
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||
ref={scrollRef}
|
||||
observeMutations={false}
|
||||
hideTopShadow={isMobile && stickyUserHeader}
|
||||
data-scroll-shadow="true"
|
||||
data-scrollbar="chat"
|
||||
>
|
||||
<div className="relative z-0 min-h-full">
|
||||
<MessageList
|
||||
ref={messageListRef}
|
||||
|
||||
@@ -131,10 +131,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
} = sessionState;
|
||||
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const { showReasoningTraces, toolCallExpansion } = useUIStore(
|
||||
const { showReasoningTraces, toolCallExpansion, stickyUserHeader } = useUIStore(
|
||||
useShallow((state) => ({
|
||||
showReasoningTraces: state.showReasoningTraces,
|
||||
toolCallExpansion: state.toolCallExpansion,
|
||||
stickyUserHeader: state.stickyUserHeader,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -164,6 +165,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
|
||||
const isUser = messageRole.isUser;
|
||||
const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader);
|
||||
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
|
||||
|
||||
const sessionId = message.info.sessionID;
|
||||
|
||||
@@ -940,12 +943,16 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistantTopPaddingClass = !isUser && shouldShowHeader
|
||||
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
|
||||
: 'pt-0';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'group w-full',
|
||||
isUser ? (isMobile ? 'pt-2' : 'pt-6') : (shouldShowHeader ? (isMobile ? 'pt-10' : 'pt-6') : 'pt-0'),
|
||||
isUser ? (isMobile ? 'pt-2' : 'pt-6') : assistantTopPaddingClass,
|
||||
isUser ? 'pb-0' : isFollowedByAssistant ? 'pb-0' : 'pb-8'
|
||||
)}
|
||||
data-message-id={message.info.id}
|
||||
@@ -955,37 +962,74 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
{isUser ? (
|
||||
displayParts.length === 0 ? null : (
|
||||
<FadeInOnReveal>
|
||||
<div className="flex justify-end">
|
||||
<div style={{ backgroundColor: 'var(--chat-user-message-bg)' }} className="max-w-[85%] rounded-2xl rounded-br-sm px-5 py-3 shadow-none border border-primary/5">
|
||||
<MessageBody
|
||||
messageId={message.info.id}
|
||||
parts={displayParts}
|
||||
isUser={isUser}
|
||||
isMessageCompleted={isMessageCompleted}
|
||||
messageFinish={messageFinish}
|
||||
syntaxTheme={syntaxTheme}
|
||||
isMobile={isMobile}
|
||||
hasTouchInput={hasTouchInput}
|
||||
copiedCode={copiedCode}
|
||||
onCopyCode={handleCopyCode}
|
||||
expandedTools={expandedTools}
|
||||
onToggleTool={handleToggleTool}
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={false}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
agentMention={agentMention}
|
||||
onRevert={handleRevert}
|
||||
onFork={isUser ? handleFork : undefined}
|
||||
errorMessage={assistantErrorText}
|
||||
/>
|
||||
<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-2xl rounded-br-sm px-5 py-3 shadow-none border border-primary/5">
|
||||
<MessageBody
|
||||
messageId={message.info.id}
|
||||
parts={displayParts}
|
||||
isUser={isUser}
|
||||
isMessageCompleted={isMessageCompleted}
|
||||
messageFinish={messageFinish}
|
||||
syntaxTheme={syntaxTheme}
|
||||
isMobile={isMobile}
|
||||
hasTouchInput={hasTouchInput}
|
||||
copiedCode={copiedCode}
|
||||
onCopyCode={handleCopyCode}
|
||||
expandedTools={expandedTools}
|
||||
onToggleTool={handleToggleTool}
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={false}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
agentMention={agentMention}
|
||||
onRevert={handleRevert}
|
||||
onFork={isUser ? handleFork : undefined}
|
||||
errorMessage={assistantErrorText}
|
||||
userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'}
|
||||
stickyUserHeaderEnabled={stickyUserHeader}
|
||||
/>
|
||||
</div>
|
||||
{useExternalUserActionsRow ? (
|
||||
<MessageBody
|
||||
messageId={message.info.id}
|
||||
parts={displayParts}
|
||||
isUser={isUser}
|
||||
isMessageCompleted={isMessageCompleted}
|
||||
messageFinish={messageFinish}
|
||||
syntaxTheme={syntaxTheme}
|
||||
isMobile={isMobile}
|
||||
hasTouchInput={hasTouchInput}
|
||||
copiedCode={copiedCode}
|
||||
onCopyCode={handleCopyCode}
|
||||
expandedTools={expandedTools}
|
||||
onToggleTool={handleToggleTool}
|
||||
onShowPopup={handleShowPopup}
|
||||
streamPhase={streamPhase}
|
||||
allowAnimation={allowAnimation}
|
||||
onContentChange={onContentChange}
|
||||
shouldShowHeader={false}
|
||||
hasTextContent={hasTextContent}
|
||||
onCopyMessage={handleCopyMessage}
|
||||
copiedMessage={copiedMessage}
|
||||
showReasoningTraces={showReasoningTraces}
|
||||
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
|
||||
agentMention={agentMention}
|
||||
onRevert={handleRevert}
|
||||
onFork={isUser ? handleFork : undefined}
|
||||
errorMessage={assistantErrorText}
|
||||
userActionsMode="external-actions"
|
||||
stickyUserHeaderEnabled={stickyUserHeader}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{showStickyInlineHoverRow ? <div aria-hidden="true" className="absolute left-0 right-0 top-full h-11" /> : null}
|
||||
</div>
|
||||
</FadeInOnReveal>
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import { filterSyntheticParts } from '@/lib/messages/synthetic';
|
||||
import { detectTurns, type Turn } from './hooks/useTurnGrouping';
|
||||
import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic } from './contexts/TurnGroupingContext';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
|
||||
|
||||
@@ -414,7 +415,7 @@ const TurnBlock: React.FC<TurnBlockProps> = ({
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-full z-0 h-8 bg-gradient-to-b from-[var(--surface-background)] to-transparent"
|
||||
className="pointer-events-none absolute inset-x-0 top-full z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -528,7 +529,8 @@ const MessageListContent: React.FC<{
|
||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => {
|
||||
stickyUserHeader: boolean;
|
||||
}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader }) => {
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => (
|
||||
@@ -538,7 +540,7 @@ const MessageListContent: React.FC<{
|
||||
onMessageContentChange={onMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
scrollToBottom={scrollToBottom}
|
||||
stickyUserHeader
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -560,6 +562,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
scrollRef,
|
||||
}, ref) => {
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (permissions.length === 0 && questions.length === 0) {
|
||||
@@ -1044,6 +1047,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
onMessageContentChange={onMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
scrollToBottom={scrollToBottom}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
/>
|
||||
)}
|
||||
</FadeInDisabledProvider>
|
||||
|
||||
@@ -361,6 +361,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const {
|
||||
toggleFavoriteModel,
|
||||
isFavoriteModel,
|
||||
collapsedModelProviders,
|
||||
toggleModelProviderCollapsed,
|
||||
addRecentModel,
|
||||
addRecentAgent,
|
||||
addRecentEffort,
|
||||
@@ -370,6 +372,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
setSettingsPage,
|
||||
} = useUIStore();
|
||||
const hiddenModels = useUIStore((state) => state.hiddenModels);
|
||||
const collapsedProviderSet = React.useMemo(
|
||||
() => new Set(collapsedModelProviders.map((providerId) => providerId.trim()).filter(Boolean)),
|
||||
[collapsedModelProviders]
|
||||
);
|
||||
|
||||
// Separate state for agent selector to avoid conflict with model selector
|
||||
const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false);
|
||||
@@ -2104,6 +2110,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
};
|
||||
|
||||
const renderModelSelector = () => {
|
||||
const normalizedDesktopQuery = desktopModelQuery.trim();
|
||||
const forceExpandProviders = normalizedDesktopQuery.length > 0;
|
||||
|
||||
// Filter favorites
|
||||
const filteredFavorites = favoriteModelsList.filter(({ model, providerID }) => {
|
||||
const provider = providers.find(p => p.id === providerID);
|
||||
@@ -2132,7 +2141,22 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
})
|
||||
.filter((provider) => provider.models.length > 0);
|
||||
|
||||
const hasResults = filteredFavorites.length > 0 || filteredRecents.length > 0 || filteredProviders.length > 0;
|
||||
const providerSections = filteredProviders.map((provider) => {
|
||||
const providerId = typeof provider.id === 'string' ? provider.id : '';
|
||||
const isExpanded = forceExpandProviders || !collapsedProviderSet.has(providerId);
|
||||
const models = Array.isArray(provider.models) ? (provider.models as ProviderModel[]) : [];
|
||||
return {
|
||||
provider,
|
||||
isExpanded,
|
||||
models,
|
||||
visibleModels: isExpanded ? models : [],
|
||||
};
|
||||
});
|
||||
|
||||
const hasResults =
|
||||
filteredFavorites.length > 0 ||
|
||||
filteredRecents.length > 0 ||
|
||||
filteredProviders.length > 0;
|
||||
|
||||
// Build flat list for keyboard navigation
|
||||
type FlatModelItem = { model: ProviderModel; providerID: string; modelID: string; section: string };
|
||||
@@ -2144,8 +2168,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
filteredRecents.forEach(({ model, providerID, modelID }) => {
|
||||
flatModelList.push({ model, providerID, modelID, section: 'recent' });
|
||||
});
|
||||
filteredProviders.forEach((provider) => {
|
||||
(provider.models as ProviderModel[]).forEach((model) => {
|
||||
providerSections.forEach(({ provider, visibleModels }) => {
|
||||
visibleModels.forEach((model) => {
|
||||
flatModelList.push({ model, providerID: provider.id as string, modelID: model.id as string, section: 'provider' });
|
||||
});
|
||||
});
|
||||
@@ -2245,7 +2269,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
|
||||
<ScrollableOverlay
|
||||
outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1"
|
||||
className="overlay-scrollbar-target--no-gutter"
|
||||
>
|
||||
<div className="p-1">
|
||||
<div
|
||||
role="button"
|
||||
@@ -2314,20 +2341,54 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
)}
|
||||
|
||||
{/* All Providers - Flat List */}
|
||||
{filteredProviders.map((provider, index) => (
|
||||
{providerSections.map(({ provider, isExpanded, visibleModels }, index) => (
|
||||
<React.Fragment 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"
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={forceExpandProviders ? -1 : 0}
|
||||
aria-disabled={forceExpandProviders}
|
||||
onClick={() => {
|
||||
if (forceExpandProviders) {
|
||||
return;
|
||||
}
|
||||
toggleModelProviderCollapsed(String(provider.id));
|
||||
setModelSelectedIndex(0);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (forceExpandProviders) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
toggleModelProviderCollapsed(String(provider.id));
|
||||
setModelSelectedIndex(0);
|
||||
}
|
||||
}}
|
||||
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',
|
||||
forceExpandProviders ? 'cursor-default' : 'cursor-pointer'
|
||||
)}
|
||||
aria-expanded={isExpanded}
|
||||
title={forceExpandProviders ? undefined : (isExpanded ? 'Collapse provider' : 'Expand provider')}
|
||||
>
|
||||
<ProviderLogo
|
||||
providerId={provider.id}
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
/>
|
||||
{provider.name}
|
||||
</DropdownMenuLabel>
|
||||
{(provider.models as ProviderModel[]).map((model: ProviderModel) => {
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ProviderLogo
|
||||
providerId={provider.id}
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 truncate">{provider.name}</span>
|
||||
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center text-muted-foreground">
|
||||
{isExpanded ? (
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
) : (
|
||||
<RiArrowRightSLine className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && visibleModels.map((model: ProviderModel) => {
|
||||
const idx = currentFlatIndex++;
|
||||
return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, modelSelectedIndex === idx);
|
||||
})}
|
||||
|
||||
@@ -286,6 +286,8 @@ interface MessageBodyProps {
|
||||
onRevert?: () => void;
|
||||
onFork?: () => void;
|
||||
errorMessage?: string;
|
||||
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
|
||||
stickyUserHeaderEnabled?: boolean;
|
||||
}
|
||||
|
||||
const UserMessageBody: React.FC<{
|
||||
@@ -300,7 +302,9 @@ const UserMessageBody: React.FC<{
|
||||
agentMention?: AgentMentionInfo;
|
||||
onRevert?: () => void;
|
||||
onFork?: () => void;
|
||||
}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork }) => {
|
||||
userActionsMode?: 'inline' | 'external-content' | 'external-actions';
|
||||
stickyUserHeaderEnabled?: boolean;
|
||||
}> = ({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }) => {
|
||||
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
|
||||
const copyHintTimeoutRef = React.useRef<number | null>(null);
|
||||
|
||||
@@ -326,6 +330,8 @@ const UserMessageBody: React.FC<{
|
||||
const isMessageCopied = Boolean(copiedMessage);
|
||||
const isTouchContext = Boolean(hasTouchInput ?? isMobile);
|
||||
const hasCopyableText = Boolean(hasTextContent);
|
||||
const showUserContent = userActionsMode !== 'external-actions';
|
||||
const showUserActions = userActionsMode !== 'external-content';
|
||||
|
||||
const clearCopyHintTimeout = React.useCallback(() => {
|
||||
if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
@@ -371,6 +377,113 @@ const UserMessageBody: React.FC<{
|
||||
[hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint]
|
||||
);
|
||||
|
||||
const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || onFork) && showUserActions ? (
|
||||
<div className={cn(
|
||||
'group/user-actions',
|
||||
isMobile
|
||||
? userActionsMode === 'inline'
|
||||
? 'flex items-center justify-end pt-2 pb-3'
|
||||
: stickyUserHeaderEnabled
|
||||
? 'flex h-9 items-start justify-end pt-0'
|
||||
: 'flex h-11 items-start justify-end pt-0'
|
||||
: userActionsMode === 'inline'
|
||||
? 'absolute top-full left-0 right-0 z-10 pt-5'
|
||||
: 'flex h-8 items-start justify-end pt-2'
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-end gap-1',
|
||||
isMobile
|
||||
? userActionsMode === 'inline'
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
: userActionsMode === 'inline'
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0',
|
||||
isMobile
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0 transition-opacity duration-150 group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-hover/user-actions:pointer-events-auto group-hover/user-actions:opacity-100 group-hover/user-shell:pointer-events-auto group-hover/user-shell:opacity-100'
|
||||
)}
|
||||
>
|
||||
{onRevert && (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Revert to this message"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRevert();
|
||||
}}
|
||||
>
|
||||
<RiArrowGoBackLine className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onFork && (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Fork from this message"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onFork();
|
||||
}}
|
||||
>
|
||||
<RiGitBranchLine className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canCopyMessage && hasCopyableText && (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
|
||||
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Copy message text"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleCopyButtonClick}
|
||||
onFocus={() => setCopyHintVisible(true)}
|
||||
onBlur={() => {
|
||||
if (!isMessageCopied) {
|
||||
setCopyHintVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isMessageCopied ? (
|
||||
<RiCheckLine className="h-3 w-3 text-[color:var(--status-success)]" />
|
||||
) : (
|
||||
<RiFileCopyLine className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>Copy message</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (!showUserContent) {
|
||||
return <>{actionsBlock}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full group/message"
|
||||
@@ -416,92 +529,7 @@ const UserMessageBody: React.FC<{
|
||||
})}
|
||||
</div>
|
||||
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} compact />
|
||||
{(canCopyMessage && hasCopyableText) || onRevert || onFork ? (
|
||||
<div className={cn(
|
||||
"absolute top-full left-0 right-0 z-10 group/user-actions",
|
||||
isMobile ? "pt-2 pb-3" : "pt-5"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex translate-x-5 items-center justify-end gap-1",
|
||||
isMobile
|
||||
? "pointer-events-auto opacity-100"
|
||||
: "pointer-events-none opacity-0 transition-opacity duration-150 group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-hover/user-actions:pointer-events-auto group-hover/user-actions:opacity-100"
|
||||
)}
|
||||
>
|
||||
{onRevert && (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Revert to this message"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRevert();
|
||||
}}
|
||||
>
|
||||
<RiArrowGoBackLine className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>Revert from here</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onFork && (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onFork();
|
||||
}}
|
||||
>
|
||||
<RiGitBranchLine className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>Fork from here</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canCopyMessage && hasCopyableText && (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
|
||||
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Copy message text"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleCopyButtonClick}
|
||||
onFocus={() => setCopyHintVisible(true)}
|
||||
onBlur={() => {
|
||||
if (!isMessageCopied) {
|
||||
setCopyHintVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isMessageCopied ? (
|
||||
<RiCheckLine className="h-3 w-3 text-[color:var(--status-success)]" />
|
||||
) : (
|
||||
<RiFileCopyLine className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>Copy message</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{actionsBlock}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1421,6 +1449,8 @@ const MessageBody: React.FC<MessageBodyProps> = ({ isUser, ...props }) => {
|
||||
agentMention={props.agentMention}
|
||||
onRevert={props.onRevert}
|
||||
onFork={props.onFork}
|
||||
userActionsMode={props.userActionsMode}
|
||||
stickyUserHeaderEnabled={props.stickyUserHeaderEnabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cn } from '@/lib/utils';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import type { AgentMentionInfo } from '../types';
|
||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
||||
|
||||
@@ -18,6 +19,10 @@ const buildMentionUrl = (name: string): string => {
|
||||
return `https://opencode.ai/docs/agents/#${encoded}`;
|
||||
};
|
||||
|
||||
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
|
||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||
};
|
||||
|
||||
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention }) => {
|
||||
const CLAMP_LINES = 2;
|
||||
const partWithText = part as PartWithText;
|
||||
@@ -27,6 +32,8 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const [isTruncated, setIsTruncated] = React.useState(false);
|
||||
const [collapseZoneHeight, setCollapseZoneHeight] = React.useState<number>(0);
|
||||
const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode);
|
||||
const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
|
||||
const textRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => {
|
||||
@@ -91,7 +98,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
}
|
||||
}, [collapseZoneHeight, hasActiveSelectionInElement, isExpanded, isTruncated]);
|
||||
|
||||
const processedContent = React.useMemo(() => {
|
||||
const processedMarkdownContent = React.useMemo(() => {
|
||||
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
|
||||
return textContent;
|
||||
}
|
||||
@@ -100,6 +107,31 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
return textContent.replace(agentMention.token, mentionHtml);
|
||||
}, [agentMention, textContent]);
|
||||
|
||||
const plainTextContent = React.useMemo(() => {
|
||||
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
|
||||
return textContent;
|
||||
}
|
||||
|
||||
const idx = textContent.indexOf(agentMention.token);
|
||||
const before = textContent.slice(0, idx);
|
||||
const after = textContent.slice(idx + agentMention.token.length);
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<a
|
||||
href={buildMentionUrl(agentMention.name)}
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{agentMention.token}
|
||||
</a>
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
}, [agentMention, textContent]);
|
||||
|
||||
if (!textContent || textContent.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -109,16 +141,21 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
||||
<div
|
||||
className={cn(
|
||||
"break-words font-sans typography-markdown",
|
||||
normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap',
|
||||
!isExpanded && "line-clamp-2",
|
||||
isTruncated && !isExpanded && "cursor-pointer"
|
||||
)}
|
||||
ref={textRef}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<SimpleMarkdownRenderer
|
||||
content={processedContent}
|
||||
disableLinkSafety
|
||||
/>
|
||||
{normalizedRenderingMode === 'markdown' ? (
|
||||
<SimpleMarkdownRenderer
|
||||
content={processedMarkdownContent}
|
||||
disableLinkSafety
|
||||
/>
|
||||
) : (
|
||||
plainTextContent
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React from 'react';
|
||||
import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
|
||||
import { RiArrowLeftRightLine, RiChat4Line, RiCloseLine, RiDonutChartFill, RiFileTextLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
|
||||
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { DiffView, FilesView, PlanView } from '@/components/views';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
@@ -12,6 +15,7 @@ import { ContextPanelContent } from './ContextSidebarTab';
|
||||
const CONTEXT_PANEL_MIN_WIDTH = 360;
|
||||
const CONTEXT_PANEL_MAX_WIDTH = 1400;
|
||||
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
|
||||
const CONTEXT_TAB_LABEL_MAX_CHARS = 24;
|
||||
|
||||
const normalizeDirectoryKey = (value: string): string => {
|
||||
if (!value) return '';
|
||||
@@ -52,23 +56,133 @@ const getRelativePathLabel = (filePath: string | null, directory: string): strin
|
||||
return normalizedFile;
|
||||
};
|
||||
|
||||
const getModeLabel = (mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'): string => {
|
||||
if (mode === 'chat') return 'Chat';
|
||||
if (mode === 'file') return 'Files';
|
||||
if (mode === 'diff') return 'Diff';
|
||||
if (mode === 'plan') return 'Plan';
|
||||
return 'Context';
|
||||
};
|
||||
|
||||
const getFileNameFromPath = (path: string | null): string | null => {
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = path.replace(/\\/g, '/').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return segments[segments.length - 1] || null;
|
||||
};
|
||||
|
||||
const getTabLabel = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'; label: string | null; targetPath: string | null }): string => {
|
||||
if (tab.label) {
|
||||
return tab.label;
|
||||
}
|
||||
|
||||
if (tab.mode === 'file') {
|
||||
return getFileNameFromPath(tab.targetPath) || 'Files';
|
||||
}
|
||||
|
||||
return getModeLabel(tab.mode);
|
||||
};
|
||||
|
||||
const getTabIcon = (tab: { mode: 'diff' | 'file' | 'context' | 'plan' | 'chat'; targetPath: string | null }): React.ReactNode | undefined => {
|
||||
if (tab.mode === 'file') {
|
||||
return tab.targetPath
|
||||
? <FileTypeIcon filePath={tab.targetPath} className="h-3.5 w-3.5" />
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (tab.mode === 'diff') {
|
||||
return <RiArrowLeftRightLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
if (tab.mode === 'plan') {
|
||||
return <RiFileTextLine className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
if (tab.mode === 'context') {
|
||||
return <RiDonutChartFill className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
if (tab.mode === 'chat') {
|
||||
return <RiChat4Line className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getSessionIDFromDedupeKey = (dedupeKey: string | undefined): string | null => {
|
||||
if (!dedupeKey || !dedupeKey.startsWith('session:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionID = dedupeKey.slice('session:'.length).trim();
|
||||
return sessionID || null;
|
||||
};
|
||||
|
||||
const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null): string => {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const url = new URL(window.location.pathname, window.location.origin);
|
||||
url.searchParams.set('ocPanel', 'session-chat');
|
||||
url.searchParams.set('sessionId', sessionID);
|
||||
if (directory && directory.trim().length > 0) {
|
||||
url.searchParams.set('directory', directory);
|
||||
} else {
|
||||
url.searchParams.delete('directory');
|
||||
}
|
||||
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const truncateTabLabel = (value: string, maxChars: number): string => {
|
||||
if (value.length <= maxChars) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value.slice(0, maxChars - 3)}...`;
|
||||
};
|
||||
|
||||
export const ContextPanel: React.FC = () => {
|
||||
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
||||
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
|
||||
|
||||
const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined));
|
||||
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
|
||||
const closeContextPanelTab = useUIStore((state) => state.closeContextPanelTab);
|
||||
const toggleContextPanelExpanded = useUIStore((state) => state.toggleContextPanelExpanded);
|
||||
const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth);
|
||||
const setActiveContextPanelTab = useUIStore((state) => state.setActiveContextPanelTab);
|
||||
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
|
||||
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
|
||||
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
|
||||
const { themeMode, lightThemeId, darkThemeId, currentTheme } = useThemeSystem();
|
||||
|
||||
const isOpen = Boolean(panelState?.isOpen && panelState?.mode);
|
||||
const tabs = React.useMemo(() => panelState?.tabs ?? [], [panelState?.tabs]);
|
||||
const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? tabs[tabs.length - 1] ?? null;
|
||||
const isOpen = Boolean(panelState?.isOpen && activeTab);
|
||||
const isExpanded = Boolean(isOpen && panelState?.expanded);
|
||||
const width = clampWidth(panelState?.width ?? CONTEXT_PANEL_DEFAULT_WIDTH);
|
||||
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const startXRef = React.useRef(0);
|
||||
const startWidthRef = React.useRef(width);
|
||||
const resizingWidthRef = React.useRef<number | null>(null);
|
||||
const activeResizePointerIDRef = React.useRef<number | null>(null);
|
||||
const panelRef = React.useRef<HTMLElement | null>(null);
|
||||
const chatFrameRefs = React.useRef<Map<string, HTMLIFrameElement>>(new Map());
|
||||
const wasOpenRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -85,39 +199,73 @@ export const ContextPanel: React.FC = () => {
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [isOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isResizing || !directoryKey) {
|
||||
const applyLiveWidth = React.useCallback((nextWidth: number) => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const delta = startXRef.current - event.clientX;
|
||||
setContextPanelWidth(directoryKey, startWidthRef.current + delta);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
}, [directoryKey, isResizing, setContextPanelWidth]);
|
||||
panel.style.setProperty('--oc-context-panel-width', `${nextWidth}px`);
|
||||
}, []);
|
||||
|
||||
const handleResizeStart = React.useCallback((event: React.PointerEvent) => {
|
||||
if (!isOpen || isExpanded || !directoryKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// ignore; fallback listeners still handle drag
|
||||
}
|
||||
|
||||
activeResizePointerIDRef.current = event.pointerId;
|
||||
setIsResizing(true);
|
||||
startXRef.current = event.clientX;
|
||||
startWidthRef.current = width;
|
||||
resizingWidthRef.current = width;
|
||||
applyLiveWidth(width);
|
||||
event.preventDefault();
|
||||
}, [directoryKey, isExpanded, isOpen, width]);
|
||||
}, [applyLiveWidth, directoryKey, isExpanded, isOpen, width]);
|
||||
|
||||
const handleResizeMove = React.useCallback((event: React.PointerEvent) => {
|
||||
if (!isResizing || activeResizePointerIDRef.current !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = startXRef.current - event.clientX;
|
||||
const nextWidth = clampWidth(startWidthRef.current + delta);
|
||||
if (resizingWidthRef.current === nextWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
resizingWidthRef.current = nextWidth;
|
||||
applyLiveWidth(nextWidth);
|
||||
}, [applyLiveWidth, isResizing]);
|
||||
|
||||
const handleResizeEnd = React.useCallback((event: React.PointerEvent) => {
|
||||
if (activeResizePointerIDRef.current !== event.pointerId || !directoryKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const finalWidth = resizingWidthRef.current ?? width;
|
||||
setIsResizing(false);
|
||||
activeResizePointerIDRef.current = null;
|
||||
resizingWidthRef.current = null;
|
||||
setContextPanelWidth(directoryKey, finalWidth);
|
||||
}, [directoryKey, setContextPanelWidth, width]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isResizing) {
|
||||
resizingWidthRef.current = null;
|
||||
}
|
||||
}, [isResizing]);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
if (!directoryKey) {
|
||||
@@ -143,50 +291,190 @@ export const ContextPanel: React.FC = () => {
|
||||
handleClose();
|
||||
}, [handleClose]);
|
||||
|
||||
const activeFilePath = useFilesViewTabsStore((state) => (directoryKey ? (state.byRoot[directoryKey]?.selectedPath ?? null) : null));
|
||||
React.useEffect(() => {
|
||||
if (!directoryKey || !activeTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelTitle = panelState?.mode === 'diff' ? 'Diff' : panelState?.mode === 'file' ? 'File' : panelState?.mode === 'context' ? 'Context' : panelState?.mode === 'plan' ? 'Plan' : 'Panel';
|
||||
const effectivePath = panelState?.mode === 'file' ? (activeFilePath ?? panelState?.targetPath ?? null) : panelState?.mode === 'context' ? null : (panelState?.targetPath ?? null);
|
||||
const pathLabel = getRelativePathLabel(effectivePath, effectiveDirectory);
|
||||
if (activeTab.mode === 'file' && activeTab.targetPath) {
|
||||
setSelectedFilePath(directoryKey, activeTab.targetPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = panelState?.mode === 'diff'
|
||||
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate />
|
||||
: panelState?.mode === 'file'
|
||||
? <FilesView mode="editor-only" />
|
||||
: panelState?.mode === 'context'
|
||||
if (activeTab.mode === 'diff' && activeTab.targetPath) {
|
||||
setPendingDiffFile(activeTab.targetPath);
|
||||
}
|
||||
}, [activeTab, directoryKey, setPendingDiffFile, setSelectedFilePath]);
|
||||
|
||||
const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null;
|
||||
|
||||
const postThemeSyncToEmbeddedChat = React.useCallback(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
themeMode,
|
||||
lightThemeId,
|
||||
darkThemeId,
|
||||
currentTheme,
|
||||
};
|
||||
|
||||
for (const frame of chatFrameRefs.current.values()) {
|
||||
const frameWindow = frame.contentWindow;
|
||||
if (!frameWindow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const directThemeSync = (frameWindow as unknown as {
|
||||
__openchamberApplyThemeSync?: (themePayload: typeof payload) => void;
|
||||
}).__openchamberApplyThemeSync;
|
||||
|
||||
if (typeof directThemeSync === 'function') {
|
||||
try {
|
||||
directThemeSync(payload);
|
||||
continue;
|
||||
} catch {
|
||||
// fallback to postMessage below
|
||||
}
|
||||
}
|
||||
|
||||
frameWindow.postMessage(
|
||||
{
|
||||
type: 'openchamber:theme-sync',
|
||||
payload,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
}
|
||||
}, [currentTheme, darkThemeId, lightThemeId, themeMode]);
|
||||
|
||||
const postEmbeddedVisibilityToChats = React.useCallback(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [tabID, frame] of chatFrameRefs.current.entries()) {
|
||||
const frameWindow = frame.contentWindow;
|
||||
if (!frameWindow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = { visible: activeChatTabID === tabID };
|
||||
const directVisibilitySync = (frameWindow as unknown as {
|
||||
__openchamberSetEmbeddedVisibility?: (visibilityPayload: typeof payload) => void;
|
||||
}).__openchamberSetEmbeddedVisibility;
|
||||
|
||||
if (typeof directVisibilitySync === 'function') {
|
||||
try {
|
||||
directVisibilitySync(payload);
|
||||
continue;
|
||||
} catch {
|
||||
// fallback to postMessage below
|
||||
}
|
||||
}
|
||||
|
||||
frameWindow.postMessage(
|
||||
{
|
||||
type: 'openchamber:embedded-visibility',
|
||||
payload,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
}
|
||||
}, [activeChatTabID]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const hasAnyChatTab = tabs.some((tab) => tab.mode === 'chat');
|
||||
if (!hasAnyChatTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
postThemeSyncToEmbeddedChat();
|
||||
postEmbeddedVisibilityToChats();
|
||||
}, [darkThemeId, lightThemeId, postEmbeddedVisibilityToChats, postThemeSyncToEmbeddedChat, tabs, themeMode]);
|
||||
|
||||
const tabItems = React.useMemo(() => tabs.map((tab) => {
|
||||
const rawLabel = getTabLabel(tab);
|
||||
const label = truncateTabLabel(rawLabel, CONTEXT_TAB_LABEL_MAX_CHARS);
|
||||
const tabPathLabel = getRelativePathLabel(tab.targetPath, effectiveDirectory);
|
||||
return {
|
||||
id: tab.id,
|
||||
label,
|
||||
icon: getTabIcon(tab),
|
||||
title: tabPathLabel ? `${rawLabel}: ${tabPathLabel}` : rawLabel,
|
||||
closeLabel: `Close ${label} tab`,
|
||||
};
|
||||
}), [effectiveDirectory, tabs]);
|
||||
|
||||
const activeNonChatContent = activeTab?.mode === 'diff'
|
||||
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate showOpenInEditorAction />
|
||||
: activeTab?.mode === 'context'
|
||||
? <ContextPanelContent />
|
||||
: panelState?.mode === 'plan'
|
||||
: activeTab?.mode === 'plan'
|
||||
? <PlanView />
|
||||
: null;
|
||||
|
||||
const chatTabs = React.useMemo(
|
||||
() => tabs.filter((tab) => tab.mode === 'chat'),
|
||||
[tabs],
|
||||
);
|
||||
const hasFileTabs = React.useMemo(
|
||||
() => tabs.some((tab) => tab.mode === 'file'),
|
||||
[tabs],
|
||||
);
|
||||
|
||||
const isFileTabActive = activeTab?.mode === 'file';
|
||||
|
||||
const header = (
|
||||
<header className="flex h-10 items-center gap-2 border-b border-border/40 px-2.5">
|
||||
<div className="min-w-0 flex-1 truncate typography-ui-label text-foreground">
|
||||
<span>{panelTitle}</span>
|
||||
{pathLabel ? <span className="ml-2 text-muted-foreground">{pathLabel}</span> : null}
|
||||
<header className="flex h-8 items-stretch border-b border-border/40">
|
||||
<SortableTabsStrip
|
||||
items={tabItems}
|
||||
activeId={activeTab?.id ?? null}
|
||||
onSelect={(tabID) => {
|
||||
if (!directoryKey) {
|
||||
return;
|
||||
}
|
||||
setActiveContextPanelTab(directoryKey, tabID);
|
||||
}}
|
||||
onClose={(tabID) => {
|
||||
if (!directoryKey) {
|
||||
return;
|
||||
}
|
||||
closeContextPanelTab(directoryKey, tabID);
|
||||
}}
|
||||
onReorder={(activeTabID, overTabID) => {
|
||||
if (!directoryKey) {
|
||||
return;
|
||||
}
|
||||
reorderContextPanelTabs(directoryKey, activeTabID, overTabID);
|
||||
}}
|
||||
layoutMode="scrollable"
|
||||
/>
|
||||
<div className="flex items-center gap-1 px-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleExpanded}
|
||||
className="h-7 w-7 p-0"
|
||||
title={isExpanded ? 'Collapse panel' : 'Expand panel'}
|
||||
aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
|
||||
>
|
||||
{isExpanded ? <RiFullscreenExitLine className="h-3.5 w-3.5" /> : <RiFullscreenLine className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
className="h-7 w-7 p-0"
|
||||
title="Close panel"
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleExpanded}
|
||||
className="h-6 w-6 p-0"
|
||||
title={isExpanded ? 'Collapse panel' : 'Expand panel'}
|
||||
aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
|
||||
>
|
||||
{isExpanded ? <RiFullscreenExitLine className="h-3.5 w-3.5" /> : <RiFullscreenLine className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
className="h-6 w-6 p-0"
|
||||
title="Close panel"
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -196,13 +484,16 @@ export const ContextPanel: React.FC = () => {
|
||||
|
||||
const panelStyle: React.CSSProperties = isExpanded
|
||||
? {
|
||||
['--oc-context-panel-width' as string]: '100vw',
|
||||
['--oc-context-panel-width' as string]: '100%',
|
||||
width: '100%',
|
||||
minWidth: '100%',
|
||||
maxWidth: '100%',
|
||||
}
|
||||
: {
|
||||
width: `${width}px`,
|
||||
minWidth: `${width}px`,
|
||||
maxWidth: `${width}px`,
|
||||
['--oc-context-panel-width' as string]: `${width}px`,
|
||||
width: 'var(--oc-context-panel-width)',
|
||||
minWidth: 'var(--oc-context-panel-width)',
|
||||
maxWidth: 'var(--oc-context-panel-width)',
|
||||
['--oc-context-panel-width' as string]: `${isResizing ? (resizingWidthRef.current ?? width) : width}px`,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -228,13 +519,57 @@ export const ContextPanel: React.FC = () => {
|
||||
isResizing && 'bg-primary'
|
||||
)}
|
||||
onPointerDown={handleResizeStart}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeEnd}
|
||||
onPointerCancel={handleResizeEnd}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize context panel"
|
||||
/>
|
||||
)}
|
||||
{header}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">{content}</div>
|
||||
<div className={cn('relative min-h-0 flex-1 overflow-hidden', isResizing && 'pointer-events-none')}>
|
||||
{hasFileTabs ? (
|
||||
<div className={cn('absolute inset-0', isFileTabActive ? 'block' : 'hidden')}>
|
||||
<FilesView mode="editor-only" />
|
||||
</div>
|
||||
) : null}
|
||||
{chatTabs.map((tab) => {
|
||||
const sessionID = getSessionIDFromDedupeKey(tab.dedupeKey);
|
||||
if (!sessionID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const src = buildEmbeddedSessionChatURL(sessionID, directoryKey || null);
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
key={tab.id}
|
||||
ref={(node) => {
|
||||
if (!node) {
|
||||
chatFrameRefs.current.delete(tab.id);
|
||||
return;
|
||||
}
|
||||
chatFrameRefs.current.set(tab.id, node);
|
||||
}}
|
||||
src={src}
|
||||
title={`Session chat ${sessionID}`}
|
||||
className={cn(
|
||||
'absolute inset-0 h-full w-full border-0 bg-background',
|
||||
activeChatTabID === tab.id ? 'block' : 'hidden'
|
||||
)}
|
||||
onLoad={() => {
|
||||
postThemeSyncToEmbeddedChat();
|
||||
postEmbeddedVisibilityToChats();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{activeTab?.mode !== 'chat' && !isFileTabActive ? activeNonChatContent : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
|
||||
|
||||
import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiStackLine, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react';
|
||||
import { DiffIcon } from '@/components/icons/DiffIcon';
|
||||
@@ -121,6 +121,19 @@ const resolveTilde = (path: string, homeDir: string | null): string => {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const getActiveContextMode = (panelState: {
|
||||
isOpen: boolean;
|
||||
activeTabId: string | null;
|
||||
tabs: Array<{ id: string; mode: 'diff' | 'file' | 'context' | 'plan' | 'chat' }>;
|
||||
} | undefined): 'diff' | 'file' | 'context' | 'plan' | 'chat' | null => {
|
||||
if (!panelState?.isOpen || !Array.isArray(panelState.tabs) || panelState.tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeTab = panelState.tabs.find((tab) => tab.id === panelState.activeTabId) ?? panelState.tabs[panelState.tabs.length - 1];
|
||||
return activeTab?.mode ?? null;
|
||||
};
|
||||
|
||||
interface TabConfig {
|
||||
id: MainTab;
|
||||
label: string;
|
||||
@@ -504,6 +517,28 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return { id: activeProject.id, path: activeProject.path };
|
||||
}, [activeProject]);
|
||||
|
||||
const lastProjectActionsContextRef = React.useRef<{
|
||||
projectRef: { id: string; path: string };
|
||||
directory: string;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeProjectRef || !actionDirectory) {
|
||||
return;
|
||||
}
|
||||
lastProjectActionsContextRef.current = {
|
||||
projectRef: activeProjectRef,
|
||||
directory: actionDirectory,
|
||||
};
|
||||
}, [actionDirectory, activeProjectRef]);
|
||||
|
||||
const projectActionsContext = React.useMemo(() => {
|
||||
if (activeProjectRef && actionDirectory) {
|
||||
return { projectRef: activeProjectRef, directory: actionDirectory };
|
||||
}
|
||||
return lastProjectActionsContextRef.current;
|
||||
}, [actionDirectory, activeProjectRef]);
|
||||
|
||||
|
||||
const [planTabAvailable, setPlanTabAvailable] = React.useState(false);
|
||||
const showPlanTab = planTabAvailable;
|
||||
@@ -644,7 +679,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
if (panelState?.isOpen && panelState.mode === 'context') {
|
||||
if (getActiveContextMode(panelState) === 'context') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
@@ -658,7 +693,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return false;
|
||||
}
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
return Boolean(panelState?.isOpen && panelState.mode === 'context');
|
||||
return getActiveContextMode(panelState) === 'context';
|
||||
}, [contextPanelByDirectory, openDirectory]);
|
||||
|
||||
const handleOpenContextPlan = React.useCallback(() => {
|
||||
@@ -668,7 +703,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
}
|
||||
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
if (panelState?.isOpen && panelState.mode === 'plan') {
|
||||
if (getActiveContextMode(panelState) === 'plan') {
|
||||
closeContextPanel(directory);
|
||||
return;
|
||||
}
|
||||
@@ -682,7 +717,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return false;
|
||||
}
|
||||
const panelState = contextPanelByDirectory[directory];
|
||||
return Boolean(panelState?.isOpen && panelState.mode === 'plan');
|
||||
return getActiveContextMode(panelState) === 'plan';
|
||||
}, [contextPanelByDirectory, openDirectory]);
|
||||
|
||||
const desktopHeaderIconButtonClass = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors';
|
||||
@@ -818,6 +853,14 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return base;
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const servicesTabItems = React.useMemo(() => {
|
||||
return servicesTabs.map((tab) => ({
|
||||
id: tab.value,
|
||||
label: tab.label,
|
||||
icon: <tab.icon className="h-3.5 w-3.5" />,
|
||||
}));
|
||||
}, [servicesTabs]);
|
||||
|
||||
const quotaDisplayTabs = React.useMemo(() => {
|
||||
return [
|
||||
{ value: 'usage' as const, label: 'Used' },
|
||||
@@ -825,6 +868,17 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
];
|
||||
}, []);
|
||||
|
||||
const quotaDisplayTabItems = React.useMemo(() => {
|
||||
return quotaDisplayTabs.map((tab) => ({ id: tab.value, label: tab.label }));
|
||||
}, [quotaDisplayTabs]);
|
||||
|
||||
const mobileServicesTabItems = React.useMemo<SortableTabsStripItem[]>(() => {
|
||||
return [
|
||||
{ id: 'usage', label: 'Usage', icon: <RiTimerLine className="h-3.5 w-3.5" /> },
|
||||
{ id: 'mcp', label: 'MCP', icon: <RiCommandLine className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
|
||||
@@ -975,15 +1029,15 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
</Tooltip>
|
||||
|
||||
{activeProjectLabel && (
|
||||
<div className="mr-3 min-w-0 max-w-[16rem] truncate typography-ui-label font-medium text-foreground">
|
||||
<div className="mr-3 min-w-0 max-w-[16rem] truncate pl-2 typography-ui-header text-[calc(var(--text-ui-header)+0.125rem)] font-medium text-foreground">
|
||||
{activeProjectLabel}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeProjectRef && actionDirectory && (
|
||||
{projectActionsContext && (
|
||||
<ProjectActionsButton
|
||||
projectRef={activeProjectRef}
|
||||
directory={actionDirectory}
|
||||
projectRef={projectActionsContext.projectRef}
|
||||
directory={projectActionsContext.directory}
|
||||
className="mr-1"
|
||||
/>
|
||||
)}
|
||||
@@ -1075,18 +1129,25 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
align="end"
|
||||
className="w-[min(30rem,calc(100vw-2rem))] max-h-[75vh] overflow-y-auto bg-[var(--surface-elevated)] p-0"
|
||||
>
|
||||
<div className="sticky top-0 z-20 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-2">
|
||||
<AnimatedTabs<'instance' | 'usage' | 'mcp'>
|
||||
value={desktopServicesTab}
|
||||
onValueChange={(value) => {
|
||||
setDesktopServicesTab(value);
|
||||
if (value === 'usage' && quotaResults.length === 0) {
|
||||
fetchAllQuotas();
|
||||
}
|
||||
}}
|
||||
tabs={servicesTabs}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<div className="sticky top-0 z-20 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-px">
|
||||
<div className="h-9">
|
||||
<SortableTabsStrip
|
||||
items={servicesTabItems}
|
||||
activeId={desktopServicesTab}
|
||||
onSelect={(tabID) => {
|
||||
const value = tabID as 'instance' | 'usage' | 'mcp';
|
||||
setDesktopServicesTab(value);
|
||||
if (value === 'usage' && quotaResults.length === 0) {
|
||||
fetchAllQuotas();
|
||||
}
|
||||
}}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
activePillInsetClassName="gap-0.5 px-px py-0"
|
||||
activePillButtonClassName="h-8"
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDesktopApp && desktopServicesTab === 'instance' && (
|
||||
@@ -1113,13 +1174,17 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AnimatedTabs<'usage' | 'remaining'>
|
||||
value={quotaDisplayMode}
|
||||
onValueChange={handleDisplayModeChange}
|
||||
tabs={quotaDisplayTabs}
|
||||
size="sm"
|
||||
className="w-[10.5rem]"
|
||||
/>
|
||||
<div className="h-7 w-[10.5rem]">
|
||||
<SortableTabsStrip
|
||||
items={quotaDisplayTabItems}
|
||||
activeId={quotaDisplayMode}
|
||||
onSelect={(tabID) => handleDisplayModeChange(tabID as 'usage' | 'remaining')}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
activePillInsetClassName="gap-0.5 px-px py-0"
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
@@ -1526,10 +1591,10 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{activeProjectRef && actionDirectory && (
|
||||
{projectActionsContext && (
|
||||
<ProjectActionsButton
|
||||
projectRef={activeProjectRef}
|
||||
directory={actionDirectory}
|
||||
projectRef={projectActionsContext.projectRef}
|
||||
directory={projectActionsContext.directory}
|
||||
compact
|
||||
allowMobile
|
||||
className="h-9"
|
||||
@@ -1568,22 +1633,26 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
className="h-dvh w-[100vw] max-h-none rounded-none border-0 p-0 overflow-hidden"
|
||||
>
|
||||
<div className="flex h-full flex-col bg-[var(--surface-elevated)]">
|
||||
<div className="sticky top-0 z-20 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-2">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-3">
|
||||
<AnimatedTabs<'usage' | 'mcp'>
|
||||
value={mobileServicesTab}
|
||||
onValueChange={(value) => {
|
||||
setMobileServicesTab(value);
|
||||
if (value === 'usage' && quotaResults.length === 0) {
|
||||
fetchAllQuotas();
|
||||
}
|
||||
}}
|
||||
tabs={[
|
||||
{ value: 'usage', label: 'Usage', icon: RiTimerLine },
|
||||
{ value: 'mcp', label: 'MCP', icon: RiCommandLine },
|
||||
]}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<div className="sticky top-0 z-20 border-b border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-px">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-0">
|
||||
<div className="h-10 min-w-0 flex-1">
|
||||
<SortableTabsStrip
|
||||
items={mobileServicesTabItems}
|
||||
activeId={mobileServicesTab}
|
||||
onSelect={(tabID) => {
|
||||
const value = tabID as 'usage' | 'mcp';
|
||||
setMobileServicesTab(value);
|
||||
if (value === 'usage' && quotaResults.length === 0) {
|
||||
fetchAllQuotas();
|
||||
}
|
||||
}}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
activePillInsetClassName="gap-0.5 px-px py-0"
|
||||
activePillButtonClassName="h-8"
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMobileRateLimitsOpen(false)}
|
||||
|
||||
@@ -76,7 +76,9 @@ export const MainLayout: React.FC = () => {
|
||||
return false;
|
||||
}
|
||||
const panelState = state.contextPanelByDirectory[directoryKey];
|
||||
return Boolean(panelState?.isOpen && panelState?.mode);
|
||||
const tabs = panelState?.tabs ?? [];
|
||||
const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? tabs[tabs.length - 1];
|
||||
return Boolean(panelState?.isOpen && activeTab);
|
||||
});
|
||||
const setSidebarOpen = useUIStore((state) => state.setSidebarOpen);
|
||||
const rightSidebarAutoClosedRef = React.useRef(false);
|
||||
|
||||
@@ -185,6 +185,7 @@ export const ProjectActionsButton = ({
|
||||
const [runningByKey, setRunningByKey] = React.useState<Record<string, RunningEntry>>({});
|
||||
const tabByKeyRef = React.useRef<Record<string, string>>({});
|
||||
const urlWatchByRunKeyRef = React.useRef<Record<string, UrlWatchEntry>>({});
|
||||
const loadRequestIdRef = React.useRef(0);
|
||||
|
||||
const projectId = projectRef?.id ?? null;
|
||||
const projectPath = projectRef?.path ?? '';
|
||||
@@ -224,22 +225,38 @@ export const ProjectActionsButton = ({
|
||||
|
||||
const loadActions = React.useCallback(async () => {
|
||||
if (!stableProjectRef) {
|
||||
setActions([]);
|
||||
setSelectedActionId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = loadRequestIdRef.current + 1;
|
||||
loadRequestIdRef.current = requestId;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const state = await getProjectActionsState(stableProjectRef);
|
||||
if (loadRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
const filtered = state.actions;
|
||||
setActions(filtered);
|
||||
setSelectedActionId(filtered[0]?.id ?? null);
|
||||
setSelectedActionId((current) => {
|
||||
if (filtered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (current && filtered.some((entry) => entry.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return filtered[0]?.id ?? null;
|
||||
});
|
||||
} catch {
|
||||
setActions([]);
|
||||
setSelectedActionId(null);
|
||||
if (loadRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
// Keep last known actions while next project loads or transient fetch fails.
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (loadRequestIdRef.current === requestId) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [stableProjectRef]);
|
||||
|
||||
@@ -616,12 +633,10 @@ export const ProjectActionsButton = ({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-md p-2',
|
||||
'typography-ui-label font-medium text-muted-foreground hover:bg-interactive-hover hover:text-foreground transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
'disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
aria-label="Add action"
|
||||
@@ -635,12 +650,10 @@ export const ProjectActionsButton = ({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
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',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
'disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
onClick={openProjectActionsSettings}
|
||||
@@ -673,7 +686,7 @@ export const ProjectActionsButton = ({
|
||||
'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-md p-2',
|
||||
'typography-ui-label font-medium text-muted-foreground hover:bg-interactive-hover hover:text-foreground transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
'disabled:opacity-50',
|
||||
'disabled:cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
|
||||
@@ -738,7 +751,7 @@ export const ProjectActionsButton = ({
|
||||
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:opacity-50'
|
||||
'transition-colors disabled:cursor-not-allowed'
|
||||
)}
|
||||
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
|
||||
>
|
||||
|
||||
@@ -16,33 +16,22 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const startXRef = React.useRef(0);
|
||||
const startWidthRef = React.useRef(rightSidebarWidth || 420);
|
||||
const resizingWidthRef = React.useRef<number | null>(null);
|
||||
const activeResizePointerIDRef = React.useRef<number | null>(null);
|
||||
const sidebarRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isResizing) {
|
||||
const clampRightSidebarWidth = React.useCallback((value: number) => {
|
||||
return Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, value));
|
||||
}, []);
|
||||
|
||||
const applyLiveWidth = React.useCallback((nextWidth: number) => {
|
||||
const sidebar = sidebarRef.current;
|
||||
if (!sidebar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const delta = startXRef.current - event.clientX;
|
||||
const nextWidth = Math.min(
|
||||
RIGHT_SIDEBAR_MAX_WIDTH,
|
||||
Math.max(RIGHT_SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
|
||||
);
|
||||
setRightSidebarWidth(nextWidth);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
}, [isResizing, setRightSidebarWidth]);
|
||||
sidebar.style.setProperty('--oc-right-sidebar-width', `${nextWidth}px`);
|
||||
}, []);
|
||||
|
||||
const appliedWidth = isOpen
|
||||
? Math.min(RIGHT_SIDEBAR_MAX_WIDTH, Math.max(RIGHT_SIDEBAR_MIN_WIDTH, rightSidebarWidth || 420))
|
||||
@@ -52,23 +41,75 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
activeResizePointerIDRef.current = event.pointerId;
|
||||
setIsResizing(true);
|
||||
startXRef.current = event.clientX;
|
||||
startWidthRef.current = appliedWidth;
|
||||
resizingWidthRef.current = appliedWidth;
|
||||
applyLiveWidth(appliedWidth);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent) => {
|
||||
if (!isResizing || activeResizePointerIDRef.current !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = startXRef.current - event.clientX;
|
||||
const nextWidth = clampRightSidebarWidth(startWidthRef.current + delta);
|
||||
if (resizingWidthRef.current === nextWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
resizingWidthRef.current = nextWidth;
|
||||
applyLiveWidth(nextWidth);
|
||||
};
|
||||
|
||||
const handlePointerEnd = (event: React.PointerEvent) => {
|
||||
if (activeResizePointerIDRef.current !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const finalWidth = clampRightSidebarWidth(resizingWidthRef.current ?? appliedWidth);
|
||||
activeResizePointerIDRef.current = null;
|
||||
resizingWidthRef.current = null;
|
||||
setIsResizing(false);
|
||||
setRightSidebarWidth(finalWidth);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isResizing) {
|
||||
resizingWidthRef.current = null;
|
||||
activeResizePointerIDRef.current = null;
|
||||
}
|
||||
}, [isResizing]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className={cn(
|
||||
'relative flex h-full overflow-hidden border-l border-border/40 bg-sidebar/50',
|
||||
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
|
||||
!isOpen && 'border-l-0'
|
||||
)}
|
||||
style={{
|
||||
width: `${appliedWidth}px`,
|
||||
minWidth: `${appliedWidth}px`,
|
||||
maxWidth: `${appliedWidth}px`,
|
||||
width: 'var(--oc-right-sidebar-width)',
|
||||
minWidth: 'var(--oc-right-sidebar-width)',
|
||||
maxWidth: 'var(--oc-right-sidebar-width)',
|
||||
['--oc-right-sidebar-width' as string]: `${isResizing ? (resizingWidthRef.current ?? appliedWidth) : appliedWidth}px`,
|
||||
overflowX: 'clip',
|
||||
}}
|
||||
aria-hidden={!isOpen || appliedWidth === 0}
|
||||
@@ -80,6 +121,9 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
isResizing && 'bg-primary'
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize right panel"
|
||||
@@ -88,6 +132,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex h-full min-h-0 w-full flex-col transition-opacity duration-300 ease-in-out',
|
||||
isResizing && 'pointer-events-none',
|
||||
!isOpen && 'pointer-events-none select-none opacity-0'
|
||||
)}
|
||||
aria-hidden={!isOpen}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { RiFolder3Line, RiGitBranchLine } from '@remixicon/react';
|
||||
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { GitView } from '@/components/views';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { SidebarFilesTree } from './SidebarFilesTree';
|
||||
@@ -12,19 +12,29 @@ export const RightSidebarTabs: React.FC = () => {
|
||||
const rightSidebarTab = useUIStore((state) => state.rightSidebarTab);
|
||||
const setRightSidebarTab = useUIStore((state) => state.setRightSidebarTab);
|
||||
|
||||
const tabItems = React.useMemo(() => [
|
||||
{
|
||||
id: 'git',
|
||||
label: 'Git',
|
||||
icon: <RiGitBranchLine className="h-3.5 w-3.5" />,
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'Files',
|
||||
icon: <RiFolder3Line className="h-3.5 w-3.5" />,
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-transparent">
|
||||
<div className="border-b border-border/40 bg-transparent px-3 py-1.5">
|
||||
<AnimatedTabs<RightTab>
|
||||
value={rightSidebarTab}
|
||||
onValueChange={setRightSidebarTab}
|
||||
size="sm"
|
||||
collapseLabelsOnSmall
|
||||
collapseLabelsOnNarrow
|
||||
tabs={[
|
||||
{ value: 'git', label: 'Git', icon: RiGitBranchLine },
|
||||
{ value: 'files', label: 'Files', icon: RiFolder3Line },
|
||||
]}
|
||||
<div className="h-9 bg-transparent pt-1 px-2">
|
||||
<SortableTabsStrip
|
||||
items={tabItems}
|
||||
activeId={rightSidebarTab}
|
||||
onSelect={(tabID) => setRightSidebarTab(tabID as RightTab)}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,33 +18,22 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const startXRef = React.useRef(0);
|
||||
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
|
||||
const resizingWidthRef = React.useRef<number | null>(null);
|
||||
const activeResizePointerIDRef = React.useRef<number | null>(null);
|
||||
const sidebarRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile || !isResizing) {
|
||||
const clampSidebarWidth = React.useCallback((value: number) => {
|
||||
return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, value));
|
||||
}, []);
|
||||
|
||||
const applyLiveWidth = React.useCallback((nextWidth: number) => {
|
||||
const sidebar = sidebarRef.current;
|
||||
if (!sidebar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const delta = event.clientX - startXRef.current;
|
||||
const nextWidth = Math.min(
|
||||
SIDEBAR_MAX_WIDTH,
|
||||
Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
|
||||
);
|
||||
setSidebarWidth(nextWidth);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
}, [isMobile, isResizing, setSidebarWidth]);
|
||||
sidebar.style.setProperty('--oc-left-sidebar-width', `${nextWidth}px`);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile && isResizing) {
|
||||
@@ -52,6 +41,13 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
}
|
||||
}, [isMobile, isResizing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isResizing) {
|
||||
resizingWidthRef.current = null;
|
||||
activeResizePointerIDRef.current = null;
|
||||
}
|
||||
}, [isResizing]);
|
||||
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
@@ -65,14 +61,58 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
activeResizePointerIDRef.current = event.pointerId;
|
||||
setIsResizing(true);
|
||||
startXRef.current = event.clientX;
|
||||
startWidthRef.current = appliedWidth;
|
||||
resizingWidthRef.current = appliedWidth;
|
||||
applyLiveWidth(appliedWidth);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent) => {
|
||||
if (isMobile || !isResizing || activeResizePointerIDRef.current !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = event.clientX - startXRef.current;
|
||||
const nextWidth = clampSidebarWidth(startWidthRef.current + delta);
|
||||
if (resizingWidthRef.current === nextWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
resizingWidthRef.current = nextWidth;
|
||||
applyLiveWidth(nextWidth);
|
||||
};
|
||||
|
||||
const handlePointerEnd = (event: React.PointerEvent) => {
|
||||
if (activeResizePointerIDRef.current !== event.pointerId || isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const finalWidth = clampSidebarWidth(resizingWidthRef.current ?? appliedWidth);
|
||||
activeResizePointerIDRef.current = null;
|
||||
resizingWidthRef.current = null;
|
||||
setIsResizing(false);
|
||||
setSidebarWidth(finalWidth);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className={cn(
|
||||
'relative flex h-full overflow-hidden border-r border-border/40',
|
||||
'bg-sidebar/50',
|
||||
@@ -80,9 +120,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
!isOpen && 'border-r-0'
|
||||
)}
|
||||
style={{
|
||||
width: `${appliedWidth}px`,
|
||||
minWidth: `${appliedWidth}px`,
|
||||
maxWidth: `${appliedWidth}px`,
|
||||
width: 'var(--oc-left-sidebar-width)',
|
||||
minWidth: 'var(--oc-left-sidebar-width)',
|
||||
maxWidth: 'var(--oc-left-sidebar-width)',
|
||||
['--oc-left-sidebar-width' as string]: `${isResizing ? (resizingWidthRef.current ?? appliedWidth) : appliedWidth}px`,
|
||||
overflowX: 'clip',
|
||||
}}
|
||||
aria-hidden={!isOpen || appliedWidth === 0}
|
||||
@@ -94,6 +135,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
isResizing && 'bg-primary'
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize left panel"
|
||||
@@ -102,9 +146,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex h-full flex-col transition-opacity duration-300 ease-in-out',
|
||||
isResizing && 'pointer-events-none',
|
||||
!isOpen && 'pointer-events-none select-none opacity-0'
|
||||
)}
|
||||
style={{ width: `${appliedWidth}px`, overflowX: 'hidden' }}
|
||||
style={{ width: 'var(--oc-left-sidebar-width)', overflowX: 'hidden' }}
|
||||
aria-hidden={!isOpen}
|
||||
>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
|
||||
@@ -105,9 +105,9 @@ const VisualSectionContent: React.FC = () => {
|
||||
return <OpenChamberVisualSettings visibleSettings={['theme', 'fontSize', 'terminalFontSize', 'spacing', 'cornerRadius', 'inputBarOffset', 'terminalQuickKeys', 'navRail']} />;
|
||||
};
|
||||
|
||||
// Chat section: Default Tool Output, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft
|
||||
// Chat section: Default Tool Output, User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft
|
||||
const ChatSectionContent: React.FC = () => {
|
||||
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'mermaidRendering', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'reasoning', 'textJustificationActivity', 'queueMode', 'persistDraft']} />;
|
||||
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'mermaidRendering', 'userMessageRendering', 'stickyUserHeader', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'reasoning', 'textJustificationActivity', 'queueMode', 'persistDraft']} />;
|
||||
};
|
||||
|
||||
// Sessions section: Default model & agent, Session retention, Memory limits
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import {
|
||||
setDirectoryShowHidden,
|
||||
useDirectoryShowHidden,
|
||||
@@ -96,7 +97,24 @@ const MERMAID_RENDERING_OPTIONS: Option<'svg' | 'ascii'>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export type VisibleSetting = 'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'mermaidRendering' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft';
|
||||
const USER_MESSAGE_RENDERING_OPTIONS: Option<'markdown' | 'plain'>[] = [
|
||||
{
|
||||
id: 'markdown',
|
||||
label: 'Markdown',
|
||||
description: 'Render user text with markdown formatting.',
|
||||
},
|
||||
{
|
||||
id: 'plain',
|
||||
label: 'Plain text',
|
||||
description: 'Render user text with preserved whitespace and links.',
|
||||
},
|
||||
];
|
||||
|
||||
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
|
||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||
};
|
||||
|
||||
export type VisibleSetting = 'theme' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'terminalQuickKeys' | 'persistDraft';
|
||||
|
||||
interface OpenChamberVisualSettingsProps {
|
||||
/** Which settings to show. If undefined, shows all. */
|
||||
@@ -114,6 +132,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const setToolCallExpansion = useUIStore(state => state.setToolCallExpansion);
|
||||
const mermaidRenderingMode = useUIStore(state => state.mermaidRenderingMode);
|
||||
const setMermaidRenderingMode = useUIStore(state => state.setMermaidRenderingMode);
|
||||
const userMessageRenderingMode = useUIStore(state => state.userMessageRenderingMode);
|
||||
const setUserMessageRenderingMode = useUIStore(state => state.setUserMessageRenderingMode);
|
||||
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
|
||||
const setStickyUserHeader = useUIStore(state => state.setStickyUserHeader);
|
||||
const fontSize = useUIStore(state => state.fontSize);
|
||||
const setFontSize = useUIStore(state => state.setFontSize);
|
||||
const terminalFontSize = useUIStore(state => state.terminalFontSize);
|
||||
@@ -151,6 +173,16 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
} = useThemeSystem();
|
||||
|
||||
const [themesReloading, setThemesReloading] = React.useState(false);
|
||||
const handleUserMessageRenderingModeChange = React.useCallback((mode: 'markdown' | 'plain') => {
|
||||
setUserMessageRenderingMode(mode);
|
||||
void updateDesktopSettings({ userMessageRenderingMode: mode });
|
||||
}, [setUserMessageRenderingMode]);
|
||||
|
||||
const handleStickyUserHeaderChange = React.useCallback((enabled: boolean) => {
|
||||
setStickyUserHeader(enabled);
|
||||
void updateDesktopSettings({ stickyUserHeader: enabled });
|
||||
}, [setStickyUserHeader]);
|
||||
|
||||
const lightThemes = React.useMemo(
|
||||
() => availableThemes
|
||||
.filter((theme) => theme.metadata.variant === 'light')
|
||||
@@ -185,11 +217,14 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
return visibleSettings.includes(setting);
|
||||
};
|
||||
|
||||
const hasAppearanceSettings = shouldShow('theme') && !isVSCodeRuntime();
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const hasAppearanceSettings = shouldShow('theme') && !isVSCode;
|
||||
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset');
|
||||
const hasNavigationSettings = (!isMobile && shouldShow('navRail')) || (shouldShow('terminalQuickKeys') && !isMobile);
|
||||
const hasBehaviorSettings = shouldShow('toolOutput')
|
||||
|| shouldShow('mermaidRendering')
|
||||
|| shouldShow('userMessageRendering')
|
||||
|| shouldShow('stickyUserHeader')
|
||||
|| shouldShow('diffLayout')
|
||||
|| (shouldShow('mobileStatusBar') && isMobile)
|
||||
|| shouldShow('dotfiles')
|
||||
@@ -573,116 +608,179 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldShow('mermaidRendering') && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Mermaid Rendering</h4>
|
||||
<div role="radiogroup" aria-label="Mermaid rendering mode" className="mt-1 space-y-0">
|
||||
{MERMAID_RENDERING_OPTIONS.map((option) => {
|
||||
const selected = mermaidRenderingMode === option.id;
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onClick={() => setMermaidRenderingMode(option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setMermaidRenderingMode(option.id);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 py-0.5 text-left"
|
||||
>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => setMermaidRenderingMode(option.id)}
|
||||
ariaLabel={`Mermaid rendering: ${option.label}`}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
{(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || (shouldShow('diffLayout') && !isVSCode)) && (
|
||||
<div className="grid grid-cols-1 gap-y-2 md:grid-cols-[minmax(0,16rem)_minmax(0,16rem)] md:justify-start md:gap-x-2">
|
||||
{shouldShow('userMessageRendering') && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">User Message Rendering</h4>
|
||||
<div role="radiogroup" aria-label="User message rendering mode" className="mt-1 space-y-0">
|
||||
{USER_MESSAGE_RENDERING_OPTIONS.map((option) => {
|
||||
const selected = normalizeUserMessageRenderingMode(userMessageRenderingMode) === option.id;
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onClick={() => handleUserMessageRenderingModeChange(option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleUserMessageRenderingModeChange(option.id);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 py-0.5 text-left"
|
||||
>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => handleUserMessageRenderingModeChange(option.id)}
|
||||
ariaLabel={`User message rendering: ${option.label}`}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldShow('mermaidRendering') && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Mermaid Rendering</h4>
|
||||
<div role="radiogroup" aria-label="Mermaid rendering mode" className="mt-1 space-y-0">
|
||||
{MERMAID_RENDERING_OPTIONS.map((option) => {
|
||||
const selected = mermaidRenderingMode === option.id;
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onClick={() => setMermaidRenderingMode(option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setMermaidRenderingMode(option.id);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 py-0.5 text-left"
|
||||
>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => setMermaidRenderingMode(option.id)}
|
||||
ariaLabel={`Mermaid rendering: ${option.label}`}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldShow('diffLayout') && !isVSCode && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Diff Layout</h4>
|
||||
<div role="radiogroup" aria-label="Diff layout" className="mt-1 space-y-0">
|
||||
{DIFF_LAYOUT_OPTIONS.map((option) => {
|
||||
const selected = diffLayoutPreference === option.id;
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onClick={() => setDiffLayoutPreference(option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setDiffLayoutPreference(option.id);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 py-0.5 text-left"
|
||||
>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => setDiffLayoutPreference(option.id)}
|
||||
ariaLabel={`Diff layout: ${option.label}`}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldShow('diffLayout') && !isVSCode && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Diff View Mode</h4>
|
||||
<div role="radiogroup" aria-label="Diff view mode" className="mt-1 space-y-0">
|
||||
{DIFF_VIEW_MODE_OPTIONS.map((option) => {
|
||||
const selected = diffViewMode === option.id;
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onClick={() => setDiffViewMode(option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setDiffViewMode(option.id);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 py-0.5 text-left"
|
||||
>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => setDiffViewMode(option.id)}
|
||||
ariaLabel={`Diff view mode: ${option.label}`}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShow('diffLayout') && !isVSCodeRuntime() && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Diff Layout</h4>
|
||||
<div role="radiogroup" aria-label="Diff layout" className="mt-1 space-y-0">
|
||||
{DIFF_LAYOUT_OPTIONS.map((option) => {
|
||||
const selected = diffLayoutPreference === option.id;
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onClick={() => setDiffLayoutPreference(option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setDiffLayoutPreference(option.id);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 py-0.5 text-left"
|
||||
>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => setDiffLayoutPreference(option.id)}
|
||||
ariaLabel={`Diff layout: ${option.label}`}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{shouldShow('diffLayout') && !isVSCodeRuntime() && (
|
||||
<section className="p-2">
|
||||
<h4 className="typography-ui-header font-medium text-foreground">Diff View Mode</h4>
|
||||
<div role="radiogroup" aria-label="Diff view mode" className="mt-1 space-y-0">
|
||||
{DIFF_VIEW_MODE_OPTIONS.map((option) => {
|
||||
const selected = diffViewMode === option.id;
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onClick={() => setDiffViewMode(option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setDiffViewMode(option.id);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 py-0.5 text-left"
|
||||
>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => setDiffViewMode(option.id)}
|
||||
ariaLabel={`Diff view mode: ${option.label}`}
|
||||
/>
|
||||
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{((shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && (
|
||||
{(shouldShow('stickyUserHeader') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && (
|
||||
<section className="p-2 space-y-0.5">
|
||||
{shouldShow('stickyUserHeader') && (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={stickyUserHeader}
|
||||
onClick={() => handleStickyUserHeaderChange(!stickyUserHeader)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleStickyUserHeaderChange(!stickyUserHeader);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={stickyUserHeader}
|
||||
onChange={handleStickyUserHeaderChange}
|
||||
ariaLabel="Sticky user header"
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">Sticky User Header</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShow('mobileStatusBar') && isMobile && (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -152,15 +152,20 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<div className="mb-4">
|
||||
{showModeTabs && (
|
||||
<div className="mb-4">
|
||||
<AnimatedTabs
|
||||
tabs={[
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'external', label: 'External' },
|
||||
]}
|
||||
value={mode}
|
||||
onValueChange={onModeChange}
|
||||
animate={false}
|
||||
/>
|
||||
<div className="h-10">
|
||||
<SortableTabsStrip
|
||||
items={[
|
||||
{ id: 'manual', label: 'Manual' },
|
||||
{ id: 'external', label: 'External' },
|
||||
]}
|
||||
activeId={mode}
|
||||
onSelect={(next) => onModeChange(next as 'manual' | 'external')}
|
||||
layoutMode="fit"
|
||||
variant="animated"
|
||||
animateActivePill={false}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="typography-ui-header font-semibold text-foreground px-1">Skills Catalog</h2>
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiChat4Line,
|
||||
RiCheckboxBlankLine,
|
||||
RiCheckboxLine,
|
||||
RiCheckLine,
|
||||
@@ -859,6 +860,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const renameProject = useProjectsStore((state) => state.renameProject);
|
||||
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
const deviceInfo = useDeviceInfo();
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
|
||||
@@ -2552,6 +2554,27 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
</>
|
||||
);
|
||||
})() : null}
|
||||
<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>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive [&>svg]:mr-1"
|
||||
@@ -2604,6 +2627,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
addSessionToFolder,
|
||||
removeSessionFromFolder,
|
||||
createFolderAndStartRename,
|
||||
openContextPanelTab,
|
||||
notifyOnSubtasks,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type AnimatedTabOption<T extends string> = {
|
||||
value: T;
|
||||
label: string;
|
||||
icon?: React.ComponentType<{ className?: string; size?: number | string }>;
|
||||
};
|
||||
|
||||
interface AnimatedTabsProps<T extends string> {
|
||||
tabs: AnimatedTabOption<T>[];
|
||||
value: T;
|
||||
onValueChange: (value: T) => void;
|
||||
className?: string;
|
||||
isInteractive?: boolean;
|
||||
animate?: boolean;
|
||||
collapseLabelsOnSmall?: boolean;
|
||||
collapseLabelsOnNarrow?: boolean;
|
||||
size?: 'default' | 'sm';
|
||||
}
|
||||
|
||||
export function AnimatedTabs<T extends string>({
|
||||
tabs,
|
||||
value,
|
||||
onValueChange,
|
||||
className,
|
||||
isInteractive = true,
|
||||
animate = true,
|
||||
collapseLabelsOnSmall = false,
|
||||
collapseLabelsOnNarrow = false,
|
||||
size = 'default',
|
||||
}: AnimatedTabsProps<T>) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const indicatorRef = React.useRef<HTMLDivElement>(null);
|
||||
const tabRefs = React.useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const [isReadyToAnimate, setIsReadyToAnimate] = React.useState(false);
|
||||
|
||||
const updateIndicator = React.useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const indicator = indicatorRef.current;
|
||||
const activeTab = tabRefs.current.get(value);
|
||||
|
||||
if (!container || !indicator || !activeTab) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const tabRect = activeTab.getBoundingClientRect();
|
||||
|
||||
const left = tabRect.left - containerRect.left;
|
||||
const width = tabRect.width;
|
||||
|
||||
indicator.style.transform = `translateX(${left}px)`;
|
||||
indicator.style.width = `${width}px`;
|
||||
}, [value]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
updateIndicator();
|
||||
if (!isReadyToAnimate) {
|
||||
setIsReadyToAnimate(true);
|
||||
}
|
||||
}, [isReadyToAnimate, updateIndicator, value, tabs.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver(() => updateIndicator());
|
||||
observer.observe(container);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [updateIndicator]);
|
||||
|
||||
const setTabRef = React.useCallback((el: HTMLButtonElement | null, tabValue: string) => {
|
||||
if (el) {
|
||||
tabRefs.current.set(tabValue, el);
|
||||
} else {
|
||||
tabRefs.current.delete(tabValue);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn('relative w-full', collapseLabelsOnNarrow && '@container/animated-tabs', className)}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative flex items-center overflow-hidden bg-[var(--surface-muted)]/50',
|
||||
size === 'sm'
|
||||
? 'h-8 rounded-lg py-0.5 px-px gap-0.5'
|
||||
: 'h-10 rounded-lg py-0.5 px-px gap-0.5'
|
||||
)}
|
||||
>
|
||||
{/* Sliding indicator */}
|
||||
<div
|
||||
ref={indicatorRef}
|
||||
className={cn(
|
||||
'absolute top-0.5 bottom-0.5 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] shadow-none',
|
||||
animate && isReadyToAnimate ? 'transition-[transform,width] duration-200 ease-out' : null
|
||||
)}
|
||||
style={{ width: 0, transform: 'translateX(0)' }}
|
||||
/>
|
||||
|
||||
{tabs.map((tab) => {
|
||||
const isActive = value === tab.value;
|
||||
const Icon = tab.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
ref={(el) => setTabRef(el, tab.value)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isInteractive) return;
|
||||
onValueChange(tab.value);
|
||||
}}
|
||||
className={cn(
|
||||
'animated-tabs__button relative z-10 flex flex-1 items-center justify-center font-medium transition-colors duration-150',
|
||||
size === 'sm' ? 'h-6 rounded-lg px-2.5 text-sm' : 'h-7 rounded-lg px-3 text-sm',
|
||||
collapseLabelsOnSmall ? 'gap-0 sm:gap-1.5' : 'gap-1.5',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-background'
|
||||
)}
|
||||
aria-pressed={isActive}
|
||||
aria-label={tab.label}
|
||||
aria-disabled={!isInteractive}
|
||||
tabIndex={isInteractive ? 0 : -1}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className={cn(
|
||||
size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4',
|
||||
isActive ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<span className={cn('animated-tabs__label truncate', collapseLabelsOnSmall ? 'hidden sm:inline' : null)}>
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type Modifier,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
horizontalListSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS as DndCSS } from '@dnd-kit/utilities';
|
||||
import { RiCloseLine } from '@remixicon/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
export type SortableTabsStripItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
title?: string;
|
||||
closable?: boolean;
|
||||
closeLabel?: string;
|
||||
};
|
||||
|
||||
type SortableTabsStripProps = {
|
||||
items: SortableTabsStripItem[];
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onClose?: (id: string) => void;
|
||||
onReorder?: (activeId: string, overId: string) => void;
|
||||
layoutMode?: 'scrollable' | 'fit';
|
||||
variant?: 'default' | 'active-pill' | 'animated';
|
||||
activePillInsetClassName?: string;
|
||||
activePillButtonClassName?: string;
|
||||
inactiveTabsIconOnly?: boolean;
|
||||
animateActivePill?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const restrictToXAxis: Modifier = ({ transform }) => ({
|
||||
...transform,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const SortableTabWrapper: React.FC<{ id: string; children: React.ReactNode; className?: string }> = ({ id, children, className }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
data-sortable-tab-id={id}
|
||||
style={{
|
||||
transform: DndCSS.Transform.toString(transform),
|
||||
transition,
|
||||
}}
|
||||
className={cn('h-full rounded-md', className, isDragging && 'opacity-50')}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StaticTabWrapper: React.FC<{ id: string; children: React.ReactNode; className?: string }> = ({ id, children, className }) => (
|
||||
<div className={cn('h-full', className)} data-sortable-tab-id={id}>{children}</div>
|
||||
);
|
||||
|
||||
export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
items,
|
||||
activeId,
|
||||
onSelect,
|
||||
onClose,
|
||||
onReorder,
|
||||
layoutMode = 'scrollable',
|
||||
variant = 'default',
|
||||
activePillInsetClassName,
|
||||
activePillButtonClassName,
|
||||
inactiveTabsIconOnly = false,
|
||||
animateActivePill,
|
||||
className,
|
||||
}) => {
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const [overflow, setOverflow] = React.useState<{ left: boolean; right: boolean }>({ left: false, right: false });
|
||||
const itemIDs = React.useMemo(() => items.map((item) => item.id), [items]);
|
||||
const isScrollable = layoutMode === 'scrollable';
|
||||
const isActivePillVariant = variant === 'active-pill';
|
||||
const isAnimatedVariant = variant === 'animated';
|
||||
const usesActivePillIndicator = isActivePillVariant || isAnimatedVariant;
|
||||
const useIntrinsicPillSizing = isActivePillVariant && isScrollable;
|
||||
const showPillTrackBackground = isAnimatedVariant;
|
||||
const shouldAnimateActivePill = animateActivePill ?? isAnimatedVariant;
|
||||
const reorderEnabled = typeof onReorder === 'function';
|
||||
const Wrapper = reorderEnabled ? SortableTabWrapper : StaticTabWrapper;
|
||||
const tabRefs = React.useRef<Map<string, HTMLButtonElement>>(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 } }),
|
||||
);
|
||||
|
||||
const isSamePillRect = React.useCallback((
|
||||
a: { left: number; top: number; width: number; height: number } | null,
|
||||
b: { left: number; top: number; width: number; height: number } | null,
|
||||
) => {
|
||||
if (!a || !b) {
|
||||
return a === b;
|
||||
}
|
||||
return Math.abs(a.left - b.left) < 0.5
|
||||
&& Math.abs(a.top - b.top) < 0.5
|
||||
&& Math.abs(a.width - b.width) < 0.5
|
||||
&& Math.abs(a.height - b.height) < 0.5;
|
||||
}, []);
|
||||
|
||||
const setTabRef = React.useCallback((id: string, element: HTMLButtonElement | null) => {
|
||||
if (element) {
|
||||
tabRefs.current.set(id, element);
|
||||
return;
|
||||
}
|
||||
tabRefs.current.delete(id);
|
||||
}, []);
|
||||
|
||||
const updateActivePillRect = React.useCallback(() => {
|
||||
if (!usesActivePillIndicator || !activeId) {
|
||||
setPillRect((prev) => (prev === null ? prev : null));
|
||||
return;
|
||||
}
|
||||
|
||||
const container = scrollRef.current;
|
||||
const activeTab = tabRefs.current.get(activeId);
|
||||
if (!container || !activeTab) {
|
||||
setPillRect((prev) => (prev === null ? prev : null));
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const tabRect = activeTab.getBoundingClientRect();
|
||||
|
||||
const nextRect = {
|
||||
left: tabRect.left - containerRect.left + container.scrollLeft,
|
||||
top: tabRect.top - containerRect.top + container.scrollTop,
|
||||
width: tabRect.width,
|
||||
height: tabRect.height,
|
||||
};
|
||||
|
||||
setPillRect((prev) => (isSamePillRect(prev, nextRect) ? prev : nextRect));
|
||||
}, [activeId, isSamePillRect, usesActivePillIndicator]);
|
||||
|
||||
const updateOverflow = React.useCallback(() => {
|
||||
if (!isScrollable) {
|
||||
setOverflow({ left: false, right: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const element = scrollRef.current;
|
||||
if (!element) {
|
||||
setOverflow({ left: false, right: false });
|
||||
return;
|
||||
}
|
||||
|
||||
setOverflow({
|
||||
left: element.scrollLeft > 2,
|
||||
right: element.scrollLeft + element.clientWidth < element.scrollWidth - 2,
|
||||
});
|
||||
}, [isScrollable]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isScrollable) {
|
||||
setOverflow({ left: false, right: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const element = scrollRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateOverflow();
|
||||
element.addEventListener('scroll', updateOverflow, { passive: true });
|
||||
const observer = new ResizeObserver(updateOverflow);
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('scroll', updateOverflow);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [isScrollable, items.length, updateOverflow]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!usesActivePillIndicator) {
|
||||
setPillRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
updateActivePillRect();
|
||||
|
||||
const element = scrollRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateActivePillRect);
|
||||
observer.observe(element);
|
||||
|
||||
if (activeId) {
|
||||
const activeTab = tabRefs.current.get(activeId);
|
||||
if (activeTab) {
|
||||
observer.observe(activeTab);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [activeId, items.length, updateActivePillRect, usesActivePillIndicator]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
updateActivePillRect();
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isScrollable || !activeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = scrollRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
const escapedID = typeof window.CSS?.escape === 'function'
|
||||
? window.CSS.escape(activeId)
|
||||
: activeId.replace(/"/g, '\\"');
|
||||
const target = element.querySelector<HTMLElement>(`[data-sortable-tab-id="${escapedID}"]`);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
updateOverflow();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [activeId, isScrollable, items.length, updateOverflow]);
|
||||
|
||||
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
|
||||
if (!onReorder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
onReorder(String(active.id), String(over.id));
|
||||
}, [onReorder]);
|
||||
|
||||
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" />
|
||||
) : 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" />
|
||||
) : null}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'relative flex h-full min-w-0 flex-1',
|
||||
usesActivePillIndicator ? 'items-center overflow-x-hidden overflow-y-hidden' : 'items-stretch',
|
||||
usesActivePillIndicator && '@container/pill-tabs',
|
||||
usesActivePillIndicator && 'pill-tabs__track',
|
||||
usesActivePillIndicator && (activePillInsetClassName ?? 'gap-0.5 py-0.5'),
|
||||
showPillTrackBackground && 'rounded-lg bg-[var(--surface-muted)]/50',
|
||||
isScrollable
|
||||
? 'overflow-x-auto scrollbar-none'
|
||||
: 'overflow-x-hidden',
|
||||
)}
|
||||
style={isScrollable ? { scrollbarWidth: 'none', msOverflowStyle: 'none' } : undefined}
|
||||
role="tablist"
|
||||
aria-label="Tabs"
|
||||
>
|
||||
{usesActivePillIndicator && pillRect ? (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0 top-0 z-0 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)]',
|
||||
shouldAnimateActivePill && 'transition-[transform,width,height] duration-200 ease-out'
|
||||
)}
|
||||
style={{
|
||||
transform: `translate(${pillRect.left}px, ${pillRect.top}px)`,
|
||||
width: `${pillRect.width}px`,
|
||||
height: `${pillRect.height}px`,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeId;
|
||||
const showInactiveIconOnly = inactiveTabsIconOnly && usesActivePillIndicator && !isActive && Boolean(item.icon);
|
||||
const shouldShowLabel = !showInactiveIconOnly;
|
||||
const useIntrinsicActiveTab = inactiveTabsIconOnly && usesActivePillIndicator && isActive && !isScrollable && !useIntrinsicPillSizing;
|
||||
const closable = item.closable !== false && Boolean(onClose);
|
||||
const wrapperClassName = (isScrollable || useIntrinsicPillSizing)
|
||||
? undefined
|
||||
: usesActivePillIndicator
|
||||
? (useIntrinsicActiveTab
|
||||
? 'flex-none basis-auto'
|
||||
: (isMobile ? 'flex-1 basis-0 min-w-0' : 'flex-1 basis-0 min-w-fit'))
|
||||
: 'min-w-0 flex-1 basis-0';
|
||||
return (
|
||||
<Wrapper key={item.id} id={item.id} className={wrapperClassName}>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex h-full items-center',
|
||||
(isScrollable || useIntrinsicPillSizing)
|
||||
? 'shrink-0'
|
||||
: usesActivePillIndicator
|
||||
? 'w-full'
|
||||
: 'w-full min-w-0',
|
||||
usesActivePillIndicator
|
||||
? 'relative z-10 bg-transparent'
|
||||
: isActive
|
||||
? 'border-r border-border/40 bg-[var(--surface-elevated)] text-foreground'
|
||||
: 'border-r border-border/40 bg-[var(--surface-elevated)]/25 text-muted-foreground hover:bg-[var(--surface-elevated)]/65 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
ref={(element) => setTabRef(item.id, element)}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-label={showInactiveIconOnly ? (item.title ?? item.label) : undefined}
|
||||
onClick={() => onSelect(item.id)}
|
||||
className={cn(
|
||||
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 && (showInactiveIconOnly ? 'gap-0' : 'gap-1.5'),
|
||||
usesActivePillIndicator
|
||||
? useIntrinsicPillSizing
|
||||
? 'shrink-0 whitespace-nowrap px-3 text-center'
|
||||
: isScrollable
|
||||
? 'max-w-56 shrink-0 px-3 text-center'
|
||||
: (showInactiveIconOnly
|
||||
? 'px-2 !min-w-0 text-center'
|
||||
: useIntrinsicActiveTab
|
||||
? 'shrink-0 whitespace-nowrap px-3 text-center'
|
||||
: 'px-3 text-center')
|
||||
: isScrollable
|
||||
? 'max-w-56 justify-start truncate pl-3 pr-2 text-left'
|
||||
: 'w-full justify-center truncate px-2.5 text-center',
|
||||
usesActivePillIndicator
|
||||
? (activePillButtonClassName ?? (isActivePillVariant ? (isMobile ? 'h-[34px]' : 'h-[27px]') : 'h-7'))
|
||||
: null,
|
||||
usesActivePillIndicator
|
||||
? isActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
: null,
|
||||
usesActivePillIndicator
|
||||
? 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-background'
|
||||
: null
|
||||
)}
|
||||
title={item.title ?? item.label}
|
||||
>
|
||||
{usesActivePillIndicator ? (
|
||||
<>
|
||||
{item.icon ? <span className="flex shrink-0 items-center justify-center">{item.icon}</span> : null}
|
||||
{shouldShowLabel ? <span className="animated-tabs__label truncate">{item.label}</span> : null}
|
||||
</>
|
||||
) : (
|
||||
<span className={cn('flex min-w-0 items-center gap-1.5', !isScrollable && 'justify-center')}>
|
||||
{item.icon ? <span className="flex shrink-0 items-center justify-center">{item.icon}</span> : null}
|
||||
<span className="truncate leading-[1.2]">{item.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{closable ? (
|
||||
<button
|
||||
type="button"
|
||||
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'
|
||||
)}
|
||||
aria-label={item.closeLabel ?? `Close ${item.label} tab`}
|
||||
title={item.closeLabel ?? `Close ${item.label} tab`}
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!reorderEnabled) {
|
||||
return list;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={[restrictToXAxis]}
|
||||
>
|
||||
<SortableContext items={itemIDs} strategy={horizontalListSortingStrategy}>
|
||||
{list}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
@@ -3,8 +3,13 @@ import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ScrollableOverlay } from "./ScrollableOverlay"
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea"> & { outerClassName?: string }>(
|
||||
({ className, outerClassName, ...props }, ref) => {
|
||||
type TextareaProps = React.ComponentProps<"textarea"> & {
|
||||
outerClassName?: string;
|
||||
scrollbarClassName?: string;
|
||||
};
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, outerClassName, scrollbarClassName, ...props }, ref) => {
|
||||
return (
|
||||
<ScrollableOverlay
|
||||
as="textarea"
|
||||
@@ -12,6 +17,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"tex
|
||||
disableHorizontal
|
||||
fillContainer={false}
|
||||
outerClassName={cn("w-full rounded-lg focus-within:ring-1 focus-within:ring-primary/50", outerClassName)}
|
||||
scrollbarClassName={scrollbarClassName}
|
||||
className={cn(
|
||||
"text-foreground border border-border/80 placeholder:text-muted-foreground appearance-none dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-lg bg-transparent px-3 py-2 typography-markdown outline-none focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:typography-ui-label",
|
||||
"hover:border-input aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine, RiGitCommitLine, RiLoader4Line, RiTextWrap } from '@remixicon/react';
|
||||
import { RiArrowDownSLine, RiArrowRightSLine, RiEditLine, RiGitCommitLine, RiLoader4Line, RiTextWrap } from '@remixicon/react';
|
||||
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
@@ -111,6 +111,60 @@ const isNewStatusFile = (file: GitStatus['files'][number]): boolean => {
|
||||
return index === 'A' || workingDir === 'A' || index === '?' || workingDir === '?';
|
||||
};
|
||||
|
||||
const isAbsolutePath = (value: string): boolean => {
|
||||
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
|
||||
};
|
||||
|
||||
const toAbsolutePath = (directory: string, filePath: string): string => {
|
||||
const normalizedDirectory = directory.replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||
const normalizedFilePath = filePath.replace(/\\/g, '/');
|
||||
if (isAbsolutePath(normalizedFilePath)) {
|
||||
return normalizedFilePath;
|
||||
}
|
||||
const trimmedFilePath = normalizedFilePath.replace(/^\/+/, '');
|
||||
return normalizedDirectory ? `${normalizedDirectory}/${trimmedFilePath}` : trimmedFilePath;
|
||||
};
|
||||
|
||||
const getFirstChangedModifiedLine = (original: string, modified: string): number => {
|
||||
const originalLines = original.split('\n');
|
||||
const modifiedLines = modified.split('\n');
|
||||
const sharedLength = Math.min(originalLines.length, modifiedLines.length);
|
||||
|
||||
for (let index = 0; index < sharedLength; index += 1) {
|
||||
if (originalLines[index] !== modifiedLines[index]) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (modifiedLines.length > originalLines.length) {
|
||||
return originalLines.length + 1;
|
||||
}
|
||||
|
||||
if (originalLines.length > modifiedLines.length) {
|
||||
return Math.max(1, modifiedLines.length);
|
||||
}
|
||||
|
||||
return 1;
|
||||
};
|
||||
|
||||
const getFirstVisibleModifiedLineFromPatch = (patch: string): number | null => {
|
||||
if (!patch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = patch.match(/@@\s*-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s*@@/m);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(match[1], 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const formatDiffTotals = (insertions?: number, deletions?: number) => {
|
||||
const added = insertions ?? 0;
|
||||
const removed = deletions ?? 0;
|
||||
@@ -547,6 +601,9 @@ interface MultiFileDiffEntryProps {
|
||||
defaultCollapsed?: boolean;
|
||||
expandRequestPath?: string | null;
|
||||
expandRequestNonce?: number;
|
||||
showOpenInEditorAction?: boolean;
|
||||
isOpeningInEditor?: boolean;
|
||||
onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void;
|
||||
}
|
||||
|
||||
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
@@ -561,6 +618,9 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
defaultCollapsed = false,
|
||||
expandRequestPath = null,
|
||||
expandRequestNonce = 0,
|
||||
showOpenInEditorAction = false,
|
||||
isOpeningInEditor = false,
|
||||
onOpenInEditor,
|
||||
}) => {
|
||||
const { git } = useRuntimeAPIs();
|
||||
const cachedDiff = useGitStore(
|
||||
@@ -763,6 +823,25 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
</div>
|
||||
<div className="relative flex items-center gap-2">
|
||||
{formatDiffTotals(file.insertions, file.deletions)}
|
||||
{showOpenInEditorAction && onOpenInEditor ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-70 hover:opacity-100"
|
||||
title="Open this file in editor at change"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenInEditor(file.path, diffData);
|
||||
}}
|
||||
disabled={isOpeningInEditor}
|
||||
>
|
||||
{isOpeningInEditor ? (
|
||||
<RiLoader4Line className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RiEditLine className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<DiffViewToggle
|
||||
mode={renderSideBySide ? 'side-by-side' : 'unified'}
|
||||
onModeChange={(mode: DiffViewMode) => {
|
||||
@@ -819,6 +898,7 @@ interface DiffViewProps {
|
||||
stackedDefaultCollapsedAll?: boolean;
|
||||
hideFileSelector?: boolean;
|
||||
pinSelectedFileHeaderToTopOnNavigate?: boolean;
|
||||
showOpenInEditorAction?: boolean;
|
||||
}
|
||||
|
||||
export const DiffView: React.FC<DiffViewProps> = ({
|
||||
@@ -826,6 +906,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
stackedDefaultCollapsedAll = false,
|
||||
hideFileSelector = false,
|
||||
pinSelectedFileHeaderToTopOnNavigate = false,
|
||||
showOpenInEditorAction = false,
|
||||
}) => {
|
||||
const { git } = useRuntimeAPIs();
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
@@ -853,6 +934,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
|
||||
const diffViewMode = useUIStore((state) => state.diffViewMode);
|
||||
const setDiffViewMode = useUIStore((state) => state.setDiffViewMode);
|
||||
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
|
||||
// Default to wrap on mobile
|
||||
const diffWrapLines = isMobile || diffWrapLinesStore;
|
||||
|
||||
@@ -1283,6 +1365,69 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
return { original: selectedCachedDiff.original, modified: selectedCachedDiff.modified, isBinary: selectedCachedDiff.isBinary };
|
||||
}, [selectedCachedDiff]);
|
||||
|
||||
const [openingEditorFilePath, setOpeningEditorFilePath] = React.useState<string | null>(null);
|
||||
|
||||
const openFileInEditorAtChange = React.useCallback(async (filePath: string, cachedDiffData: DiffData | null) => {
|
||||
if (!effectiveDirectory || !filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpeningEditorFilePath(filePath);
|
||||
try {
|
||||
let targetLine: number | null = null;
|
||||
|
||||
if (cachedDiffData && !cachedDiffData.isBinary && !isImageFile(filePath)) {
|
||||
targetLine = getFirstChangedModifiedLine(cachedDiffData.original, cachedDiffData.modified);
|
||||
}
|
||||
|
||||
if (targetLine === null) {
|
||||
try {
|
||||
const patchResponse = await git.getGitDiff(effectiveDirectory, {
|
||||
path: filePath,
|
||||
contextLines: 3,
|
||||
});
|
||||
targetLine = getFirstVisibleModifiedLineFromPatch(patchResponse.diff);
|
||||
} catch {
|
||||
targetLine = null;
|
||||
}
|
||||
}
|
||||
|
||||
let diffForNavigation = cachedDiffData;
|
||||
if (targetLine === null || !diffForNavigation) {
|
||||
const response = await git.getGitFileDiff(effectiveDirectory, { path: filePath });
|
||||
diffForNavigation = {
|
||||
original: response.original ?? '',
|
||||
modified: response.modified ?? '',
|
||||
isBinary: response.isBinary,
|
||||
};
|
||||
setDiff(effectiveDirectory, filePath, diffForNavigation);
|
||||
}
|
||||
|
||||
const resolvedTargetLine = targetLine ?? ((diffForNavigation.isBinary || isImageFile(filePath))
|
||||
? 1
|
||||
: getFirstChangedModifiedLine(diffForNavigation.original, diffForNavigation.modified));
|
||||
|
||||
openContextFileAtLine(
|
||||
effectiveDirectory,
|
||||
toAbsolutePath(effectiveDirectory, filePath),
|
||||
resolvedTargetLine,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
setOpeningEditorFilePath((current) => (current === filePath ? null : current));
|
||||
}
|
||||
}, [effectiveDirectory, git, openContextFileAtLine, setDiff]);
|
||||
|
||||
const openSelectedFileInEditorAtChange = React.useCallback(async () => {
|
||||
if (!selectedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openFileInEditorAtChange(selectedFile, selectedDiffData);
|
||||
}, [openFileInEditorAtChange, selectedDiffData, selectedFile]);
|
||||
|
||||
const isOpeningSelectedInEditor = Boolean(selectedFile && openingEditorFilePath === selectedFile);
|
||||
|
||||
const hasCurrentDiff = !!selectedCachedDiff;
|
||||
const isCurrentFileLoading = !isStackedView && !!selectedFile && !hasCurrentDiff;
|
||||
|
||||
@@ -1402,6 +1547,11 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
defaultCollapsed={stackedDefaultCollapsedAll ? true : index >= defaultExpandedCount}
|
||||
expandRequestPath={stackedExpandTarget}
|
||||
expandRequestNonce={stackedExpandRequestNonce}
|
||||
showOpenInEditorAction={showOpenInEditorAction}
|
||||
isOpeningInEditor={openingEditorFilePath === file.path}
|
||||
onOpenInEditor={(filePath, diffData) => {
|
||||
void openFileInEditorAtChange(filePath, diffData);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1528,6 +1678,24 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
<RiTextWrap className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{showOpenInEditorAction && selectedFileEntry && !isStackedView && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-70 hover:opacity-100"
|
||||
onClick={() => {
|
||||
void openSelectedFileInEditorAtChange();
|
||||
}}
|
||||
disabled={isOpeningSelectedInEditor}
|
||||
title="Open this file at first changed line"
|
||||
>
|
||||
{isOpeningSelectedInEditor ? (
|
||||
<RiLoader4Line className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RiEditLine className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{selectedFileEntry && currentLayoutForSelectedFile && (
|
||||
<DiffViewToggle
|
||||
mode={currentLayoutForSelectedFile === 'side-by-side' ? 'side-by-side' : 'unified'}
|
||||
|
||||
@@ -399,6 +399,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
const currentDirectory = useEffectiveDirectory() ?? '';
|
||||
const root = normalizePath(currentDirectory.trim());
|
||||
const showEditorTabsRow = isMobile || mode !== 'editor-only';
|
||||
const suppressFileLoadingIndicator = mode === 'editor-only' && !isMobile;
|
||||
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||
const gitStatus = useGitStatus(currentDirectory);
|
||||
|
||||
@@ -410,7 +412,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const [wrapLines, setWrapLines] = React.useState(isMobile);
|
||||
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||
const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('view');
|
||||
const [textViewMode, setTextViewMode] = React.useState<'view' | 'edit'>('edit');
|
||||
|
||||
const lightTheme = React.useMemo(
|
||||
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false),
|
||||
@@ -504,6 +506,18 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const copiedPathTimeoutRef = React.useRef<number | null>(null);
|
||||
const editorViewRef = React.useRef<EditorView | null>(null);
|
||||
const editorWrapperRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [editorViewReadyNonce, setEditorViewReadyNonce] = React.useState(0);
|
||||
const pendingNavigationRafRef = React.useRef<number | null>(null);
|
||||
const pendingNavigationCycleRef = React.useRef<{ key: string; attempts: number }>({ key: '', attempts: 0 });
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (pendingNavigationRafRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(pendingNavigationRafRef.current);
|
||||
pendingNavigationRafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null);
|
||||
const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null);
|
||||
@@ -544,6 +558,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
// Session/config for sending comments
|
||||
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
|
||||
const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation);
|
||||
const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation);
|
||||
|
||||
// Global mouseup to end drag selection
|
||||
React.useEffect(() => {
|
||||
@@ -1163,7 +1179,7 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
const loadSelectedFile = React.useCallback(async (node: FileNode) => {
|
||||
setFileError(null);
|
||||
setDesktopImageSrc('');
|
||||
setLoadedFilePath(node.path);
|
||||
setLoadedFilePath(null);
|
||||
|
||||
const selectedIsImage = isImageFile(node.path);
|
||||
const isSvg = node.path.toLowerCase().endsWith('.svg');
|
||||
@@ -1184,6 +1200,7 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
if (!runtime.isDesktop && selectedIsImage && !isSvg) {
|
||||
setFileContent('');
|
||||
setDraftContent('');
|
||||
setLoadedFilePath(node.path);
|
||||
setFileLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -1196,6 +1213,7 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
setDraftContent(content.length > MAX_VIEW_CHARS
|
||||
? `${content.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
|
||||
: content);
|
||||
setLoadedFilePath(node.path);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isDirectoryReadError(error)) {
|
||||
@@ -1535,6 +1553,19 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
|
||||
const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg'));
|
||||
const selectedFilePath = selectedFile?.path ?? '';
|
||||
const pendingNavigationTargetPath = React.useMemo(
|
||||
() => normalizePath(pendingFileNavigation?.path ?? ''),
|
||||
[pendingFileNavigation?.path],
|
||||
);
|
||||
const shouldMaskEditorForPendingNavigation = Boolean(
|
||||
pendingFileNavigation
|
||||
&& pendingNavigationTargetPath
|
||||
&& selectedFilePath
|
||||
&& selectedFilePath === pendingNavigationTargetPath
|
||||
&& !fileLoading
|
||||
&& !fileError
|
||||
&& !isSelectedImage,
|
||||
);
|
||||
|
||||
const displaySelectedPath = React.useMemo(() => {
|
||||
return getDisplayPath(root, selectedFilePath);
|
||||
@@ -1580,9 +1611,151 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
}, [canEdit, textViewMode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTextViewMode('view');
|
||||
setTextViewMode('edit');
|
||||
}, [selectedFile?.path]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingFileNavigation || !root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduleNavigationRetry = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (pendingNavigationRafRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingNavigationRafRef.current = window.requestAnimationFrame(() => {
|
||||
pendingNavigationRafRef.current = null;
|
||||
setEditorViewReadyNonce((value) => value + 1);
|
||||
});
|
||||
};
|
||||
|
||||
const isEditorSyncedWithDraft = (view: EditorView, expectedContent: string): boolean => {
|
||||
if (view.state.doc.length !== expectedContent.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedContent.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sampleSize = Math.min(128, expectedContent.length);
|
||||
const startSample = view.state.sliceDoc(0, sampleSize);
|
||||
if (startSample !== expectedContent.slice(0, sampleSize)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endFrom = Math.max(0, expectedContent.length - sampleSize);
|
||||
const endSample = view.state.sliceDoc(endFrom, expectedContent.length);
|
||||
return endSample === expectedContent.slice(endFrom);
|
||||
};
|
||||
|
||||
const targetPath = normalizePath(pendingFileNavigation.path);
|
||||
if (!targetPath) {
|
||||
setPendingFileNavigation(null);
|
||||
pendingNavigationCycleRef.current = { key: '', attempts: 0 };
|
||||
return;
|
||||
}
|
||||
|
||||
const navigationKey = `${targetPath}:${pendingFileNavigation.line}:${pendingFileNavigation.column ?? 1}`;
|
||||
if (pendingNavigationCycleRef.current.key !== navigationKey) {
|
||||
pendingNavigationCycleRef.current = { key: navigationKey, attempts: 0 };
|
||||
}
|
||||
|
||||
if (selectedFile?.path !== targetPath) {
|
||||
if (selectedPath !== targetPath) {
|
||||
setSelectedPath(root, targetPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileLoading || loadedFilePath !== targetPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileError || isSelectedImage) {
|
||||
setPendingFileNavigation(null);
|
||||
pendingNavigationCycleRef.current = { key: '', attempts: 0 };
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (textViewMode !== 'edit') {
|
||||
setTextViewMode('edit');
|
||||
return;
|
||||
}
|
||||
|
||||
const view = editorViewRef.current;
|
||||
if (!view) {
|
||||
scheduleNavigationRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEditorSyncedWithDraft(view, draftContent)) {
|
||||
scheduleNavigationRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetLineNumber = Math.max(1, Math.min(pendingFileNavigation.line, view.state.doc.lines));
|
||||
const targetLine = view.state.doc.line(targetLineNumber);
|
||||
const targetColumn = Math.max(1, pendingFileNavigation.column || 1);
|
||||
const lineLength = Math.max(0, targetLine.to - targetLine.from);
|
||||
const clampedColumnOffset = Math.min(lineLength, targetColumn - 1);
|
||||
const targetPosition = targetLine.from + clampedColumnOffset;
|
||||
const isAtTarget = view.state.selection.main.head === targetPosition;
|
||||
const shouldDispatch = !isAtTarget || pendingNavigationCycleRef.current.attempts === 0;
|
||||
|
||||
if (shouldDispatch) {
|
||||
pendingNavigationCycleRef.current.attempts += 1;
|
||||
view.dispatch({
|
||||
selection: { anchor: targetPosition },
|
||||
effects: EditorView.scrollIntoView(targetPosition, { y: 'center' }),
|
||||
});
|
||||
view.focus();
|
||||
scheduleNavigationRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.requestAnimationFrame(() => {
|
||||
const syncedView = editorViewRef.current;
|
||||
if (!syncedView) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncedView.dispatch({
|
||||
selection: { anchor: targetPosition },
|
||||
effects: EditorView.scrollIntoView(targetPosition, { y: 'center' }),
|
||||
});
|
||||
syncedView.focus();
|
||||
});
|
||||
}
|
||||
|
||||
setPendingFileNavigation(null);
|
||||
pendingNavigationCycleRef.current = { key: '', attempts: 0 };
|
||||
}, [
|
||||
canEdit,
|
||||
draftContent,
|
||||
editorViewReadyNonce,
|
||||
fileError,
|
||||
fileLoading,
|
||||
isSelectedImage,
|
||||
loadedFilePath,
|
||||
pendingFileNavigation,
|
||||
root,
|
||||
selectedFile?.path,
|
||||
selectedPath,
|
||||
setPendingFileNavigation,
|
||||
setSelectedPath,
|
||||
textViewMode,
|
||||
]);
|
||||
|
||||
const nudgeEditorSelectionAboveKeyboard = React.useCallback((view: EditorView | null) => {
|
||||
if (!isMobile || !view || !view.hasFocus || typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -1707,12 +1880,14 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
.then((src) => {
|
||||
if (!cancelled) {
|
||||
setDesktopImageSrc(src);
|
||||
setLoadedFilePath(selectedFile.path);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
setDesktopImageSrc('');
|
||||
setFileError(error instanceof Error ? error.message : 'Failed to read file');
|
||||
setLoadedFilePath(null);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -1858,6 +2033,7 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
</Dialog>
|
||||
<div className="flex flex-col border-b border-border/40 flex-shrink-0">
|
||||
{/* Row 1: Tabs */}
|
||||
{showEditorTabsRow ? (
|
||||
<div className="flex min-w-0 items-center px-3 py-1.5">
|
||||
{isMobile && showMobilePageContent && (
|
||||
<button
|
||||
@@ -1997,10 +2173,11 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Row 2: Actions (right-aligned) */}
|
||||
{selectedFile && (
|
||||
<div className="flex items-center justify-end gap-1 px-3 pb-1.5">
|
||||
<div className={cn('flex items-center justify-end gap-1 px-3 pb-1.5', !showEditorTabsRow && 'pt-1.5')}>
|
||||
{canEdit && textViewMode === 'edit' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -2168,10 +2345,14 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
{!selectedFile ? (
|
||||
<div className="p-3 typography-ui text-muted-foreground">Pick a file from the tree.</div>
|
||||
) : fileLoading ? (
|
||||
<div className="p-3 flex items-center gap-2 typography-ui text-muted-foreground">
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
suppressFileLoadingIndicator
|
||||
? <div className="p-3" />
|
||||
: (
|
||||
<div className="p-3 flex items-center gap-2 typography-ui text-muted-foreground">
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
) : fileError ? (
|
||||
<div className="p-3 typography-ui text-[color:var(--status-error)]">{fileError}</div>
|
||||
) : isSelectedImage ? (
|
||||
@@ -2210,102 +2391,114 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
renderShikiFileView(selectedFile, draftContent)
|
||||
) : (
|
||||
<div
|
||||
className="relative h-full"
|
||||
className={cn('relative h-full', shouldMaskEditorForPendingNavigation && 'overflow-hidden')}
|
||||
ref={editorWrapperRef}
|
||||
data-keyboard-avoid="none"
|
||||
style={isMobile ? { height: 'calc(100% - var(--oc-keyboard-inset, 0px))' } : undefined}
|
||||
>
|
||||
<CodeMirrorEditor
|
||||
value={draftContent}
|
||||
onChange={setDraftContent}
|
||||
extensions={editorExtensions}
|
||||
className="h-full"
|
||||
blockWidgets={blockWidgets}
|
||||
onViewReady={(view) => {
|
||||
editorViewRef.current = view;
|
||||
window.requestAnimationFrame(() => {
|
||||
nudgeEditorSelectionAboveKeyboard(view);
|
||||
});
|
||||
}}
|
||||
onViewDestroy={() => {
|
||||
if (editorViewRef.current) {
|
||||
editorViewRef.current = null;
|
||||
}
|
||||
}}
|
||||
enableSearch
|
||||
searchOpen={isSearchOpen}
|
||||
onSearchOpenChange={setIsSearchOpen}
|
||||
highlightLines={lineSelection
|
||||
? {
|
||||
start: Math.min(lineSelection.start, lineSelection.end),
|
||||
end: Math.max(lineSelection.start, lineSelection.end),
|
||||
}
|
||||
: undefined}
|
||||
lineNumbersConfig={{
|
||||
domEventHandlers: {
|
||||
mousedown: (view: EditorView, line: { from: number; to: number }, event: Event) => {
|
||||
if (!(event instanceof MouseEvent)) {
|
||||
return false;
|
||||
}
|
||||
if (event.button !== 0) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
<div className={cn('h-full', shouldMaskEditorForPendingNavigation && 'invisible')}>
|
||||
<CodeMirrorEditor
|
||||
value={draftContent}
|
||||
onChange={setDraftContent}
|
||||
extensions={editorExtensions}
|
||||
className="h-full"
|
||||
blockWidgets={blockWidgets}
|
||||
onViewReady={(view) => {
|
||||
editorViewRef.current = view;
|
||||
setEditorViewReadyNonce((value) => value + 1);
|
||||
window.requestAnimationFrame(() => {
|
||||
nudgeEditorSelectionAboveKeyboard(view);
|
||||
});
|
||||
}}
|
||||
onViewDestroy={() => {
|
||||
if (editorViewRef.current) {
|
||||
editorViewRef.current = null;
|
||||
}
|
||||
setEditorViewReadyNonce((value) => value + 1);
|
||||
}}
|
||||
enableSearch
|
||||
searchOpen={isSearchOpen}
|
||||
onSearchOpenChange={setIsSearchOpen}
|
||||
highlightLines={lineSelection
|
||||
? {
|
||||
start: Math.min(lineSelection.start, lineSelection.end),
|
||||
end: Math.max(lineSelection.start, lineSelection.end),
|
||||
}
|
||||
: undefined}
|
||||
lineNumbersConfig={{
|
||||
domEventHandlers: {
|
||||
mousedown: (view: EditorView, line: { from: number; to: number }, event: Event) => {
|
||||
if (!(event instanceof MouseEvent)) {
|
||||
return false;
|
||||
}
|
||||
if (event.button !== 0) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
const lineNumber = view.state.doc.lineAt(line.from).number;
|
||||
const lineNumber = view.state.doc.lineAt(line.from).number;
|
||||
|
||||
// Mobile: tap-to-extend selection
|
||||
if (isMobile && lineSelection && !event.shiftKey) {
|
||||
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
|
||||
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
|
||||
// Mobile: tap-to-extend selection
|
||||
if (isMobile && lineSelection && !event.shiftKey) {
|
||||
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
|
||||
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
|
||||
setLineSelection({ start, end });
|
||||
isSelectingRef.current = false;
|
||||
selectionStartRef.current = null;
|
||||
setIsDragging(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
isSelectingRef.current = true;
|
||||
selectionStartRef.current = lineNumber;
|
||||
setIsDragging(true);
|
||||
|
||||
if (lineSelection && event.shiftKey) {
|
||||
const start = Math.min(lineSelection.start, lineNumber);
|
||||
const end = Math.max(lineSelection.end, lineNumber);
|
||||
setLineSelection({ start, end });
|
||||
} else {
|
||||
setLineSelection({ start: lineNumber, end: lineNumber });
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
mouseover: (view: EditorView, line: { from: number; to: number }, event: Event) => {
|
||||
if (!(event instanceof MouseEvent)) {
|
||||
return false;
|
||||
}
|
||||
if (event.buttons !== 1) {
|
||||
return false;
|
||||
}
|
||||
if (!isSelectingRef.current || selectionStartRef.current === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lineNumber = view.state.doc.lineAt(line.from).number;
|
||||
const start = Math.min(selectionStartRef.current, lineNumber);
|
||||
const end = Math.max(selectionStartRef.current, lineNumber);
|
||||
setLineSelection({ start, end });
|
||||
setIsDragging(true);
|
||||
return false;
|
||||
},
|
||||
mouseup: () => {
|
||||
isSelectingRef.current = false;
|
||||
selectionStartRef.current = null;
|
||||
setIsDragging(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
isSelectingRef.current = true;
|
||||
selectionStartRef.current = lineNumber;
|
||||
setIsDragging(true);
|
||||
|
||||
if (lineSelection && event.shiftKey) {
|
||||
const start = Math.min(lineSelection.start, lineNumber);
|
||||
const end = Math.max(lineSelection.end, lineNumber);
|
||||
setLineSelection({ start, end });
|
||||
} else {
|
||||
setLineSelection({ start: lineNumber, end: lineNumber });
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
mouseover: (view: EditorView, line: { from: number; to: number }, event: Event) => {
|
||||
if (!(event instanceof MouseEvent)) {
|
||||
return false;
|
||||
}
|
||||
if (event.buttons !== 1) {
|
||||
return false;
|
||||
}
|
||||
if (!isSelectingRef.current || selectionStartRef.current === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lineNumber = view.state.doc.lineAt(line.from).number;
|
||||
const start = Math.min(selectionStartRef.current, lineNumber);
|
||||
const end = Math.max(selectionStartRef.current, lineNumber);
|
||||
setLineSelection({ start, end });
|
||||
setIsDragging(true);
|
||||
return false;
|
||||
return false;
|
||||
},
|
||||
},
|
||||
mouseup: () => {
|
||||
isSelectingRef.current = false;
|
||||
selectionStartRef.current = null;
|
||||
setIsDragging(false);
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{shouldMaskEditorForPendingNavigation && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background">
|
||||
<div className="flex items-center gap-2 typography-ui text-muted-foreground">
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
Opening file at change...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
@@ -2567,10 +2760,14 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
<div className="flex-1 min-h-0 min-w-0 relative">
|
||||
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
|
||||
{fileLoading ? (
|
||||
<div className="p-4 flex items-center gap-2 typography-ui text-muted-foreground">
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
suppressFileLoadingIndicator
|
||||
? <div className="p-4" />
|
||||
: (
|
||||
<div className="p-4 flex items-center gap-2 typography-ui text-muted-foreground">
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
) : fileError ? (
|
||||
<div className="p-4 typography-ui text-[color:var(--status-error)]">{fileError}</div>
|
||||
) : isSelectedImage ? (
|
||||
@@ -2608,7 +2805,8 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
) : canUseShikiFileView && textViewMode === 'view' ? (
|
||||
renderShikiFileView(selectedFile, draftContent)
|
||||
) : (
|
||||
<div className="h-full">
|
||||
<div className={cn('relative h-full', shouldMaskEditorForPendingNavigation && 'overflow-hidden')}>
|
||||
<div className={cn('h-full', shouldMaskEditorForPendingNavigation && 'invisible')}>
|
||||
<CodeMirrorEditor
|
||||
value={draftContent}
|
||||
onChange={setDraftContent}
|
||||
@@ -2626,6 +2824,15 @@ const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{shouldMaskEditorForPendingNavigation && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background">
|
||||
<div className="flex items-center gap-2 typography-ui text-muted-foreground">
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
Opening file at change...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
RiSplitCellsHorizontal,
|
||||
} from '@remixicon/react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -224,10 +224,44 @@ interface GitViewProps {
|
||||
export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
const { git } = useRuntimeAPIs();
|
||||
const currentDirectory = useEffectiveDirectory();
|
||||
const { currentSessionId, worktreeMetadata: worktreeMap } = useSessionStore();
|
||||
const worktreeMetadata = currentSessionId
|
||||
? worktreeMap.get(currentSessionId) ?? undefined
|
||||
: undefined;
|
||||
const {
|
||||
currentSessionId,
|
||||
worktreeMetadata: worktreeMap,
|
||||
availableWorktrees,
|
||||
newSessionDraft,
|
||||
} = useSessionStore();
|
||||
const normalizedCurrentDirectory = normalizePath(currentDirectory);
|
||||
const inferredWorktreeMetadata = React.useMemo(() => {
|
||||
if (!normalizedCurrentDirectory) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fromAvailable = availableWorktrees.find(
|
||||
(metadata) => normalizePath(metadata.path) === normalizedCurrentDirectory
|
||||
);
|
||||
if (fromAvailable) {
|
||||
return fromAvailable;
|
||||
}
|
||||
|
||||
for (const metadata of worktreeMap.values()) {
|
||||
if (normalizePath(metadata.path) === normalizedCurrentDirectory) {
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [availableWorktrees, normalizedCurrentDirectory, worktreeMap]);
|
||||
const worktreeMetadata = React.useMemo(() => {
|
||||
if (currentSessionId) {
|
||||
return worktreeMap.get(currentSessionId) ?? inferredWorktreeMetadata;
|
||||
}
|
||||
|
||||
if (newSessionDraft?.open) {
|
||||
return inferredWorktreeMetadata;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [currentSessionId, inferredWorktreeMetadata, newSessionDraft?.open, worktreeMap]);
|
||||
|
||||
|
||||
const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } =
|
||||
@@ -343,6 +377,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
);
|
||||
const [hasUserAdjustedSelection, setHasUserAdjustedSelection] = React.useState(false);
|
||||
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
|
||||
const [isRevertingAll, setIsRevertingAll] = React.useState(false);
|
||||
const [integrateRefreshKey, setIntegrateRefreshKey] = React.useState(0);
|
||||
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
|
||||
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>(
|
||||
@@ -406,6 +441,13 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
|
||||
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
|
||||
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false);
|
||||
|
||||
const actionTabItems = React.useMemo(() => [
|
||||
{ id: 'commit', label: 'Commit', icon: <RiGitCommitLine className="h-3.5 w-3.5" /> },
|
||||
{ id: 'branch', label: 'Update', icon: <RiGitMergeLine className="h-3.5 w-3.5" /> },
|
||||
{ id: 'pr', label: 'PR', icon: <RiGitPullRequestLine className="h-3.5 w-3.5" /> },
|
||||
{ id: 'worktree', label: 'Worktree', icon: <RiSplitCellsHorizontal className="h-3.5 w-3.5" /> },
|
||||
], []);
|
||||
const [actionTab, setActionTab] = React.useState<ActionTab>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'commit';
|
||||
@@ -1253,6 +1295,56 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
[currentDirectory, refreshStatusAndBranches, git]
|
||||
);
|
||||
|
||||
const handleRevertAll = React.useCallback(
|
||||
async (paths: string[]) => {
|
||||
if (!currentDirectory || paths.length === 0 || isRevertingAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uniquePaths = Array.from(new Set(paths));
|
||||
setIsRevertingAll(true);
|
||||
setRevertingPaths((previous) => {
|
||||
const next = new Set(previous);
|
||||
uniquePaths.forEach((path) => next.add(path));
|
||||
return next;
|
||||
});
|
||||
|
||||
const failed: Array<{ path: string; message: string }> = [];
|
||||
|
||||
try {
|
||||
await Promise.all(uniquePaths.map(async (filePath) => {
|
||||
try {
|
||||
await git.revertGitFile(currentDirectory, filePath);
|
||||
} catch (err) {
|
||||
failed.push({
|
||||
path: filePath,
|
||||
message: err instanceof Error ? err.message : 'Failed to revert changes',
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
await refreshStatusAndBranches(false);
|
||||
|
||||
if (failed.length === 0) {
|
||||
toast.success(`Reverted ${uniquePaths.length} file${uniquePaths.length === 1 ? '' : 's'}`);
|
||||
} else if (failed.length === uniquePaths.length) {
|
||||
toast.error(failed[0]?.message || 'Failed to revert changes');
|
||||
} else {
|
||||
const successCount = uniquePaths.length - failed.length;
|
||||
toast.warning(`Reverted ${successCount} file${successCount === 1 ? '' : 's'}, ${failed.length} failed`);
|
||||
}
|
||||
} finally {
|
||||
setRevertingPaths((previous) => {
|
||||
const next = new Set(previous);
|
||||
uniquePaths.forEach((path) => next.delete(path));
|
||||
return next;
|
||||
});
|
||||
setIsRevertingAll(false);
|
||||
}
|
||||
},
|
||||
[currentDirectory, git, isRevertingAll, refreshStatusAndBranches]
|
||||
);
|
||||
|
||||
const handleInsertHighlights = React.useCallback(() => {
|
||||
if (generatedHighlights.length === 0) return;
|
||||
const normalizedHighlights = generatedHighlights
|
||||
@@ -1709,29 +1801,25 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<div className="h-full min-h-0 flex flex-col">
|
||||
<div className={cn('min-w-0 min-h-0 h-full flex flex-col', isSidebarMode ? 'bg-transparent border-t border-border/40' : 'bg-muted/10')}>
|
||||
<div className="px-3 py-1.5">
|
||||
<AnimatedTabs<ActionTab>
|
||||
value={actionTab}
|
||||
onValueChange={setActionTab}
|
||||
size="sm"
|
||||
collapseLabelsOnSmall
|
||||
collapseLabelsOnNarrow={isSidebarMode}
|
||||
tabs={[
|
||||
{ value: 'commit', label: 'Commit', icon: RiGitCommitLine },
|
||||
{ value: 'branch', label: 'Update', icon: RiGitMergeLine },
|
||||
{ value: 'pr', label: 'PR', icon: RiGitPullRequestLine },
|
||||
{ value: 'worktree', label: 'Worktree', icon: RiSplitCellsHorizontal },
|
||||
]}
|
||||
<div className={cn('min-w-0 min-h-0 h-full flex flex-col', isSidebarMode ? 'bg-transparent' : 'bg-muted/10')}>
|
||||
<div className={cn(isMobile ? 'h-10 px-1.5' : 'h-8 px-2')}>
|
||||
<SortableTabsStrip
|
||||
items={actionTabItems}
|
||||
activeId={actionTab}
|
||||
onSelect={(tabID) => setActionTab(tabID as ActionTab)}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
inactiveTabsIconOnly={isSidebarMode && isMobile}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-px bg-border/40" />
|
||||
{!isSidebarMode ? <div className="h-px bg-border/40" /> : null}
|
||||
|
||||
<ScrollableOverlay
|
||||
as={ScrollShadow}
|
||||
ref={actionPanelScrollRef}
|
||||
outerClassName="flex-1 min-h-0"
|
||||
className="px-4 py-4"
|
||||
className={cn('px-4', isSidebarMode ? 'pt-1 pb-4' : 'py-4')}
|
||||
disableHorizontal
|
||||
preventOverscroll
|
||||
>
|
||||
@@ -1750,6 +1838,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
onToggleFile={toggleFileSelection}
|
||||
onSelectAll={selectAll}
|
||||
onClearSelection={clearSelection}
|
||||
onRevertAll={handleRevertAll}
|
||||
onViewDiff={(path) => {
|
||||
if (isSidebarMode && currentDirectory && !isMobile) {
|
||||
openContextDiff(currentDirectory, path);
|
||||
@@ -1761,6 +1850,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
}
|
||||
}}
|
||||
onRevertFile={handleRevertFile}
|
||||
isRevertingAll={isRevertingAll}
|
||||
/>
|
||||
|
||||
<CommitSection
|
||||
@@ -1819,52 +1909,56 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
) : null}
|
||||
|
||||
{actionTab === 'worktree' ? (
|
||||
integrateCommitsProps ? (
|
||||
<IntegrateCommitsSection
|
||||
variant="plain"
|
||||
repoRoot={integrateCommitsProps.repoRoot}
|
||||
sourceBranch={integrateCommitsProps.sourceBranch}
|
||||
worktreeMetadata={integrateCommitsProps.worktreeMetadata}
|
||||
localBranches={localBranches}
|
||||
defaultTargetBranch={defaultTargetBranch}
|
||||
refreshKey={integrateRefreshKey}
|
||||
onRefresh={() => {
|
||||
if (!currentDirectory) return;
|
||||
fetchStatus(currentDirectory, git);
|
||||
fetchBranches(currentDirectory, git);
|
||||
fetchLog(currentDirectory, git, logMaxCountLocal);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="typography-ui-header font-semibold text-foreground">Re-integrate commits</div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
Available in worktree mode.
|
||||
<div className="space-y-4">
|
||||
{integrateCommitsProps ? (
|
||||
<IntegrateCommitsSection
|
||||
variant="plain"
|
||||
repoRoot={integrateCommitsProps.repoRoot}
|
||||
sourceBranch={integrateCommitsProps.sourceBranch}
|
||||
worktreeMetadata={integrateCommitsProps.worktreeMetadata}
|
||||
localBranches={localBranches}
|
||||
defaultTargetBranch={defaultTargetBranch}
|
||||
refreshKey={integrateRefreshKey}
|
||||
onRefresh={() => {
|
||||
if (!currentDirectory) return;
|
||||
fetchStatus(currentDirectory, git);
|
||||
fetchBranches(currentDirectory, git);
|
||||
fetchLog(currentDirectory, git, logMaxCountLocal);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1 pt-3">
|
||||
<div className="typography-ui-header font-semibold text-foreground">Re-integrate commits</div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
Available in worktree mode.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actionTab === 'pr' ? (
|
||||
pullRequestProps ? (
|
||||
<PullRequestSection
|
||||
variant="plain"
|
||||
directory={pullRequestProps.directory}
|
||||
branch={pullRequestProps.branch}
|
||||
baseBranch={baseBranch}
|
||||
trackingBranch={status?.tracking ?? undefined}
|
||||
remotes={remotes}
|
||||
remoteBranches={remoteBranches}
|
||||
onGeneratedDescription={scrollActionPanelToBottom}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="typography-ui-header font-semibold text-foreground">Pull Request</div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
Push a non-base branch (with upstream) to create a PR.
|
||||
<div className="space-y-4">
|
||||
{pullRequestProps ? (
|
||||
<PullRequestSection
|
||||
variant="plain"
|
||||
directory={pullRequestProps.directory}
|
||||
branch={pullRequestProps.branch}
|
||||
baseBranch={baseBranch}
|
||||
trackingBranch={status?.tracking ?? undefined}
|
||||
remotes={remotes}
|
||||
remoteBranches={remoteBranches}
|
||||
onGeneratedDescription={scrollActionPanelToBottom}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="typography-ui-header font-semibold text-foreground">Pull Request</div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
Push a non-base branch (with upstream) to create a PR.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
|
||||
@@ -198,13 +198,13 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
{operationCompleted ? (
|
||||
mode === 'dialog' ? (
|
||||
<DialogFooter>
|
||||
<Button variant="default" size="sm" onClick={handleClose}>
|
||||
<Button variant="default" size="sm" className="h-7 px-2 py-0" onClick={handleClose}>
|
||||
{hasError ? 'Close' : 'Done'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="default" size="sm" onClick={handleClose}>
|
||||
<Button variant="default" size="sm" className="h-7 px-2 py-0" onClick={handleClose}>
|
||||
{hasError ? 'Close' : 'Done'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -214,7 +214,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
);
|
||||
|
||||
const renderForm = () => (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{/* Operation Selection */}
|
||||
<div className="space-y-3">
|
||||
<p className="typography-meta text-muted-foreground">Operation</p>
|
||||
@@ -291,15 +291,18 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
<RiArrowDownSLine className="size-4 opacity-60 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[--radix-dropdown-menu-trigger-width] p-0 max-h-[300px]">
|
||||
<Command>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-[--radix-dropdown-menu-trigger-width] p-0 max-h-(--radix-dropdown-menu-content-available-height) flex flex-col overflow-hidden"
|
||||
>
|
||||
<Command className="h-full min-h-0">
|
||||
<CommandInput
|
||||
ref={searchInputRef}
|
||||
placeholder="Search branches..."
|
||||
value={branchSearch}
|
||||
onValueChange={setBranchSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandList className="h-full min-h-0" disableHorizontal>
|
||||
<CommandEmpty>No branches found.</CommandEmpty>
|
||||
|
||||
{filteredLocal.length > 0 && (
|
||||
@@ -349,8 +352,8 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
) : null}
|
||||
|
||||
{mode === 'dialog' ? (
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleCancel}>
|
||||
<DialogFooter className="gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 py-0" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -358,7 +361,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
size="sm"
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedBranch}
|
||||
className="gap-1.5"
|
||||
className="h-7 px-2 py-0 gap-1.5"
|
||||
>
|
||||
{operation === 'merge' ? (
|
||||
<>
|
||||
@@ -374,33 +377,35 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleCancel} disabled={isDisabled}>
|
||||
<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}>
|
||||
Reset
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="default" size="sm" onClick={handleConfirm} disabled={isDisabled || !selectedBranch}>
|
||||
<Button variant="default" size="sm" className="h-7 px-2 py-0" onClick={handleConfirm} disabled={isDisabled || !selectedBranch}>
|
||||
{operation === 'merge' ? 'Merge' : 'Rebase'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
|
||||
const body = isOperating ? renderOperating() : renderForm();
|
||||
|
||||
if (mode === 'inline') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="typography-ui-header font-semibold text-foreground">Update branch</div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
Bring changes from another branch into{' '}
|
||||
<span className="font-mono text-foreground">{targetBranchLabel}</span>.
|
||||
<section className="border-0 bg-transparent rounded-none">
|
||||
<header className="border-b border-border/40 px-0 py-3">
|
||||
<div className="space-y-1">
|
||||
<div className="typography-ui-header font-semibold text-foreground">Update branch</div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
Bring changes from another branch into{' '}
|
||||
<span className="font-mono text-foreground">{targetBranchLabel}</span>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{body}
|
||||
</div>
|
||||
</header>
|
||||
<div className="pt-3">{body}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -411,7 +416,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 gap-1.5"
|
||||
className="h-7 px-2 py-0 gap-1.5"
|
||||
onClick={handleOpenDialog}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
|
||||
@@ -49,6 +49,7 @@ interface ChangeRowProps {
|
||||
onRevert: () => void;
|
||||
isReverting: boolean;
|
||||
stats?: { insertions: number; deletions: number };
|
||||
rowPaddingClassName?: string;
|
||||
}
|
||||
|
||||
export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
@@ -59,6 +60,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
onRevert,
|
||||
isReverting,
|
||||
stats,
|
||||
rowPaddingClassName,
|
||||
}) {
|
||||
const descriptor = useMemo(() => describeChange(file), [file]);
|
||||
const indicatorLabel = descriptor.description;
|
||||
@@ -98,7 +100,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex items-center gap-2 px-3 py-1.5 hover:bg-sidebar/40 cursor-pointer"
|
||||
className={`group flex items-center gap-2 py-1.5 hover:bg-sidebar/40 cursor-pointer ${rowPaddingClassName ?? 'px-3'}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onViewDiff}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import React from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { RiCheckboxBlankLine, RiCheckboxLine } from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||
import { ChangeRow } from './ChangeRow';
|
||||
@@ -15,8 +24,10 @@ interface ChangesSectionProps {
|
||||
onToggleFile: (path: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onClearSelection: () => void;
|
||||
onRevertAll?: (paths: string[]) => Promise<void> | void;
|
||||
onViewDiff: (path: string) => void;
|
||||
onRevertFile: (path: string) => void;
|
||||
isRevertingAll?: boolean;
|
||||
variant?: 'framed' | 'plain';
|
||||
maxListHeightClassName?: string;
|
||||
onVisiblePathsChange?: (paths: string[]) => void;
|
||||
@@ -33,8 +44,10 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
onToggleFile,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
onRevertAll,
|
||||
onViewDiff,
|
||||
onRevertFile,
|
||||
isRevertingAll = false,
|
||||
variant = 'framed',
|
||||
maxListHeightClassName,
|
||||
onVisiblePathsChange,
|
||||
@@ -42,7 +55,11 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const selectedCount = selectedPaths.size;
|
||||
const totalCount = changeEntries.length;
|
||||
const [confirmRevertAllOpen, setConfirmRevertAllOpen] = React.useState(false);
|
||||
const shouldVirtualize = totalCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
const hasAnySelected = selectedCount > 0;
|
||||
const areAllSelected = totalCount > 0 && selectedCount === totalCount;
|
||||
const isPartiallySelected = hasAnySelected && !areAllSelected;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: totalCount,
|
||||
@@ -82,66 +99,115 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
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-4 py-3 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 ${maxListHeightClassName ?? ''}`.trim();
|
||||
: `flex-1 min-h-0 pr-0 ${maxListHeightClassName ?? ''}`.trim();
|
||||
const rowPaddingClassName = variant === 'plain' ? 'pl-0 pr-2' : 'px-3';
|
||||
|
||||
const handleConfirmRevertAll = React.useCallback(async () => {
|
||||
if (!onRevertAll || isRevertingAll || changeEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onRevertAll(changeEntries.map((entry) => entry.path));
|
||||
setConfirmRevertAllOpen(false);
|
||||
}, [changeEntries, isRevertingAll, onRevertAll]);
|
||||
|
||||
return (
|
||||
<section className={containerClassName}>
|
||||
<header className={headerClassName}>
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Changes</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{selectedCount}/{totalCount}
|
||||
</span>
|
||||
{totalCount > 0 && (
|
||||
<>
|
||||
<>
|
||||
<section className={containerClassName}>
|
||||
<header className={headerClassName}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Changes</h3>
|
||||
{totalCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={areAllSelected ? onClearSelection : onSelectAll}
|
||||
disabled={isRevertingAll}
|
||||
aria-checked={isPartiallySelected ? 'mixed' : hasAnySelected}
|
||||
aria-label={areAllSelected ? 'Clear file selection' : 'Select all files'}
|
||||
className={cn(
|
||||
'inline-flex h-6 items-center gap-1 rounded px-1.5 text-muted-foreground',
|
||||
'hover:bg-interactive-hover/55 hover:text-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
isRevertingAll && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
{hasAnySelected ? (
|
||||
<RiCheckboxLine className={cn('size-4', isPartiallySelected ? 'text-primary/50' : 'text-primary')} />
|
||||
) : (
|
||||
<RiCheckboxBlankLine className="size-4" />
|
||||
)}
|
||||
<span className="typography-meta text-muted-foreground">{selectedCount}/{totalCount}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn('flex items-center gap-2', variant === 'plain' && 'pr-1')}>
|
||||
{totalCount > 0 && onRevertAll ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onSelectAll}
|
||||
className="h-6 px-2 text-xs text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
onClick={() => setConfirmRevertAllOpen(true)}
|
||||
disabled={isRevertingAll}
|
||||
>
|
||||
All
|
||||
Revert all
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onClearSelection}
|
||||
disabled={selectedCount === 0}
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<div className={cn('relative flex flex-col min-h-0 w-full overflow-hidden', scrollOuterClassName)}>
|
||||
<ScrollShadow
|
||||
ref={scrollRef}
|
||||
className="overlay-scrollbar-target overlay-scrollbar-container flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
{shouldVirtualize ? (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
None
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div className={cn('relative flex flex-col min-h-0 w-full overflow-hidden', scrollOuterClassName)}>
|
||||
<ScrollShadow
|
||||
ref={scrollRef}
|
||||
className="overlay-scrollbar-target overlay-scrollbar-container flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
{shouldVirtualize ? (
|
||||
<div
|
||||
className="relative w-full divide-y divide-border/60"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualRows.map((row) => {
|
||||
const file = changeEntries[row.index];
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
{virtualRows.map((row) => {
|
||||
const file = changeEntries[row.index];
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
return (
|
||||
<div
|
||||
key={file.path}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
className={cn(
|
||||
'absolute left-0 top-0 w-full',
|
||||
row.index > 0 && 'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
|
||||
)}
|
||||
style={{ transform: `translateY(${row.start}px)` }}
|
||||
>
|
||||
<ChangeRow
|
||||
file={file}
|
||||
checked={selectedPaths.has(file.path)}
|
||||
stats={diffStats?.[file.path]}
|
||||
onToggle={() => onToggleFile(file.path)}
|
||||
onViewDiff={() => onViewDiff(file.path)}
|
||||
onRevert={() => onRevertFile(file.path)}
|
||||
isReverting={revertingPaths.has(file.path) || isRevertingAll}
|
||||
rowPaddingClassName={rowPaddingClassName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div role="list" aria-label="Changed files">
|
||||
{changeEntries.map((file, index) => (
|
||||
<div
|
||||
key={file.path}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{ transform: `translateY(${row.start}px)` }}
|
||||
className={cn(
|
||||
'relative',
|
||||
index > 0 && 'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
|
||||
)}
|
||||
>
|
||||
<ChangeRow
|
||||
file={file}
|
||||
@@ -150,31 +216,36 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
onToggle={() => onToggleFile(file.path)}
|
||||
onViewDiff={() => onViewDiff(file.path)}
|
||||
onRevert={() => onRevertFile(file.path)}
|
||||
isReverting={revertingPaths.has(file.path)}
|
||||
isReverting={revertingPaths.has(file.path) || isRevertingAll}
|
||||
rowPaddingClassName={rowPaddingClassName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60" role="list" aria-label="Changed files">
|
||||
{changeEntries.map((file) => (
|
||||
<ChangeRow
|
||||
key={file.path}
|
||||
file={file}
|
||||
checked={selectedPaths.has(file.path)}
|
||||
stats={diffStats?.[file.path]}
|
||||
onToggle={() => onToggleFile(file.path)}
|
||||
onViewDiff={() => onViewDiff(file.path)}
|
||||
onRevert={() => onRevertFile(file.path)}
|
||||
isReverting={revertingPaths.has(file.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollShadow>
|
||||
<OverlayScrollbar containerRef={scrollRef} disableHorizontal />
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollShadow>
|
||||
<OverlayScrollbar containerRef={scrollRef} disableHorizontal />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={confirmRevertAllOpen} onOpenChange={(open) => { if (!isRevertingAll) setConfirmRevertAllOpen(open); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revert all changes?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will discard local changes for {totalCount} file{totalCount === 1 ? '' : 's'} in the list.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmRevertAllOpen(false)} disabled={isRevertingAll}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => void handleConfirmRevertAll()} disabled={isRevertingAll}>
|
||||
{isRevertingAll ? 'Reverting...' : 'Revert all'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,15 +22,17 @@ export const CommitInput: React.FC<CommitInputProps> = ({
|
||||
}) => {
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-resize based on content
|
||||
React.useEffect(() => {
|
||||
// Auto-resize based on content (layout phase to avoid mount flicker)
|
||||
React.useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
// Reset height to measure scrollHeight accurately
|
||||
textarea.style.height = `${MIN_HEIGHT}px`;
|
||||
const newHeight = Math.min(Math.max(textarea.scrollHeight, MIN_HEIGHT), MAX_HEIGHT);
|
||||
const contentHeight = textarea.scrollHeight;
|
||||
const newHeight = Math.min(Math.max(contentHeight, MIN_HEIGHT), MAX_HEIGHT);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
textarea.style.overflowY = contentHeight > MAX_HEIGHT ? 'auto' : 'hidden';
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
@@ -44,8 +46,9 @@ export const CommitInput: React.FC<CommitInputProps> = ({
|
||||
autoCorrect={hasTouchInput ? 'on' : 'off'}
|
||||
autoCapitalize={hasTouchInput ? 'sentences' : 'off'}
|
||||
spellCheck={hasTouchInput ? true : false}
|
||||
scrollbarClassName="hidden"
|
||||
className={cn(
|
||||
'rounded-lg bg-transparent resize-none overflow-y-auto',
|
||||
'rounded-lg bg-transparent resize-none overflow-y-hidden',
|
||||
disabled && 'opacity-50'
|
||||
)}
|
||||
style={{ minHeight: MIN_HEIGHT, maxHeight: MAX_HEIGHT }}
|
||||
|
||||
@@ -64,11 +64,11 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
const headerClassName =
|
||||
variant === 'framed'
|
||||
? 'flex w-full items-center justify-between px-3 py-2'
|
||||
: 'flex w-full items-center justify-between px-4 py-3 border-b border-border/40';
|
||||
: '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-4 py-3';
|
||||
: 'flex flex-col gap-3 px-0 py-3';
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
@@ -168,14 +168,14 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
{isMobile ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onCommitAndPush()}
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Commit & Push"
|
||||
>
|
||||
<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" />
|
||||
) : (
|
||||
@@ -184,7 +184,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Commit & Push</p>
|
||||
<p>Push</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -193,17 +193,17 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||
onClick={() => onCommitAndPush()}
|
||||
disabled={!canCommit || isGeneratingMessage}
|
||||
className="commit-actions__btn"
|
||||
aria-label="Commit & Push"
|
||||
aria-label="Push"
|
||||
>
|
||||
{commitAction === 'commitAndPush' ? (
|
||||
<>
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
<span className="commit-actions__label commit-actions__label--long">Pushing...</span>
|
||||
<span className="commit-actions__label">Pushing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiArrowUpLine className="size-4" />
|
||||
<span className="commit-actions__label commit-actions__label--long">Commit & Push</span>
|
||||
<span className="commit-actions__label">Push</span>
|
||||
</>
|
||||
)}
|
||||
</ButtonLarge>
|
||||
|
||||
@@ -285,7 +285,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
|
||||
if (useTwoRowHeader) {
|
||||
return (
|
||||
<header className={`@container/git-header border-b border-border/40 px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'bg-background'}`}>
|
||||
<header className={`@container/git-header px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'border-b border-border/40 bg-background'}`}>
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
{isWorktreeMode ? (
|
||||
@@ -320,7 +320,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={`@container/git-header flex items-center gap-2 border-b border-border/40 px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'bg-background'}`}>
|
||||
<header className={`@container/git-header flex items-center gap-2 px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'border-b border-border/40 bg-background'}`}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
{isWorktreeMode ? (
|
||||
<WorktreeBranchDisplay
|
||||
|
||||
@@ -377,7 +377,7 @@ Important:
|
||||
|
||||
<DropdownMenu open={branchDropdownOpen} onOpenChange={setBranchDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-7 px-2 py-0 gap-1.5">
|
||||
Target
|
||||
<span className="max-w-[160px] truncate font-mono text-xs text-muted-foreground">{targetBranch}</span>
|
||||
<RiArrowDownSLine className="size-4 opacity-60" />
|
||||
@@ -416,15 +416,15 @@ Important:
|
||||
</DropdownMenu>
|
||||
|
||||
{ui.kind === 'ready' ? (
|
||||
<Button size="sm" onClick={() => void handleMove()} disabled={!isEligible || ui.plan.commits.length === 0}>
|
||||
<Button size="sm" className="h-7 px-2 py-0" onClick={() => void handleMove()} disabled={!isEligible || ui.plan.commits.length === 0}>
|
||||
Move
|
||||
</Button>
|
||||
) : ui.kind === 'loading' ? (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2 py-0" disabled>
|
||||
Checking…
|
||||
</Button>
|
||||
) : ui.kind === 'running' ? (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2 py-0" disabled>
|
||||
Moving…
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -1483,7 +1483,7 @@ export const PullRequestSection: React.FC<{
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-8 px-0"
|
||||
className="h-7 w-7 px-0"
|
||||
onClick={() => {
|
||||
setIsEditingPr(false);
|
||||
setEditTitle(pr.title || '');
|
||||
@@ -1501,7 +1501,7 @@ export const PullRequestSection: React.FC<{
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-8 px-0"
|
||||
className="h-7 w-7 px-0"
|
||||
onClick={() => updatePr(pr)}
|
||||
disabled={isUpdating || !editTitle.trim()}
|
||||
aria-label="Save PR title and description"
|
||||
@@ -1518,7 +1518,7 @@ export const PullRequestSection: React.FC<{
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-8 px-0"
|
||||
className="h-7 w-7 px-0"
|
||||
onClick={() => setIsEditingPr(true)}
|
||||
aria-label="Edit PR title and description"
|
||||
>
|
||||
@@ -1536,7 +1536,7 @@ export const PullRequestSection: React.FC<{
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-8 px-0"
|
||||
className="h-7 w-7 px-0"
|
||||
onClick={openChecksDialog}
|
||||
disabled={isLoadingCheckDetails}
|
||||
aria-label="Open checks details"
|
||||
@@ -1554,7 +1554,7 @@ export const PullRequestSection: React.FC<{
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-8 px-0 border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]"
|
||||
className="h-7 w-7 px-0 border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]"
|
||||
onClick={sendFailedChecksToChat}
|
||||
aria-label="Resolve failed checks with agent"
|
||||
>
|
||||
@@ -1570,7 +1570,7 @@ export const PullRequestSection: React.FC<{
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-8 px-0"
|
||||
className="h-7 w-7 px-0"
|
||||
onClick={openCommentsDialog}
|
||||
aria-label="Open PR comments"
|
||||
>
|
||||
@@ -1585,7 +1585,7 @@ export const PullRequestSection: React.FC<{
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-8 px-0 border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]"
|
||||
className="h-7 w-7 px-0 border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]"
|
||||
onClick={sendCommentsToChat}
|
||||
aria-label="Share comments with agent"
|
||||
>
|
||||
@@ -1601,7 +1601,7 @@ export const PullRequestSection: React.FC<{
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-8 px-0"
|
||||
className="h-7 w-7 px-0"
|
||||
onClick={() => markReady(pr)}
|
||||
disabled={isMarkingReady || isMerging || isUpdating || isEditingPr}
|
||||
aria-label="Mark PR ready for review"
|
||||
@@ -1622,7 +1622,7 @@ export const PullRequestSection: React.FC<{
|
||||
onValueChange={(value) => setMergeMethod(value as MergeMethod)}
|
||||
disabled={isMerging || pr.state !== 'open'}
|
||||
>
|
||||
<SelectTrigger size="lg" className="h-8 w-auto min-w-0">
|
||||
<SelectTrigger size="lg" className="h-7 w-auto min-w-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1635,7 +1635,7 @@ export const PullRequestSection: React.FC<{
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-8 px-0"
|
||||
className="h-7 w-7 px-0"
|
||||
onClick={() => mergePr(pr)}
|
||||
disabled={isMerging || isMarkingReady || pr.state !== 'open' || pr.draft || isUpdating || isEditingPr}
|
||||
aria-label="Merge pull request"
|
||||
@@ -1661,7 +1661,7 @@ export const PullRequestSection: React.FC<{
|
||||
</div>
|
||||
</div>
|
||||
{repoUrl ? (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 px-2 py-0" asChild>
|
||||
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
|
||||
<RiExternalLinkLine className="size-4" />
|
||||
Repo
|
||||
@@ -1831,6 +1831,7 @@ export const PullRequestSection: React.FC<{
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 py-0"
|
||||
onClick={generateDescription}
|
||||
disabled={isGenerating || isCreating}
|
||||
>
|
||||
@@ -1840,7 +1841,7 @@ export const PullRequestSection: React.FC<{
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-w-[7.5rem] justify-center gap-2"
|
||||
className="h-7 min-w-[7.5rem] justify-center gap-2 px-2 py-0"
|
||||
onClick={createPr}
|
||||
disabled={isCreating || !isConnected || !targetBaseBranch.trim() || targetBaseBranch.trim() === branch}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import type { Theme, ThemeMode } from '@/types/theme';
|
||||
import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { isDesktopLocalOriginActive, isVSCodeRuntime } from '@/lib/desktop';
|
||||
@@ -25,6 +26,12 @@ type ThemePreferences = {
|
||||
darkThemeId: string;
|
||||
};
|
||||
|
||||
type ThemeSyncPayload = {
|
||||
themeMode?: unknown;
|
||||
lightThemeId?: unknown;
|
||||
darkThemeId?: unknown;
|
||||
};
|
||||
|
||||
const DEFAULT_LIGHT_ID = DEFAULT_LIGHT_THEME_ID;
|
||||
const DEFAULT_DARK_ID = DEFAULT_DARK_THEME_ID;
|
||||
|
||||
@@ -40,6 +47,26 @@ const fallbackThemeForVariant = (variant: 'light' | 'dark'): Theme =>
|
||||
|
||||
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
|
||||
|
||||
const suppressTransitionsForThemeSwitch = () => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
root.classList.add('oc-theme-switching');
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
root.classList.remove('oc-theme-switching');
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
root.classList.remove('oc-theme-switching');
|
||||
};
|
||||
};
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
@@ -354,6 +381,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const restoreTransitions = suppressTransitionsForThemeSwitch();
|
||||
cssGenerator.apply(currentTheme);
|
||||
applyVSCodeRuntimeClass(isVSCode);
|
||||
updateBrowserChrome(currentTheme);
|
||||
@@ -361,6 +389,8 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(currentTheme.metadata.variant);
|
||||
|
||||
return restoreTransitions;
|
||||
}, [applyVSCodeRuntimeClass, cssGenerator, currentTheme, isVSCode, updateBrowserChrome]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -404,6 +434,128 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
localStorage.setItem('splashFgDark', darkTheme.colors.surface.foreground);
|
||||
}, [preferences, currentTheme, ensureThemeById]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.storageArea !== window.localStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'themeMode' && event.key !== 'lightThemeId' && event.key !== 'darkThemeId') {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreferences((prev) => {
|
||||
const nextModeRaw = localStorage.getItem('themeMode');
|
||||
const nextMode: ThemeMode =
|
||||
nextModeRaw === 'light' || nextModeRaw === 'dark' || nextModeRaw === 'system'
|
||||
? nextModeRaw
|
||||
: prev.themeMode;
|
||||
|
||||
const nextLightRaw = localStorage.getItem('lightThemeId');
|
||||
const nextLight = typeof nextLightRaw === 'string' && nextLightRaw.trim().length > 0
|
||||
? nextLightRaw.trim()
|
||||
: prev.lightThemeId;
|
||||
|
||||
const nextDarkRaw = localStorage.getItem('darkThemeId');
|
||||
const nextDark = typeof nextDarkRaw === 'string' && nextDarkRaw.trim().length > 0
|
||||
? nextDarkRaw.trim()
|
||||
: prev.darkThemeId;
|
||||
|
||||
if (nextMode === prev.themeMode && nextLight === prev.lightThemeId && nextDark === prev.darkThemeId) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
themeMode: nextMode,
|
||||
lightThemeId: nextLight,
|
||||
darkThemeId: nextDark,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
}, []);
|
||||
|
||||
const applyIncomingThemeSync = useCallback((payload: ThemeSyncPayload) => {
|
||||
const mode = payload.themeMode;
|
||||
const light = payload.lightThemeId;
|
||||
const dark = payload.darkThemeId;
|
||||
|
||||
if ((mode !== 'light' && mode !== 'dark' && mode !== 'system') || typeof light !== 'string' || typeof dark !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedLight = light.trim();
|
||||
const normalizedDark = dark.trim();
|
||||
if (!normalizedLight || !normalizedDark) {
|
||||
return;
|
||||
}
|
||||
|
||||
suppressTransitionsForThemeSwitch();
|
||||
flushSync(() => {
|
||||
setPreferences((prev) => {
|
||||
if (prev.themeMode === mode && prev.lightThemeId === normalizedLight && prev.darkThemeId === normalizedDark) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
themeMode: mode,
|
||||
lightThemeId: normalizedLight,
|
||||
darkThemeId: normalizedDark,
|
||||
};
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedWindow = window as unknown as {
|
||||
__openchamberApplyThemeSync?: (payload: ThemeSyncPayload) => void;
|
||||
};
|
||||
|
||||
scopedWindow.__openchamberApplyThemeSync = applyIncomingThemeSync;
|
||||
|
||||
return () => {
|
||||
if (scopedWindow.__openchamberApplyThemeSync === applyIncomingThemeSync) {
|
||||
delete scopedWindow.__openchamberApplyThemeSync;
|
||||
}
|
||||
};
|
||||
}, [applyIncomingThemeSync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data as {
|
||||
type?: unknown;
|
||||
payload?: ThemeSyncPayload;
|
||||
};
|
||||
|
||||
if (data?.type !== 'openchamber:theme-sync' || !data.payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyIncomingThemeSync(data.payload);
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [applyIncomingThemeSync]);
|
||||
|
||||
useEffect(() => {
|
||||
void updateDesktopSettings({
|
||||
themeId: currentTheme.metadata.id,
|
||||
|
||||
@@ -247,7 +247,8 @@ const getMessageFromStore = (sessionId: string, messageId: string): { info: Mess
|
||||
return message;
|
||||
};
|
||||
|
||||
export const useEventStream = () => {
|
||||
export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const {
|
||||
addStreamingPart,
|
||||
completeStreamingMessage,
|
||||
@@ -373,9 +374,13 @@ export const useEventStream = () => {
|
||||
}, [requestPendingPermissionsRefresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestPendingPermissionsRefresh(true);
|
||||
requestPendingQuestionsRefresh(true);
|
||||
}, [requestPendingPermissionsRefresh, requestPendingQuestionsRefresh]);
|
||||
}, [enabled, requestPendingPermissionsRefresh, requestPendingQuestionsRefresh]);
|
||||
|
||||
const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => {
|
||||
if (typeof value !== 'string') return null;
|
||||
@@ -2194,6 +2199,12 @@ export const useEventStream = () => {
|
||||
}, [scheduleReconnect]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
stopStream();
|
||||
publishStatus('idle', null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__messageTracker = trackMessage;
|
||||
}
|
||||
@@ -2430,6 +2441,7 @@ export const useEventStream = () => {
|
||||
publishStatus('idle', null);
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
effectiveDirectory,
|
||||
trackMessage,
|
||||
resolveVisibilityState,
|
||||
|
||||
@@ -23,9 +23,10 @@ const sendVisibility = (visible: boolean) => {
|
||||
void apis.push.setVisibility({ visible });
|
||||
};
|
||||
|
||||
export const usePushVisibilityBeacon = () => {
|
||||
export const usePushVisibilityBeacon = (options?: { enabled?: boolean }) => {
|
||||
const enabled = options?.enabled ?? true;
|
||||
React.useEffect(() => {
|
||||
if (!isWebRuntime() || typeof document === 'undefined') {
|
||||
if (!enabled || !isWebRuntime() || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,5 +59,5 @@ export const usePushVisibilityBeacon = () => {
|
||||
window.removeEventListener('focus', report);
|
||||
window.removeEventListener('blur', report);
|
||||
};
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
};
|
||||
|
||||
@@ -101,7 +101,8 @@ const resolveSessionSendConfig = (sessionId: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
export function useQueuedMessageAutoSend() {
|
||||
export function useQueuedMessageAutoSend(options?: { enabled?: boolean }) {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
|
||||
const sessionStatus = useSessionStore((state) => state.sessionStatus);
|
||||
|
||||
@@ -109,6 +110,10 @@ export function useQueuedMessageAutoSend() {
|
||||
const previousStatusRef = React.useRef<Map<string, SessionStatusType>>(new Map());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dispatchSessionQueue = async (sessionId: string, queueSnapshot: QueuedMessage[]) => {
|
||||
if (queueSnapshot.length === 0) {
|
||||
return;
|
||||
@@ -187,6 +192,5 @@ export function useQueuedMessageAutoSend() {
|
||||
});
|
||||
|
||||
previousStatusRef.current = nextStatusMap;
|
||||
}, [queuedMessages, sessionStatus]);
|
||||
}, [enabled, queuedMessages, sessionStatus]);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ export const triggerSessionStatusPoll = () => {
|
||||
* Architecture: server maintains authoritative state, client applies snapshots.
|
||||
* SSE remains the primary transport; snapshots repair missed updates.
|
||||
*/
|
||||
export function useServerSessionStatus() {
|
||||
export function useServerSessionStatus(options?: { enabled?: boolean }) {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const isSyncingRef = React.useRef(false);
|
||||
const hasPendingImmediateSyncRef = React.useRef(false);
|
||||
const lastSyncAtRef = React.useRef(0);
|
||||
@@ -260,6 +261,10 @@ export function useServerSessionStatus() {
|
||||
|
||||
// Initial snapshot sync on mount
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
void fetchSessionStatus(true);
|
||||
|
||||
return () => {
|
||||
@@ -270,10 +275,14 @@ export function useServerSessionStatus() {
|
||||
clearTimeout(followUpTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [fetchSessionStatus]);
|
||||
}, [enabled, fetchSessionStatus]);
|
||||
|
||||
// Sync snapshot when tab becomes visible
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
triggerImmediatePoll();
|
||||
@@ -284,15 +293,20 @@ export function useServerSessionStatus() {
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [triggerImmediatePoll]);
|
||||
}, [enabled, triggerImmediatePoll]);
|
||||
|
||||
// Update the ref for external access
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
triggerImmediatePollRef = null;
|
||||
return;
|
||||
}
|
||||
|
||||
triggerImmediatePollRef = triggerImmediatePoll;
|
||||
return () => {
|
||||
triggerImmediatePollRef = null;
|
||||
};
|
||||
}, [triggerImmediatePoll]);
|
||||
}, [enabled, triggerImmediatePoll]);
|
||||
|
||||
return {
|
||||
fetchSessionStatus,
|
||||
|
||||
@@ -57,10 +57,12 @@ type CleanupResult = {
|
||||
|
||||
type CleanupOptions = {
|
||||
autoRun?: boolean;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export const useSessionAutoCleanup = (options?: CleanupOptions) => {
|
||||
const autoRun = options?.autoRun !== false;
|
||||
const enabled = options?.enabled ?? true;
|
||||
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
@@ -147,6 +149,10 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => {
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!autoRun) {
|
||||
return;
|
||||
}
|
||||
@@ -166,6 +172,7 @@ export const useSessionAutoCleanup = (options?: CleanupOptions) => {
|
||||
autoDeleteEnabled,
|
||||
autoDeleteLastRunAt,
|
||||
autoRun,
|
||||
enabled,
|
||||
isLoading,
|
||||
sessions.length,
|
||||
runCleanup,
|
||||
|
||||
@@ -9,8 +9,13 @@ type SessionStatusPayload = {
|
||||
next?: number;
|
||||
};
|
||||
|
||||
export const useSessionStatusBootstrap = () => {
|
||||
export const useSessionStatusBootstrap = (options?: { enabled?: boolean }) => {
|
||||
const enabled = options?.enabled ?? true;
|
||||
React.useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const bootstrap = async () => {
|
||||
@@ -38,5 +43,5 @@ export const useSessionStatusBootstrap = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root.oc-theme-switching *,
|
||||
:root.oc-theme-switching *::before,
|
||||
:root.oc-theme-switching *::after {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* Suppress WebKit-specific hover/focus adornments on the chat textarea */
|
||||
textarea[data-chat-input="true"] {
|
||||
-webkit-appearance: none;
|
||||
@@ -496,6 +503,10 @@ html:not(.dark) .chat-scroll {
|
||||
scrollbar-gutter: auto !important;
|
||||
}
|
||||
|
||||
.overlay-scrollbar-target--no-gutter {
|
||||
scrollbar-gutter: unset !important;
|
||||
}
|
||||
|
||||
.overlay-scrollbar-target::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
@@ -710,6 +721,20 @@ html:not(.dark) .chat-scroll {
|
||||
}
|
||||
}
|
||||
|
||||
.pill-tabs__track {
|
||||
padding-inline: 0.375rem;
|
||||
}
|
||||
|
||||
@container pill-tabs (max-width: 23rem) {
|
||||
.pill-tabs__track {
|
||||
padding-inline: 1px;
|
||||
}
|
||||
|
||||
.pill-tabs__button {
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Text font: IBM Plex Sans */
|
||||
.streamdown-content {
|
||||
font-family: var(--font-sans);
|
||||
|
||||
@@ -111,6 +111,8 @@ export type DesktopSettings = {
|
||||
gitProviderId?: string;
|
||||
gitModelId?: string;
|
||||
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed';
|
||||
userMessageRenderingMode?: 'markdown' | 'plain';
|
||||
stickyUserHeader?: boolean;
|
||||
fontSize?: number;
|
||||
terminalFontSize?: number;
|
||||
padding?: number;
|
||||
|
||||
@@ -363,6 +363,15 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
store.setToolCallExpansion(settings.toolCallExpansion);
|
||||
}
|
||||
}
|
||||
if (typeof settings.userMessageRenderingMode === 'string'
|
||||
&& (settings.userMessageRenderingMode === 'markdown' || settings.userMessageRenderingMode === 'plain')) {
|
||||
if (settings.userMessageRenderingMode !== store.userMessageRenderingMode) {
|
||||
store.setUserMessageRenderingMode(settings.userMessageRenderingMode);
|
||||
}
|
||||
}
|
||||
if (typeof settings.stickyUserHeader === 'boolean' && settings.stickyUserHeader !== store.stickyUserHeader) {
|
||||
store.setStickyUserHeader(settings.stickyUserHeader);
|
||||
}
|
||||
if (typeof settings.fontSize === 'number' && Number.isFinite(settings.fontSize) && settings.fontSize !== store.fontSize) {
|
||||
store.setFontSize(settings.fontSize);
|
||||
}
|
||||
@@ -720,6 +729,13 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
) {
|
||||
result.toolCallExpansion = candidate.toolCallExpansion;
|
||||
}
|
||||
if (typeof candidate.userMessageRenderingMode === 'string'
|
||||
&& (candidate.userMessageRenderingMode === 'markdown' || candidate.userMessageRenderingMode === 'plain')) {
|
||||
result.userMessageRenderingMode = candidate.userMessageRenderingMode;
|
||||
}
|
||||
if (typeof candidate.stickyUserHeader === 'boolean') {
|
||||
result.stickyUserHeader = candidate.stickyUserHeader;
|
||||
}
|
||||
if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) {
|
||||
result.fontSize = candidate.fontSize;
|
||||
}
|
||||
|
||||
@@ -7,18 +7,41 @@ import type { ShortcutCombo } from '@/lib/shortcuts';
|
||||
|
||||
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files';
|
||||
export type RightSidebarTab = 'git' | 'files';
|
||||
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan';
|
||||
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat';
|
||||
export type MermaidRenderingMode = 'svg' | 'ascii';
|
||||
export type UserMessageRenderingMode = 'markdown' | 'plain';
|
||||
|
||||
type ContextPanelTab = {
|
||||
id: string;
|
||||
mode: ContextPanelMode;
|
||||
targetPath: string | null;
|
||||
dedupeKey: string;
|
||||
label: string | null;
|
||||
touchedAt: number;
|
||||
};
|
||||
|
||||
type ContextPanelTabDescriptor = {
|
||||
mode: ContextPanelMode;
|
||||
targetPath?: string | null;
|
||||
dedupeKey?: string | null;
|
||||
label?: string | null;
|
||||
};
|
||||
|
||||
type ContextPanelDirectoryState = {
|
||||
isOpen: boolean;
|
||||
expanded: boolean;
|
||||
mode: ContextPanelMode | null;
|
||||
targetPath: string | null;
|
||||
tabs: ContextPanelTab[];
|
||||
activeTabId: string | null;
|
||||
width: number;
|
||||
touchedAt: number;
|
||||
};
|
||||
|
||||
type PendingFileNavigation = {
|
||||
path: string;
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
|
||||
export type MainTabGuard = (nextTab: MainTab) => boolean;
|
||||
export type EventStreamStatus =
|
||||
| 'idle'
|
||||
@@ -67,6 +90,8 @@ const isLegacyDefaultTemplates = (value: unknown): boolean => {
|
||||
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
|
||||
const CONTEXT_PANEL_MIN_WIDTH = 360;
|
||||
const CONTEXT_PANEL_MAX_WIDTH = 1400;
|
||||
const CONTEXT_PANEL_MAX_TABS = 12;
|
||||
const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120;
|
||||
const LEFT_SIDEBAR_MIN_WIDTH = 300;
|
||||
const RIGHT_SIDEBAR_MIN_WIDTH = 400;
|
||||
|
||||
@@ -97,21 +122,319 @@ const clampContextPanelWidth = (width: number): number => {
|
||||
return Math.min(CONTEXT_PANEL_MAX_WIDTH, Math.max(CONTEXT_PANEL_MIN_WIDTH, Math.round(width)));
|
||||
};
|
||||
|
||||
const normalizeContextTargetPath = (value: string | null | undefined): string | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.replace(/\\/g, '/');
|
||||
};
|
||||
|
||||
const normalizeContextTabLabel = (value: string | null | undefined): string | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed.length > CONTEXT_PANEL_MAX_LABEL_LENGTH
|
||||
? trimmed.slice(0, CONTEXT_PANEL_MAX_LABEL_LENGTH)
|
||||
: trimmed;
|
||||
};
|
||||
|
||||
const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath: string | null): string => {
|
||||
if (mode === 'file') {
|
||||
return targetPath || mode;
|
||||
}
|
||||
|
||||
return mode;
|
||||
};
|
||||
|
||||
const normalizeContextPanelTabDedupeKey = (
|
||||
mode: ContextPanelMode,
|
||||
targetPath: string | null,
|
||||
dedupeKey: string | null | undefined,
|
||||
): string => {
|
||||
if (typeof dedupeKey === 'string') {
|
||||
const trimmed = dedupeKey.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return buildDefaultContextPanelTabDedupeKey(mode, targetPath);
|
||||
};
|
||||
|
||||
const buildContextPanelTabID = (mode: ContextPanelMode, dedupeKey: string): string => {
|
||||
return dedupeKey === mode ? mode : `${mode}:${dedupeKey}`;
|
||||
};
|
||||
|
||||
const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPanelTab => {
|
||||
const normalizedTargetPath = normalizeContextTargetPath(descriptor.targetPath);
|
||||
const dedupeKey = normalizeContextPanelTabDedupeKey(
|
||||
descriptor.mode,
|
||||
normalizedTargetPath,
|
||||
descriptor.dedupeKey,
|
||||
);
|
||||
return {
|
||||
id: buildContextPanelTabID(descriptor.mode, dedupeKey),
|
||||
mode: descriptor.mode,
|
||||
targetPath: normalizedTargetPath,
|
||||
dedupeKey,
|
||||
label: normalizeContextTabLabel(descriptor.label),
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const clampContextPanelTabs = (tabs: ContextPanelTab[], maxTabs: number, activeTabId: string | null): ContextPanelTab[] => {
|
||||
if (tabs.length <= maxTabs) {
|
||||
return tabs;
|
||||
}
|
||||
|
||||
const tabsByTouch = [...tabs].sort((a, b) => a.touchedAt - b.touchedAt);
|
||||
const removable = tabsByTouch.filter((tab) => tab.id !== activeTabId);
|
||||
const removeCount = tabs.length - maxTabs;
|
||||
if (removeCount <= 0 || removable.length === 0) {
|
||||
return tabs.slice(-maxTabs);
|
||||
}
|
||||
|
||||
const removeSet = new Set(removable.slice(0, removeCount).map((tab) => tab.id));
|
||||
return tabs.filter((tab) => !removeSet.has(tab.id));
|
||||
};
|
||||
|
||||
const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
||||
if (!Array.isArray(tabs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: ContextPanelTab[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of tabs) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = entry as {
|
||||
mode?: unknown;
|
||||
targetPath?: unknown;
|
||||
dedupeKey?: unknown;
|
||||
label?: unknown;
|
||||
touchedAt?: unknown;
|
||||
};
|
||||
|
||||
if (candidate.mode !== 'diff' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetPath = normalizeContextTargetPath(typeof candidate.targetPath === 'string' ? candidate.targetPath : null);
|
||||
const dedupeKey = normalizeContextPanelTabDedupeKey(
|
||||
candidate.mode,
|
||||
targetPath,
|
||||
typeof candidate.dedupeKey === 'string' ? candidate.dedupeKey : null,
|
||||
);
|
||||
const id = buildContextPanelTabID(candidate.mode, dedupeKey);
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(id);
|
||||
result.push({
|
||||
id,
|
||||
mode: candidate.mode,
|
||||
targetPath,
|
||||
dedupeKey,
|
||||
label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null),
|
||||
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
|
||||
? candidate.touchedAt
|
||||
: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const resolveActiveContextPanelTabID = (tabs: ContextPanelTab[], activeTabId: string | null): string | null => {
|
||||
if (activeTabId && tabs.some((tab) => tab.id === activeTabId)) {
|
||||
return activeTabId;
|
||||
}
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tabs[tabs.length - 1].id;
|
||||
};
|
||||
|
||||
const touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanelDirectoryState => {
|
||||
if (prev) {
|
||||
return { ...prev, touchedAt: Date.now() };
|
||||
const tabs = sanitizeContextPanelTabs(prev.tabs);
|
||||
const activeTabId = resolveActiveContextPanelTabID(tabs, prev.activeTabId);
|
||||
return {
|
||||
...prev,
|
||||
tabs,
|
||||
activeTabId,
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen: false,
|
||||
expanded: false,
|
||||
mode: null,
|
||||
targetPath: null,
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
width: CONTEXT_PANEL_DEFAULT_WIDTH,
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const upsertContextPanelTab = (
|
||||
current: ContextPanelDirectoryState,
|
||||
descriptor: ContextPanelTabDescriptor,
|
||||
): ContextPanelDirectoryState => {
|
||||
const nextTab = createContextPanelTab(descriptor);
|
||||
const existingIndex = current.tabs.findIndex((tab) => tab.id === nextTab.id);
|
||||
const tabs = existingIndex === -1
|
||||
? [...current.tabs, nextTab]
|
||||
: current.tabs.map((tab, index) => (index === existingIndex
|
||||
? {
|
||||
...tab,
|
||||
mode: nextTab.mode,
|
||||
targetPath: nextTab.targetPath,
|
||||
dedupeKey: nextTab.dedupeKey,
|
||||
label: nextTab.label,
|
||||
touchedAt: Date.now(),
|
||||
}
|
||||
: tab));
|
||||
|
||||
const activeTabId = nextTab.id;
|
||||
const clampedTabs = clampContextPanelTabs(tabs, CONTEXT_PANEL_MAX_TABS, activeTabId);
|
||||
|
||||
return {
|
||||
...current,
|
||||
isOpen: true,
|
||||
tabs: clampedTabs,
|
||||
activeTabId: resolveActiveContextPanelTabID(clampedTabs, activeTabId),
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const closeContextPanelTab = (
|
||||
current: ContextPanelDirectoryState,
|
||||
tabID: string,
|
||||
): ContextPanelDirectoryState => {
|
||||
const nextTabs = current.tabs.filter((tab) => tab.id !== tabID);
|
||||
const nextActiveTabId = current.activeTabId === tabID
|
||||
? (nextTabs[nextTabs.length - 1]?.id ?? null)
|
||||
: resolveActiveContextPanelTabID(nextTabs, current.activeTabId);
|
||||
|
||||
return {
|
||||
...current,
|
||||
tabs: nextTabs,
|
||||
activeTabId: nextActiveTabId,
|
||||
isOpen: nextTabs.length > 0 ? current.isOpen : false,
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const reorderContextPanelTabs = (
|
||||
current: ContextPanelDirectoryState,
|
||||
activeTabID: string,
|
||||
overTabID: string,
|
||||
): ContextPanelDirectoryState => {
|
||||
if (activeTabID === overTabID) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const fromIndex = current.tabs.findIndex((tab) => tab.id === activeTabID);
|
||||
const toIndex = current.tabs.findIndex((tab) => tab.id === overTabID);
|
||||
if (fromIndex === -1 || toIndex === -1) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const tabs = [...current.tabs];
|
||||
const [moved] = tabs.splice(fromIndex, 1);
|
||||
if (!moved) {
|
||||
return current;
|
||||
}
|
||||
|
||||
tabs.splice(toIndex, 0, moved);
|
||||
|
||||
return {
|
||||
...current,
|
||||
tabs,
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const sanitizeContextPanelByDirectory = (
|
||||
value: unknown,
|
||||
): Record<string, ContextPanelDirectoryState> => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const source = value as Record<string, unknown>;
|
||||
const next: Record<string, ContextPanelDirectoryState> = {};
|
||||
|
||||
for (const [rawDirectory, rawState] of Object.entries(source)) {
|
||||
const directory = normalizeDirectoryPath(rawDirectory);
|
||||
if (!directory || !rawState || typeof rawState !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = rawState as {
|
||||
isOpen?: unknown;
|
||||
expanded?: unknown;
|
||||
tabs?: unknown;
|
||||
activeTabId?: unknown;
|
||||
width?: unknown;
|
||||
touchedAt?: unknown;
|
||||
mode?: unknown;
|
||||
targetPath?: unknown;
|
||||
dedupeKey?: unknown;
|
||||
label?: unknown;
|
||||
};
|
||||
|
||||
let tabs = sanitizeContextPanelTabs(candidate.tabs);
|
||||
let activeTabId = typeof candidate.activeTabId === 'string' ? candidate.activeTabId : null;
|
||||
|
||||
if (tabs.length === 0 && (candidate.mode === 'diff' || candidate.mode === 'file' || candidate.mode === 'context' || candidate.mode === 'plan' || candidate.mode === 'chat')) {
|
||||
tabs = [createContextPanelTab({
|
||||
mode: candidate.mode,
|
||||
targetPath: typeof candidate.targetPath === 'string' ? candidate.targetPath : null,
|
||||
dedupeKey: typeof candidate.dedupeKey === 'string' ? candidate.dedupeKey : null,
|
||||
label: typeof candidate.label === 'string' ? candidate.label : null,
|
||||
})];
|
||||
activeTabId = tabs[0]?.id ?? null;
|
||||
}
|
||||
|
||||
const resolvedActiveTabId = resolveActiveContextPanelTabID(tabs, activeTabId);
|
||||
const clampedTabs = clampContextPanelTabs(tabs, CONTEXT_PANEL_MAX_TABS, resolvedActiveTabId);
|
||||
|
||||
next[directory] = {
|
||||
isOpen: candidate.isOpen === true,
|
||||
expanded: candidate.expanded === true,
|
||||
tabs: clampedTabs,
|
||||
activeTabId: resolveActiveContextPanelTabID(clampedTabs, resolvedActiveTabId),
|
||||
width: clampContextPanelWidth(typeof candidate.width === 'number' ? candidate.width : CONTEXT_PANEL_DEFAULT_WIDTH),
|
||||
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
|
||||
? candidate.touchedAt
|
||||
: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const clampContextPanelRoots = (
|
||||
byDirectory: Record<string, ContextPanelDirectoryState>,
|
||||
maxRoots: number
|
||||
@@ -152,6 +475,7 @@ interface UIStore {
|
||||
mainTabGuard: MainTabGuard | null;
|
||||
sidebarOpenBeforeFullscreenTab: boolean | null;
|
||||
pendingDiffFile: string | null;
|
||||
pendingFileNavigation: PendingFileNavigation | null;
|
||||
isMobile: boolean;
|
||||
isKeyboardOpen: boolean;
|
||||
isCommandPaletteOpen: boolean;
|
||||
@@ -188,6 +512,7 @@ interface UIStore {
|
||||
|
||||
favoriteModels: Array<{ providerID: string; modelID: string }>;
|
||||
hiddenModels: Array<{ providerID: string; modelID: string }>;
|
||||
collapsedModelProviders: string[];
|
||||
recentModels: Array<{ providerID: string; modelID: string }>;
|
||||
recentAgents: string[];
|
||||
recentEfforts: Record<string, string[]>;
|
||||
@@ -224,6 +549,8 @@ interface UIStore {
|
||||
showTerminalQuickKeysOnDesktop: boolean;
|
||||
persistChatDraft: boolean;
|
||||
mermaidRenderingMode: MermaidRenderingMode;
|
||||
userMessageRenderingMode: UserMessageRenderingMode;
|
||||
stickyUserHeader: boolean;
|
||||
showMobileSessionStatusBar: boolean;
|
||||
isMobileSessionStatusBarCollapsed: boolean;
|
||||
viewPagerPage: 'left' | 'center' | 'right';
|
||||
@@ -240,10 +567,15 @@ interface UIStore {
|
||||
setRightSidebarOpen: (open: boolean) => void;
|
||||
setRightSidebarWidth: (width: number) => void;
|
||||
setRightSidebarTab: (tab: RightSidebarTab) => void;
|
||||
openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor) => void;
|
||||
openContextDiff: (directory: string, filePath: string) => void;
|
||||
openContextFile: (directory: string, filePath: string) => void;
|
||||
openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void;
|
||||
openContextOverview: (directory: string) => void;
|
||||
openContextPlan: (directory: string) => void;
|
||||
setActiveContextPanelTab: (directory: string, tabID: string) => void;
|
||||
reorderContextPanelTabs: (directory: string, activeTabID: string, overTabID: string) => void;
|
||||
closeContextPanelTab: (directory: string, tabID: string) => void;
|
||||
closeContextPanel: (directory: string) => void;
|
||||
toggleContextPanelExpanded: (directory: string) => void;
|
||||
setContextPanelWidth: (directory: string, width: number) => void;
|
||||
@@ -257,6 +589,7 @@ interface UIStore {
|
||||
setActiveMainTab: (tab: MainTab) => void;
|
||||
setMainTabGuard: (guard: MainTabGuard | null) => void;
|
||||
setPendingDiffFile: (filePath: string | null) => void;
|
||||
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
|
||||
navigateToDiff: (filePath: string) => void;
|
||||
consumePendingDiffFile: () => string | null;
|
||||
setIsMobile: (isMobile: boolean) => void;
|
||||
@@ -298,6 +631,7 @@ interface UIStore {
|
||||
isHiddenModel: (providerID: string, modelID: string) => boolean;
|
||||
hideAllModels: (providerID: string, modelIDs: string[]) => void;
|
||||
showAllModels: (providerID: string) => void;
|
||||
toggleModelProviderCollapsed: (providerID: string) => void;
|
||||
isFavoriteModel: (providerID: string, modelID: string) => boolean;
|
||||
addRecentModel: (providerID: string, modelID: string) => void;
|
||||
addRecentAgent: (agentName: string) => void;
|
||||
@@ -323,6 +657,8 @@ interface UIStore {
|
||||
setMaxLastMessageLength: (value: number) => void;
|
||||
setPersistChatDraft: (value: boolean) => void;
|
||||
setMermaidRenderingMode: (value: MermaidRenderingMode) => void;
|
||||
setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void;
|
||||
setStickyUserHeader: (value: boolean) => void;
|
||||
setShowMobileSessionStatusBar: (value: boolean) => void;
|
||||
setIsMobileSessionStatusBarCollapsed: (value: boolean) => void;
|
||||
setViewPagerPage: (page: 'left' | 'center' | 'right') => void;
|
||||
@@ -362,6 +698,7 @@ export const useUIStore = create<UIStore>()(
|
||||
mainTabGuard: null,
|
||||
sidebarOpenBeforeFullscreenTab: null,
|
||||
pendingDiffFile: null,
|
||||
pendingFileNavigation: null,
|
||||
isMobile: false,
|
||||
isKeyboardOpen: false,
|
||||
isCommandPaletteOpen: false,
|
||||
@@ -394,6 +731,7 @@ export const useUIStore = create<UIStore>()(
|
||||
inputBarOffset: 0,
|
||||
favoriteModels: [],
|
||||
hiddenModels: [],
|
||||
collapsedModelProviders: [],
|
||||
recentModels: [],
|
||||
recentAgents: [],
|
||||
recentEfforts: {},
|
||||
@@ -427,6 +765,8 @@ export const useUIStore = create<UIStore>()(
|
||||
showTerminalQuickKeysOnDesktop: false,
|
||||
persistChatDraft: true,
|
||||
mermaidRenderingMode: 'svg',
|
||||
userMessageRenderingMode: 'markdown',
|
||||
stickyUserHeader: true,
|
||||
showMobileSessionStatusBar: true,
|
||||
isMobileSessionStatusBarCollapsed: false,
|
||||
isExpandedInput: false,
|
||||
@@ -525,10 +865,9 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ rightSidebarTab: tab });
|
||||
},
|
||||
|
||||
openContextDiff: (directory, filePath) => {
|
||||
openContextPanelTab: (directory, tab) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedFilePath = (filePath || '').trim();
|
||||
if (!normalizedDirectory || !normalizedFilePath) {
|
||||
if (!normalizedDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -537,16 +876,21 @@ export const useUIStore = create<UIStore>()(
|
||||
const current = touchContextPanelState(prev);
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...current,
|
||||
isOpen: true,
|
||||
mode: 'diff' as const,
|
||||
targetPath: normalizedFilePath,
|
||||
},
|
||||
[normalizedDirectory]: upsertContextPanelTab(current, tab),
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
});
|
||||
},
|
||||
|
||||
openContextDiff: (directory, filePath) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedFilePath = (filePath || '').trim();
|
||||
if (!normalizedDirectory || !normalizedFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
get().openContextPanelTab(normalizedDirectory, { mode: 'diff', targetPath: normalizedFilePath });
|
||||
get().setPendingDiffFile(normalizedFilePath);
|
||||
},
|
||||
|
||||
@@ -557,20 +901,24 @@ export const useUIStore = create<UIStore>()(
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...current,
|
||||
isOpen: true,
|
||||
mode: 'file' as const,
|
||||
targetPath: normalizedFilePath,
|
||||
},
|
||||
};
|
||||
get().openContextPanelTab(normalizedDirectory, { mode: 'file', targetPath: normalizedFilePath });
|
||||
get().setPendingFileNavigation(null);
|
||||
},
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
openContextFileAtLine: (directory, filePath, line, column) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedFilePath = normalizeContextTargetPath(filePath);
|
||||
const normalizedLine = Number.isFinite(line) ? Math.max(1, Math.trunc(line)) : 1;
|
||||
const normalizedColumn = Number.isFinite(column) ? Math.max(1, Math.trunc(column as number)) : 1;
|
||||
if (!normalizedDirectory || !normalizedFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
get().openContextPanelTab(normalizedDirectory, { mode: 'file', targetPath: normalizedFilePath });
|
||||
get().setPendingFileNavigation({
|
||||
path: normalizedFilePath,
|
||||
line: normalizedLine,
|
||||
column: normalizedColumn,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -580,21 +928,7 @@ export const useUIStore = create<UIStore>()(
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...current,
|
||||
isOpen: true,
|
||||
mode: 'context' as const,
|
||||
targetPath: null,
|
||||
},
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
});
|
||||
get().openContextPanelTab(normalizedDirectory, { mode: 'context' });
|
||||
},
|
||||
|
||||
openContextPlan: (directory) => {
|
||||
@@ -603,16 +937,37 @@ export const useUIStore = create<UIStore>()(
|
||||
return;
|
||||
}
|
||||
|
||||
get().openContextPanelTab(normalizedDirectory, { mode: 'plan' });
|
||||
},
|
||||
|
||||
setActiveContextPanelTab: (directory, tabID) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedTabID = (tabID || '').trim();
|
||||
if (!normalizedDirectory || !normalizedTabID) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
if (!current.tabs.some((tab) => tab.id === normalizedTabID)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (current.activeTabId === normalizedTabID && current.isOpen) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...current,
|
||||
isOpen: true,
|
||||
mode: 'plan' as const,
|
||||
targetPath: null,
|
||||
activeTabId: normalizedTabID,
|
||||
touchedAt: Date.now(),
|
||||
tabs: current.tabs.map((tab) => (tab.id === normalizedTabID
|
||||
? { ...tab, touchedAt: Date.now() }
|
||||
: tab)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -620,6 +975,58 @@ export const useUIStore = create<UIStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
reorderContextPanelTabs: (directory, activeTabID, overTabID) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedActiveTabID = (activeTabID || '').trim();
|
||||
const normalizedOverTabID = (overTabID || '').trim();
|
||||
if (!normalizedDirectory || !normalizedActiveTabID || !normalizedOverTabID) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
if (!current.tabs.some((tab) => tab.id === normalizedActiveTabID) || !current.tabs.some((tab) => tab.id === normalizedOverTabID)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const next = reorderContextPanelTabs(current, normalizedActiveTabID, normalizedOverTabID);
|
||||
if (next.tabs === current.tabs) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: next,
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
});
|
||||
},
|
||||
|
||||
closeContextPanelTab: (directory, tabID) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
const normalizedTabID = (tabID || '').trim();
|
||||
if (!normalizedDirectory || !normalizedTabID) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
if (!current.tabs.some((tab) => tab.id === normalizedTabID)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: closeContextPanelTab(current, normalizedTabID),
|
||||
};
|
||||
|
||||
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };
|
||||
});
|
||||
},
|
||||
|
||||
closeContextPanel: (directory) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
if (!normalizedDirectory) {
|
||||
@@ -774,6 +1181,10 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ pendingDiffFile: filePath });
|
||||
},
|
||||
|
||||
setPendingFileNavigation: (navigation) => {
|
||||
set({ pendingFileNavigation: navigation });
|
||||
},
|
||||
|
||||
navigateToDiff: (filePath) => {
|
||||
const guard = get().mainTabGuard;
|
||||
if (guard && !guard('diff')) {
|
||||
@@ -1074,6 +1485,26 @@ export const useUIStore = create<UIStore>()(
|
||||
}));
|
||||
},
|
||||
|
||||
toggleModelProviderCollapsed: (providerID) => {
|
||||
const normalizedProviderID = typeof providerID === 'string' ? providerID.trim() : '';
|
||||
if (!normalizedProviderID) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const isCollapsed = state.collapsedModelProviders.includes(normalizedProviderID);
|
||||
if (isCollapsed) {
|
||||
return {
|
||||
collapsedModelProviders: state.collapsedModelProviders.filter((id) => id !== normalizedProviderID),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
collapsedModelProviders: [...state.collapsedModelProviders, normalizedProviderID],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
isFavoriteModel: (providerID, modelID) => {
|
||||
const { favoriteModels } = get();
|
||||
return favoriteModels.some(
|
||||
@@ -1224,6 +1655,12 @@ export const useUIStore = create<UIStore>()(
|
||||
setMermaidRenderingMode: (value) => {
|
||||
set({ mermaidRenderingMode: value });
|
||||
},
|
||||
setUserMessageRenderingMode: (value) => {
|
||||
set({ userMessageRenderingMode: value });
|
||||
},
|
||||
setStickyUserHeader: (value) => {
|
||||
set({ stickyUserHeader: value });
|
||||
},
|
||||
setShowMobileSessionStatusBar: (value) => {
|
||||
set({ showMobileSessionStatusBar: value });
|
||||
},
|
||||
@@ -1274,7 +1711,7 @@ export const useUIStore = create<UIStore>()(
|
||||
{
|
||||
name: 'ui-store',
|
||||
storage: createJSONStorage(() => getSafeStorage()),
|
||||
version: 5,
|
||||
version: 7,
|
||||
migrate: (persistedState, version) => {
|
||||
if (!persistedState || typeof persistedState !== 'object') {
|
||||
return persistedState;
|
||||
@@ -1318,9 +1755,7 @@ export const useUIStore = create<UIStore>()(
|
||||
state.rightSidebarTab = 'git';
|
||||
}
|
||||
|
||||
if (!state.contextPanelByDirectory || typeof state.contextPanelByDirectory !== 'object') {
|
||||
state.contextPanelByDirectory = {};
|
||||
}
|
||||
state.contextPanelByDirectory = sanitizeContextPanelByDirectory(state.contextPanelByDirectory);
|
||||
|
||||
if (version < 5) {
|
||||
if (!state.shortcutOverrides || typeof state.shortcutOverrides !== 'object') {
|
||||
@@ -1337,6 +1772,14 @@ export const useUIStore = create<UIStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 6) {
|
||||
state.contextPanelByDirectory = sanitizeContextPanelByDirectory(state.contextPanelByDirectory);
|
||||
}
|
||||
|
||||
if (version < 7) {
|
||||
state.contextPanelByDirectory = sanitizeContextPanelByDirectory(state.contextPanelByDirectory);
|
||||
}
|
||||
|
||||
return state;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
@@ -1374,6 +1817,7 @@ export const useUIStore = create<UIStore>()(
|
||||
cornerRadius: state.cornerRadius,
|
||||
favoriteModels: state.favoriteModels,
|
||||
hiddenModels: state.hiddenModels,
|
||||
collapsedModelProviders: state.collapsedModelProviders,
|
||||
recentModels: state.recentModels,
|
||||
recentAgents: state.recentAgents,
|
||||
recentEfforts: state.recentEfforts,
|
||||
@@ -1394,6 +1838,8 @@ export const useUIStore = create<UIStore>()(
|
||||
maxLastMessageLength: state.maxLastMessageLength,
|
||||
persistChatDraft: state.persistChatDraft,
|
||||
mermaidRenderingMode: state.mermaidRenderingMode,
|
||||
userMessageRenderingMode: state.userMessageRenderingMode,
|
||||
stickyUserHeader: state.stickyUserHeader,
|
||||
showMobileSessionStatusBar: state.showMobileSessionStatusBar,
|
||||
isMobileSessionStatusBarCollapsed: state.isMobileSessionStatusBarCollapsed,
|
||||
shortcutOverrides: state.shortcutOverrides,
|
||||
|
||||
Reference in New Issue
Block a user