feat: move projects to sidebar rail and speed up session switching (#506)
* feat: move projects from header tabs to left sidebar rail * refactor: remove variant from git generation session context * feat: implemented drandrop action for navrails * refactor: improve sidebar drag-and-drop smoothness and remove floating overlay * fix: prevent mobile nav rail touch from closing drawer and opening menu * fix: hide header tabs on desktop * fix: prevent session flicker when switching projects * style: soften chat panel divider borders * style: improve icon contrast across core navigation * fix: stabilize session selection during project switching * style: unify sidebar surfaces and transparent section layers * style: remove UI shadows and keep only scroll shadow * perf: speed up project switching with cached session loading * perf: speed up Git changes view and background refresh * perf: make session switching lighter and less aggressive * perf: optimized sessions list loading while project switching * perf: smooth chat rendering and reduce interaction spikes * fix: restore reliable load older messages visibility
This commit is contained in:
committed by
GitHub
parent
4c69bccf56
commit
10851bd7ac
@@ -12,10 +12,10 @@
|
|||||||
transition: filter 300ms;
|
transition: filter 300ms;
|
||||||
}
|
}
|
||||||
.logo:hover {
|
.logo:hover {
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
filter: none;
|
||||||
}
|
}
|
||||||
.logo.react:hover {
|
.logo.react:hover {
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes logo-spin {
|
@keyframes logo-spin {
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
|
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||||
>
|
>
|
||||||
{showTabs ? (
|
{showTabs ? (
|
||||||
<div className="px-2 pt-2 pb-1 border-b border-border/60">
|
<div className="px-2 pt-2 pb-1 border-b border-border/60">
|
||||||
@@ -198,7 +198,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
|
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-interactive-selection text-interactive-selection-foreground shadow-sm'
|
? 'bg-interactive-selection text-interactive-selection-foreground shadow-none'
|
||||||
: 'text-muted-foreground hover:bg-interactive-hover/50'
|
: 'text-muted-foreground hover:bg-interactive-hover/50'
|
||||||
)}
|
)}
|
||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
|
|||||||
@@ -176,8 +176,8 @@ export const ChatContainer: React.FC = () => {
|
|||||||
const [turnStart, setTurnStart] = React.useState(0);
|
const [turnStart, setTurnStart] = React.useState(0);
|
||||||
const turnHandleRef = React.useRef<number | null>(null);
|
const turnHandleRef = React.useRef<number | null>(null);
|
||||||
const turnIdleRef = React.useRef(false);
|
const turnIdleRef = React.useRef(false);
|
||||||
const TURN_INIT = 20;
|
const TURN_INIT = 5;
|
||||||
const TURN_BATCH = 20;
|
const TURN_BATCH = 8;
|
||||||
|
|
||||||
const userTurnIndexes = React.useMemo(() => {
|
const userTurnIndexes = React.useMemo(() => {
|
||||||
const indexes: number[] = [];
|
const indexes: number[] = [];
|
||||||
@@ -304,7 +304,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
|
|
||||||
const hasMoreAbove = React.useMemo(() => {
|
const hasMoreAbove = React.useMemo(() => {
|
||||||
if (!memoryState) {
|
if (!memoryState) {
|
||||||
return false;
|
return sessionMessages.length >= getMemoryLimits().HISTORICAL_MESSAGES;
|
||||||
}
|
}
|
||||||
if (memoryState.historyComplete === true) {
|
if (memoryState.historyComplete === true) {
|
||||||
return false;
|
return false;
|
||||||
@@ -323,6 +323,13 @@ export const ChatContainer: React.FC = () => {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}, [memoryState, sessionMessages.length]);
|
}, [memoryState, sessionMessages.length]);
|
||||||
|
|
||||||
|
const hasHistoryMetadata = React.useMemo(() => {
|
||||||
|
if (!memoryState) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return memoryState.hasMoreAbove !== undefined || memoryState.historyComplete !== undefined;
|
||||||
|
}, [memoryState]);
|
||||||
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setIsLoadingOlder(false);
|
setIsLoadingOlder(false);
|
||||||
@@ -384,7 +391,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasSessionMessages = hasSessionMessagesEntry;
|
const hasSessionMessages = hasSessionMessagesEntry;
|
||||||
if (hasSessionMessages) {
|
if (hasSessionMessages && hasHistoryMetadata) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,7 +417,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
void load();
|
void load();
|
||||||
}, [currentSessionId, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]);
|
}, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]);
|
||||||
|
|
||||||
if (!currentSessionId && !draftOpen) {
|
if (!currentSessionId && !draftOpen) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1820,7 +1820,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const stopIconSizeClass = isMobile ? 'h-6 w-6' : (isVSCode ? 'h-4 w-4' : 'h-5 w-5');
|
const stopIconSizeClass = isMobile ? 'h-6 w-6' : (isVSCode ? 'h-4 w-4' : 'h-5 w-5');
|
||||||
const iconSizeClass = isMobile ? 'h-[18px] w-[18px]' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
|
const iconSizeClass = isMobile ? 'h-[18px] w-[18px]' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
|
||||||
|
|
||||||
const iconButtonBaseClass = 'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0';
|
const iconButtonBaseClass = 'flex items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0';
|
||||||
const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass);
|
const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass);
|
||||||
|
|
||||||
// Send button - respects queue mode setting
|
// Send button - respects queue mode setting
|
||||||
@@ -1981,8 +1981,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
footerIconButtonClass,
|
footerIconButtonClass,
|
||||||
'rounded-md text-muted-foreground',
|
'rounded-md',
|
||||||
'hover:bg-interactive-hover/40 hover:text-foreground'
|
'hover:bg-interactive-hover/40'
|
||||||
)}
|
)}
|
||||||
onPointerDownCapture={(event) => {
|
onPointerDownCapture={(event) => {
|
||||||
if (event.pointerType === 'touch') {
|
if (event.pointerType === 'touch') {
|
||||||
@@ -2288,7 +2288,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
'rounded-md',
|
'rounded-md',
|
||||||
isExpandedInput
|
isExpandedInput
|
||||||
? 'text-primary'
|
? 'text-primary'
|
||||||
: 'text-muted-foreground hover:bg-[var(--interactive-hover)]/40 hover:text-foreground'
|
: 'text-foreground hover:bg-[var(--interactive-hover)]/40'
|
||||||
)}
|
)}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -920,7 +920,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
displayParts.length === 0 ? null : (
|
displayParts.length === 0 ? null : (
|
||||||
<FadeInOnReveal>
|
<FadeInOnReveal>
|
||||||
<div className="flex justify-end">
|
<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-sm border border-primary/5">
|
<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
|
<MessageBody
|
||||||
messageId={message.info.id}
|
messageId={message.info.id}
|
||||||
parts={displayParts}
|
parts={displayParts}
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
|
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{showTabs ? (
|
{showTabs ? (
|
||||||
@@ -269,7 +269,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
|
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-interactive-selection text-interactive-selection-foreground shadow-sm'
|
? 'bg-interactive-selection text-interactive-selection-foreground shadow-none'
|
||||||
: 'text-muted-foreground hover:bg-interactive-hover/50'
|
: 'text-muted-foreground hover:bg-interactive-hover/50'
|
||||||
)}
|
)}
|
||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="absolute z-[100] min-w-0 w-full max-w-[520px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
|
className="absolute z-[100] min-w-0 w-full max-w-[520px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{showTabs ? (
|
{showTabs ? (
|
||||||
@@ -385,7 +385,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
|
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-interactive-selection text-interactive-selection-foreground shadow-sm'
|
? 'bg-interactive-selection text-interactive-selection-foreground shadow-none'
|
||||||
: 'text-muted-foreground hover:bg-interactive-hover/50'
|
: 'text-muted-foreground hover:bg-interactive-hover/50'
|
||||||
)}
|
)}
|
||||||
onPointerDown={(event) => {
|
onPointerDown={(event) => {
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | nul
|
|||||||
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
{copied ? <RiCheckLine className="size-3.5" /> : <RiFileCopyLine className="size-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
{showMenu && (
|
{showMenu && (
|
||||||
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-lg">
|
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-none">
|
||||||
<button
|
<button
|
||||||
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
|
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
|
||||||
onClick={() => handleCopy('csv')}
|
onClick={() => handleCopy('csv')}
|
||||||
@@ -364,7 +364,7 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
|
|||||||
<RiDownloadLine className="size-3.5" />
|
<RiDownloadLine className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
{showMenu && (
|
{showMenu && (
|
||||||
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-lg">
|
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-none">
|
||||||
<button
|
<button
|
||||||
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
|
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
|
||||||
onClick={() => handleDownload('csv')}
|
onClick={() => handleDownload('csv')}
|
||||||
|
|||||||
@@ -45,6 +45,34 @@ const getMessageParentId = (message: ChatMessageEntry): string | null => {
|
|||||||
return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null;
|
return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasSameTurnStructure = (prev: ChatMessageEntry[], next: ChatMessageEntry[]): boolean => {
|
||||||
|
if (prev === next) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (prev.length !== next.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < prev.length; index += 1) {
|
||||||
|
const prevMessage = prev[index];
|
||||||
|
const nextMessage = next[index];
|
||||||
|
|
||||||
|
if (prevMessage.info.id !== nextMessage.info.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolveMessageRole(prevMessage) !== resolveMessageRole(nextMessage)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getMessageParentId(prevMessage) !== getMessageParentId(nextMessage)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
|
const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
|
||||||
if (!message) return false;
|
if (!message) return false;
|
||||||
if (resolveMessageRole(message) !== 'user') return false;
|
if (resolveMessageRole(message) !== 'user') return false;
|
||||||
@@ -423,6 +451,13 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
}) => {
|
}) => {
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
|
const turnStructureCacheRef = React.useRef<{
|
||||||
|
messages: ChatMessageEntry[];
|
||||||
|
turns: Turn[];
|
||||||
|
ungroupedMessages: ChatMessageEntry[];
|
||||||
|
} | null>(null);
|
||||||
|
const normalizedMessageCacheRef = React.useRef<Map<string, { source: ChatMessageEntry; normalized: ChatMessageEntry }>>(new Map());
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (permissions.length === 0 && questions.length === 0) {
|
if (permissions.length === 0 && questions.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -432,6 +467,7 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
|
|
||||||
const baseDisplayMessages = React.useMemo(() => {
|
const baseDisplayMessages = React.useMemo(() => {
|
||||||
const seenIds = new Set<string>();
|
const seenIds = new Set<string>();
|
||||||
|
const nextNormalizedCache = new Map<string, { source: ChatMessageEntry; normalized: ChatMessageEntry }>();
|
||||||
const normalizedMessages = messages
|
const normalizedMessages = messages
|
||||||
.filter((message) => {
|
.filter((message) => {
|
||||||
const messageId = message.info?.id;
|
const messageId = message.info?.id;
|
||||||
@@ -443,19 +479,30 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.map((message) => {
|
.map((message, index) => {
|
||||||
const filteredParts = filterSyntheticParts(message.parts);
|
const messageId = typeof message.info?.id === 'string' && message.info.id.length > 0
|
||||||
// Optimization: If parts haven't changed, return the original message object.
|
? message.info.id
|
||||||
// This preserves referential equality and prevents unnecessary re-renders of ChatMessage (which is memoized).
|
: `__idx_${index}`;
|
||||||
if (filteredParts === message.parts) {
|
const cacheKey = `${messageId}:${resolveMessageRole(message) ?? 'unknown'}`;
|
||||||
return message;
|
const cached = normalizedMessageCacheRef.current.get(cacheKey);
|
||||||
|
if (cached && cached.source === message) {
|
||||||
|
nextNormalizedCache.set(cacheKey, cached);
|
||||||
|
return cached.normalized;
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
...message,
|
const filteredParts = filterSyntheticParts(message.parts);
|
||||||
parts: filteredParts,
|
const normalized = filteredParts === message.parts
|
||||||
};
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
parts: filteredParts,
|
||||||
|
};
|
||||||
|
nextNormalizedCache.set(cacheKey, { source: message, normalized });
|
||||||
|
return normalized;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
normalizedMessageCacheRef.current = nextNormalizedCache;
|
||||||
|
|
||||||
const output: ChatMessageEntry[] = [];
|
const output: ChatMessageEntry[] = [];
|
||||||
|
|
||||||
for (let index = 0; index < normalizedMessages.length; index += 1) {
|
for (let index = 0; index < normalizedMessages.length; index += 1) {
|
||||||
@@ -573,6 +620,14 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
}, [activeRetryStatus, baseDisplayMessages]);
|
}, [activeRetryStatus, baseDisplayMessages]);
|
||||||
|
|
||||||
const { turns, ungroupedMessages } = React.useMemo(() => {
|
const { turns, ungroupedMessages } = React.useMemo(() => {
|
||||||
|
const cached = turnStructureCacheRef.current;
|
||||||
|
if (cached && hasSameTurnStructure(cached.messages, displayMessages)) {
|
||||||
|
return {
|
||||||
|
turns: cached.turns,
|
||||||
|
ungroupedMessages: cached.ungroupedMessages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const groupedTurns = detectTurns(displayMessages);
|
const groupedTurns = detectTurns(displayMessages);
|
||||||
const groupedMessageIds = new Set<string>();
|
const groupedMessageIds = new Set<string>();
|
||||||
|
|
||||||
@@ -584,11 +639,18 @@ const MessageList: React.FC<MessageListProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ungrouped = displayMessages.filter((message) => !groupedMessageIds.has(message.info.id));
|
const ungrouped = displayMessages.filter((message) => !groupedMessageIds.has(message.info.id));
|
||||||
|
const nextValue = {
|
||||||
return {
|
|
||||||
turns: groupedTurns,
|
turns: groupedTurns,
|
||||||
ungroupedMessages: ungrouped,
|
ungroupedMessages: ungrouped,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
turnStructureCacheRef.current = {
|
||||||
|
messages: displayMessages,
|
||||||
|
turns: groupedTurns,
|
||||||
|
ungroupedMessages: ungrouped,
|
||||||
|
};
|
||||||
|
|
||||||
|
return nextValue;
|
||||||
}, [displayMessages]);
|
}, [displayMessages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
|
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2" fillContainer={false}>
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2" fillContainer={false}>
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-0 bottom-full mb-1 z-50",
|
"absolute right-0 bottom-full mb-1 z-50",
|
||||||
"w-max min-w-[200px]",
|
"w-max min-w-[200px]",
|
||||||
"rounded-xl border border-border bg-background shadow-md",
|
"rounded-xl border border-border bg-background shadow-none",
|
||||||
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
|
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
|
||||||
"duration-150"
|
"duration-150"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -510,6 +510,26 @@ const getMessageRole = (message: ChatMessageEntry): string => {
|
|||||||
return typeof role === 'string' ? role : '';
|
return typeof role === 'string' ? role : '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasSameTurnStructure = (prev: ChatMessageEntry[], next: ChatMessageEntry[]): boolean => {
|
||||||
|
if (prev === next) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (prev.length !== next.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < prev.length; index += 1) {
|
||||||
|
if (prev[index]?.info?.id !== next[index]?.info?.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (getMessageRole(prev[index]) !== getMessageRole(next[index])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const getStructureKey = (messages: ChatMessageEntry[]): string => {
|
const getStructureKey = (messages: ChatMessageEntry[]): string => {
|
||||||
if (messages.length === 0) return '';
|
if (messages.length === 0) return '';
|
||||||
return messages
|
return messages
|
||||||
@@ -517,13 +537,116 @@ const getStructureKey = (messages: ChatMessageEntry[]): string => {
|
|||||||
.join('|');
|
.join('|');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAppendOnlyChange = (prev: ChatMessageEntry[], next: ChatMessageEntry[]): boolean => {
|
||||||
|
if (prev.length > next.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < prev.length; index += 1) {
|
||||||
|
if (prev[index]?.info?.id !== next[index]?.info?.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (getMessageRole(prev[index]) !== getMessageRole(next[index])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendTurnsIncremental = (prevTurns: Turn[], appendedMessages: ChatMessageEntry[]): Turn[] => {
|
||||||
|
if (appendedMessages.length === 0) {
|
||||||
|
return prevTurns;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTurns = prevTurns.length > 0
|
||||||
|
? [
|
||||||
|
...prevTurns.slice(0, -1),
|
||||||
|
{
|
||||||
|
...prevTurns[prevTurns.length - 1],
|
||||||
|
assistantMessages: [...prevTurns[prevTurns.length - 1].assistantMessages],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let currentTurn = nextTurns.length > 0 ? nextTurns[nextTurns.length - 1] : null;
|
||||||
|
|
||||||
|
appendedMessages.forEach((message) => {
|
||||||
|
const role = getMessageRole(message);
|
||||||
|
if (role === 'user') {
|
||||||
|
currentTurn = {
|
||||||
|
turnId: message.info.id,
|
||||||
|
userMessage: message,
|
||||||
|
assistantMessages: [],
|
||||||
|
};
|
||||||
|
nextTurns.push(currentTurn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'assistant' && currentTurn) {
|
||||||
|
currentTurn.assistantMessages.push(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextTurns;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendNeighborsIncremental = (
|
||||||
|
prevNeighbors: Map<string, NeighborInfo>,
|
||||||
|
prevMessages: ChatMessageEntry[],
|
||||||
|
nextMessages: ChatMessageEntry[],
|
||||||
|
): Map<string, NeighborInfo> => {
|
||||||
|
if (nextMessages.length <= prevMessages.length) {
|
||||||
|
return prevNeighbors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appended = nextMessages.slice(prevMessages.length);
|
||||||
|
if (appended.length === 0) {
|
||||||
|
return prevNeighbors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextNeighbors = new Map(prevNeighbors);
|
||||||
|
const previousTail = prevMessages.length > 0 ? prevMessages[prevMessages.length - 1] : undefined;
|
||||||
|
if (previousTail) {
|
||||||
|
nextNeighbors.set(previousTail.info.id, {
|
||||||
|
previousMessage: prevMessages.length > 1 ? prevMessages[prevMessages.length - 2] : undefined,
|
||||||
|
nextMessage: appended[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
appended.forEach((message, index) => {
|
||||||
|
const previousMessage = index === 0 ? previousTail : appended[index - 1];
|
||||||
|
const nextMessage = index < appended.length - 1 ? appended[index + 1] : undefined;
|
||||||
|
nextNeighbors.set(message.info.id, {
|
||||||
|
previousMessage,
|
||||||
|
nextMessage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextNeighbors;
|
||||||
|
};
|
||||||
|
|
||||||
export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ messages, children }) => {
|
export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ messages, children }) => {
|
||||||
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
|
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
|
||||||
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
|
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
|
||||||
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
|
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
|
||||||
const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed';
|
const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed';
|
||||||
const structureKey = React.useMemo(() => getStructureKey(messages), [messages]);
|
const structureKeyCacheRef = React.useRef<{ messages: ChatMessageEntry[]; key: string } | null>(null);
|
||||||
|
const structureKey = React.useMemo(() => {
|
||||||
|
const cached = structureKeyCacheRef.current;
|
||||||
|
if (cached && hasSameTurnStructure(cached.messages, messages)) {
|
||||||
|
return cached.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getStructureKey(messages);
|
||||||
|
structureKeyCacheRef.current = {
|
||||||
|
messages,
|
||||||
|
key,
|
||||||
|
};
|
||||||
|
return key;
|
||||||
|
}, [messages]);
|
||||||
const staticCacheRef = React.useRef<{
|
const staticCacheRef = React.useRef<{
|
||||||
|
messages: ChatMessageEntry[];
|
||||||
structureKey: string;
|
structureKey: string;
|
||||||
defaultActivityExpanded: boolean;
|
defaultActivityExpanded: boolean;
|
||||||
showTextJustificationActivity: boolean;
|
showTextJustificationActivity: boolean;
|
||||||
@@ -542,6 +665,76 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
|
|||||||
return cached.value;
|
return cached.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cached &&
|
||||||
|
cached.defaultActivityExpanded === defaultActivityExpanded &&
|
||||||
|
cached.showTextJustificationActivity === showTextJustificationActivity &&
|
||||||
|
isAppendOnlyChange(cached.messages, messages)
|
||||||
|
) {
|
||||||
|
const appendedMessages = messages.slice(cached.messages.length);
|
||||||
|
const turns = appendTurnsIncremental(cached.value.turns, appendedMessages);
|
||||||
|
const lastTurnId = turns.length > 0 ? turns[turns.length - 1]!.turnId : null;
|
||||||
|
|
||||||
|
const messageToTurn = new Map(cached.value.messageToTurn);
|
||||||
|
let currentTurn = turns.length > 0 ? turns[turns.length - 1] : null;
|
||||||
|
appendedMessages.forEach((message) => {
|
||||||
|
const role = getMessageRole(message);
|
||||||
|
if (role === 'user') {
|
||||||
|
currentTurn = turns.find((turn) => turn.turnId === message.info.id) ?? null;
|
||||||
|
if (currentTurn) {
|
||||||
|
messageToTurn.set(message.info.id, currentTurn);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (role === 'assistant' && currentTurn) {
|
||||||
|
messageToTurn.set(message.info.id, currentTurn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const turnActivityInfo = new Map(cached.value.turnActivityInfo);
|
||||||
|
const previousLastTurnId = cached.value.lastTurnId;
|
||||||
|
if (previousLastTurnId && previousLastTurnId !== lastTurnId) {
|
||||||
|
const finalizedTurn = turns.find((turn) => turn.turnId === previousLastTurnId);
|
||||||
|
if (finalizedTurn) {
|
||||||
|
turnActivityInfo.set(previousLastTurnId, getTurnActivityInfo(finalizedTurn, showTextJustificationActivity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastTurnId) {
|
||||||
|
turnActivityInfo.delete(lastTurnId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageNeighbors = appendNeighborsIncremental(cached.value.messageNeighbors, cached.messages, messages);
|
||||||
|
|
||||||
|
const lastTurnMessageIds = new Set<string>();
|
||||||
|
if (turns.length > 0) {
|
||||||
|
const lastTurn = turns[turns.length - 1]!;
|
||||||
|
lastTurnMessageIds.add(lastTurn.userMessage.info.id);
|
||||||
|
lastTurn.assistantMessages.forEach((msg) => {
|
||||||
|
lastTurnMessageIds.add(msg.info.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const value: TurnGroupingStaticData = {
|
||||||
|
turns,
|
||||||
|
messageToTurn,
|
||||||
|
turnActivityInfo,
|
||||||
|
lastTurnId,
|
||||||
|
lastTurnMessageIds,
|
||||||
|
defaultActivityExpanded,
|
||||||
|
messageNeighbors,
|
||||||
|
};
|
||||||
|
|
||||||
|
staticCacheRef.current = {
|
||||||
|
messages,
|
||||||
|
structureKey,
|
||||||
|
defaultActivityExpanded,
|
||||||
|
showTextJustificationActivity,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
const turns = detectTurns(messages);
|
const turns = detectTurns(messages);
|
||||||
const lastTurnId = turns.length > 0 ? turns[turns.length - 1]!.turnId : null;
|
const lastTurnId = turns.length > 0 ? turns[turns.length - 1]!.turnId : null;
|
||||||
|
|
||||||
@@ -582,6 +775,7 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
|
|||||||
};
|
};
|
||||||
|
|
||||||
staticCacheRef.current = {
|
staticCacheRef.current = {
|
||||||
|
messages,
|
||||||
structureKey,
|
structureKey,
|
||||||
defaultActivityExpanded,
|
defaultActivityExpanded,
|
||||||
showTextJustificationActivity,
|
showTextJustificationActivity,
|
||||||
@@ -601,12 +795,29 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
|
|||||||
if (!lastTurn) return undefined;
|
if (!lastTurn) return undefined;
|
||||||
// Re-slice assistant messages from the live `messages` array so that
|
// Re-slice assistant messages from the live `messages` array so that
|
||||||
// streamed part updates are reflected without re-detecting all turns.
|
// streamed part updates are reflected without re-detecting all turns.
|
||||||
const userIdx = messages.findIndex((m) => m.info.id === lastTurn.userMessage.info.id);
|
const lastTurnUserId = lastTurn.userMessage.info.id;
|
||||||
if (userIdx < 0) return getTurnActivityInfo(lastTurn, showTextJustificationActivity);
|
const liveAssistant: ChatMessageEntry[] = [];
|
||||||
const liveAssistant = messages.slice(userIdx + 1).filter((m) => {
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
const role = (m.info as { clientRole?: string | null }).clientRole ?? m.info.role;
|
const candidate = messages[index];
|
||||||
return role === 'assistant';
|
if (!candidate) {
|
||||||
});
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.info.id === lastTurnUserId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = (candidate.info as { clientRole?: string | null }).clientRole ?? candidate.info.role;
|
||||||
|
if (role === 'assistant') {
|
||||||
|
liveAssistant.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liveAssistant.length === 0 && messages.every((message) => message.info.id !== lastTurnUserId)) {
|
||||||
|
return getTurnActivityInfo(lastTurn, showTextJustificationActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
liveAssistant.reverse();
|
||||||
const liveTurn: Turn = { ...lastTurn, assistantMessages: liveAssistant };
|
const liveTurn: Turn = { ...lastTurn, assistantMessages: liveAssistant };
|
||||||
return getTurnActivityInfo(liveTurn, showTextJustificationActivity);
|
return getTurnActivityInfo(liveTurn, showTextJustificationActivity);
|
||||||
}, [staticValue, messages, showTextJustificationActivity]);
|
}, [staticValue, messages, showTextJustificationActivity]);
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1',
|
'flex items-center gap-1',
|
||||||
'rounded-lg border border-[var(--interactive-border)]',
|
'rounded-lg border border-[var(--interactive-border)]',
|
||||||
'bg-[var(--surface-elevated)] shadow-lg',
|
'bg-[var(--surface-elevated)] shadow-none',
|
||||||
'px-1.5 py-1',
|
'px-1.5 py-1',
|
||||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||||
isClosing
|
isClosing
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function InlineCommentCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border shadow-sm w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden transition-all duration-200",
|
"rounded-lg border shadow-none w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden transition-all duration-200",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function InlineCommentInput({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border shadow-sm w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden animate-in fade-in zoom-in-95 duration-200",
|
"rounded-lg border shadow-none w-full max-w-[min(100%,calc(var(--oc-context-panel-width,100vw)-var(--oc-editor-gutter-width,0px)))] overflow-hidden animate-in fade-in zoom-in-95 duration-200",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'app-region-no-drag inline-flex h-7 items-center self-center rounded-md border border-[var(--interactive-border)]',
|
'app-region-no-drag inline-flex h-7 items-center self-center rounded-md border border-[var(--interactive-border)]',
|
||||||
'bg-[var(--surface-elevated)] shadow-sm overflow-hidden',
|
'bg-[var(--surface-elevated)] shadow-none overflow-hidden',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -211,7 +211,8 @@ export const ContextPanel: React.FC = () => {
|
|||||||
data-context-panel="true"
|
data-context-panel="true"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex min-h-0 flex-col overflow-hidden border-l border-border bg-background',
|
'flex min-h-0 flex-col overflow-hidden bg-background',
|
||||||
|
!isExpanded && 'border-l border-border/40',
|
||||||
isExpanded
|
isExpanded
|
||||||
? 'absolute inset-0 z-20 min-w-0'
|
? 'absolute inset-0 z-20 min-w-0'
|
||||||
: 'relative h-full flex-shrink-0',
|
: 'relative h-full flex-shrink-0',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect } from 'react';
|
||||||
import { motion, useMotionValue, animate } from 'motion/react';
|
import { motion, useMotionValue, animate } from 'motion/react';
|
||||||
import { RiSettings3Line } from '@remixicon/react';
|
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
import { BottomTerminalDock } from './BottomTerminalDock';
|
import { BottomTerminalDock } from './BottomTerminalDock';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { NavRail } from './NavRail';
|
||||||
import { RightSidebar } from './RightSidebar';
|
import { RightSidebar } from './RightSidebar';
|
||||||
import { RightSidebarTabs } from './RightSidebarTabs';
|
import { RightSidebarTabs } from './RightSidebarTabs';
|
||||||
import { ContextPanel } from './ContextPanel';
|
import { ContextPanel } from './ContextPanel';
|
||||||
@@ -659,25 +659,15 @@ export const MainLayout: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
aria-hidden={!mobileLeftDrawerOpen}
|
aria-hidden={!mobileLeftDrawerOpen}
|
||||||
>
|
>
|
||||||
<div className="h-full overflow-hidden flex flex-col bg-sidebar shadow-xl drawer-safe-area">
|
<div className="h-full overflow-hidden flex bg-sidebar shadow-none drawer-safe-area">
|
||||||
<div className="flex-1 overflow-hidden">
|
<div onPointerDownCapture={(e) => e.stopPropagation()}>
|
||||||
|
<NavRail className="shrink-0" mobile />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 overflow-hidden flex flex-col">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<SessionSidebar mobileVariant />
|
<SessionSidebar mobileVariant />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border p-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setMobileLeftDrawerOpen(false);
|
|
||||||
setSettingsDialogOpen(true);
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-muted-foreground hover:bg-interactive-hover hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<RiSettings3Line className="h-5 w-5" />
|
|
||||||
<span className="typography-ui-label">Settings</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
|
|
||||||
@@ -720,7 +710,7 @@ export const MainLayout: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
aria-hidden={!isRightSidebarOpen}
|
aria-hidden={!isRightSidebarOpen}
|
||||||
>
|
>
|
||||||
<div className="h-full overflow-hidden flex flex-col bg-background shadow-xl drawer-safe-area">
|
<div className="h-full overflow-hidden flex flex-col bg-background shadow-none drawer-safe-area">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<GitView mode="sidebar" />
|
<GitView mode="sidebar" />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
@@ -775,6 +765,8 @@ export const MainLayout: React.FC = () => {
|
|||||||
<div className={cn('absolute inset-0 flex flex-col', isMultiRunLauncherOpen && 'invisible')}>
|
<div className={cn('absolute inset-0 flex flex-col', isMultiRunLauncherOpen && 'invisible')}>
|
||||||
<Header />
|
<Header />
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<NavRail />
|
||||||
|
<div className="flex flex-1 min-w-0 overflow-hidden border-t border-l border-border/50 rounded-tl-xl">
|
||||||
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
|
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile}>
|
||||||
<SessionSidebar hideProjectSelector />
|
<SessionSidebar hideProjectSelector />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
@@ -801,6 +793,7 @@ export const MainLayout: React.FC = () => {
|
|||||||
<ErrorBoundary><TerminalView /></ErrorBoundary>
|
<ErrorBoundary><TerminalView /></ErrorBoundary>
|
||||||
</BottomTerminalDock>
|
</BottomTerminalDock>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,690 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
type Modifier,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import {
|
||||||
|
RiFolderAddLine,
|
||||||
|
RiSettings3Line,
|
||||||
|
RiQuestionLine,
|
||||||
|
RiDownloadLine,
|
||||||
|
RiInformationLine,
|
||||||
|
RiPencilLine,
|
||||||
|
RiCloseLine,
|
||||||
|
} from '@remixicon/react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { toast } from '@/components/ui';
|
||||||
|
|
||||||
|
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||||
|
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
|
import { cn, formatDirectoryName, hasModifier } from '@/lib/utils';
|
||||||
|
import { PROJECT_ICON_MAP, PROJECT_COLOR_MAP } from '@/lib/projectMeta';
|
||||||
|
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||||
|
import { useLongPress } from '@/hooks/useLongPress';
|
||||||
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
|
import type { ProjectEntry } from '@/lib/api/types';
|
||||||
|
|
||||||
|
const normalize = (value: string): string => {
|
||||||
|
if (!value) return '';
|
||||||
|
const replaced = value.replace(/\\/g, '/');
|
||||||
|
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAV_RAIL_WIDTH = 56;
|
||||||
|
|
||||||
|
/** Tinted background for project tiles — uses project color at low opacity, or neutral fallback */
|
||||||
|
const TileBackground: React.FC<{ colorVar: string | null; children: React.ReactNode }> = ({
|
||||||
|
colorVar,
|
||||||
|
children,
|
||||||
|
}) => (
|
||||||
|
<span
|
||||||
|
className="relative flex h-full w-full items-center justify-center rounded-lg overflow-hidden"
|
||||||
|
style={{ backgroundColor: 'var(--surface-muted)' }}
|
||||||
|
>
|
||||||
|
{colorVar && (
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 opacity-15"
|
||||||
|
style={{ backgroundColor: colorVar }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative z-10 flex items-center justify-center">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** First-letter avatar fallback */
|
||||||
|
const LetterAvatar: React.FC<{ label: string; color?: string | null }> = ({
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
}) => {
|
||||||
|
const letter = label.charAt(0).toUpperCase() || '?';
|
||||||
|
const colorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex h-4 w-4 items-center justify-center text-[15px] font-medium leading-none select-none"
|
||||||
|
style={{ color: colorVar ?? 'var(--surface-foreground)', fontFamily: 'var(--font-mono, monospace)' }}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectStatusDots: React.FC<{
|
||||||
|
color: string;
|
||||||
|
variant?: 'streaming' | 'attention' | 'none';
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}> = ({ color, variant = 'none', size = 'md' }) => (
|
||||||
|
<span className="inline-flex items-center justify-center gap-px" aria-hidden="true">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<span key={index} className="inline-flex h-[3px] w-[3px] items-center justify-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
size === 'sm' ? 'h-[2.5px] w-[2.5px]' : 'h-[3px] w-[3px]',
|
||||||
|
'rounded-full',
|
||||||
|
variant === 'streaming' && 'animate-grid-pulse',
|
||||||
|
variant === 'attention' && 'animate-attention-diamond-pulse'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
animationDelay: variant === 'streaming'
|
||||||
|
? `${index * 150}ms`
|
||||||
|
: variant === 'attention'
|
||||||
|
? (index === 1 ? '0ms' : '130ms')
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Single project tile in the nav rail — right-click for context menu (no visible 3-dot) */
|
||||||
|
const ProjectTile: React.FC<{
|
||||||
|
project: ProjectEntry;
|
||||||
|
isActive: boolean;
|
||||||
|
hasStreaming: boolean;
|
||||||
|
hasUnread: boolean;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = ({ project, isActive, hasStreaming, hasUnread, label, onClick, onEdit, onClose }) => {
|
||||||
|
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||||
|
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||||
|
const projectColorVar = project.color ? (PROJECT_COLOR_MAP[project.color] ?? null) : null;
|
||||||
|
const showStreamingDots = hasStreaming;
|
||||||
|
const showAttentionDots = !hasStreaming && hasUnread;
|
||||||
|
|
||||||
|
const longPressHandlers = useLongPress({
|
||||||
|
onLongPress: () => setMenuOpen(true),
|
||||||
|
onTap: onClick,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip delayDuration={400}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
// Only handle right-click (desktop), not long-tap (mobile)
|
||||||
|
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.button === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
setMenuOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasStreaming ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{...longPressHandlers}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-9 items-center justify-center rounded-lg overflow-hidden cursor-default',
|
||||||
|
isActive
|
||||||
|
? 'bg-transparent border border-[var(--surface-foreground)]'
|
||||||
|
: 'bg-transparent border border-transparent hover:bg-[var(--interactive-hover)]/50 hover:border-[var(--interactive-border)]',
|
||||||
|
menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TileBackground colorVar={projectColorVar}>
|
||||||
|
<span className="relative h-full w-full leading-none">
|
||||||
|
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
{ProjectIcon ? (
|
||||||
|
<ProjectIcon
|
||||||
|
className="h-4 w-4 shrink-0"
|
||||||
|
style={projectColorVar ? { color: projectColorVar } : { color: 'var(--surface-foreground)' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LetterAvatar label={label} color={project.color} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{showStreamingDots && (
|
||||||
|
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
|
||||||
|
<ProjectStatusDots color="var(--primary)" variant="streaming" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TileBackground>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{...longPressHandlers}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-9 items-center justify-center rounded-lg overflow-hidden cursor-default',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
|
||||||
|
isActive
|
||||||
|
? 'bg-transparent border border-[var(--surface-foreground)]'
|
||||||
|
: 'bg-transparent border border-transparent hover:bg-[var(--interactive-hover)]/50 hover:border-[var(--interactive-border)]',
|
||||||
|
menuOpen && !isActive && 'bg-[var(--interactive-hover)]/50 border-[var(--interactive-border)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TileBackground colorVar={projectColorVar}>
|
||||||
|
<span className="relative h-full w-full leading-none">
|
||||||
|
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
{ProjectIcon ? (
|
||||||
|
<ProjectIcon
|
||||||
|
className="h-4 w-4 shrink-0"
|
||||||
|
style={projectColorVar ? { color: projectColorVar } : { color: 'var(--surface-foreground)' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LetterAvatar label={label} color={project.color} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{showAttentionDots && (
|
||||||
|
<span className="pointer-events-none absolute inset-x-0 top-[calc(50%+9px)] flex justify-center">
|
||||||
|
<ProjectStatusDots color="var(--status-info)" variant="attention" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TileBackground>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
{label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<span className="sr-only">Project options</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" side="right" sideOffset={4} className="min-w-[160px]">
|
||||||
|
<DropdownMenuItem onClick={onEdit} className="gap-2">
|
||||||
|
<RiPencilLine className="h-4 w-4" />
|
||||||
|
Edit project
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-destructive focus:text-destructive gap-2"
|
||||||
|
>
|
||||||
|
<RiCloseLine className="h-4 w-4" />
|
||||||
|
Close project
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Constrain drag to Y axis only */
|
||||||
|
const restrictToYAxis: Modifier = ({ transform }) => ({
|
||||||
|
...transform,
|
||||||
|
x: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Sortable wrapper for ProjectTile */
|
||||||
|
const SortableProjectTile: React.FC<{
|
||||||
|
id: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}> = ({ id, children }) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}}
|
||||||
|
className={cn(isDragging && 'opacity-30 z-50')}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NavRailProps {
|
||||||
|
className?: string;
|
||||||
|
mobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
|
||||||
|
const projects = useProjectsStore((s) => s.projects);
|
||||||
|
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
|
||||||
|
const setActiveProjectIdOnly = useProjectsStore((s) => s.setActiveProjectIdOnly);
|
||||||
|
const addProject = useProjectsStore((s) => s.addProject);
|
||||||
|
const removeProject = useProjectsStore((s) => s.removeProject);
|
||||||
|
const reorderProjects = useProjectsStore((s) => s.reorderProjects);
|
||||||
|
const updateProjectMeta = useProjectsStore((s) => s.updateProjectMeta);
|
||||||
|
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
|
||||||
|
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
|
||||||
|
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
||||||
|
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
|
||||||
|
|
||||||
|
const sessionStatus = useSessionStore((s) => s.sessionStatus);
|
||||||
|
const sessionAttentionStates = useSessionStore((s) => s.sessionAttentionStates);
|
||||||
|
const sessionsByDirectory = useSessionStore((s) => s.sessionsByDirectory);
|
||||||
|
const getSessionsByDirectory = useSessionStore((s) => s.getSessionsByDirectory);
|
||||||
|
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||||
|
const availableWorktreesByProject = useSessionStore((s) => s.availableWorktreesByProject);
|
||||||
|
|
||||||
|
const updateStore = useUpdateStore();
|
||||||
|
const { available: updateAvailable, downloaded: updateDownloaded } = updateStore;
|
||||||
|
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const [editingProject, setEditingProject] = React.useState<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
icon?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
|
||||||
|
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
||||||
|
|
||||||
|
const formatLabel = React.useCallback(
|
||||||
|
(project: ProjectEntry): string => {
|
||||||
|
return (
|
||||||
|
project.label?.trim() ||
|
||||||
|
formatDirectoryName(project.path, homeDirectory) ||
|
||||||
|
project.path
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[homeDirectory],
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectIndicators = React.useMemo(() => {
|
||||||
|
const result = new Map<string, { hasStreaming: boolean; hasUnread: boolean }>();
|
||||||
|
for (const project of projects) {
|
||||||
|
const projectRoot = normalize(project.path);
|
||||||
|
if (!projectRoot) {
|
||||||
|
result.set(project.id, { hasStreaming: false, hasUnread: false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirs: string[] = [projectRoot];
|
||||||
|
const worktrees = availableWorktreesByProject.get(projectRoot) ?? [];
|
||||||
|
for (const meta of worktrees) {
|
||||||
|
const p =
|
||||||
|
meta && typeof meta === 'object' && 'path' in meta
|
||||||
|
? (meta as { path?: unknown }).path
|
||||||
|
: null;
|
||||||
|
if (typeof p === 'string' && p.trim()) {
|
||||||
|
const normalized = normalize(p);
|
||||||
|
if (normalized && normalized !== projectRoot) {
|
||||||
|
dirs.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let hasStreaming = false;
|
||||||
|
let hasUnread = false;
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
|
||||||
|
for (const session of list) {
|
||||||
|
if (!session?.id || seen.has(session.id)) continue;
|
||||||
|
seen.add(session.id);
|
||||||
|
|
||||||
|
const statusType = sessionStatus?.get(session.id)?.type ?? 'idle';
|
||||||
|
if (statusType === 'busy' || statusType === 'retry') {
|
||||||
|
hasStreaming = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCurrentVisible =
|
||||||
|
session.id === currentSessionId && project.id === activeProjectId;
|
||||||
|
if (
|
||||||
|
!isCurrentVisible &&
|
||||||
|
sessionAttentionStates.get(session.id)?.needsAttention === true
|
||||||
|
) {
|
||||||
|
hasUnread = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasStreaming && hasUnread) break;
|
||||||
|
}
|
||||||
|
if (hasStreaming && hasUnread) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.set(project.id, { hasStreaming, hasUnread });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [
|
||||||
|
activeProjectId,
|
||||||
|
availableWorktreesByProject,
|
||||||
|
currentSessionId,
|
||||||
|
getSessionsByDirectory,
|
||||||
|
projects,
|
||||||
|
sessionAttentionStates,
|
||||||
|
sessionStatus,
|
||||||
|
sessionsByDirectory,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleAddProject = React.useCallback(() => {
|
||||||
|
if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
|
||||||
|
sessionEvents.requestDirectoryDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
import('@/lib/desktop')
|
||||||
|
.then(({ requestDirectoryAccess }) => requestDirectoryAccess(''))
|
||||||
|
.then((result) => {
|
||||||
|
if (result.success && result.path) {
|
||||||
|
const added = addProject(result.path, { id: result.projectId });
|
||||||
|
if (!added) {
|
||||||
|
toast.error('Failed to add project', {
|
||||||
|
description: 'Please select a valid directory.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (result.error && result.error !== 'Directory selection cancelled') {
|
||||||
|
toast.error('Failed to select directory', { description: result.error });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to select directory:', error);
|
||||||
|
toast.error('Failed to select directory');
|
||||||
|
});
|
||||||
|
}, [addProject, tauriIpcAvailable]);
|
||||||
|
|
||||||
|
const handleEditProject = React.useCallback(
|
||||||
|
(projectId: string) => {
|
||||||
|
const project = projects.find((p) => p.id === projectId);
|
||||||
|
if (!project) return;
|
||||||
|
setEditingProject({
|
||||||
|
id: project.id,
|
||||||
|
name: formatLabel(project),
|
||||||
|
path: project.path,
|
||||||
|
icon: project.icon,
|
||||||
|
color: project.color,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[projects, formatLabel],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSaveProjectEdit = React.useCallback(
|
||||||
|
(data: { label: string; icon: string | null; color: string | null }) => {
|
||||||
|
if (!editingProject) return;
|
||||||
|
updateProjectMeta(editingProject.id, data);
|
||||||
|
setEditingProject(null);
|
||||||
|
},
|
||||||
|
[editingProject, updateProjectMeta],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCloseProject = React.useCallback(
|
||||||
|
(projectId: string) => {
|
||||||
|
removeProject(projectId);
|
||||||
|
},
|
||||||
|
[removeProject],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cmd/Ctrl+number to switch projects
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
|
||||||
|
const num = parseInt(e.key, 10);
|
||||||
|
if (num >= 1 && num <= projects.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = projects[num - 1];
|
||||||
|
if (target && target.id !== activeProjectId) {
|
||||||
|
setActiveProjectIdOnly(target.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [projects, activeProjectId, setActiveProjectIdOnly]);
|
||||||
|
|
||||||
|
// Drag-to-reorder
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectIds = React.useMemo(() => projects.map((p) => p.id), [projects]);
|
||||||
|
|
||||||
|
const handleDragEnd = React.useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const fromIndex = projects.findIndex((p) => p.id === active.id);
|
||||||
|
const toIndex = projects.findIndex((p) => p.id === over.id);
|
||||||
|
if (fromIndex !== -1 && toIndex !== -1) {
|
||||||
|
reorderProjects(fromIndex, toIndex);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projects, reorderProjects],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navRailActionButtonClass = cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||||
|
'text-foreground hover:bg-interactive-hover',
|
||||||
|
'transition-colors',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]',
|
||||||
|
);
|
||||||
|
|
||||||
|
const navRailActionIconClass = 'h-4.5 w-4.5';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-14 shrink-0 flex-col items-center bg-[var(--surface-background)] overflow-hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label="Project navigation"
|
||||||
|
>
|
||||||
|
{/* Projects list */}
|
||||||
|
<div className="flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden scrollbar-none">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
modifiers={[restrictToYAxis]}
|
||||||
|
>
|
||||||
|
<SortableContext items={projectIds} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="flex flex-col items-center gap-3 px-1 py-3">
|
||||||
|
{projects.map((project) => {
|
||||||
|
const isActive = project.id === activeProjectId;
|
||||||
|
const indicators = projectIndicators.get(project.id);
|
||||||
|
return (
|
||||||
|
<SortableProjectTile key={project.id} id={project.id}>
|
||||||
|
<ProjectTile
|
||||||
|
project={project}
|
||||||
|
isActive={isActive}
|
||||||
|
hasStreaming={indicators?.hasStreaming ?? false}
|
||||||
|
hasUnread={indicators?.hasUnread ?? false}
|
||||||
|
label={formatLabel(project)}
|
||||||
|
onClick={() => {
|
||||||
|
if (project.id !== activeProjectId) {
|
||||||
|
setActiveProjectIdOnly(project.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEdit={() => handleEditProject(project.id)}
|
||||||
|
onClose={() => handleCloseProject(project.id)}
|
||||||
|
/>
|
||||||
|
</SortableProjectTile>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{/* Add project button */}
|
||||||
|
<div className="flex flex-col items-center px-1 pb-3">
|
||||||
|
<Tooltip delayDuration={400}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddProject}
|
||||||
|
className={navRailActionButtonClass}
|
||||||
|
aria-label="Add project"
|
||||||
|
>
|
||||||
|
<RiFolderAddLine className={navRailActionIconClass} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
Add project
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom actions */}
|
||||||
|
<div className="shrink-0 w-full pt-3 pb-4 flex flex-col items-center gap-2">
|
||||||
|
{(updateAvailable || updateDownloaded) && (
|
||||||
|
<Tooltip delayDuration={400}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUpdateDialogOpen(true)}
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||||
|
'bg-[var(--primary)]/10 text-[var(--primary)]',
|
||||||
|
'hover:bg-[var(--primary)]/20 transition-colors',
|
||||||
|
)}
|
||||||
|
aria-label="Update available"
|
||||||
|
>
|
||||||
|
<RiDownloadLine className={navRailActionIconClass} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
Update available
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isDesktopApp && !(updateAvailable || updateDownloaded) && (
|
||||||
|
<Tooltip delayDuration={400}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAboutDialogOpen(true)}
|
||||||
|
className={navRailActionButtonClass}
|
||||||
|
aria-label="About"
|
||||||
|
>
|
||||||
|
<RiInformationLine className={navRailActionIconClass} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
About OpenChamber
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!mobile && (
|
||||||
|
<Tooltip delayDuration={400}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleHelpDialog}
|
||||||
|
className={navRailActionButtonClass}
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
>
|
||||||
|
<RiQuestionLine className={navRailActionIconClass} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
Keyboard shortcuts
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tooltip delayDuration={400}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSettingsDialogOpen(true)}
|
||||||
|
className={navRailActionButtonClass}
|
||||||
|
aria-label="Settings"
|
||||||
|
>
|
||||||
|
<RiSettings3Line className={navRailActionIconClass} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
Settings
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
{editingProject && (
|
||||||
|
<ProjectEditDialog
|
||||||
|
open={!!editingProject}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setEditingProject(null);
|
||||||
|
}}
|
||||||
|
projectName={editingProject.name}
|
||||||
|
projectPath={editingProject.path}
|
||||||
|
initialIcon={editingProject.icon}
|
||||||
|
initialColor={editingProject.color}
|
||||||
|
onSave={handleSaveProjectEdit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<UpdateDialog
|
||||||
|
open={updateDialogOpen}
|
||||||
|
onOpenChange={setUpdateDialogOpen}
|
||||||
|
info={updateStore.info}
|
||||||
|
downloading={updateStore.downloading}
|
||||||
|
downloaded={updateStore.downloaded}
|
||||||
|
progress={updateStore.progress}
|
||||||
|
error={updateStore.error}
|
||||||
|
onDownload={updateStore.downloadUpdate}
|
||||||
|
onRestart={updateStore.restartToUpdate}
|
||||||
|
runtimeType={updateStore.runtimeType}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NAV_RAIL_WIDTH };
|
||||||
@@ -61,7 +61,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children })
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex h-full overflow-hidden border-l border-border bg-sidebar',
|
'relative flex h-full overflow-hidden border-l border-border/40 bg-sidebar/50',
|
||||||
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
|
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
|
||||||
!isOpen && 'border-l-0'
|
!isOpen && 'border-l-0'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export const RightSidebarTabs: React.FC = () => {
|
|||||||
const setRightSidebarTab = useUIStore((state) => state.setRightSidebarTab);
|
const setRightSidebarTab = useUIStore((state) => state.setRightSidebarTab);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-sidebar">
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-transparent">
|
||||||
<div className="border-b border-border/40 bg-background px-3 py-1.5">
|
<div className="border-b border-border/40 bg-transparent px-3 py-1.5">
|
||||||
<AnimatedTabs<RightTab>
|
<AnimatedTabs<RightTab>
|
||||||
value={rightSidebarTab}
|
value={rightSidebarTab}
|
||||||
onValueChange={setRightSidebarTab}
|
onValueChange={setRightSidebarTab}
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { RiDownloadLine, RiInformationLine, RiQuestionLine, RiSettings3Line } from '@remixicon/react';
|
|
||||||
import { toast } from '@/components/ui';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
|
||||||
import { UpdateDialog } from '../ui/UpdateDialog';
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
|
||||||
|
|
||||||
export const SIDEBAR_CONTENT_WIDTH = 264;
|
export const SIDEBAR_CONTENT_WIDTH = 250;
|
||||||
const SIDEBAR_MIN_WIDTH = 300;
|
const SIDEBAR_MIN_WIDTH = 250;
|
||||||
const SIDEBAR_MAX_WIDTH = 500;
|
const SIDEBAR_MAX_WIDTH = 500;
|
||||||
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -20,70 +14,10 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children }) => {
|
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children }) => {
|
||||||
const { sidebarWidth, setSidebarWidth, setSettingsDialogOpen, setAboutDialogOpen, toggleHelpDialog } = useUIStore();
|
const { sidebarWidth, setSidebarWidth } = useUIStore();
|
||||||
const [isResizing, setIsResizing] = React.useState(false);
|
const [isResizing, setIsResizing] = React.useState(false);
|
||||||
const startXRef = React.useRef(0);
|
const startXRef = React.useRef(0);
|
||||||
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
|
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
|
||||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
|
||||||
|
|
||||||
const updateStore = useUpdateStore();
|
|
||||||
const pendingMenuUpdateCheckRef = React.useRef(false);
|
|
||||||
|
|
||||||
const checkForUpdates = updateStore.checkForUpdates;
|
|
||||||
const { available, downloaded, checking } = updateStore;
|
|
||||||
|
|
||||||
const [isDesktopApp, setIsDesktopApp] = React.useState<boolean>(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsDesktopApp(Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMenuUpdateCheck = () => {
|
|
||||||
if (!(window as unknown as { __TAURI__?: unknown }).__TAURI__) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pendingMenuUpdateCheckRef.current = true;
|
|
||||||
void checkForUpdates();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener(CHECK_FOR_UPDATES_EVENT, handleMenuUpdateCheck as EventListener);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener(CHECK_FOR_UPDATES_EVENT, handleMenuUpdateCheck as EventListener);
|
|
||||||
};
|
|
||||||
}, [checkForUpdates]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!pendingMenuUpdateCheckRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (checking) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (available || downloaded) {
|
|
||||||
setUpdateDialogOpen(true);
|
|
||||||
} else {
|
|
||||||
toast.success('No updates available', {
|
|
||||||
description: 'You are running the latest version.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
pendingMenuUpdateCheckRef.current = false;
|
|
||||||
}, [available, downloaded, checking]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isMobile || !isResizing) {
|
if (isMobile || !isResizing) {
|
||||||
@@ -119,7 +53,6 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
|||||||
}, [isMobile, isResizing]);
|
}, [isMobile, isResizing]);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,10 +74,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex h-full overflow-hidden border-r border-border',
|
'relative flex h-full overflow-hidden border-r border-border/40',
|
||||||
isDesktopApp
|
'bg-sidebar/50',
|
||||||
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
|
||||||
: 'bg-sidebar',
|
|
||||||
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
|
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
|
||||||
!isOpen && 'border-r-0'
|
!isOpen && 'border-r-0'
|
||||||
)}
|
)}
|
||||||
@@ -179,86 +110,6 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
|
|||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<ErrorBoundary>{children}</ErrorBoundary>
|
<ErrorBoundary>{children}</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 border-t border-border h-12 px-2 bg-sidebar">
|
|
||||||
<div className="flex h-full items-center justify-between gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setSettingsDialogOpen(true)}
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 items-center gap-2 rounded-md px-2',
|
|
||||||
'text-sm font-semibold text-sidebar-foreground/90',
|
|
||||||
'hover:text-sidebar-foreground hover:bg-interactive-hover',
|
|
||||||
'transition-all duration-200'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RiSettings3Line className="h-4 w-4" />
|
|
||||||
<span>Settings</span>
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{(available || downloaded) ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setUpdateDialogOpen(true)}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 rounded-md px-2 py-1',
|
|
||||||
'text-xs font-semibold',
|
|
||||||
'bg-primary/10 text-primary',
|
|
||||||
'hover:bg-primary/20',
|
|
||||||
'transition-colors'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RiDownloadLine className="h-3.5 w-3.5" />
|
|
||||||
<span>Update</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
) : !isDesktopApp && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={() => setAboutDialogOpen(true)}
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-8 items-center justify-center rounded-md',
|
|
||||||
'text-sidebar-foreground/70',
|
|
||||||
'hover:text-sidebar-foreground hover:bg-interactive-hover',
|
|
||||||
'transition-all duration-200'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RiInformationLine className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">About OpenChamber</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={toggleHelpDialog}
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-8 items-center justify-center rounded-md',
|
|
||||||
'text-sidebar-foreground/70',
|
|
||||||
'hover:text-sidebar-foreground hover:bg-interactive-hover',
|
|
||||||
'transition-all duration-200'
|
|
||||||
)}
|
|
||||||
aria-label="Keyboard shortcuts"
|
|
||||||
>
|
|
||||||
<RiQuestionLine className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">Keyboard shortcuts</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<UpdateDialog
|
|
||||||
open={updateDialogOpen}
|
|
||||||
onOpenChange={setUpdateDialogOpen}
|
|
||||||
info={updateStore.info}
|
|
||||||
downloading={updateStore.downloading}
|
|
||||||
downloaded={updateStore.downloaded}
|
|
||||||
progress={updateStore.progress}
|
|
||||||
error={updateStore.error}
|
|
||||||
onDownload={updateStore.downloadUpdate}
|
|
||||||
onRestart={updateStore.restartToUpdate}
|
|
||||||
runtimeType={updateStore.runtimeType}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -744,7 +744,7 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<span className="absolute top-3.5 left-[-12px] w-3 h-px bg-border/40" />
|
<span className="absolute top-3.5 left-[-12px] w-3 h-px bg-border/40" />
|
||||||
{isLast && (
|
{isLast && (
|
||||||
<span className="absolute top-3.5 bottom-0 left-[-13px] w-[2px] bg-background" />
|
<span className="absolute top-3.5 bottom-0 left-[-13px] w-[2px] bg-sidebar/50" />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -770,12 +770,12 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [childrenByDir, expandedPaths, handleOpenFile, selectedPath, toggleDirectory, handleOpenDialog, canCreateFile, canCreateFolder, canRename, canDelete, contextMenuPath, getFileStatus, getFolderBadge]);
|
}, [childrenByDir, expandedPaths, handleOpenFile, selectedPath, toggleDirectory, handleOpenDialog, canCreateFile, canCreateFolder, canRename, canDelete, canReveal, contextMenuPath, getFileStatus, getFolderBadge, handleRevealPath]);
|
||||||
|
|
||||||
const hasTree = Boolean(root && childrenByDir[root]);
|
const hasTree = Boolean(root && childrenByDir[root]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
<section className="flex h-full min-h-0 flex-col overflow-hidden bg-transparent">
|
||||||
<div className="flex items-center gap-2 border-b border-border/40 px-3 py-2">
|
<div className="flex items-center gap-2 border-b border-border/40 px-3 py-2">
|
||||||
<div className="relative min-w-0 flex-1">
|
<div className="relative min-w-0 flex-1">
|
||||||
<RiSearchLine className="pointer-events-none absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
|
<RiSearchLine className="pointer-events-none absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
|||||||
let currentFlatIndex = 0;
|
let currentFlatIndex = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ backgroundColor: 'var(--surface-elevated)' }} className="absolute bottom-full left-0 mb-1 z-50 border border-border/30 rounded-xl overflow-hidden shadow-lg w-[min(380px,calc(100vw-2rem))] flex flex-col">
|
<div style={{ backgroundColor: 'var(--surface-elevated)' }} className="absolute bottom-full left-0 mb-1 z-50 border border-border/30 rounded-xl overflow-hidden shadow-none w-[min(380px,calc(100vw-2rem))] flex flex-col">
|
||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<div className="p-2 border-b border-border/40">
|
<div className="p-2 border-b border-border/40">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ export const DirectoryAutocomplete = React.forwardRef<DirectoryAutocompleteHandl
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="absolute z-[100] w-full max-h-48 bg-background border border-border rounded-lg shadow-lg top-full mt-1 left-0 flex flex-col overflow-hidden"
|
className="absolute z-[100] w-full max-h-48 bg-background border border-border rounded-lg shadow-none top-full mt-1 left-0 flex flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-3">
|
<div className="flex items-center justify-center py-3">
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
useSensors,
|
useSensors,
|
||||||
useDraggable,
|
useDraggable,
|
||||||
useDroppable,
|
useDroppable,
|
||||||
type Modifier,
|
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import {
|
import {
|
||||||
@@ -114,6 +113,10 @@ const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
|
|||||||
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
|
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
|
||||||
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
|
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
|
||||||
|
|
||||||
|
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
|
||||||
|
const SESSION_PREFETCH_CONCURRENCY = 1;
|
||||||
|
const SESSION_PREFETCH_PENDING_LIMIT = 6;
|
||||||
|
|
||||||
const formatDateLabel = (value: string | number) => {
|
const formatDateLabel = (value: string | number) => {
|
||||||
const targetDate = new Date(value);
|
const targetDate = new Date(value);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -218,19 +221,6 @@ const compareSessionsByPinnedAndTime = (
|
|||||||
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
|
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
|
||||||
};
|
};
|
||||||
|
|
||||||
const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => {
|
|
||||||
if (!(activatorEvent instanceof MouseEvent) || !activeNodeRect) {
|
|
||||||
return transform;
|
|
||||||
}
|
|
||||||
const overlayHeight = 32;
|
|
||||||
const pointerLiftY = 16;
|
|
||||||
return {
|
|
||||||
...transform,
|
|
||||||
x: transform.x,
|
|
||||||
y: transform.y - overlayHeight / 2 - pointerLiftY,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format project label: kebab-case/snake_case → Title Case
|
// Format project label: kebab-case/snake_case → Title Case
|
||||||
const formatProjectLabel = (label: string): string => {
|
const formatProjectLabel = (label: string): string => {
|
||||||
return label
|
return label
|
||||||
@@ -289,7 +279,7 @@ const DraggableSessionRow: React.FC<{
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
className={isDragging ? 'opacity-40' : undefined}
|
className={isDragging ? 'opacity-30' : undefined}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -375,14 +365,14 @@ const SessionFolderDndScope: React.FC<{
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DragOverlay dropAnimation={null}>
|
<DragOverlay>
|
||||||
{activeDragId && hasFolders ? (
|
{activeDragId && hasFolders ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: activeDragWidth ? `${activeDragWidth}px` : 'auto',
|
width: activeDragWidth ? `${activeDragWidth}px` : 'auto',
|
||||||
height: activeDragHeight ? `${activeDragHeight}px` : 'auto'
|
height: activeDragHeight ? `${activeDragHeight}px` : 'auto'
|
||||||
}}
|
}}
|
||||||
className="flex items-center rounded-md border border-border bg-sidebar px-1.5 py-1 shadow-lg opacity-90 pointer-events-none"
|
className="flex items-center rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1 shadow-none pointer-events-none"
|
||||||
>
|
>
|
||||||
<RiStickyNoteLine className="h-4 w-4 text-muted-foreground mr-2 flex-shrink-0" />
|
<RiStickyNoteLine className="h-4 w-4 text-muted-foreground mr-2 flex-shrink-0" />
|
||||||
<div className="min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">
|
<div className="min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">
|
||||||
@@ -466,13 +456,19 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
isDragging,
|
isDragging,
|
||||||
} = useSortable({ id });
|
} = useSortable({ id });
|
||||||
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} className={cn('relative', isDragging && 'opacity-40')}>
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||||
|
className={cn('relative', isDragging && 'opacity-30')}
|
||||||
|
>
|
||||||
{!hideHeader ? (
|
{!hideHeader ? (
|
||||||
<>
|
<>
|
||||||
{/* Sentinel for sticky detection */}
|
{/* Sentinel for sticky detection */}
|
||||||
@@ -489,11 +485,11 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'sticky top-0 z-10 pt-2 pb-1.5 w-full text-left cursor-pointer group/project border-b select-none',
|
'sticky top-0 z-10 pt-2 pb-1.5 w-full text-left cursor-pointer group/project border-b select-none',
|
||||||
!isDesktopShell && 'bg-sidebar',
|
!isDesktopShell && 'bg-transparent',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isDesktopShell
|
backgroundColor: isDesktopShell
|
||||||
? isStuck ? 'var(--sidebar-stuck-bg)' : 'transparent'
|
? (isStuck ? 'transparent' : 'transparent')
|
||||||
: undefined,
|
: undefined,
|
||||||
borderColor: isHovered
|
borderColor: isHovered
|
||||||
? 'var(--color-border-hover)'
|
? 'var(--color-border-hover)'
|
||||||
@@ -708,10 +704,13 @@ const SortableGroupItemBase: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={{ transform: CSS.Transform.toString(transform), transition, willChange: 'transform' }}
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'space-y-0.5 rounded-md',
|
'space-y-0.5 rounded-md',
|
||||||
isDragging && 'opacity-0',
|
isDragging && 'opacity-50',
|
||||||
)}
|
)}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
@@ -723,16 +722,7 @@ const SortableGroupItemBase: React.FC<{
|
|||||||
|
|
||||||
const SortableGroupItem = React.memo(SortableGroupItemBase);
|
const SortableGroupItem = React.memo(SortableGroupItemBase);
|
||||||
|
|
||||||
const GroupDragOverlayBase: React.FC<{ label: string; showBranchIcon: boolean; width?: number }> = ({ label, showBranchIcon, width }) => {
|
|
||||||
return (
|
|
||||||
<div style={width ? { width: `${width}px` } : undefined} className="h-8 min-w-[180px] max-w-[320px] rounded-sm border border-border bg-sidebar px-2 shadow-lg flex items-center gap-1.5">
|
|
||||||
{showBranchIcon ? <RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" /> : null}
|
|
||||||
<p className="text-[15px] font-semibold truncate text-foreground">{label}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GroupDragOverlay = React.memo(GroupDragOverlayBase);
|
|
||||||
|
|
||||||
interface SessionSidebarProps {
|
interface SessionSidebarProps {
|
||||||
mobileVariant?: boolean;
|
mobileVariant?: boolean;
|
||||||
@@ -748,7 +738,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
onSessionSelected,
|
onSessionSelected,
|
||||||
allowReselect = false,
|
allowReselect = false,
|
||||||
hideDirectoryControls = false,
|
hideDirectoryControls = false,
|
||||||
hideProjectSelector = false,
|
hideProjectSelector = true,
|
||||||
showOnlyMainWorkspace = false,
|
showOnlyMainWorkspace = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||||
@@ -761,6 +751,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const [directoryStatus, setDirectoryStatus] = React.useState<Map<string, 'unknown' | 'exists' | 'missing'>>(
|
const [directoryStatus, setDirectoryStatus] = React.useState<Map<string, 'unknown' | 'exists' | 'missing'>>(
|
||||||
() => new Map(),
|
() => new Map(),
|
||||||
);
|
);
|
||||||
|
const directoryStatusRef = React.useRef<Map<string, 'unknown' | 'exists' | 'missing'>>(new Map());
|
||||||
const checkingDirectories = React.useRef<Set<string>>(new Set());
|
const checkingDirectories = React.useRef<Set<string>>(new Set());
|
||||||
const safeStorage = React.useMemo(() => getSafeStorage(), []);
|
const safeStorage = React.useMemo(() => getSafeStorage(), []);
|
||||||
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
|
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
|
||||||
@@ -847,8 +838,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
return new Map();
|
return new Map();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [activeDraggedGroupId, setActiveDraggedGroupId] = React.useState<string | null>(null);
|
|
||||||
const [activeDraggedGroupWidth, setActiveDraggedGroupWidth] = React.useState<number | null>(null);
|
|
||||||
const [isProjectRenameInline, setIsProjectRenameInline] = React.useState(false);
|
const [isProjectRenameInline, setIsProjectRenameInline] = React.useState(false);
|
||||||
const [projectRenameDraft, setProjectRenameDraft] = React.useState('');
|
const [projectRenameDraft, setProjectRenameDraft] = React.useState('');
|
||||||
const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map());
|
const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map());
|
||||||
@@ -865,7 +855,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||||
const addProject = useProjectsStore((state) => state.addProject);
|
const addProject = useProjectsStore((state) => state.addProject);
|
||||||
const removeProject = useProjectsStore((state) => state.removeProject);
|
const removeProject = useProjectsStore((state) => state.removeProject);
|
||||||
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
|
|
||||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||||
const renameProject = useProjectsStore((state) => state.renameProject);
|
const renameProject = useProjectsStore((state) => state.renameProject);
|
||||||
|
|
||||||
@@ -879,9 +868,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
|
||||||
|
|
||||||
// Session Folders store
|
// Session Folders store
|
||||||
// Subscribe to foldersMap so renderSessionNode/renderGroupSessions re-run when any folder changes.
|
|
||||||
// getFoldersForScope is a stable function selector and does not trigger re-renders on its own.
|
|
||||||
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
|
||||||
const collapsedFolderIds = useSessionFoldersStore((state) => state.collapsedFolderIds);
|
const collapsedFolderIds = useSessionFoldersStore((state) => state.collapsedFolderIds);
|
||||||
const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope);
|
const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope);
|
||||||
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
||||||
@@ -900,6 +886,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||||
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
|
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
|
||||||
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
|
||||||
|
const loadMessages = useSessionStore((state) => state.loadMessages);
|
||||||
const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle);
|
const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle);
|
||||||
const shareSession = useSessionStore((state) => state.shareSession);
|
const shareSession = useSessionStore((state) => state.shareSession);
|
||||||
const unshareSession = useSessionStore((state) => state.unshareSession);
|
const unshareSession = useSessionStore((state) => state.unshareSession);
|
||||||
@@ -1022,6 +1009,99 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
||||||
}, [sessions, pinnedSessionIds]);
|
}, [sessions, pinnedSessionIds]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
directoryStatusRef.current = directoryStatus;
|
||||||
|
}, [directoryStatus]);
|
||||||
|
|
||||||
|
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
|
||||||
|
const sessionPrefetchQueueRef = React.useRef<string[]>([]);
|
||||||
|
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const pumpSessionPrefetchQueue = React.useCallback(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (sessionPrefetchInFlightRef.current.size < SESSION_PREFETCH_CONCURRENCY && sessionPrefetchQueueRef.current.length > 0) {
|
||||||
|
const nextSessionId = sessionPrefetchQueueRef.current.shift();
|
||||||
|
if (!nextSessionId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = useSessionStore.getState();
|
||||||
|
if (state.currentSessionId === nextSessionId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMessages = state.messages.has(nextSessionId);
|
||||||
|
const memory = state.sessionMemoryState.get(nextSessionId);
|
||||||
|
const isHydrated = hasMessages && memory?.historyComplete !== undefined;
|
||||||
|
if (isHydrated) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionPrefetchInFlightRef.current.add(nextSessionId);
|
||||||
|
void loadMessages(nextSessionId)
|
||||||
|
.catch(() => {
|
||||||
|
return;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
sessionPrefetchInFlightRef.current.delete(nextSessionId);
|
||||||
|
pumpSessionPrefetchQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [loadMessages]);
|
||||||
|
|
||||||
|
const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => {
|
||||||
|
if (!sessionId || sessionId === currentSessionId || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = useSessionStore.getState();
|
||||||
|
const hasMessages = state.messages.has(sessionId);
|
||||||
|
const memory = state.sessionMemoryState.get(sessionId);
|
||||||
|
const isHydrated = hasMessages && memory?.historyComplete !== undefined;
|
||||||
|
if (isHydrated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionPrefetchInFlightRef.current.has(sessionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionPrefetchQueueRef.current.includes(sessionId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionPrefetchQueueRef.current.length >= SESSION_PREFETCH_PENDING_LIMIT) {
|
||||||
|
sessionPrefetchQueueRef.current.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingTimer = sessionPrefetchTimersRef.current.get(sessionId);
|
||||||
|
if (existingTimer !== undefined) {
|
||||||
|
window.clearTimeout(existingTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
sessionPrefetchTimersRef.current.delete(sessionId);
|
||||||
|
sessionPrefetchQueueRef.current.push(sessionId);
|
||||||
|
pumpSessionPrefetchQueue();
|
||||||
|
}, SESSION_PREFETCH_HOVER_DELAY_MS);
|
||||||
|
sessionPrefetchTimersRef.current.set(sessionId, timer);
|
||||||
|
}, [currentSessionId, pumpSessionPrefetchQueue]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentSessionId || sortedSessions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
|
||||||
|
if (currentIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id);
|
||||||
|
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
|
||||||
|
}, [currentSessionId, scheduleSessionPrefetch, sortedSessions]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const normalizedProjects = projects
|
const normalizedProjects = projects
|
||||||
@@ -1094,7 +1174,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
directories.forEach((directory) => {
|
directories.forEach((directory) => {
|
||||||
const known = directoryStatus.get(directory);
|
const known = directoryStatusRef.current.get(directory);
|
||||||
if ((known && known !== 'unknown') || checkingDirectories.current.has(directory)) {
|
if ((known && known !== 'unknown') || checkingDirectories.current.has(directory)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1147,13 +1227,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
checkingDirectories.current.delete(directory);
|
checkingDirectories.current.delete(directory);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [sortedSessions, projects, directoryStatus]);
|
}, [sortedSessions, projects]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
const prefetchTimers = sessionPrefetchTimersRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
if (copyTimeout.current) {
|
if (copyTimeout.current) {
|
||||||
clearTimeout(copyTimeout.current);
|
clearTimeout(copyTimeout.current);
|
||||||
}
|
}
|
||||||
|
prefetchTimers.forEach((timer) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
});
|
||||||
|
prefetchTimers.clear();
|
||||||
|
sessionPrefetchQueueRef.current = [];
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2035,8 +2121,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
setIsProjectRenameInline(false);
|
setIsProjectRenameInline(false);
|
||||||
}, [activeProjectForHeader, projectRenameDraft, renameProject]);
|
}, [activeProjectForHeader, projectRenameDraft, renameProject]);
|
||||||
|
|
||||||
const headerActionButtonClass =
|
const desktopHeaderActionButtonClass =
|
||||||
|
'inline-flex h-6 w-6 items-center justify-center rounded-md leading-none text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
|
||||||
|
const mobileHeaderActionButtonClass =
|
||||||
'inline-flex h-6 w-6 items-center justify-center rounded-md leading-none text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
|
'inline-flex h-6 w-6 items-center justify-center rounded-md leading-none text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
|
||||||
|
const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass;
|
||||||
|
const headerActionIconClass = 'h-4.5 w-4.5';
|
||||||
|
const addProjectButtonClass = cn(
|
||||||
|
'inline-flex items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||||
|
mobileVariant
|
||||||
|
? 'h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50'
|
||||||
|
: 'h-8 w-8 text-foreground hover:bg-interactive-hover',
|
||||||
|
!isDesktopShellRuntime && 'bg-transparent hover:bg-sidebar/40',
|
||||||
|
);
|
||||||
|
|
||||||
// Track when project sticky headers become "stuck"
|
// Track when project sticky headers become "stuck"
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -2508,7 +2605,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
removeSessionFromFolder,
|
removeSessionFromFolder,
|
||||||
createFolderAndStartRename,
|
createFolderAndStartRename,
|
||||||
notifyOnSubtasks,
|
notifyOnSubtasks,
|
||||||
foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector)
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2626,7 +2722,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
depth={depth}
|
depth={depth}
|
||||||
onNewSession={() => {
|
onNewSession={() => {
|
||||||
if (projectId && projectId !== activeProjectId) {
|
if (projectId && projectId !== activeProjectId) {
|
||||||
setActiveProject(projectId);
|
setActiveProjectIdOnly(projectId);
|
||||||
}
|
}
|
||||||
setActiveMainTab('chat');
|
setActiveMainTab('chat');
|
||||||
if (mobileVariant) {
|
if (mobileVariant) {
|
||||||
@@ -2697,7 +2793,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
<div className="oc-group">
|
<div className="oc-group">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/gh flex items-center justify-between gap-2 py-1 min-w-0 rounded-sm",
|
"group/gh relative flex items-center justify-between gap-1 py-1 min-w-0 rounded-sm",
|
||||||
!hideGroupLabel && "hover:bg-interactive-hover/50 cursor-pointer"
|
!hideGroupLabel && "hover:bg-interactive-hover/50 cursor-pointer"
|
||||||
)}
|
)}
|
||||||
onClick={!hideGroupLabel ? () => {
|
onClick={!hideGroupLabel ? () => {
|
||||||
@@ -2730,12 +2826,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
aria-label={!hideGroupLabel ? (isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`) : undefined}
|
aria-label={!hideGroupLabel ? (isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`) : undefined}
|
||||||
>
|
>
|
||||||
{!hideGroupLabel ? (
|
{!hideGroupLabel ? (
|
||||||
<div className="min-w-0 flex items-center gap-1.5 pl-1.5">
|
<div className={cn(
|
||||||
|
"min-w-0 flex items-center gap-1.5 pl-1.5 transition-[padding]",
|
||||||
|
mobileVariant
|
||||||
|
? (!group.isMain && group.worktree ? "pr-14" : "pr-7")
|
||||||
|
: (!group.isMain && group.worktree
|
||||||
|
? "group-hover/gh:pr-14 group-focus-within/gh:pr-14"
|
||||||
|
: "group-hover/gh:pr-7 group-focus-within/gh:pr-7"),
|
||||||
|
)}>
|
||||||
{!group.isMain || isGitProject ? (
|
{!group.isMain || isGitProject ? (
|
||||||
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
<RiGitBranchLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
) : null}
|
) : null}
|
||||||
<div className="min-w-0 flex flex-col justify-center">
|
<div className="min-w-0 flex flex-col justify-center">
|
||||||
<p className={cn('text-[15px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
|
<p className={cn('text-[14px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
|
||||||
{group.label}
|
{group.label}
|
||||||
</p>
|
</p>
|
||||||
{showBranchSubtitle ? (
|
{showBranchSubtitle ? (
|
||||||
@@ -2752,60 +2855,67 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : <div />}
|
) : <div />}
|
||||||
{group.directory ? (
|
{group.directory ? (
|
||||||
<div className="flex items-center gap-1 px-0.5">
|
<>
|
||||||
{!group.isMain && group.worktree ? (
|
{!group.isMain && group.worktree ? (
|
||||||
|
<div className={cn(
|
||||||
|
'absolute right-7 top-1/2 -translate-y-1/2 z-10 transition-opacity',
|
||||||
|
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100',
|
||||||
|
)}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
sessionEvents.requestDelete({
|
||||||
|
sessions: allGroupSessions,
|
||||||
|
mode: 'worktree',
|
||||||
|
worktree: group.worktree,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
|
aria-label={`Delete ${group.label}`}
|
||||||
|
>
|
||||||
|
<RiDeleteBinLine className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" sideOffset={4}>
|
||||||
|
<p>Delete worktree</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={cn(
|
||||||
|
'absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity',
|
||||||
|
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100',
|
||||||
|
)}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
sessionEvents.requestDelete({
|
if (projectId && projectId !== activeProjectId) {
|
||||||
sessions: allGroupSessions,
|
setActiveProjectIdOnly(projectId);
|
||||||
mode: 'worktree',
|
}
|
||||||
worktree: group.worktree,
|
setActiveMainTab('chat');
|
||||||
});
|
if (mobileVariant) {
|
||||||
|
setSessionSwitcherOpen(false);
|
||||||
|
}
|
||||||
|
openNewSessionDraft({ directoryOverride: group.directory });
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
aria-label={`New session in ${group.label}`}
|
||||||
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100',
|
|
||||||
)}
|
|
||||||
aria-label={`Delete ${group.label}`}
|
|
||||||
>
|
>
|
||||||
<RiDeleteBinLine className="h-4 w-4" />
|
<RiAddLine className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={4}>
|
<TooltipContent side="bottom" sideOffset={4}>
|
||||||
<p>Delete worktree</p>
|
<p>New session</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
</div>
|
||||||
<Tooltip>
|
</>
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (projectId && projectId !== activeProjectId) {
|
|
||||||
setActiveProject(projectId);
|
|
||||||
}
|
|
||||||
setActiveMainTab('chat');
|
|
||||||
if (mobileVariant) {
|
|
||||||
setSessionSwitcherOpen(false);
|
|
||||||
}
|
|
||||||
openNewSessionDraft({ directoryOverride: group.directory });
|
|
||||||
}}
|
|
||||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
||||||
aria-label={`New session in ${group.label}`}
|
|
||||||
>
|
|
||||||
<RiAddLine className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" sideOffset={4}>
|
|
||||||
<p>New session</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!isCollapsed ? (
|
{!isCollapsed ? (
|
||||||
@@ -2857,7 +2967,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
renderSessionNode,
|
renderSessionNode,
|
||||||
toggleGroupSessionLimit,
|
toggleGroupSessionLimit,
|
||||||
activeProjectId,
|
activeProjectId,
|
||||||
setActiveProject,
|
setActiveProjectIdOnly,
|
||||||
setActiveMainTab,
|
setActiveMainTab,
|
||||||
mobileVariant,
|
mobileVariant,
|
||||||
setSessionSwitcherOpen,
|
setSessionSwitcherOpen,
|
||||||
@@ -2873,7 +2983,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
renamingFolderId,
|
renamingFolderId,
|
||||||
renameFolderDraft,
|
renameFolderDraft,
|
||||||
pinnedSessionIds,
|
pinnedSessionIds,
|
||||||
foldersMap, // trigger re-render when folder data changes (getFoldersForScope is a stable fn selector)
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2893,7 +3002,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-full flex-col text-foreground overflow-x-hidden',
|
'flex h-full flex-col text-foreground overflow-x-hidden',
|
||||||
mobileVariant ? '' : 'bg-sidebar',
|
mobileVariant ? '' : 'bg-transparent',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!hideDirectoryControls && (
|
{!hideDirectoryControls && (
|
||||||
@@ -2934,7 +3043,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onClick={() => setActiveProject(project.id)}
|
onClick={() => setActiveProjectIdOnly(project.id)}
|
||||||
className={cn('truncate', project.id === activeProjectId && 'text-primary')}
|
className={cn('truncate', project.id === activeProjectId && 'text-primary')}
|
||||||
>
|
>
|
||||||
<span className="truncate">{label}</span>
|
<span className="truncate">{label}</span>
|
||||||
@@ -3009,14 +3118,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenDirectoryDialog}
|
onClick={handleOpenDirectoryDialog}
|
||||||
className={cn(
|
className={addProjectButtonClass}
|
||||||
'inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
|
||||||
!isDesktopShellRuntime && 'bg-sidebar/60 hover:bg-sidebar',
|
|
||||||
)}
|
|
||||||
aria-label="Add project"
|
aria-label="Add project"
|
||||||
title="Add project"
|
title="Add project"
|
||||||
>
|
>
|
||||||
<RiFolderAddLine className="h-4.5 w-4.5" />
|
<RiFolderAddLine className={headerActionIconClass} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -3035,7 +3141,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeProjectForHeader.id !== activeProjectId) {
|
if (activeProjectForHeader.id !== activeProjectId) {
|
||||||
setActiveProject(activeProjectForHeader.id);
|
setActiveProjectIdOnly(activeProjectForHeader.id);
|
||||||
}
|
}
|
||||||
const newWorktreePath = await createWorktreeOnly();
|
const newWorktreePath = await createWorktreeOnly();
|
||||||
if (!newWorktreePath) {
|
if (!newWorktreePath) {
|
||||||
@@ -3050,7 +3156,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
className={headerActionButtonClass}
|
className={headerActionButtonClass}
|
||||||
aria-label="New worktree"
|
aria-label="New worktree"
|
||||||
>
|
>
|
||||||
<RiNodeTree className="h-4.5 w-4.5" />
|
<RiNodeTree className={headerActionIconClass} />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={4}><p>New worktree</p></TooltipContent>
|
<TooltipContent side="bottom" sideOffset={4}><p>New worktree</p></TooltipContent>
|
||||||
@@ -3063,7 +3169,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
className={headerActionButtonClass}
|
className={headerActionButtonClass}
|
||||||
aria-label="New from issue"
|
aria-label="New from issue"
|
||||||
>
|
>
|
||||||
<RiGithubLine className="h-4.5 w-4.5" />
|
<RiGithubLine className={headerActionIconClass} />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={4}><p>New from issue</p></TooltipContent>
|
<TooltipContent side="bottom" sideOffset={4}><p>New from issue</p></TooltipContent>
|
||||||
@@ -3076,7 +3182,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
className={headerActionButtonClass}
|
className={headerActionButtonClass}
|
||||||
aria-label="New from PR"
|
aria-label="New from PR"
|
||||||
>
|
>
|
||||||
<RiGitPullRequestLine className="h-4.5 w-4.5" />
|
<RiGitPullRequestLine className={headerActionIconClass} />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={4}><p>New from PR</p></TooltipContent>
|
<TooltipContent side="bottom" sideOffset={4}><p>New from PR</p></TooltipContent>
|
||||||
@@ -3089,7 +3195,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
className={headerActionButtonClass}
|
className={headerActionButtonClass}
|
||||||
aria-label="New multi-run"
|
aria-label="New multi-run"
|
||||||
>
|
>
|
||||||
<ArrowsMerge className="h-4.5 w-4.5" />
|
<ArrowsMerge className={headerActionIconClass} />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={4}><p>New multi-run</p></TooltipContent>
|
<TooltipContent side="bottom" sideOffset={4}><p>New multi-run</p></TooltipContent>
|
||||||
@@ -3105,7 +3211,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
className={headerActionButtonClass}
|
className={headerActionButtonClass}
|
||||||
aria-label="Manage branches"
|
aria-label="Manage branches"
|
||||||
>
|
>
|
||||||
<RiGitRepositoryLine className="h-4.5 w-4.5" />
|
<RiGitRepositoryLine className={headerActionIconClass} />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={4}><p>Manage branches</p></TooltipContent>
|
<TooltipContent side="bottom" sideOffset={4}><p>Manage branches</p></TooltipContent>
|
||||||
@@ -3120,7 +3226,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
className={headerActionButtonClass}
|
className={headerActionButtonClass}
|
||||||
aria-label="Project notes and todos"
|
aria-label="Project notes and todos"
|
||||||
>
|
>
|
||||||
<RiStickyNoteLine className="h-4.5 w-4.5" />
|
<RiStickyNoteLine className={headerActionIconClass} />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={4}><p>Project notes</p></TooltipContent>
|
<TooltipContent side="bottom" sideOffset={4}><p>Project notes</p></TooltipContent>
|
||||||
@@ -3135,7 +3241,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
className={headerActionButtonClass}
|
className={headerActionButtonClass}
|
||||||
aria-label="Project notes and todos"
|
aria-label="Project notes and todos"
|
||||||
>
|
>
|
||||||
<RiStickyNoteLine className="h-4.5 w-4.5" />
|
<RiStickyNoteLine className={headerActionIconClass} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -3225,7 +3331,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
onHoverChange={(hovered) => setHoveredProjectId(hovered ? projectKey : null)}
|
onHoverChange={(hovered) => setHoveredProjectId(hovered ? projectKey : null)}
|
||||||
onNewSession={() => {
|
onNewSession={() => {
|
||||||
if (projectKey !== activeProjectId) {
|
if (projectKey !== activeProjectId) {
|
||||||
setActiveProject(projectKey);
|
setActiveProjectIdOnly(projectKey);
|
||||||
}
|
}
|
||||||
setActiveMainTab('chat');
|
setActiveMainTab('chat');
|
||||||
if (mobileVariant) {
|
if (mobileVariant) {
|
||||||
@@ -3235,7 +3341,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
}}
|
}}
|
||||||
onNewWorktreeSession={() => {
|
onNewWorktreeSession={() => {
|
||||||
if (projectKey !== activeProjectId) {
|
if (projectKey !== activeProjectId) {
|
||||||
setActiveProject(projectKey);
|
setActiveProjectIdOnly(projectKey);
|
||||||
}
|
}
|
||||||
setActiveMainTab('chat');
|
setActiveMainTab('chat');
|
||||||
if (mobileVariant) {
|
if (mobileVariant) {
|
||||||
@@ -3245,19 +3351,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
}}
|
}}
|
||||||
onNewSessionFromGitHubIssue={() => {
|
onNewSessionFromGitHubIssue={() => {
|
||||||
if (projectKey !== activeProjectId) {
|
if (projectKey !== activeProjectId) {
|
||||||
setActiveProject(projectKey);
|
setActiveProjectIdOnly(projectKey);
|
||||||
}
|
}
|
||||||
setIssuePickerOpen(true);
|
setIssuePickerOpen(true);
|
||||||
}}
|
}}
|
||||||
onNewSessionFromGitHubPR={() => {
|
onNewSessionFromGitHubPR={() => {
|
||||||
if (projectKey !== activeProjectId) {
|
if (projectKey !== activeProjectId) {
|
||||||
setActiveProject(projectKey);
|
setActiveProjectIdOnly(projectKey);
|
||||||
}
|
}
|
||||||
setPullRequestPickerOpen(true);
|
setPullRequestPickerOpen(true);
|
||||||
}}
|
}}
|
||||||
onOpenMultiRunLauncher={() => {
|
onOpenMultiRunLauncher={() => {
|
||||||
if (projectKey !== activeProjectId) {
|
if (projectKey !== activeProjectId) {
|
||||||
setActiveProject(projectKey);
|
setActiveProjectIdOnly(projectKey);
|
||||||
}
|
}
|
||||||
openMultiRunLauncher();
|
openMultiRunLauncher();
|
||||||
}}
|
}}
|
||||||
@@ -3282,19 +3388,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragStart={(event) => {
|
|
||||||
setActiveDraggedGroupId(String(event.active.id));
|
|
||||||
const width = (event.active.rect.current.initial?.width ?? null);
|
|
||||||
setActiveDraggedGroupWidth(typeof width === 'number' ? width : null);
|
|
||||||
}}
|
|
||||||
onDragCancel={() => {
|
|
||||||
setActiveDraggedGroupId(null);
|
|
||||||
setActiveDraggedGroupWidth(null);
|
|
||||||
}}
|
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
setActiveDraggedGroupId(null);
|
|
||||||
setActiveDraggedGroupWidth(null);
|
|
||||||
if (!over || active.id === over.id) {
|
if (!over || active.id === over.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3324,18 +3419,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
<DragOverlay modifiers={[centerDragOverlayUnderPointer]} dropAnimation={null}>
|
<DragOverlay dropAnimation={null} />
|
||||||
{activeDraggedGroupId ? (
|
|
||||||
(() => {
|
|
||||||
const dragGroup = orderedGroups.find((group) => group.id === activeDraggedGroupId);
|
|
||||||
if (!dragGroup) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const showBranchIcon = !dragGroup.isMain || Boolean(isRepo);
|
|
||||||
return <GroupDragOverlay label={dragGroup.label} showBranchIcon={showBranchIcon} width={activeDraggedGroupWidth ?? undefined} />;
|
|
||||||
})()
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
</DndContext>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-1 text-left typography-micro text-muted-foreground">
|
<div className="py-1 text-left typography-micro text-muted-foreground">
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export const HelpDialog: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [`${mod} + 1...9`],
|
keys: [`${mod} + 1...9`],
|
||||||
description: "Switch Project or Main Tab",
|
description: "Switch Project",
|
||||||
icon: RiLayoutLeftLine,
|
icon: RiLayoutLeftLine,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function AnimatedTabs<T extends string>({
|
|||||||
<div
|
<div
|
||||||
ref={indicatorRef}
|
ref={indicatorRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-0.5 bottom-0.5 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] shadow-sm',
|
'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
|
animate && isReadyToAnimate ? 'transition-[transform,width] duration-200 ease-out' : null
|
||||||
)}
|
)}
|
||||||
style={{ width: 0, transform: 'translateX(0)' }}
|
style={{ width: 0, transform: 'translateX(0)' }}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function DropdownMenuContent({
|
|||||||
color: 'var(--surface-elevated-foreground)',
|
color: 'var(--surface-elevated-foreground)',
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-visible rounded-xl border-2 border-border/60 p-1 shadow-md",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-visible rounded-xl border-2 border-border/60 p-1 shadow-none",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -236,7 +236,7 @@ function DropdownMenuSubContent({
|
|||||||
color: 'var(--surface-elevated-foreground)',
|
color: 'var(--surface-elevated-foreground)',
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-visible rounded-xl border-2 border-border/60 p-1 shadow-md",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-visible rounded-xl border-2 border-border/60 p-1 shadow-none",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function SelectContent({
|
|||||||
color: 'var(--surface-elevated-foreground)',
|
color: 'var(--surface-elevated-foreground)',
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden rounded-xl border-2 border-border/60 shadow-md transform-gpu will-change-transform",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden rounded-xl border-2 border-border/60 shadow-none transform-gpu will-change-transform",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
fitContent && "w-max min-w-0",
|
fitContent && "w-max min-w-0",
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ export const Slider: React.FC<SliderProps> = ({
|
|||||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4',
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4',
|
||||||
'[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary',
|
'[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary',
|
||||||
'[&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:transition-transform',
|
'[&::-webkit-slider-thumb]:shadow-none [&::-webkit-slider-thumb]:transition-transform',
|
||||||
'[&::-webkit-slider-thumb]:hover:scale-110 [&::-webkit-slider-thumb]:active:scale-95',
|
'[&::-webkit-slider-thumb]:hover:scale-110 [&::-webkit-slider-thumb]:active:scale-95',
|
||||||
'[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full',
|
'[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full',
|
||||||
'[&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-md'
|
'[&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-none'
|
||||||
)}
|
)}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-valuemin={min}
|
aria-valuemin={min}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Switch = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-[var(--interactive-border)]',
|
'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-[var(--interactive-border)]',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ width: '36px', height: '20px', minWidth: '36px', minHeight: '20px' }}
|
style={{ width: '36px', height: '20px', minWidth: '36px', minHeight: '20px' }}
|
||||||
@@ -18,7 +18,7 @@ const Switch = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<SwitchPrimitives.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none block rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
'pointer-events-none block rounded-full bg-background shadow-none ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||||
)}
|
)}
|
||||||
style={{ width: '16px', height: '16px', minWidth: '16px', minHeight: '16px' }}
|
style={{ width: '16px', height: '16px', minWidth: '16px', minHeight: '16px' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ type GitmojiCachePayload = {
|
|||||||
const GITMOJI_CACHE_KEY = 'gitmojiCache';
|
const GITMOJI_CACHE_KEY = 'gitmojiCache';
|
||||||
const GITMOJI_CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 7;
|
const GITMOJI_CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 7;
|
||||||
const GITMOJI_CACHE_VERSION = '1';
|
const GITMOJI_CACHE_VERSION = '1';
|
||||||
|
const GIT_DIFF_PRIORITY_PREFETCH_LIMIT = 40;
|
||||||
|
const GIT_DIFF_PRIORITY_BASELINE_LIMIT = 20;
|
||||||
const GITMOJI_SOURCE_URL =
|
const GITMOJI_SOURCE_URL =
|
||||||
'https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json';
|
'https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json';
|
||||||
|
|
||||||
@@ -245,6 +247,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
|||||||
fetchBranches,
|
fetchBranches,
|
||||||
fetchLog,
|
fetchLog,
|
||||||
fetchIdentity,
|
fetchIdentity,
|
||||||
|
prefetchDiffs,
|
||||||
setLogMaxCount,
|
setLogMaxCount,
|
||||||
} = useGitStore();
|
} = useGitStore();
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
@@ -310,6 +313,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
|||||||
const [commitMessage, setCommitMessage] = React.useState(
|
const [commitMessage, setCommitMessage] = React.useState(
|
||||||
initialSnapshot?.commitMessage ?? ''
|
initialSnapshot?.commitMessage ?? ''
|
||||||
);
|
);
|
||||||
|
const [visibleChangePaths, setVisibleChangePaths] = React.useState<string[]>([]);
|
||||||
const [isGitmojiPickerOpen, setIsGitmojiPickerOpen] = React.useState(false);
|
const [isGitmojiPickerOpen, setIsGitmojiPickerOpen] = React.useState(false);
|
||||||
const actionPanelScrollRef = React.useRef<HTMLElement | null>(null);
|
const actionPanelScrollRef = React.useRef<HTMLElement | null>(null);
|
||||||
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
|
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
|
||||||
@@ -620,10 +624,12 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
|||||||
|
|
||||||
const dirState = useGitStore.getState().directories.get(currentDirectory);
|
const dirState = useGitStore.getState().directories.get(currentDirectory);
|
||||||
if (!dirState?.status) {
|
if (!dirState?.status) {
|
||||||
fetchAll(currentDirectory, git, { force: true });
|
void fetchAll(currentDirectory, git, { force: true });
|
||||||
|
} else {
|
||||||
|
void fetchStatus(currentDirectory, git, { silent: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [currentDirectory, setActiveDirectory, fetchAll, git]);
|
}, [currentDirectory, setActiveDirectory, fetchAll, fetchStatus, git]);
|
||||||
|
|
||||||
const refreshStatusAndBranches = React.useCallback(
|
const refreshStatusAndBranches = React.useCallback(
|
||||||
async (showErrors = true) => {
|
async (showErrors = true) => {
|
||||||
@@ -706,6 +712,39 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
|||||||
return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path));
|
return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path));
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentDirectory || changeEntries.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedPaths: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const pushPath = (path: string) => {
|
||||||
|
if (!path || seen.has(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(path);
|
||||||
|
orderedPaths.push(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
Array.from(selectedPaths).forEach(pushPath);
|
||||||
|
visibleChangePaths.forEach(pushPath);
|
||||||
|
changeEntries.slice(0, GIT_DIFF_PRIORITY_BASELINE_LIMIT).forEach((entry) => pushPath(entry.path));
|
||||||
|
|
||||||
|
if (orderedPaths.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: GIT_DIFF_PRIORITY_PREFETCH_LIMIT });
|
||||||
|
}, 120);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [changeEntries, currentDirectory, git, prefetchDiffs, selectedPaths, visibleChangePaths]);
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!status || changeEntries.length === 0) {
|
if (!status || changeEntries.length === 0) {
|
||||||
@@ -1628,7 +1667,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden bg-background" data-keyboard-avoid="true">
|
<div className={cn('flex h-full flex-col overflow-hidden', isSidebarMode ? 'bg-transparent' : 'bg-background')} data-keyboard-avoid="true">
|
||||||
<GitHeader
|
<GitHeader
|
||||||
status={status}
|
status={status}
|
||||||
localBranches={localBranches}
|
localBranches={localBranches}
|
||||||
@@ -1670,7 +1709,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
|||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<div className="h-full min-h-0 flex flex-col">
|
<div className="h-full min-h-0 flex flex-col">
|
||||||
<div className={cn('min-w-0 min-h-0 h-full bg-muted/10 flex flex-col', isSidebarMode && 'border-t border-border/40')}>
|
<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">
|
<div className="px-3 py-1.5">
|
||||||
<AnimatedTabs<ActionTab>
|
<AnimatedTabs<ActionTab>
|
||||||
value={actionTab}
|
value={actionTab}
|
||||||
@@ -1704,6 +1743,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
|||||||
variant="plain"
|
variant="plain"
|
||||||
maxListHeightClassName="max-h-[40vh]"
|
maxListHeightClassName="max-h-[40vh]"
|
||||||
changeEntries={changeEntries}
|
changeEntries={changeEntries}
|
||||||
|
onVisiblePathsChange={setVisibleChangePaths}
|
||||||
selectedPaths={selectedPaths}
|
selectedPaths={selectedPaths}
|
||||||
diffStats={status?.diffStats}
|
diffStats={status?.diffStats}
|
||||||
revertingPaths={revertingPaths}
|
revertingPaths={revertingPaths}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const SettingsWindow: React.FC<SettingsWindowProps> = ({ open, onOpenChan
|
|||||||
className={cn(
|
className={cn(
|
||||||
'fixed z-50 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
|
'fixed z-50 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
|
||||||
'w-[90vw] max-w-[960px] h-[85vh] max-h-[900px]',
|
'w-[90vw] max-w-[960px] h-[85vh] max-h-[900px]',
|
||||||
'rounded-xl border shadow-2xl overflow-hidden',
|
'rounded-xl border shadow-none overflow-hidden',
|
||||||
'bg-background'
|
'bg-background'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const AIHighlightsBox: React.FC<AIHighlightsBoxProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 rounded-xl border border-border/60 bg-background/60 px-3 py-2">
|
<div className="space-y-2 rounded-xl border border-border/60 bg-transparent px-3 py-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="typography-micro text-muted-foreground">AI highlights</p>
|
<p className="typography-micro text-muted-foreground">AI highlights</p>
|
||||||
<Tooltip delayDuration={1000}>
|
<Tooltip delayDuration={1000}>
|
||||||
|
|||||||
@@ -96,14 +96,13 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<div
|
||||||
<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 px-3 py-1.5 hover:bg-sidebar/40 cursor-pointer"
|
role="button"
|
||||||
role="button"
|
tabIndex={0}
|
||||||
tabIndex={0}
|
onClick={onViewDiff}
|
||||||
onClick={onViewDiff}
|
onKeyDown={handleKeyDown}
|
||||||
onKeyDown={handleKeyDown}
|
>
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleToggleClick}
|
onClick={handleToggleClick}
|
||||||
@@ -175,7 +174,6 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent sideOffset={8}>Revert changes</TooltipContent>
|
<TooltipContent sideOffset={8}>Revert changes</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||||
@@ -18,8 +19,12 @@ interface ChangesSectionProps {
|
|||||||
onRevertFile: (path: string) => void;
|
onRevertFile: (path: string) => void;
|
||||||
variant?: 'framed' | 'plain';
|
variant?: 'framed' | 'plain';
|
||||||
maxListHeightClassName?: string;
|
maxListHeightClassName?: string;
|
||||||
|
onVisiblePathsChange?: (paths: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHANGE_LIST_VIRTUALIZE_THRESHOLD = 120;
|
||||||
|
const CHANGE_ROW_ESTIMATE_PX = 34;
|
||||||
|
|
||||||
export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||||
changeEntries,
|
changeEntries,
|
||||||
selectedPaths,
|
selectedPaths,
|
||||||
@@ -32,10 +37,43 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
|||||||
onRevertFile,
|
onRevertFile,
|
||||||
variant = 'framed',
|
variant = 'framed',
|
||||||
maxListHeightClassName,
|
maxListHeightClassName,
|
||||||
|
onVisiblePathsChange,
|
||||||
}) => {
|
}) => {
|
||||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const selectedCount = selectedPaths.size;
|
const selectedCount = selectedPaths.size;
|
||||||
const totalCount = changeEntries.length;
|
const totalCount = changeEntries.length;
|
||||||
|
const shouldVirtualize = totalCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: totalCount,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => CHANGE_ROW_ESTIMATE_PX,
|
||||||
|
overscan: 10,
|
||||||
|
enabled: shouldVirtualize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualRows = React.useMemo(
|
||||||
|
() => (shouldVirtualize ? rowVirtualizer.getVirtualItems() : []),
|
||||||
|
[rowVirtualizer, shouldVirtualize],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!onVisiblePathsChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCount === 0) {
|
||||||
|
onVisiblePathsChange([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldVirtualize) {
|
||||||
|
onVisiblePathsChange(changeEntries.slice(0, Math.min(30, totalCount)).map((entry) => entry.path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onVisiblePathsChange(virtualRows.map((row) => changeEntries[row.index]?.path).filter((value): value is string => Boolean(value)));
|
||||||
|
}, [changeEntries, onVisiblePathsChange, shouldVirtualize, totalCount, virtualRows]);
|
||||||
|
|
||||||
const containerClassName =
|
const containerClassName =
|
||||||
variant === 'framed'
|
variant === 'framed'
|
||||||
@@ -86,20 +124,54 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
|||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="overlay-scrollbar-target overlay-scrollbar-container flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden"
|
className="overlay-scrollbar-target overlay-scrollbar-container flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden"
|
||||||
>
|
>
|
||||||
<ul className="divide-y divide-border/60">
|
{shouldVirtualize ? (
|
||||||
{changeEntries.map((file) => (
|
<div
|
||||||
<ChangeRow
|
className="relative w-full divide-y divide-border/60"
|
||||||
key={file.path}
|
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||||
file={file}
|
>
|
||||||
checked={selectedPaths.has(file.path)}
|
{virtualRows.map((row) => {
|
||||||
stats={diffStats?.[file.path]}
|
const file = changeEntries[row.index];
|
||||||
onToggle={() => onToggleFile(file.path)}
|
if (!file) {
|
||||||
onViewDiff={() => onViewDiff(file.path)}
|
return null;
|
||||||
onRevert={() => onRevertFile(file.path)}
|
}
|
||||||
isReverting={revertingPaths.has(file.path)}
|
|
||||||
/>
|
return (
|
||||||
))}
|
<div
|
||||||
</ul>
|
key={file.path}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
data-index={row.index}
|
||||||
|
className="absolute left-0 top-0 w-full"
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
</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>
|
</ScrollShadow>
|
||||||
<OverlayScrollbar containerRef={scrollRef} disableHorizontal />
|
<OverlayScrollbar containerRef={scrollRef} disableHorizontal />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const CommitInput: React.FC<CommitInputProps> = ({
|
|||||||
autoCapitalize={hasTouchInput ? 'sentences' : 'off'}
|
autoCapitalize={hasTouchInput ? 'sentences' : 'off'}
|
||||||
spellCheck={hasTouchInput ? true : false}
|
spellCheck={hasTouchInput ? true : false}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg bg-background/80 resize-none overflow-y-auto',
|
'rounded-lg bg-transparent resize-none overflow-y-auto',
|
||||||
disabled && 'opacity-50'
|
disabled && 'opacity-50'
|
||||||
)}
|
)}
|
||||||
style={{ minHeight: MIN_HEIGHT, maxHeight: MAX_HEIGHT }}
|
style={{ minHeight: MIN_HEIGHT, maxHeight: MAX_HEIGHT }}
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
|
|
||||||
if (useTwoRowHeader) {
|
if (useTwoRowHeader) {
|
||||||
return (
|
return (
|
||||||
<header className="@container/git-header border-b border-border/40 px-3 py-2 bg-background">
|
<header className={`@container/git-header border-b border-border/40 px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'bg-background'}`}>
|
||||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{isWorktreeMode ? (
|
{isWorktreeMode ? (
|
||||||
@@ -320,7 +320,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="@container/git-header flex items-center gap-2 border-b border-border/40 px-3 py-2 bg-background">
|
<header className={`@container/git-header flex items-center gap-2 border-b border-border/40 px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'bg-background'}`}>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||||
{isWorktreeMode ? (
|
{isWorktreeMode ? (
|
||||||
<WorktreeBranchDisplay
|
<WorktreeBranchDisplay
|
||||||
|
|||||||
@@ -698,7 +698,7 @@ export const PullRequestSection: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{run.output?.text ? (
|
{run.output?.text ? (
|
||||||
<div className="rounded border border-border/40 bg-background/40 px-2 py-2 typography-micro text-muted-foreground whitespace-pre-wrap max-h-48 overflow-y-auto">
|
<div className="rounded border border-border/40 bg-transparent px-2 py-2 typography-micro text-muted-foreground whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||||
{run.output.text}
|
{run.output.text}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -776,7 +776,7 @@ export const PullRequestSection: React.FC<{
|
|||||||
{step.conclusion ? <span className="ml-auto flex-shrink-0">{step.conclusion}</span> : null}
|
{step.conclusion ? <span className="ml-auto flex-shrink-0">{step.conclusion}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="ml-6 mt-1 rounded border border-border/40 bg-background/40 px-2 py-2 typography-micro text-muted-foreground space-y-1">
|
<div className="ml-6 mt-1 rounded border border-border/40 bg-transparent px-2 py-2 typography-micro text-muted-foreground space-y-1">
|
||||||
{typeof step.number === 'number' ? <div>Step: {step.number}</div> : null}
|
{typeof step.number === 'number' ? <div>Step: {step.number}</div> : null}
|
||||||
{step.status ? <div>Status: {step.status}</div> : null}
|
{step.status ? <div>Status: {step.status}</div> : null}
|
||||||
{step.conclusion ? <div>Conclusion: {step.conclusion}</div> : null}
|
{step.conclusion ? <div>Conclusion: {step.conclusion}</div> : null}
|
||||||
@@ -1304,7 +1304,7 @@ export const PullRequestSection: React.FC<{
|
|||||||
|
|
||||||
const containerClassName =
|
const containerClassName =
|
||||||
variant === 'framed'
|
variant === 'framed'
|
||||||
? 'rounded-xl border border-border/60 bg-background/70 overflow-hidden'
|
? 'rounded-xl border border-border/60 bg-transparent overflow-hidden'
|
||||||
: 'border-0 bg-transparent rounded-none';
|
: 'border-0 bg-transparent rounded-none';
|
||||||
const headerClassName =
|
const headerClassName =
|
||||||
variant === 'framed'
|
variant === 'framed'
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function useGitPolling() {
|
|||||||
|
|
||||||
setActiveDirectory(effectiveDirectory);
|
setActiveDirectory(effectiveDirectory);
|
||||||
|
|
||||||
fetchAll(effectiveDirectory, git);
|
void fetchAll(effectiveDirectory, git, { silentIfCached: true });
|
||||||
|
|
||||||
startPolling(git);
|
startPolling(git);
|
||||||
|
|
||||||
|
|||||||
@@ -1040,6 +1040,63 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
|
|||||||
animation: attention-diamond-pulse 2.3s ease-in-out infinite;
|
animation: attention-diamond-pulse 2.3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes navrail-dot-wave {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(0.72);
|
||||||
|
opacity: 0.52;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-navrail-dot-wave {
|
||||||
|
animation: navrail-dot-wave 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes border-glow-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
border-color: var(--interactive-border);
|
||||||
|
outline-color: transparent;
|
||||||
|
}
|
||||||
|
12.5% {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 22%, var(--interactive-border));
|
||||||
|
outline-color: color-mix(in srgb, var(--primary) 8%, transparent);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 40%, var(--interactive-border));
|
||||||
|
outline-color: color-mix(in srgb, var(--primary) 16%, transparent);
|
||||||
|
}
|
||||||
|
37.5% {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 62%, var(--interactive-border));
|
||||||
|
outline-color: color-mix(in srgb, var(--primary) 26%, transparent);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 78%, var(--interactive-border));
|
||||||
|
outline-color: color-mix(in srgb, var(--primary) 34%, transparent);
|
||||||
|
}
|
||||||
|
62.5% {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 62%, var(--interactive-border));
|
||||||
|
outline-color: color-mix(in srgb, var(--primary) 26%, transparent);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 40%, var(--interactive-border));
|
||||||
|
outline-color: color-mix(in srgb, var(--primary) 16%, transparent);
|
||||||
|
}
|
||||||
|
87.5% {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 22%, var(--interactive-border));
|
||||||
|
outline-color: color-mix(in srgb, var(--primary) 8%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-border-glow-pulse {
|
||||||
|
outline: 1px solid transparent;
|
||||||
|
outline-offset: 1px;
|
||||||
|
will-change: border-color, outline-color;
|
||||||
|
animation: border-glow-pulse 3.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes marquee-scroll {
|
@keyframes marquee-scroll {
|
||||||
0% { transform: translateX(0); }
|
0% { transform: translateX(0); }
|
||||||
100% { transform: translateX(-100%); }
|
100% { transform: translateX(-100%); }
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ export async function generateCommitMessage(
|
|||||||
providerId: generationSession.providerID,
|
providerId: generationSession.providerID,
|
||||||
modelId: generationSession.modelID,
|
modelId: generationSession.modelID,
|
||||||
agent: generationSession.agent,
|
agent: generationSession.agent,
|
||||||
variant: generationSession.variant,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const prompt = `You are generating a Conventional Commits subject line using session context and selected file paths.
|
const prompt = `You are generating a Conventional Commits subject line using session context and selected file paths.
|
||||||
@@ -251,7 +250,6 @@ export async function generatePullRequestDescription(
|
|||||||
providerId: generationSession.providerID,
|
providerId: generationSession.providerID,
|
||||||
modelId: generationSession.modelID,
|
modelId: generationSession.modelID,
|
||||||
agent: generationSession.agent,
|
agent: generationSession.agent,
|
||||||
variant: generationSession.variant,
|
|
||||||
base: payload.base,
|
base: payload.base,
|
||||||
head: payload.head,
|
head: payload.head,
|
||||||
commits: commits.length,
|
commits: commits.length,
|
||||||
@@ -324,7 +322,6 @@ type SessionGenerationContext = {
|
|||||||
providerID: string;
|
providerID: string;
|
||||||
modelID: string;
|
modelID: string;
|
||||||
agent?: string;
|
agent?: string;
|
||||||
variant?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveSessionGenerationContext = (): SessionGenerationContext | null => {
|
const resolveSessionGenerationContext = (): SessionGenerationContext | null => {
|
||||||
@@ -347,16 +344,11 @@ const resolveSessionGenerationContext = (): SessionGenerationContext | null => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const variant = agent
|
|
||||||
? context.getAgentModelVariantForSession(sessionId, agent, selectedModel.providerId, selectedModel.modelId)
|
|
||||||
: (config.currentVariant || undefined);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
providerID: selectedModel.providerId,
|
providerID: selectedModel.providerId,
|
||||||
modelID: selectedModel.modelId,
|
modelID: selectedModel.modelId,
|
||||||
agent,
|
agent,
|
||||||
variant,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -381,7 +373,6 @@ const runStructuredGenerationInActiveSession = async ({
|
|||||||
providerID: generationSession.providerID,
|
providerID: generationSession.providerID,
|
||||||
modelID: generationSession.modelID,
|
modelID: generationSession.modelID,
|
||||||
agent: generationSession.agent,
|
agent: generationSession.agent,
|
||||||
variant: generationSession.variant,
|
|
||||||
});
|
});
|
||||||
const trimmedDirectory = typeof directory === 'string' ? directory.trim() : '';
|
const trimmedDirectory = typeof directory === 'string' ? directory.trim() : '';
|
||||||
const firstNewlineIndex = prompt.indexOf('\n');
|
const firstNewlineIndex = prompt.indexOf('\n');
|
||||||
@@ -407,7 +398,6 @@ const runStructuredGenerationInActiveSession = async ({
|
|||||||
modelID: generationSession.modelID,
|
modelID: generationSession.modelID,
|
||||||
},
|
},
|
||||||
...(generationSession.agent ? { agent: generationSession.agent } : {}),
|
...(generationSession.agent ? { agent: generationSession.agent } : {}),
|
||||||
...(generationSession.variant ? { variant: generationSession.variant } : {}),
|
|
||||||
format: {
|
format: {
|
||||||
type: 'json_schema',
|
type: 'json_schema',
|
||||||
schema,
|
schema,
|
||||||
|
|||||||
@@ -12,12 +12,12 @@
|
|||||||
"base": "#DA702C",
|
"base": "#DA702C",
|
||||||
"hover": "#DA702C",
|
"hover": "#DA702C",
|
||||||
"active": "#F9AE77",
|
"active": "#F9AE77",
|
||||||
"foreground": "#111010",
|
"foreground": "#151313",
|
||||||
"muted": "#DA702C80",
|
"muted": "#DA702C80",
|
||||||
"emphasis": "#F9AE77"
|
"emphasis": "#F9AE77"
|
||||||
},
|
},
|
||||||
"surface": {
|
"surface": {
|
||||||
"background": "#111010",
|
"background": "#151313",
|
||||||
"foreground": "#CECDC3",
|
"foreground": "#CECDC3",
|
||||||
"muted": "#1C1B1A",
|
"muted": "#1C1B1A",
|
||||||
"mutedForeground": "#878580",
|
"mutedForeground": "#878580",
|
||||||
@@ -40,19 +40,19 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"error": "#D14D41",
|
"error": "#D14D41",
|
||||||
"errorForeground": "#111010",
|
"errorForeground": "#151313",
|
||||||
"errorBackground": "#AF302920",
|
"errorBackground": "#AF302920",
|
||||||
"errorBorder": "#AF302950",
|
"errorBorder": "#AF302950",
|
||||||
"warning": "#DA702C",
|
"warning": "#DA702C",
|
||||||
"warningForeground": "#111010",
|
"warningForeground": "#151313",
|
||||||
"warningBackground": "#BC521520",
|
"warningBackground": "#BC521520",
|
||||||
"warningBorder": "#BC521550",
|
"warningBorder": "#BC521550",
|
||||||
"success": "#A0AF54",
|
"success": "#A0AF54",
|
||||||
"successForeground": "#111010",
|
"successForeground": "#151313",
|
||||||
"successBackground": "#66800B20",
|
"successBackground": "#66800B20",
|
||||||
"successBorder": "#66800B50",
|
"successBorder": "#66800B50",
|
||||||
"info": "#4385BE",
|
"info": "#4385BE",
|
||||||
"infoForeground": "#111010",
|
"infoForeground": "#151313",
|
||||||
"infoBackground": "#205EA620",
|
"infoBackground": "#205EA620",
|
||||||
"infoBorder": "#205EA650"
|
"infoBorder": "#205EA650"
|
||||||
},
|
},
|
||||||
@@ -138,9 +138,9 @@
|
|||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"userMessage": "#CECDC3",
|
"userMessage": "#CECDC3",
|
||||||
"userMessageBackground": "#2E1B10",
|
"userMessageBackground": "#27180E",
|
||||||
"assistantMessage": "#CECDC3",
|
"assistantMessage": "#CECDC3",
|
||||||
"assistantMessageBackground": "#111010",
|
"assistantMessageBackground": "#151313",
|
||||||
"timestamp": "#878580",
|
"timestamp": "#878580",
|
||||||
"divider": "#343331"
|
"divider": "#343331"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -499,18 +499,19 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
: Math.max(baseLimit, userExpandedLimit ?? 0);
|
: Math.max(baseLimit, userExpandedLimit ?? 0);
|
||||||
|
|
||||||
// Don't pass Infinity to API - use undefined for "fetch all".
|
// Don't pass Infinity to API - use undefined for "fetch all".
|
||||||
// For finite loads, overfetch by 1 so hasMoreAbove is accurate.
|
// Use targetLimit directly and infer "has more" when payload fills the window,
|
||||||
const fetchLimit = noLimit ? undefined : targetLimit + 1;
|
// matching OpenCode behavior and avoiding hidden "load older" on exact-limit responses.
|
||||||
|
const fetchLimit = noLimit ? undefined : targetLimit;
|
||||||
const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId, fetchLimit));
|
const allMessages = await executeWithSessionDirectory(sessionId, () => opencodeClient.getSessionMessages(sessionId, fetchLimit));
|
||||||
|
|
||||||
// Filter out reverted messages first
|
// Filter out reverted messages first
|
||||||
const revertMessageId = getSessionRevertMessageId(sessionId);
|
const revertMessageId = getSessionRevertMessageId(sessionId);
|
||||||
const messagesWithoutReverted = filterRevertedMessages(allMessages, revertMessageId);
|
const messagesWithoutReverted = filterRevertedMessages(allMessages, revertMessageId);
|
||||||
|
|
||||||
// Accurate older-history detection for finite loads.
|
// If server fills the requested window, assume there may be more above.
|
||||||
// If server returns > targetLimit, there are older messages above current window.
|
// This is intentionally optimistic and corrected on subsequent load-more calls.
|
||||||
const hasMoreAbove = typeof fetchLimit === 'number'
|
const hasMoreAbove = typeof fetchLimit === 'number'
|
||||||
? messagesWithoutReverted.length > targetLimit
|
? messagesWithoutReverted.length >= targetLimit
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const watermark = get().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId;
|
const watermark = get().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId;
|
||||||
@@ -2626,7 +2627,7 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fetchLimit = desiredLimit + 1;
|
const fetchLimit = desiredLimit;
|
||||||
const allMessages = await executeWithSessionDirectory(
|
const allMessages = await executeWithSessionDirectory(
|
||||||
sessionId,
|
sessionId,
|
||||||
() => opencodeClient.getSessionMessages(sessionId, fetchLimit)
|
() => opencodeClient.getSessionMessages(sessionId, fetchLimit)
|
||||||
@@ -2634,7 +2635,7 @@ export const useMessageStore = create<MessageStore>()(
|
|||||||
|
|
||||||
if (direction === "up" && currentMessages.length > 0) {
|
if (direction === "up" && currentMessages.length > 0) {
|
||||||
const dedupedMessages = dedupeMessagesById(allMessages);
|
const dedupedMessages = dedupeMessagesById(allMessages);
|
||||||
const hasPotentialMore = allMessages.length >= fetchLimit;
|
const hasPotentialMore = allMessages.length >= desiredLimit;
|
||||||
const firstCurrentMessage = currentMessages[0];
|
const firstCurrentMessage = currentMessages[0];
|
||||||
const indexInAll = dedupedMessages.findIndex((message) => message.info.id === firstCurrentMessage.info.id);
|
const indexInAll = dedupedMessages.findIndex((message) => message.info.id === firstCurrentMessage.info.id);
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,81 @@ const readSessionSelectionMap = (): SessionSelectionMap => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let sessionSelectionCache: SessionSelectionMap | null = null;
|
let sessionSelectionCache: SessionSelectionMap | null = null;
|
||||||
|
let loadSessionsRequestSeq = 0;
|
||||||
|
|
||||||
|
type ProjectSessionResult = {
|
||||||
|
projectId: string;
|
||||||
|
projectPath: string | null;
|
||||||
|
sessions: Session[];
|
||||||
|
discoveredWorktrees: WorktreeMetadata[];
|
||||||
|
validPaths: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectSessionCacheEntry = {
|
||||||
|
cachedAt: number;
|
||||||
|
result: ProjectSessionResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectRepoCacheEntry = {
|
||||||
|
cachedAt: number;
|
||||||
|
isGitRepo: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROJECT_SESSION_CACHE_TTL_MS = 30_000;
|
||||||
|
const PROJECT_REPO_STATUS_CACHE_TTL_MS = 120_000;
|
||||||
|
const projectSessionCache = new Map<string, ProjectSessionCacheEntry>();
|
||||||
|
const projectRepoStatusCache = new Map<string, ProjectRepoCacheEntry>();
|
||||||
|
|
||||||
|
const getFreshProjectSessionCache = (projectPath: string): ProjectSessionResult | null => {
|
||||||
|
const key = normalizePath(projectPath) ?? projectPath;
|
||||||
|
const cached = projectSessionCache.get(key);
|
||||||
|
if (!cached) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Date.now() - cached.cachedAt > PROJECT_SESSION_CACHE_TTL_MS) {
|
||||||
|
projectSessionCache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cached.result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setProjectSessionCache = (projectPath: string, result: ProjectSessionResult) => {
|
||||||
|
const key = normalizePath(projectPath) ?? projectPath;
|
||||||
|
projectSessionCache.set(key, { cachedAt: Date.now(), result });
|
||||||
|
};
|
||||||
|
|
||||||
|
const pruneProjectCaches = (validProjectPaths: Iterable<string>) => {
|
||||||
|
const valid = new Set<string>();
|
||||||
|
for (const path of validProjectPaths) {
|
||||||
|
const normalized = normalizePath(path) ?? path;
|
||||||
|
if (normalized) {
|
||||||
|
valid.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of projectSessionCache.keys()) {
|
||||||
|
if (!valid.has(key)) {
|
||||||
|
projectSessionCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of projectRepoStatusCache.keys()) {
|
||||||
|
if (!valid.has(key)) {
|
||||||
|
projectRepoStatusCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProjectRepoStatus = async (projectPath: string): Promise<boolean> => {
|
||||||
|
const key = normalizePath(projectPath) ?? projectPath;
|
||||||
|
const cached = projectRepoStatusCache.get(key);
|
||||||
|
if (cached && Date.now() - cached.cachedAt <= PROJECT_REPO_STATUS_CACHE_TTL_MS) {
|
||||||
|
return cached.isGitRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGitRepo = await checkIsGitRepository(key).catch(() => false);
|
||||||
|
projectRepoStatusCache.set(key, { cachedAt: Date.now(), isGitRepo });
|
||||||
|
return isGitRepo;
|
||||||
|
};
|
||||||
|
|
||||||
const getSessionSelectionMap = (): SessionSelectionMap => {
|
const getSessionSelectionMap = (): SessionSelectionMap => {
|
||||||
if (!sessionSelectionCache) {
|
if (!sessionSelectionCache) {
|
||||||
@@ -262,7 +337,8 @@ const getSessionDirectory = (sessions: Session[], sessionId: string): string | n
|
|||||||
const hydrateSessionWorktreeMetadata = async (
|
const hydrateSessionWorktreeMetadata = async (
|
||||||
sessions: Session[],
|
sessions: Session[],
|
||||||
projectDirectory: string | null,
|
projectDirectory: string | null,
|
||||||
existingMetadata: Map<string, WorktreeMetadata>
|
existingMetadata: Map<string, WorktreeMetadata>,
|
||||||
|
preloadedWorktrees?: WorktreeMetadata[]
|
||||||
): Promise<Map<string, WorktreeMetadata> | null> => {
|
): Promise<Map<string, WorktreeMetadata> | null> => {
|
||||||
const normalizedProject = normalizePath(projectDirectory);
|
const normalizedProject = normalizePath(projectDirectory);
|
||||||
if (!normalizedProject || sessions.length === 0) {
|
if (!normalizedProject || sessions.length === 0) {
|
||||||
@@ -278,11 +354,15 @@ const hydrateSessionWorktreeMetadata = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
let worktreeEntries: WorktreeMetadata[];
|
let worktreeEntries: WorktreeMetadata[];
|
||||||
try {
|
if (Array.isArray(preloadedWorktrees)) {
|
||||||
worktreeEntries = await listProjectWorktrees({ id: `path:${normalizedProject}`, path: normalizedProject });
|
worktreeEntries = preloadedWorktrees;
|
||||||
} catch (error) {
|
} else {
|
||||||
console.debug("Failed to hydrate worktree metadata from worktree list:", error);
|
try {
|
||||||
return null;
|
worktreeEntries = await listProjectWorktrees({ id: `path:${normalizedProject}`, path: normalizedProject });
|
||||||
|
} catch (error) {
|
||||||
|
console.debug("Failed to hydrate worktree metadata from worktree list:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(worktreeEntries) || worktreeEntries.length === 0) {
|
if (!Array.isArray(worktreeEntries) || worktreeEntries.length === 0) {
|
||||||
@@ -377,6 +457,8 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
availableWorktreesByProject: new Map(),
|
availableWorktreesByProject: new Map(),
|
||||||
|
|
||||||
loadSessions: async () => {
|
loadSessions: async () => {
|
||||||
|
const requestSeq = ++loadSessionsRequestSeq;
|
||||||
|
const isLatestRequest = () => requestSeq === loadSessionsRequestSeq;
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const directoryStore = useDirectoryStore.getState();
|
const directoryStore = useDirectoryStore.getState();
|
||||||
@@ -548,15 +630,148 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
? projectsStore.projects
|
? projectsStore.projects
|
||||||
: (legacyRoot ? [{ id: 'legacy', path: legacyRoot }] : []);
|
: (legacyRoot ? [{ id: 'legacy', path: legacyRoot }] : []);
|
||||||
|
|
||||||
type ProjectSessionResult = {
|
const applyProjectResults = async (projectResults: ProjectSessionResult[]) => {
|
||||||
projectId: string;
|
const sessionsByDirectory = new Map<string, Session[]>();
|
||||||
projectPath: string | null;
|
projectResults.forEach((result) => {
|
||||||
sessions: Session[];
|
if (!result.projectPath) {
|
||||||
discoveredWorktrees: WorktreeMetadata[];
|
return;
|
||||||
validPaths: Set<string>;
|
}
|
||||||
|
|
||||||
|
result.validPaths.forEach((directory) => {
|
||||||
|
const directoryKey = normalizePath(directory) ?? directory;
|
||||||
|
const directorySessions = result.sessions.filter((session) => {
|
||||||
|
const dir = normalizePath((session as { directory?: string | null }).directory ?? null) ?? directoryKey;
|
||||||
|
return dir === directoryKey;
|
||||||
|
});
|
||||||
|
sessionsByDirectory.set(directoryKey, dedupeSessionsById(directorySessions));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedSessions: Session[] = dedupeSessionsById(Array.from(sessionsByDirectory.values()).flat());
|
||||||
|
const stateSnapshot = get();
|
||||||
|
|
||||||
|
let nextWorktreeMetadata = stateSnapshot.worktreeMetadata;
|
||||||
|
for (const result of projectResults) {
|
||||||
|
if (!result.projectPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const hydratedMetadata = await hydrateSessionWorktreeMetadata(
|
||||||
|
result.sessions,
|
||||||
|
result.projectPath,
|
||||||
|
nextWorktreeMetadata,
|
||||||
|
result.discoveredWorktrees
|
||||||
|
);
|
||||||
|
if (hydratedMetadata) {
|
||||||
|
nextWorktreeMetadata = hydratedMetadata;
|
||||||
|
}
|
||||||
|
} catch (metadataError) {
|
||||||
|
console.debug("Failed to refresh worktree metadata during session load:", metadataError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
|
||||||
|
projectResults.forEach((result) => {
|
||||||
|
if (result.projectPath) {
|
||||||
|
worktreesByProject.set(result.projectPath, result.discoveredWorktrees);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const allValidPaths = new Set<string>();
|
||||||
|
projectResults.forEach((result) => {
|
||||||
|
result.validPaths.forEach((value) => {
|
||||||
|
const key = normalizePath(value) ?? value;
|
||||||
|
if (key) {
|
||||||
|
allValidPaths.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeDirectoryCandidate = normalizedFallback ?? activeProjectRoot ?? null;
|
||||||
|
const activeDirectory = activeDirectoryCandidate && allValidPaths.has(activeDirectoryCandidate)
|
||||||
|
? activeDirectoryCandidate
|
||||||
|
: (activeProjectRoot ?? activeDirectoryCandidate);
|
||||||
|
|
||||||
|
const activeDirectorySessions = activeDirectory
|
||||||
|
? sessionsByDirectory.get(activeDirectory) ?? []
|
||||||
|
: mergedSessions;
|
||||||
|
|
||||||
|
const validSessionIds = new Set(mergedSessions.map((session) => session.id));
|
||||||
|
|
||||||
|
// Keep directory-scoped stored selections tidy.
|
||||||
|
for (const [directoryKey, directorySessions] of sessionsByDirectory.entries()) {
|
||||||
|
clearInvalidSessionSelection(directoryKey, directorySessions.map((session) => session.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryChanged = (activeDirectory ?? null) !== (stateSnapshot.lastLoadedDirectory ?? null);
|
||||||
|
|
||||||
|
let nextCurrentId = stateSnapshot.currentSessionId;
|
||||||
|
const currentSessionInActiveDirectory = Boolean(
|
||||||
|
nextCurrentId && activeDirectorySessions.some((session) => session.id === nextCurrentId)
|
||||||
|
);
|
||||||
|
if (!nextCurrentId || !validSessionIds.has(nextCurrentId) || (directoryChanged && !currentSessionInActiveDirectory)) {
|
||||||
|
nextCurrentId = activeDirectorySessions[0]?.id ?? mergedSessions[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeDirectory) {
|
||||||
|
const storedSelection = getStoredSessionForDirectory(activeDirectory);
|
||||||
|
if (storedSelection && validSessionIds.has(storedSelection)) {
|
||||||
|
nextCurrentId = storedSelection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedDirectoryForCurrent = (() => {
|
||||||
|
if (!nextCurrentId) {
|
||||||
|
return activeDirectory ?? null;
|
||||||
|
}
|
||||||
|
const metadataPath = nextWorktreeMetadata.get(nextCurrentId)?.path;
|
||||||
|
if (metadataPath) {
|
||||||
|
return normalizePath(metadataPath) ?? metadataPath;
|
||||||
|
}
|
||||||
|
const sessionDir = getSessionDirectory(mergedSessions, nextCurrentId);
|
||||||
|
if (sessionDir) {
|
||||||
|
return sessionDir;
|
||||||
|
}
|
||||||
|
return activeDirectory ?? null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!isLatestRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
opencodeClient.setDirectory(resolvedDirectoryForCurrent ?? undefined);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to sync OpenCode directory after session load:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeWorktrees = activeProjectRoot
|
||||||
|
? projectResults.find((result) => result.projectPath === activeProjectRoot)?.discoveredWorktrees ?? []
|
||||||
|
: [];
|
||||||
|
|
||||||
|
set({
|
||||||
|
sessions: mergedSessions,
|
||||||
|
sessionsByDirectory,
|
||||||
|
currentSessionId: nextCurrentId,
|
||||||
|
lastLoadedDirectory: activeDirectory ?? null,
|
||||||
|
isLoading: false,
|
||||||
|
worktreeMetadata: nextWorktreeMetadata,
|
||||||
|
availableWorktrees: activeWorktrees,
|
||||||
|
availableWorktreesByProject: worktreesByProject,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeDirectory) {
|
||||||
|
storeSessionForDirectory(activeDirectory, nextCurrentId);
|
||||||
|
}
|
||||||
|
if (resolvedDirectoryForCurrent && resolvedDirectoryForCurrent !== activeDirectory) {
|
||||||
|
storeSessionForDirectory(resolvedDirectoryForCurrent, nextCurrentId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (projectEntries.length === 0) {
|
if (projectEntries.length === 0) {
|
||||||
|
if (!isLatestRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
sessions: [],
|
sessions: [],
|
||||||
sessionsByDirectory: new Map(),
|
sessionsByDirectory: new Map(),
|
||||||
@@ -570,6 +785,29 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pruneProjectCaches(projectEntries.map((entry) => entry.path));
|
||||||
|
|
||||||
|
const activeProjectId = projectsStore.activeProjectId;
|
||||||
|
const cachedProjectResults: ProjectSessionResult[] = [];
|
||||||
|
projectEntries.forEach((project) => {
|
||||||
|
const normalizedProject = normalizePath(project.path);
|
||||||
|
if (!normalizedProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cached = getFreshProjectSessionCache(normalizedProject);
|
||||||
|
if (cached) {
|
||||||
|
cachedProjectResults.push(cached);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasCachedActiveProject = cachedProjectResults.some(
|
||||||
|
(result) => result.projectId === activeProjectId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasCachedActiveProject && isLatestRequest()) {
|
||||||
|
await applyProjectResults(cachedProjectResults);
|
||||||
|
}
|
||||||
|
|
||||||
const projectResults: ProjectSessionResult[] = await Promise.all(
|
const projectResults: ProjectSessionResult[] = await Promise.all(
|
||||||
projectEntries.map(async (project: Pick<ProjectEntry, 'id' | 'path'>) => {
|
projectEntries.map(async (project: Pick<ProjectEntry, 'id' | 'path'>) => {
|
||||||
const normalizedProject = normalizePath(project.path);
|
const normalizedProject = normalizePath(project.path);
|
||||||
@@ -583,7 +821,13 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGitRepo = await checkIsGitRepository(normalizedProject).catch(() => false);
|
const cached = getFreshProjectSessionCache(normalizedProject);
|
||||||
|
const isActiveProject = project.id === activeProjectId;
|
||||||
|
if (cached && !isActiveProject) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGitRepo = await getProjectRepoStatus(normalizedProject);
|
||||||
const parentSessions = await fetchSessionsForDirectory(normalizedProject || null);
|
const parentSessions = await fetchSessionsForDirectory(normalizedProject || null);
|
||||||
vscodeDebugLog("projectSessions", {
|
vscodeDebugLog("projectSessions", {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
@@ -634,144 +878,23 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
|
|
||||||
const mergedSessions = dedupeSessionsById([...parentSessions, ...subdirectorySessions]);
|
const mergedSessions = dedupeSessionsById([...parentSessions, ...subdirectorySessions]);
|
||||||
|
|
||||||
return {
|
const result: ProjectSessionResult = {
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
projectPath: normalizedProject,
|
projectPath: normalizedProject,
|
||||||
sessions: mergedSessions,
|
sessions: mergedSessions,
|
||||||
discoveredWorktrees,
|
discoveredWorktrees,
|
||||||
validPaths,
|
validPaths,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
setProjectSessionCache(normalizedProject, result);
|
||||||
|
return result;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
await applyProjectResults(projectResults);
|
||||||
const sessionsByDirectory = new Map<string, Session[]>();
|
|
||||||
projectResults.forEach((result) => {
|
|
||||||
if (!result.projectPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.validPaths.forEach((directory) => {
|
|
||||||
const directoryKey = normalizePath(directory) ?? directory;
|
|
||||||
const directorySessions = result.sessions.filter((session) => {
|
|
||||||
const dir = normalizePath((session as { directory?: string | null }).directory ?? null) ?? directoryKey;
|
|
||||||
return dir === directoryKey;
|
|
||||||
});
|
|
||||||
sessionsByDirectory.set(directoryKey, dedupeSessionsById(directorySessions));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const mergedSessions: Session[] = dedupeSessionsById(Array.from(sessionsByDirectory.values()).flat());
|
|
||||||
const stateSnapshot = get();
|
|
||||||
|
|
||||||
let nextWorktreeMetadata = stateSnapshot.worktreeMetadata;
|
|
||||||
for (const result of projectResults) {
|
|
||||||
if (!result.projectPath) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const hydratedMetadata = await hydrateSessionWorktreeMetadata(
|
|
||||||
result.sessions,
|
|
||||||
result.projectPath,
|
|
||||||
nextWorktreeMetadata
|
|
||||||
);
|
|
||||||
if (hydratedMetadata) {
|
|
||||||
nextWorktreeMetadata = hydratedMetadata;
|
|
||||||
}
|
|
||||||
} catch (metadataError) {
|
|
||||||
console.debug("Failed to refresh worktree metadata during session load:", metadataError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
|
|
||||||
projectResults.forEach((result) => {
|
|
||||||
if (result.projectPath) {
|
|
||||||
worktreesByProject.set(result.projectPath, result.discoveredWorktrees);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const allValidPaths = new Set<string>();
|
|
||||||
projectResults.forEach((result) => {
|
|
||||||
result.validPaths.forEach((value) => {
|
|
||||||
const key = normalizePath(value) ?? value;
|
|
||||||
if (key) {
|
|
||||||
allValidPaths.add(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeDirectoryCandidate = normalizedFallback ?? activeProjectRoot ?? null;
|
|
||||||
const activeDirectory = activeDirectoryCandidate && allValidPaths.has(activeDirectoryCandidate)
|
|
||||||
? activeDirectoryCandidate
|
|
||||||
: (activeProjectRoot ?? activeDirectoryCandidate);
|
|
||||||
|
|
||||||
const activeDirectorySessions = activeDirectory
|
|
||||||
? sessionsByDirectory.get(activeDirectory) ?? []
|
|
||||||
: mergedSessions;
|
|
||||||
|
|
||||||
const validSessionIds = new Set(mergedSessions.map((session) => session.id));
|
|
||||||
|
|
||||||
// Keep directory-scoped stored selections tidy.
|
|
||||||
for (const [directoryKey, directorySessions] of sessionsByDirectory.entries()) {
|
|
||||||
clearInvalidSessionSelection(directoryKey, directorySessions.map((session) => session.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const directoryChanged = (activeDirectory ?? null) !== (stateSnapshot.lastLoadedDirectory ?? null);
|
|
||||||
|
|
||||||
let nextCurrentId = stateSnapshot.currentSessionId;
|
|
||||||
if (!nextCurrentId || !validSessionIds.has(nextCurrentId) || directoryChanged) {
|
|
||||||
nextCurrentId = activeDirectorySessions[0]?.id ?? mergedSessions[0]?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeDirectory) {
|
|
||||||
const storedSelection = getStoredSessionForDirectory(activeDirectory);
|
|
||||||
if (storedSelection && validSessionIds.has(storedSelection)) {
|
|
||||||
nextCurrentId = storedSelection;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedDirectoryForCurrent = (() => {
|
|
||||||
if (!nextCurrentId) {
|
|
||||||
return activeDirectory ?? null;
|
|
||||||
}
|
|
||||||
const metadataPath = nextWorktreeMetadata.get(nextCurrentId)?.path;
|
|
||||||
if (metadataPath) {
|
|
||||||
return normalizePath(metadataPath) ?? metadataPath;
|
|
||||||
}
|
|
||||||
const sessionDir = getSessionDirectory(mergedSessions, nextCurrentId);
|
|
||||||
if (sessionDir) {
|
|
||||||
return sessionDir;
|
|
||||||
}
|
|
||||||
return activeDirectory ?? null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
try {
|
|
||||||
opencodeClient.setDirectory(resolvedDirectoryForCurrent ?? undefined);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to sync OpenCode directory after session load:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeWorktrees = activeProjectRoot
|
|
||||||
? projectResults.find((result) => result.projectPath === activeProjectRoot)?.discoveredWorktrees ?? []
|
|
||||||
: [];
|
|
||||||
|
|
||||||
set({
|
|
||||||
sessions: mergedSessions,
|
|
||||||
sessionsByDirectory,
|
|
||||||
currentSessionId: nextCurrentId,
|
|
||||||
lastLoadedDirectory: activeDirectory ?? null,
|
|
||||||
isLoading: false,
|
|
||||||
worktreeMetadata: nextWorktreeMetadata,
|
|
||||||
availableWorktrees: activeWorktrees,
|
|
||||||
availableWorktreesByProject: worktreesByProject,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (activeDirectory) {
|
|
||||||
storeSessionForDirectory(activeDirectory, nextCurrentId);
|
|
||||||
}
|
|
||||||
if (resolvedDirectoryForCurrent && resolvedDirectoryForCurrent !== activeDirectory) {
|
|
||||||
storeSessionForDirectory(resolvedDirectoryForCurrent, nextCurrentId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!isLatestRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
set({
|
set({
|
||||||
error: error instanceof Error ? error.message : "Failed to load sessions",
|
error: error instanceof Error ? error.message : "Failed to load sessions",
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ const GIT_POLL_MAX_INTERVAL = 10000;
|
|||||||
const GIT_POLL_BACKOFF_STEP = 5000;
|
const GIT_POLL_BACKOFF_STEP = 5000;
|
||||||
const LOG_STALE_THRESHOLD = 10000;
|
const LOG_STALE_THRESHOLD = 10000;
|
||||||
const DIFF_PREFETCH_MAX_FILES = 25;
|
const DIFF_PREFETCH_MAX_FILES = 25;
|
||||||
|
const DIFF_PREFETCH_FOCUS_MAX_FILES = 40;
|
||||||
const DIFF_PREFETCH_CONCURRENCY = 4;
|
const DIFF_PREFETCH_CONCURRENCY = 4;
|
||||||
const DIFF_PREFETCH_TIMEOUT_MS = 15000;
|
const DIFF_PREFETCH_TIMEOUT_MS = 15000;
|
||||||
|
const RECENT_DIRECTORIES_LIMIT = 3;
|
||||||
|
|
||||||
// Diff cache limits to prevent memory bloat with many modified files
|
// Diff cache limits to prevent memory bloat with many modified files
|
||||||
const DIFF_CACHE_MAX_ENTRIES = 30;
|
const DIFF_CACHE_MAX_ENTRIES = 30;
|
||||||
@@ -37,6 +39,7 @@ interface GitStore {
|
|||||||
directories: Map<string, DirectoryGitState>;
|
directories: Map<string, DirectoryGitState>;
|
||||||
|
|
||||||
activeDirectory: string | null;
|
activeDirectory: string | null;
|
||||||
|
recentDirectories: string[];
|
||||||
|
|
||||||
isLoadingStatus: boolean;
|
isLoadingStatus: boolean;
|
||||||
isLoadingLog: boolean;
|
isLoadingLog: boolean;
|
||||||
@@ -53,12 +56,13 @@ interface GitStore {
|
|||||||
fetchBranches: (directory: string, git: GitAPI) => Promise<void>;
|
fetchBranches: (directory: string, git: GitAPI) => Promise<void>;
|
||||||
fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise<void>;
|
fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise<void>;
|
||||||
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
|
fetchIdentity: (directory: string, git: GitAPI) => Promise<void>;
|
||||||
fetchAll: (directory: string, git: GitAPI, options?: { force?: boolean }) => Promise<void>;
|
fetchAll: (directory: string, git: GitAPI, options?: { force?: boolean; silentIfCached?: boolean }) => Promise<void>;
|
||||||
|
|
||||||
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number; isBinary?: boolean } | null;
|
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number; isBinary?: boolean } | null;
|
||||||
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }) => void;
|
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }) => void;
|
||||||
clearDiffCache: (directory: string) => void;
|
clearDiffCache: (directory: string) => void;
|
||||||
fetchAllDiffs: (directory: string, git: GitAPI) => Promise<void>;
|
fetchAllDiffs: (directory: string, git: GitAPI) => Promise<void>;
|
||||||
|
prefetchDiffs: (directory: string, git: GitAPI, filePaths: string[], options?: { maxFiles?: number }) => Promise<void>;
|
||||||
|
|
||||||
setLogMaxCount: (directory: string, maxCount: number) => void;
|
setLogMaxCount: (directory: string, maxCount: number) => void;
|
||||||
|
|
||||||
@@ -84,6 +88,28 @@ interface GitAPI {
|
|||||||
getGitFileDiff: (directory: string, options: { path: string }) => Promise<GitFileDiffResponse>;
|
getGitFileDiff: (directory: string, options: { path: string }) => Promise<GitFileDiffResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inFlightDiffFetchesByDirectory = new Map<string, Set<string>>();
|
||||||
|
const diffFetchGenerationByDirectory = new Map<string, number>();
|
||||||
|
|
||||||
|
const getDiffFetchGeneration = (directory: string): number =>
|
||||||
|
diffFetchGenerationByDirectory.get(directory) ?? 0;
|
||||||
|
|
||||||
|
const bumpDiffFetchGeneration = (directory: string): number => {
|
||||||
|
const next = getDiffFetchGeneration(directory) + 1;
|
||||||
|
diffFetchGenerationByDirectory.set(directory, next);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInFlightDiffs = (directory: string): Set<string> => {
|
||||||
|
const existing = inFlightDiffFetchesByDirectory.get(directory);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const created = new Set<string>();
|
||||||
|
inFlightDiffFetchesByDirectory.set(directory, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
const createEmptyDirectoryState = (): DirectoryGitState => ({
|
const createEmptyDirectoryState = (): DirectoryGitState => ({
|
||||||
isGitRepo: null,
|
isGitRepo: null,
|
||||||
status: null,
|
status: null,
|
||||||
@@ -241,6 +267,7 @@ export const useGitStore = create<GitStore>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
directories: new Map(),
|
directories: new Map(),
|
||||||
activeDirectory: null,
|
activeDirectory: null,
|
||||||
|
recentDirectories: [],
|
||||||
isLoadingStatus: false,
|
isLoadingStatus: false,
|
||||||
isLoadingLog: false,
|
isLoadingLog: false,
|
||||||
isLoadingBranches: false,
|
isLoadingBranches: false,
|
||||||
@@ -249,15 +276,26 @@ export const useGitStore = create<GitStore>()(
|
|||||||
currentPollInterval: GIT_POLL_BASE_INTERVAL,
|
currentPollInterval: GIT_POLL_BASE_INTERVAL,
|
||||||
|
|
||||||
setActiveDirectory: (directory) => {
|
setActiveDirectory: (directory) => {
|
||||||
const { activeDirectory, directories } = get();
|
const { activeDirectory, directories, recentDirectories } = get();
|
||||||
if (activeDirectory === directory) return;
|
if (activeDirectory === directory) return;
|
||||||
|
|
||||||
|
if (activeDirectory) {
|
||||||
|
bumpDiffFetchGeneration(activeDirectory);
|
||||||
|
}
|
||||||
|
if (directory) {
|
||||||
|
bumpDiffFetchGeneration(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRecentDirectories = directory
|
||||||
|
? [directory, ...recentDirectories.filter((entry) => entry !== directory)].slice(0, RECENT_DIRECTORIES_LIMIT)
|
||||||
|
: recentDirectories;
|
||||||
|
|
||||||
if (directory && !directories.has(directory)) {
|
if (directory && !directories.has(directory)) {
|
||||||
const newDirectories = new Map(directories);
|
const newDirectories = new Map(directories);
|
||||||
newDirectories.set(directory, createEmptyDirectoryState());
|
newDirectories.set(directory, createEmptyDirectoryState());
|
||||||
set({ activeDirectory: directory, directories: newDirectories });
|
set({ activeDirectory: directory, recentDirectories: nextRecentDirectories, directories: newDirectories });
|
||||||
} else {
|
} else {
|
||||||
set({ activeDirectory: directory });
|
set({ activeDirectory: directory, recentDirectories: nextRecentDirectories });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -322,6 +360,9 @@ export const useGitStore = create<GitStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasFileContentChange = changedPaths.size > 0;
|
const hasFileContentChange = changedPaths.size > 0;
|
||||||
|
if (hasFileContentChange) {
|
||||||
|
bumpDiffFetchGeneration(directory);
|
||||||
|
}
|
||||||
|
|
||||||
newDirectories.set(directory, {
|
newDirectories.set(directory, {
|
||||||
...currentDirState,
|
...currentDirState,
|
||||||
@@ -423,10 +464,12 @@ export const useGitStore = create<GitStore>()(
|
|||||||
set({ directories: newDirectories });
|
set({ directories: newDirectories });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { force = false } = options;
|
const { force = false, silentIfCached = false } = options;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
await get().fetchStatus(directory, git);
|
await get().fetchStatus(directory, git, {
|
||||||
|
silent: silentIfCached && Boolean(dirState?.status),
|
||||||
|
});
|
||||||
|
|
||||||
const updatedDirState = get().directories.get(directory);
|
const updatedDirState = get().directories.get(directory);
|
||||||
if (!updatedDirState?.isGitRepo) return;
|
if (!updatedDirState?.isGitRepo) return;
|
||||||
@@ -462,6 +505,7 @@ export const useGitStore = create<GitStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearDiffCache: (directory) => {
|
clearDiffCache: (directory) => {
|
||||||
|
bumpDiffFetchGeneration(directory);
|
||||||
const newDirectories = new Map(get().directories);
|
const newDirectories = new Map(get().directories);
|
||||||
const dirState = newDirectories.get(directory);
|
const dirState = newDirectories.get(directory);
|
||||||
if (dirState) {
|
if (dirState) {
|
||||||
@@ -474,13 +518,49 @@ export const useGitStore = create<GitStore>()(
|
|||||||
const dirState = get().directories.get(directory);
|
const dirState = get().directories.get(directory);
|
||||||
if (!dirState?.status?.files || dirState.status.files.length === 0) return;
|
if (!dirState?.status?.files || dirState.status.files.length === 0) return;
|
||||||
|
|
||||||
const files = dirState.status.files;
|
const limitedFilesToFetch = dirState.status.files
|
||||||
|
.map((file) => file.path)
|
||||||
|
.slice(0, DIFF_PREFETCH_MAX_FILES);
|
||||||
|
await get().prefetchDiffs(directory, git, limitedFilesToFetch, { maxFiles: DIFF_PREFETCH_MAX_FILES });
|
||||||
|
},
|
||||||
|
|
||||||
// Find files that need fetching (no cache)
|
prefetchDiffs: async (directory, git, filePaths, options = {}) => {
|
||||||
const filesToFetch = files.filter((file) => !dirState.diffCache.has(file.path));
|
const dirState = get().directories.get(directory);
|
||||||
|
if (!dirState?.status?.files || dirState.status.files.length === 0 || filePaths.length === 0) return;
|
||||||
|
|
||||||
const limitedFilesToFetch = filesToFetch.slice(0, DIFF_PREFETCH_MAX_FILES);
|
const { maxFiles = DIFF_PREFETCH_FOCUS_MAX_FILES } = options;
|
||||||
if (limitedFilesToFetch.length === 0) return;
|
const availablePaths = new Set(dirState.status.files.map((file) => file.path));
|
||||||
|
const inFlight = getInFlightDiffs(directory);
|
||||||
|
|
||||||
|
const dedupedPaths: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
if (!filePath || seen.has(filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(filePath);
|
||||||
|
if (!availablePaths.has(filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (dirState.diffCache.has(filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFlight.has(filePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dedupedPaths.push(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitedFilePaths = dedupedPaths.slice(0, Math.max(1, maxFiles));
|
||||||
|
if (limitedFilePaths.length === 0) return;
|
||||||
|
|
||||||
|
const generation = getDiffFetchGeneration(directory);
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined' && document.hidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
limitedFilePaths.forEach((path) => inFlight.add(path));
|
||||||
|
|
||||||
let nextIndex = 0;
|
let nextIndex = 0;
|
||||||
const results: Array<{ path: string; diff: { original: string; modified: string; isBinary?: boolean } }> = [];
|
const results: Array<{ path: string; diff: { original: string; modified: string; isBinary?: boolean } }> = [];
|
||||||
@@ -488,7 +568,7 @@ export const useGitStore = create<GitStore>()(
|
|||||||
const takeNext = () => {
|
const takeNext = () => {
|
||||||
const current = nextIndex;
|
const current = nextIndex;
|
||||||
nextIndex += 1;
|
nextIndex += 1;
|
||||||
return current < limitedFilesToFetch.length ? limitedFilesToFetch[current] : null;
|
return current < limitedFilePaths.length ? limitedFilePaths[current] : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchWithTimeout = async (filePath: string) => {
|
const fetchWithTimeout = async (filePath: string) => {
|
||||||
@@ -505,19 +585,30 @@ export const useGitStore = create<GitStore>()(
|
|||||||
|
|
||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
if (generation !== getDiffFetchGeneration(directory)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const next = takeNext();
|
const next = takeNext();
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
try {
|
try {
|
||||||
results.push(await fetchWithTimeout(next.path));
|
results.push(await fetchWithTimeout(next));
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore individual failures/timeouts during prefetch.
|
// Ignore individual failures/timeouts during prefetch.
|
||||||
|
} finally {
|
||||||
|
inFlight.delete(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const workerCount = Math.min(DIFF_PREFETCH_CONCURRENCY, limitedFilesToFetch.length);
|
const workerCount = Math.min(DIFF_PREFETCH_CONCURRENCY, limitedFilePaths.length);
|
||||||
await Promise.allSettled(Array.from({ length: workerCount }, () => worker()));
|
await Promise.allSettled(Array.from({ length: workerCount }, () => worker()));
|
||||||
|
|
||||||
|
limitedFilePaths.forEach((path) => inFlight.delete(path));
|
||||||
|
|
||||||
|
if (generation !== getDiffFetchGeneration(directory)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update diff cache with results
|
// Update diff cache with results
|
||||||
const newDirectories = new Map(get().directories);
|
const newDirectories = new Map(get().directories);
|
||||||
const currentDirState = newDirectories.get(directory);
|
const currentDirState = newDirectories.get(directory);
|
||||||
@@ -559,17 +650,34 @@ export const useGitStore = create<GitStore>()(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { activeDirectory } = get();
|
const { activeDirectory, recentDirectories } = get();
|
||||||
if (!activeDirectory) {
|
if (!activeDirectory) {
|
||||||
set({ pollIntervalId: schedulePoll() });
|
set({ pollIntervalId: schedulePoll() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusChanged = await get().fetchStatus(activeDirectory, git, { silent: true });
|
const pollTargets = [
|
||||||
if (statusChanged) {
|
activeDirectory,
|
||||||
await get().fetchLog(activeDirectory, git);
|
...recentDirectories
|
||||||
// Pre-fetch all diffs so they're ready when user opens Diff tab
|
.filter((directory) => directory !== activeDirectory)
|
||||||
void get().fetchAllDiffs(activeDirectory, git);
|
.slice(0, Math.max(0, RECENT_DIRECTORIES_LIMIT - 1)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let anyStatusChanged = false;
|
||||||
|
|
||||||
|
for (const targetDirectory of pollTargets) {
|
||||||
|
const statusChanged = await get().fetchStatus(targetDirectory, git, { silent: true });
|
||||||
|
if (statusChanged) {
|
||||||
|
anyStatusChanged = true;
|
||||||
|
if (targetDirectory === activeDirectory) {
|
||||||
|
await get().fetchLog(activeDirectory, git);
|
||||||
|
// Pre-fetch all diffs so they're ready when user opens Diff tab
|
||||||
|
void get().fetchAllDiffs(activeDirectory, git);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anyStatusChanged) {
|
||||||
// Reset to base interval on changes
|
// Reset to base interval on changes
|
||||||
set({ currentPollInterval: GIT_POLL_BASE_INTERVAL });
|
set({ currentPollInterval: GIT_POLL_BASE_INTERVAL });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,6 +7,25 @@ import { updateDesktopSettings } from '@/lib/persistence';
|
|||||||
import { getSafeStorage } from './utils/safeStorage';
|
import { getSafeStorage } from './utils/safeStorage';
|
||||||
import { useDirectoryStore } from './useDirectoryStore';
|
import { useDirectoryStore } from './useDirectoryStore';
|
||||||
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
||||||
|
import { PROJECT_COLORS } from '@/lib/projectMeta';
|
||||||
|
|
||||||
|
/** Pick a color key that's least used among existing projects */
|
||||||
|
const pickAutoColor = (projects: ProjectEntry[]): string => {
|
||||||
|
const colorKeys = PROJECT_COLORS.map((c) => c.key);
|
||||||
|
const usageCounts = new Map<string, number>();
|
||||||
|
for (const key of colorKeys) {
|
||||||
|
usageCounts.set(key, 0);
|
||||||
|
}
|
||||||
|
for (const p of projects) {
|
||||||
|
if (p.color && usageCounts.has(p.color)) {
|
||||||
|
usageCounts.set(p.color, (usageCounts.get(p.color) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Find minimum usage, then pick randomly among those with min usage
|
||||||
|
const minUsage = Math.min(...usageCounts.values());
|
||||||
|
const candidates = colorKeys.filter((k) => usageCounts.get(k) === minUsage);
|
||||||
|
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||||
|
};
|
||||||
|
|
||||||
interface ProjectPathValidationResult {
|
interface ProjectPathValidationResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -280,6 +299,7 @@ export const useProjectsStore = create<ProjectsStore>()(
|
|||||||
id,
|
id,
|
||||||
path: normalizedPath,
|
path: normalizedPath,
|
||||||
label,
|
label,
|
||||||
|
color: pickAutoColor(get().projects),
|
||||||
addedAt: now,
|
addedAt: now,
|
||||||
lastOpenedAt: now,
|
lastOpenedAt: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ const normalizePath = (value?: string | null): string | null => {
|
|||||||
return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced;
|
return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sessionChoiceAnalysisSignature = new Map<string, string>();
|
||||||
|
const ENABLE_ACTIVE_SESSION_TRIM = false;
|
||||||
|
|
||||||
|
const buildSessionChoiceAnalysisSignature = (messages: Array<{ info: Message; parts: Part[] }>): string => {
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : '';
|
||||||
|
const lastAssistant = [...messages]
|
||||||
|
.reverse()
|
||||||
|
.find((message) => message.info?.role === 'assistant');
|
||||||
|
const lastAssistantId = typeof lastAssistant?.info?.id === 'string' ? lastAssistant.info.id : '';
|
||||||
|
return `${messages.length}:${lastMessageId}:${lastAssistantId}`;
|
||||||
|
};
|
||||||
|
|
||||||
const resolveSessionDirectory = (
|
const resolveSessionDirectory = (
|
||||||
sessions: Session[],
|
sessions: Session[],
|
||||||
sessionId: string | null | undefined,
|
sessionId: string | null | undefined,
|
||||||
@@ -319,7 +332,9 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
await get().loadMessages(id);
|
await get().loadMessages(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
get().trimToViewportWindow(id, getMessageLimit());
|
if (ENABLE_ACTIVE_SESSION_TRIM) {
|
||||||
|
get().trimToViewportWindow(id, getMessageLimit());
|
||||||
|
}
|
||||||
|
|
||||||
// Analyze session messages to extract agent/model/variant choices
|
// Analyze session messages to extract agent/model/variant choices
|
||||||
// This ensures context is available even when ModelControls isn't mounted
|
// This ensures context is available even when ModelControls isn't mounted
|
||||||
@@ -327,12 +342,18 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
if (sessionMessages && sessionMessages.length > 0) {
|
if (sessionMessages && sessionMessages.length > 0) {
|
||||||
const agents = useConfigStore.getState().agents;
|
const agents = useConfigStore.getState().agents;
|
||||||
if (agents.length > 0) {
|
if (agents.length > 0) {
|
||||||
|
const analysisSignature = buildSessionChoiceAnalysisSignature(sessionMessages);
|
||||||
|
if (sessionChoiceAnalysisSignature.get(id) === analysisSignature) {
|
||||||
|
get().evictLeastRecentlyUsed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await useContextStore.getState().analyzeAndSaveExternalSessionChoices(
|
await useContextStore.getState().analyzeAndSaveExternalSessionChoices(
|
||||||
id,
|
id,
|
||||||
agents,
|
agents,
|
||||||
get().messages
|
get().messages
|
||||||
);
|
);
|
||||||
|
sessionChoiceAnalysisSignature.set(id, analysisSignature);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to analyze session choices:', error);
|
console.warn('Failed to analyze session choices:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
height: var(--particle-size, 6px);
|
height: var(--particle-size, 6px);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--firework-color, var(--status-success));
|
background: var(--firework-color, var(--status-success));
|
||||||
box-shadow: 0 0 12px rgba(255, 255, 255, 0.25);
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-50%, -50%) scale(0.4);
|
transform: translate(-50%, -50%) scale(0.4);
|
||||||
animation: firework-pop var(--firework-duration, var(--fireworks-duration-ms)) ease-out forwards;
|
animation: firework-pop var(--firework-duration, var(--fireworks-duration-ms)) ease-out forwards;
|
||||||
|
|||||||
Reference in New Issue
Block a user