@@ -314,7 +516,7 @@ export const SessionAuthGate: React.FC = ({ children }) =>
ref={passwordInputRef}
type="password"
autoComplete="current-password"
- placeholder="Enter password"
+ placeholder={t('sessionAuth.password.placeholder')}
value={password}
onChange={(event) => {
setPassword(event.target.value);
@@ -332,7 +534,7 @@ export const SessionAuthGate: React.FC = ({ children }) =>
type="submit"
size="icon"
disabled={!password || isSubmitting}
- aria-label={isSubmitting ? 'Unlocking' : 'Unlock'}
+ aria-label={isSubmitting ? t('sessionAuth.actions.unlockingAria') : t('sessionAuth.actions.unlockAria')}
>
{isSubmitting ? (
@@ -341,6 +543,45 @@ export const SessionAuthGate: React.FC = ({ children }) =>
)}
+ {canOfferPasskeySetup ? (
+
+
+
+
+ ) : (
+
+ )}
{errorMessage && (
{errorMessage}
@@ -353,7 +594,7 @@ export const SessionAuthGate: React.FC = ({ children }) =>
- Use Local if remote is unreachable.
+ {t('sessionAuth.locked.hostSwitcherHint')}
)}
diff --git a/src/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx b/src/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx
index 3a1aa39..98cc461 100644
--- a/src/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx
+++ b/src/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx
@@ -3,6 +3,7 @@ import { cn, fuzzyMatch } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useAgentsStore, isAgentBuiltIn, type AgentWithExtras } from '@/stores/useAgentsStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
+import { useI18n } from '@/lib/i18n';
interface AgentInfo {
name: string;
@@ -40,13 +41,15 @@ export const AgentMentionAutocomplete = React.forwardRef {
+ const { t } = useI18n();
const containerRef = React.useRef(null);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [agents, setAgents] = React.useState([]);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const ignoreTabClickRef = React.useRef(false);
- const { getVisibleAgents } = useConfigStore();
- const { agents: agentsWithMetadata, loadAgents } = useAgentsStore();
+ const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
+ const agentsWithMetadata = useAgentsStore((state) => state.agents);
+ const loadAgents = useAgentsStore((state) => state.loadAgents);
React.useEffect(() => {
if (agentsWithMetadata.length === 0) {
@@ -156,7 +159,7 @@ export const AgentMentionAutocomplete = React.forwardRef#{agent.name}
{isSystem ? (
- system
+ {t('chat.agentMentionAutocomplete.badge.system')}
) : agent.scope ? (
([
+ { id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
+ { id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
+ { id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
+ ]), [t]);
+
return (
- {([
- { id: 'commands' as const, label: 'Commands' },
- { id: 'agents' as const, label: 'Agents' },
- { id: 'files' as const, label: 'Files' },
- ]).map((tab) => (
+ {tabs.map((tab) => (
);
diff --git a/src/packages/ui/src/components/chat/ChangedFilesList.tsx b/src/packages/ui/src/components/chat/ChangedFilesList.tsx
new file mode 100644
index 0000000..bc8f670
--- /dev/null
+++ b/src/packages/ui/src/components/chat/ChangedFilesList.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
+import { type ChangedFileEntry, getDisplayPath, getFileStats } from './changedFiles';
+import { useI18n } from '@/lib/i18n';
+
+interface ChangedFilesListProps {
+ files: ChangedFileEntry[];
+ currentDirectory: string;
+ onOpenFile: (file: ChangedFileEntry) => void;
+}
+
+export const ChangedFilesList: React.FC
= ({ files, currentDirectory, onOpenFile }) => {
+ const { t } = useI18n();
+ return (
+ <>
+
+ {t('chat.changedFiles.title')}
+ {files.length}
+
+
+
+ {files.map((file, index) => {
+ const { fileName, dirPart } = getDisplayPath(file, currentDirectory);
+ const stats = getFileStats(file);
+
+ return (
+
+ );
+ })}
+
+ >
+ );
+};
diff --git a/src/packages/ui/src/components/chat/ChatContainer.tsx b/src/packages/ui/src/components/chat/ChatContainer.tsx
index d072445..ff3401a 100644
--- a/src/packages/ui/src/components/chat/ChatContainer.tsx
+++ b/src/packages/ui/src/components/chat/ChatContainer.tsx
@@ -1,23 +1,23 @@
import React from 'react';
import { RiArrowLeftLine } from '@remixicon/react';
-import { useShallow } from 'zustand/react/shallow';
-import type { Message, Part } from '@opencode-ai/sdk/v2';
+import type { Message, Part, Session } from '@opencode-ai/sdk/v2';
import { ChatInput } from './ChatInput';
-import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { Skeleton } from '@/components/ui/skeleton';
import ChatEmptyState from './ChatEmptyState';
import MessageList, { type MessageListHandle } from './MessageList';
+import { PermissionCard } from './PermissionCard';
+import { QuestionCard } from './QuestionCard';
+import { StatusRowContainer } from './StatusRowContainer';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
-import { useChatScrollManager } from '@/hooks/useChatScrollManager';
+import { useChatScrollManager, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatScrollManager';
import { useChatTimelineController } from './hooks/useChatTimelineController';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
import { useDeviceInfo } from '@/lib/device';
import { Button } from '@/components/ui/button';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
-import { TimelineDialog } from './TimelineDialog';
import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
import { cn } from '@/lib/utils';
@@ -26,11 +26,99 @@ import {
flattenBlockingRequests,
} from './lib/blockingRequests';
+// New sync system imports
+import { useSessionUIStore } from '@/sync/session-ui-store';
+import { useViewportStore } from '@/sync/viewport-store';
+import { useStreamingStore } from '@/sync/streaming';
+import {
+ useSessionMessageCount,
+ useSessionMessageRecords,
+ useSessions,
+ useDirectorySync,
+ useSessionStatus,
+} from '@/sync/sync-context';
+import { useSync } from '@/sync/use-sync';
+import { usePlanDetection } from '@/hooks/usePlanDetection';
+import { getAllSyncSessions } from '@/sync/sync-refs';
+import { useI18n } from '@/lib/i18n';
+
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';
+const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
+const CHAT_SCROLL_STYLE = {
+ overflowAnchor: 'none',
+ overscrollBehavior: 'contain',
+ overscrollBehaviorY: 'contain',
+} as const;
+const CHAT_NAVIGATION_IGNORED_TARGET_SELECTOR = [
+ 'a[href]',
+ 'button',
+ 'input',
+ 'select',
+ 'textarea',
+ '[contenteditable="true"]',
+ '[role="button"]',
+ '[role="combobox"]',
+ '[role="dialog"]',
+ '[role="listbox"]',
+ '[role="menu"]',
+ '[role="menuitem"]',
+ '[role="option"]',
+ '[role="textbox"]',
+ '[data-radix-popper-content-wrapper]',
+].join(',');
+type SessionMessageRecord = { info: Message; parts: Part[] };
+
+const isHTMLElement = (target: EventTarget | null): target is HTMLElement => {
+ return target instanceof HTMLElement;
+};
+
+const shouldIgnoreChatNavigationTarget = (target: EventTarget | null): boolean => {
+ if (!isHTMLElement(target)) {
+ return false;
+ }
+
+ return Boolean(target.closest(CHAT_NAVIGATION_IGNORED_TARGET_SELECTOR));
+};
+
+const shouldIgnoreChatNavigationForFocus = (activeElement: Element | null, scrollContainer: HTMLElement | null): boolean => {
+ if (typeof document === 'undefined') {
+ return true;
+ }
+
+ if (!activeElement || activeElement === document.body || activeElement === document.documentElement) {
+ return true;
+ }
+
+ if (shouldIgnoreChatNavigationTarget(activeElement)) {
+ return true;
+ }
+
+ return !scrollContainer?.contains(activeElement);
+};
+
+const hasBlockingChatOverlay = (): boolean => {
+ const {
+ isAboutDialogOpen,
+ isCommandPaletteOpen,
+ isHelpDialogOpen,
+ isImagePreviewOpen,
+ isMultiRunLauncherOpen,
+ isSessionSwitcherOpen,
+ isSettingsDialogOpen,
+ } = useUIStore.getState();
+
+ return isAboutDialogOpen
+ || isCommandPaletteOpen
+ || isHelpDialogOpen
+ || isImagePreviewOpen
+ || isMultiRunLauncherOpen
+ || isSessionSwitcherOpen
+ || isSettingsDialogOpen;
+};
type HydratingToolSkeletonRow = {
id: string;
@@ -38,6 +126,162 @@ type HydratingToolSkeletonRow = {
detailWidth: string;
};
+type ChatViewportProps = {
+ currentSessionId: string;
+ isDesktopExpandedInput: boolean;
+ isMobile: boolean;
+ stickyUserHeader: boolean;
+ scrollRef: React.RefObject;
+ messageListRef: React.RefObject;
+ turnStart: number;
+ pendingRevealWork: boolean;
+ renderedMessages: SessionMessageRecord[];
+ hasMoreAboveTurns: boolean;
+ isLoadingOlder: boolean;
+ sessionIsWorking: boolean;
+ streamingMessageId: string | null;
+ activeStreamingPhase: import('./message/types').StreamPhase | null;
+ retryOverlay: {
+ sessionId: string;
+ message: string;
+ confirmedAt?: number;
+ fallbackTimestamp?: number;
+ } | null;
+ handleMessageContentChange: (reason?: ContentChangeReason) => void;
+ getAnimationHandlers: (messageId: string) => AnimationHandlers;
+ handleLoadOlder: () => void;
+ scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
+ sessionQuestions: QuestionRequest[];
+ sessionPermissions: PermissionRequest[];
+ isProgrammaticFollowActive: boolean;
+};
+
+const ChatViewport = React.memo(({
+ currentSessionId,
+ isDesktopExpandedInput,
+ isMobile,
+ stickyUserHeader,
+ scrollRef,
+ messageListRef,
+ turnStart,
+ pendingRevealWork,
+ renderedMessages,
+ hasMoreAboveTurns,
+ isLoadingOlder,
+ sessionIsWorking,
+ streamingMessageId,
+ activeStreamingPhase,
+ retryOverlay,
+ handleMessageContentChange,
+ getAnimationHandlers,
+ handleLoadOlder,
+ scrollToBottom,
+ sessionQuestions,
+ sessionPermissions,
+ isProgrammaticFollowActive,
+}: ChatViewportProps) => {
+ const focusScrollContainer = React.useCallback((event: React.MouseEvent) => {
+ if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) {
+ return;
+ }
+
+ if (typeof window !== 'undefined' && window.getSelection()?.type === 'Range') {
+ return;
+ }
+
+ scrollRef.current?.focus({ preventScroll: true });
+ }, [scrollRef]);
+
+ return (
+
+
+
+
+
+ {(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
+
+ {sessionQuestions.map((question) => (
+
+ ))}
+ {sessionPermissions.map((permission) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}, (prev, next) => {
+ return prev.currentSessionId === next.currentSessionId
+ && prev.isDesktopExpandedInput === next.isDesktopExpandedInput
+ && prev.isMobile === next.isMobile
+ && prev.stickyUserHeader === next.stickyUserHeader
+ && prev.scrollRef === next.scrollRef
+ && prev.messageListRef === next.messageListRef
+ && prev.turnStart === next.turnStart
+ && prev.pendingRevealWork === next.pendingRevealWork
+ && prev.renderedMessages === next.renderedMessages
+ && prev.hasMoreAboveTurns === next.hasMoreAboveTurns
+ && prev.isLoadingOlder === next.isLoadingOlder
+ && prev.sessionIsWorking === next.sessionIsWorking
+ && prev.streamingMessageId === next.streamingMessageId
+ && prev.activeStreamingPhase === next.activeStreamingPhase
+ && prev.retryOverlay === next.retryOverlay
+ && prev.handleMessageContentChange === next.handleMessageContentChange
+ && prev.getAnimationHandlers === next.getAnimationHandlers
+ && prev.handleLoadOlder === next.handleLoadOlder
+ && prev.scrollToBottom === next.scrollToBottom
+ && prev.sessionQuestions === next.sessionQuestions
+ && prev.sessionPermissions === next.sessionPermissions
+ && prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive;
+});
+
+ChatViewport.displayName = 'ChatViewport';
+
const HYDRATING_SKELETON_ITEMS: Array<{
id: number;
toolRows: HydratingToolSkeletonRow[];
@@ -71,101 +315,179 @@ const HYDRATING_SKELETON_ITEMS: Array<{
];
export const ChatContainer: React.FC = () => {
- const {
- currentSessionId,
- loadMessages,
- loadMoreMessages,
- updateViewportAnchor,
- openNewSessionDraft,
- setCurrentSession,
- newSessionDraft,
- } = useSessionStore(
- useShallow((state) => ({
- currentSessionId: state.currentSessionId,
- loadMessages: state.loadMessages,
- loadMoreMessages: state.loadMoreMessages,
- updateViewportAnchor: state.updateViewportAnchor,
- openNewSessionDraft: state.openNewSessionDraft,
- setCurrentSession: state.setCurrentSession,
- newSessionDraft: state.newSessionDraft,
- }))
+ const { t } = useI18n();
+ // Session UI state
+ const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
+ const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
+ const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
+ const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
+ const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor);
+ const isSyncing = useViewportStore((s) => s.isSyncing);
+ const sessionMemoryStateMap = useViewportStore((s) => s.sessionMemoryState);
+
+ // Sync actions
+ const sync = useSync();
+ const loadMessages = React.useCallback(
+ (sessionId: string) => sync.syncSession(sessionId),
+ [sync],
+ );
+ const loadMoreMessages = React.useCallback(
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ (sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId),
+ [sync],
);
- const { isSyncing, messageStreamStates, sessionMemoryStateMap } = useSessionStore(
- useShallow((state) => ({
- isSyncing: state.isSyncing,
- messageStreamStates: state.messageStreamStates,
- sessionMemoryStateMap: state.sessionMemoryState,
- }))
- );
+ // UI store
+ const isExpandedInput = useUIStore((state) => state.isExpandedInput);
+ const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
+ const chatRenderMode = useUIStore((state) => state.chatRenderMode);
- const {
- isTimelineDialogOpen,
- setTimelineDialogOpen,
- isExpandedInput,
- stickyUserHeader,
- chatRenderMode,
- } = useUIStore();
-
- const sessionMessages = useSessionStore(
+ // Streaming state
+ const streamingMessageId = useStreamingStore(
React.useCallback(
- (state) => (currentSessionId ? state.messages.get(currentSessionId) ?? EMPTY_MESSAGES : EMPTY_MESSAGES),
- [currentSessionId]
- )
+ (s) => (currentSessionId ? s.streamingMessageIds.get(currentSessionId) ?? null : null),
+ [currentSessionId],
+ ),
+ );
+ const activeStreamingPhase = useStreamingStore(
+ React.useCallback(
+ (s) => {
+ if (!streamingMessageId) return null;
+ return s.messageStreamStates.get(streamingMessageId)?.phase ?? null;
+ },
+ [streamingMessageId],
+ ),
+ );
+ const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '');
+ const hasLoadedSessionMessages = useDirectorySync(
+ React.useCallback(
+ (state) => (currentSessionId ? state.message[currentSessionId] !== undefined : false),
+ [currentSessionId],
+ ),
+ );
+ // Messages from sync system
+ const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '');
+ const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
+
+ // Sessions from sync system
+ const sessions = useSessions();
+
+ // Plan detection - watches messages for plan creation and signals store
+ usePlanDetection(currentSessionId ?? '');
+
+ // Session status from sync system
+ const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '') ?? IDLE_SESSION_STATUS;
+
+ // Permissions & questions from sync system
+ const allPermissions = useDirectorySync(
+ React.useCallback((s) => s.permission ?? {}, []),
+ );
+ const allQuestions = useDirectorySync(
+ React.useCallback((s) => s.question ?? {}, []),
);
- const sessions = useSessionStore((state) => state.sessions);
+ // Convert Record → Map for blockingRequests helpers
+ const permissionsMap = React.useMemo(() => {
+ const m = new Map();
+ for (const [k, v] of Object.entries(allPermissions)) m.set(k, v as PermissionRequest[]);
+ return m;
+ }, [allPermissions]);
- const blockingRequestState = useSessionStore(
- useShallow((state) => ({
- sessions: state.sessions,
- permissions: state.permissions,
- questions: state.questions,
- }))
- );
+ const questionsMap = React.useMemo(() => {
+ const m = new Map();
+ for (const [k, v] of Object.entries(allQuestions)) m.set(k, v as QuestionRequest[]);
+ return m;
+ }, [allQuestions]);
const scopedSessionIds = React.useMemo(
() => collectVisibleSessionIdsForBlockingRequests(
- blockingRequestState.sessions.map((session) => ({ id: session.id, parentID: session.parentID })),
+ sessions.map((session) => ({ id: session.id, parentID: session.parentID })),
currentSessionId,
),
- [blockingRequestState.sessions, currentSessionId]
+ [sessions, currentSessionId],
);
const sessionPermissions = React.useMemo(() => {
if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS;
- return flattenBlockingRequests(blockingRequestState.permissions, scopedSessionIds);
- }, [blockingRequestState.permissions, scopedSessionIds]);
+ return flattenBlockingRequests(permissionsMap, scopedSessionIds);
+ }, [permissionsMap, scopedSessionIds]);
const sessionQuestions = React.useMemo(() => {
if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS;
- return flattenBlockingRequests(blockingRequestState.questions, scopedSessionIds);
- }, [blockingRequestState.questions, scopedSessionIds]);
+ return flattenBlockingRequests(questionsMap, scopedSessionIds);
+ }, [questionsMap, scopedSessionIds]);
+ const sessionIsWorking = React.useMemo(() => {
+ if (!currentSessionId || sessionPermissions.length > 0 || sessionQuestions.length > 0) {
+ return false;
+ }
- const historyMeta = useSessionStore(
- React.useCallback(
- (state) => (currentSessionId ? state.sessionHistoryMeta.get(currentSessionId) ?? null : null),
- [currentSessionId]
- )
- );
+ if (streamingMessageId || activeStreamingPhase) {
+ return true;
+ }
- const streamingMessageId = useSessionStore(
- React.useCallback(
- (state) => (currentSessionId ? state.streamingMessageIds.get(currentSessionId) ?? null : null),
- [currentSessionId]
- )
- );
+ const statusType = sessionStatusForCurrent.type ?? 'idle';
+ if (statusType === 'busy' || statusType === 'retry') {
+ return true;
+ }
- const sessionStatusForCurrent = useSessionStore(
- React.useCallback(
- (state) => (currentSessionId ? state.sessionStatus?.get(currentSessionId) ?? IDLE_SESSION_STATUS : IDLE_SESSION_STATUS),
- [currentSessionId]
- )
- );
+ const lastMessage = sessionMessages[sessionMessages.length - 1]?.info as Message | undefined;
+ return Boolean(
+ lastMessage
+ && lastMessage.role === 'assistant'
+ && typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number',
+ );
+ }, [activeStreamingPhase, currentSessionId, sessionMessages, sessionPermissions.length, sessionQuestions.length, sessionStatusForCurrent.type, streamingMessageId]);
+ const activeRetryStatus = React.useMemo(() => {
+ if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') {
+ return null;
+ }
- const hasSessionMessagesEntry = useSessionStore(
- React.useCallback((state) => (currentSessionId ? state.messages.has(currentSessionId) : false), [currentSessionId])
- );
+ const rawMessage = typeof (sessionStatusForCurrent as { message?: string }).message === 'string'
+ ? (((sessionStatusForCurrent as { message?: string }).message) ?? '').trim()
+ : '';
+
+ return {
+ sessionId: currentSessionId,
+ message: rawMessage || DEFAULT_RETRY_MESSAGE,
+ confirmedAt: (sessionStatusForCurrent as { confirmedAt?: number }).confirmedAt,
+ };
+ }, [currentSessionId, sessionStatusForCurrent]);
+ const [retryFallbackTimestamp, setRetryFallbackTimestamp] = React.useState(0);
+ const retryFallbackSessionRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (!activeRetryStatus || typeof activeRetryStatus.confirmedAt === 'number') {
+ retryFallbackSessionRef.current = null;
+ setRetryFallbackTimestamp(0);
+ return;
+ }
+
+ if (retryFallbackSessionRef.current !== activeRetryStatus.sessionId) {
+ retryFallbackSessionRef.current = activeRetryStatus.sessionId;
+ setRetryFallbackTimestamp(Date.now());
+ }
+ }, [activeRetryStatus]);
+
+ const retryOverlay = React.useMemo(() => {
+ if (!activeRetryStatus) {
+ return null;
+ }
+
+ return {
+ ...activeRetryStatus,
+ fallbackTimestamp: retryFallbackTimestamp,
+ };
+ }, [activeRetryStatus, retryFallbackTimestamp]);
+
+ // History metadata — use sync's hasMore/isLoading
+ const historyMeta = React.useMemo(() => {
+ if (!currentSessionId) return null;
+ return {
+ limit: sessionMessages.length,
+ complete: !sync.hasMore(currentSessionId),
+ loading: sync.isLoading(currentSessionId),
+ };
+ }, [currentSessionId, sessionMessages.length, sync]);
const { isMobile } = useDeviceInfo();
const draftOpen = Boolean(newSessionDraft?.open);
@@ -173,24 +495,19 @@ export const ChatContainer: React.FC = () => {
const messageListRef = React.useRef(null);
const parentSession = React.useMemo(() => {
- if (!currentSessionId) {
- return null;
- }
-
+ if (!currentSessionId) return null;
const current = sessions.find((session) => session.id === currentSessionId);
const parentID = current?.parentID;
- if (!parentID) {
- return null;
- }
-
- return sessions.find((session) => session.id === parentID) ?? null;
+ if (!parentID) return null;
+ return sessions.find((session) => session.id === parentID)
+ ?? getAllSyncSessions().find((session) => session.id === parentID)
+ ?? null;
}, [currentSessionId, sessions]);
const handleReturnToParentSession = React.useCallback(() => {
- if (!parentSession) {
- return;
- }
- void setCurrentSession(parentSession.id);
+ if (!parentSession) return;
+ const parentDirectory = (parentSession as Session & { directory?: string | null }).directory ?? null;
+ setCurrentSession(parentSession.id, parentDirectory);
}, [parentSession, setCurrentSession]);
const returnToParentButton = parentSession ? (
@@ -200,11 +517,13 @@ export const ChatContainer: React.FC = () => {
size="xs"
onClick={handleReturnToParentSession}
className="absolute left-3 top-3 z-20 !font-normal bg-[var(--surface-background)]/95"
- aria-label="Return to parent session"
- title={parentSession.title?.trim() ? `Return to: ${parentSession.title}` : 'Return to parent session'}
+ aria-label={t('chat.container.returnToParent.aria')}
+ title={parentSession.title?.trim()
+ ? t('chat.container.returnToParent.titleNamed', { title: parentSession.title })
+ : t('chat.container.returnToParent.title')}
>
- Parent
+ {t('chat.container.returnToParent.label')}
) : null;
@@ -219,83 +538,146 @@ export const ChatContainer: React.FC = () => {
}, [sessionPermissions, sessionQuestions]);
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
+ const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
+ activeTurnChangeRef.current(turnId);
+ }, []);
const {
scrollRef,
handleMessageContentChange,
getAnimationHandlers,
+ prepareForBottomResume,
scrollToBottom,
- releasePinnedScroll,
isPinned,
isOverflowing,
isProgrammaticFollowActive,
} = useChatScrollManager({
currentSessionId,
- sessionMessages,
- streamingMessageId,
+ sessionMessageCount,
+ sessionIsWorking,
sessionMemoryState: sessionMemoryStateMap,
updateViewportAnchor,
isSyncing,
isMobile,
chatRenderMode,
- messageStreamStates,
sessionPermissions: sessionBlockingCards,
- onActiveTurnChange: (turnId) => {
- activeTurnChangeRef.current(turnId);
- },
+ onActiveTurnChange: handleActiveTurnChange,
});
+ const viewportMessages = sessionMessages;
+
const timelineController = useChatTimelineController({
sessionId: currentSessionId,
- messages: sessionMessages,
+ messages: viewportMessages,
historyMeta,
scrollRef,
messageListRef,
loadMoreMessages,
+ prepareForBottomResume,
scrollToBottom,
isPinned,
isOverflowing,
});
- const { resumeToBottomInstant } = timelineController;
+ const { loadEarlier, resumeToBottomInstant } = timelineController;
+
+ const runLatestInstantResume = React.useCallback(async () => {
+ if (!currentSessionId) {
+ scrollToBottom({ instant: true, force: true });
+ return;
+ }
+ await resumeToBottomInstant();
+ }, [currentSessionId, resumeToBottomInstant, scrollToBottom]);
+
+ const resumeToLatestInstant = React.useCallback(() => {
+ void runLatestInstantResume();
+ }, [runLatestInstantResume]);
React.useEffect(() => {
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
}, [timelineController.handleActiveTurnChange]);
+ React.useEffect(() => {
+ if (sessionPermissions.length === 0 && sessionQuestions.length === 0) {
+ return;
+ }
+ handleMessageContentChange('permission');
+ }, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
+
+ const handleLoadOlder = React.useCallback(() => {
+ void loadEarlier();
+ }, [loadEarlier]);
+
const navigation = useChatTurnNavigation({
sessionId: currentSessionId,
turnIds: timelineController.turnIds,
activeTurnId: timelineController.activeTurnId,
scrollToTurn: timelineController.scrollToTurn,
scrollToMessage: timelineController.scrollToMessage,
- resumeToBottom: timelineController.resumeToBottom,
+ resumeToBottom: timelineController.resumeToBottomInstant,
});
React.useEffect(() => {
- if (typeof window === 'undefined' || !currentSessionId) {
+ if (typeof window === 'undefined' || !currentSessionId || isDesktopExpandedInput) {
return;
}
- const handleSessionReselected = (event: Event) => {
- const customEvent = event as CustomEvent;
- if (customEvent.detail !== currentSessionId) {
+ const handleChatTurnKeyDown = (event: KeyboardEvent) => {
+ if (event.defaultPrevented || event.isComposing) {
return;
}
- resumeToBottomInstant();
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
+ return;
+ }
+
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+
+ const { activeMainTab } = useUIStore.getState();
+ if (activeMainTab !== 'chat' || hasBlockingChatOverlay()) {
+ return;
+ }
+
+ const scrollContainer = scrollRef.current;
+ if (shouldIgnoreChatNavigationForFocus(document.activeElement, scrollContainer)) {
+ return;
+ }
+
+ if (shouldIgnoreChatNavigationTarget(event.target)) {
+ return;
+ }
+
+ event.preventDefault();
+ const offset = event.key === 'ArrowUp' ? -1 : 1;
+ void navigation.scrollByTurnOffset(offset, { resumePastEnd: false });
+ };
+
+ window.addEventListener('keydown', handleChatTurnKeyDown);
+ return () => {
+ window.removeEventListener('keydown', handleChatTurnKeyDown);
+ };
+ }, [currentSessionId, isDesktopExpandedInput, navigation, scrollRef]);
+
+ React.useEffect(() => {
+ if (typeof window === 'undefined' || !currentSessionId) return;
+
+ const handleSessionReselected = (event: Event) => {
+ const customEvent = event as CustomEvent;
+ if (customEvent.detail !== currentSessionId) return;
+ if (isPinned || !isOverflowing || isProgrammaticFollowActive) return;
+ void resumeToBottomInstant();
};
window.addEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
return () => {
window.removeEventListener(SESSION_RESELECTED_EVENT, handleSessionReselected as EventListener);
};
- }, [currentSessionId, resumeToBottomInstant]);
+ }, [currentSessionId, isOverflowing, isPinned, isProgrammaticFollowActive, resumeToBottomInstant]);
React.useLayoutEffect(() => {
const container = scrollRef.current;
- if (!container) {
- return;
- }
+ if (!container) return;
const updateChatScrollHeight = () => {
container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`);
@@ -329,37 +711,56 @@ export const ChatContainer: React.FC = () => {
};
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
- const hasHistoryMetadata = React.useMemo(() => {
- return Boolean(historyMeta);
- }, [historyMeta]);
+ const lastScrolledSessionRef = React.useRef(null);
const isSessionHydrating =
Boolean(currentSessionId)
- && (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true);
+ && !hasLoadedSessionMessages;
React.useEffect(() => {
if (!currentSessionId) {
return;
}
- const hasSessionMessages = hasSessionMessagesEntry;
- if (hasSessionMessages && hasHistoryMetadata) {
+ if (lastScrolledSessionRef.current === currentSessionId) {
return;
}
+ const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
+ if (hasHashTarget) {
+ lastScrolledSessionRef.current = currentSessionId;
+ return;
+ }
+
+ lastScrolledSessionRef.current = currentSessionId;
+
+ if (typeof window === 'undefined') {
+ resumeToLatestInstant();
+ return;
+ }
+
+ window.requestAnimationFrame(() => {
+ resumeToLatestInstant();
+ });
+ }, [currentSessionId, resumeToLatestInstant]);
+
+ React.useEffect(() => {
+ if (!currentSessionId) return;
+ if (hasLoadedSessionMessages) return;
+
const load = async () => {
await loadMessages(currentSessionId).finally(() => {
const statusType = sessionStatusForCurrent.type ?? 'idle';
const isActivePhase = statusType === 'busy' || statusType === 'retry';
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
- const shouldSkipScroll = (isActivePhase && isPinned) || hasHashTarget;
+ const shouldSkipScroll = hasHashTarget || (isActivePhase && isPinned);
if (!shouldSkipScroll) {
if (typeof window === 'undefined') {
- scrollToBottom({ instant: true });
+ resumeToLatestInstant();
} else {
window.requestAnimationFrame(() => {
- scrollToBottom({ instant: true });
+ resumeToLatestInstant();
});
}
}
@@ -367,41 +768,35 @@ export const ChatContainer: React.FC = () => {
};
void load();
- }, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]);
+ }, [currentSessionId, hasLoadedSessionMessages, isPinned, loadMessages, resumeToLatestInstant, sessionStatusForCurrent.type]);
- if (!currentSessionId && !draftOpen) {
- return (
-
-
-
- );
- }
+ if (!currentSessionId && !draftOpen) {
+ return (
+
+
+
+ );
+ }
- if (!currentSessionId && draftOpen) {
- return (
-
- {!isDesktopExpandedInput ? (
-
-
-
- ) : null}
+ if (!currentSessionId && draftOpen) {
+ return (
+
+ {!isDesktopExpandedInput ? (
+
+
+
+ ) : null}
-
-
-
+ isDesktopExpandedInput
+ ? 'flex-1 min-h-0 bg-background'
+ : 'bg-background'
+ )}
+ >
+
+
+
);
}
@@ -409,23 +804,20 @@ export const ChatContainer: React.FC = () => {
return null;
}
- if (isSessionHydrating && sessionMessages.length === 0 && !streamingMessageId) {
- return (
-
- {returnToParentButton}
-
+ {returnToParentButton}
+
-
+
{HYDRATING_SKELETON_ITEMS.map((item) => (
@@ -457,26 +849,23 @@ export const ChatContainer: React.FC = () => {
-
-
+ isDesktopExpandedInput
+ ? 'flex-1 min-h-0 bg-background'
+ : 'bg-background'
+ )}
+ >
+
+
);
}
- if (sessionMessages.length === 0 && !streamingMessageId) {
- return (
-
- {returnToParentButton}
-
+ {returnToParentButton}
+
{
-
-
+ isDesktopExpandedInput
+ ? 'flex-1 min-h-0 bg-background'
+ : 'bg-background'
+ )}
+ >
+
+
);
}
- return (
-
- {returnToParentButton}
-
-
-
-
- {
- void timelineController.loadEarlier();
- }}
- scrollToBottom={scrollToBottom}
- scrollRef={scrollRef}
- />
-
-
-
-
-
+ return (
+
+ {returnToParentButton}
+
- {!isDesktopExpandedInput && sessionMessages.length > 0 && (
-
+ {!isDesktopExpandedInput && sessionMessages.length > 0 && (
+
)}
-
+
-
-
{
- releasePinnedScroll();
- return navigation.scrollToMessageId(messageId, { behavior: 'smooth', updateHash: false });
- }}
- onScrollByTurnOffset={(offset) => {
- releasePinnedScroll();
- void navigation.scrollByTurnOffset(offset);
- }}
- onResumeToLatest={navigation.resumeToLatest}
- />
);
};
diff --git a/src/packages/ui/src/components/chat/ChatEmptyState.tsx b/src/packages/ui/src/components/chat/ChatEmptyState.tsx
index 00c30ee..e5ad6e9 100644
--- a/src/packages/ui/src/components/chat/ChatEmptyState.tsx
+++ b/src/packages/ui/src/components/chat/ChatEmptyState.tsx
@@ -1,45 +1,29 @@
import React from 'react';
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
-import { TextLoop } from '@/components/ui/TextLoop';
import { useThemeSystem } from '@/contexts/useThemeSystem';
-
-const phrases = [
- "Fix the failing tests",
- "Refactor this to be more readable",
- "Add form validation",
- "Optimize this function",
- "Write tests for this",
- "Explain how this works",
- "Add a new feature",
- "Help me debug this",
- "Review my code",
- "Simplify this logic",
- "Add error handling",
- "Create a new component",
- "Update the documentation",
- "Find the bug here",
- "Improve performance",
- "Add type definitions",
-];
+import { useGlobalSyncStore } from '@/sync/global-sync-store';
+import { useI18n } from '@/lib/i18n';
const ChatEmptyState: React.FC = () => {
+ const { t } = useI18n();
const { currentTheme } = useThemeSystem();
+ const initError = useGlobalSyncStore((s) => s.error);
- // Use theme's muted foreground for secondary text
const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)';
return (
-
-
- {phrases.map((phrase) => (
- "{phrase}…"
- ))}
-
+
+ {initError ? (
+
+ {t('chat.emptyState.opencodeUnreachable')}
+
+ {initError.message}
+
+
+ ) : (
+
{t('chat.emptyState.startNewChat')}
+ )}
);
};
diff --git a/src/packages/ui/src/components/chat/ChatErrorBoundary.tsx b/src/packages/ui/src/components/chat/ChatErrorBoundary.tsx
index c2fc26a..29d3e12 100644
--- a/src/packages/ui/src/components/chat/ChatErrorBoundary.tsx
+++ b/src/packages/ui/src/components/chat/ChatErrorBoundary.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { RiChat3Line, RiRestartLine } from '@remixicon/react';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
+import { useI18n } from '@/lib/i18n';
interface ChatErrorBoundaryState {
hasError: boolean;
@@ -14,8 +15,21 @@ interface ChatErrorBoundaryProps {
sessionId?: string;
}
-export class ChatErrorBoundary extends React.Component
{
- constructor(props: ChatErrorBoundaryProps) {
+interface ChatErrorBoundaryTexts {
+ title: string;
+ description: string;
+ sessionLabel: string;
+ detailsSummary: string;
+ resetAction: string;
+ persistentHint: string;
+}
+
+interface ChatErrorBoundaryViewProps extends ChatErrorBoundaryProps {
+ texts: ChatErrorBoundaryTexts;
+}
+
+class ChatErrorBoundaryView extends React.Component {
+ constructor(props: ChatErrorBoundaryViewProps) {
super(props);
this.state = { hasError: false };
}
@@ -44,24 +58,24 @@ export class ChatErrorBoundary extends React.Component
- Chat Error
+ {this.props.texts.title}
- The chat interface encountered an error. This might be due to a temporary network issue or corrupted message data.
+ {this.props.texts.description}
{this.props.sessionId && (
- Session: {this.props.sessionId}
+ {this.props.texts.sessionLabel}: {this.props.sessionId}
)}
{this.state.error && (
- Error details
-
+ {this.props.texts.detailsSummary}
+
{this.state.error.toString()}
@@ -70,12 +84,12 @@ export class ChatErrorBoundary extends React.Component
- If the problem persists, try refreshing the page.
+ {this.props.texts.persistentHint}
@@ -86,3 +100,20 @@ export class ChatErrorBoundary extends React.Component
+ );
+}
diff --git a/src/packages/ui/src/components/chat/ChatInput.tsx b/src/packages/ui/src/components/chat/ChatInput.tsx
index dd1526d..24b724d 100644
--- a/src/packages/ui/src/components/chat/ChatInput.tsx
+++ b/src/packages/ui/src/components/chat/ChatInput.tsx
@@ -16,37 +16,40 @@ import {
RiSendPlane2Line,
} from '@remixicon/react';
import { BrowserVoiceButton } from '@/components/voice';
-import { useSessionStore } from '@/stores/useSessionStore';
-import { useSessionStore as useSessionManagementStore } from '@/stores/sessionStore';
+// sessionStore removed — currentSessionId comes from useSessionUIStore
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
+import { useSessionUIStore } from '@/sync/session-ui-store';
+import { useSelectionStore } from '@/sync/selection-store';
+import { useInputStore } from '@/sync/input-store';
import type { AttachedFile } from '@/stores/types/sessionTypes';
+import * as sessionActions from '@/sync/session-actions';
+import { useUserMessageHistory } from '@/sync/sync-context';
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { appendInlineComments } from '@/lib/messages/inlineComments';
+import { renderMagicPrompt } from '@/lib/magicPrompts';
import { AttachedFilesList } from './FileAttachment';
import { QueuedMessageChips } from './QueuedMessageChips';
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
-import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
+import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete';
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
import { cn, formatDirectoryName, isMacOS } from '@/lib/utils';
import { ModelControls } from './ModelControls';
-import { UnifiedControlsDrawer } from './UnifiedControlsDrawer';
import { parseAgentMentions } from '@/lib/messages/agentMentions';
import { StatusRow } from './StatusRow';
+import { PendingChangesBar } from './PendingChangesBar';
import { MobileAgentButton } from './MobileAgentButton';
import { MobileModelButton } from './MobileModelButton';
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
-import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { toast } from '@/components/ui';
-import { useFileStore } from '@/stores/fileStore';
-import { useMessageStore } from '@/stores/messageStore';
+// useMessageStore removed — messages now come from sync system
import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
import { isIMECompositionEvent } from '@/lib/ime';
import { StopIcon } from '@/components/icons/StopIcon';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
-import type { MobileControlsPanel } from './mobileControlsUtils';
+import { getCycledPrimaryAgentName, type MobileControlsPanel } from './mobileControlsUtils';
import {
DropdownMenu,
DropdownMenuContent,
@@ -61,10 +64,14 @@ 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 { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
+import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
+import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
import { usePermissionStore } from '@/stores/permissionStore';
+import { extractGitChangedFiles } from './changedFiles';
+import { useI18n } from '@/lib/i18n';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = [];
@@ -81,6 +88,28 @@ const VS_CODE_DROP_DATA_TYPES = [
const FILE_URI_PREFIX = 'file://';
+const encodeFilePath = (filepath: string): string => {
+ let normalized = filepath.replace(/\\/g, '/');
+ if (/^[A-Za-z]:/.test(normalized)) {
+ normalized = `/${normalized}`;
+ }
+ return normalized
+ .split('/')
+ .map((segment, index) => {
+ if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment;
+ return encodeURIComponent(segment);
+ })
+ .join('/');
+};
+
+const toServerFileUrl = (filepath: string): string => {
+ const normalized = filepath.replace(/\\/g, '/').trim();
+ if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) {
+ return normalized;
+ }
+ return `file://${encodeFilePath(normalized)}`;
+};
+
const isLikelyAbsolutePath = (value: string): boolean => (
value.startsWith('/')
|| value.startsWith('\\\\')
@@ -198,6 +227,373 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
return PROJECT_COLOR_MAP[projectColor] ?? undefined;
};
+const MemoModelControls = React.memo(ModelControls);
+const MemoBrowserVoiceButton = React.memo(BrowserVoiceButton);
+const MemoMobileAgentButton = React.memo(MobileAgentButton);
+const MemoMobileModelButton = React.memo(MobileModelButton);
+const MemoStatusRow = React.memo(StatusRow);
+
+type ComposerAttachmentControlsProps = {
+ isMobile: boolean;
+ isVSCode: boolean;
+ footerIconButtonClass: string;
+ iconSizeClass: string;
+ fileInputRef: React.RefObject
;
+ handleLocalFileSelect: (event: React.ChangeEvent) => void | Promise;
+ handlePickLocalFiles: () => void;
+ handleOpenCommandMenu: () => void;
+ openIssuePicker: () => void;
+ openPrPicker: () => void;
+ onOpenSettings?: () => void;
+};
+
+const ComposerAttachmentControls = React.memo(function ComposerAttachmentControls(props: ComposerAttachmentControlsProps) {
+ const { t } = useI18n();
+ const {
+ isMobile,
+ isVSCode,
+ footerIconButtonClass,
+ iconSizeClass,
+ fileInputRef,
+ handleLocalFileSelect,
+ handlePickLocalFiles,
+ handleOpenCommandMenu,
+ openIssuePicker,
+ openPrPicker,
+ onOpenSettings,
+ } = props;
+
+ return (
+
+ {isMobile ? (
+
+ ) : null}
+
+
+
+ {isVSCode ? (
+
+ ) : (
+
+
+
+
+
+ {
+ requestAnimationFrame(handlePickLocalFiles);
+ }}
+ >
+
+ {t('chat.chatInput.actions.attachFiles')}
+
+ {
+ requestAnimationFrame(openIssuePicker);
+ }}
+ >
+
+ {t('chat.chatInput.actions.linkGithubIssue')}
+
+ {
+ requestAnimationFrame(openPrPicker);
+ }}
+ >
+
+ {t('chat.chatInput.actions.linkGithubPr')}
+
+
+
+ )}
+
+
+ {onOpenSettings ? (
+
+ ) : null}
+
+ );
+}, (prev, next) => (
+ prev.isMobile === next.isMobile
+ && prev.isVSCode === next.isVSCode
+ && prev.footerIconButtonClass === next.footerIconButtonClass
+ && prev.iconSizeClass === next.iconSizeClass
+ && prev.onOpenSettings === next.onOpenSettings
+));
+
+type PermissionAutoAcceptButtonProps = {
+ footerIconButtonClass: string;
+ iconSizeClass: string;
+ permissionScopeSessionId: string | null;
+ permissionAutoAcceptEnabled: boolean;
+ handlePermissionAutoAcceptToggle: () => void;
+ withTooltip?: boolean;
+};
+
+const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButton(props: PermissionAutoAcceptButtonProps) {
+ const { t } = useI18n();
+ const {
+ footerIconButtonClass,
+ iconSizeClass,
+ permissionScopeSessionId,
+ permissionAutoAcceptEnabled,
+ handlePermissionAutoAcceptToggle,
+ withTooltip = false,
+ } = props;
+
+ const ariaLabel = permissionAutoAcceptEnabled
+ ? t('chat.chatInput.permissionAutoAccept.disable')
+ : t('chat.chatInput.permissionAutoAccept.enable');
+ const tooltipLabel = permissionAutoAcceptEnabled
+ ? t('chat.chatInput.permissionAutoAccept.on')
+ : t('chat.chatInput.permissionAutoAccept.off');
+
+ const button = (
+
+ );
+
+ if (!withTooltip) {
+ return button;
+ }
+
+ return (
+
+
+ {button}
+
+
+ {tooltipLabel}
+
+
+ );
+});
+
+type FocusModeButtonProps = {
+ footerIconButtonClass: string;
+ iconSizeClass: string;
+ isExpandedInput: boolean;
+ onToggle: () => void;
+};
+
+const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
+ const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
+ const { t } = useI18n();
+
+ return (
+
+
+
+
+
+
+ {t('chat.chatInput.focusMode.label')}
+
+ {isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
+
+
+
+
+ );
+});
+
+type ComposerActionButtonsProps = {
+ isMobile: boolean;
+ footerIconButtonClass: string;
+ sendIconSizeClass: string;
+ stopIconSizeClass: string;
+ canSend: boolean;
+ canAbort: boolean;
+ hasContent: boolean;
+ currentSessionId: string | null;
+ newSessionDraftOpen: boolean;
+ onPrimaryAction: () => void;
+ onQueueMessage: () => void;
+ onAbort: () => void;
+};
+
+const ComposerActionButtons = React.memo(function ComposerActionButtons(props: ComposerActionButtonsProps) {
+ const {
+ isMobile,
+ footerIconButtonClass,
+ sendIconSizeClass,
+ stopIconSizeClass,
+ canSend,
+ canAbort,
+ hasContent,
+ currentSessionId,
+ newSessionDraftOpen,
+ onPrimaryAction,
+ onQueueMessage,
+ onAbort,
+ } = props;
+ const { t } = useI18n();
+
+ const sendButton = (
+
+ );
+
+ if (!canAbort) {
+ return sendButton;
+ }
+
+ return (
+
+ {hasContent ? (
+
+ ) : null}
+
+
+ );
+}, (prev, next) => (
+ prev.isMobile === next.isMobile
+ && prev.footerIconButtonClass === next.footerIconButtonClass
+ && prev.sendIconSizeClass === next.sendIconSizeClass
+ && prev.stopIconSizeClass === next.stopIconSizeClass
+ && prev.canSend === next.canSend
+ && prev.canAbort === next.canAbort
+ && prev.hasContent === next.hasContent
+ && prev.currentSessionId === next.currentSessionId
+ && prev.newSessionDraftOpen === next.newSessionDraftOpen
+));
+
const appendWithLineBreaks = (base: string, next: string): string => {
const separator = !base
? ''
@@ -266,14 +662,46 @@ const saveStoredDraft = (sessionId: string | null, draft: string): void => {
}
};
-export const ChatInput: React.FC = ({ onOpenSettings, scrollToBottom }) => {
+// Per-session confirmed mentions key — tracks which @mentions are confirmed (blue) vs plain text
+const getConfirmedMentionsKey = (sessionId: string | null): string =>
+ `openchamber_chat_confirmed_mentions_${sessionId ?? 'new'}`;
+
+const saveConfirmedMentions = (sessionId: string | null, mentions: Set): void => {
+ try {
+ if (mentions.size > 0) {
+ localStorage.setItem(getConfirmedMentionsKey(sessionId), JSON.stringify([...mentions]));
+ } else {
+ localStorage.removeItem(getConfirmedMentionsKey(sessionId));
+ }
+ } catch {
+ // Ignore localStorage errors
+ }
+};
+
+const loadConfirmedMentions = (sessionId: string | null): Set => {
+ try {
+ const raw = localStorage.getItem(getConfirmedMentionsKey(sessionId));
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed)) {
+ return new Set(parsed.filter((v): v is string => typeof v === 'string'));
+ }
+ }
+ } catch {
+ // Ignore localStorage errors
+ }
+ return new Set();
+};
+
+const ChatInputComponent: React.FC = ({ onOpenSettings, scrollToBottom }) => {
+ const { t } = useI18n();
// Track if we restored a draft on mount (for text selection)
const initialDraftRef = React.useRef(null);
// Track initial session ID (captured at mount time for draft restoration)
const initialSessionIdRef = React.useRef(null);
const [message, setMessage] = React.useState(() => {
// Read per-session draft at mount time using the current session from the store
- const sessionId = useSessionStore.getState().currentSessionId;
+ const sessionId = useSessionUIStore.getState().currentSessionId;
initialSessionIdRef.current = sessionId;
const draft = getStoredDraft(sessionId);
if (draft) {
@@ -281,8 +709,14 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo
}
return draft;
});
+ // Restore confirmed mentions from localStorage on mount
+ const confirmedMentionsRef = React.useRef>(loadConfirmedMentions(initialSessionIdRef.current));
+ // Helper: check if a mention path looks like a file/folder (has path separators, extension, or was explicitly confirmed)
+ const isConfirmedFilePath = (text: string): boolean =>
+ text.includes('/') || text.includes('\\') || text.includes('.') || confirmedMentionsRef.current.has(text);
const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal');
const [isDragging, setIsDragging] = React.useState(false);
+ const [isInternalDrag, setIsInternalDrag] = React.useState(false);
const [showFileMention, setShowFileMention] = React.useState(false);
const [mentionQuery, setMentionQuery] = React.useState('');
const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false);
@@ -291,13 +725,15 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo
const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false);
const [skillQuery, setSkillQuery] = React.useState('');
const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
- const [mobileControlsOpen, setMobileControlsOpen] = React.useState(false);
const [mobileControlsPanel, setMobileControlsPanel] = React.useState(null);
// Message history navigation state (up/down arrow to recall previous messages)
const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent
const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
const textareaRef = React.useRef(null);
+ const cursorPosRef = React.useRef(0);
+ const previousMessageLengthRef = React.useRef(message.length);
const dropZoneRef = React.useRef(null);
+ const dragEnterCountRef = React.useRef(0);
const suppressNextFileDropTextInsertRef = React.useRef(false);
const suppressNextFileDropTextInsertTimeoutRef = React.useRef | null>(null);
const pendingDroppedAbsolutePathsRef = React.useRef([]);
@@ -313,54 +749,77 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo
const lastPersistedDraftRef = React.useRef