Major UI refresh: sidebar redesign, theme expansion, and chat performance optimizations (#706)
## Summary Complete sidebar redesign and comprehensive UI polish pass with performance optimizations, theme system refinements, and desktop integration improvements. ## Key Changes **Sidebar & Navigation Redesign** - Redesigned sessions sidebar layout with unified button primitives - Added activity sections with project grouping and improved session organization - Refined sidebar corners, spacing, and visual hierarchy - Removed NavRail component in favor of streamlined sidebar - Stabilized sessions bar toggle position in fullscreen mode **Performance Optimizations** - Reduced chat streaming CPU usage and storage churn - Optimized task tool polling and live timers with debouncing - Prevented chat state races and reduced background request load - Debounced draft writes and coalesced session reloads - Optimized message store updates and turn tracking **Theme & Visual System** - Added theme-aware window corners (desktop) and border radius tokens - Introduced glassmorphism effects on desktop sidebar - Added backdrop blur to UI elements **Chat Experience** - Added session-based permission auto-accept toggle in chat input - Polished permission shield UX with improved icon sizing and spacing - Fixed chat scroll-to-bottom behavior and timeline tracking - Enhanced tool output display with better path label detection - Removed duplicate draft context details in chat header - Added text selection menu to chat messages **Git Improvements** - Refreshed git history visual design with cleaner dividers - Added remote removal action in sync selector - Stabilized git polling to prevent excessive requests - Improved tool output rendering for git operations **Settings & Panels** - Fixed mobile scrolling on settings pages - Made outside-click settings close instantly - Reduced settings load churn and CPU spikes - Improved services dropdown layout and spacing - Softened panel resize handles **Desktop Integration** - Synced macOS window theme with app theme - Restored window dragging in sidebar header zones - Fixed system window corners on macOS - Improved header session metadata and action controls **Button & Component Standardization** - Unified button primitives across all components - Standardized destructive action patterns - Removed unused button variants (button-large, button-small) - Aligned context tab close hit areas --------- Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
359879153a
commit
321cc7252a
@@ -15,7 +15,7 @@ import { useChatScrollManager } from '@/hooks/useChatScrollManager';
|
||||
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { ButtonSmall } from '@/components/ui/button-small';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||
import { TimelineDialog } from './TimelineDialog';
|
||||
import type { PermissionRequest } from '@/types/permission';
|
||||
@@ -30,6 +30,7 @@ const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
|
||||
const EMPTY_QUESTIONS: QuestionRequest[] = [];
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
const SESSION_RESELECTED_EVENT = 'openchamber:session-reselected';
|
||||
|
||||
type HydratingToolSkeletonRow = {
|
||||
id: string;
|
||||
@@ -193,7 +194,7 @@ export const ChatContainer: React.FC = () => {
|
||||
}, [parentSession, setCurrentSession]);
|
||||
|
||||
const returnToParentButton = parentSession ? (
|
||||
<ButtonSmall
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -204,7 +205,7 @@ export const ChatContainer: React.FC = () => {
|
||||
>
|
||||
<RiArrowLeftLine className="h-4 w-4" />
|
||||
Parent
|
||||
</ButtonSmall>
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -255,6 +256,7 @@ export const ChatContainer: React.FC = () => {
|
||||
isPinned,
|
||||
isOverflowing,
|
||||
});
|
||||
const { resumeToBottomInstant } = timelineController;
|
||||
|
||||
React.useEffect(() => {
|
||||
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
||||
@@ -269,6 +271,26 @@ export const ChatContainer: React.FC = () => {
|
||||
resumeToBottom: timelineController.resumeToBottom,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || !currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSessionReselected = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<string>;
|
||||
if (customEvent.detail !== currentSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
resumeToBottomInstant();
|
||||
};
|
||||
|
||||
window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
|
||||
};
|
||||
}, [currentSessionId, resumeToBottomInstant]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
@@ -366,7 +388,7 @@ export const ChatContainer: React.FC = () => {
|
||||
>
|
||||
{!isDesktopExpandedInput ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<ChatEmptyState showDraftContext />
|
||||
<ChatEmptyState />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
@@ -374,7 +396,7 @@ export const ChatContainer: React.FC = () => {
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
|
||||
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
|
||||
)}
|
||||
>
|
||||
<ChatInput scrollToBottom={scrollToBottom} />
|
||||
@@ -444,7 +466,7 @@ export const ChatContainer: React.FC = () => {
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
|
||||
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
|
||||
)}
|
||||
>
|
||||
<ChatInput scrollToBottom={scrollToBottom} />
|
||||
@@ -507,7 +529,7 @@ export const ChatContainer: React.FC = () => {
|
||||
'relative z-10',
|
||||
isDesktopExpandedInput
|
||||
? 'flex-1 min-h-0 bg-background'
|
||||
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
|
||||
: 'bg-background/95 supports-[backdrop-filter]:bg-background/80'
|
||||
)}
|
||||
>
|
||||
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import React from 'react';
|
||||
import { RiGitBranchLine } from '@remixicon/react';
|
||||
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { TextLoop } from '@/components/ui/TextLoop';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useGitStatus, useGitStore } from '@/stores/useGitStore';
|
||||
|
||||
const phrases = [
|
||||
"Fix the failing tests",
|
||||
@@ -27,51 +22,15 @@ const phrases = [
|
||||
"Add type definitions",
|
||||
];
|
||||
|
||||
interface ChatEmptyStateProps {
|
||||
showDraftContext?: boolean;
|
||||
}
|
||||
|
||||
const ChatEmptyState: React.FC<ChatEmptyStateProps> = ({
|
||||
showDraftContext = false,
|
||||
}) => {
|
||||
const ChatEmptyState: React.FC = () => {
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const { git } = useRuntimeAPIs();
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const { setActiveDirectory, fetchStatus } = useGitStore();
|
||||
const gitStatus = useGitStatus(effectiveDirectory ?? null);
|
||||
|
||||
// Use theme's muted foreground for secondary text
|
||||
const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)';
|
||||
const branchName = typeof gitStatus?.current === 'string' && gitStatus.current.trim().length > 0
|
||||
? gitStatus.current.trim()
|
||||
: null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showDraftContext || !effectiveDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveDirectory(effectiveDirectory);
|
||||
|
||||
const state = useGitStore.getState().directories.get(effectiveDirectory);
|
||||
if (!state?.status && state?.isGitRepo !== false) {
|
||||
void fetchStatus(effectiveDirectory, git, { silent: true });
|
||||
}
|
||||
}, [effectiveDirectory, fetchStatus, git, setActiveDirectory, showDraftContext]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-full w-full gap-6">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
{showDraftContext && (
|
||||
<div className="max-w-[calc(100%-2rem)] flex flex-col items-center gap-1">
|
||||
{branchName && (
|
||||
<div className="inline-flex items-center gap-1 text-body-md" style={{ color: textColor }}>
|
||||
<RiGitBranchLine className="h-4 w-4 shrink-0" />
|
||||
<span className="overflow-hidden whitespace-nowrap" title={branchName}>{branchName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<TextLoop
|
||||
className="text-body-md"
|
||||
interval={4}
|
||||
|
||||
@@ -7,13 +7,17 @@ import {
|
||||
RiCloseLine,
|
||||
RiCommandLine,
|
||||
RiExternalLinkLine,
|
||||
RiFolderLine,
|
||||
RiFullscreenLine,
|
||||
RiGitPullRequestLine,
|
||||
RiShieldCheckLine,
|
||||
RiShieldUserLine,
|
||||
RiGithubLine,
|
||||
RiSendPlane2Line,
|
||||
} from '@remixicon/react';
|
||||
import { BrowserVoiceButton } from '@/components/voice';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useSessionStore as useSessionManagementStore } from '@/stores/sessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
@@ -25,7 +29,7 @@ import { QueuedMessageChips } from './QueuedMessageChips';
|
||||
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
||||
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
||||
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
|
||||
import { cn, isMacOS } from '@/lib/utils';
|
||||
import { cn, formatDirectoryName, isMacOS } from '@/lib/utils';
|
||||
import { ModelControls } from './ModelControls';
|
||||
import { UnifiedControlsDrawer } from './UnifiedControlsDrawer';
|
||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||
@@ -49,15 +53,52 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
|
||||
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
|
||||
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
|
||||
import { useGitBranches, useGitStore } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
|
||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
|
||||
const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500;
|
||||
|
||||
const normalizePath = (value?: string | null): string | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const normalized = trimmed.replace(/\\/g, '/');
|
||||
if (normalized === '/') {
|
||||
return '/';
|
||||
}
|
||||
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
||||
};
|
||||
|
||||
const getProjectDisplayLabel = (project: { label?: string; path: string }): string => {
|
||||
const label = project.label?.trim();
|
||||
if (label) {
|
||||
return label;
|
||||
}
|
||||
return formatDirectoryName(project.path);
|
||||
};
|
||||
|
||||
const getProjectIconColor = (projectColor?: string | null): string | undefined => {
|
||||
if (!projectColor) {
|
||||
return undefined;
|
||||
}
|
||||
return PROJECT_COLOR_MAP[projectColor] ?? undefined;
|
||||
};
|
||||
|
||||
const appendWithLineBreaks = (base: string, next: string): string => {
|
||||
const separator = !base
|
||||
@@ -154,10 +195,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
|
||||
// Ref to track current message value without triggering re-renders in effects
|
||||
const messageRef = React.useRef(message);
|
||||
const draftPersistTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const skipNextDraftPersistRef = React.useRef(false);
|
||||
const lastPersistedDraftRef = React.useRef<Map<string, string>>(new Map());
|
||||
const currentSessionIdForDraftRef = React.useRef<string | null>(null);
|
||||
|
||||
const sendMessage = useSessionStore((state) => state.sendMessage);
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
|
||||
const newSessionDraft = useSessionStore((state) => state.newSessionDraft);
|
||||
const newSessionDraftOpen = Boolean(newSessionDraft?.open);
|
||||
const setNewSessionDraftTarget = useSessionStore((state) => state.setNewSessionDraftTarget);
|
||||
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
||||
const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation);
|
||||
const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort);
|
||||
const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
|
||||
@@ -169,18 +217,25 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText);
|
||||
const pendingInputText = useSessionStore((state) => state.pendingInputText);
|
||||
const consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts);
|
||||
const currentManagementSessionId = useSessionManagementStore((state) => state.currentSessionId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||
|
||||
const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
|
||||
const agents = getVisibleAgents();
|
||||
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
|
||||
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore();
|
||||
const { working } = useAssistantStatus();
|
||||
const { git: runtimeGit } = useRuntimeAPIs();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const chatSearchDirectory = useChatSearchDirectory();
|
||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||
const [textareaScrollTop, setTextareaScrollTop] = React.useState(0);
|
||||
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
|
||||
|
||||
const isDesktopExpanded = isExpandedInput && !isMobile;
|
||||
const chatInputRadius = 'var(--radius-lg)';
|
||||
|
||||
const sendableAttachedFiles = React.useMemo(
|
||||
() => attachedFiles.filter((file) => file.source !== 'server'),
|
||||
@@ -423,6 +478,29 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
messageRef.current = message;
|
||||
}, [message]);
|
||||
|
||||
React.useEffect(() => {
|
||||
currentSessionIdForDraftRef.current = currentSessionId;
|
||||
}, [currentSessionId]);
|
||||
|
||||
const persistDraftImmediately = React.useCallback((sessionId: string | null, draft: string) => {
|
||||
const key = getDraftKey(sessionId);
|
||||
const lastPersisted = lastPersistedDraftRef.current.get(key);
|
||||
if (lastPersisted === draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveStoredDraft(sessionId, draft);
|
||||
lastPersistedDraftRef.current.set(key, draft);
|
||||
}, []);
|
||||
|
||||
const clearPendingDraftPersist = React.useCallback(() => {
|
||||
if (!draftPersistTimerRef.current) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(draftPersistTimerRef.current);
|
||||
draftPersistTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Handle initial draft restoration and text selection
|
||||
const hasHandledInitialDraftRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
@@ -455,10 +533,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const oldSessionId = prevSessionIdRef.current;
|
||||
prevSessionIdRef.current = currentSessionId;
|
||||
setInputMode('normal');
|
||||
clearPendingDraftPersist();
|
||||
skipNextDraftPersistRef.current = true;
|
||||
|
||||
if (persistChatDraft) {
|
||||
// Save current draft for the session we're leaving
|
||||
saveStoredDraft(oldSessionId, messageRef.current);
|
||||
persistDraftImmediately(oldSessionId, messageRef.current);
|
||||
// Restore draft for the session we're entering
|
||||
const newDraft = getStoredDraft(currentSessionId);
|
||||
setMessage(newDraft);
|
||||
@@ -472,7 +552,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
setMessage('');
|
||||
}
|
||||
}
|
||||
}, [currentSessionId, persistChatDraft]);
|
||||
}, [clearPendingDraftPersist, currentSessionId, persistChatDraft, persistDraftImmediately]);
|
||||
|
||||
// Focus textarea when new session draft is opened
|
||||
const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen);
|
||||
@@ -494,16 +574,37 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
// Persist chat input draft to localStorage per session (only if setting enabled)
|
||||
React.useEffect(() => {
|
||||
if (!persistChatDraft) {
|
||||
// Clear stored draft for current session when setting is disabled
|
||||
try {
|
||||
localStorage.removeItem(getDraftKey(currentSessionId));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
clearPendingDraftPersist();
|
||||
persistDraftImmediately(currentSessionId, '');
|
||||
return;
|
||||
}
|
||||
saveStoredDraft(currentSessionId, message);
|
||||
}, [message, persistChatDraft, currentSessionId]);
|
||||
|
||||
if (skipNextDraftPersistRef.current) {
|
||||
skipNextDraftPersistRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
clearPendingDraftPersist();
|
||||
const draftSnapshot = message;
|
||||
const sessionSnapshot = currentSessionId;
|
||||
draftPersistTimerRef.current = setTimeout(() => {
|
||||
draftPersistTimerRef.current = null;
|
||||
persistDraftImmediately(sessionSnapshot, draftSnapshot);
|
||||
}, CHAT_DRAFT_PERSIST_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
clearPendingDraftPersist();
|
||||
};
|
||||
}, [clearPendingDraftPersist, currentSessionId, message, persistChatDraft, persistDraftImmediately]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
clearPendingDraftPersist();
|
||||
if (persistChatDraft) {
|
||||
persistDraftImmediately(currentSessionIdForDraftRef.current, messageRef.current);
|
||||
}
|
||||
};
|
||||
}, [clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]);
|
||||
|
||||
// Session activity for queue availability and controls
|
||||
const { phase: sessionPhase } = useCurrentSessionActivity();
|
||||
@@ -2164,6 +2265,243 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
|
||||
const footerGapClass = 'gap-x-1.5 gap-y-0';
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const showDraftTargetSelectors = newSessionDraftOpen && !isVSCode;
|
||||
|
||||
const selectedDraftProject = React.useMemo(() => {
|
||||
const explicit = newSessionDraft?.selectedProjectId
|
||||
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
|
||||
: null;
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const active = activeProjectId
|
||||
? projects.find((project) => project.id === activeProjectId) ?? null
|
||||
: null;
|
||||
if (active) {
|
||||
return active;
|
||||
}
|
||||
|
||||
return projects[0] ?? null;
|
||||
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
|
||||
|
||||
const selectedDraftProjectPath = React.useMemo(
|
||||
() => normalizePath(selectedDraftProject?.path ?? null),
|
||||
[selectedDraftProject?.path],
|
||||
);
|
||||
|
||||
const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath);
|
||||
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
||||
const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showDraftTargetSelectors || !selectedDraftProjectPath || !selectedDraftProject || !runtimeGit) {
|
||||
setIsDiscoveringDraftBranches(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedDraftProjectBranches?.all) {
|
||||
setIsDiscoveringDraftBranches(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsDiscoveringDraftBranches(true);
|
||||
|
||||
void fetchBranches(selectedDraftProjectPath, runtimeGit)
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsDiscoveringDraftBranches(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchBranches, runtimeGit, selectedDraftProject, selectedDraftProjectBranches?.all, selectedDraftProjectPath, showDraftTargetSelectors]);
|
||||
|
||||
const projectRootBranchOption = React.useMemo(() => {
|
||||
if (!selectedDraftProject) {
|
||||
return null;
|
||||
}
|
||||
const value = normalizePath(selectedDraftProject.path);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const projectRootBranch = selectedDraftProjectBranches?.current?.trim() ?? '';
|
||||
if (!projectRootBranch) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
value,
|
||||
label: projectRootBranch,
|
||||
};
|
||||
}, [selectedDraftProject, selectedDraftProjectBranches]);
|
||||
|
||||
const worktreeBranchOptions = React.useMemo(() => {
|
||||
if (!selectedDraftProject) {
|
||||
return [] as Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const options: Array<{ value: string; label: string }> = [];
|
||||
const rootValue = projectRootBranchOption?.value ?? null;
|
||||
|
||||
const worktrees = (() => {
|
||||
if (!selectedDraftProjectPath) {
|
||||
return [];
|
||||
}
|
||||
return availableWorktreesByProject.get(selectedDraftProjectPath)
|
||||
?? availableWorktreesByProject.get(selectedDraftProject.path)
|
||||
?? [];
|
||||
})();
|
||||
|
||||
worktrees
|
||||
.slice()
|
||||
.sort((a, b) => a.branch.localeCompare(b.branch))
|
||||
.forEach((worktree) => {
|
||||
const normalizedValue = normalizePath(worktree.path);
|
||||
if (!normalizedValue || normalizedValue === rootValue || seen.has(normalizedValue)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalizedValue);
|
||||
options.push({
|
||||
value: normalizedValue,
|
||||
label: worktree.branch?.trim() || formatDirectoryName(worktree.path),
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [availableWorktreesByProject, projectRootBranchOption?.value, selectedDraftProject, selectedDraftProjectPath]);
|
||||
|
||||
const selectedDraftDirectory = React.useMemo(
|
||||
() => normalizePath(newSessionDraft?.directoryOverride ?? null) ?? selectedDraftProjectPath,
|
||||
[newSessionDraft?.directoryOverride, selectedDraftProjectPath],
|
||||
);
|
||||
|
||||
const draftBranchItems = React.useMemo(() => {
|
||||
const baseItems: Array<{ value: string; label: string }> = [];
|
||||
if (projectRootBranchOption) {
|
||||
baseItems.push(projectRootBranchOption);
|
||||
}
|
||||
baseItems.push(...worktreeBranchOptions);
|
||||
|
||||
if (!selectedDraftDirectory) {
|
||||
return baseItems;
|
||||
}
|
||||
if (baseItems.some((option) => option.value === selectedDraftDirectory)) {
|
||||
return baseItems;
|
||||
}
|
||||
return [
|
||||
...baseItems,
|
||||
{ value: selectedDraftDirectory, label: formatDirectoryName(selectedDraftDirectory) },
|
||||
];
|
||||
}, [projectRootBranchOption, selectedDraftDirectory, worktreeBranchOptions]);
|
||||
|
||||
const selectedDraftBranchLabel = React.useMemo(() => {
|
||||
const selectedValue = selectedDraftDirectory ?? draftBranchItems[0]?.value ?? null;
|
||||
if (!selectedValue) {
|
||||
return null;
|
||||
}
|
||||
return draftBranchItems.find((item) => item.value === selectedValue)?.label ?? formatDirectoryName(selectedValue);
|
||||
}, [draftBranchItems, selectedDraftDirectory]);
|
||||
|
||||
const selectedDraftBranchIsKnown = React.useMemo(() => {
|
||||
if (!selectedDraftDirectory) {
|
||||
return true;
|
||||
}
|
||||
if (projectRootBranchOption?.value === selectedDraftDirectory) {
|
||||
return true;
|
||||
}
|
||||
return worktreeBranchOptions.some((option) => option.value === selectedDraftDirectory);
|
||||
}, [projectRootBranchOption?.value, selectedDraftDirectory, worktreeBranchOptions]);
|
||||
|
||||
const shouldShowDraftBranchSelector = React.useMemo(() => {
|
||||
if (isDiscoveringDraftBranches) {
|
||||
return false;
|
||||
}
|
||||
if (projectRootBranchOption) {
|
||||
return true;
|
||||
}
|
||||
return worktreeBranchOptions.length > 0;
|
||||
}, [isDiscoveringDraftBranches, projectRootBranchOption, worktreeBranchOptions.length]);
|
||||
|
||||
const handleDraftProjectChange = React.useCallback((projectId: string) => {
|
||||
const project = projects.find((entry) => entry.id === projectId);
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
if (activeProjectId !== projectId) {
|
||||
setActiveProjectIdOnly(projectId);
|
||||
}
|
||||
setNewSessionDraftTarget({
|
||||
projectId,
|
||||
directoryOverride: project.path,
|
||||
});
|
||||
}, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]);
|
||||
|
||||
const handleDraftDirectoryChange = React.useCallback((directory: string) => {
|
||||
if (!selectedDraftProject) {
|
||||
return;
|
||||
}
|
||||
setNewSessionDraftTarget({
|
||||
projectId: selectedDraftProject.id,
|
||||
directoryOverride: directory,
|
||||
});
|
||||
}, [selectedDraftProject, setNewSessionDraftTarget]);
|
||||
|
||||
const renderProjectLabelWithIcon = React.useCallback((project: {
|
||||
id: string;
|
||||
path: string;
|
||||
label?: string;
|
||||
icon?: string | null;
|
||||
color?: string | null;
|
||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
|
||||
iconBackground?: string | null;
|
||||
}) => {
|
||||
const imageUrl = getProjectIconImageUrl(
|
||||
{ id: project.id, iconImage: project.iconImage ?? null },
|
||||
{
|
||||
themeVariant: currentTheme.metadata.variant,
|
||||
iconColor: currentTheme.colors.surface.foreground,
|
||||
},
|
||||
);
|
||||
const ProjectIcon = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const iconColor = getProjectIconColor(project.color);
|
||||
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
{imageUrl ? (
|
||||
<span
|
||||
className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
|
||||
style={project.iconBackground ? { backgroundColor: project.iconBackground } : undefined}
|
||||
>
|
||||
<img src={imageUrl} alt="" className="h-full w-full object-contain" draggable={false} />
|
||||
</span>
|
||||
) : ProjectIcon ? (
|
||||
<ProjectIcon className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
|
||||
) : (
|
||||
<RiFolderLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
|
||||
)}
|
||||
<span className="truncate">{getProjectDisplayLabel(project)}</span>
|
||||
</span>
|
||||
);
|
||||
}, [currentTheme.colors.surface.foreground, currentTheme.metadata.variant]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) {
|
||||
return;
|
||||
}
|
||||
const valid = draftBranchItems.some((option) => option.value === selectedDraftDirectory);
|
||||
if (valid) {
|
||||
return;
|
||||
}
|
||||
setNewSessionDraftTarget({
|
||||
projectId: selectedDraftProject.id,
|
||||
directoryOverride: selectedDraftProject.path,
|
||||
});
|
||||
}, [draftBranchItems, selectedDraftDirectory, selectedDraftProject, setNewSessionDraftTarget, showDraftTargetSelectors]);
|
||||
|
||||
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5');
|
||||
const buttonSizeClass = isMobile ? 'h-8 w-8' : (isVSCode ? 'h-5 w-5' : 'h-6 w-6');
|
||||
const sendIconSizeClass = isMobile ? 'h-4 w-4' : (isVSCode ? 'h-3.5 w-3.5' : 'h-4 w-4');
|
||||
@@ -2172,6 +2510,73 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
|
||||
const iconButtonBaseClass = 'flex cursor-pointer items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0 disabled:cursor-not-allowed';
|
||||
const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass);
|
||||
const permissionScopeSessionId = currentSessionId ?? currentManagementSessionId;
|
||||
const permissionAutoAcceptEnabled = usePermissionStore((state) => {
|
||||
if (!permissionScopeSessionId) {
|
||||
return false;
|
||||
}
|
||||
return state.isSessionAutoAccepting(permissionScopeSessionId);
|
||||
});
|
||||
|
||||
const handlePermissionAutoAcceptToggle = React.useCallback(() => {
|
||||
if (!permissionScopeSessionId) {
|
||||
toast.error('Open a session first');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEnabled = !permissionAutoAcceptEnabled;
|
||||
setSessionAutoAccept(permissionScopeSessionId, nextEnabled).catch(() => {
|
||||
toast.error('Failed to toggle permission auto-accept');
|
||||
});
|
||||
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]);
|
||||
|
||||
const permissionAutoAcceptAriaLabel = permissionAutoAcceptEnabled
|
||||
? 'Disable permission auto-accept'
|
||||
: 'Enable permission auto-accept';
|
||||
const permissionAutoAcceptTooltipLabel = permissionAutoAcceptEnabled
|
||||
? 'Permission auto-accept: on'
|
||||
: 'Permission auto-accept: off';
|
||||
|
||||
const permissionAutoAcceptButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePermissionAutoAcceptToggle}
|
||||
className={cn(
|
||||
footerIconButtonClass,
|
||||
'rounded-md hover:bg-transparent',
|
||||
!permissionScopeSessionId && 'opacity-30',
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
aria-pressed={permissionAutoAcceptEnabled}
|
||||
aria-label={permissionAutoAcceptAriaLabel}
|
||||
title={permissionAutoAcceptAriaLabel}
|
||||
>
|
||||
{permissionAutoAcceptEnabled ? (
|
||||
<RiShieldCheckLine className={cn(iconSizeClass)} style={{ color: 'var(--status-info)' }} />
|
||||
) : (
|
||||
<RiShieldUserLine className={cn(iconSizeClass)} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const permissionAutoAcceptButtonWithTooltip = (
|
||||
<Tooltip delayDuration={600}>
|
||||
<TooltipTrigger asChild>
|
||||
{permissionAutoAcceptButton}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
{permissionAutoAcceptTooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
// Send button - respects queue mode setting
|
||||
const sendButton = (
|
||||
@@ -2536,6 +2941,74 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
showAssistantStatus={false}
|
||||
showTodos
|
||||
/>
|
||||
{showDraftTargetSelectors && selectedDraftProject ? (
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
|
||||
<Select
|
||||
value={selectedDraftProject.id}
|
||||
onValueChange={handleDraftProjectChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[state=open]:bg-transparent"
|
||||
>
|
||||
<SelectValue>
|
||||
{renderProjectLabelWithIcon(selectedDraftProject)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent fitContent>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
|
||||
{renderProjectLabelWithIcon(project)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{shouldShowDraftBranchSelector ? (
|
||||
<Select
|
||||
value={selectedDraftDirectory ?? draftBranchItems[0]?.value ?? normalizePath(selectedDraftProject.path) ?? ''}
|
||||
onValueChange={handleDraftDirectoryChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[state=open]:bg-transparent"
|
||||
>
|
||||
<SelectValue>
|
||||
{selectedDraftBranchLabel ?? 'Branch'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent fitContent>
|
||||
{projectRootBranchOption ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Project root</SelectLabel>
|
||||
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate">
|
||||
{projectRootBranchOption.label}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
) : null}
|
||||
{worktreeBranchOptions.length > 0 ? (
|
||||
<>
|
||||
{projectRootBranchOption ? <SelectSeparator /> : null}
|
||||
<SelectGroup>
|
||||
<SelectLabel>Worktrees</SelectLabel>
|
||||
{worktreeBranchOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
) : null}
|
||||
{selectedDraftDirectory && !selectedDraftBranchIsKnown ? (
|
||||
<SelectItem value={selectedDraftDirectory} className="max-w-[24rem] truncate">
|
||||
{selectedDraftBranchLabel}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col relative overflow-visible",
|
||||
@@ -2548,7 +3021,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
isDragging && "ring-2 ring-primary ring-offset-2"
|
||||
)}
|
||||
style={{
|
||||
borderRadius: cornerRadius,
|
||||
borderRadius: chatInputRadius,
|
||||
backgroundColor: currentTheme?.colors?.surface?.subtle,
|
||||
}}
|
||||
ref={dropZoneRef}
|
||||
@@ -2714,8 +3187,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
flex: isDesktopExpanded ? '1 1 auto' : 'none',
|
||||
height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
|
||||
maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
||||
borderTopLeftRadius: cornerRadius,
|
||||
borderTopRightRadius: cornerRadius,
|
||||
borderTopLeftRadius: chatInputRadius,
|
||||
borderTopRightRadius: chatInputRadius,
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
@@ -2727,16 +3200,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
isMobile ? 'flex items-center gap-x-1.5' : cn('flex items-center justify-between', footerGapClass)
|
||||
)}
|
||||
style={{
|
||||
borderBottomLeftRadius: cornerRadius,
|
||||
borderBottomRightRadius: cornerRadius,
|
||||
borderBottomLeftRadius: chatInputRadius,
|
||||
borderBottomRightRadius: chatInputRadius,
|
||||
}}
|
||||
data-chat-input-footer="true"
|
||||
>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<div className="flex w-full items-center justify-between gap-x-1.5">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
{attachmentsControls}
|
||||
{permissionAutoAcceptButton}
|
||||
</div>
|
||||
<div className="flex items-center min-w-0 gap-x-1 justify-end">
|
||||
<div className="flex items-center gap-x-1 min-w-0 max-w-[60vw] flex-shrink">
|
||||
@@ -2801,6 +3275,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{permissionAutoAcceptButtonWithTooltip}
|
||||
</div>
|
||||
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
||||
<ModelControls className={cn('flex-1 min-w-0 justify-end')} />
|
||||
|
||||
@@ -971,6 +971,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const assistantTopPaddingClass = !isUser && shouldShowHeader
|
||||
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
|
||||
: 'pt-0';
|
||||
const userMessageRadius = 'var(--radius-lg)';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -995,7 +996,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
>
|
||||
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
|
||||
<div className="max-w-[85%]">
|
||||
<div style={{ backgroundColor: 'var(--chat-user-message-bg)' }} className="rounded-[var(--radius-xl)] rounded-br-[var(--radius-sm)] px-5 py-3 shadow-none border border-primary/5">
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--chat-user-message-bg)',
|
||||
borderRadius: userMessageRadius,
|
||||
borderBottomRightRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
className="px-5 py-3 shadow-none border border-primary/5"
|
||||
>
|
||||
<MessageBody
|
||||
messageId={message.info.id}
|
||||
parts={displayParts}
|
||||
|
||||
@@ -971,12 +971,14 @@ const useFileReferenceInteractions = ({
|
||||
readFile,
|
||||
editor,
|
||||
preferRuntimeEditor,
|
||||
deferValidationUntilIdle = false,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
effectiveDirectory: string;
|
||||
readFile?: (path: string) => Promise<{ content: string; path: string }>;
|
||||
editor?: EditorAPI;
|
||||
preferRuntimeEditor?: boolean;
|
||||
deferValidationUntilIdle?: boolean;
|
||||
}) => {
|
||||
const validationCacheRef = React.useRef<Map<string, boolean>>(new Map());
|
||||
const inFlightValidationsRef = React.useRef<Map<string, Promise<boolean>>>(new Map());
|
||||
@@ -1048,6 +1050,9 @@ const useFileReferenceInteractions = ({
|
||||
};
|
||||
|
||||
const runValidationSweep = async (paths: string[], expectedPassID: number) => {
|
||||
if (deferValidationUntilIdle) {
|
||||
return;
|
||||
}
|
||||
if (isValidationSweepRunningRef.current || paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1208,9 +1213,13 @@ const useFileReferenceInteractions = ({
|
||||
annotationDebounceRef.current = window.setTimeout(() => {
|
||||
annotationDebounceRef.current = null;
|
||||
void annotateFileLinks();
|
||||
}, 120);
|
||||
}, deferValidationUntilIdle ? 280 : 120);
|
||||
});
|
||||
observer.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
...(deferValidationUntilIdle ? {} : { characterData: true }),
|
||||
});
|
||||
observer.observe(container, { childList: true, subtree: true, characterData: true });
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
@@ -1226,7 +1235,7 @@ const useFileReferenceInteractions = ({
|
||||
container.removeEventListener('click', handleClick);
|
||||
container.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, readFile]);
|
||||
}, [containerRef, deferValidationUntilIdle, editor, effectiveDirectory, preferRuntimeEditor, readFile]);
|
||||
};
|
||||
|
||||
const useMermaidInlineInteractions = ({
|
||||
@@ -1345,6 +1354,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
readFile: files.readFile,
|
||||
editor,
|
||||
preferRuntimeEditor: runtime.isVSCode,
|
||||
deferValidationUntilIdle: isStreaming,
|
||||
});
|
||||
|
||||
const shikiThemes = useMarkdownShikiThemes();
|
||||
@@ -1358,10 +1368,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
? 'streamdown-content streamdown-reasoning'
|
||||
: 'streamdown-content';
|
||||
|
||||
const streamdownAnimated = React.useMemo(
|
||||
() => ({ animation: 'blurIn' as const, duration: 150, easing: 'ease-out' }),
|
||||
[],
|
||||
);
|
||||
const streamdownAnimated = undefined;
|
||||
|
||||
const markdownContent = (
|
||||
<div className={cn('break-words w-full min-w-0', className)} ref={streamdownContainerRef}>
|
||||
|
||||
@@ -318,6 +318,33 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD
|
||||
};
|
||||
};
|
||||
|
||||
const normalizedMessageBySource = new WeakMap<ChatMessageEntry, ChatMessageEntry>();
|
||||
|
||||
const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => {
|
||||
const cached = normalizedMessageBySource.get(message);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const filteredParts = filterSyntheticParts(message.parts);
|
||||
const normalized = filteredParts === message.parts
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
parts: filteredParts,
|
||||
};
|
||||
|
||||
normalizedMessageBySource.set(message, normalized);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const isAssistantTextOnlyMessage = (message: ChatMessageEntry): boolean => {
|
||||
if (resolveMessageRole(message) !== 'assistant') {
|
||||
return false;
|
||||
}
|
||||
return message.parts.length > 0 && message.parts.every((part) => part?.type === 'text');
|
||||
};
|
||||
|
||||
interface MessageListProps {
|
||||
sessionKey: string;
|
||||
turnStart: number;
|
||||
@@ -813,6 +840,11 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
previousOrder: string[];
|
||||
animatedIds: Set<string>;
|
||||
}>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() });
|
||||
const baseDisplayCacheRef = React.useRef<{
|
||||
input: ChatMessageEntry[];
|
||||
output: ChatMessageEntry[];
|
||||
outputIndexById: Map<string, number>;
|
||||
} | null>(null);
|
||||
|
||||
const stableOnMessageContentChange = useStableEvent(onMessageContentChange);
|
||||
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
|
||||
@@ -843,8 +875,51 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
|
||||
|
||||
const baseDisplayMessages = React.useMemo(() => {
|
||||
const seenIdsFromTail = new Set<string>();
|
||||
const cached = baseDisplayCacheRef.current;
|
||||
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
const canUseTailFastPath = Boolean(lastMessage && isAssistantTextOnlyMessage(lastMessage));
|
||||
|
||||
if (cached && canUseTailFastPath && cached.input.length === messages.length && messages.length > 0) {
|
||||
let changedCount = 0;
|
||||
let changedIndex = -1;
|
||||
let idsStable = true;
|
||||
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
if (messages[index]?.info?.id !== cached.input[index]?.info?.id) {
|
||||
idsStable = false;
|
||||
break;
|
||||
}
|
||||
if (messages[index] !== cached.input[index]) {
|
||||
changedCount += 1;
|
||||
changedIndex = index;
|
||||
if (changedCount > 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (idsStable && changedCount === 1 && changedIndex === messages.length - 1) {
|
||||
const changedMessage = messages[changedIndex];
|
||||
const previousMessage = changedIndex > 0 ? messages[changedIndex - 1] : undefined;
|
||||
const bridgeSensitive = isUserSubtaskMessage(previousMessage) || isUserShellMarkerMessage(previousMessage);
|
||||
|
||||
if (changedMessage && isAssistantTextOnlyMessage(changedMessage) && !bridgeSensitive) {
|
||||
const outputIndex = cached.outputIndexById.get(changedMessage.info.id);
|
||||
if (outputIndex !== undefined) {
|
||||
const nextOutput = [...cached.output];
|
||||
nextOutput[outputIndex] = getNormalizedMessageForDisplay(changedMessage);
|
||||
baseDisplayCacheRef.current = {
|
||||
input: messages,
|
||||
output: nextOutput,
|
||||
outputIndexById: cached.outputIndexById,
|
||||
};
|
||||
return nextOutput;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seenIdsFromTail = new Set<string>();
|
||||
const dedupedMessages: ChatMessageEntry[] = [];
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
@@ -855,26 +930,13 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
}
|
||||
seenIdsFromTail.add(messageId);
|
||||
}
|
||||
dedupedMessages.push(message);
|
||||
dedupedMessages.push(getNormalizedMessageForDisplay(message));
|
||||
}
|
||||
dedupedMessages.reverse();
|
||||
|
||||
const normalizedMessages = dedupedMessages
|
||||
.map((message) => {
|
||||
const filteredParts = filterSyntheticParts(message.parts);
|
||||
const normalized = filteredParts === message.parts
|
||||
? message
|
||||
: {
|
||||
...message,
|
||||
parts: filteredParts,
|
||||
};
|
||||
return normalized;
|
||||
});
|
||||
|
||||
const output: ChatMessageEntry[] = [];
|
||||
|
||||
for (let index = 0; index < normalizedMessages.length; index += 1) {
|
||||
const current = normalizedMessages[index];
|
||||
for (let index = 0; index < dedupedMessages.length; index += 1) {
|
||||
const current = dedupedMessages[index];
|
||||
const previous = output.length > 0 ? output[output.length - 1] : undefined;
|
||||
|
||||
if (isUserSubtaskMessage(previous)) {
|
||||
@@ -896,6 +958,19 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
output.push(current);
|
||||
}
|
||||
|
||||
const outputIndexById = new Map<string, number>();
|
||||
output.forEach((message, index) => {
|
||||
const id = message.info?.id;
|
||||
if (typeof id === 'string' && id.length > 0) {
|
||||
outputIndexById.set(id, index);
|
||||
}
|
||||
});
|
||||
baseDisplayCacheRef.current = {
|
||||
input: messages,
|
||||
output,
|
||||
outputIndexById,
|
||||
};
|
||||
|
||||
return output;
|
||||
}, [messages]);
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ import { Input } from '@/components/ui/input';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { TextLoop } from '@/components/ui/TextLoop';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
||||
@@ -350,14 +349,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
? (sessionSavedAgentName || stickySessionAgentName || currentAgentName)
|
||||
: currentAgentName;
|
||||
|
||||
const sessionIdForEditMode = currentSessionId ?? '__global__';
|
||||
const sessionEditMode = useContextStore((state) => {
|
||||
if (!uiAgentName) {
|
||||
return undefined;
|
||||
}
|
||||
return state.getSessionAgentEditMode(sessionIdForEditMode, uiAgentName, 'ask');
|
||||
});
|
||||
const setSessionAgentEditMode = useContextStore((state) => state.setSessionAgentEditMode);
|
||||
const {
|
||||
toggleFavoriteModel,
|
||||
isFavoriteModel,
|
||||
@@ -506,21 +497,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return getCurrentAgent?.();
|
||||
}, [agents, getCurrentAgent, uiAgentName]);
|
||||
|
||||
const agentEditAction = React.useMemo<PermissionAction>(() => {
|
||||
if (!currentAgent) {
|
||||
return 'deny';
|
||||
}
|
||||
return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow';
|
||||
}, [currentAgent]);
|
||||
|
||||
const selectionContextReady = Boolean(uiAgentName);
|
||||
|
||||
const approveEditsAvailable = agentEditAction === 'ask';
|
||||
const approveEditsChecked = approveEditsAvailable
|
||||
? sessionEditMode === 'allow' || sessionEditMode === 'full'
|
||||
: agentEditAction === 'allow';
|
||||
const approveEditsDisabled = !selectionContextReady || !approveEditsAvailable;
|
||||
|
||||
const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default';
|
||||
const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8';
|
||||
const editToggleIconClass = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4';
|
||||
@@ -545,13 +521,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
return <RiQuestionLine className={combinedClassName} style={iconStyle} />;
|
||||
}, [editToggleIconClass]);
|
||||
|
||||
const handleApproveEditsToggle = React.useCallback((checked: boolean) => {
|
||||
if (!selectionContextReady || !currentAgentName || !approveEditsAvailable) {
|
||||
return;
|
||||
}
|
||||
setSessionAgentEditMode(sessionIdForEditMode, currentAgentName, checked ? 'allow' : 'ask', 'ask');
|
||||
}, [approveEditsAvailable, currentAgentName, selectionContextReady, setSessionAgentEditMode, sessionIdForEditMode]);
|
||||
|
||||
const currentProvider = getCurrentProvider();
|
||||
const models = Array.isArray(currentProvider?.models) ? currentProvider.models : [];
|
||||
|
||||
@@ -1819,23 +1788,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
onClose={closeMobilePanel}
|
||||
title="Select agent"
|
||||
contentMaxHeightClassName="max-h-[min(52dvh,360px)]"
|
||||
footer={(
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
'typography-meta font-medium',
|
||||
approveEditsDisabled ? 'text-muted-foreground' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
Auto-approve edits
|
||||
</span>
|
||||
<Switch
|
||||
checked={approveEditsChecked}
|
||||
disabled={approveEditsDisabled}
|
||||
onCheckedChange={handleApproveEditsToggle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{selectableDesktopAgents.map((agent) => {
|
||||
@@ -2302,10 +2254,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
|
||||
{/* Favorites Section */}
|
||||
{filteredFavorites.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<DropdownMenuLabel
|
||||
style={{ backgroundColor: 'var(--surface-elevated)' }}
|
||||
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30"
|
||||
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30"
|
||||
>
|
||||
<RiStarFill className="h-4 w-4 text-primary" />
|
||||
Favorites
|
||||
@@ -2314,16 +2265,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const idx = currentFlatIndex++;
|
||||
return renderModelRow(model, providerID, modelID, 'fav', idx, modelSelectedIndex === idx);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recents Section */}
|
||||
{filteredRecents.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
{filteredFavorites.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel
|
||||
style={{ backgroundColor: 'var(--surface-elevated)' }}
|
||||
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30"
|
||||
className="typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30"
|
||||
>
|
||||
<RiTimeLine className="h-4 w-4" />
|
||||
Recent
|
||||
@@ -2332,7 +2282,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const idx = currentFlatIndex++;
|
||||
return renderModelRow(model, providerID, modelID, 'recent', idx, modelSelectedIndex === idx);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator before providers */}
|
||||
@@ -2342,7 +2292,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
|
||||
{/* All Providers - Flat List */}
|
||||
{providerSections.map(({ provider, isExpanded, visibleModels }, index) => (
|
||||
<React.Fragment key={provider.id}>
|
||||
<div key={provider.id}>
|
||||
{index > 0 && <DropdownMenuSeparator />}
|
||||
<div
|
||||
role="button"
|
||||
@@ -2366,8 +2316,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex w-full items-center gap-2 -mx-1 px-3 py-1.5 sticky top-0 z-10 border-b border-border/30',
|
||||
'bg-[var(--surface-elevated)] text-left transition-colors',
|
||||
'typography-micro font-semibold text-muted-foreground uppercase tracking-wider flex w-full items-center gap-2 -mx-1 px-3 py-1.5 border-b border-border/30',
|
||||
'text-left transition-colors',
|
||||
forceExpandProviders ? 'cursor-default' : 'cursor-pointer'
|
||||
)}
|
||||
aria-expanded={isExpanded}
|
||||
@@ -2392,7 +2342,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
const idx = currentFlatIndex++;
|
||||
return renderModelRow(model, provider.id as string, model.id as string, 'provider', idx, modelSelectedIndex === idx);
|
||||
})}
|
||||
</React.Fragment>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
@@ -2755,24 +2705,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex flex-col gap-1 px-1 py-0.5">
|
||||
<div className="rounded-xl bg-transparent">
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<span className={cn(
|
||||
'typography-meta font-medium',
|
||||
approveEditsDisabled ? 'text-muted-foreground' : 'text-foreground'
|
||||
)}>
|
||||
Auto-approve edits
|
||||
</span>
|
||||
<Switch
|
||||
checked={approveEditsChecked}
|
||||
disabled={approveEditsDisabled}
|
||||
onCheckedChange={handleApproveEditsToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{renderAgentTooltipContent()}
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface UseChatTimelineControllerResult {
|
||||
loadEarlier: () => Promise<void>;
|
||||
revealBufferedTurns: () => Promise<boolean>;
|
||||
resumeToBottom: () => void;
|
||||
resumeToBottomInstant: () => void;
|
||||
scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
||||
scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
|
||||
captureViewportAnchor: () => ViewportAnchor | null;
|
||||
@@ -394,6 +395,14 @@ export const useChatTimelineController = ({
|
||||
scrollToBottom({ force: true });
|
||||
}, [scrollToBottom]);
|
||||
|
||||
const resumeToBottomInstant = React.useCallback(() => {
|
||||
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
|
||||
setTurnStart(nextStart);
|
||||
setPendingRevealWork(false);
|
||||
setIsLoadingOlder(false);
|
||||
scrollToBottom({ instant: true, force: true });
|
||||
}, [scrollToBottom]);
|
||||
|
||||
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
||||
setActiveTurnId(turnId);
|
||||
}, []);
|
||||
@@ -411,6 +420,7 @@ export const useChatTimelineController = ({
|
||||
loadEarlier,
|
||||
revealBufferedTurns,
|
||||
resumeToBottom,
|
||||
resumeToBottomInstant,
|
||||
scrollToTurn,
|
||||
scrollToMessage,
|
||||
captureViewportAnchor,
|
||||
|
||||
@@ -18,11 +18,30 @@ export const collectVisibleSessionIdsForBlockingRequests = (
|
||||
return [];
|
||||
}
|
||||
|
||||
const childIds = sessions
|
||||
.filter((session) => session.parentID === currentSessionId)
|
||||
.map((session) => session.id);
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const session of sessions) {
|
||||
if (!session.parentID) {
|
||||
continue;
|
||||
}
|
||||
const existing = childrenByParent.get(session.parentID) ?? [];
|
||||
existing.push(session.id);
|
||||
childrenByParent.set(session.parentID, existing);
|
||||
}
|
||||
|
||||
return [currentSessionId, ...childIds];
|
||||
const scoped = [currentSessionId];
|
||||
const seen = new Set(scoped);
|
||||
for (const sessionId of scoped) {
|
||||
const children = childrenByParent.get(sessionId) ?? [];
|
||||
for (const childId of children) {
|
||||
if (seen.has(childId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(childId);
|
||||
scoped.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
return scoped;
|
||||
};
|
||||
|
||||
export const flattenBlockingRequests = <T extends { id: string }>(
|
||||
|
||||
@@ -80,6 +80,7 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
||||
let ro: ResizeObserver | undefined;
|
||||
let mo: MutationObserver | undefined;
|
||||
let frame: number | undefined;
|
||||
let roDebounce: ReturnType<typeof setTimeout> | undefined;
|
||||
let active: string | undefined;
|
||||
let dirty = true;
|
||||
|
||||
@@ -198,12 +199,17 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(roDebounce);
|
||||
roDebounce = undefined;
|
||||
ro?.disconnect();
|
||||
ro = undefined;
|
||||
if (CtorRO) {
|
||||
ro = new CtorRO(() => {
|
||||
dirty = true;
|
||||
schedule();
|
||||
clearTimeout(roDebounce);
|
||||
roDebounce = setTimeout(() => {
|
||||
dirty = true;
|
||||
schedule();
|
||||
}, 100);
|
||||
});
|
||||
ro.observe(container);
|
||||
for (const element of nodes.values()) {
|
||||
@@ -218,7 +224,15 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
||||
dirty = true;
|
||||
schedule();
|
||||
});
|
||||
mo.observe(container, { subtree: true, childList: true, characterData: true });
|
||||
const moConfig: MutationObserverInit = {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
};
|
||||
if (!CtorRO) {
|
||||
moConfig.characterData = true;
|
||||
moConfig.characterDataOldValue = false;
|
||||
}
|
||||
mo.observe(container, moConfig);
|
||||
}
|
||||
|
||||
dirty = true;
|
||||
@@ -292,6 +306,8 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
||||
caf(frame);
|
||||
}
|
||||
frame = undefined;
|
||||
clearTimeout(roDebounce);
|
||||
roDebounce = undefined;
|
||||
clear();
|
||||
io?.disconnect();
|
||||
ro?.disconnect();
|
||||
|
||||
@@ -81,69 +81,8 @@ interface ProjectActivityResult {
|
||||
|
||||
export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivityResult => {
|
||||
const activityParts: TurnActivityRecord[] = [];
|
||||
const hasTools = input.assistantMessages.some((message) =>
|
||||
message.parts.some((part) => part.type === 'tool')
|
||||
);
|
||||
const hasReasoning = input.assistantMessages.some((message) =>
|
||||
message.parts.some((part) => part.type === 'reasoning' && Boolean(getPartText(part)))
|
||||
);
|
||||
|
||||
input.assistantMessages.forEach((message) => {
|
||||
message.parts.forEach((part, partIndex) => {
|
||||
if (part.type === 'tool') {
|
||||
activityParts.push({
|
||||
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
|
||||
kind: 'tool',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === 'reasoning') {
|
||||
const text = getPartText(part);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
activityParts.push({
|
||||
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
|
||||
kind: 'reasoning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!input.showTextJustificationActivity || part.type !== 'text') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAssistantMessageCompleted(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finish = getMessageFinish(message);
|
||||
if (!finish) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (finish === 'stop') {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = getPartText(part);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityParts.push({
|
||||
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
|
||||
kind: 'justification',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const activitySegments: TurnActivityGroup[] = [];
|
||||
const activityByPart = new WeakMap<object, TurnActivityRecord>();
|
||||
activityParts.forEach((activity) => {
|
||||
activityByPart.set(activity.part as object, activity);
|
||||
});
|
||||
let hasTools = false;
|
||||
let hasReasoning = false;
|
||||
|
||||
const taskMessageById = new Map<string, string>();
|
||||
const taskOrder: string[] = [];
|
||||
@@ -151,32 +90,66 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
|
||||
let currentAfterToolPartId: string | null = null;
|
||||
|
||||
input.assistantMessages.forEach((message) => {
|
||||
const messageId = message.info.id;
|
||||
const messageCompleted = isAssistantMessageCompleted(message);
|
||||
const finish = getMessageFinish(message);
|
||||
|
||||
message.parts.forEach((part, partIndex) => {
|
||||
if (part.type === 'tool') {
|
||||
const toolName = (part as { tool?: unknown }).tool;
|
||||
if (isStandaloneTool(toolName)) {
|
||||
const toolPartId = part.id ?? `${messageId}-part-${partIndex}-${part.type}`;
|
||||
if (!taskMessageById.has(toolPartId)) {
|
||||
taskMessageById.set(toolPartId, messageId);
|
||||
taskOrder.push(toolPartId);
|
||||
}
|
||||
currentAfterToolPartId = toolPartId;
|
||||
return;
|
||||
}
|
||||
const isTool = part.type === 'tool';
|
||||
if (isTool) {
|
||||
hasTools = true;
|
||||
}
|
||||
|
||||
const activity = activityByPart.get(part as object);
|
||||
if (!activity) {
|
||||
const text = part.type === 'reasoning' || part.type === 'text'
|
||||
? getPartText(part)
|
||||
: undefined;
|
||||
|
||||
if (part.type === 'reasoning' && text) {
|
||||
hasReasoning = true;
|
||||
}
|
||||
|
||||
const toolName = isTool
|
||||
? (part as { tool?: unknown }).tool
|
||||
: undefined;
|
||||
const standaloneTool = isTool && isStandaloneTool(toolName);
|
||||
if (standaloneTool) {
|
||||
const toolPartId = part.id ?? `${message.info.id}-part-${partIndex}-${part.type}`;
|
||||
if (!taskMessageById.has(toolPartId)) {
|
||||
taskMessageById.set(toolPartId, message.info.id);
|
||||
taskOrder.push(toolPartId);
|
||||
}
|
||||
currentAfterToolPartId = toolPartId;
|
||||
}
|
||||
|
||||
let kind: TurnActivityRecord['kind'] | null = null;
|
||||
if (isTool) {
|
||||
kind = 'tool';
|
||||
} else if (part.type === 'reasoning') {
|
||||
if (text) {
|
||||
kind = 'reasoning';
|
||||
}
|
||||
} else if (
|
||||
input.showTextJustificationActivity
|
||||
&& part.type === 'text'
|
||||
&& messageCompleted
|
||||
&& typeof finish === 'string'
|
||||
&& finish !== 'stop'
|
||||
&& text
|
||||
) {
|
||||
kind = 'justification';
|
||||
}
|
||||
|
||||
if (!kind) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activity.kind === 'tool') {
|
||||
const toolName = (activity.part as { tool?: unknown }).tool;
|
||||
if (isStandaloneTool(toolName)) {
|
||||
return;
|
||||
}
|
||||
const activity: TurnActivityRecord = {
|
||||
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
|
||||
kind,
|
||||
};
|
||||
activityParts.push(activity);
|
||||
|
||||
if (kind === 'tool' && standaloneTool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = partsByAfterTool.get(currentAfterToolPartId) ?? [];
|
||||
@@ -185,6 +158,8 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
|
||||
});
|
||||
});
|
||||
|
||||
const activitySegments: TurnActivityGroup[] = [];
|
||||
|
||||
const pickStartAnchor = (segmentParts: TurnActivityRecord[]): string | undefined => {
|
||||
if (segmentParts.length === 0) {
|
||||
return undefined;
|
||||
|
||||
@@ -24,6 +24,12 @@ interface SelectionPayload {
|
||||
|
||||
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
|
||||
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
|
||||
const BLOCK_TAGS = new Set([
|
||||
'address', 'article', 'aside', 'blockquote', 'dd', 'div', 'dl', 'dt',
|
||||
'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3',
|
||||
'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre',
|
||||
'section', 'table', 'ul',
|
||||
]);
|
||||
|
||||
const normalizeLineBreaks = (value: string): string => value.replace(/\r\n?/g, '\n');
|
||||
|
||||
@@ -103,6 +109,11 @@ const renderBlockMarkdownNode = (node: Node): string => {
|
||||
return `\`\`\`${language}\n${code}\n\`\`\``;
|
||||
}
|
||||
|
||||
if (tag === 'code') {
|
||||
const code = normalizeLineBreaks(element.textContent || '').trim();
|
||||
return code ? `\`${code.replace(/`/g, '\\`')}\`` : '';
|
||||
}
|
||||
|
||||
if (tag === 'ul') return renderListMarkdown(element, false);
|
||||
if (tag === 'ol') return renderListMarkdown(element, true);
|
||||
|
||||
@@ -137,8 +148,34 @@ const renderBlockMarkdownNode = (node: Node): string => {
|
||||
return trimSelectionValue(Array.from(element.childNodes).map((child) => renderInlineMarkdownNode(child)).join(''));
|
||||
};
|
||||
|
||||
const isInlineSelectionFragment = (fragment: DocumentFragment): boolean => {
|
||||
return Array.from(fragment.childNodes).every((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return true;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const element = node as HTMLElement;
|
||||
return !BLOCK_TAGS.has(element.tagName.toLowerCase());
|
||||
});
|
||||
};
|
||||
|
||||
const rangeToMarkdown = (range: Range, plainText: string): string => {
|
||||
const fragment = range.cloneContents();
|
||||
|
||||
if (isInlineSelectionFragment(fragment)) {
|
||||
const inlineMarkdown = trimSelectionValue(
|
||||
Array.from(fragment.childNodes)
|
||||
.map((node) => renderInlineMarkdownNode(node))
|
||||
.join('')
|
||||
);
|
||||
if (inlineMarkdown) {
|
||||
return inlineMarkdown;
|
||||
}
|
||||
}
|
||||
|
||||
const markdown = Array.from(fragment.childNodes)
|
||||
.map((node) => renderBlockMarkdownNode(node))
|
||||
.filter((value) => value.length > 0)
|
||||
@@ -436,6 +473,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
)}
|
||||
style={{
|
||||
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
|
||||
backdropFilter: 'blur(28px)',
|
||||
WebkitBackdropFilter: 'blur(28px)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
@@ -507,6 +546,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
style={{
|
||||
backdropFilter: 'blur(28px)',
|
||||
WebkitBackdropFilter: 'blur(28px)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleAddToChat}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from '@/lib/utils';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDurationTickerNow } from './useDurationTicker';
|
||||
|
||||
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
||||
|
||||
@@ -65,19 +66,7 @@ const formatDuration = (start: number, end?: number, now: number = Date.now()):
|
||||
};
|
||||
|
||||
const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 100);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active]);
|
||||
const now = useDurationTickerNow(active, 250);
|
||||
|
||||
return <>{formatDuration(start, end, now)}</>;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { Text } from '@/components/ui/text';
|
||||
@@ -32,6 +33,7 @@ import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
|
||||
import { MinDurationShineText } from './MinDurationShineText';
|
||||
import { ToolRevealOnMount } from './ToolRevealOnMount';
|
||||
import { getToolIcon } from './toolPresentation';
|
||||
import { useDurationTickerNow } from './useDurationTicker';
|
||||
|
||||
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
|
||||
|
||||
@@ -144,6 +146,14 @@ const normalizeToolName = (toolName: string | undefined | null): string => {
|
||||
};
|
||||
|
||||
const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
|
||||
const TASK_TOOL_POLL_FAST_MS = 1200;
|
||||
const TASK_TOOL_POLL_IDLE_MS = 3200;
|
||||
const TASK_TOOL_POLL_HIDDEN_MS = 6000;
|
||||
const TASK_TOOL_INITIAL_FETCH_LIMIT = 500;
|
||||
const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160;
|
||||
const TASK_TOOL_IDLE_FETCH_LIMIT = 80;
|
||||
const TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS = 3;
|
||||
const TASK_TOOL_SETTLE_GRACE_MS = 2500;
|
||||
|
||||
const formatDuration = (start: number, end?: number, now: number = Date.now()) => {
|
||||
const duration = Math.min(Math.max(0, (end ?? now) - start), MAX_DURATION_MS);
|
||||
@@ -154,17 +164,7 @@ const formatDuration = (start: number, end?: number, now: number = Date.now()) =
|
||||
};
|
||||
|
||||
const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active]);
|
||||
const now = useDurationTickerNow(active, 250);
|
||||
|
||||
return <>{formatDuration(start, end, now)}</>;
|
||||
};
|
||||
@@ -642,6 +642,41 @@ const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[])
|
||||
return entries;
|
||||
};
|
||||
|
||||
const buildTaskSessionMessagesSignature = (messages: SessionMessageWithParts[]): string => {
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : '';
|
||||
const lastMessageUpdated =
|
||||
typeof lastMessage?.info?.time?.completed === 'number'
|
||||
? lastMessage.info.time.completed
|
||||
: typeof lastMessage?.info?.time?.created === 'number'
|
||||
? lastMessage.info.time.created
|
||||
: 0;
|
||||
const lastParts = Array.isArray(lastMessage?.parts) ? lastMessage.parts : [];
|
||||
const lastPart = lastParts[lastParts.length - 1] as Record<string, unknown> | undefined;
|
||||
const tailType = typeof lastPart?.type === 'string' ? lastPart.type : '';
|
||||
const tailId = typeof lastPart?.id === 'string' ? lastPart.id : '';
|
||||
const tailTextLength = (() => {
|
||||
const textCandidate = lastPart?.text;
|
||||
if (typeof textCandidate === 'string') {
|
||||
return textCandidate.length;
|
||||
}
|
||||
const stateCandidate = lastPart?.state;
|
||||
if (stateCandidate && typeof stateCandidate === 'object') {
|
||||
const stateStatus = (stateCandidate as Record<string, unknown>).status;
|
||||
if (typeof stateStatus === 'string') {
|
||||
return stateStatus.length;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
|
||||
return `${messages.length}:${lastMessageId}:${lastMessageUpdated}:${lastParts.length}:${tailType}:${tailId}:${tailTextLength}`;
|
||||
};
|
||||
|
||||
const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
|
||||
const title = entry.state?.title;
|
||||
if (typeof title === 'string' && title.trim().length > 0) {
|
||||
@@ -687,7 +722,16 @@ const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return trimmed.includes('/') || trimmed.includes('\\');
|
||||
if (trimmed.includes('/') || trimmed.includes('\\')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const baseName = trimmed.split(/[\\/]/).pop() || trimmed;
|
||||
if (baseName.startsWith('.') || baseName.includes('.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^[A-Za-z0-9_-]+$/.test(baseName);
|
||||
};
|
||||
|
||||
const stripTaskMetadataFromOutput = (output: string): string => {
|
||||
@@ -1692,6 +1736,69 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
return false;
|
||||
}, [childSessionMessages, isTaskTool, taskSessionId]);
|
||||
|
||||
const childSessionActivity = useSessionActivity(taskSessionId);
|
||||
const [taskChildSeenActive, setTaskChildSeenActive] = React.useState(false);
|
||||
const [taskChildPollingStopped, setTaskChildPollingStopped] = React.useState(false);
|
||||
|
||||
const taskPollNoChangeCountRef = React.useRef(0);
|
||||
const taskPollLastSignatureRef = React.useRef<string>('');
|
||||
|
||||
React.useEffect(() => {
|
||||
setTaskChildSeenActive(false);
|
||||
setTaskChildPollingStopped(false);
|
||||
taskPollNoChangeCountRef.current = 0;
|
||||
taskPollLastSignatureRef.current = '';
|
||||
}, [taskSessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTaskTool || !taskSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const childSessionIsActive =
|
||||
childSessionActivity.phase === 'busy'
|
||||
|| childSessionActivity.phase === 'retry'
|
||||
|| childSessionHasInFlightTools
|
||||
|| (!isFinalized && activeLatched);
|
||||
|
||||
if (childSessionIsActive) {
|
||||
if (!taskChildSeenActive) {
|
||||
setTaskChildSeenActive(true);
|
||||
}
|
||||
if (taskChildPollingStopped) {
|
||||
setTaskChildPollingStopped(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!taskChildSeenActive || taskChildPollingStopped || childSessionTaskSummaryEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
setTaskChildPollingStopped(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setTaskChildPollingStopped(true);
|
||||
}, TASK_TOOL_SETTLE_GRACE_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [
|
||||
childSessionActivity.phase,
|
||||
childSessionHasInFlightTools,
|
||||
childSessionTaskSummaryEntries.length,
|
||||
activeLatched,
|
||||
isFinalized,
|
||||
isTaskTool,
|
||||
taskChildPollingStopped,
|
||||
taskChildSeenActive,
|
||||
taskSessionId,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') {
|
||||
setLocalFinalizedAt(undefined);
|
||||
@@ -1730,7 +1837,10 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldPoll = isActive || childSessionHasInFlightTools || childSessionTaskSummaryEntries.length === 0;
|
||||
const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry';
|
||||
const shouldPoll =
|
||||
!taskChildPollingStopped
|
||||
&& (isActive || childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
|
||||
const shouldFetchSnapshot = childSessionTaskSummaryEntries.length === 0 || shouldPoll;
|
||||
if (!shouldFetchSnapshot) {
|
||||
return;
|
||||
@@ -1739,33 +1849,83 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
let cancelled = false;
|
||||
let pollTimer: number | undefined;
|
||||
|
||||
const fetchSessionMessages = async () => {
|
||||
const isVisible = () => {
|
||||
if (typeof document === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
return document.visibilityState === 'visible';
|
||||
};
|
||||
|
||||
const resolveFetchLimit = (isInitialFetch: boolean) => {
|
||||
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
|
||||
return TASK_TOOL_INITIAL_FETCH_LIMIT;
|
||||
}
|
||||
if (isActive || childSessionHasInFlightTools || childSessionActive) {
|
||||
return TASK_TOOL_ACTIVE_FETCH_LIMIT;
|
||||
}
|
||||
return TASK_TOOL_IDLE_FETCH_LIMIT;
|
||||
};
|
||||
|
||||
const resolvePollDelay = () => {
|
||||
if (!isVisible()) {
|
||||
return TASK_TOOL_POLL_HIDDEN_MS;
|
||||
}
|
||||
if (taskPollNoChangeCountRef.current >= TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS) {
|
||||
return TASK_TOOL_POLL_IDLE_MS;
|
||||
}
|
||||
return TASK_TOOL_POLL_FAST_MS;
|
||||
};
|
||||
|
||||
const scheduleNextPoll = () => {
|
||||
if (!shouldPoll || typeof window === 'undefined' || cancelled) {
|
||||
return;
|
||||
}
|
||||
pollTimer = window.setTimeout(() => {
|
||||
pollTimer = undefined;
|
||||
void fetchSessionMessages(false);
|
||||
}, resolvePollDelay());
|
||||
};
|
||||
|
||||
const fetchSessionMessages = async (isInitialFetch: boolean) => {
|
||||
try {
|
||||
const messages = await opencodeClient.getSessionMessages(taskSessionId, 500);
|
||||
const messages = await opencodeClient.getSessionMessages(taskSessionId, resolveFetchLimit(isInitialFetch));
|
||||
if (cancelled || !Array.isArray(messages) || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSignature = buildTaskSessionMessagesSignature(messages as SessionMessageWithParts[]);
|
||||
if (nextSignature === taskPollLastSignatureRef.current) {
|
||||
taskPollNoChangeCountRef.current += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
taskPollLastSignatureRef.current = nextSignature;
|
||||
taskPollNoChangeCountRef.current = 0;
|
||||
useSessionStore.getState().syncMessages(taskSessionId, messages);
|
||||
} catch {
|
||||
// Ignore transient subagent fetch errors.
|
||||
} finally {
|
||||
scheduleNextPoll();
|
||||
}
|
||||
};
|
||||
|
||||
void fetchSessionMessages();
|
||||
|
||||
if (shouldPoll && typeof window !== 'undefined') {
|
||||
pollTimer = window.setInterval(() => {
|
||||
void fetchSessionMessages();
|
||||
}, 1200);
|
||||
}
|
||||
void fetchSessionMessages(true);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (typeof pollTimer === 'number') {
|
||||
window.clearInterval(pollTimer);
|
||||
window.clearTimeout(pollTimer);
|
||||
}
|
||||
};
|
||||
}, [childSessionHasInFlightTools, childSessionTaskSummaryEntries.length, isActive, isTaskTool, taskSessionId]);
|
||||
}, [
|
||||
childSessionActivity.phase,
|
||||
childSessionHasInFlightTools,
|
||||
childSessionTaskSummaryEntries.length,
|
||||
isActive,
|
||||
isTaskTool,
|
||||
taskChildPollingStopped,
|
||||
taskSessionId,
|
||||
]);
|
||||
|
||||
|
||||
const taskSummaryLenRef = React.useRef<number>(taskSummaryEntries.length);
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
|
||||
return;
|
||||
}
|
||||
target.style.opacity = '';
|
||||
target.style.filter = '';
|
||||
// target.style.filter = '';
|
||||
target.style.transform = '';
|
||||
target.style.maskImage = '';
|
||||
target.style.webkitMaskImage = '';
|
||||
@@ -61,7 +61,7 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
|
||||
CSS.supports('-webkit-mask-image', 'linear-gradient(to right, black, transparent)'));
|
||||
|
||||
el.style.opacity = '0';
|
||||
el.style.filter = wipe ? 'blur(3px)' : 'blur(2px)';
|
||||
// el.style.filter = wipe ? 'blur(3px)' : 'blur(2px)';
|
||||
el.style.transform = wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)';
|
||||
|
||||
if (maskSupported) {
|
||||
@@ -84,16 +84,15 @@ export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
|
||||
|
||||
const keyframes: Keyframe[] = maskSupported
|
||||
? [
|
||||
{ opacity: 0, filter: 'blur(3px)', transform: 'translateX(-0.06em)', maskPosition: '100% 0%' },
|
||||
{ opacity: 1, filter: 'blur(0px)', transform: 'translateX(0)', maskPosition: '0% 0%' },
|
||||
{ opacity: 0, transform: 'translateX(-0.06em)', maskPosition: '100% 0%' },
|
||||
{ opacity: 1, transform: 'translateX(0)', maskPosition: '0% 0%' },
|
||||
]
|
||||
: [
|
||||
{
|
||||
opacity: 0,
|
||||
filter: wipe ? 'blur(3px)' : 'blur(2px)',
|
||||
transform: wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)',
|
||||
},
|
||||
{ opacity: 1, filter: 'blur(0px)', transform: wipe ? 'translateX(0)' : 'translateY(0)' },
|
||||
{ opacity: 1, transform: wipe ? 'translateX(0)' : 'translateY(0)' },
|
||||
];
|
||||
|
||||
animation = node.animate(keyframes, {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
|
||||
type Subscriber = (now: number) => void;
|
||||
|
||||
type TickerChannel = {
|
||||
subscribers: Set<Subscriber>;
|
||||
timerId: number | null;
|
||||
};
|
||||
|
||||
const tickerChannels = new Map<number, TickerChannel>();
|
||||
|
||||
const getTickerChannel = (intervalMs: number): TickerChannel => {
|
||||
const existing = tickerChannels.get(intervalMs);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created: TickerChannel = {
|
||||
subscribers: new Set<Subscriber>(),
|
||||
timerId: null,
|
||||
};
|
||||
tickerChannels.set(intervalMs, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const subscribeToTicker = (intervalMs: number, subscriber: Subscriber): (() => void) => {
|
||||
const channel = getTickerChannel(intervalMs);
|
||||
channel.subscribers.add(subscriber);
|
||||
subscriber(Date.now());
|
||||
|
||||
if (channel.timerId === null && typeof window !== 'undefined') {
|
||||
channel.timerId = window.setInterval(() => {
|
||||
const now = Date.now();
|
||||
channel.subscribers.forEach((listener) => {
|
||||
listener(now);
|
||||
});
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const tracked = tickerChannels.get(intervalMs);
|
||||
if (!tracked) {
|
||||
return;
|
||||
}
|
||||
|
||||
tracked.subscribers.delete(subscriber);
|
||||
if (tracked.subscribers.size > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tracked.timerId !== null && typeof window !== 'undefined') {
|
||||
window.clearInterval(tracked.timerId);
|
||||
}
|
||||
tickerChannels.delete(intervalMs);
|
||||
};
|
||||
};
|
||||
|
||||
export const useDurationTickerNow = (active: boolean, intervalMs: number = 250): number => {
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
return subscribeToTicker(intervalMs, setNow);
|
||||
}, [active, intervalMs]);
|
||||
|
||||
return now;
|
||||
};
|
||||
Reference in New Issue
Block a user