perf: harden sync architecture and modularize runtimes (#803)

* fix: added desktop app background throttling

* perf: add streaming debug metrics panel

- Show streaming performance metrics in the debug panel
- Auto-enable stream profiling while the panel is open
- Add JSON export for sharing UI and VS Code metrics

* perf: batch streaming updates more aggressively

- Buffer message deltas and metadata updates to cut render churn
- Skip no-op part updates before they touch the message store
- Fix the desktop debug panel shortcut binding

* perf: split streaming event handling and coalesce deltas

- Move streaming content events onto a dedicated fast path
- Defer non-critical stream side effects off the hot path
- Merge repeated message delta events before they reach the UI

* perf: isolate streaming rows from chat rerenders

- Memoize chat rows against render-relevant message changes only
- Read live assistant text directly from store to narrow streaming updates
- Split the active streaming entry from the stable message list path

* perf: streamline chat streaming and SSE proxying

- Reduce chat rerenders around the active streaming path
- Simplify server SSE forwarding to avoid duplicate proxy work

* fix: preserve the first streaming text chunk

- Show the initial text chunk immediately before batched deltas arrive
- Bypass batching for the first text or reasoning part update
- Keep later streaming updates buffered for performance

* perf: align streaming/render hot paths with opencode parity

* perf: harden turn/cache stability and stale delta suppression

* fix: stabilize chat rendering and disable timeline interactions

- Disabled timeline dialog access from shortcuts, commands, and chat input
- Reduced chat render churn by simplifying message list and turn staging behavior
- Improved session-switch stability to prevent update-depth crashes

* perf: track static message rerenders during streaming

* perf: reduce sorted-mode activity rerender fanout

* perf: reduce chat rerender fanout and add active-turn metrics

- Reduced sorted-mode rerender coupling by tightening turn context propagation
- Added a metric for static rerenders outside the active turn during streaming
- Exposed new chat render counters in the debug panel for parity tracking

* fix: keep sorted activity mounted while stream grows

* fix: stabilize session and history scroll rendering

* refactor: decouple server routes from index

* refactor: extract fs module from server index

* refactor: move opencode route ownership into module

* refactor: extract notification route registration

* refactor: extract opencode and notification runtimes from index

* refactor: extract settings runtime and complete server modularization pass

* refactor: modularize server config, skills, icons, and tunnel routes

* refactor: extract server modules from monolithic index.js

Split proxy, routes, runtime helpers, and notification emitter
into dedicated modules under packages/web/server/lib/.

* refactor: replace session/message stores with SSE-driven sync layer

Delete ~9200 lines of old architecture (useEventStream, messageStore,
sessionStore, useSessionStore, questionStore, useTodoStore, client SSE).

New sync layer: event pipeline with coalescing + 16ms flush, pure event
reducer, per-directory child stores with LRU eviction, cursor pagination,
optimistic updates, deferred timeline staging, text throttle.

Migrate all UI consumers to sync hooks (useSessionMessages,
useSessionMessageRecords, useSessionStatus, useSessionPermissions, etc).

Strip session-ui-store to UI-only state, delegate SDK ops to
session-actions with abort-if-busy, optimistic store updates, and
response merging for revert/fork/archive/delete.

Add notification-store for SSE-driven session attention tracking,
cross-directory GlobalSessionStatusStore for sidebar indicators,
client-side diff snapshot sanitization to prevent memory bloat,
and revert message filtering via useVisibleSessionMessages.

* feat: notification store, session actions, activity detection

Add notification-store.ts for SSE-driven attention tracking.
Add sanitize.ts to strip diff snapshot memory bloat.
Add session-actions.ts with optimistic revert/fork/archive/delete.
Improve useSessionActivity with incomplete-message fallback.
Delete useServerSessionStatus polling hook.

* fix: add directory param to all SDK calls, fix command/shell/abort routing

All SDK calls in session-actions.ts now pass directory parameter —
required by OpenCode server to scope session operations. Without it,
abort, commands, revert, fork, and other operations returned 500.

Add routeMessage() in session-ui-store for shell mode (session.shell),
slash commands (session.command), and normal prompts. Command lookup
checks both sync child store and useCommandsStore. Handle /compact
locally via session.summarize().

Implement getContextUsage() to restore header context usage display —
reads token counts from last assistant message in sync store.

* refactor: replace custom API proxy with http-proxy-middleware

Remove ~280 lines of custom proxy code: forwardSseRequest,
forwardGenericApiRequest, collectRequestBodyBuffer, header
manipulation, hop-by-hop filtering, SSE block buffering.

Replace with single createProxyMiddleware() call that handles
SSE streaming, large bodies, and timeouts out of the box.
Dynamic router for OpenCode port changes after restarts.
Auth headers injected via proxyReq hook.

Keep: readiness gate, Windows session merge, API prefix detection.

* perf: targeted event draft cloning to fix streaming render cascade

Event handler was eagerly cloning all state slices on every event,
breaking Zustand selector referential equality. During streaming
(~60 events/sec), this caused every subscriber to re-render regardless
of which slice actually changed.

Now only clones fields the specific event type mutates. Also extracts
StatusRowContainer to isolate high-frequency useAssistantStatus
subscription, removes dead messageStreamStatesMap subscription from
ChatContainer, and narrows useAssistantStatus to only track last
assistant message parts.

MessageList renders: 1972 → 296 per streaming session (-85%).

* fix: null safety for sync state slices

Add defensive ?? {} guards on permission, question, session_status,
and message record access. Prevents crashes when child store state
is partially initialized during bootstrap.

* perf: dedup inflight SDK calls, extract concurrency util, delay PR tracking

Extract mapWithConcurrency to shared lib/concurrency.ts. Add in-flight
dedup for loadProviders/loadAgents to prevent concurrent duplicate SDK
calls. Delay initial PR background tracking by 5s to reduce startup
CPU burst.

* fix: header session lookup across all child stores

Session title and context panel click failed when session belonged to
a different directory than the current child store. Fall back to
getAllSyncSessions() to search all initialized stores.

* chore: bump @opencode-ai/sdk to 1.3.5

* docs: add sync event handling guide

* Optimize session prefetch and improve delete/archive UX

- Add settlement delay to session prefetch to avoid race conditions on
  rapid session switches
- Reduce git diff prefetch and session cache limits for better performance
- Implement optimistic UI updates for session delete/archive operations
  with proper rollback on failure
- Wire session prefetch hook into SessionSidebar with sync integration

* Add file content cache and sync optimizations

- Wrap FilesAPI with in-memory LRU cache for file content with dual
  constraints (entry count and byte size)
- Optimize chat timeline scroll restoration using useLayoutEffect
- Preserve React references in message and part arrays to prevent
  unnecessary re-renders when prepending history
- Add session prefetch TTL cache to prevent redundant fetches
- Integrate session prefetch cache clearing with eviction flow

* Improve session sidebar error handling and add diff prefetch filtering

Load active and archived sessions independently using Promise.allSettled
to prevent one failure from blocking the other. Add retry logic to session
API calls and skip large files during diff prefetch to improve performance.

* Replace sendMessage with optimisticSend wrapper

Introduces optimistic UI updates for normal chat messages to provide
instant feedback. Messages appear immediately in the UI while the API
call executes in the background, with automatic rollback on errors.

* perf: split stores, proper optimistic send, fix revert/directory bugs

- split session-ui-store into voice/input/selection/viewport stores
  to reduce subscriber re-evaluation during streaming
- wire optimisticSend through useSync shadow Map infrastructure
  matching OpenCode's pattern (no heuristic part detection)
- port OpenCode Identifier.ascending ID format for correct sorting
- pass messageID to promptAsync to prevent duplicate messages
- fix worktree directory not propagating to session actions
  (dynamic dir() via opencodeClient.getDirectory)
- fix setCurrentSession accepting directoryHint for new sessions
- fix revert not hiding messages (session limit was 5, bumped to match loaded count)
- fix revert optimistic message removal from store
- fix load-more flicker (useLayoutEffect scroll compensation)
- add prefetch TTL cache, file content LRU cache
- add session prefetch for adjacent sessions
- add instant archive/delete (optimistic before SDK call)
- migrate legacy window.__zustand_session_store__ to session-ui-store
- add retry + independent error handling for archived sessions
- add AGENTS.md performance rules

* perf: startup optimization — dedup, caching, light git status, diff rendering gates

- defer diff prefetch to git tab open, reduce concurrency 4→2, skip >500 changed lines
- cap project git checks concurrency (2), directory status probe (3)
- dedup provider/agent loading, github auth, worktree list (in-flight + TTL caches)
- delay PR tracking 5s, cache 403 search failures per-repo
- coalesce settings PUT (200ms debounce), cache settings GET (2s TTL)
- cache canonical directory resolution (60s TTL)
- persist missing directory status to localStorage (10min TTL)
- light/heavy git status: polling skips numstat+line counting+rev-list
- large diff rendering gate (>500 lines → "render anyway" button)
- tokenization degradation for >500KB files in Pierre
- parallelize main.tsx pre-render awaits
- batch sidebar file tree expanded paths restoration (3 at a time)
- remove bare useConfigStore() subscription in AgentsPage
- sync worktree sandboxes to OpenCode SQLite DB
- fix RightSidebarTabs ternary → explicit tab matching
- defensive guards on sync state (session_status, permission, question, message)

* fix: add defensive guards on remaining sync state field accesses

guard session_status, permission, message, todo, part, config with ?? {}
in useDirectorySync selectors, session-cache, and bootstrap

* fix: add missing directory dep to useCallback in use-sync.ts

* fix: preserve diffStats when light-mode polling overwrites status

* perf: optimize startup git status polling and diff rendering

Preserves diff stats when lightweight polling updates repository status
Reduces startup overhead with smarter git polling and store updates
Adds detailed optimization and migration docs for next performance steps

* fix: keep chat diff stats stable during git status updates

Prevents lightweight git polling from dropping diff statistics
Keeps MessageList diff indicators consistent while status refreshes
Improves reliability of git-aware chat rendering

* fix: user animation replay, queued message variant, startup provider loading

- consume animation ID after first play to prevent re-animation
  on neighbor assistant message completion
- capture send config (model/agent/variant) at queue time matching
  OpenCode's FollowupDraft pattern instead of re-resolving at send time
- replace one-shot startup recovery effect with polling interval
  that retries every 2s until providers and agents load
- fix optimistic bridge to avoid re-render loop (stable ref wrappers)

* chore: update tauri to 2.10.3 and all plugins to latest

- tauri 2.9.4 → 2.10.3
- tauri-build 2.5.3 → 2.5.6
- tauri-plugin-dialog 2.4.2 → 2.6.0
- tauri-plugin-log 2.7.1 → 2.8.0
- tauri-plugin-shell 2.3.3 → 2.3.5
- tauri-plugin-updater 2 (floating) → 2.10.0 (pinned)
- @tauri-apps/api ^2.9.0 → ^2.10.1
- wry 0.53.5 → 0.54.4 (transitive)

* refactor: decouple web server index orchestration runtimes

* fix: align VS Code runtime behavior with web and reduce draft view CPU load

- Queue VS Code bridge and SSE startup requests until API readiness to avoid false bootstrap failures
- Make agent manager actions directory-aware and remove real worktrees with safer partial-failure handling
- Replace heavy logo animation path with a lightweight pulse to cut draft-session CPU usage

* fix: restore auto-selected file sending in chat input

- Send server-selected files as proper file URLs in the message payload
- Include server-backed attachments in submit flow instead of dropping them
- Restore queued-message attachments through the refactored input store

* fix: restore session model selection consistently on session switch

- Restore agent, model, and variant from the latest loaded user message for each session
- Wait for session messages before applying restored selections to avoid stale or missing state
- Remove legacy session-choice inference paths that caused overlap and instability

* fix: restore permission replies and auto-accept across sessions

- Scope permission and question replies to the target session directory so answers take effect reliably
- Make permission auto-accept immediately handle pending requests and react to new permission prompts
- Keep parent-session handling working for child-session requests through the shared response path

* feat: add reusable fuzzy branch fuzzy-search helper and dialog integration (#798)

* feat: add reusable fuzzy branch search for worktrees

* chore: drop planning docs from feature branch

* feat: make worktree branch refresh manual

* feat: add configurable session retention action

* refactor: centralize global session state in ui store

* fix: cancel debounced permission push after reply

* docs: clarify global and directory session store architecture

* docs: refine agent development rules and session activity guidance

- Clarify agent code of conduct and durable development patterns
- Add explicit shared-store rerender and live-state guidance
- Narrow session activity fallback to avoid stale working state

* chore: updated .gitignore

---------

Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
Bohdan Triapitsyn
2026-03-31 18:47:00 +03:00
committed by GitHub
co-authored by Iuliia Ivashko
parent 8dfe833faf
commit c9e31a0e6c
245 changed files with 31986 additions and 32683 deletions
+112 -131
View File
@@ -1,10 +1,8 @@
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 { ChatInput } from './ChatInput';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { Skeleton } from '@/components/ui/skeleton';
import ChatEmptyState from './ChatEmptyState';
@@ -14,10 +12,10 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
import { useChatTimelineController } from './hooks/useChatTimelineController';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
import { useTimelineStaging } from '@/hooks/useTimelineStaging';
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,6 +24,19 @@ 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 {
useSessionMessageRecords,
useSessions,
useDirectorySync,
useSessionStatus,
} from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { getAllSyncSessions } from '@/sync/sync-refs';
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
const EMPTY_QUESTIONS: QuestionRequest[] = [];
@@ -71,101 +82,97 @@ 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,
}))
// 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, stickyUserHeader, chatRenderMode } = useUIStore();
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],
),
);
// Messages from sync system
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '');
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
// Sessions from sync system
const sessions = useSessions();
// 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<string, PermissionRequest[]>();
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<string, QuestionRequest[]>();
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 historyMeta = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.sessionHistoryMeta.get(currentSessionId) ?? null : null),
[currentSessionId]
)
);
// 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 streamingMessageId = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.streamingMessageIds.get(currentSessionId) ?? null : null),
[currentSessionId]
)
);
const sessionStatusForCurrent = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.sessionStatus?.get(currentSessionId) ?? IDLE_SESSION_STATUS : IDLE_SESSION_STATUS),
[currentSessionId]
)
);
const hasSessionMessagesEntry = useSessionStore(
React.useCallback((state) => (currentSessionId ? state.messages.has(currentSessionId) : false), [currentSessionId])
);
const hasSessionMessagesEntry = sessionMessages.length > 0 || (currentSessionId ? sync.hasMore(currentSessionId) : false);
const { isMobile } = useDeviceInfo();
const draftOpen = Boolean(newSessionDraft?.open);
@@ -173,24 +180,18 @@ export const ChatContainer: React.FC = () => {
const messageListRef = React.useRef<MessageListHandle | null>(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;
setCurrentSession(parentSession.id);
}, [parentSession, setCurrentSession]);
const returnToParentButton = parentSession ? (
@@ -219,13 +220,15 @@ 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,
scrollToBottom,
releasePinnedScroll,
isPinned,
isOverflowing,
isProgrammaticFollowActive,
@@ -238,16 +241,20 @@ export const ChatContainer: React.FC = () => {
isSyncing,
isMobile,
chatRenderMode,
messageStreamStates,
sessionPermissions: sessionBlockingCards,
onActiveTurnChange: (turnId) => {
activeTurnChangeRef.current(turnId);
},
onActiveTurnChange: handleActiveTurnChange,
});
// Deferred timeline staging — renders 1 message on first paint,
// adds 3 per rAF frame to avoid blocking.
const { stagedMessages } = useTimelineStaging({
sessionKey: currentSessionId ?? '',
messages: sessionMessages,
});
const timelineController = useChatTimelineController({
sessionId: currentSessionId,
messages: sessionMessages,
messages: stagedMessages,
historyMeta,
scrollRef,
messageListRef,
@@ -272,16 +279,11 @@ export const ChatContainer: React.FC = () => {
});
React.useEffect(() => {
if (typeof window === 'undefined' || !currentSessionId) {
return;
}
if (typeof window === 'undefined' || !currentSessionId) return;
const handleSessionReselected = (event: Event) => {
const customEvent = event as CustomEvent<string>;
if (customEvent.detail !== currentSessionId) {
return;
}
if (customEvent.detail !== currentSessionId) return;
resumeToBottomInstant();
};
@@ -293,9 +295,7 @@ export const ChatContainer: React.FC = () => {
React.useLayoutEffect(() => {
const container = scrollRef.current;
if (!container) {
return;
}
if (!container) return;
const updateChatScrollHeight = () => {
container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`);
@@ -329,23 +329,15 @@ export const ChatContainer: React.FC = () => {
};
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
const hasHistoryMetadata = React.useMemo(() => {
return Boolean(historyMeta);
}, [historyMeta]);
const hasHistoryMetadata = Boolean(historyMeta);
const isSessionHydrating =
Boolean(currentSessionId)
&& (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true);
React.useEffect(() => {
if (!currentSessionId) {
return;
}
const hasSessionMessages = hasSessionMessagesEntry;
if (hasSessionMessages && hasHistoryMetadata) {
return;
}
if (!currentSessionId) return;
if (hasSessionMessagesEntry && hasHistoryMetadata) return;
const load = async () => {
await loadMessages(currentSessionId).finally(() => {
@@ -523,6 +515,9 @@ export const ChatContainer: React.FC = () => {
<ScrollShadow
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
ref={scrollRef}
style={(timelineController.pendingRevealWork || timelineController.isLoadingOlder)
? { overflowAnchor: 'none' }
: undefined}
observeMutations={false}
hideTopShadow={isMobile && stickyUserHeader}
data-scroll-shadow="true"
@@ -569,20 +564,6 @@ export const ChatContainer: React.FC = () => {
)}
<ChatInput scrollToBottom={scrollToBottom} />
</div>
<TimelineDialog
open={isTimelineDialogOpen}
onOpenChange={setTimelineDialogOpen}
onScrollToMessage={(messageId) => {
releasePinnedScroll();
return navigation.scrollToMessageId(messageId, { behavior: 'smooth', updateHash: false });
}}
onScrollByTurnOffset={(offset) => {
releasePinnedScroll();
void navigation.scrollByTurnOffset(offset);
}}
onResumeToLatest={navigation.resumeToLatest}
/>
</div>
);
};
+94 -59
View File
@@ -16,12 +16,16 @@ 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 { useSessionMessageRecords } from '@/sync/sync-context';
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
import { appendInlineComments } from '@/lib/messages/inlineComments';
import { AttachedFilesList } from './FileAttachment';
@@ -40,8 +44,7 @@ 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';
@@ -81,6 +84,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('\\\\')
@@ -273,7 +298,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const initialSessionIdRef = React.useRef<string | null>(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) {
@@ -313,25 +338,32 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
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 newSessionDraft = useSessionStore((state) => state.newSessionDraft);
// TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sendMessage = React.useRef((...args: any[]) =>
Promise.resolve((useSessionUIStore.getState().sendMessage as (...a: unknown[]) => unknown)(...args)),
).current;
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const newSessionDraft = useSessionUIStore((s) => s.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);
const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
const attachedFiles = useSessionStore((state) => state.attachedFiles);
const addAttachedFile = useSessionStore((state) => state.addAttachedFile);
const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles);
const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection);
const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText);
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
const pendingInputText = useSessionStore((state) => state.pendingInputText);
const consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts);
const currentManagementSessionId = useSessionManagementStore((state) => state.currentSessionId);
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
const availableWorktreesByProject = useSessionUIStore((s) => s.availableWorktreesByProject);
const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId);
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
const attachedFiles = useInputStore((s) => s.attachedFiles);
const addAttachedFile = useInputStore((s) => s.addAttachedFile);
const clearAttachedFiles = useInputStore((s) => s.clearAttachedFiles);
const saveSessionAgentSelection = useSelectionStore((s) => s.saveSessionAgentSelection);
const consumePendingInputText = useInputStore((s) => s.consumePendingInputText);
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
const pendingInputText = useInputStore((s) => s.pendingInputText);
const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts);
const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort);
const abortCurrentOperation = React.useCallback(
(sessionIdOverride?: string) => sessionActions.abortCurrentOperation(sessionIdOverride ?? currentSessionId ?? ''),
[currentSessionId],
);
const currentManagementSessionId = currentSessionId;
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
@@ -339,7 +371,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
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 { isMobile, inputBarOffset, isKeyboardOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore();
const { working } = useAssistantStatus();
const { git: runtimeGit } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
@@ -351,10 +383,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const isDesktopExpanded = isExpandedInput && !isMobile;
const chatInputRadius = 'var(--radius-lg)';
const sendableAttachedFiles = React.useMemo(
() => attachedFiles.filter((file) => file.source !== 'server'),
[attachedFiles],
);
const sendableAttachedFiles = attachedFiles;
const hasInlineMentionForHighlight = React.useMemo(() => {
if (!message || !message.includes('@') || inputMode === 'shell') {
@@ -426,8 +455,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const sanitizeAttachmentsForSend = React.useCallback(
(files: AttachedFile[] | undefined): AttachedFile[] => (files ?? [])
.filter((file) => file.source !== 'server')
.map((file) => ({ ...file })),
.map((file) => ({
...file,
dataUrl: file.source === 'server' && file.serverPath
? toServerFileUrl(file.serverPath)
: file.dataUrl,
})),
[],
);
@@ -498,7 +531,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
filename,
mimeType: 'text/plain',
size: 0,
dataUrl: normalizedServerPath,
dataUrl: toServerFileUrl(normalizedServerPath),
source: 'server',
serverPath: normalizedServerPath,
});
@@ -565,15 +598,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
// User message history for up/down arrow navigation
// Get raw messages from store (stable reference)
const sessionMessages = useMessageStore(
React.useCallback(
(state) => (currentSessionId ? state.messages.get(currentSessionId) : undefined),
[currentSessionId]
)
);
const sessionMessages = useSessionMessageRecords(currentSessionId ?? "");
// Derive user message history with useMemo to avoid infinite re-renders
const userMessageHistory = React.useMemo(() => {
if (!sessionMessages) return [];
if (!sessionMessages || !currentSessionId) return [];
return sessionMessages
.filter((m) => m.info.role === 'user')
.map((m) => {
@@ -585,7 +613,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
})
.filter((text) => text.length > 0)
.reverse(); // Most recent first
}, [sessionMessages]);
}, [sessionMessages, currentSessionId]);
// Keep messageRef in sync with message state
React.useEffect(() => {
@@ -866,6 +894,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
addToQueue(currentSessionId, {
content: messageToQueue,
attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined,
sendConfig: currentProviderId && currentModelId ? {
providerID: currentProviderId,
modelID: currentModelId,
agent: currentAgentName ?? undefined,
variant: currentVariant ?? undefined,
} : undefined,
});
// Clear input and attachments
@@ -877,7 +911,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (!isMobile) {
textareaRef.current?.focus();
}
}, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]);
}, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
const handleSubmit = async (options?: SubmitOptions) => {
const queuedOnly = options?.queuedOnly ?? false;
@@ -1037,25 +1071,26 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
.split(/\s+/)[0]
?.toLowerCase();
// NEW: /undo - revert to last message (populates input with reverted message text)
if (commandName === 'undo' && currentSessionId) {
await useSessionStore.getState().handleSlashUndo(currentSessionId);
// Don't clear message - pendingInputText will populate it with reverted message
await useSessionUIStore.getState().handleSlashUndo(currentSessionId);
scrollToBottom?.({ instant: true, force: true });
return; // Don't send to assistant
return;
}
// NEW: /redo - unrevert or partial redo (populates input with message text)
else if (commandName === 'redo' && currentSessionId) {
await useSessionStore.getState().handleSlashRedo(currentSessionId);
// Don't clear message - pendingInputText will populate it
await useSessionUIStore.getState().handleSlashRedo(currentSessionId);
scrollToBottom?.({ instant: true, force: true });
return; // Don't send to assistant
return;
}
// NEW: /timeline - open timeline dialog
else if (commandName === 'timeline' && currentSessionId) {
setTimelineDialogOpen(true);
setMessage('');
return; // Don't send to assistant
else if (commandName === 'compact' && currentSessionId) {
const { opencodeClient } = await import('@/lib/opencode/client');
const sdk = opencodeClient.getSdkClient();
const configState = useConfigStore.getState();
await sdk.session.summarize({
sessionID: currentSessionId,
modelID: configState.currentModelId || '',
providerID: configState.currentProviderId || '',
});
return;
}
}
@@ -1108,21 +1143,21 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) {
toast.error('Attachments are too large to send. Please try reducing the number or size of images.');
if (allAttachments.length > 0) {
useFileStore.setState({ attachedFiles: allAttachments });
useInputStore.setState({ attachedFiles: allAttachments });
}
return;
}
if (isSoftNetworkError) {
if (allAttachments.length > 0) {
useFileStore.setState({ attachedFiles: allAttachments });
useInputStore.setState({ attachedFiles: allAttachments });
toast.error('Failed to send attachments. Try fewer files or smaller images.');
}
return;
}
if (allAttachments.length > 0) {
useFileStore.setState({ attachedFiles: allAttachments });
useInputStore.setState({ attachedFiles: allAttachments });
}
toast.error(rawMessage || 'Message failed to send. Attachments restored.');
});
@@ -2560,7 +2595,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (!selectedDraftDirectory || !selectedDraftBranchIsKnown) {
return;
}
useSessionStore.getState().setDraftPreserveDirectoryOverride(false);
useSessionUIStore.getState().setDraftPreserveDirectoryOverride(false);
}, [newSessionDraft?.open, newSessionDraft?.preserveDirectoryOverride, selectedDraftBranchIsKnown, selectedDraftDirectory]);
const shouldShowDraftBranchSelector = React.useMemo(() => {
@@ -2574,7 +2609,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}, [isDiscoveringDraftBranches, projectRootBranchOption, worktreeBranchOptions.length]);
const handleDraftProjectChange = React.useCallback((projectId: string) => {
const draft = useSessionStore.getState().newSessionDraft;
const draft = useSessionUIStore.getState().newSessionDraft;
if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) {
return;
}
@@ -2592,7 +2627,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]);
const handleDraftDirectoryChange = React.useCallback((directory: string) => {
const draft = useSessionStore.getState().newSessionDraft;
const draft = useSessionUIStore.getState().newSessionDraft;
if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) {
return;
}
+53 -37
View File
@@ -4,10 +4,13 @@ import { useShallow } from 'zustand/react/shallow';
import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme';
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useContextStore } from '@/stores/contextStore';
import { useStreamingStore } from '@/sync/streaming';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
import { useDeviceInfo } from '@/lib/device';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
@@ -26,6 +29,8 @@ import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/li
import type { TurnGroupingContext } from './lib/turns/types';
import { copyTextToClipboard } from '@/lib/clipboard';
import { FadeInOnReveal } from './message/FadeInOnReveal';
import { streamPerfCount } from '@/stores/utils/streamDebug';
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessageInfoEqual, areRenderRelevantPartsEqual } from './message/renderCompare';
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
@@ -123,6 +128,8 @@ interface ChatMessageProps {
animationHandlers?: AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
turnGroupingContext?: TurnGroupingContext;
assistantHeaderMessageId?: string;
isInActiveTurn?: boolean;
animateUserOnMount?: boolean;
onUserAnimationConsumed?: (messageId: string) => void;
}
@@ -134,6 +141,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onContentChange,
animationHandlers,
turnGroupingContext,
assistantHeaderMessageId,
isInActiveTurn = false,
animateUserOnMount = false,
onUserAnimationConsumed,
}) => {
@@ -141,36 +150,31 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const { currentTheme } = useThemeSystem();
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
const sessionState = useSessionStore(
useShallow((state) => ({
lifecyclePhase: state.messageStreamStates.get(message.info.id)?.phase ?? null,
isStreamingMessage: (() => {
const sessionId =
(message.info as { sessionID?: string }).sessionID ??
state.currentSessionId ??
null;
if (!sessionId) return false;
return (state.streamingMessageIds.get(sessionId) ?? null) === message.info.id;
})(),
currentSessionId: state.currentSessionId,
getAgentModelForSession: state.getAgentModelForSession,
getSessionModelSelection: state.getSessionModelSelection,
revertToMessage: state.revertToMessage,
forkFromMessage: state.forkFromMessage,
}))
);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const streamState = useStreamingStore((s) => s.messageStreamStates.get(message.info.id));
const lifecyclePhase = isInActiveTurn ? (streamState?.phase ?? null) : null;
const {
lifecyclePhase,
isStreamingMessage,
currentSessionId,
getAgentModelForSession,
getSessionModelSelection,
revertToMessage,
forkFromMessage,
} = sessionState;
const msgSessionId = (message.info as { sessionID?: string }).sessionID ?? currentSessionId ?? null;
const streamingMsgForSession = useStreamingStore((s) => msgSessionId ? s.streamingMessageIds.get(msgSessionId) ?? null : null);
const isStreamingMessage = isInActiveTurn ? streamingMsgForSession === message.info.id : false;
const hasActiveStreamInSession = typeof streamingMsgForSession === 'string' && streamingMsgForSession.length > 0;
const providers = useConfigStore((state) => state.providers);
const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession);
const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection);
const revertToMessage = sessionActions.revertToMessage;
const forkFromMessage = sessionActions.forkFromMessage;
streamPerfCount('ui.chat_message.render');
if (isStreamingMessage) {
streamPerfCount('ui.chat_message.render.streaming');
} else if (hasActiveStreamInSession) {
streamPerfCount('ui.chat_message.render.static_during_stream');
if (!isInActiveTurn) {
streamPerfCount('ui.chat_message.render.static_outside_active_turn_during_stream');
}
}
const providers = useConfigStore.getState().providers;
const { showReasoningTraces, stickyUserHeader, chatRenderMode, showExpandedBashTools, showExpandedEditTools } = useUIStore(
useShallow((state) => ({
showReasoningTraces: state.showReasoningTraces,
@@ -211,11 +215,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const sessionId = message.info.sessionID;
// Subscribe to context changes so badges update immediately on mode switches.
// Keep non-active-turn rows detached from context-store churn.
const { currentContextAgent, savedSessionAgentSelection } = useContextStore(
useShallow((state) => ({
currentContextAgent: sessionId ? state.currentAgentContext.get(sessionId) : undefined,
savedSessionAgentSelection: sessionId ? state.sessionAgentSelections.get(sessionId) : undefined,
currentContextAgent: isInActiveTurn && sessionId ? state.currentAgentContext.get(sessionId) : undefined,
savedSessionAgentSelection: isInActiveTurn && sessionId ? state.sessionAgentSelections.get(sessionId) : undefined,
}))
);
@@ -607,7 +611,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}, [message.info.id]);
React.useEffect(() => {
const headerMessageId = turnGroupingContext?.headerMessageId;
const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId;
if (isUser || !headerMessageId || headerMessageId !== message.info.id) {
return;
}
@@ -616,13 +620,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
if (isCurrentlyStreaming) {
setHasStartedStreamingHeader(true);
}
}, [isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]);
}, [assistantHeaderMessageId, isUser, message.info.id, streamPhase, turnGroupingContext?.headerMessageId]);
const shouldShowHeader = React.useMemo(() => {
if (isUser) return true;
// Use turn grouping context if available for more precise control
const headerMessageId = turnGroupingContext?.headerMessageId;
const headerMessageId = assistantHeaderMessageId ?? turnGroupingContext?.headerMessageId;
if (headerMessageId) {
// For turn grouping: only show header for the first assistant message in the turn
const isFirstAssistantInTurn = message.info.id === headerMessageId;
@@ -644,7 +648,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
// Ungrouped fallback path: always show assistant header.
return true;
}, [hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]);
}, [assistantHeaderMessageId, hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]);
const handleCopyCode = React.useCallback((code: string) => {
void copyTextToClipboard(code).then((result) => {
@@ -1086,6 +1090,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
)}
<MessageBody
sessionId={message.info.sessionID}
messageId={message.info.id}
parts={visibleParts}
isUser={isUser}
@@ -1131,4 +1136,15 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
);
};
export default React.memo(ChatMessage);
export default React.memo(ChatMessage, (prev, next) => {
return areRenderRelevantMessageInfoEqual(prev.message.info, next.message.info)
&& areRenderRelevantPartsEqual(prev.message.parts, next.message.parts)
&& areOptionalRenderRelevantMessagesEqual(prev.previousMessage, next.previousMessage)
&& areOptionalRenderRelevantMessagesEqual(prev.nextMessage, next.nextMessage)
&& prev.onContentChange === next.onContentChange
&& prev.turnGroupingContext === next.turnGroupingContext
&& prev.assistantHeaderMessageId === next.assistantHeaderMessageId
&& prev.isInActiveTurn === next.isInActiveTurn
&& prev.animateUserOnMount === next.animateUserOnMount
&& prev.onUserAnimationConsumed === next.onUserAnimationConsumed;
});
@@ -1,10 +1,10 @@
import React from 'react';
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine, RiTimeLine } from '@remixicon/react';
import { RiCommandLine, RiFileLine, RiFlashlightLine, RiRefreshLine, RiScissorsLine, RiTerminalBoxLine, RiArrowGoBackLine, RiArrowGoForwardLine } from '@remixicon/react';
import { cn, fuzzyMatch } from '@/lib/utils';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessages } from '@/sync/sync-context';
import { useCommandsStore } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
interface CommandInfo {
@@ -42,16 +42,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
onTabSelect,
style,
}, ref) => {
const { hasMessagesInCurrentSession, currentSessionId } = useSessionStore(
useShallow((state) => {
const sessionId = state.currentSessionId;
const messageCount = sessionId ? (state.messages.get(sessionId)?.length ?? 0) : 0;
return {
hasMessagesInCurrentSession: messageCount > 0,
currentSessionId: sessionId,
};
})
);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionMessages = useSessionMessages(currentSessionId ?? '');
const hasMessagesInCurrentSession = sessionMessages.length > 0;
const hasSession = Boolean(currentSessionId);
const [commands, setCommands] = React.useState<CommandInfo[]>([]);
@@ -114,7 +107,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
? [
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
{ name: 'timeline', description: 'Jump to a specific message', isBuiltIn: true },
]
: []
),
@@ -158,7 +150,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
? [
{ name: 'undo', description: 'Undo the last message', isBuiltIn: true },
{ name: 'redo', description: 'Redo previously undone messages', isBuiltIn: true },
{ name: 'timeline', description: 'Jump to a specific message', isBuiltIn: true },
]
: []
),
@@ -233,8 +224,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
return <RiArrowGoBackLine className="h-3.5 w-3.5 text-orange-500" />;
case 'redo':
return <RiArrowGoForwardLine className="h-3.5 w-3.5 text-orange-500" />;
case 'timeline':
return <RiTimeLine className="h-3.5 w-3.5 text-blue-500" />;
case 'compact':
return <RiScissorsLine className="h-3.5 w-3.5 text-purple-500" />;
case 'test':
@@ -1,6 +1,7 @@
import React, { useRef, memo } from 'react';
import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiGithubLine, RiGitPullRequestLine } from '@remixicon/react';
import { useSessionStore, type AttachedFile } from '@/stores/useSessionStore';
import { useInputStore } from '@/sync/input-store';
import type { AttachedFile } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
@@ -13,7 +14,7 @@ import type { ToolPopupContent } from './message/types';
export const FileAttachmentButton = memo(() => {
const fileInputRef = useRef<HTMLInputElement>(null);
const { addAttachedFile } = useSessionStore();
const { addAttachedFile } = useInputStore();
const { isMobile } = useUIStore();
const isVSCodeRuntime = useIsVSCodeRuntime();
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
@@ -255,7 +256,7 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
FileChip.displayName = 'FileChip';
export const AttachedFilesList = memo(() => {
const { attachedFiles, removeAttachedFile } = useSessionStore();
const { attachedFiles, removeAttachedFile } = useInputStore();
const localFiles = attachedFiles.filter((file) => file.source !== 'server');
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,8 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { getAgentDisplayName } from './mobileControlsUtils';
import { getAgentColor } from '@/lib/agentColors';
@@ -16,8 +17,8 @@ const LONG_PRESS_MS = 500;
// NOTE: Use pointer events instead of onClick to keep soft keyboard open on mobile
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAgent, onOpenAgentPanel, className }) => {
const { currentAgentName, getVisibleAgents } = useConfigStore();
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessionAgentName = useSessionStore((state) =>
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionAgentName = useSelectionStore((state) =>
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
);
@@ -1,5 +1,7 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessions, useAllSessionStatuses } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -52,6 +54,7 @@ import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { useDrawerSwipe } from '@/hooks/useDrawerSwipe';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useNotificationStore } from '@/sync/notification-store';
interface MobileSessionStatusBarProps {
onSessionSwitch?: (sessionId: string) => void;
@@ -74,9 +77,10 @@ const normalize = (value: string): string => {
function useSessionGrouping(
sessions: Session[],
sessionStatus: Map<string, { type: string }> | undefined,
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined
sessionStatus: Record<string, { type: string }> | undefined
) {
const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
const parentChildMap = React.useMemo(() => {
const map = new Map<string, Session[]>();
const allIds = new Set(sessions.map((s) => s.id));
@@ -91,7 +95,7 @@ function useSessionGrouping(
}, [sessions]);
const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => {
const status = sessionStatus?.get(sessionId);
const status = sessionStatus?.[sessionId];
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
return 'idle';
}, [sessionStatus]);
@@ -126,7 +130,7 @@ function useSessionGrouping(
topLevel.forEach((session) => {
const statusType = getStatusType(session.id);
const hasRunning = hasRunningChildren(session.id);
const attention = sessionAttentionStates?.get(session.id)?.needsAttention ?? false;
const attention = (unseenCounts[session.id] ?? 0) > 0;
const enriched: SessionWithStatus = {
...session,
@@ -155,28 +159,27 @@ function useSessionGrouping(
viewed.sort(sortByUpdated);
return [...running, ...viewed];
}, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, sessionAttentionStates]);
}, [sessions, getStatusType, hasRunningChildren, getRunningChildrenCount, getChildIndicators, unseenCounts]);
const totalRunning = processedSessions.reduce((sum, s) => {
const selfRunning = s._statusType !== 'idle' ? 1 : 0;
return sum + selfRunning + (s._runningChildrenCount ?? 0);
}, 0);
const totalUnread = processedSessions.filter((s) => sessionAttentionStates?.get(s.id)?.needsAttention ?? false).length;
const totalUnread = processedSessions.filter((s) => (unseenCounts[s.id] ?? 0) > 0).length;
return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length };
}
function useSessionHelpers(
agents: Array<{ name: string }>,
sessionStatus: Map<string, { type: string }> | undefined,
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined
sessionStatus: Record<string, { type: string }> | undefined
) {
const getSessionAgentName = React.useCallback((session: Session): string => {
const agent = (session as { agent?: string }).agent;
if (agent) return agent;
const sessionAgentSelection = useSessionStore.getState().getSessionAgentSelection(session.id);
const sessionAgentSelection = useSelectionStore.getState().getSessionAgentSelection(session.id);
if (sessionAgentSelection) return sessionAgentSelection;
return agents[0]?.name ?? 'agent';
@@ -189,14 +192,14 @@ function useSessionHelpers(
}, []);
const isRunning = React.useCallback((sessionId: string): boolean => {
const status = sessionStatus?.get(sessionId);
const status = sessionStatus?.[sessionId];
return status?.type === 'busy' || status?.type === 'retry';
}, [sessionStatus]);
// Use server-authoritative attention state instead of local activity state
const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
const needsAttention = React.useCallback((sessionId: string): boolean => {
return sessionAttentionStates?.get(sessionId)?.needsAttention ?? false;
}, [sessionAttentionStates]);
return (unseenCounts[sessionId] ?? 0) > 0;
}, [unseenCounts]);
return { getSessionAgentName, getSessionTitle, isRunning, needsAttention };
}
@@ -204,17 +207,16 @@ function useSessionHelpers(
// Hook to calculate project status indicators
function useProjectStatus(
sessions: Session[],
sessionStatus: Map<string, { type: string }> | undefined,
sessionAttentionStates: Map<string, { needsAttention: boolean }> | undefined,
sessionStatus: Record<string, { type: string }> | undefined,
currentSessionId: string | null
) {
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory);
const notifUnseenCounts = useNotificationStore((s) => s.index.session.unseenCount);
const projectStatusMap = React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => {
const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => {
const status = sessionStatus?.get(sessionId);
const status = sessionStatus?.[sessionId];
if (status?.type === 'busy' || status?.type === 'retry') return status.type;
return 'idle';
};
@@ -241,7 +243,7 @@ function useProjectStatus(
let hasUnread = false;
for (const dir of dirs) {
const list = sessionsByDirectory.get(dir) ?? getSessionsByDirectory(dir);
const list = getSessionsByDirectory(dir);
for (const session of list) {
if (!session?.id || seen.has(session.id)) {
continue;
@@ -253,7 +255,7 @@ function useProjectStatus(
hasRunning = true;
}
if (session.id !== currentSessionId && sessionAttentionStates?.get(session.id)?.needsAttention === true) {
if (session.id !== currentSessionId && (notifUnseenCounts[session.id] ?? 0) > 0) {
hasUnread = true;
}
@@ -267,7 +269,7 @@ function useProjectStatus(
}
return { hasRunning, hasUnread };
}, [sessionsByDirectory, getSessionsByDirectory, availableWorktreesByProject, sessionStatus, sessionAttentionStates, currentSessionId]);
}, [getSessionsByDirectory, availableWorktreesByProject, sessionStatus, notifUnseenCounts, currentSessionId]);
return projectStatusMap;
}
@@ -1290,7 +1292,7 @@ function ExpandedView({
const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(null);
const [hasMeasured, setHasMeasured] = React.useState(false);
const { handleTouchStart, handleTouchMove, handleTouchEnd } = useDrawerSwipe();
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
React.useEffect(() => {
if (containerRef.current && !hasMeasured && !isExpanded) {
@@ -1429,13 +1431,12 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
cornerRadius,
}) => {
const { currentTheme } = useThemeSystem();
const sessions = useSessionStore((state) => state.sessions);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessionStatus = useSessionStore((state) => state.sessionStatus);
const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates);
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const getContextUsage = useSessionStore((state) => state.getContextUsage);
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionStatus = useAllSessionStatuses();
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
const agents = useConfigStore((state) => state.agents);
const { getCurrentModel } = useConfigStore();
const { isMobile, showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore();
@@ -1452,9 +1453,9 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
// Directory store
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus, sessionAttentionStates);
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus, sessionAttentionStates);
const getProjectStatus = useProjectStatus(sessions, sessionStatus, sessionAttentionStates, currentSessionId);
const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus);
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus);
const getProjectStatus = useProjectStatus(sessions, sessionStatus, currentSessionId);
const currentSession = sessions.find((s) => s.id === currentSessionId);
const currentSessionTitle = currentSession
+154 -211
View File
@@ -47,7 +47,10 @@ import { getEditModeColors } from '@/lib/permissions/editModeColors';
import { cn, fuzzyMatch } from '@/lib/utils';
import { useContextStore } from '@/stores/contextStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useDirectorySync, useSessionMessages } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { useUIStore } from '@/stores/useUIStore';
import { useModelLists } from '@/hooks/useModelLists';
import { useIsTextTruncated } from '@/hooks/useIsTextTruncated';
@@ -314,20 +317,23 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const agents = getVisibleAgents();
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession);
const sync = useSync();
const {
currentSessionId,
messages,
getSessionModelSelection,
saveSessionModelSelection,
saveSessionAgentSelection,
saveAgentModelForSession,
getAgentModelForSession,
saveAgentModelVariantForSession,
getAgentModelVariantForSession,
analyzeAndSaveExternalSessionChoices,
} = useSessionStore();
} = useSelectionStore();
const contextHydrated = useContextStore((state) => state.hasHydrated);
const sessionSavedAgentName = useContextStore((state) =>
const sessionSavedAgentName = useSelectionStore((state) =>
currentSessionId ? state.sessionAgentSelections.get(currentSessionId) ?? null : null
);
@@ -563,31 +569,45 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
];
const prevAgentNameRef = React.useRef<string | undefined>(undefined);
const latestLoadedUserChoiceRestoreRef = React.useRef<string | null>(null);
const currentSessionMessageCount = currentSessionId ? (messages.get(currentSessionId)?.length ?? -1) : -1;
const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined;
const hasCurrentSessionMessagesEntry = useDirectorySync(
React.useCallback(
(state) => (currentSessionId ? state.message[currentSessionId] !== undefined : false),
[currentSessionId],
),
currentSessionDirectory ?? undefined,
);
const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined);
const latestLoadedUserChoice = React.useMemo(() => {
for (let i = currentSessionMessagesFromSync.length - 1; i >= 0; i -= 1) {
const message = currentSessionMessagesFromSync[i] as typeof currentSessionMessagesFromSync[number] & {
model?: { providerID?: string; modelID?: string };
variant?: string;
mode?: string;
};
if (message.role !== 'user') {
continue;
}
const sessionInitializationRef = React.useRef<{
sessionId: string;
resolved: boolean;
inFlight: boolean;
} | null>(null);
const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0
? message.model.providerID
: undefined;
const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0
? message.model.modelID
: undefined;
const agent = typeof message.agent === 'string' && message.agent.trim().length > 0
? message.agent
: (typeof message.mode === 'string' && message.mode.trim().length > 0 ? message.mode : undefined);
const variant = typeof message.variant === 'string' && message.variant.trim().length > 0
? message.variant
: undefined;
// If we have an explicit per-session agent selection (eg. server-injected mode switch),
// treat the session as resolved and don't run inference/fallback that could cause flicker.
React.useEffect(() => {
if (!currentSessionId) {
return;
return { id: message.id, agent, providerID, modelID, variant };
}
const refState = sessionInitializationRef.current;
if (!refState || refState.sessionId !== currentSessionId) {
return;
}
if (sessionSavedAgentName && agents.some((agent) => agent.name === sessionSavedAgentName)) {
refState.resolved = true;
refState.inFlight = false;
}
}, [agents, currentSessionId, sessionSavedAgentName]);
return null;
}, [currentSessionMessagesFromSync]);
const tryApplyModelSelection = React.useCallback(
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
@@ -606,21 +626,93 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return 'model-missing';
}
const providerMatches = currentProviderId === providerId;
const modelMatches = currentModelId === modelId;
if (providerMatches && modelMatches) {
return 'applied';
}
setProvider(providerId);
setModel(modelId);
if (currentSessionId && agentName) {
saveAgentModelForSession(currentSessionId, agentName, providerId, modelId);
if (currentSessionId) {
saveSessionModelSelection(currentSessionId, providerId, modelId);
if (agentName) {
saveAgentModelForSession(currentSessionId, agentName, providerId, modelId);
}
}
return 'applied';
},
[providers, setProvider, setModel, currentSessionId, saveAgentModelForSession],
[providers, currentProviderId, currentModelId, setProvider, setModel, currentSessionId, saveAgentModelForSession, saveSessionModelSelection],
);
React.useEffect(() => {
if (!currentSessionId) {
sessionInitializationRef.current = null;
latestLoadedUserChoiceRestoreRef.current = null;
return;
}
if (!contextHydrated || providers.length === 0 || !hasCurrentSessionMessagesEntry || !latestLoadedUserChoice?.providerID || !latestLoadedUserChoice.modelID) {
return;
}
const restoreKey = [
currentSessionId,
latestLoadedUserChoice.id,
latestLoadedUserChoice.agent ?? '',
latestLoadedUserChoice.providerID,
latestLoadedUserChoice.modelID,
latestLoadedUserChoice.variant ?? '',
].join('|');
if (latestLoadedUserChoiceRestoreRef.current === restoreKey) {
return;
}
if (latestLoadedUserChoice.agent && currentAgentName !== latestLoadedUserChoice.agent) {
setAgent(latestLoadedUserChoice.agent);
}
const applyResult = tryApplyModelSelection(
latestLoadedUserChoice.providerID,
latestLoadedUserChoice.modelID,
latestLoadedUserChoice.agent || currentAgentName || undefined,
);
if (applyResult !== 'applied') {
return;
}
if (latestLoadedUserChoice.agent) {
saveSessionAgentSelection(currentSessionId, latestLoadedUserChoice.agent);
saveAgentModelVariantForSession(
currentSessionId,
latestLoadedUserChoice.agent,
latestLoadedUserChoice.providerID,
latestLoadedUserChoice.modelID,
latestLoadedUserChoice.variant,
);
}
saveSessionModelSelection(currentSessionId, latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID);
latestLoadedUserChoiceRestoreRef.current = restoreKey;
}, [
currentSessionId,
currentAgentName,
contextHydrated,
providers,
hasCurrentSessionMessagesEntry,
latestLoadedUserChoice,
setAgent,
tryApplyModelSelection,
saveSessionAgentSelection,
saveAgentModelVariantForSession,
saveSessionModelSelection,
]);
React.useEffect(() => {
if (!currentSessionId) {
latestLoadedUserChoiceRestoreRef.current = null;
return;
}
@@ -628,31 +720,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return;
}
if (!sessionInitializationRef.current || sessionInitializationRef.current.sessionId !== currentSessionId) {
sessionInitializationRef.current = { sessionId: currentSessionId, resolved: false, inFlight: false };
}
const state = sessionInitializationRef.current;
if (!state || state.resolved || state.inFlight) {
return;
}
let isCancelled = false;
const finalize = () => {
if (isCancelled) {
return;
}
const refState = sessionInitializationRef.current;
if (refState && refState.sessionId === currentSessionId) {
refState.resolved = true;
refState.inFlight = false;
}
};
const applySavedSelections = (): 'resolved' | 'waiting' | 'continue' => {
const savedSessionModel = getSessionModelSelection(currentSessionId);
const savedAgentName = currentSessionId
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
? useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
: null;
if (savedAgentName) {
if (currentAgentName !== savedAgentName) {
@@ -668,9 +739,17 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
if (result === 'provider-missing') {
return 'waiting';
}
} else {
}
}
if (savedSessionModel) {
const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined);
if (result === 'applied') {
return 'resolved';
}
if (result === 'provider-missing') {
return 'waiting';
}
}
for (const agent of agents) {
@@ -683,7 +762,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
setAgent(agent.name);
}
const existingSelection = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, agent.name);
}
@@ -705,14 +784,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
const existingSelection = currentSessionId
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
? (useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
: null;
// If we already have a valid agent selected (often from server-injected mode switch),
// don't override it with a fallback.
const preferred =
(currentSessionId
? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
? (useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current)
: null) ||
currentAgentName;
if (preferred && agents.some((agent) => agent.name === preferred)) {
@@ -740,174 +819,38 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
};
const resolveSessionPreferences = async () => {
try {
const savedOutcome = applySavedSelections();
if (savedOutcome === 'resolved') {
finalize();
return;
}
if (savedOutcome === 'waiting') {
return;
}
const savedOutcome = applySavedSelections();
if (savedOutcome === 'resolved' || savedOutcome === 'waiting') {
return;
}
if (currentSessionMessageCount === -1) {
return;
}
if (currentSessionMessageCount > 0) {
state.inFlight = true;
try {
const discoveredChoices = await analyzeAndSaveExternalSessionChoices(currentSessionId, agents);
if (isCancelled) {
return;
}
if (discoveredChoices.size > 0) {
let latestAgent: string | null = null;
let latestTimestamp = -Infinity;
for (const [agentName, choice] of discoveredChoices) {
if (choice.timestamp > latestTimestamp) {
latestTimestamp = choice.timestamp;
latestAgent = agentName;
}
}
if (latestAgent) {
// If server/user already selected an agent for this session, don't override
// with heuristic inference mid-stream.
const latestSaved = useContextStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (latestSaved && latestSaved !== latestAgent) {
finalize();
return;
}
if (!latestSaved) {
saveSessionAgentSelection(currentSessionId, latestAgent);
}
if (currentAgentName !== latestAgent) {
setAgent(latestAgent);
}
const latestChoice = discoveredChoices.get(latestAgent);
if (latestChoice) {
const applyResult = tryApplyModelSelection(
latestChoice.providerId,
latestChoice.modelId,
latestAgent,
);
if (applyResult === 'applied') {
finalize();
return;
}
if (applyResult === 'provider-missing') {
return;
}
} else {
finalize();
return;
}
}
}
} catch (error) {
if (!isCancelled) {
console.error('[ModelControls] Error resolving session from messages:', error);
}
} finally {
const refState = sessionInitializationRef.current;
if (!isCancelled && refState && refState.sessionId === currentSessionId) {
refState.inFlight = false;
}
}
}
if (isCancelled) {
return;
}
applyFallbackAgent();
finalize();
} catch (error) {
if (!isCancelled) {
console.error('[ModelControls] Error in session switch:', error);
}
if (!hasCurrentSessionMessagesEntry) {
if (!sync.isLoading(currentSessionId)) {
void sync.syncSession(currentSessionId);
}
};
return;
}
resolveSessionPreferences();
if (latestLoadedUserChoice) {
return;
}
return () => {
isCancelled = true;
};
applyFallbackAgent();
}, [
currentSessionId,
currentSessionMessageCount,
hasCurrentSessionMessagesEntry,
latestLoadedUserChoice,
agents,
primaryAgents,
currentAgentName,
getSessionModelSelection,
getAgentModelForSession,
setAgent,
tryApplyModelSelection,
analyzeAndSaveExternalSessionChoices,
saveSessionAgentSelection,
contextHydrated,
providers,
sessionSavedAgentName,
]);
React.useEffect(() => {
if (!contextHydrated || !currentSessionId || providers.length === 0 || agents.length === 0) {
return;
}
const preferredAgent = sessionSavedAgentName || currentAgentName;
if (!preferredAgent) {
return;
}
const preferredSelection = getAgentModelForSession(currentSessionId, preferredAgent);
if (!preferredSelection) {
return;
}
const provider = providers.find(p => p.id === preferredSelection.providerId);
if (!provider) {
return;
}
const modelExists = Array.isArray(provider.models)
? provider.models.some((m: ProviderModel) => m.id === preferredSelection.modelId)
: false;
if (!modelExists) {
return;
}
const providerMatches = currentProviderId === preferredSelection.providerId;
const modelMatches = currentModelId === preferredSelection.modelId;
if (providerMatches && modelMatches) {
return;
}
if (preferredAgent !== currentAgentName) {
setAgent(preferredAgent);
}
tryApplyModelSelection(preferredSelection.providerId, preferredSelection.modelId, preferredAgent);
}, [
contextHydrated,
currentSessionId,
currentAgentName,
currentProviderId,
currentModelId,
providers,
agents,
getAgentModelForSession,
tryApplyModelSelection,
setAgent,
sessionSavedAgentName,
sync,
]);
React.useEffect(() => {
@@ -2,7 +2,9 @@ import React from 'react';
import { RiCheckLine, RiCloseLine, RiFileEditLine, RiGlobalLine, RiPencilAiLine, RiQuestionLine, RiTerminalBoxLine, RiTimeLine, RiToolsLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { PermissionRequest, PermissionResponse } from '@/types/permission';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import * as sessionActions from '@/sync/session-actions';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
@@ -62,15 +64,14 @@ export const PermissionCard: React.FC<PermissionCardProps> = ({
}) => {
const [isResponding, setIsResponding] = React.useState(false);
const [hasResponded, setHasResponded] = React.useState(false);
const { respondToPermission } = useSessionStore();
const isFromSubagent = useSessionStore(
React.useCallback((state) => {
const currentSessionId = state.currentSessionId;
if (!currentSessionId || permission.sessionID === currentSessionId) return false;
const sourceSession = state.sessions.find((session) => session.id === permission.sessionID);
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
}, [permission.sessionID])
);
const respondToPermission = sessionActions.respondToPermission;;
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const isFromSubagent = React.useMemo(() => {
if (!currentSessionId || permission.sessionID === currentSessionId) return false;
const sourceSession = sessions.find((session) => session.id === permission.sessionID);
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
}, [permission.sessionID, currentSessionId, sessions]);
const { currentTheme } = useThemeSystem();
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
@@ -2,7 +2,7 @@ import React from 'react';
import { RiCheckLine, RiCloseLine, RiTimeLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { PermissionRequest as PermissionRequestPayload, PermissionResponse } from '@/types/permission';
import { useSessionStore } from '@/stores/useSessionStore';
import * as sessionActions from '@/sync/session-actions';
interface PermissionRequestProps {
permission: PermissionRequestPayload;
@@ -15,7 +15,7 @@ export const PermissionRequest: React.FC<PermissionRequestProps> = ({
}) => {
const [isResponding, setIsResponding] = React.useState(false);
const [hasResponded, setHasResponded] = React.useState(false);
const { respondToPermission } = useSessionStore();
const respondToPermission = sessionActions.respondToPermission;;
const handleResponse = async (response: PermissionResponse) => {
setIsResponding(true);
@@ -4,7 +4,9 @@ import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import type { QuestionRequest } from '@/types/question';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import * as sessionActions from '@/sync/session-actions';
interface QuestionCardProps {
question: QuestionRequest;
@@ -14,15 +16,15 @@ type TabKey = string;
const SUMMARY_TAB = 'summary';
export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
const { respondToQuestion, rejectQuestion } = useSessionStore();
const isFromSubagent = useSessionStore(
React.useCallback((state) => {
const currentSessionId = state.currentSessionId;
if (!currentSessionId || question.sessionID === currentSessionId) return false;
const sourceSession = state.sessions.find((session) => session.id === question.sessionID);
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
}, [question.sessionID])
);
const respondToQuestion = sessionActions.respondToQuestion;
const rejectQuestion = sessionActions.rejectQuestion;;
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const isFromSubagent = React.useMemo(() => {
if (!currentSessionId || question.sessionID === currentSessionId) return false;
const sourceSession = sessions.find((session) => session.id === question.sessionID);
return Boolean(sourceSession?.parentID && sourceSession.parentID === currentSessionId);
}, [question.sessionID, currentSessionId, sessions]);
const [activeTab, setActiveTab] = React.useState<TabKey>('0');
const [isResponding, setIsResponding] = React.useState(false);
const [hasResponded, setHasResponded] = React.useState(false);
@@ -1,8 +1,8 @@
import React, { memo } from 'react';
import { RiCloseLine, RiMessage2Line } from '@remixicon/react';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useFileStore } from '@/stores/fileStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
interface QueuedMessageChipProps {
message: QueuedMessage;
@@ -67,7 +67,7 @@ interface QueuedMessageChipsProps {
const EMPTY_QUEUE: QueuedMessage[] = [];
export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsProps) => {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const queuedMessages = useMessageQueueStore(
React.useCallback(
(state) => {
@@ -84,10 +84,9 @@ export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsPro
const popped = popToInput(currentSessionId, message.id);
if (popped) {
// Restore attachments to file store if any
if (popped.attachments && popped.attachments.length > 0) {
const currentAttachments = useFileStore.getState().attachedFiles;
useFileStore.setState({
const currentAttachments = useInputStore.getState().attachedFiles;
useInputStore.setState({
attachedFiles: [...currentAttachments, ...popped.attachments]
});
}
@@ -1,7 +1,7 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useContextStore } from '@/stores/contextStore';
import { formatEffortLabel, getAgentDisplayName, getModelDisplayName } from './mobileControlsUtils';
@@ -19,7 +19,7 @@ export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) =>
getCurrentModelVariants,
getVisibleAgents,
} = useConfigStore();
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionAgentName = useContextStore((state) =>
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
);
+15 -15
View File
@@ -1,4 +1,5 @@
import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
import {
RiArrowDownSLine,
RiArrowUpDoubleLine,
@@ -9,8 +10,13 @@ import {
RiTimeLine,
} from "@remixicon/react";
import { cn } from "@/lib/utils";
import { useTodoStore, type TodoItem, type TodoPriority, type TodoStatus } from "@/stores/useTodoStore";
import { useSessionStore } from "@/stores/useSessionStore";
import { useDirectorySync } from "@/sync/sync-context";
import type { Todo } from "@opencode-ai/sdk/v2/client";
// Compat aliases for old TodoItem shape
type TodoItem = Todo & { id?: string };
type TodoStatus = string;
type TodoPriority = string;
import { useUIStore } from "@/stores/useUIStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
@@ -146,21 +152,15 @@ export const StatusRow: React.FC<StatusRowProps> = ({
agentName,
}) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const todos = useTodoStore((state) =>
currentSessionId ? state.sessionTodos.get(currentSessionId) ?? EMPTY_TODOS : EMPTY_TODOS
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const todosRecord = useDirectorySync((state) => state.todo);
const todos: TodoItem[] = React.useMemo(
() => (currentSessionId ? todosRecord[currentSessionId] ?? EMPTY_TODOS : EMPTY_TODOS),
[todosRecord, currentSessionId],
);
const loadTodos = useTodoStore((state) => state.loadTodos);
const { isMobile } = useUIStore();
const isCompact = isMobile || isVSCodeRuntime();
// Load todos when session changes
React.useEffect(() => {
if (currentSessionId) {
void loadTodos(currentSessionId);
}
}, [currentSessionId, loadTodos]);
// Filter out cancelled todos for display and keep original order.
// This prevents items from jumping around when status changes.
const visibleTodos = React.useMemo(() => {
@@ -313,8 +313,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
{/* Todo list */}
<div className="px-3 py-2 max-h-[200px] overflow-y-auto divide-y divide-border">
{visibleTodos.map((todo) => (
<TodoItemRow key={todo.id} todo={todo} />
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}
</div>
</div>
@@ -0,0 +1,30 @@
import React from 'react';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useConfigStore } from '@/stores/useConfigStore';
import { StatusRow } from './StatusRow';
/**
* Self-contained wrapper subscribes to assistant status internally
* so MessageList doesn't re-render on every streaming part delta.
*/
export const StatusRowContainer: React.FC = React.memo(() => {
const { working } = useAssistantStatus();
const currentAgentName = useConfigStore((state) => state.currentAgentName);
return (
<StatusRow
isWorking={working.isWorking}
statusText={working.statusText}
isGenericStatus={working.isGenericStatus}
isWaitingForPermission={working.isWaitingForPermission}
wasAborted={working.wasAborted}
abortActive={working.abortActive}
retryInfo={working.retryInfo}
showAssistantStatus
showTodos={false}
agentName={currentAgentName}
/>
);
});
StatusRowContainer.displayName = 'StatusRowContainer';
@@ -7,8 +7,8 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useSessionStore } from '@/stores/useSessionStore';
import { useMessageStore } from '@/stores/messageStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessageRecords } from '@/sync/sync-context';
import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBackLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { Part } from '@opencode-ai/sdk/v2';
@@ -44,13 +44,10 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
onScrollByTurnOffset,
onResumeToLatest,
}) => {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const messages = useMessageStore((state) =>
currentSessionId ? state.messages.get(currentSessionId) || [] : []
);
const revertToMessage = useSessionStore((state) => state.revertToMessage);
const forkFromMessage = useSessionStore((state) => state.forkFromMessage);
const loadSessions = useSessionStore((state) => state.loadSessions);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const messages = useSessionMessageRecords(currentSessionId ?? '');
const revertToMessage = useSessionUIStore((state) => state.revertToMessage);
const forkFromMessage = useSessionUIStore((state) => state.forkFromMessage);
const [forkingMessageId, setForkingMessageId] = React.useState<string | null>(null);
const [searchQuery, setSearchQuery] = React.useState('');
@@ -78,7 +75,6 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
setForkingMessageId(messageId);
try {
await forkFromMessage(currentSessionId, messageId);
await loadSessions();
onOpenChange(false);
} finally {
setForkingMessageId(null);
@@ -3,7 +3,8 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { cn } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useContextStore } from '@/stores/contextStore';
import { useUIStore } from '@/stores/useUIStore';
import { useModelLists } from '@/hooks/useModelLists';
@@ -55,11 +56,8 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
} = useConfigStore();
const { addRecentModel, addRecentEffort, recentEfforts } = useUIStore();
const { recentModelsList } = useModelLists();
const {
currentSessionId,
saveAgentModelForSession,
saveAgentModelVariantForSession,
} = useSessionStore();
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const { saveAgentModelForSession, saveAgentModelVariantForSession } = useSelectionStore();
const sessionAgentName = useContextStore((state) =>
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
);
@@ -10,7 +10,18 @@ interface TurnListProps<TEntry extends TurnListEntry> {
}
const TurnList = <TEntry extends TurnListEntry>({ entries, renderEntry }: TurnListProps<TEntry>): React.ReactElement => {
return <>{entries.map((entry) => renderEntry(entry))}</>;
return (
<>
{entries.map((entry) => (
<div
key={entry.key}
data-turn-entry={entry.key}
>
{renderEntry(entry)}
</div>
))}
</>
);
};
export default React.memo(TurnList) as typeof TurnList;
@@ -141,7 +141,7 @@ export const useChatTimelineController = ({
historyMetaRef.current = historyMeta;
}, [historyMeta]);
React.useEffect(() => {
React.useLayoutEffect(() => {
if (initializedSessionRef.current === sessionId) {
return;
}
@@ -153,11 +153,11 @@ export const useChatTimelineController = ({
previousTurnCountRef.current = turnWindowModel.turnCount;
}, [sessionId, turnWindowModel.turnCount]);
React.useEffect(() => {
React.useLayoutEffect(() => {
setTurnStart((current) => clampTurnStart(current, turnWindowModel.turnCount));
}, [turnWindowModel.turnCount]);
React.useEffect(() => {
React.useLayoutEffect(() => {
const previousTurnCount = previousTurnCountRef.current;
const nextTurnCount = turnWindowModel.turnCount;
if (previousTurnCount === nextTurnCount) {
@@ -180,6 +180,42 @@ export const useChatTimelineController = ({
return windowMessagesByTurn(messages, turnWindowModel, turnStart);
}, [messages, turnStart, turnWindowModel]);
// --- Synchronous scroll compensation for load-more / reveal ---
// fetchOlderHistory and revealBufferedTurns store a snapshot here
// before triggering the state change. useLayoutEffect consumes it
// after React commits new DOM — before the browser paints.
const prePrependScrollRef = React.useRef<{
height: number;
top: number;
anchor: ViewportAnchor | null;
} | null>(null);
React.useLayoutEffect(() => {
const snap = prePrependScrollRef.current;
const container = scrollRef.current;
if (!snap || !container) return;
prePrependScrollRef.current = null;
// Try anchor-based restoration first (pixel-perfect)
if (snap.anchor) {
const anchorEl = container.querySelector<HTMLElement>(
`[data-message-id="${snap.anchor.messageId}"]`,
);
if (anchorEl) {
const containerRect = container.getBoundingClientRect();
const anchorTop = anchorEl.getBoundingClientRect().top - containerRect.top;
container.scrollTop += anchorTop - snap.anchor.offsetTop;
return;
}
}
// Fallback: height-delta compensation
const delta = container.scrollHeight - snap.height;
if (delta > 0) {
container.scrollTop = snap.top + delta;
}
}, [renderedMessages, scrollRef]);
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
return messageListRef.current?.captureViewportAnchor() ?? null;
}, [messageListRef]);
@@ -188,35 +224,19 @@ export const useChatTimelineController = ({
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
}, [messageListRef]);
const restoreViewportWithFallback = React.useCallback((input: {
anchor: ViewportAnchor | null;
previousHeight: number | null;
previousTop: number | null;
}) => {
const container = scrollRef.current;
if (input.anchor && restoreViewportAnchor(input.anchor)) {
return;
}
if (!container || input.previousHeight === null || input.previousTop === null) {
return;
}
const heightDelta = container.scrollHeight - input.previousHeight;
if (heightDelta !== 0) {
container.scrollTop = input.previousTop + heightDelta;
}
}, [restoreViewportAnchor, scrollRef]);
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => {
if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) {
return false;
}
const anchor = captureViewportAnchor();
const container = scrollRef.current;
const previousHeight = container?.scrollHeight ?? null;
const previousTop = container?.scrollTop ?? null;
if (container) {
prePrependScrollRef.current = {
height: container.scrollHeight,
top: container.scrollTop,
anchor: captureViewportAnchor(),
};
}
setPendingRevealWork(true);
setTurnStart((current) => {
@@ -225,14 +245,9 @@ export const useChatTimelineController = ({
});
await waitForFrames(1);
restoreViewportWithFallback({
anchor,
previousHeight,
previousTop,
});
setPendingRevealWork(false);
return true;
}, [captureViewportAnchor, restoreViewportWithFallback, scrollRef]);
}, [captureViewportAnchor, scrollRef]);
const fetchOlderHistory = React.useCallback(async (input: {
preserveViewport: boolean;
@@ -244,16 +259,22 @@ export const useChatTimelineController = ({
return false;
}
const anchor = input.preserveViewport ? captureViewportAnchor() : null;
const container = scrollRef.current;
const previousHeight = input.preserveViewport ? (container?.scrollHeight ?? null) : null;
const previousTop = input.preserveViewport ? (container?.scrollTop ?? null) : null;
const beforeMessages = messagesRef.current;
const beforeMessageCount = beforeMessages.length;
const beforeOldestMessageId = beforeMessages[0]?.info?.id ?? null;
const beforeLimit = historyMetaRef.current?.limit ?? getMemoryLimits().HISTORICAL_MESSAGES;
setPendingRevealWork(true);
// Store scroll snapshot BEFORE the fetch so useLayoutEffect can
// compensate synchronously when React commits the new messages.
if (input.preserveViewport && container) {
prePrependScrollRef.current = {
height: container.scrollHeight,
top: container.scrollTop,
anchor: captureViewportAnchor(),
};
}
setIsLoadingOlder(true);
try {
@@ -274,20 +295,11 @@ export const useChatTimelineController = ({
&& typeof afterOldestMessageId === 'string'
&& beforeOldestMessageId !== afterOldestMessageId);
if (input.preserveViewport) {
restoreViewportWithFallback({
anchor,
previousHeight,
previousTop,
});
}
return historyGrew || afterLimit > beforeLimit;
} finally {
setIsLoadingOlder(false);
setPendingRevealWork(false);
}
}, [captureViewportAnchor, loadMoreMessages, restoreViewportWithFallback, scrollRef]);
}, [captureViewportAnchor, loadMoreMessages, scrollRef]);
const loadEarlier = React.useCallback(async () => {
if (await revealBufferedTurns()) {
@@ -1,9 +1,11 @@
import React from 'react';
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
import { stabilizeTurnProjection } from '../lib/turns/stabilizeTurnProjection';
import type { ChatMessageEntry, TurnProjectionResult } from '../lib/turns/types';
import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types';
import { streamPerfMeasure } from '@/stores/utils/streamDebug';
interface UseTurnRecordsOptions {
sessionKey?: string;
showTextJustificationActivity: boolean;
}
@@ -18,33 +20,59 @@ export const useTurnRecords = (
options: UseTurnRecordsOptions,
): TurnRecordsResult => {
const previousProjectionRef = React.useRef<TurnProjectionResult | null>(null);
const staticTurnsRef = React.useRef<TurnRecord[]>([]);
const streamingTurnRef = React.useRef<TurnRecord | undefined>(undefined);
React.useEffect(() => {
previousProjectionRef.current = null;
}, [options.showTextJustificationActivity]);
staticTurnsRef.current = [];
streamingTurnRef.current = undefined;
}, [options.sessionKey, options.showTextJustificationActivity]);
const projection = React.useMemo(() => {
const rawProjection = projectTurnRecords(messages, {
previousProjection: previousProjectionRef.current,
showTextJustificationActivity: options.showTextJustificationActivity,
return streamPerfMeasure('ui.turns.projection_ms', () => {
const rawProjection = projectTurnRecords(messages, {
previousProjection: previousProjectionRef.current,
showTextJustificationActivity: options.showTextJustificationActivity,
});
const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current);
previousProjectionRef.current = stabilizedProjection;
return stabilizedProjection;
});
const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current);
previousProjectionRef.current = stabilizedProjection;
return stabilizedProjection;
}, [messages, options.showTextJustificationActivity]);
const staticTurns = React.useMemo(() => {
if (projection.turns.length <= 1) {
return [];
const nextStatic = projection.turns.length <= 1
? []
: projection.turns.slice(0, -1);
const previousStatic = staticTurnsRef.current;
if (previousStatic.length === nextStatic.length) {
let isSame = true;
for (let index = 0; index < nextStatic.length; index += 1) {
if (previousStatic[index] !== nextStatic[index]) {
isSame = false;
break;
}
}
if (isSame) {
return previousStatic;
}
}
return projection.turns.slice(0, -1);
staticTurnsRef.current = nextStatic;
return nextStatic;
}, [projection.turns]);
const streamingTurn = React.useMemo(() => {
if (projection.turns.length === 0) {
return undefined;
const nextStreamingTurn = projection.turns.length === 0
? undefined
: projection.turns[projection.turns.length - 1];
if (streamingTurnRef.current === nextStreamingTurn) {
return streamingTurnRef.current;
}
return projection.turns[projection.turns.length - 1];
streamingTurnRef.current = nextStreamingTurn;
return nextStreamingTurn;
}, [projection.turns]);
return {
@@ -1,4 +1,4 @@
import type { SessionMemoryState } from '@/stores/types/sessionTypes';
import type { SessionMemoryState } from '@/sync/viewport-store';
export interface TurnHistorySignalsInput {
memoryState: SessionMemoryState | null;
@@ -171,16 +171,11 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit
});
let firstWithAny: string | undefined;
let cumulative = 0;
for (const message of input.assistantMessages) {
const count = countByMessage.get(message.info.id) ?? 0;
if (count > 0 && !firstWithAny) {
firstWithAny = message.info.id;
}
cumulative += count;
if (cumulative >= 2) {
return message.info.id;
}
}
return firstWithAny;
@@ -20,8 +20,8 @@ export interface StageTurnsResult {
}
const DEFAULT_STAGE_CONFIG: TurnStageConfig = {
init: 1,
batch: 3,
init: 10,
batch: 8,
};
export const getInitialStageCount = (total: number, config: TurnStageConfig): number => {
@@ -105,6 +105,7 @@ export type Turn = Pick<TurnRecord, 'turnId' | 'userMessage' | 'assistantMessage
export interface TurnGroupingContext {
turnId: string;
activityOwnerMessageId?: string;
isFirstAssistantInTurn: boolean;
isLastAssistantInTurn: boolean;
summaryBody?: string;
@@ -19,7 +19,7 @@ import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
@@ -35,6 +35,7 @@ import { ToolRevealOnMount } from './parts/ToolRevealOnMount';
import { StaticToolRow } from './parts/ProgressiveGroup';
import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
import TurnActivity from '../components/TurnActivity';
import { areRenderRelevantPartsEqual } from './renderCompare';
type SubtaskPartLike = Part & {
type: 'subtask';
@@ -77,7 +78,7 @@ const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null =
const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
const [expanded, setExpanded] = React.useState(false);
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const description = typeof part.description === 'string' ? part.description.trim() : '';
const command = typeof part.command === 'string' ? part.command.trim() : '';
@@ -254,6 +255,7 @@ const formatTurnDuration = (durationMs: number): string => {
interface MessageBodyProps {
sessionId?: string;
messageId: string;
parts: Part[];
isUser: boolean;
@@ -562,6 +564,7 @@ const UserMessageBody: React.FC<{
};
const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
sessionId,
messageId,
parts,
isMessageCompleted,
@@ -696,7 +699,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return visibleParts.filter((part) => part.type === 'text');
}, [visibleParts]);
const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage);
const createSessionFromAssistantMessage = useSessionUIStore((state) => state.createSessionFromAssistantMessage);
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const isSortedRenderMode = chatRenderMode === 'sorted';
@@ -1065,6 +1068,8 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return all.filter((segment) => segment.anchorMessageId === messageId);
}, [isSortedRenderMode, messageId, turnGroupingContext?.activityGroupSegments]);
const hasAnchoredActivitySegments = activityGroupSegmentsForMessage.length > 0;
const activityByPart = React.useMemo(() => {
const byRef = new Map<Part, (typeof activityPartsForTurn)[number]>();
const byId = new Map<string, (typeof activityPartsForTurn)[number]>();
@@ -1092,9 +1097,14 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
}, [activityPartsForTurn]);
const toggleActivityGroup = turnGroupingContext?.toggleGroup;
const isActivityOwnerMessage = !isSortedRenderMode
|| !turnGroupingContext?.activityOwnerMessageId
|| turnGroupingContext.activityOwnerMessageId === messageId
|| hasAnchoredActivitySegments;
const shouldRenderActivityGroup = isSortedRenderMode
&& activityGroupSegmentsForMessage.length > 0
&& isActivityOwnerMessage
&& hasAnchoredActivitySegments
&& Boolean(toggleActivityGroup);
@@ -1155,6 +1165,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<AssistantTextPart
key={`assistant-text-${messageId}-${i}`}
part={part}
sessionId={sessionId}
messageId={messageId}
streamPhase={streamPhase}
chatRenderMode={chatRenderMode}
@@ -1190,6 +1201,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<AssistantTextPart
key={`reasoning-${messageId}-${i}`}
part={part}
sessionId={sessionId}
messageId={messageId}
streamPhase={streamPhase}
chatRenderMode={chatRenderMode}
@@ -1206,8 +1218,13 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const toolPart = part as ToolPartType;
const toolName = toolPart.tool?.toLowerCase() ?? '';
if (isSortedRenderMode && !isActivityOwnerMessage) {
i += 1;
continue;
}
const activity = activityByPart.get(part);
if (activity?.kind === 'tool' && !isStandaloneTool(toolName)) {
if (activity?.kind === 'tool' && (shouldRenderActivityGroup || !isStandaloneTool(toolName))) {
i += 1;
continue;
}
@@ -1279,8 +1296,10 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
expandedTools,
hasStopFinish,
isMobile,
isActivityOwnerMessage,
isSortedRenderMode,
messageId,
sessionId,
onContentChange,
onShowPopup,
onToggleTool,
@@ -1525,4 +1544,37 @@ const MessageBody: React.FC<MessageBodyProps> = ({ isUser, ...props }) => {
return <AssistantMessageBody {...props} />;
};
export default React.memo(MessageBody);
export default React.memo(MessageBody, (prev, next) => {
return prev.sessionId === next.sessionId
&& prev.messageId === next.messageId
&& prev.isUser === next.isUser
&& areRenderRelevantPartsEqual(prev.parts, next.parts)
&& prev.isMessageCompleted === next.isMessageCompleted
&& prev.messageFinish === next.messageFinish
&& prev.messageCompletedAt === next.messageCompletedAt
&& prev.messageCreatedAt === next.messageCreatedAt
&& prev.syntaxTheme === next.syntaxTheme
&& prev.isMobile === next.isMobile
&& prev.hasTouchInput === next.hasTouchInput
&& prev.copiedCode === next.copiedCode
&& prev.expandedTools === next.expandedTools
&& prev.streamPhase === next.streamPhase
&& prev.allowAnimation === next.allowAnimation
&& prev.shouldShowHeader === next.shouldShowHeader
&& prev.hasTextContent === next.hasTextContent
&& prev.copiedMessage === next.copiedMessage
&& prev.showReasoningTraces === next.showReasoningTraces
&& prev.agentMention === next.agentMention
&& prev.turnGroupingContext === next.turnGroupingContext
&& prev.errorMessage === next.errorMessage
&& prev.userActionsMode === next.userActionsMode
&& prev.stickyUserHeaderEnabled === next.stickyUserHeaderEnabled
&& prev.onCopyCode === next.onCopyCode
&& prev.onToggleTool === next.onToggleTool
&& prev.onShowPopup === next.onShowPopup
&& prev.onContentChange === next.onContentChange
&& prev.onCopyMessage === next.onCopyMessage
&& prev.onAuxiliaryContentComplete === next.onAuxiliaryContentComplete
&& prev.onRevert === next.onRevert
&& prev.onFork === next.onFork;
});
@@ -1,6 +1,7 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
import { RiChatNewLine, RiAddLine, RiFileCopyLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
@@ -196,8 +197,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const pendingSelectionRef = React.useRef<SelectionPayload | null>(null);
const openRafRef = React.useRef<number | null>(null);
const isMenuVisibleRef = React.useRef(false);
const createSession = useSessionStore((state) => state.createSession);
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
const createSession = useSessionUIStore((state) => state.createSession);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const isMobile = useUIStore((state) => state.isMobile);
React.useEffect(() => {
@@ -5,11 +5,13 @@ import type { StreamPhase } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } };
interface AssistantTextPartProps {
part: Part;
sessionId?: string;
messageId: string;
streamPhase: StreamPhase;
chatRenderMode?: 'sorted' | 'live';
@@ -22,6 +24,8 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
streamPhase,
chatRenderMode = 'live',
}) => {
// Use part directly from props — parent provides the latest version from the store.
// No store subscription here to avoid re-render cascade from unrelated delta events.
const partWithText = part as PartWithText;
const rawText = typeof partWithText.text === 'string' ? partWithText.text : '';
const contentText = typeof partWithText.content === 'string' ? partWithText.content : '';
@@ -33,6 +37,11 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
const isCooldownPhase = streamPhase === 'cooldown';
const isStreaming = chatRenderMode === 'live' && (isStreamingPhase || isCooldownPhase);
streamPerfCount('ui.assistant_text_part.render');
if (isStreaming) {
streamPerfCount('ui.assistant_text_part.render.streaming');
}
const throttledTextContent = useStreamingTextThrottle({
text: textContent,
isStreaming,
@@ -45,32 +54,7 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
isStreaming,
});
const lastDisplayLengthRef = React.useRef(0);
React.useEffect(() => {
if (!isStreaming || typeof window === 'undefined') {
lastDisplayLengthRef.current = displayTextContent.length;
return;
}
const debugEnabled = window.localStorage.getItem('openchamber_stream_debug') === '1';
if (!debugEnabled) {
lastDisplayLengthRef.current = displayTextContent.length;
return;
}
if (displayTextContent.length < lastDisplayLengthRef.current) {
console.info('[STREAM-TRACE] render_shrink', {
messageId,
partId: part.id,
rawTextLen: rawText.length,
contentLen: contentText.length,
valueLen: valueText.length,
chosenLen: textContent.length,
throttledLen: throttledTextContent.length,
displayLen: displayTextContent.length,
prevDisplayLen: lastDisplayLengthRef.current,
});
}
lastDisplayLengthRef.current = displayTextContent.length;
}, [contentText.length, displayTextContent.length, isStreaming, messageId, part.id, rawText.length, textContent.length, throttledTextContent.length, valueText.length]);
streamPerfObserve('ui.assistant_text_part.display_len', displayTextContent.length);
const time = partWithText.time;
const isFinalized = Boolean(time && typeof time.end !== 'undefined');
@@ -105,4 +89,4 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
);
};
export default AssistantTextPart;
export default React.memo(AssistantTextPart);
@@ -8,6 +8,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useUIStore } from '@/stores/useUIStore';
import { useDurationTickerNow } from './useDurationTicker';
import { MarkdownRenderer } from '../../MarkdownRenderer';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
@@ -201,16 +202,21 @@ const ReasoningPart: React.FC<ReasoningPartProps> = ({
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
const time = partWithText.time;
const isStreaming = chatRenderMode === 'live' && typeof time?.end !== 'number';
const throttledText = useStreamingTextThrottle({
text: textContent,
isStreaming,
identityKey: `${messageId}:${part.id ?? 'reasoning'}`,
});
// Show reasoning even if time.end isn't set yet (during streaming)
// Only hide if there's no text content
if (!textContent || textContent.trim().length === 0) {
if (!throttledText || throttledText.trim().length === 0) {
return null;
}
return (
<ReasoningTimelineBlock
text={textContent}
text={throttledText}
variant="thinking"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-reasoning`}
@@ -11,7 +11,9 @@ import { toolDisplayStyles } from '@/lib/typography';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionMessageRecords } from '@/sync/sync-context';
import { getSyncChildStores, getSyncDirectory } from '@/sync/sync-refs';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionActivity } from '@/hooks/useSessionActivity';
import { opencodeClient } from '@/lib/opencode/client';
@@ -572,8 +574,6 @@ type TaskToolSummaryEntry = {
type SessionMessageWithParts = MessageRecord;
const EMPTY_SESSION_MESSAGES: SessionMessageWithParts[] = [];
const normalizeSessionIdCandidate = (value: unknown): string | undefined => {
if (typeof value !== 'string') {
return undefined;
@@ -844,7 +844,7 @@ const TaskToolSummary: React.FC<{
animateTailText?: boolean;
isActive?: boolean;
}> = ({ entries, isExpanded, isMobile, output, sessionId, onShowPopup, input, animateTailText = true, isActive = false }) => {
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
const displayEntries = entries;
@@ -1674,14 +1674,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
return readTaskSessionIdFromOutput(taskOutputString);
}, [isTaskTool, metadata, parsedTaskMetadata.sessionId, partMetadata, taskOutputString]);
const childSessionMessages = useSessionStore(
React.useCallback((store) => {
if (!taskSessionId) {
return EMPTY_SESSION_MESSAGES;
}
return (store.messages.get(taskSessionId) as SessionMessageWithParts[] | undefined) ?? EMPTY_SESSION_MESSAGES;
}, [taskSessionId])
);
const childSessionMessages = useSessionMessageRecords(taskSessionId ?? '');
const metadataTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
if (!isTaskTool) {
@@ -1901,7 +1894,20 @@ const ToolPart: React.FC<ToolPartProps> = ({
taskPollLastSignatureRef.current = nextSignature;
taskPollNoChangeCountRef.current = 0;
useSessionStore.getState().syncMessages(taskSessionId, messages);
// Inject fetched subagent messages into sync child store
const childStores = getSyncChildStores();
const dir = getSyncDirectory();
childStores.update(dir, (prev) => {
const records = messages as SessionMessageWithParts[];
const partPatch: Record<string, import('@opencode-ai/sdk/v2').Part[]> = { ...prev.part };
for (const rec of records) {
partPatch[rec.info.id] = rec.parts;
}
return {
message: { ...prev.message, [taskSessionId]: records.map((r) => r.info) as import('@opencode-ai/sdk/v2').Message[] },
part: partPatch,
};
});
} catch {
// Ignore transient subagent fetch errors.
} finally {
@@ -0,0 +1,115 @@
import type { Message, Part } from '@opencode-ai/sdk/v2';
type MessageRecord = {
info: Message;
parts: Part[];
};
const readPartId = (part: Part | undefined): string | null => {
if (!part) return null;
const candidate = (part as { id?: unknown }).id;
return typeof candidate === 'string' && candidate.length > 0 ? candidate : null;
};
const readToolStatus = (part: Part | undefined): string | null => {
const status = (part as { state?: { status?: unknown } } | undefined)?.state?.status;
return typeof status === 'string' ? status : null;
};
const readPartTime = (part: Part | undefined) => {
const time = (part as { time?: { start?: unknown; end?: unknown } } | undefined)?.time;
return {
start: typeof time?.start === 'number' ? time.start : null,
end: typeof time?.end === 'number' ? time.end : null,
};
};
const readPartText = (part: Part | undefined): string => {
const candidate = part as { text?: unknown; content?: unknown; value?: unknown } | undefined;
if (!candidate) return '';
const text = typeof candidate.text === 'string' ? candidate.text : '';
const content = typeof candidate.content === 'string' ? candidate.content : '';
const value = typeof candidate.value === 'string' ? candidate.value : '';
return [text, content, value].reduce((best, next) => (next.length > best.length ? next : best), '');
};
export const areRenderRelevantPartsEqual = (left: Part[], right: Part[]): boolean => {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
const leftPart = left[index];
const rightPart = right[index];
if (leftPart.type !== rightPart.type) {
return false;
}
const leftId = readPartId(leftPart);
const rightId = readPartId(rightPart);
if (leftId !== rightId) {
return false;
}
if (leftPart.type === 'tool') {
if (readToolStatus(leftPart) !== readToolStatus(rightPart)) {
return false;
}
const leftTime = readPartTime(leftPart);
const rightTime = readPartTime(rightPart);
if (leftTime.start !== rightTime.start || leftTime.end !== rightTime.end) {
return false;
}
const leftTool = (leftPart as { tool?: unknown }).tool;
const rightTool = (rightPart as { tool?: unknown }).tool;
if (leftTool !== rightTool) {
return false;
}
continue;
}
const leftTime = readPartTime(leftPart);
const rightTime = readPartTime(rightPart);
if (leftTime.start !== rightTime.start || leftTime.end !== rightTime.end) {
return false;
}
if (leftPart.type === 'text' || leftPart.type === 'reasoning') {
if (readPartText(leftPart) !== readPartText(rightPart)) {
return false;
}
}
}
return true;
};
export const areRenderRelevantMessageInfoEqual = (left: Message, right: Message): boolean => {
if (left === right) return true;
return left.id === right.id
&& left.role === right.role
&& left.sessionID === right.sessionID
&& (left as { finish?: unknown }).finish === (right as { finish?: unknown }).finish
&& (left as { status?: unknown }).status === (right as { status?: unknown }).status
&& (left as { mode?: unknown }).mode === (right as { mode?: unknown }).mode
&& (left as { agent?: unknown }).agent === (right as { agent?: unknown }).agent
&& (left as { providerID?: unknown }).providerID === (right as { providerID?: unknown }).providerID
&& (left as { modelID?: unknown }).modelID === (right as { modelID?: unknown }).modelID
&& (left as { variant?: unknown }).variant === (right as { variant?: unknown }).variant
&& (left as { clientRole?: unknown }).clientRole === (right as { clientRole?: unknown }).clientRole
&& (left as { userMessageMarker?: unknown }).userMessageMarker === (right as { userMessageMarker?: unknown }).userMessageMarker
&& ((left as { time?: { created?: unknown; completed?: unknown } }).time?.created ?? null) === ((right as { time?: { created?: unknown; completed?: unknown } }).time?.created ?? null)
&& ((left as { time?: { created?: unknown; completed?: unknown } }).time?.completed ?? null) === ((right as { time?: { created?: unknown; completed?: unknown } }).time?.completed ?? null);
};
export const areRenderRelevantMessagesEqual = (left: MessageRecord, right: MessageRecord): boolean => {
return areRenderRelevantMessageInfoEqual(left.info, right.info) && areRenderRelevantPartsEqual(left.parts, right.parts);
};
export const areOptionalRenderRelevantMessagesEqual = (left?: MessageRecord, right?: MessageRecord): boolean => {
if (!left || !right) {
return left === right;
}
return areRenderRelevantMessagesEqual(left, right);
};
@@ -1,7 +1,7 @@
import React from 'react';
import { toast } from '@/components/ui';
import { useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentSource } from '@/stores/useInlineCommentDraftStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
type LineRangeBase = {
start: number;
@@ -48,8 +48,8 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
) {
const { source, fileLabel, language, getCodeForRange, toStoreRange, fromDraftRange } = options;
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
@@ -7,13 +7,12 @@ import { deriveMessageRole } from '@/components/chat/message/messageRole';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
import { copyTextToClipboard } from '@/lib/clipboard';
type SessionMessage = { info: Message; parts: Part[] };
const EMPTY_SESSION_MESSAGES: SessionMessage[] = [];
type ProviderModelLike = {
id?: string;
name?: string;
@@ -277,12 +276,9 @@ export const ContextPanelContent: React.FC = () => {
const [expandedRawMessages, setExpandedRawMessages] = React.useState<Record<string, boolean>>({});
const [copiedRawMessageId, setCopiedRawMessageId] = React.useState<string | null>(null);
const copyResetTimeoutRef = React.useRef<number | null>(null);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
const sessionMessages = useSessionStore((state) => {
if (!state.currentSessionId) return EMPTY_SESSION_MESSAGES;
return state.messages.get(state.currentSessionId) ?? EMPTY_SESSION_MESSAGES;
});
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessions = useSessions();
const sessionMessages = useSessionMessageRecords(currentSessionId ?? '');
const providers = useConfigStore((state) => state.providers);
React.useEffect(() => {
+20 -16
View File
@@ -20,7 +20,9 @@ import { RiArrowLeftSLine, RiChat4Line, RiChatNewLine, RiCheckLine, RiCloseLine,
import { DiffIcon } from '@/components/icons/DiffIcon';
import { useUIStore, type MainTab } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
import { getAllSyncSessions } from '@/sync/sync-refs';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -241,17 +243,13 @@ export const Header: React.FC<HeaderProps> = ({
const { getCurrentModel } = useConfigStore();
const runtimeApis = useRuntimeAPIs();
const getContextUsage = useSessionStore((state) => state.getContextUsage);
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const isNewSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const currentSessionMessages = useSessionStore((state) => {
if (!currentSessionId) {
return undefined;
}
return state.messages.get(currentSessionId);
});
const sessions = useSessionStore((state) => state.sessions);
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '');
const currentSessionMessages = currentSessionId ? (currentSessionMessageRecords.length > 0 ? currentSessionMessageRecords : undefined) : undefined;
const sessions = useSessions();
const activeProject = useProjectsStore((state) => {
if (!state.activeProjectId) {
return null;
@@ -565,14 +563,20 @@ export const Header: React.FC<HeaderProps> = ({
const currentSession = React.useMemo(() => {
if (!currentSessionId) return null;
return sessions.find((s) => s.id === currentSessionId) ?? null;
// Try current directory's store first, then fall back to all child stores.
// The sidebar loads sessions globally via SDK, but the header uses
// useSessions() which only has the current directory. This fallback
// ensures the title/directory show when the session lives elsewhere.
return sessions.find((s) => s.id === currentSessionId)
?? getAllSyncSessions().find((s) => s.id === currentSessionId)
?? null;
}, [currentSessionId, sessions]);
const worktreePath = useSessionStore((state) => {
const worktreePath = useSessionUIStore((state) => {
if (!currentSessionId) return '';
return state.worktreeMetadata.get(currentSessionId)?.path ?? '';
});
const currentSessionWorktreeBranch = useSessionStore((state) => {
const currentSessionWorktreeBranch = useSessionUIStore((state) => {
if (!currentSessionId) return null;
return state.worktreeMetadata.get(currentSessionId)?.branch?.trim() ?? null;
});
@@ -588,7 +592,7 @@ export const Header: React.FC<HeaderProps> = ({
return normalize(raw || '');
}, [currentSession?.directory]);
const draftDirectory = useSessionStore((state) => {
const draftDirectory = useSessionUIStore((state) => {
if (!state.newSessionDraft?.open) {
return '';
}
@@ -39,7 +39,8 @@ export const RightSidebarTabs: React.FC = () => {
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{rightSidebarTab === 'git' ? <GitView /> : <SidebarFilesTree />}
{rightSidebarTab === 'git' && <GitView />}
{rightSidebarTab === 'files' && <SidebarFilesTree />}
</div>
</div>
);
@@ -1,5 +1,6 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { cn } from '@/lib/utils';
@@ -23,8 +24,8 @@ const formatDirectoryPath = (path?: string) => {
};
export const SidebarContextSummary: React.FC<SidebarContextSummaryProps> = ({ className }) => {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessions = useSessions();
const { currentDirectory } = useDirectoryStore();
const activeSessionTitle = React.useMemo(() => {
@@ -453,13 +453,29 @@ export const SidebarFilesTree: React.FC = () => {
React.useEffect(() => {
if (!root || expandedPaths.length === 0) return;
for (const expandedPath of expandedPaths) {
const normalized = normalizePath(expandedPath);
if (!normalized || normalized === root) continue;
if (!normalized.startsWith(`${root}/`)) continue;
if (loadedDirsRef.current.has(normalized) || inFlightDirsRef.current.has(normalized)) continue;
void loadDirectory(normalized);
}
// Sort by depth so parent dirs load before children
const toLoad = expandedPaths
.map((p) => normalizePath(p))
.filter((normalized): normalized is string =>
!!normalized &&
normalized !== root &&
normalized.startsWith(`${root}/`) &&
!loadedDirsRef.current.has(normalized) &&
!inFlightDirsRef.current.has(normalized),
)
.sort((a, b) => a.split('/').length - b.split('/').length);
if (toLoad.length === 0) return;
// Load with concurrency limit to avoid API stampede on startup
let cancelled = false;
void (async () => {
for (let i = 0; i < toLoad.length && !cancelled; i += 3) {
const batch = toLoad.slice(i, i + 3);
await Promise.all(batch.map((dir) => loadDirectory(dir)));
}
})();
return () => { cancelled = true; };
}, [expandedPaths, loadDirectory, root]);
// --- Fuzzy search scoring (matching FilesView) ---
@@ -2,7 +2,9 @@ import React from 'react';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { SessionSidebar } from '@/components/session/SessionSidebar';
import { ChatView, SettingsView } from '@/components/views';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useViewportStore } from '@/sync/viewport-store';
import { useSessions, useDirectorySync } from '@/sync/sync-context';
import { useConfigStore } from '@/stores/useConfigStore';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { McpDropdown } from '@/components/mcp/McpDropdown';
@@ -74,7 +76,7 @@ export const VSCodeLayout: React.FC = () => {
const bootDraftOpen = React.useMemo(() => {
try {
return Boolean(useSessionStore.getState().newSessionDraft?.open);
return Boolean(useSessionUIStore.getState().newSessionDraft?.open);
} catch {
return false;
}
@@ -88,8 +90,8 @@ export const VSCodeLayout: React.FC = () => {
const expandedSidebarResizeStartXRef = React.useRef(0);
const expandedSidebarResizeStartWidthRef = React.useRef(SESSIONS_SIDEBAR_WIDTH);
const expandedSidebarResizePointerIdRef = React.useRef<number | null>(null);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessions = useSessions();
const activeSessionTitle = React.useMemo(() => {
if (!currentSessionId) {
@@ -97,21 +99,21 @@ export const VSCodeLayout: React.FC = () => {
}
return sessions.find((session) => session.id === currentSessionId)?.title || 'Session';
}, [currentSessionId, sessions]);
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
const isSyncingMessages = useSessionStore((state) => state.isSyncing);
const hasActiveSessionWork = useSessionStore((state) => {
const statuses = state.sessionStatus;
if (!statuses || statuses.size === 0) {
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const isSyncingMessages = useViewportStore((state) => state.isSyncing);
const hasActiveSessionWork = useDirectorySync((state) => {
const statuses = state.session_status;
if (!statuses || Object.keys(statuses).length === 0) {
return false;
}
for (const status of statuses.values()) {
for (const status of Object.values(statuses)) {
if (status?.type === 'busy' || status?.type === 'retry') {
return true;
}
}
return false;
});
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
() => (typeof window !== 'undefined'
? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as
@@ -120,9 +122,6 @@ export const VSCodeLayout: React.FC = () => {
);
const configInitialized = useConfigStore((state) => state.isInitialized);
const initializeConfig = useConfigStore((state) => state.initializeApp);
const loadSessions = useSessionStore((state) => state.loadSessions);
const loadMessages = useSessionStore((state) => state.loadMessages);
const messages = useSessionStore((state) => state.messages);
const [hasInitializedOnce, setHasInitializedOnce] = React.useState<boolean>(() => configInitialized);
const [isInitializing, setIsInitializing] = React.useState<boolean>(false);
const lastBootstrapAttemptAt = React.useRef<number>(0);
@@ -158,18 +157,11 @@ export const VSCodeLayout: React.FC = () => {
}
const timeoutId = window.setTimeout(() => {
const state = useSessionStore.getState();
const state = useSessionUIStore.getState();
const stillNoSession = !state.currentSessionId;
const draftStillClosed = !state.newSessionDraft?.open;
const stillSyncing = state.isSyncing;
const stillActiveWork = (() => {
const statuses = state.sessionStatus;
if (!statuses || statuses.size === 0) return false;
for (const status of statuses.values()) {
if (status?.type === 'busy' || status?.type === 'retry') return true;
}
return false;
})();
const stillSyncing = useViewportStore.getState().isSyncing;
const stillActiveWork = false; // sync bootstrap tracks session status
if (stillNoSession && draftStillClosed && !stillSyncing && !stillActiveWork) {
setCurrentView('sessions');
@@ -270,17 +262,10 @@ export const VSCodeLayout: React.FC = () => {
if (!configState.isInitialized || !configState.isConnected || configState.providers.length === 0 || configState.agents.length === 0) {
return;
}
await loadSessions();
const sessionsError = useSessionStore.getState().error;
if (debugEnabled) console.log('[OpenChamber][VSCode][bootstrap] post-load', {
providers: configState.providers.length,
agents: configState.agents.length,
sessions: useSessionStore.getState().sessions.length,
sessionsError,
});
if (typeof sessionsError === 'string' && sessionsError.length > 0) {
return;
}
setHasInitializedOnce(true);
} catch {
// Ignore bootstrap failures
@@ -289,7 +274,7 @@ export const VSCodeLayout: React.FC = () => {
}
};
void runBootstrap();
}, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing, loadSessions]);
}, [connectionStatus, configInitialized, hasInitializedOnce, initializeConfig, isInitializing]);
React.useEffect(() => {
if (viewMode !== 'editor') {
@@ -314,35 +299,9 @@ export const VSCodeLayout: React.FC = () => {
}
hasAppliedInitialSession.current = true;
void useSessionStore.getState().setCurrentSession(initialSessionId);
void useSessionUIStore.getState().setCurrentSession(initialSessionId);
}, [connectionStatus, hasInitializedOnce, initialSessionId, openNewSessionDraft, sessions, viewMode]);
// Hydrate messages when viewing chat
React.useEffect(() => {
const hydrateMessages = async () => {
if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat' || newSessionDraftOpen) {
return;
}
if (!currentSessionId) {
return;
}
const hasMessagesEntry = messages.has(currentSessionId);
if (hasMessagesEntry) {
return;
}
try {
await loadMessages(currentSessionId);
} catch {
/* ignored */
}
};
void hydrateMessages();
}, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, newSessionDraftOpen]);
// Track container width for responsive settings layout
React.useEffect(() => {
const container = containerRef.current;
@@ -532,8 +491,8 @@ interface VSCodeHeaderProps {
}
const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, onNewSession, onSettings, onAgentManager, showMcp, showContextUsage, showRateLimits }) => {
const { getCurrentModel } = useConfigStore();
const getContextUsage = useSessionStore((state) => state.getContextUsage);
const getCurrentModel = useConfigStore((s) => s.getCurrentModel);
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
const quotaResults = useQuotaStore((state) => state.results);
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
@@ -11,7 +11,7 @@ import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { cn, formatDirectoryName } from '@/lib/utils';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useMultiRunStore } from '@/stores/useMultiRunStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import type { ProjectRef } from '@/lib/openchamberConfig';
@@ -440,7 +440,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
const result = await createMultiRun(params);
if (result) {
if (result.firstSessionId) {
useSessionStore.getState().setCurrentSession(result.firstSessionId);
useSessionUIStore.getState().setCurrentSession(result.firstSessionId);
}
// Close launcher
@@ -5,8 +5,7 @@ import { NumberInput } from '@/components/ui/number-input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useAgentsStore, type AgentConfig, type AgentScope } from '@/stores/useAgentsStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { usePermissionStore } from '@/stores/permissionStore';
import { useDirectorySync } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useDeviceInfo } from '@/lib/device';
import { opencodeClient } from '@/lib/opencode/client';
@@ -184,7 +183,6 @@ const buildPermissionConfigWithGlobal = (
export const AgentsPage: React.FC = () => {
const { isMobile } = useDeviceInfo();
const { selectedAgentName, getAgentByName, createAgent, updateAgent, agents, agentDraft, setAgentDraft } = useAgentsStore();
useConfigStore();
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null;
const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent);
@@ -220,7 +218,7 @@ export const AgentsPage: React.FC = () => {
const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null);
const [toolIds, setToolIds] = React.useState<string[]>([]);
const permissionsBySession = usePermissionStore((state) => state.permissions);
const permissionsBySession = useDirectorySync((state) => state.permission);
React.useEffect(() => {
let cancelled = false;
@@ -264,7 +262,7 @@ export const AgentsPage: React.FC = () => {
}
}
for (const permissions of permissionsBySession.values()) {
for (const permissions of Object.values(permissionsBySession)) {
for (const request of permissions) {
const permissionName = request.permission?.trim();
if (permissionName && permissionName !== 'invalid') {
@@ -5,33 +5,44 @@ import { toast } from '@/components/ui';
import { NumberInput } from '@/components/ui/number-input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
const MIN_DAYS = 1;
const MAX_DAYS = 365;
const DEFAULT_RETENTION_DAYS = 30;
const RETENTION_ACTION_OPTIONS = [
{ value: 'archive', label: 'Archive' },
{ value: 'delete', label: 'Delete' },
] as const;
export const SessionRetentionSettings: React.FC = () => {
const autoDeleteEnabled = useUIStore((state) => state.autoDeleteEnabled);
const autoDeleteAfterDays = useUIStore((state) => state.autoDeleteAfterDays);
const sessionRetentionAction = useUIStore((state) => state.sessionRetentionAction);
const setAutoDeleteEnabled = useUIStore((state) => state.setAutoDeleteEnabled);
const setAutoDeleteAfterDays = useUIStore((state) => state.setAutoDeleteAfterDays);
const setSessionRetentionAction = useUIStore((state) => state.setSessionRetentionAction);
const { candidates, isRunning, runCleanup } = useSessionAutoCleanup({ autoRun: false });
const { candidates, isRunning, runCleanup, action } = useSessionAutoCleanup({ autoRun: false });
const pendingCount = candidates.length;
const handleRunCleanup = React.useCallback(async () => {
const result = await runCleanup({ force: true });
if (result.deletedIds.length === 0 && result.failedIds.length === 0) {
toast.message('No sessions eligible for deletion');
const verb = result.action === 'archive' ? 'archiving' : 'deletion';
const pastTense = result.action === 'archive' ? 'Archived' : 'Deleted';
const failureVerb = result.action === 'archive' ? 'archive' : 'delete';
if (result.completedIds.length === 0 && result.failedIds.length === 0) {
toast.message(`No sessions eligible for ${verb}`);
return;
}
if (result.deletedIds.length > 0) {
toast.success(`Deleted ${result.deletedIds.length} session${result.deletedIds.length === 1 ? '' : 's'}`);
if (result.completedIds.length > 0) {
toast.success(`${pastTense} ${result.completedIds.length} session${result.completedIds.length === 1 ? '' : 's'}`);
}
if (result.failedIds.length > 0) {
toast.error(`Failed to delete ${result.failedIds.length} session${result.failedIds.length === 1 ? '' : 's'}`);
toast.error(`Failed to ${failureVerb} ${result.failedIds.length} session${result.failedIds.length === 1 ? '' : 's'}`);
}
}, [runCleanup]);
@@ -47,7 +58,7 @@ export const SessionRetentionSettings: React.FC = () => {
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Automatically delete inactive sessions based on their last activity. Keeps recent 5 sessions.
Automatically archive or delete inactive sessions based on last activity. Keeps the 5 most recent sessions.
</TooltipContent>
</Tooltip>
</div>
@@ -103,6 +114,31 @@ export const SessionRetentionSettings: React.FC = () => {
</Button>
</div>
</div>
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">When sessions expire</span>
</div>
<div className="flex flex-wrap items-center gap-1 sm:w-fit">
{RETENTION_ACTION_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant="outline"
size="xs"
className={cn(
'!font-normal',
sessionRetentionAction === option.value
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
onClick={() => setSessionRetentionAction(option.value)}
>
{option.label}
</Button>
))}
</div>
</div>
</section>
<div className="mt-1 px-2 py-1.5 space-y-1">
@@ -124,7 +160,7 @@ export const SessionRetentionSettings: React.FC = () => {
</div>
</div>
<p className="typography-meta text-muted-foreground">
Eligible for deletion right now: <span className="tabular-nums">{pendingCount}</span>
Eligible for {action === 'archive' ? 'archiving' : 'deletion'} right now: <span className="tabular-nums">{pendingCount}</span>
</p>
</div>
</div>
@@ -4,7 +4,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useDeviceInfo } from '@/lib/device';
import { checkIsGitRepository } from '@/lib/gitApi';
@@ -24,7 +25,8 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
const projectPath = projectRefProp?.path ?? activeProject?.path ?? null;
const { sessions, getWorktreeMetadata } = useSessionStore();
const getWorktreeMetadata = useSessionUIStore((s) => s.getWorktreeMetadata);
const sessions = useSessions();
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
@@ -27,7 +27,7 @@ import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { sessionEvents } from '@/lib/sessionEvents';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessions } from '@/sync/sync-context';
export interface BranchPickerProject {
id: string;
@@ -65,7 +65,7 @@ const normalizePath = (value: string | null | undefined): string => {
};
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
const sessions = useSessionStore((state) => state.sessions);
const sessions = useSessions();
const [searchQuery, setSearchQuery] = React.useState('');
const [branches, setBranches] = React.useState<GitBranch | null>(null);
const [worktrees, setWorktrees] = React.useState<GitWorktreeInfo[]>([]);
@@ -21,9 +21,10 @@ import {
import { cn } from '@/lib/utils';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
import { useConfigStore } from '@/stores/useConfigStore';
import { useMessageStore } from '@/stores/messageStore';
import { useContextStore } from '@/stores/contextStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
@@ -377,7 +378,7 @@ export function GitHubIssuePickerDialog({
return created.id;
}
const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null);
const session = await sessionActions.createSession(sessionTitle, projectDirectory, null);
if (!session?.id) {
throw new Error('Failed to create session');
}
@@ -385,10 +386,10 @@ export function GitHubIssuePickerDialog({
})();
// Ensure worktree-based sessions also get the issue title.
void useSessionStore.getState().updateSessionTitle(sessionId, sessionTitle).catch(() => undefined);
void sessionActions.updateSessionTitle(sessionId, sessionTitle).catch(() => undefined);
try {
useSessionStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents);
useSessionUIStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents);
} catch {
// ignore
}
@@ -397,7 +398,7 @@ export function GitHubIssuePickerDialog({
onOpenChange(false);
const configState = useConfigStore.getState();
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
const defaultModel = resolveDefaultModelSelection();
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
@@ -10,15 +10,19 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectLabel,
SelectGroup,
SelectSeparator,
} from '@/components/ui/select';
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import {
RiGitBranchLine,
RiGitRepositoryLine,
@@ -29,14 +33,16 @@ import {
RiCheckLine,
RiExternalLinkLine,
RiCloseLine,
RiArrowDownSLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
import { useConfigStore } from '@/stores/useConfigStore';
import { useMessageStore } from '@/stores/messageStore';
import { useContextStore } from '@/stores/contextStore';
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
@@ -44,6 +50,7 @@ import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
import { opencodeClient } from '@/lib/opencode/client';
import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useGitBranches, useGitStore } from '@/stores/useGitStore';
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
@@ -233,12 +240,6 @@ export function NewWorktreeDialog({
const isLoadingBranches = useGitStore((state) => state.isLoadingBranches);
const fetchBranches = useGitStore((state) => state.fetchBranches);
React.useEffect(() => {
if (!open || !projectDirectory || !git) return;
if (branches?.all) return;
void fetchBranches(projectDirectory, git);
}, [open, projectDirectory, git, branches?.all, fetchBranches]);
// Compute local and remote branch lists (same pattern as GitView)
const localBranches = React.useMemo(() => {
if (!branches?.all) return [];
@@ -256,8 +257,7 @@ export function NewWorktreeDialog({
}, [branches]);
// Get existing worktrees for the current project to avoid conflicts
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
const loadSessions = useSessionStore((state) => state.loadSessions);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const existingWorktreeNames = React.useMemo(() => {
if (!projectDirectory) return new Set<string>();
const worktrees = availableWorktreesByProject.get(projectDirectory) ?? [];
@@ -278,10 +278,122 @@ export function NewWorktreeDialog({
const [githubDialogOpen, setGithubDialogOpen] = React.useState(false);
// Desktop branch picker states
const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false);
const [sourceBranchDropdownOpen, setSourceBranchDropdownOpen] = React.useState(false);
// Mobile branch picker states
const [existingBranchPickerOpen, setExistingBranchPickerOpen] = React.useState(false);
const [sourceBranchPickerOpen, setSourceBranchPickerOpen] = React.useState(false);
// Shared query state per picker (desktop + mobile)
const [existingBranchQuery, setExistingBranchQuery] = React.useState('');
const [sourceBranchQuery, setSourceBranchQuery] = React.useState('');
const existingBranchDropdownContentRef = React.useRef<HTMLDivElement | null>(null);
const sourceBranchDropdownContentRef = React.useRef<HTMLDivElement | null>(null);
const existingBranchMobileListWrapperRef = React.useRef<HTMLDivElement | null>(null);
const sourceBranchMobileListWrapperRef = React.useRef<HTMLDivElement | null>(null);
const findScrollableContainer = React.useCallback((startNode: HTMLElement | null): HTMLElement | null => {
let node: HTMLElement | null = startNode;
while (node && node !== document.body) {
const { overflowY } = window.getComputedStyle(node);
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
return node;
}
node = node.parentElement;
}
return null;
}, []);
const resetScrollToTop = React.useCallback((container: HTMLElement | null) => {
if (!container) {
return;
}
container.scrollTop = 0;
}, []);
const resetDesktopPickerScroll = React.useCallback((contentRef: React.RefObject<HTMLDivElement | null>) => {
const list = contentRef.current?.querySelector<HTMLElement>('[data-slot="command-list"]') ?? null;
resetScrollToTop(list);
}, [resetScrollToTop]);
const resetMobilePickerScroll = React.useCallback((wrapperRef: React.RefObject<HTMLDivElement | null>) => {
const scrollContainer = findScrollableContainer(wrapperRef.current);
resetScrollToTop(scrollContainer);
}, [findScrollableContainer, resetScrollToTop]);
const existingBranchRankedGroups = React.useMemo(() => {
return rankBranchesForQuery({
localBranches,
remoteBranches,
query: existingBranchQuery,
});
}, [localBranches, remoteBranches, existingBranchQuery]);
const sourceBranchRankedGroups = React.useMemo(() => {
return rankBranchesForQuery({
localBranches,
remoteBranches,
query: sourceBranchQuery,
});
}, [localBranches, remoteBranches, sourceBranchQuery]);
const hasExistingBranchQuery = existingBranchQuery.trim().length > 0;
const hasSourceBranchQuery = sourceBranchQuery.trim().length > 0;
const hasExistingBranchMatches = existingBranchRankedGroups.matching.length > 0;
const hasSourceBranchMatches = sourceBranchRankedGroups.matching.length > 0;
const canFetchBranches = Boolean(projectDirectory && git);
const handleFetchBranches = React.useCallback(() => {
if (!projectDirectory || !git) {
return;
}
void fetchBranches(projectDirectory, git);
}, [projectDirectory, git, fetchBranches]);
React.useEffect(() => {
if (!existingBranchDropdownOpen && !existingBranchPickerOpen) {
setExistingBranchQuery('');
}
}, [existingBranchDropdownOpen, existingBranchPickerOpen]);
React.useEffect(() => {
if (!sourceBranchDropdownOpen && !sourceBranchPickerOpen) {
setSourceBranchQuery('');
}
}, [sourceBranchDropdownOpen, sourceBranchPickerOpen]);
React.useEffect(() => {
if (existingBranchDropdownOpen) {
resetDesktopPickerScroll(existingBranchDropdownContentRef);
}
if (existingBranchPickerOpen) {
resetMobilePickerScroll(existingBranchMobileListWrapperRef);
}
}, [
existingBranchDropdownOpen,
existingBranchPickerOpen,
existingBranchQuery,
resetDesktopPickerScroll,
resetMobilePickerScroll,
]);
React.useEffect(() => {
if (sourceBranchDropdownOpen) {
resetDesktopPickerScroll(sourceBranchDropdownContentRef);
}
if (sourceBranchPickerOpen) {
resetMobilePickerScroll(sourceBranchMobileListWrapperRef);
}
}, [
sourceBranchDropdownOpen,
sourceBranchPickerOpen,
sourceBranchQuery,
resetDesktopPickerScroll,
resetMobilePickerScroll,
]);
// Validation state
const [validation, setValidation] = React.useState<ValidationState>({
isValidating: false,
@@ -399,7 +511,7 @@ export function NewWorktreeDialog({
}
const configState = useConfigStore.getState();
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
const defaultModel = resolveDefaultModelSelection();
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID;
@@ -630,6 +742,12 @@ Nice-to-have:
selectedBranch: '',
worktreeName: '',
});
setExistingBranchDropdownOpen(false);
setSourceBranchDropdownOpen(false);
setExistingBranchPickerOpen(false);
setSourceBranchPickerOpen(false);
setExistingBranchQuery('');
setSourceBranchQuery('');
setValidation({
isValidating: false,
branchError: null,
@@ -847,16 +965,16 @@ Nice-to-have:
? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
: 'New session';
const session = await useSessionStore.getState().createSession(sessionTitle, metadata.path, null);
const session = await sessionActions.createSession(sessionTitle, metadata.path, null);
if (!session?.id) {
throw new Error('Failed to create session');
}
createdSessionId = session.id;
void useSessionStore.getState().updateSessionTitle(session.id, sessionTitle).catch(() => undefined);
void sessionActions.updateSessionTitle(session.id, sessionTitle).catch(() => undefined);
try {
useSessionStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents);
useSessionUIStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents);
} catch {
// ignore
}
@@ -871,8 +989,6 @@ Nice-to-have:
description: `${metadata.branch || metadata.name}${sourceLabel ? ` from ${sourceLabel}` : ''} - bootstrapping in background`,
});
void loadSessions().catch(() => undefined);
onOpenChange(false);
if (createdSessionId) {
@@ -1037,17 +1153,29 @@ Nice-to-have:
<label className="typography-ui-label text-foreground block font-semibold">
Select Branch
</label>
<Button
variant="outline"
size="sm"
onClick={() => setExistingBranchPickerOpen(true)}
className="w-full justify-between h-9"
>
<span className={existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground'}>
{existingBranchState.selectedBranch || 'Choose a branch...'}
</span>
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setExistingBranchPickerOpen(true)}
className="flex-1 justify-between h-9"
>
<span className={existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground'}>
{existingBranchState.selectedBranch || 'Choose a branch...'}
</span>
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 px-0 shrink-0"
onClick={handleFetchBranches}
disabled={!canFetchBranches || isLoadingBranches}
title="Fetch branches"
>
{isLoadingBranches ? <RiLoader4Line className="size-4 animate-spin" /> : <RiRefreshLine className="size-4" />}
</Button>
</div>
{/* Mobile Branch Picker Overlay */}
<MobileOverlayPanel
@@ -1055,7 +1183,13 @@ Nice-to-have:
title="Select Branch"
onClose={() => setExistingBranchPickerOpen(false)}
>
<div className="space-y-4">
<div className="space-y-4" ref={existingBranchMobileListWrapperRef}>
<Input
value={existingBranchQuery}
onChange={(e) => setExistingBranchQuery(e.target.value)}
placeholder="Search branches..."
className="h-8"
/>
{isLoadingBranches ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
Loading branches...
@@ -1065,14 +1199,52 @@ Nice-to-have:
No branches found
</div>
) : (
<>
{localBranches.length > 0 && (
<div className="space-y-4">
{hasExistingBranchQuery && hasExistingBranchMatches && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
Local branches
Matching branches
</div>
<div className="space-y-1">
{localBranches.map(branch => (
{existingBranchRankedGroups.matching.map((branch) => (
<button
key={`${branch.source}-${branch.value}`}
onClick={() => {
setExistingBranchState(prev => ({
...prev,
selectedBranch: branch.value,
worktreeName: slugifyWorktreeName(branch.label),
}));
setValidation(prev => ({ ...prev, touched: true }));
setExistingBranchPickerOpen(false);
}}
className={cn(
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
existingBranchState.selectedBranch === branch.value
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'hover:bg-interactive-hover'
)}
>
<span className="typography-small break-all">{branch.label}</span>
</button>
))}
</div>
</div>
)}
{hasExistingBranchQuery && !hasExistingBranchMatches && (
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
No matching branches
</div>
)}
{existingBranchRankedGroups.otherLocal.length > 0 && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
{hasExistingBranchQuery ? 'Other local branches' : 'Local branches'}
</div>
<div className="space-y-1">
{existingBranchRankedGroups.otherLocal.map((branch) => (
<button
key={branch}
onClick={() => {
@@ -1097,13 +1269,14 @@ Nice-to-have:
</div>
</div>
)}
{remoteBranches.length > 0 && (
{existingBranchRankedGroups.otherRemote.length > 0 && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
Remote branches
{hasExistingBranchQuery ? 'Other remote branches' : 'Remote branches'}
</div>
<div className="space-y-1">
{remoteBranches.map(branch => (
{existingBranchRankedGroups.otherRemote.map((branch) => (
<button
key={`remotes/${branch}`}
onClick={() => {
@@ -1128,7 +1301,7 @@ Nice-to-have:
</div>
</div>
)}
</>
</div>
)}
</div>
</MobileOverlayPanel>
@@ -1274,7 +1447,13 @@ Nice-to-have:
title="Select Source Branch"
onClose={() => setSourceBranchPickerOpen(false)}
>
<div className="space-y-4">
<div className="space-y-4" ref={sourceBranchMobileListWrapperRef}>
<Input
value={sourceBranchQuery}
onChange={(e) => setSourceBranchQuery(e.target.value)}
placeholder="Search branches..."
className="h-8"
/>
{isLoadingBranches ? (
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
Loading branches...
@@ -1284,14 +1463,47 @@ Nice-to-have:
No branches found
</div>
) : (
<>
{localBranches.length > 0 && (
<div className="space-y-4">
{hasSourceBranchQuery && hasSourceBranchMatches && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
Local branches
Matching branches
</div>
<div className="space-y-1">
{localBranches.map(branch => (
{sourceBranchRankedGroups.matching.map((branch) => (
<button
key={`${branch.source}-${branch.value}`}
onClick={() => {
setNewBranchState(prev => ({ ...prev, sourceBranch: branch.value }));
setSourceBranchPickerOpen(false);
}}
className={cn(
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
newBranchState.sourceBranch === branch.value
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'hover:bg-interactive-hover'
)}
>
<span className="typography-small break-all">{branch.label}</span>
</button>
))}
</div>
</div>
)}
{hasSourceBranchQuery && !hasSourceBranchMatches && (
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
No matching branches
</div>
)}
{sourceBranchRankedGroups.otherLocal.length > 0 && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
{hasSourceBranchQuery ? 'Other local branches' : 'Local branches'}
</div>
<div className="space-y-1">
{sourceBranchRankedGroups.otherLocal.map((branch) => (
<button
key={branch}
onClick={() => {
@@ -1311,13 +1523,14 @@ Nice-to-have:
</div>
</div>
)}
{remoteBranches.length > 0 && (
{sourceBranchRankedGroups.otherRemote.length > 0 && (
<div className="space-y-2">
<div className="typography-small font-semibold text-foreground px-2">
Remote branches
{hasSourceBranchQuery ? 'Other remote branches' : 'Remote branches'}
</div>
<div className="space-y-1">
{remoteBranches.map(branch => (
{sourceBranchRankedGroups.otherRemote.map((branch) => (
<button
key={`remotes/${branch}`}
onClick={() => {
@@ -1337,7 +1550,7 @@ Nice-to-have:
</div>
</div>
)}
</>
</div>
)}
</div>
</MobileOverlayPanel>
@@ -1435,59 +1648,129 @@ Nice-to-have:
<label className="typography-ui-label text-foreground block font-semibold">
Select Branch
</label>
<Select
value={existingBranchState.selectedBranch}
onValueChange={(value) => {
setExistingBranchState(prev => ({
...prev,
selectedBranch: value,
worktreeName: slugifyWorktreeName(value),
}));
setValidation(prev => ({ ...prev, touched: true }));
}}
>
<SelectTrigger size="lg" className="w-fit">
<SelectValue placeholder="Choose a branch..." />
</SelectTrigger>
<SelectContent className="max-h-[280px] max-w-[320px]">
{isLoadingBranches ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
Loading branches...
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
No branches found
</div>
) : (
<>
{localBranches.length > 0 && (
<SelectGroup>
<SelectLabel className="typography-small font-semibold text-foreground">Local branches</SelectLabel>
{localBranches.map(branch => (
<SelectItem key={branch} value={branch} className="whitespace-normal break-all">
{branch}
</SelectItem>
))}
</SelectGroup>
)}
{localBranches.length > 0 && remoteBranches.length > 0 && (
<SelectSeparator />
)}
{remoteBranches.length > 0 && (
<SelectGroup>
<SelectLabel className="typography-small font-semibold text-foreground">Remote branches</SelectLabel>
{remoteBranches.map(branch => (
<SelectItem key={`remotes/${branch}`} value={`remotes/${branch}`} className="whitespace-normal break-all">
{branch}
</SelectItem>
))}
</SelectGroup>
)}
</>
)}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<DropdownMenu open={existingBranchDropdownOpen} onOpenChange={setExistingBranchDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 min-w-[220px] max-w-full justify-between gap-2">
<span className={cn('truncate', existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground')}>
{existingBranchState.selectedBranch || 'Choose a branch...'}
</span>
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[320px] p-0" ref={existingBranchDropdownContentRef}>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search branches..."
value={existingBranchQuery}
onValueChange={setExistingBranchQuery}
/>
<CommandList disableHorizontal>
{isLoadingBranches ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
Loading branches...
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<CommandEmpty>No branches found</CommandEmpty>
) : (
<>
{hasExistingBranchQuery && hasExistingBranchMatches && (
<CommandGroup heading="Matching branches">
{existingBranchRankedGroups.matching.map((branch) => (
<CommandItem
key={`${branch.source}-${branch.value}`}
value={branch.value}
onSelect={() => {
setExistingBranchState((prev) => ({
...prev,
selectedBranch: branch.value,
worktreeName: slugifyWorktreeName(branch.label),
}));
setValidation((prev) => ({ ...prev, touched: true }));
setExistingBranchDropdownOpen(false);
}}
>
<span className="typography-small break-all">{branch.label}</span>
</CommandItem>
))}
</CommandGroup>
)}
{hasExistingBranchQuery && !hasExistingBranchMatches && (
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
No matching branches
</div>
)}
{existingBranchRankedGroups.otherLocal.length > 0 && (
<>
{hasExistingBranchQuery && <CommandSeparator />}
<CommandGroup heading={hasExistingBranchQuery ? 'Other local branches' : 'Local branches'}>
{existingBranchRankedGroups.otherLocal.map((branch) => (
<CommandItem
key={`local-${branch}`}
value={branch}
onSelect={() => {
setExistingBranchState((prev) => ({
...prev,
selectedBranch: branch,
worktreeName: slugifyWorktreeName(branch),
}));
setValidation((prev) => ({ ...prev, touched: true }));
setExistingBranchDropdownOpen(false);
}}
>
<span className="typography-small break-all">{branch}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{existingBranchRankedGroups.otherRemote.length > 0 && (
<>
{(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && (
<CommandSeparator />
)}
<CommandGroup heading={hasExistingBranchQuery ? 'Other remote branches' : 'Remote branches'}>
{existingBranchRankedGroups.otherRemote.map((branch) => (
<CommandItem
key={`remote-${branch}`}
value={`remotes/${branch}`}
onSelect={() => {
setExistingBranchState((prev) => ({
...prev,
selectedBranch: `remotes/${branch}`,
worktreeName: slugifyWorktreeName(branch),
}));
setValidation((prev) => ({ ...prev, touched: true }));
setExistingBranchDropdownOpen(false);
}}
>
<span className="typography-small break-all">{branch}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
</>
)}
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 px-0 shrink-0"
onClick={handleFetchBranches}
disabled={!canFetchBranches || isLoadingBranches}
title="Fetch branches"
>
{isLoadingBranches ? <RiLoader4Line className="size-4 animate-spin" /> : <RiRefreshLine className="size-4" />}
</Button>
</div>
</div>
) : (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
@@ -1606,51 +1889,101 @@ Nice-to-have:
<label className="typography-ui-label text-foreground block font-semibold">
Source Branch
</label>
<Select
value={newBranchState.sourceBranch}
onValueChange={(value) => setNewBranchState(prev => ({ ...prev, sourceBranch: value }))}
>
<SelectTrigger size="lg" className="w-fit">
<SelectValue placeholder="Select source branch..." />
</SelectTrigger>
<SelectContent className="max-h-[280px] max-w-[320px]">
{isLoadingBranches ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
Loading branches...
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
No branches found
</div>
) : (
<>
{localBranches.length > 0 && (
<SelectGroup>
<SelectLabel className="typography-small font-semibold text-foreground">Local branches</SelectLabel>
{localBranches.map(branch => (
<SelectItem key={branch} value={branch} className="whitespace-normal break-all">
{branch}
</SelectItem>
))}
</SelectGroup>
<DropdownMenu open={sourceBranchDropdownOpen} onOpenChange={setSourceBranchDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 min-w-[220px] max-w-full justify-between gap-2">
<span className={cn('truncate', newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground')}>
{newBranchState.sourceBranch || 'Select source branch...'}
</span>
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[320px] p-0" ref={sourceBranchDropdownContentRef}>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search branches..."
value={sourceBranchQuery}
onValueChange={setSourceBranchQuery}
/>
<CommandList disableHorizontal>
{isLoadingBranches ? (
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
Loading branches...
</div>
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
<CommandEmpty>No branches found</CommandEmpty>
) : (
<>
{hasSourceBranchQuery && hasSourceBranchMatches && (
<CommandGroup heading="Matching branches">
{sourceBranchRankedGroups.matching.map((branch) => (
<CommandItem
key={`${branch.source}-${branch.value}`}
value={branch.value}
onSelect={() => {
setNewBranchState((prev) => ({ ...prev, sourceBranch: branch.value }));
setSourceBranchDropdownOpen(false);
}}
>
<span className="typography-small break-all">{branch.label}</span>
</CommandItem>
))}
</CommandGroup>
)}
{hasSourceBranchQuery && !hasSourceBranchMatches && (
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
No matching branches
</div>
)}
{sourceBranchRankedGroups.otherLocal.length > 0 && (
<>
{hasSourceBranchQuery && <CommandSeparator />}
<CommandGroup heading={hasSourceBranchQuery ? 'Other local branches' : 'Local branches'}>
{sourceBranchRankedGroups.otherLocal.map((branch) => (
<CommandItem
key={`local-${branch}`}
value={branch}
onSelect={() => {
setNewBranchState((prev) => ({ ...prev, sourceBranch: branch }));
setSourceBranchDropdownOpen(false);
}}
>
<span className="typography-small break-all">{branch}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{sourceBranchRankedGroups.otherRemote.length > 0 && (
<>
{(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && (
<CommandSeparator />
)}
<CommandGroup heading={hasSourceBranchQuery ? 'Other remote branches' : 'Remote branches'}>
{sourceBranchRankedGroups.otherRemote.map((branch) => (
<CommandItem
key={`remote-${branch}`}
value={`remotes/${branch}`}
onSelect={() => {
setNewBranchState((prev) => ({ ...prev, sourceBranch: `remotes/${branch}` }));
setSourceBranchDropdownOpen(false);
}}
>
<span className="typography-small break-all">{branch}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
</>
)}
{localBranches.length > 0 && remoteBranches.length > 0 && (
<SelectSeparator />
)}
{remoteBranches.length > 0 && (
<SelectGroup>
<SelectLabel className="typography-small font-semibold text-foreground">Remote branches</SelectLabel>
{remoteBranches.map(branch => (
<SelectItem key={`remotes/${branch}`} value={`remotes/${branch}`} className="whitespace-normal break-all">
{branch}
</SelectItem>
))}
</SelectGroup>
)}
</>
)}
</SelectContent>
</Select>
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
{newBranchState.sourceBranch && (
<div className="typography-micro text-muted-foreground">
New branch will be created from {newBranchState.sourceBranch}
@@ -19,7 +19,8 @@ import {
type ProjectRef,
} from '@/lib/openchamberConfig';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import { cn } from '@/lib/utils';
@@ -52,9 +53,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
const [sendingTodoId, setSendingTodoId] = React.useState<string | null>(null);
const [expandedTodoIds, setExpandedTodoIds] = React.useState<Set<string>>(() => new Set());
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
@@ -18,7 +18,8 @@ import type { Session } from '@opencode-ai/sdk/v2';
import type { WorktreeMetadata } from '@/types/worktree';
import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus';
import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import * as sessionActions from '@/sync/session-actions';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
@@ -59,17 +60,14 @@ export const SessionDialogs: React.FC = () => {
const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false);
const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState<Set<string>>(new Set());
const {
deleteSession,
deleteSessions,
archiveSession,
archiveSessions,
loadSessions,
getWorktreeMetadata,
newSessionDraft,
setNewSessionDraftTarget,
setDraftBootstrapPendingDirectory,
} = useSessionStore();
const getWorktreeMetadata = useSessionUIStore((s) => s.getWorktreeMetadata);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory);
const deleteSession = sessionActions.deleteSession;
const archiveSession = sessionActions.archiveSession;
const deleteSessions = useSessionUIStore((s) => s.deleteSessions);
const archiveSessions = useSessionUIStore((s) => s.archiveSessions);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
@@ -113,24 +111,7 @@ export const SessionDialogs: React.FC = () => {
isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches;
const deleteLocalOptionDisabled = isProcessingDelete || !isWorktreeDelete;
React.useEffect(() => {
loadSessions();
}, [loadSessions, currentDirectory]);
const projectsKey = React.useMemo(
() => projects.map((project) => `${project.id}:${project.path}`).join('|'),
[projects],
);
const lastProjectsKeyRef = React.useRef(projectsKey);
React.useEffect(() => {
if (projectsKey === lastProjectsKeyRef.current) {
return;
}
lastProjectsKeyRef.current = projectsKey;
loadSessions();
}, [loadSessions, projectsKey]);
// Session loading is handled by sync bootstrap — no manual loadSessions needed.
React.useEffect(() => {
if (hasShownInitialDirectoryPrompt || !isHomeReady || projects.length > 0) {
@@ -444,7 +425,6 @@ export const SessionDialogs: React.FC = () => {
description: renderToastDescription(archiveNote),
});
closeDeleteDialog();
loadSessions();
return;
}
@@ -497,10 +477,8 @@ export const SessionDialogs: React.FC = () => {
if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) {
// Remove selected worktree even if per-session metadata is missing.
// Use same projectRef logic as the no-sessions path.
const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
if (removed) {
await loadSessions();
}
await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
// sync handles session refresh automatically
}
if (deletedIds.length > 0) {
@@ -537,10 +515,8 @@ export const SessionDialogs: React.FC = () => {
}
if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) {
const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
if (removed) {
await loadSessions();
}
await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
// sync bootstrap refreshes sessions automatically
}
closeDeleteDialog();
@@ -560,7 +536,6 @@ export const SessionDialogs: React.FC = () => {
isWorktreeDelete,
canRemoveRemoteBranches,
removeSelectedWorktree,
loadSessions,
]);
const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null;
@@ -7,8 +7,12 @@ import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { sessionEvents } from '@/lib/sessionEvents';
import { formatDirectoryName, cn } from '@/lib/utils';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useViewportStore } from '@/sync/viewport-store';
import { useSessions, useDirectorySync, useAllSessionStatuses } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSync } from '@/sync/use-sync';
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import type { GitHubPullRequestStatus } from '@/lib/api/types';
@@ -26,7 +30,6 @@ import { useProjectSessionSelection } from './sidebar/hooks/useProjectSessionSel
import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering';
import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping';
import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects';
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
import { useDirectoryStatusProbe } from './sidebar/hooks/useDirectoryStatusProbe';
import { useSessionActions } from './sidebar/hooks/useSessionActions';
import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence';
@@ -44,6 +47,9 @@ import { SidebarFooter } from './sidebar/SidebarFooter';
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
import { SessionNodeItem } from './sidebar/SessionNodeItem';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import { checkIsGitRepository } from '@/lib/gitApi';
import type { WorktreeMetadata } from '@/types/worktree';
import type { SortableDragHandleProps } from './sidebar/sortableItems';
import {
FolderDeleteConfirmDialog,
@@ -64,6 +70,7 @@ import {
formatProjectLabel,
normalizePath,
} from './sidebar/utils';
import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
@@ -299,27 +306,93 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const gitDirectories = useGitStore((state) => state.directories);
const sessions = useSessionStore((state) => state.sessions);
const archivedSessions = useSessionStore((state) => state.archivedSessions);
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const loadMessages = useSessionStore((state) => state.loadMessages);
const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle);
const shareSession = useSessionStore((state) => state.shareSession);
const unshareSession = useSessionStore((state) => state.unshareSession);
const sessionMemoryState = useSessionStore((state) => state.sessionMemoryState);
const sessionStatus = useSessionStore((state) => state.sessionStatus);
const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates);
const permissions = useSessionStore((state) => state.permissions);
const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata);
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const sync = useSync();
const syncSessions = useSessions();
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
const sessionsByDirectory = useGlobalSessionsStore((state) => state.sessionsByDirectory);
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
const shareSession = useSessionUIStore((state) => state.shareSession);
const unshareSession = useSessionUIStore((state) => state.unshareSession);
const sessionMemoryState = useViewportStore((state) => state.sessionMemoryState);
const globalSessionStatuses = useAllSessionStatuses();
// sessionAttentionStates removed — now using notification-store directly in SessionNodeItem
const permissionsRecord = useDirectorySync((state) => state.permission);
const sessionStatus = React.useMemo(
() => new Map(Object.entries(globalSessionStatuses)),
[globalSessionStatuses],
);
const permissions = React.useMemo(
() => new Map(Object.entries(permissionsRecord)),
[permissionsRecord],
);
const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const prStatusEntries = useGitHubPrStatusStore((state) => state.entries);
const updateStore = useUpdateStore();
const sessions = React.useMemo(
() => (hasLoadedGlobalSessions ? globalActiveSessions : syncSessions),
[globalActiveSessions, hasLoadedGlobalSessions, syncSessions],
);
const syncSessionSignature = React.useMemo(
() => syncSessions
.map((session) => `${session.id}:${session.time?.updated ?? session.time?.created ?? 0}:${session.time?.archived ? 1 : 0}`)
.join('|'),
[syncSessions],
);
React.useEffect(() => {
let cancelled = false;
const discoverWorktrees = async () => {
const projectEntries = useProjectsStore.getState().projects;
if (projectEntries.length === 0) return;
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
const allWorktrees: WorktreeMetadata[] = [];
await Promise.all(
projectEntries.map(async (project) => {
const projectPath = normalizePath(project.path);
if (!projectPath) return;
try {
const isGitRepo = await checkIsGitRepository(projectPath);
if (!isGitRepo) return;
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
if (cancelled || worktrees.length === 0) return;
worktreesByProject.set(projectPath, worktrees);
allWorktrees.push(...worktrees);
} catch {
// ignore discovery errors
}
}),
);
if (cancelled) return;
useSessionUIStore.setState({
availableWorktrees: allWorktrees,
availableWorktreesByProject: worktreesByProject,
});
};
void refreshGlobalSessions(syncSessions);
void discoverWorktrees();
return () => {
cancelled = true;
};
}, [currentDirectory, syncSessionSignature, syncSessions]);
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false);
@@ -614,10 +687,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
updateStore.available &&
(updateStore.runtimeType === 'desktop' || updateStore.runtimeType === 'web');
const deleteSession = useSessionStore((state) => state.deleteSession);
const deleteSessions = useSessionStore((state) => state.deleteSessions);
const archiveSession = useSessionStore((state) => state.archiveSession);
const archiveSessions = useSessionStore((state) => state.archiveSessions);
const deleteSession = useSessionUIStore((state) => state.deleteSession);
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
const archiveSession = useSessionUIStore((state) => state.archiveSession);
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
const {
copiedSessionId,
@@ -820,7 +893,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setProjectRootBranches,
});
const isSessionsLoading = useSessionStore((state) => state.isLoading);
const isSessionsLoading = useSessionUIStore((state) => state.isLoading);
useSessionFolderCleanup({
isSessionsLoading,
sessions,
@@ -970,6 +1043,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
branchLabel?: string | null;
} | null;
}>();
const projectPathLengthBySessionId = new Map<string, number>();
projectSections.forEach((section) => {
const projectLabel = formatProjectLabel(
@@ -984,12 +1058,19 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const visit = (nodes: SessionNode[]) => {
nodes.forEach((node) => {
const nextProjectPathLength = section.project.normalizedPath.length;
const currentProjectPathLength = projectPathLengthBySessionId.get(node.session.id) ?? -1;
if (nextProjectPathLength < currentProjectPathLength) {
return;
}
meta.set(node.session.id, {
node,
projectId: section.project.id,
groupDirectory: group.directory,
secondaryMeta,
});
projectPathLengthBySessionId.set(node.session.id, nextProjectPathLength);
if (node.children.length > 0) {
visit(node.children);
}
@@ -1008,12 +1089,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
[activeNowEntries, sessions],
);
useSessionPrefetch({
currentSessionId,
sortedSessions,
recentSessionIds: activeNowSessions.map((session) => session.id),
loadMessages,
});
// Prefetch is wired below, after recentSessionIds is computed.
const activitySections = React.useMemo(() => {
const toItem = (session: Session) => {
@@ -1036,6 +1112,15 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return new Set(activitySections.flatMap((section) => section.items.map((item) => item.node.session.id)));
}, [activitySections]);
const recentSessionIdsList = React.useMemo(() => [...recentSessionIds], [recentSessionIds]);
useSessionPrefetch({
currentSessionId,
sortedSessions,
recentSessionIds: recentSessionIdsList,
loadMessages: sync.syncSession,
});
const sectionsForSidebarRender = React.useMemo(() => {
if (!isVSCode || hasSessionSearchQuery || recentSessionIds.size === 0) {
return sectionsForRender;
@@ -1105,7 +1190,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
expandedParents={expandedParents}
hasSessionSearchQuery={hasSessionSearchQuery}
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
sessionAttentionStates={sessionAttentionStates as Map<string, { needsAttention?: boolean }>}
notifyOnSubtasks={notifyOnSubtasks}
sessionStatus={sessionStatus as Map<string, { type?: string }> | undefined}
permissions={permissions as Map<string, unknown[]>}
@@ -1147,7 +1231,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
expandedParents,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
sessionAttentionStates,
notifyOnSubtasks,
sessionStatus,
permissions,
@@ -38,6 +38,7 @@ import { DraggableSessionRow } from './sessionFolderDnd';
import type { SessionNode, SessionSummaryMeta } from './types';
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { useSessionUnseenCount } from '@/sync/notification-store';
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
@@ -65,7 +66,6 @@ type Props = {
expandedParents: Set<string>;
hasSessionSearchQuery: boolean;
normalizedSessionSearchQuery: string;
sessionAttentionStates: Map<string, { needsAttention?: boolean }>;
notifyOnSubtasks: boolean;
sessionStatus?: Map<string, { type?: string }>;
permissions: Map<string, unknown[]>;
@@ -113,7 +113,6 @@ export function SessionNodeItem(props: Props): React.ReactNode {
expandedParents,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
sessionAttentionStates,
notifyOnSubtasks,
sessionStatus,
permissions,
@@ -177,8 +176,8 @@ export function SessionNodeItem(props: Props): React.ReactNode {
const isPinnedSession = pinnedSessionIds.has(session.id);
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id);
const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID);
const rawNeedsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true;
const needsAttention = rawNeedsAttention && (!isSubtaskSession || notifyOnSubtasks);
const unseenCount = useSessionUnseenCount(session.id);
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
const sessionSummary = session.summary as SessionSummaryMeta | undefined;
const sessionDiffStats = resolveSessionDiffStats(sessionSummary);
const sessionTimestamp = session.time?.updated || session.time?.created || Date.now();
@@ -1,25 +1,73 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { opencodeClient } from '@/lib/opencode/client';
import { mapWithConcurrency } from '@/lib/concurrency';
import { normalizePath } from '../utils';
type ProjectLike = { path: string };
type DirectoryStatusValue = 'unknown' | 'exists' | 'missing';
type Args = {
sortedSessions: Session[];
projects: ProjectLike[];
directoryStatus: Map<string, 'unknown' | 'exists' | 'missing'>;
setDirectoryStatus: React.Dispatch<React.SetStateAction<Map<string, 'unknown' | 'exists' | 'missing'>>>;
directoryStatus: Map<string, DirectoryStatusValue>;
setDirectoryStatus: React.Dispatch<React.SetStateAction<Map<string, DirectoryStatusValue>>>;
};
const PROBE_CONCURRENCY = 3;
const MISSING_CACHE_KEY = 'oc.directoryProbe.missing';
// Re-probe missing directories periodically in case they're recreated
const MISSING_REPROBE_MS = 10 * 60 * 1000; // 10 minutes
type MissingCache = Record<string, number>; // directory -> timestamp
function loadMissingCache(): MissingCache {
try {
const raw = localStorage.getItem(MISSING_CACHE_KEY);
if (!raw) return {};
return JSON.parse(raw) as MissingCache;
} catch {
return {};
}
}
function saveMissingCache(cache: MissingCache): void {
try {
localStorage.setItem(MISSING_CACHE_KEY, JSON.stringify(cache));
} catch {
// ignore quota errors
}
}
async function probeDirectory(directory: string): Promise<DirectoryStatusValue> {
try {
await opencodeClient.listLocalDirectory(directory);
return 'exists';
} catch {
const looksLikeSdkWorktree =
directory.includes('/opencode/worktree/') ||
directory.includes('/.opencode/data/worktree/') ||
directory.includes('/.local/share/opencode/worktree/');
if (looksLikeSdkWorktree) {
const ok = await opencodeClient.probeDirectory(directory).catch(() => false);
if (ok) return 'exists';
}
return 'missing';
}
}
export const useDirectoryStatusProbe = ({
sortedSessions,
projects,
directoryStatus,
setDirectoryStatus,
}: Args): void => {
const directoryStatusRef = React.useRef<Map<string, 'unknown' | 'exists' | 'missing'>>(new Map());
const checkingDirectories = React.useRef<Set<string>>(new Set());
const directoryStatusRef = React.useRef<Map<string, DirectoryStatusValue>>(new Map());
const probeInFlightRef = React.useRef(false);
const missingCacheRef = React.useRef<MissingCache>(loadMissingCache());
React.useEffect(() => {
directoryStatusRef.current = directoryStatus;
@@ -29,68 +77,83 @@ export const useDirectoryStatusProbe = ({
const directories = new Set<string>();
sortedSessions.forEach((session) => {
const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
if (dir) {
directories.add(dir);
}
if (dir) directories.add(dir);
});
projects.forEach((project) => {
const normalized = normalizePath(project.path);
if (normalized) {
directories.add(normalized);
}
if (normalized) directories.add(normalized);
});
directories.forEach((directory) => {
const now = Date.now();
const missingCache = missingCacheRef.current;
const toProbe: string[] = [];
const preseeded = new Map<string, DirectoryStatusValue>();
for (const directory of directories) {
const known = directoryStatusRef.current.get(directory);
if ((known && known !== 'unknown') || checkingDirectories.current.has(directory)) {
return;
if (known && known !== 'unknown') continue;
// Use cached "missing" status if fresh enough — skip the HTTP probe
const cachedAt = missingCache[directory];
if (cachedAt && now - cachedAt < MISSING_REPROBE_MS) {
preseeded.set(directory, 'missing');
continue;
}
checkingDirectories.current.add(directory);
opencodeClient
.listLocalDirectory(directory)
.then(() => {
setDirectoryStatus((prev) => {
const next = new Map(prev);
if (next.get(directory) === 'exists') {
return prev;
}
next.set(directory, 'exists');
return next;
});
})
.catch(async () => {
const looksLikeSdkWorktree =
directory.includes('/opencode/worktree/') ||
directory.includes('/.opencode/data/worktree/') ||
directory.includes('/.local/share/opencode/worktree/');
if (looksLikeSdkWorktree) {
const ok = await opencodeClient.probeDirectory(directory).catch(() => false);
if (ok) {
setDirectoryStatus((prev) => {
const next = new Map(prev);
if (next.get(directory) === 'exists') {
return prev;
}
next.set(directory, 'exists');
return next;
});
return;
}
toProbe.push(directory);
}
// Apply preseeded missing statuses immediately (no HTTP call)
if (preseeded.size > 0) {
setDirectoryStatus((prev) => {
let changed = false;
const next = new Map(prev);
for (const [dir, status] of preseeded) {
if (next.get(dir) !== status) {
next.set(dir, status);
changed = true;
}
}
return changed ? next : prev;
});
}
setDirectoryStatus((prev) => {
const next = new Map(prev);
if (next.get(directory) === 'missing') {
return prev;
}
next.set(directory, 'missing');
return next;
});
})
.finally(() => {
checkingDirectories.current.delete(directory);
if (toProbe.length === 0 || probeInFlightRef.current) return;
probeInFlightRef.current = true;
let cancelled = false;
let cacheChanged = false;
void mapWithConcurrency(toProbe, PROBE_CONCURRENCY, async (directory) => {
const status = await probeDirectory(directory);
// Update missing cache
if (status === 'missing') {
missingCache[directory] = Date.now();
cacheChanged = true;
} else if (missingCache[directory]) {
delete missingCache[directory];
cacheChanged = true;
}
if (!cancelled) {
setDirectoryStatus((prev) => {
if (prev.get(directory) === status) return prev;
const next = new Map(prev);
next.set(directory, status);
return next;
});
}
return { directory, status };
}).finally(() => {
probeInFlightRef.current = false;
if (cacheChanged) {
saveMissingCache(missingCache);
}
});
return () => {
cancelled = true;
};
}, [sortedSessions, projects, setDirectoryStatus]);
};
@@ -1,5 +1,6 @@
import React from 'react';
import { checkIsGitRepository } from '@/lib/gitApi';
import { mapWithConcurrency } from '@/lib/concurrency';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
type Project = { id: string; path: string; normalizedPath: string };
@@ -39,26 +40,25 @@ export const useProjectRepoStatus = (args: Args): void => {
};
}
normalized.forEach((project) => {
checkIsGitRepository(project.path)
.then((result) => {
if (!cancelled) {
setProjectRepoStatus((prev) => {
const next = new Map(prev);
next.set(project.id, result);
return next;
});
}
})
.catch(() => {
if (!cancelled) {
setProjectRepoStatus((prev) => {
const next = new Map(prev);
next.set(project.id, null);
return next;
});
}
});
void mapWithConcurrency(normalized, 2, async (project) => {
try {
const result = await checkIsGitRepository(project.path);
if (!cancelled) {
setProjectRepoStatus((prev) => {
const next = new Map(prev);
next.set(project.id, result);
return next;
});
}
} catch {
if (!cancelled) {
setProjectRepoStatus((prev) => {
const next = new Map(prev);
next.set(project.id, null);
return next;
});
}
}
});
return () => {
@@ -78,12 +78,10 @@ export const useProjectRepoStatus = (args: Args): void => {
React.useEffect(() => {
let cancelled = false;
const run = async () => {
const entries = await Promise.all(
normalizedProjects.map(async (project) => {
const branch = await getRootBranch(project.normalizedPath).catch(() => null);
return { id: project.id, branch };
}),
);
const entries = await mapWithConcurrency(normalizedProjects, 2, async (project) => {
const branch = await getRootBranch(project.normalizedPath).catch(() => null);
return { id: project.id, branch };
});
if (cancelled) {
return;
}
@@ -1,8 +1,10 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getSyncMessages } from '@/sync/sync-refs';
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
const SESSION_PREFETCH_SETTLE_MS = 600;
const SESSION_PREFETCH_CONCURRENCY = 1;
const SESSION_PREFETCH_PENDING_LIMIT = 6;
@@ -10,7 +12,7 @@ type Args = {
currentSessionId: string | null;
sortedSessions: Session[];
recentSessionIds?: string[];
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
loadMessages: (sessionId: string) => Promise<unknown>;
};
export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], loadMessages }: Args): void => {
@@ -29,15 +31,14 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
break;
}
const state = useSessionStore.getState();
const state = useSessionUIStore.getState();
if (state.currentSessionId === nextSessionId) {
continue;
}
const hasMessages = state.messages.has(nextSessionId);
const historyMeta = state.sessionHistoryMeta.get(nextSessionId);
const isHydrated = hasMessages && typeof historyMeta?.complete === 'boolean';
if (isHydrated) {
// Check if messages already loaded in sync child store
const hasMessages = getSyncMessages(nextSessionId).length > 0;
if (hasMessages) {
continue;
}
@@ -56,11 +57,9 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
return;
}
const state = useSessionStore.getState();
const hasMessages = state.messages.has(sessionId);
const historyMeta = state.sessionHistoryMeta.get(sessionId);
const isHydrated = hasMessages && typeof historyMeta?.complete === 'boolean';
if (isHydrated) {
// Already loaded in sync
const hasMessages = getSyncMessages(sessionId).length > 0;
if (hasMessages) {
return;
}
@@ -89,30 +88,32 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSes
sessionPrefetchTimersRef.current.set(sessionId, timer);
}, [currentSessionId, pumpSessionPrefetchQueue]);
// Wait for the active session to finish loading before prefetching neighbors.
// On rapid session switches the timer resets, so only the final session triggers prefetch.
React.useEffect(() => {
if (!currentSessionId || sortedSessions.length === 0) {
return;
}
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
if (currentIndex < 0) {
return;
}
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id);
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
const timer = window.setTimeout(() => {
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
if (currentIndex < 0) return;
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id);
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
}, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer);
}, [currentSessionId, scheduleSessionPrefetch, sortedSessions]);
React.useEffect(() => {
if (!currentSessionId || recentSessionIds.length === 0) {
return;
}
const currentIndex = recentSessionIds.indexOf(currentSessionId);
if (currentIndex < 0) {
return;
}
scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]);
scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]);
const timer = window.setTimeout(() => {
const currentIndex = recentSessionIds.indexOf(currentSessionId);
if (currentIndex < 0) return;
scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]);
scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]);
}, SESSION_PREFETCH_SETTLE_MS);
return () => window.clearTimeout(timer);
}, [currentSessionId, recentSessionIds, scheduleSessionPrefetch]);
React.useEffect(() => {
@@ -10,43 +10,38 @@ import {
CommandShortcut,
} from '@/components/ui/command';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useDeviceInfo } from '@/lib/device';
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine } from '@remixicon/react';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { SETTINGS_PAGE_METADATA, SETTINGS_GROUP_LABELS, type SettingsRuntimeContext } from '@/lib/settings/metadata';
export const CommandPalette: React.FC = () => {
const {
isCommandPaletteOpen,
setCommandPaletteOpen,
setHelpDialogOpen,
setActiveMainTab,
setSettingsDialogOpen,
setSettingsPage,
setSessionSwitcherOpen,
setTimelineDialogOpen,
toggleSidebar,
toggleRightSidebar,
setRightSidebarOpen,
setRightSidebarTab,
toggleBottomTerminal,
setBottomTerminalExpanded,
isBottomTerminalExpanded,
shortcutOverrides,
} = useUIStore();
const isCommandPaletteOpen = useUIStore((s) => s.isCommandPaletteOpen);
const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen);
const setHelpDialogOpen = useUIStore((s) => s.setHelpDialogOpen);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setSettingsPage = useUIStore((s) => s.setSettingsPage);
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar);
const setRightSidebarOpen = useUIStore((s) => s.setRightSidebarOpen);
const setRightSidebarTab = useUIStore((s) => s.setRightSidebarTab);
const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal);
const setBottomTerminalExpanded = useUIStore((s) => s.setBottomTerminalExpanded);
const isBottomTerminalExpanded = useUIStore((s) => s.isBottomTerminalExpanded);
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
const {
openNewSessionDraft,
setCurrentSession,
getSessionsByDirectory,
} = useSessionStore();
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
const getSessionsByDirectory = useSessionUIStore((s) => s.getSessionsByDirectory);
const { currentDirectory } = useDirectoryStore();
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const { themeMode, setThemeMode } = useThemeSystem();
const handleClose = () => {
@@ -167,11 +162,6 @@ export const CommandPalette: React.FC = () => {
handleClose();
};
const handleOpenTimeline = () => {
setTimelineDialogOpen(true);
handleClose();
};
const directorySessions = getSessionsByDirectory(currentDirectory ?? '');
const currentSessions = React.useMemo(() => {
return directorySessions.slice(0, 5);
@@ -252,11 +242,6 @@ export const CommandPalette: React.FC = () => {
<span>Open Git Panel</span>
<CommandShortcut>{shortcut('open_git_panel')}</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenTimeline}>
<RiTimeLine className="mr-2 h-4 w-4" />
<span>Open Timeline</span>
<CommandShortcut>{shortcut('open_timeline')}</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenSettings}>
<RiSettings3Line className="mr-2 h-4 w-4" />
<span>Open Settings</span>
@@ -186,12 +186,6 @@ export const HelpDialog: React.FC = () => {
description: "Switch Project",
icon: RiLayoutLeftLine,
},
{
id: 'open_timeline',
description: "Open Timeline",
icon: RiTimeLine,
keys: '',
},
{
id: 'toggle_services_menu',
description: 'Toggle Services Menu',
+332 -118
View File
@@ -1,37 +1,162 @@
import React from 'react';
import { useSessionStore, MEMORY_LIMITS } from '@/stores/useSessionStore';
import { RiBarChartBoxLine, RiCloseLine, RiDatabase2Line, RiFileCopyLine, RiPulseLine, RiRefreshLine } from '@remixicon/react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useViewportStore } from '@/sync/viewport-store';
import { useSessions, useDirectorySync } from '@/sync/sync-context';
import { MEMORY_LIMITS } from '@/stores/types/sessionTypes';
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { getMessageLimit, getBackgroundTrimLimit } from '@/stores/types/sessionTypes';
import { getBackgroundTrimLimit } from '@/stores/types/sessionTypes';
import { getStreamPerfSnapshot, getVsCodeStreamPerfSnapshot, resetStreamPerf, type StreamPerfSnapshot } from '@/stores/utils/streamDebug';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { RiCloseLine, RiDatabase2Line, RiPulseLine } from '@remixicon/react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
interface MemoryDebugPanelProps {
interface DebugPanelProps {
onClose?: () => void;
}
export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) => {
const {
sessions,
messages,
sessionMemoryState,
currentSessionId,
} = useSessionStore();
type DebugTab = 'memory' | 'streaming';
const formatDuration = (durationMs: number): string => {
if (durationMs < 1000) {
return `${Math.round(durationMs)}ms`;
}
const seconds = durationMs / 1000;
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainderSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainderSeconds}s`;
};
const MetricCard: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => {
return (
<div
className="rounded-md p-2"
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-muted) 55%, transparent)' }}
>
<div className="typography-meta text-[var(--surface-muted-foreground)]">{label}</div>
<div className="typography-markdown font-semibold text-[var(--surface-foreground)]">{value}</div>
</div>
);
};
const PerfSection: React.FC<{ title: string; snapshot: StreamPerfSnapshot; emptyLabel: string }> = ({ title, snapshot, emptyLabel }) => {
const topEntries = snapshot.entries.slice(0, 12);
const totalSamples = snapshot.entries.reduce((sum, entry) => sum + entry.count, 0);
return (
<div className="space-y-2 border-t border-[var(--interactive-border)] pt-2 first:border-t-0 first:pt-0">
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label font-semibold text-[var(--surface-foreground)]">{title}</div>
<div className="typography-meta text-[var(--surface-muted-foreground)]">
{snapshot.startedAt ? formatDuration(snapshot.durationMs) : 'idle'}
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<MetricCard label="Metrics" value={snapshot.entries.length} />
<MetricCard label="Samples" value={totalSamples} />
<MetricCard label="Last Update" value={snapshot.lastUpdatedAt ? 'live' : 'n/a'} />
</div>
{topEntries.length === 0 ? (
<div
className="rounded-md p-3 typography-meta text-[var(--surface-muted-foreground)]"
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-muted) 45%, transparent)' }}
>
{emptyLabel}
</div>
) : (
<ScrollableOverlay outerClassName="max-h-64" className="pr-1">
<div className="space-y-1">
{topEntries.map((entry) => (
<div
key={entry.metric}
className="rounded-md border border-[var(--interactive-border)] p-2"
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-elevated) 88%, transparent)' }}
>
<div className="typography-meta font-medium text-[var(--surface-foreground)] break-all">{entry.metric}</div>
<div className="mt-1 grid grid-cols-4 gap-2 typography-meta text-[var(--surface-muted-foreground)]">
<span>count {entry.count}</span>
<span>avg {entry.avg}</span>
<span>max {entry.max}</span>
<span>total {entry.total}</span>
</div>
</div>
))}
</div>
</ScrollableOverlay>
)}
</div>
);
};
export const DebugPanel: React.FC<DebugPanelProps> = ({ onClose }) => {
const [activeTab, setActiveTab] = React.useState<DebugTab>('memory');
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle');
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessionMemoryState = useViewportStore((state) => state.sessionMemoryState);
const sessions = useSessions();
const messageRecord = useDirectorySync((state) => state.message);
const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount);
const [streamSnapshot, setStreamSnapshot] = React.useState<StreamPerfSnapshot>(() => getStreamPerfSnapshot());
const [vscodeStreamSnapshot, setVsCodeStreamSnapshot] = React.useState<StreamPerfSnapshot>(() => getVsCodeStreamPerfSnapshot());
const streamMetricCounts = React.useMemo(() => {
const counts = new Map<string, number>();
streamSnapshot.entries.forEach((entry) => {
counts.set(entry.metric, entry.count);
});
return {
messageListRender: counts.get('ui.message_list.render') ?? 0,
messageListRenderStreaming: counts.get('ui.message_list.render.streaming') ?? 0,
chatMessageRender: counts.get('ui.chat_message.render') ?? 0,
chatMessageRenderStreaming: counts.get('ui.chat_message.render.streaming') ?? 0,
chatMessageRenderStaticDuringStream: counts.get('ui.chat_message.render.static_during_stream') ?? 0,
chatMessageRenderStaticOutsideActiveTurnDuringStream:
counts.get('ui.chat_message.render.static_outside_active_turn_during_stream') ?? 0,
};
}, [streamSnapshot.entries]);
React.useEffect(() => {
const refresh = () => {
setStreamSnapshot(getStreamPerfSnapshot());
setVsCodeStreamSnapshot(getVsCodeStreamPerfSnapshot());
};
refresh();
const intervalId = window.setInterval(refresh, 500);
return () => window.clearInterval(intervalId);
}, []);
React.useEffect(() => {
if (copyState === 'idle') {
return;
}
const timeoutId = window.setTimeout(() => {
setCopyState('idle');
}, 1500);
return () => window.clearTimeout(timeoutId);
}, [copyState]);
const totalMessages = React.useMemo(() => {
let total = 0;
messages.forEach((sessionMessages) => {
total += sessionMessages.length;
});
for (const sessionId of Object.keys(messageRecord)) {
total += messageRecord[sessionId]?.length ?? 0;
}
return total;
}, [messages]);
}, [messageRecord]);
const sessionStats = React.useMemo(() => {
return sessions.map(session => {
const messageCount = messages.get(session.id)?.length || 0;
const messageCount = messageRecord[session.id]?.length || 0;
const memoryState = sessionMemoryState.get(session.id);
return {
id: session.id,
@@ -44,120 +169,209 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
isCurrent: session.id === currentSessionId
};
}).sort((a, b) => b.lastAccessed - a.lastAccessed);
}, [sessions, messages, sessionMemoryState, currentSessionId]);
}, [sessions, messageRecord, sessionMemoryState, currentSessionId]);
const cachedSessionCount = messages.size;
const cachedSessionCount = Object.keys(messageRecord).length;
const handleCopyStreamingDebug = React.useCallback(async () => {
try {
const payload = {
generatedAt: new Date().toISOString(),
ui: getStreamPerfSnapshot(),
vscode: getVsCodeStreamPerfSnapshot(),
};
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
setCopyState('copied');
} catch {
setCopyState('error');
}
}, []);
return (
<Card className="fixed bottom-4 right-4 w-96 p-4 shadow-none z-50 bg-background/95 bottom-safe-area">
<div className="flex items-center justify-between mb-3">
<Card
className="fixed bottom-4 right-4 z-50 w-[28rem] p-4 shadow-none bottom-safe-area"
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-background) 94%, transparent)' }}
>
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<RiDatabase2Line className="h-4 w-4" />
<h3 className="font-semibold typography-ui-label">Memory Debug Panel</h3>
{activeTab === 'memory' ? (
<RiDatabase2Line className="h-4 w-4 text-[var(--surface-foreground)]" />
) : (
<RiBarChartBoxLine className="h-4 w-4 text-[var(--surface-foreground)]" />
)}
<h3 className="typography-ui-label font-semibold text-[var(--surface-foreground)]">Debug Panel</h3>
</div>
{onClose && (
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
onClick={onClose}
>
<RiCloseLine className="h-4 w-4" />
</Button>
)}
</div>
<div className="space-y-3">
{}
<div className="grid grid-cols-2 gap-2 typography-meta">
<div className="bg-muted/50 rounded p-2">
<div className="text-muted-foreground">Total Messages</div>
<div className="typography-markdown font-semibold">{totalMessages}</div>
</div>
<div className="bg-muted/50 rounded p-2">
<div className="text-muted-foreground">Cached Sessions</div>
<div className="typography-markdown font-semibold">{cachedSessionCount} / {MEMORY_LIMITS.MAX_SESSIONS}</div>
</div>
</div>
{null}
{}
<div className="typography-meta space-y-1 border-t pt-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Viewport Window:</span>
<span>{getBackgroundTrimLimit()} messages</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Zombie Timeout:</span>
<span>{MEMORY_LIMITS.ZOMBIE_TIMEOUT / 1000 / 60} minutes</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">GitHub Total Requests:</span>
<span>{totalGitHubRequests}</span>
</div>
</div>
{}
<div className="border-t pt-2">
<div className="typography-meta font-semibold mb-1">Sessions in Memory:</div>
<ScrollableOverlay outerClassName="max-h-48" className="space-y-1 pr-1">
{sessionStats.map(stat => (
<div
key={stat.id}
className={`typography-meta p-1.5 rounded flex items-center justify-between ${
stat.isCurrent ? 'bg-primary/10' : 'bg-muted/30'
}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate">{stat.title}</span>
{stat.isStreaming && (
<RiPulseLine className="h-3 w-3 text-primary animate-pulse" />
)}
{stat.isZombie && (
<span className="text-status-warning">!</span>
)}
</div>
<div className="flex items-center gap-2">
<span className={`font-mono ${
stat.messageCount > getMessageLimit() ? 'text-status-warning' : ''
}`}>
{stat.messageCount} msgs
</span>
{stat.backgroundCount > 0 && (
<span className="text-primary">+{stat.backgroundCount}</span>
)}
</div>
</div>
))}
</ScrollableOverlay>
</div>
<div className="flex gap-2 pt-2 border-t">
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
{activeTab === 'streaming' ? (
<>
<Button size="xs" variant="ghost" onClick={handleCopyStreamingDebug}>
<RiFileCopyLine className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="outline"
className="typography-meta"
size="xs"
variant="ghost"
onClick={() => {
console.log('[MemoryDebug] Session store state:', {
sessions: sessions.map(s => ({ id: s.id, title: s.title })),
currentSessionId,
cachedSessions: Array.from(messages.keys()),
memoryStates: Object.fromEntries(sessionMemoryState),
});
resetStreamPerf();
setStreamSnapshot(getStreamPerfSnapshot());
setVsCodeStreamSnapshot(getVsCodeStreamPerfSnapshot());
}}
>
Log State
<RiRefreshLine className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Log current memory state to browser console
</TooltipContent>
</Tooltip>
</>
) : null}
{onClose ? (
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={onClose}>
<RiCloseLine className="h-4 w-4" />
</Button>
) : null}
</div>
</div>
<div
className="mb-3 flex gap-1 rounded-md p-1"
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-muted) 55%, transparent)' }}
>
<Button
size="sm"
variant={activeTab === 'memory' ? 'secondary' : 'ghost'}
className="flex-1"
onClick={() => setActiveTab('memory')}
>
Memory
</Button>
<Button
size="sm"
variant={activeTab === 'streaming' ? 'secondary' : 'ghost'}
className="flex-1"
onClick={() => setActiveTab('streaming')}
>
Streaming
</Button>
</div>
{activeTab === 'memory' ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 typography-meta">
<MetricCard label="Total Messages" value={totalMessages} />
<MetricCard label="Cached Sessions" value={`${cachedSessionCount} / ${MEMORY_LIMITS.MAX_SESSIONS}`} />
</div>
<div className="typography-meta space-y-1 border-t border-[var(--interactive-border)] pt-2">
<div className="flex justify-between gap-2">
<span className="text-[var(--surface-muted-foreground)]">Viewport Window</span>
<span className="text-[var(--surface-foreground)]">{getBackgroundTrimLimit()} messages</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-[var(--surface-muted-foreground)]">Zombie Timeout</span>
<span className="text-[var(--surface-foreground)]">{MEMORY_LIMITS.ZOMBIE_TIMEOUT / 1000 / 60} minutes</span>
</div>
<div className="flex justify-between gap-2">
<span className="text-[var(--surface-muted-foreground)]">GitHub Total Requests</span>
<span className="text-[var(--surface-foreground)]">{totalGitHubRequests}</span>
</div>
</div>
<div className="border-t border-[var(--interactive-border)] pt-2">
<div className="mb-1 typography-meta font-semibold text-[var(--surface-foreground)]">Sessions in Memory</div>
<ScrollableOverlay outerClassName="max-h-48" className="space-y-1 pr-1">
{sessionStats.map(stat => (
<div
key={stat.id}
className="typography-meta flex items-center justify-between rounded p-1.5"
style={{
backgroundColor: stat.isCurrent
? 'color-mix(in srgb, var(--interactive-selection) 22%, transparent)'
: 'color-mix(in srgb, var(--surface-muted) 35%, transparent)',
}}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-[var(--surface-foreground)]">{stat.title}</span>
{stat.isStreaming ? <RiPulseLine className="h-3 w-3 animate-pulse text-[var(--status-info)]" /> : null}
{stat.isZombie ? <span className="text-[var(--status-warning)]">!</span> : null}
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-[var(--surface-foreground)]">
{stat.messageCount} msgs
</span>
{stat.backgroundCount > 0 ? (
<span className="text-[var(--status-info)]">+{stat.backgroundCount}</span>
) : null}
</div>
</div>
))}
</ScrollableOverlay>
</div>
<div className="flex gap-2 border-t border-[var(--interactive-border)] pt-2">
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="typography-meta"
onClick={() => {
console.log('[DebugPanel] Session store state:', {
sessions: sessions.map(s => ({ id: s.id, title: s.title })),
currentSessionId,
cachedSessions: Object.keys(messageRecord),
memoryStates: Object.fromEntries(sessionMemoryState),
});
}}
>
Log State
</Button>
</TooltipTrigger>
<TooltipContent side="top">Log current memory state to browser console</TooltipContent>
</Tooltip>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2 rounded-md border border-[var(--interactive-border)] px-3 py-2 typography-meta text-[var(--surface-muted-foreground)]">
<span>
{copyState === 'copied'
? 'Streaming debug JSON copied'
: copyState === 'error'
? 'Failed to copy JSON'
: 'Copy exports both UI and VS Code streaming metrics as JSON'}
</span>
<Button size="xs" variant="outline" onClick={handleCopyStreamingDebug}>
Copy JSON
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<MetricCard label="UI Metrics" value={streamSnapshot.entries.length} />
<MetricCard label="VS Code Metrics" value={vscodeStreamSnapshot.entries.length} />
<MetricCard label="MsgList Renders" value={streamMetricCounts.messageListRender} />
<MetricCard label="MsgList Stream Renders" value={streamMetricCounts.messageListRenderStreaming} />
<MetricCard label="ChatMessage Renders" value={streamMetricCounts.chatMessageRender} />
<MetricCard label="ChatMessage Stream Renders" value={streamMetricCounts.chatMessageRenderStreaming} />
<MetricCard label="ChatMessage Static During Stream" value={streamMetricCounts.chatMessageRenderStaticDuringStream} />
<MetricCard
label="ChatMessage Static Outside Active Turn"
value={streamMetricCounts.chatMessageRenderStaticOutsideActiveTurnDuringStream}
/>
</div>
<PerfSection
title="UI Streaming Metrics"
snapshot={streamSnapshot}
emptyLabel="No UI streaming samples yet. Start a stream and keep this panel open."
/>
{vscodeStreamSnapshot.entries.length > 0 ? (
<PerfSection
title="VS Code Bridge Metrics"
snapshot={vscodeStreamSnapshot}
emptyLabel="No VS Code bridge samples yet."
/>
) : null}
</div>
)}
</Card>
);
};
export const MemoryDebugPanel = DebugPanel;
@@ -192,6 +192,23 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
role="img"
aria-label="OpenChamber logo"
>
<style>{`
@keyframes openchamber-logo-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.openchamber-logo-pulse {
animation: openchamber-logo-pulse 3s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.openchamber-logo-pulse {
animation: none;
}
}
`}</style>
{/* Left face - base fill */}
<path
d={`M${center.x} ${center.y} L${left.x} ${left.y} L${bottomLeft.x} ${bottomLeft.y} L${bottom.x} ${bottom.y} Z`}
@@ -240,17 +257,7 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
/>
{/* OpenCode logo on top face */}
<g opacity={isAnimated ? undefined : 1}>
{isAnimated && (
<animate
attributeName="opacity"
values="0.4;1;0.4"
dur="3s"
repeatCount="indefinite"
calcMode="spline"
keySplines="0.4 0 0.6 1; 0.4 0 0.6 1"
/>
)}
<g className={isAnimated ? 'openchamber-logo-pulse' : undefined} opacity={1}>
{/*
Isometric transform for top face:
OpenCode logo (32x40 viewBox) centered and projected to isometric plane
@@ -1,10 +1,10 @@
import React from 'react';
import { ChatContainer } from '@/components/chat/ChatContainer';
import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
export const ChatView: React.FC = () => {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
return (
<ChatErrorBoundary sessionId={currentSessionId || undefined}>
+25 -3
View File
@@ -31,6 +31,7 @@ import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib
// Minimum width for side-by-side diff view (px)
const SIDE_BY_SIDE_MIN_WIDTH = 1100;
const DIFF_REQUEST_TIMEOUT_MS = 15000;
const LARGE_DIFF_CHANGED_LINES = 500;
// Perf: limit concurrent expanded diffs in stacked view.
// Expanding many diffs mounts many Pierre instances + lots of DOM.
@@ -638,6 +639,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [forceRenderLarge, setForceRenderLarge] = React.useState(false);
const lastDiffRequestRef = React.useRef<string | null>(null);
const sectionRef = React.useRef<HTMLDivElement | null>(null);
@@ -881,7 +883,24 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
Loading diff
</div>
) : null}
{diffData ? (
{diffData && !forceRenderLarge && (file.insertions + file.deletions) > LARGE_DIFF_CHANGED_LINES ? (
<div className="flex flex-col items-center gap-2 px-4 py-8 text-sm text-muted-foreground">
<div className="typography-ui-label font-semibold text-foreground">
Large diff ({file.insertions + file.deletions} changed lines)
</div>
<div className="typography-meta text-muted-foreground">
Rendering may be slow. You can still view the diff by clicking below.
</div>
<button
type="button"
className="typography-ui-label text-primary hover:underline"
onClick={() => setForceRenderLarge(true)}
>
Render anyway
</button>
</div>
) : null}
{diffData && (forceRenderLarge || (file.insertions + file.deletions) <= LARGE_DIFF_CHANGED_LINES) ? (
<InlineDiffViewer
filePath={file.path}
diff={diffData}
@@ -917,7 +936,9 @@ export const DiffView: React.FC<DiffViewProps> = ({
const isGitRepo = useIsGitRepo(effectiveDirectory ?? null);
const status = useGitStatus(effectiveDirectory ?? null);
const isLoadingStatus = useGitStore((state) => state.isLoadingStatus);
const { setActiveDirectory, fetchStatus, setDiff } = useGitStore();
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const setDiff = useGitStore((state) => state.setDiff);
const [selectedFile, setSelectedFile] = React.useState<string | null>(null);
const [stackedExpandTarget, setStackedExpandTarget] = React.useState<string | null>(null);
@@ -1722,7 +1743,8 @@ export const useDiffFileCount = (): number => {
const { git } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
const { setActiveDirectory, fetchStatus } = useGitStore();
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fileCount = useGitFileCount(effectiveDirectory ?? null);
React.useEffect(() => {
+14 -18
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useConfigStore } from '@/stores/useConfigStore';
import { useFireworksCelebration } from '@/contexts/FireworksContext';
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
@@ -225,13 +225,11 @@ export const GitView: React.FC = () => {
const currentDirectory = useEffectiveDirectory();
const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null);
const [isWaitingForGitRefreshAfterBootstrap, setIsWaitingForGitRefreshAfterBootstrap] = React.useState(false);
const {
currentSessionId,
worktreeMetadata: worktreeMap,
availableWorktrees,
newSessionDraft,
setDraftBootstrapPendingDirectory,
} = useSessionStore();
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory);
const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata);
const availableWorktrees = useSessionUIStore((s) => s.availableWorktrees);
const normalizedCurrentDirectory = normalizePath(currentDirectory);
const inferredWorktreeMetadata = React.useMemo(() => {
if (!normalizedCurrentDirectory) {
@@ -276,16 +274,14 @@ export const GitView: React.FC = () => {
const currentIdentity = useGitIdentity(currentDirectory ?? null);
const isLoading = useGitStore((state) => state.isLoadingStatus);
const isLogLoading = useGitStore((state) => state.isLoadingLog);
const {
setActiveDirectory,
fetchAll,
fetchStatus,
fetchBranches,
fetchLog,
fetchIdentity,
prefetchDiffs,
setLogMaxCount,
} = useGitStore();
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const fetchAll = useGitStore((state) => state.fetchAll);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const fetchLog = useGitStore((state) => state.fetchLog);
const fetchIdentity = useGitStore((state) => state.fetchIdentity);
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
const setLogMaxCount = useGitStore((state) => state.setLogMaxCount);
const isMobile = useUIStore((state) => state.isMobile);
const openContextDiff = useUIStore((state) => state.openContextDiff);
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
@@ -29,6 +29,9 @@ import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils';
// Threshold (bytes) above which syntax highlighting is degraded for performance
const LARGE_CONTENT_BYTES = 500_000;
interface PierreDiffViewerProps {
original: string;
modified: string;
@@ -439,6 +442,11 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
}, [darkResolvedTheme, diffThemeKey, isDark, lightResolvedTheme]);
const isLargeContent = useMemo(() =>
Math.max(original.length, modified.length) > LARGE_CONTENT_BYTES,
[original.length, modified.length],
);
const options = useMemo(() => ({
theme: {
dark: darkTheme.metadata.id,
@@ -450,8 +458,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
hunkSeparators: 'line-info-basic' as const,
// Perf: disable intra-line diff (word-level) globally.
lineDiffType: 'none' as const,
maxLineDiffLength: 1000,
maxLineLengthForHighlighting: 1000,
// Perf: degrade tokenization/highlighting for large files (>500KB)
maxLineDiffLength: isLargeContent ? 0 : 1000,
maxLineLengthForHighlighting: isLargeContent ? 1 : 1000,
expansionLineCount: 20,
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
disableFileHeader: true,
@@ -460,7 +469,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
onLineSelected: handleSelectionChange,
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
renderAnnotation,
}), [darkTheme.metadata.id, isDark, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]);
}), [darkTheme.metadata.id, isDark, isLargeContent, lightTheme.metadata.id, renderSideBySide, wrapLines, handleSelectionChange, renderAnnotation]);
const lineAnnotations = useMemo(() => {
@@ -15,7 +15,8 @@ import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line } from '@remixicon/react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { EditorView } from '@codemirror/view';
@@ -80,8 +81,8 @@ type SelectedLineRange = {
};
export const PlanView: React.FC = () => {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const sessions = useSessions();
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const runtimeApis = useRuntimeAPIs();
useUIStore();
@@ -1,7 +1,7 @@
import React from 'react';
import { RiAddLine, RiArrowDownLine, RiArrowGoBackLine, RiArrowLeftLine, RiArrowRightLine, RiArrowUpLine, RiCloseLine, RiCommandLine } from '@remixicon/react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { type TerminalStreamEvent } from '@/lib/api/types';
@@ -93,7 +93,8 @@ export const TerminalView: React.FC = () => {
const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop);
const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop;
const { currentSessionId, newSessionDraft } = useSessionStore();
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
const hasActiveContext = currentSessionId !== null || newSessionDraft?.open === true;
const effectiveDirectory = useEffectiveDirectory() ?? null;
@@ -5,6 +5,7 @@ import {
RiCheckLine,
RiMore2Line,
RiFileCopyLine,
RiLoader4Line,
} from '@remixicon/react';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
@@ -12,7 +13,8 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import { cn } from '@/lib/utils';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useAgentGroupsStore, type AgentGroup, type AgentGroupSession } from '@/stores/useAgentGroupsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionStatus, useAllSessionStatuses } from '@/sync/sync-context';
import { ChatContainer } from '@/components/chat/ChatContainer';
import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
import {
@@ -35,53 +37,57 @@ interface AgentGroupDetailProps {
className?: string;
}
const SessionStatusDot: React.FC<{ sessionId: string }> = ({ sessionId }) => {
const status = useGlobalSessionStatus(sessionId);
if (!status || status.type === 'idle') return null;
return (
<span className="relative flex h-2 w-2 flex-shrink-0" title={status.type}>
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500" />
</span>
);
};
export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
group,
className,
}) => {
const { selectedSessionId, selectSession, deleteGroupWorktree, keepOnlyGroupWorktree } = useAgentGroupsStore();
const { setCurrentSession, currentSessionId } = useSessionStore();
const selectedSessionId = useAgentGroupsStore((s) => s.selectedSessionId);
const selectSession = useAgentGroupsStore((s) => s.selectSession);
const deleteGroupSessions = useAgentGroupsStore((s) => s.deleteGroupSessions);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const [worktreeDialog, setWorktreeDialog] = React.useState<null | { kind: 'remove' | 'keepOnly'; path: string; label: string }>(null);
const [isProcessing, setIsProcessing] = React.useState(false);
// Find the currently selected session
const selectedSession = React.useMemo(() => {
if (!selectedSessionId) return group.sessions[0] ?? null;
return group.sessions.find((s) => s.id === selectedSessionId) ?? group.sessions[0] ?? null;
}, [group.sessions, selectedSessionId]);
// When selecting a session, switch to that OpenCode session
// NOTE: We intentionally do NOT change the global directory here to avoid
// re-triggering loadGroups() which would cause groups to disappear
const handleSessionSelect = React.useCallback((session: AgentGroupSession) => {
selectSession(session.id);
// Switch to the OpenCode session
setCurrentSession(session.id);
setCurrentSession(session.id, session.path);
}, [selectSession, setCurrentSession]);
// Auto-select first session when group changes and sync OpenCode session
React.useEffect(() => {
if (group.sessions.length > 0) {
const session = selectedSessionId
const session = selectedSessionId
? group.sessions.find((s) => s.id === selectedSessionId) ?? group.sessions[0]
: group.sessions[0];
if (session) {
// Always ensure the OpenCode session is synced
if (session.id !== currentSessionId) {
setCurrentSession(session.id);
}
// Update selection if not already selected
if (!selectedSessionId) {
selectSession(session.id);
if (session) {
if (session.id !== currentSessionId) {
setCurrentSession(session.id, session.path);
}
if (!selectedSessionId) {
selectSession(session.id);
}
}
}
}, [group.name, group.sessions, selectedSessionId, currentSessionId, selectSession, setCurrentSession]);
// Check if the current OpenCode session matches the selected agent group session
const isSessionSynced = selectedSession?.id === currentSessionId;
const handleCopyWorktreePath = React.useCallback(() => {
@@ -98,12 +104,12 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
});
}, [selectedSession?.path]);
const handleRemoveSelectedWorktree = React.useCallback(async () => {
const handleRemoveSelectedWorktree = React.useCallback(() => {
if (!selectedSession) return;
setWorktreeDialog({ kind: 'remove', path: selectedSession.path, label: selectedSession.displayLabel });
}, [selectedSession]);
const handleKeepOnlySelectedWorktree = React.useCallback(async () => {
const handleKeepOnlySelectedWorktree = React.useCallback(() => {
if (!selectedSession) return;
setWorktreeDialog({ kind: 'keepOnly', path: selectedSession.path, label: selectedSession.displayLabel });
}, [selectedSession]);
@@ -112,32 +118,36 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
if (!worktreeDialog || isProcessing) return;
setIsProcessing(true);
try {
const normalize = (v: string) => v.replace(/\\/g, '/').replace(/\/+$/, '') || v;
const targetPath = normalize(worktreeDialog.path);
let sessionsToDelete: AgentGroupSession[];
if (worktreeDialog.kind === 'remove') {
toast.info('Removing worktree...');
const ok = await deleteGroupWorktree(group.name, worktreeDialog.path);
if (ok) {
toast.success('Worktree removed');
} else {
const error = useAgentGroupsStore.getState().error;
toast.error(error || 'Failed to remove worktree');
return;
}
sessionsToDelete = group.sessions.filter((s) => normalize(s.path) === targetPath);
} else {
toast.info('Removing other worktrees...');
const ok = await keepOnlyGroupWorktree(group.name, worktreeDialog.path);
if (ok) {
toast.success('Removed other worktrees');
} else {
const error = useAgentGroupsStore.getState().error;
toast.error(error || 'Failed to remove other worktrees');
return;
}
sessionsToDelete = group.sessions.filter((s) => normalize(s.path) !== targetPath);
}
const { failedIds, failedWorktreePaths } = await deleteGroupSessions(sessionsToDelete, { removeWorktrees: true });
if (failedIds.length > 0 || failedWorktreePaths.length > 0) {
toast.error('Failed to fully remove worktree');
} else {
toast.success(worktreeDialog.kind === 'remove' ? 'Worktree removed' : 'Removed other worktrees');
}
setWorktreeDialog(null);
} finally {
setIsProcessing(false);
}
}, [deleteGroupWorktree, group.name, isProcessing, keepOnlyGroupWorktree, worktreeDialog]);
}, [deleteGroupSessions, group.sessions, isProcessing, worktreeDialog]);
// Group-level status: show if any session is busy
const allStatuses = useAllSessionStatuses();
const groupBusy = React.useMemo(
() => group.sessions.some((s) => allStatuses[s.id]?.type === 'busy'),
[group.sessions, allStatuses],
);
return (
<div className={cn('flex h-full flex-col bg-background', className)}>
@@ -145,7 +155,10 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
<div className="flex-shrink-0 border-b border-border/30 px-4 py-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h1 className="typography-heading-lg text-foreground truncate">{group.name}</h1>
<div className="flex items-center gap-2">
<h1 className="typography-heading-lg text-foreground truncate">{group.name}</h1>
{groupBusy && <RiLoader4Line className="h-4 w-4 animate-spin text-amber-500 flex-shrink-0" />}
</div>
<div className="flex items-center gap-2 mt-1 typography-meta text-muted-foreground">
<span>{group.sessionCount} model{group.sessionCount !== 1 ? 's' : ''}</span>
<span>·</span>
@@ -156,7 +169,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
</div>
</div>
</div>
{/* Model Selector Dropdown */}
{group.sessions.length > 0 && (
<div className="mt-3 flex items-center gap-2">
@@ -170,9 +183,9 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
<div className="flex items-center gap-2 min-w-0">
{selectedSession && (
<>
<ProviderLogo
providerId={selectedSession.providerId}
className="h-5 w-5 flex-shrink-0"
<ProviderLogo
providerId={selectedSession.providerId}
className="h-5 w-5 flex-shrink-0"
/>
<span className="truncate typography-body">
{selectedSession.modelId}
@@ -182,6 +195,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
#{selectedSession.instanceNumber}
</span>
)}
<SessionStatusDot sessionId={selectedSession.id} />
</>
)}
</div>
@@ -195,9 +209,9 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
onClick={() => handleSessionSelect(session)}
className="flex items-center gap-2 py-2"
>
<ProviderLogo
providerId={session.providerId}
className="h-5 w-5 flex-shrink-0"
<ProviderLogo
providerId={session.providerId}
className="h-5 w-5 flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
@@ -209,6 +223,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
#{session.instanceNumber}
</span>
)}
<SessionStatusDot sessionId={session.id} />
</div>
{session.branch && (
<div className="flex items-center gap-1 typography-micro text-muted-foreground/60">
@@ -236,7 +251,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
void handleRemoveSelectedWorktree();
handleRemoveSelectedWorktree();
}}
variant="destructive"
>
@@ -245,7 +260,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
void handleKeepOnlySelectedWorktree();
handleKeepOnlySelectedWorktree();
}}
>
Leave this one, remove others
@@ -292,7 +307,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Chat Content */}
<div className="flex-1 min-h-0">
{selectedSession ? (
@@ -302,7 +317,6 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
</ChatErrorBoundary>
) : (
<div className="h-full flex flex-col">
{/* Info banner about the worktree */}
<div className="px-4 py-2 bg-muted/30 border-b border-border/30">
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
<ProviderLogo providerId={selectedSession.providerId} className="h-4 w-4" />
@@ -315,8 +329,6 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
</span>
</div>
</div>
{/* Loading or no session state */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8">
<p className="typography-body text-muted-foreground mb-2">
@@ -5,6 +5,7 @@ import {
RiMore2Line,
RiSearchLine,
RiGitBranchLine,
RiLoader4Line,
} from '@remixicon/react';
import { toast } from '@/components/ui';
import { Input } from '@/components/ui/input';
@@ -26,16 +27,16 @@ import {
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { useAgentGroupsStore, type AgentGroup } from '@/stores/useAgentGroupsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useAllSessionStatuses } from '@/sync/sync-context';
const formatRelativeTime = (timestamp: number): string => {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / (60 * 1000));
const hours = Math.floor(diff / (60 * 60 * 1000));
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
if (minutes < 1) return 'now';
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
@@ -45,30 +46,30 @@ const formatRelativeTime = (timestamp: number): string => {
interface AgentGroupItemProps {
group: AgentGroup;
isSelected: boolean;
isBusy: boolean;
onSelect: () => void;
}
const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSelect }) => {
const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBusy, onSelect }) => {
const [menuOpen, setMenuOpen] = React.useState(false);
const [confirmOpen, setConfirmOpen] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
const deleteGroup = useAgentGroupsStore((state) => state.deleteGroup);
const deleteGroupSessions = useAgentGroupsStore((s) => s.deleteGroupSessions);
const handleDeleteGroup = React.useCallback(async () => {
if (isDeleting) return;
setIsDeleting(true);
toast.info(`Deleting "${group.name}"...`);
const ok = await deleteGroup(group.name);
if (ok) {
const { failedIds, failedWorktreePaths } = await deleteGroupSessions(group.sessions, { removeWorktrees: true });
if (failedIds.length === 0 && failedWorktreePaths.length === 0) {
toast.success(`Deleted "${group.name}"`);
} else {
const error = useAgentGroupsStore.getState().error;
toast.error(error || `Failed to delete "${group.name}"`);
toast.error(`Failed to fully delete "${group.name}"`);
}
setIsDeleting(false);
setConfirmOpen(false);
}, [deleteGroup, group.name, isDeleting]);
}, [deleteGroupSessions, group.name, group.sessions, isDeleting]);
return (
<>
<div
@@ -83,9 +84,12 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSe
type="button"
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
<span className="truncate typography-ui-label font-normal text-foreground">
{group.name}
</span>
<div className="flex items-center gap-1.5">
<span className="truncate typography-ui-label font-normal text-foreground">
{group.name}
</span>
{isBusy && <RiLoader4Line className="h-3 w-3 animate-spin text-amber-500 flex-shrink-0" />}
</div>
<div className="flex items-center gap-2">
<span className="typography-micro text-muted-foreground/60 flex items-center gap-1">
<RiGitBranchLine className="h-3 w-3" />
@@ -96,7 +100,7 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSe
</span>
</div>
</button>
<div className="flex items-center gap-1.5 self-stretch">
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
@@ -154,6 +158,7 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, onSe
interface AgentManagerSidebarProps {
className?: string;
groups: AgentGroup[];
selectedGroupName?: string | null;
onGroupSelect?: (groupName: string) => void;
onNewAgent?: () => void;
@@ -161,36 +166,40 @@ interface AgentManagerSidebarProps {
export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
className,
groups,
selectedGroupName,
onGroupSelect,
onNewAgent,
}) => {
const [searchQuery, setSearchQuery] = React.useState('');
const [showAll, setShowAll] = React.useState(false);
const { groups, isLoading, loadGroups } = useAgentGroupsStore();
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
// Load groups when directory changes
React.useEffect(() => {
if (currentDirectory) {
loadGroups();
const isLoading = useAgentGroupsStore((s) => s.isLoading);
// Session statuses for busy indicators
const allStatuses = useAllSessionStatuses();
const busyGroups = React.useMemo(() => {
const set = new Set<string>();
for (const group of groups) {
if (group.sessions.some((s) => allStatuses[s.id]?.type === 'busy')) {
set.add(group.name);
}
}
}, [currentDirectory, loadGroups]);
return set;
}, [groups, allStatuses]);
const MAX_VISIBLE = 5;
const filteredGroups = React.useMemo(() => {
if (!searchQuery.trim()) return groups;
const query = searchQuery.toLowerCase();
return groups.filter(group =>
return groups.filter(group =>
group.name.toLowerCase().includes(query)
);
}, [searchQuery, groups]);
const visibleGroups = showAll ? filteredGroups : filteredGroups.slice(0, MAX_VISIBLE);
const remainingCount = filteredGroups.length - MAX_VISIBLE;
return (
<div className={cn('flex h-full flex-col text-foreground border-r border-border/30', className)}>
{/* Search Input */}
@@ -205,7 +214,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
/>
</div>
</div>
{/* New Agent Button */}
<div className="px-2.5 pb-2">
<Button
@@ -217,7 +226,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
<span className="typography-ui-label">New Agent Group</span>
</Button>
</div>
{/* Agent Groups Section Header */}
<div className="px-2.5 py-1.5 flex items-center gap-1">
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
@@ -230,7 +239,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
</span>
)}
</div>
{/* Group List */}
<ScrollableOverlay
outerClassName="flex-1 min-h-0"
@@ -241,11 +250,11 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
key={group.name}
group={group}
isSelected={selectedGroupName === group.name}
isBusy={busyGroups.has(group.name)}
onSelect={() => onGroupSelect?.(group.name)}
/>
))}
{/* Show More Link */}
{!showAll && remainingCount > 0 && (
<button
type="button"
@@ -255,8 +264,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
... More ({remainingCount})
</button>
)}
{/* Show Less Link */}
{showAll && filteredGroups.length > MAX_VISIBLE && (
<button
type="button"
@@ -266,8 +274,7 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
Show less
</button>
)}
{/* Empty State */}
{!isLoading && filteredGroups.length === 0 && (
<div className="py-4 text-center">
<p className="typography-meta text-muted-foreground">
@@ -6,10 +6,8 @@ import { AgentGroupDetail } from './AgentGroupDetail';
import { cn } from '@/lib/utils';
import { useAgentGroupsStore } from '@/stores/useAgentGroupsStore';
import { useMultiRunStore } from '@/stores/useMultiRunStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import type { CreateMultiRunParams } from '@/types/multirun';
interface AgentManagerViewProps {
@@ -30,37 +28,37 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
'connecting' | 'connected' | 'error' | 'disconnected' | undefined
: 'connecting') || 'connecting'
);
const configInitialized = useConfigStore((state) => state.isInitialized);
const initializeApp = useConfigStore((state) => state.initializeApp);
const loadSessions = useSessionStore((state) => state.loadSessions);
const setDirectory = useDirectoryStore((state) => state.setDirectory);
const configInitialized = useConfigStore((s) => s.isInitialized);
const initializeApp = useConfigStore((s) => s.initializeApp);
const setDirectory = useDirectoryStore((s) => s.setDirectory);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const bootstrapAttemptAt = React.useRef<number>(0);
const {
selectedGroupName,
selectGroup,
getSelectedGroup,
loadGroups,
} = useAgentGroupsStore();
const groups = useAgentGroupsStore((s) => s.groups);
const selectedGroupName = useAgentGroupsStore((s) => s.selectedGroupName);
const selectGroup = useAgentGroupsStore((s) => s.selectGroup);
const loadGroups = useAgentGroupsStore((s) => s.loadGroups);
const { createMultiRun, isLoading: isCreatingMultiRun } = useMultiRunStore();
const createMultiRun = useMultiRunStore((s) => s.createMultiRun);
const isCreatingMultiRun = useMultiRunStore((s) => s.isLoading);
const selectedGroup = React.useMemo(
() => (selectedGroupName ? groups.find((g) => g.name === selectedGroupName) ?? null : null),
[groups, selectedGroupName],
);
// VS Code connection bootstrap
React.useEffect(() => {
if (!isVSCodeRuntime) {
return;
}
if (!isVSCodeRuntime) return;
const current =
(typeof window !== 'undefined'
? (window as unknown as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status
: undefined) as 'connecting' | 'connected' | 'error' | 'disconnected' | undefined;
if (current === 'connected' || current === 'connecting' || current === 'error' || current === 'disconnected') {
setConnectionStatus(current);
}
if (current) setConnectionStatus(current);
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ status?: string; error?: string }>).detail;
const status = detail?.status;
const status = (event as CustomEvent<{ status?: string }>).detail?.status;
if (status === 'connected' || status === 'connecting' || status === 'error' || status === 'disconnected') {
setConnectionStatus(status);
}
@@ -70,14 +68,9 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
}, [isVSCodeRuntime]);
React.useEffect(() => {
if (!isVSCodeRuntime || connectionStatus !== 'connected') {
return;
}
if (!isVSCodeRuntime || connectionStatus !== 'connected') return;
const now = Date.now();
if (now - bootstrapAttemptAt.current < 750) {
return;
}
if (now - bootstrapAttemptAt.current < 750) return;
bootstrapAttemptAt.current = now;
const workspaceFolder = (typeof window !== 'undefined'
@@ -85,52 +78,22 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
: null);
if (typeof workspaceFolder === 'string' && workspaceFolder.trim().length > 0) {
try {
setDirectory(workspaceFolder, { showOverlay: false });
} catch {
// ignored
}
try { setDirectory(workspaceFolder, { showOverlay: false }); } catch { /* ignored */ }
}
const runBootstrap = async () => {
try {
if (!configInitialized) {
await initializeApp();
}
if (!configInitialized) void initializeApp();
}, [connectionStatus, configInitialized, initializeApp, isVSCodeRuntime, setDirectory]);
const configState = useConfigStore.getState();
if (
!configState.isInitialized ||
!configState.isConnected ||
configState.providers.length === 0 ||
configState.agents.length === 0
) {
return;
}
await loadSessions();
if (streamDebugEnabled()) {
console.log('[OpenChamber][VSCode][agentManager] bootstrap complete', {
providers: configState.providers.length,
agents: configState.agents.length,
sessions: useSessionStore.getState().sessions.length,
});
}
} catch {
// ignored
}
};
void runBootstrap();
}, [connectionStatus, configInitialized, initializeApp, isVSCodeRuntime, loadSessions, setDirectory]);
// Load groups on mount and when directory changes
React.useEffect(() => {
void loadGroups();
}, [currentDirectory, loadGroups]);
const handleGroupSelect = React.useCallback((groupName: string) => {
selectGroup(groupName);
}, [selectGroup]);
const handleNewAgent = React.useCallback(() => {
// Clear selection to show the empty state / new agent form
selectGroup(null);
}, [selectGroup]);
@@ -141,54 +104,30 @@ export const AgentManagerView: React.FC<AgentManagerViewProps> = ({ className })
if (result) {
toast.success(`Agent group "${params.name}" created with ${result.sessionIds.length} session(s)`);
const groupSlug = result.groupSlug;
const waitForGroup = async (attempts = 6) => {
for (let attempt = 0; attempt < attempts; attempt += 1) {
await loadGroups();
const groupsState = useAgentGroupsStore.getState();
if (groupsState.groups.some((group) => group.name === groupSlug)) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
return false;
};
// Refresh sessions + groups and wait briefly for OpenCode to surface the new worktree sessions.
try {
await useSessionStore.getState().loadSessions();
} catch {
// ignore
}
await waitForGroup();
selectGroup(groupSlug);
// Refresh groups — new worktrees + sessions now exist
await loadGroups();
selectGroup(result.groupSlug);
} else {
const error = useMultiRunStore.getState().error;
toast.error(error || 'Failed to create agent group');
}
}, [createMultiRun, loadGroups, selectGroup]);
const selectedGroup = getSelectedGroup();
return (
<div className={cn('flex h-full w-full bg-background', className)}>
{/* Left Sidebar - Agent Groups List */}
<div className="w-64 flex-shrink-0">
<AgentManagerSidebar
groups={groups}
selectedGroupName={selectedGroupName}
onGroupSelect={handleGroupSelect}
onNewAgent={handleNewAgent}
/>
</div>
{/* Main Content Area */}
<div className="flex-1 min-w-0">
{selectedGroup ? (
<AgentGroupDetail group={selectedGroup} />
) : (
<AgentManagerEmptyState
<AgentManagerEmptyState
onCreateGroup={handleCreateGroup}
isCreating={isCreatingMultiRun}
/>
@@ -9,7 +9,8 @@ import {
import { Button } from '@/components/ui/button';
import { RiAlertLine, RiLoader4Line, RiChat1Line, RiAddLine } from '@remixicon/react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
import { toast } from '@/components/ui';
import { getConflictDetails, type MergeConflictDetails } from '@/lib/gitApi';
@@ -33,10 +34,10 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
onAbort,
onClearState,
}) => {
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
const setPendingSyntheticParts = useSessionStore((state) => state.setPendingSyntheticParts);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const setPendingSyntheticParts = useInputStore((state) => state.setPendingSyntheticParts);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const [isLoading, setIsLoading] = React.useState(false);
@@ -15,7 +15,8 @@ import {
CommandList,
} from '@/components/ui/command';
import { toast } from '@/components/ui';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
import { execCommand } from '@/lib/execCommands';
import {
@@ -55,7 +56,7 @@ export const IntegrateCommitsSection: React.FC<{
refreshKey,
onRefresh,
}) => {
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
const searchInputRef = React.useRef<HTMLInputElement>(null);
@@ -167,7 +168,7 @@ export const IntegrateCommitsSection: React.FC<{
const persistTarget = React.useCallback(
(branch: string) => {
if (!currentSessionId) return;
useSessionStore.getState().setWorktreeMetadata(currentSessionId, {
useSessionUIStore.getState().setWorktreeMetadata(currentSessionId, {
...worktreeMetadata,
createdFromBranch: branch,
});
@@ -175,7 +176,7 @@ export const IntegrateCommitsSection: React.FC<{
[currentSessionId, worktreeMetadata]
);
const openNewSessionDraft = useSessionStore((s) => s.openNewSessionDraft);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const buildConflictContext = React.useCallback((payload: { state: IntegrateInProgress; details: IntegrateConflictDetails }) => {
const visibleText = `Resolve cherry-pick conflicts, stage the resolved files, and continue the cherry-pick. Keep intent of commit ${payload.state.currentCommit} onto branch ${payload.state.targetBranch}.`;
@@ -217,8 +218,8 @@ Important:
return { visibleText, instructionsText, payloadText };
}, []);
const setPendingInputText = useSessionStore((s) => s.setPendingInputText);
const setPendingSyntheticParts = useSessionStore((s) => s.setPendingSyntheticParts);
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
const setPendingSyntheticParts = useInputStore((s) => s.setPendingSyntheticParts);
const handleResolveWithAi = React.useCallback((
payload: { state: IntegrateInProgress; details: IntegrateConflictDetails },
@@ -51,8 +51,8 @@ import { useDeviceInfo } from '@/lib/device';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageStore } from '@/stores/messageStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { useConfigStore } from '@/stores/useConfigStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
@@ -284,7 +284,7 @@ export const PullRequestSection: React.FC<{
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const { isMobile, hasTouchInput } = useDeviceInfo();
const openGitHubSettings = React.useCallback(() => {
@@ -633,7 +633,7 @@ export const PullRequestSection: React.FC<{
}
const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState();
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
const providerID = currentProviderId || lastUsedProvider?.providerID;
const modelID = currentModelId || lastUsedProvider?.modelID;
if (!providerID || !modelID) {
@@ -656,14 +656,13 @@ export const PullRequestSection: React.FC<{
instructionsText: string,
payloadText: string,
) => {
void useMessageStore.getState().sendMessage(
void useSessionUIStore.getState().sendMessage(
visibleText,
target.providerID,
target.modelID,
target.currentAgentName ?? undefined,
target.sessionId,
undefined,
null,
undefined,
[
{ text: instructionsText, synthetic: true },
{ text: payloadText, synthetic: true },