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:
Bohdan Triapitsyn
2026-03-20 01:01:03 +02:00
committed by GitHub
co-authored by Iuliia Ivashko
parent 359879153a
commit 321cc7252a
222 changed files with 8575 additions and 5456 deletions
@@ -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}
+493 -18
View File
@@ -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}>
+92 -17
View File
@@ -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;
};